Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit a39ab7add1b689bd21661054dba653888314a142
parent c9776bb5d55a59c8d8bedb5966aef61ea17007ea
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  1 Oct 2026 12:53:31 -0400

Merge r16/x-login (release 16 slice XL) — connecting an X account works: the Connect window is the operator's own browser without automation signals, and the fetchers can use the operator's browser login directly (social.x.cookieSource, gallery-dl's container semantics, a Check that says what gallery-dl will see); the refresh never replaces a logged-in jar with an empty one; reviewed SHIP

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
MENVIRONMENT.md | 1+
MSETTINGS.md | 25+++++++++++++++++++++++++
Mcommon/controller/fetchPosts.ts | 22++++++++++++++++++----
Mcommon/lib/envVars.ts | 1+
Mcommon/lib/settingsDocs.test.ts | 2+-
Mcommon/lib/settingsDocs.ts | 12++++++++++++
Mcommon/lib/settingsSchema.test.ts | 22+++++++++++++++++++++-
Mcommon/lib/settingsSchema.ts | 29+++++++++++++++++++++++++++++
Acommon/social/__fixtures__/firefoxCookieStore.ts | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/social/fetchers.ts | 15++++++++++++++-
Acommon/social/nodeSqlite.ts | 36++++++++++++++++++++++++++++++++++++
Mcommon/social/playwrightRuntime.ts | 3+++
Acommon/social/xBrowser.test.ts | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/social/xBrowser.ts | 242+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/social/xBrowserLogin.test.ts | 357+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/social/xBrowserLogin.ts | 561+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/social/xCookieSource.test.ts | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/social/xCookieSource.ts | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/social/xGalleryDlFetcher.test.ts | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/social/xGalleryDlFetcher.ts | 69++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mcommon/social/xPlaywrightFetcher.ts | 48++++++++++++++++++++++++++++++++++++++++--------
Acommon/social/xSessionBroker.test.ts | 157+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/social/xSessionBroker.ts | 207+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Meditor/CHANGELOG.md | 1+
Meditor/app/settings/components/XSessionSection.tsx | 174++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Meditor/app/settings/page.tsx | 11+++++++++--
Meditor/app/settings/xSessionActions.ts | 92++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Meditor/e2e/x-session.spec.ts | 68+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mplans/FACTS.md | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mplans/release-16.md | 256++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msettings.json.example | 3+++
31 files changed, 2991 insertions(+), 88 deletions(-)

diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md @@ -70,6 +70,7 @@ Tokens, credentials and knobs a running process reads. Most configuration is not | `OLLAMA_DIGEST_MODEL` | `qwen2.5:7b` | The ollama model the local digest lane asks for when settings name none. | common/lib/digestApps.ts | | `CLAUDE_DIGEST_MODEL` | the CLI's default | The model the metered digest lane asks `claude` for when settings name none. | common/lib/digestApps.ts | | `NITTER_INSTANCES` | a built-in list | Comma-separated Nitter instances for the X fallback fetcher, in order of preference. | common/social/xNitterFetcher.ts | +| `ARCHILYZER_X_BROWSER` | the first of `chromium`, `google-chrome`, `google-chrome-stable`, `chrome` on PATH, else Playwright's bundled Chromium | The Chromium-family browser /settings' "Connect X account" opens (a path, or a name looked up on PATH). It is launched without the automation signals, in the X session profile; a value that is not an executable refuses the connect rather than opening another browser. | common/social/xBrowser.ts | | `UMTOOL_URL` | unset (no link) | umtool's front door; when set, the video page links to it. | editor/app/channels/[slug]/videos/[id]/page.tsx | | `TRANSCRIPT_SITE_URL` | — | MCP server: one published archive to read over HTTP. | mcp/src/sources.ts | | `TRANSCRIPT_HUB_URL` | — | MCP server: a hub, federating every archive it lists. | mcp/src/sources.ts | diff --git a/SETTINGS.md b/SETTINGS.md @@ -19,6 +19,7 @@ A copied example PINS every default it spells — including each lane's `autoQue | [`workers`](#workers) | `[]` | | [`cookiesFromBrowser`](#cookiesfrombrowser) | `""` | | [`cookieMode`](#cookiemode) | `"when-required"` | +| [`social`](#social) | object — see below | | [`sleepBetweenDownloadsSeconds`](#sleepbetweendownloadsseconds) | `10` | | [`downloadFormat`](#downloadformat) | `"auto"` | | [`minFreeDiskGB`](#minfreediskgb) | `5` | @@ -157,6 +158,30 @@ How yt-dlp invocations use the configured cookies (see common/lib/cookiePolicy.t Default: `"when-required"` +## `social` + +Per-platform settings of the social-post fetchers. Today one key: where the X fetchers' login comes from (`social.x.cookieSource`, chosen in the X account session section of /settings). See common/social/xCookieSource.ts. + +#### `social` + +| Key | Default | Description | +|---|---|---| +| `x` | `{}` | X / Twitter. See `social.x` below. | + +#### `social.x` + +| Key | Default | Description | +|---|---|---| +| `cookieSource` | absent | Where the X fetchers' login comes from. `"browser"`: the operator's everyday browser, named by `cookiesFromBrowser` — gallery-dl is handed `--cookies-from-browser <spec>` and reads it on every run, and the Playwright fallback reads the same store (Firefox only; common/social/xBrowserLogin.ts), so the login lasts as long as the browser's. `"profile"`: the session broker's persistent profile ("Connect X account" on /settings) and the cookie jar it exports. ABSENT (the default) is resolved at read time, never stored: `"browser"` when `cookiesFromBrowser` is set and no profile is connected (no exported jar carrying an auth_token), else `"profile"`. The source, not `cookieMode`, governs the X fetchers. | + +Default: + +```json +{ + "x": {} +} +``` + ## `sleepBetweenDownloadsSeconds` Pause (seconds) inserted between per-video yt-dlp invocations in managed batch downloads. yt-dlp's own `-t sleep` only paces requests within one invocation, so without this the managed loop hammers the source IP back-to-back. 0 disables. Per-channel override available. diff --git a/common/controller/fetchPosts.ts b/common/controller/fetchPosts.ts @@ -41,13 +41,17 @@ import "../social/xGalleryDlFetcher"; // only ever used when a channel opts into it via postFetcher: "x-playwright". import "../social/xPlaywrightFetcher"; import "../social/xNitterFetcher"; +import { + resolveXCookieSourceFor, + type XLoginSettings, +} from "../social/xBrowserLogin"; export type FetchPostsOptions = { paths: Paths; slug: string; - // Global settings, for the cookie policy. Structural so settings.ts need not - // be imported here. - settings: CookiePolicyInputs; + // Global settings, for the cookie policy and the X login source. Structural + // so settings.ts need not be imported here. + settings: CookiePolicyInputs & XLoginSettings; // Ignore the stored watermark and re-walk the account's full history. New // posts are still deduped against the archive, so this is a safe repair // operation rather than a duplicate-maker. @@ -122,7 +126,15 @@ export async function fetchPosts( opts.full || resumeCursor ? undefined : ((await latestPostCreatedAt(channelRoot)) ?? undefined); - const cookies = alwaysCookies(resolveCookiePolicy(settings, config)); + const policy = resolveCookiePolicy(settings, config); + const cookies = alwaysCookies(policy); + // An X fetcher's login source (social.x.cookieSource, xCookieSource.ts), + // resolved against THIS channel's browser spec — its own cookiesFromBrowser + // over the global one, whatever the cookieMode. + const xLogin = + fetcher.platform === "twitter" + ? await resolveXCookieSourceFor(paths, settings, policy.cookies) + : undefined; log( `Fetching posts for ${slug} via ${fetcher.label} (@${handle})` + @@ -154,6 +166,8 @@ export async function fetchPosts( cursor: resumeCursor, seenIds, cookies, + cookieSource: xLogin?.source, + browserCookies: xLogin?.browserSpec, limit: opts.limit, signal: effectiveSignal, onLog: log, diff --git a/common/lib/envVars.ts b/common/lib/envVars.ts @@ -106,6 +106,7 @@ const DECLARED: EnvVarDecl[] = [ { name: "OLLAMA_DIGEST_MODEL", audience: "runtime", default: "`qwen2.5:7b`", readBy: "common/lib/digestApps.ts", doc: "The ollama model the local digest lane asks for when settings name none." }, { name: "CLAUDE_DIGEST_MODEL", audience: "runtime", default: "the CLI's default", readBy: "common/lib/digestApps.ts", doc: "The model the metered digest lane asks `claude` for when settings name none." }, { name: "NITTER_INSTANCES", audience: "runtime", default: "a built-in list", readBy: "common/social/xNitterFetcher.ts", doc: "Comma-separated Nitter instances for the X fallback fetcher, in order of preference." }, + { name: "ARCHILYZER_X_BROWSER", audience: "runtime", default: "the first of `chromium`, `google-chrome`, `google-chrome-stable`, `chrome` on PATH, else Playwright's bundled Chromium", readBy: "common/social/xBrowser.ts", doc: "The Chromium-family browser /settings' \"Connect X account\" opens (a path, or a name looked up on PATH). It is launched without the automation signals, in the X session profile; a value that is not an executable refuses the connect rather than opening another browser." }, { name: "UMTOOL_URL", audience: "runtime", default: "unset (no link)", readBy: "editor/app/channels/[slug]/videos/[id]/page.tsx", doc: "umtool's front door; when set, the video page links to it." }, { name: "TRANSCRIPT_SITE_URL", audience: "runtime", default: "—", readBy: "mcp/src/sources.ts", doc: "MCP server: one published archive to read over HTTP." }, { name: "TRANSCRIPT_HUB_URL", audience: "runtime", default: "—", readBy: "mcp/src/sources.ts", doc: "MCP server: a hub, federating every archive it lists." }, diff --git a/common/lib/settingsDocs.test.ts b/common/lib/settingsDocs.test.ts @@ -33,7 +33,7 @@ test("the example parses back to the defaults", async () => { assert.deepEqual(parsed, defaultSiteSettings()); }); -// EVERY FIELD, NOT ONLY THE 31 TOP-LEVEL ONES. The *_FIELD_DOCS records are +// EVERY FIELD, NOT ONLY THE 32 TOP-LEVEL ONES. The *_FIELD_DOCS records are // complete by type (FieldDocs<T> requires one entry per key); these two check // the wiring — that every object-valued block has a key table, and that a // block's table names every key its default actually carries. diff --git a/common/lib/settingsDocs.ts b/common/lib/settingsDocs.ts @@ -19,6 +19,8 @@ import { DIGEST_SETTINGS_FIELD_DOCS, SAVED_VIDEO_BACKUP_SETTINGS_FIELD_DOCS, SOCIAL_LINK_FIELD_DOCS, + SOCIAL_SETTINGS_FIELD_DOCS, + X_SOCIAL_SETTINGS_FIELD_DOCS, SYNC_SCHEDULER_SETTINGS_FIELD_DOCS, defaultSiteSettings, siteSettingsSchema, @@ -160,6 +162,16 @@ export function blockTables(d: SiteSettings): Partial<Record<keyof SiteSettings, docs: CHANNEL_AUTO_PAUSE_FIELD_DOCS, }, ], + social: [ + { path: "social", docs: SOCIAL_SETTINGS_FIELD_DOCS, defaults: fromObject(d.social) }, + { + path: "social.x", + docs: X_SOCIAL_SETTINGS_FIELD_DOCS, + // Absent from the default block: the source is resolved at read time + // and only a chosen one is written. + defaults: fromObject(d.social.x), + }, + ], socialLinks: [{ path: "socialLinks[]", docs: SOCIAL_LINK_FIELD_DOCS }], savedVideoBackup: [ { diff --git a/common/lib/settingsSchema.test.ts b/common/lib/settingsSchema.test.ts @@ -35,6 +35,7 @@ import type { ReportDebouncePreset, SavedVideoBackupSettings, SocialLink, + SocialSettings, SyncSchedulerSettings, } from "./settingsSchema"; @@ -62,6 +63,8 @@ type PreSchemaSiteSettings = { workers: Worker[]; cookiesFromBrowser: string; cookieMode: CookieMode; + // Release 16 slice XL — the one key it adds. + social: SocialSettings; sleepBetweenDownloadsSeconds: number; downloadFormat: DownloadFormatPreset; minFreeDiskGB: number; @@ -92,7 +95,7 @@ type PreSchemaSiteSettings = { type Same<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false; const shapeUnchanged: Same<SiteSettings, PreSchemaSiteSettings> = true; -test("SiteSettings keeps its 31 fields, in file order", () => { +test("SiteSettings keeps its 32 fields, in file order", () => { assert.equal(shapeUnchanged, true); assert.deepEqual(Object.keys(siteSettingsSchema.shape), [ "adminTitle", @@ -102,6 +105,7 @@ test("SiteSettings keeps its 31 fields, in file order", () => { "workers", "cookiesFromBrowser", "cookieMode", + "social", "sleepBetweenDownloadsSeconds", "downloadFormat", "minFreeDiskGB", @@ -154,6 +158,22 @@ test("defaults() and defaultSiteSettings() are the schema's answer for an empty assert.deepEqual(d.archiveStorage, { bucket: "", publicBaseUrl: "" }); assert.equal(d.skipLiveDownloads, true); assert.equal(d.inlineTranscribeOnFallback, false); + // The X login source is resolved at read time, so the default stores none. + assert.deepEqual(d.social, { x: {} }); +}); + +test("social.x.cookieSource keeps a known source and drops anything else", () => { + const parse = (social: unknown) => siteSettingsSchema.parse({ social }).social; + assert.deepEqual(parse({ x: { cookieSource: "browser" } }), { x: { cookieSource: "browser" } }); + assert.deepEqual(parse({ x: { cookieSource: "profile" } }), { x: { cookieSource: "profile" } }); + // An unknown source reads as absent — the read-time default — never a throw. + assert.deepEqual(parse({ x: { cookieSource: "chrome" } }), { x: {} }); + assert.deepEqual(parse({ x: { cookieSource: "browser", extra: 1 }, bsky: {} }), { + x: { cookieSource: "browser" }, + }); + for (const bad of [null, "browser", [], 3, { x: "browser" }, { x: [] }]) { + assert.deepEqual(parse(bad), { x: {} }, JSON.stringify(bad)); + } }); // ── THE CLAMP BOUNDARIES ───────────────────────────────────────────────────── diff --git a/common/lib/settingsSchema.ts b/common/lib/settingsSchema.ts @@ -89,8 +89,34 @@ import { type DigestTimestampMode, } from "./digest"; import type { FieldDocs } from "./fieldDocs"; +import { + sanitizeSocial, + type SocialSettings, + type XSocialSettings, +} from "../social/xCookieSource"; export type { Worker } from "./workers"; +export type { SocialSettings, XSocialSettings } from "../social/xCookieSource"; + +// The `social` block (release 16 slice XL). The types and the coercion live +// with the source they choose, in social/xCookieSource.ts (pure, client-safe); +// the documentation lives here with every other block's. +export const SOCIAL_SETTINGS_FIELD_DOCS: FieldDocs<SocialSettings> = { + x: "X / Twitter. See `social.x` below.", +}; + +export const X_SOCIAL_SETTINGS_FIELD_DOCS: FieldDocs<XSocialSettings> = { + cookieSource: + "Where the X fetchers' login comes from. `\"browser\"`: the operator's everyday browser, " + + "named by `cookiesFromBrowser` — gallery-dl is handed `--cookies-from-browser <spec>` and " + + "reads it on every run, and the Playwright fallback reads the same store (Firefox only; " + + "common/social/xBrowserLogin.ts), so the login lasts as long as the browser's. " + + "`\"profile\"`: the session broker's persistent profile (\"Connect X account\" on /settings) " + + "and the cookie jar it exports. ABSENT (the default) is resolved at read time, never " + + "stored: `\"browser\"` when `cookiesFromBrowser` is set and no profile is connected (no " + + "exported jar carrying an auth_token), else `\"profile\"`. The source, not `cookieMode`, " + + "governs the X fetchers.", +}; export type { AutoQueueSettings } from "./autoQueueTypes"; export type { ChannelPriority } from "./channelPriority"; @@ -1450,6 +1476,9 @@ export const siteSettingsSchema = z.object({ cookieMode: settingsField((v): CookieMode => (isCookieMode(v) ? v : DEFAULT_COOKIE_MODE)).describe( "How yt-dlp invocations use the configured cookies (see common/lib/cookiePolicy.ts): \"always\" passes them on every invocation, \"when-required\" (default; the historical behavior) only to retry an auth/age failure, \"defer\" never in normal runs — auth-gated videos are excluded from batches and collected into the per-channel \"Needs cookies\" bucket for a manual cookie run. Per-channel override available (ChannelConfig.cookieMode).", ), + social: settingsField((v): SocialSettings => sanitizeSocial(v)).describe( + "Per-platform settings of the social-post fetchers. Today one key: where the X fetchers' login comes from (`social.x.cookieSource`, chosen in the X account session section of /settings). See common/social/xCookieSource.ts.", + ), sleepBetweenDownloadsSeconds: settingsField((v): number => clampSleepBetweenDownloadsSeconds(v)).describe( "Pause (seconds) inserted between per-video yt-dlp invocations in managed batch downloads. yt-dlp's own `-t sleep` only paces requests within one invocation, so without this the managed loop hammers the source IP back-to-back. 0 disables. Per-channel override available.", ), diff --git a/common/social/__fixtures__/firefoxCookieStore.ts b/common/social/__fixtures__/firefoxCookieStore.ts @@ -0,0 +1,90 @@ +// A FIREFOX COOKIE STORE FOR TESTS — `cookies.sqlite` with Firefox's +// `moz_cookies` table, written through node:sqlite. Used by +// social/xBrowserLogin.test.ts and the editor's x-session e2e, so neither +// reads a real browser profile. Test-only: nothing outside a test imports it. + +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import { loadSqlite, type SqliteDb } from "../nodeSqlite"; + +export type FixtureCookie = { + host: string; + name: string; + value: string; + path?: string; + // Seconds since the epoch, as Firefox has stored it. + expiry?: number; + // PRTime: microseconds since the epoch. + lastAccessed?: number; + isSecure?: 0 | 1; + isHttpOnly?: 0 | 1; + sameSite?: number; + originAttributes?: string; +}; + +// Firefox's table as recent releases create it. +const SCHEMA = `CREATE TABLE moz_cookies ( + id INTEGER PRIMARY KEY, + originAttributes TEXT NOT NULL DEFAULT '', + name TEXT, + value TEXT, + host TEXT, + path TEXT, + expiry INTEGER, + lastAccessed INTEGER, + creationTime INTEGER, + isSecure INTEGER, + isHttpOnly INTEGER, + inBrowserElement INTEGER DEFAULT 0, + sameSite INTEGER DEFAULT 0, + schemeMap INTEGER DEFAULT 0, + isPartitionedAttributeSet INTEGER DEFAULT 0, + CONSTRAINT moz_uniqueid UNIQUE (name, host, path, originAttributes) +)`; + +export function insertFixtureCookies(db: SqliteDb, cookies: ReadonlyArray<FixtureCookie>): void { + const insert = db.prepare( + "INSERT INTO moz_cookies (originAttributes, name, value, host, path, expiry, " + + "lastAccessed, creationTime, isSecure, isHttpOnly, sameSite) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ); + const nowUs = Date.now() * 1000; + for (const c of cookies) { + insert.run( + c.originAttributes ?? "", + c.name, + c.value, + c.host, + c.path ?? "/", + c.expiry ?? Math.floor(Date.now() / 1000) + 86_400 * 365, + c.lastAccessed ?? nowUs, + c.lastAccessed ?? nowUs, + c.isSecure ?? 1, + c.isHttpOnly ?? 1, + c.sameSite ?? 0, + ); + } +} + +// Write `<profileDir>/cookies.sqlite` holding `cookies`. With `keepOpen`, the +// store is left open in WAL mode with the rows still in `cookies.sqlite-wal` +// — what a running Firefox looks like — and the caller closes it. +export async function writeFirefoxCookieStore( + profileDir: string, + cookies: ReadonlyArray<FixtureCookie>, + opts: { keepOpen?: boolean } = {}, +): Promise<{ file: string; db?: SqliteDb }> { + await mkdir(profileDir, { recursive: true }); + const file = path.join(profileDir, "cookies.sqlite"); + const { DatabaseSync } = await loadSqlite(); + const db = new DatabaseSync(file); + db.exec(SCHEMA); + if (opts.keepOpen) { + db.exec("PRAGMA journal_mode=WAL"); + db.exec("PRAGMA wal_autocheckpoint=0"); + } + insertFixtureCookies(db, cookies); + if (opts.keepOpen) return { file, db }; + db.close(); + return { file }; +} diff --git a/common/social/fetchers.ts b/common/social/fetchers.ts @@ -14,6 +14,7 @@ // in the client bundle. import type { Post, PostAvailability, PostPlatform } from "../lib/posts"; +import type { XCookieSource } from "./xCookieSource"; export type PostFetchInput = { // The account's canonical URL as configured on the channel. @@ -37,6 +38,17 @@ export type PostFetchInput = { // Resolved cookie spec from resolveCookiePolicy(), when the fetcher needs // credentials. Bluesky ignores it entirely. cookies?: string; + // WHERE AN X FETCHER'S LOGIN COMES FROM this run — `social.x.cookieSource`, + // resolved by the fetch controller (xCookieSource.ts): "browser" reads the + // operator's everyday browser named by `browserCookies`; "profile" uses the + // session broker's connected profile. Unset (a caller that does not resolve + // it): the profile's jar when it holds a login, else `cookies`, as before the + // choice existed. Platforms without a login ignore both. + cookieSource?: XCookieSource; + // The browser spec the "browser" source reads: the channel's + // cookiesFromBrowser over the global one, REGARDLESS of cookieMode — the + // source, not yt-dlp's mode, governs the X fetchers. + browserCookies?: string; // Soft cap on how many posts to return in one run. Undefined = no cap // beyond the watermark/seen-id stop conditions. limit?: number; @@ -73,7 +85,8 @@ export type SocialFetcherProbe = { // Which config fields this fetcher surfaces in the channel form. Mirrors // TranscriptionApp.fields. export type SocialFetcherFields = { - // Needs a browser cookie spec (cookiesFromBrowser / cookieMode). + // Needs a browser cookie spec (cookiesFromBrowser / cookieMode; for X, the + // login source `social.x.cookieSource`). cookies?: boolean; // Needs an external binary whose path is configurable. binPath?: boolean; diff --git a/common/social/nodeSqlite.ts b/common/social/nodeSqlite.ts @@ -0,0 +1,36 @@ +// node:sqlite, loaded at run time — for reading a browser's cookie store +// (xBrowserLogin.ts) and for the tests' fixture stores. +// +// The specifier is assembled so no bundler tries to resolve it (the same reason +// playwrightRuntime.ts assembles its own): `node:sqlite` exists only with the +// prefix, and Node 22.13+ loads it without a flag (with an ExperimentalWarning, +// once per process). Structural types, because the repo's @types/node predates +// the module. No imports, so a test harness can load it on its own. + +type SqliteStatement = { + all: (...params: unknown[]) => unknown[]; + run: (...params: unknown[]) => unknown; +}; + +export type SqliteDb = { + prepare: (sql: string) => SqliteStatement; + exec: (sql: string) => void; + close: () => void; +}; + +export type SqliteModule = { + DatabaseSync: new (file: string, opts?: Record<string, unknown>) => SqliteDb; +}; + +const SQLITE = ["node", "sqlite"].join(":"); + +export async function loadSqlite(): Promise<SqliteModule> { + const mod = (await import(/* webpackIgnore: true */ SQLITE)) as Partial<SqliteModule> & { + default?: SqliteModule; + }; + const m = mod.DatabaseSync ? (mod as SqliteModule) : mod.default; + if (!m?.DatabaseSync) { + throw new Error("node:sqlite is not available in this Node (22.13 or later loads it without a flag)."); + } + return m; +} diff --git a/common/social/playwrightRuntime.ts b/common/social/playwrightRuntime.ts @@ -47,6 +47,9 @@ export type BrowserContextLike = { newPage: () => Promise<PageLike>; pages: () => PageLike[]; cookies: () => Promise<unknown[]>; + // The X fallback fetcher's browser-login path hands the operator's browser + // cookies to a fresh context (xBrowserLogin.ts). + addCookies: (cookies: ReadonlyArray<Record<string, unknown>>) => Promise<void>; close: () => Promise<void>; on: (event: string, cb: () => void) => void; }; diff --git a/common/social/xBrowser.test.ts b/common/social/xBrowser.test.ts @@ -0,0 +1,162 @@ +// The Connect window's browser and its launch options (release 16 slice XL). +// Pure: nothing here launches a browser. +// +// Run with: pnpm --filter yt-dlp-transcript-common exec tsx --test social/xBrowser.test.ts + +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { + buildXBrowserLaunchOptions, + describeXBrowser, + findXBrowser, + readXBrowserRecord, + recordedXBrowser, + writeXBrowserRecord, + X_BROWSER_ENV, +} from "./xBrowser"; + +const TMP = mkdtempSync(path.join(os.tmpdir(), "xl-xbrowser-")); +after(() => rm(TMP, { recursive: true, force: true })); + +// A fake filesystem of executables. +const only = (...files: string[]) => (p: string) => files.includes(p); + +test("the system browser: the first of chromium, google-chrome, google-chrome-stable, chrome on PATH", () => { + const env = { PATH: "/opt/bin:/usr/bin" }; + assert.deepEqual( + findXBrowser({ env, isExecutable: only("/usr/bin/google-chrome-stable", "/usr/bin/chromium") }), + { kind: "system", executablePath: "/usr/bin/chromium", from: "path" }, + ); + assert.deepEqual( + findXBrowser({ env, isExecutable: only("/usr/bin/chrome", "/opt/bin/google-chrome") }), + { kind: "system", executablePath: "/opt/bin/google-chrome", from: "path" }, + ); +}); + +test("no system browser: Playwright's bundled build", () => { + assert.deepEqual(findXBrowser({ env: { PATH: "/usr/bin" }, isExecutable: only() }), { + kind: "bundled", + }); + assert.deepEqual(findXBrowser({ env: {}, isExecutable: only("/usr/bin/chromium") }), { + kind: "bundled", + }); +}); + +test("ARCHILYZER_X_BROWSER wins, as a path or a name on PATH", () => { + const isExecutable = only("/srv/brave", "/usr/bin/chromium", "/usr/bin/vivaldi"); + assert.deepEqual( + findXBrowser({ env: { [X_BROWSER_ENV]: "/srv/brave", PATH: "/usr/bin" }, isExecutable }), + { kind: "system", executablePath: "/srv/brave", from: "env" }, + ); + assert.deepEqual( + findXBrowser({ env: { [X_BROWSER_ENV]: "vivaldi", PATH: "/usr/bin" }, isExecutable }), + { kind: "system", executablePath: "/usr/bin/vivaldi", from: "env" }, + ); + // A blank override is no override. + assert.deepEqual( + findXBrowser({ env: { [X_BROWSER_ENV]: " ", PATH: "/usr/bin" }, isExecutable }), + { kind: "system", executablePath: "/usr/bin/chromium", from: "path" }, + ); +}); + +test("an ARCHILYZER_X_BROWSER that is not an executable refuses — it never opens another browser", () => { + const isExecutable = only("/usr/bin/chromium"); + assert.throws( + () => findXBrowser({ env: { [X_BROWSER_ENV]: "/nope/chrome", PATH: "/usr/bin" }, isExecutable }), + /ARCHILYZER_X_BROWSER names "\/nope\/chrome", which is not an executable file\./, + ); + assert.throws( + () => findXBrowser({ env: { [X_BROWSER_ENV]: "edge", PATH: "/usr/bin" }, isExecutable }), + /not an executable file on PATH/, + ); +}); + +test("the Connect window: the system browser, headed, sandboxed, without the automation signals", () => { + const opts = buildXBrowserLaunchOptions({ + browser: { kind: "system", executablePath: "/usr/bin/chromium", from: "path" }, + headless: false, + sandbox: true, + }); + assert.deepEqual(opts, { + headless: false, + executablePath: "/usr/bin/chromium", + ignoreDefaultArgs: ["--enable-automation"], + args: ["--disable-blink-features=AutomationControlled", "--test-type"], + chromiumSandbox: true, + viewport: { width: 1280, height: 900 }, + }); +}); + +test("the bundled build: no executablePath, the same signals dropped", () => { + const headed = buildXBrowserLaunchOptions({ browser: { kind: "bundled" }, headless: false, sandbox: true }); + assert.equal("executablePath" in headed, false); + assert.deepEqual(headed.ignoreDefaultArgs, ["--enable-automation"]); + assert.deepEqual(headed.args, ["--disable-blink-features=AutomationControlled", "--test-type"]); + assert.equal(headed.chromiumSandbox, true); + + // The headless refresh: no viewport, Playwright's default sandbox setting. + const headless = buildXBrowserLaunchOptions({ browser: { kind: "bundled" }, headless: true }); + assert.deepEqual(headless, { + headless: true, + ignoreDefaultArgs: ["--enable-automation"], + args: ["--disable-blink-features=AutomationControlled", "--test-type"], + }); +}); + +test("ignoreDefaultArgs names ONLY --enable-automation (never `true`: it would drop --password-store=basic)", () => { + for (const browser of [ + { kind: "bundled" as const }, + { kind: "system" as const, executablePath: "/usr/bin/chromium", from: "path" as const }, + ]) { + for (const headless of [true, false]) { + const o = buildXBrowserLaunchOptions({ browser, headless, sandbox: !headless }); + assert.ok(Array.isArray(o.ignoreDefaultArgs)); + assert.deepEqual(o.ignoreDefaultArgs, ["--enable-automation"]); + } + } +}); + +test("describeXBrowser says which browser, and how it was found", () => { + assert.match(describeXBrowser({ kind: "bundled" }), /bundled Chromium/); + assert.equal( + describeXBrowser( + { kind: "system", executablePath: "/usr/bin/chromium", from: "path" }, + "Chromium 153.0.8010.47 Arch Linux", + ), + "Chromium 153.0.8010.47 Arch Linux at /usr/bin/chromium (found on PATH)", + ); + assert.match( + describeXBrowser({ kind: "system", executablePath: "/srv/brave", from: "env" }), + /^\/srv\/brave \(from ARCHILYZER_X_BROWSER\)$/, + ); +}); + +test("the profile records who wrote it, and the refresh's fallback reads it back", async () => { + const profile = path.join(TMP, "profile"); + assert.equal(await readXBrowserRecord(profile), null); + + await writeXBrowserRecord( + profile, + { kind: "system", executablePath: "/usr/bin/chromium", from: "path" }, + "Chromium 153", + ); + const rec = await readXBrowserRecord(profile); + assert.equal(rec?.executablePath, "/usr/bin/chromium"); + assert.equal(rec?.version, "Chromium 153"); + assert.deepEqual(recordedXBrowser(rec, only("/usr/bin/chromium")), { + kind: "system", + executablePath: "/usr/bin/chromium", + from: "recorded", + }); + // The recorded executable is gone: no fallback. + assert.equal(recordedXBrowser(rec, only()), undefined); + + await writeXBrowserRecord(profile, { kind: "bundled" }); + const bundled = await readXBrowserRecord(profile); + assert.equal(bundled?.executablePath, null); + assert.equal(recordedXBrowser(bundled, only("/usr/bin/chromium")), undefined); +}); diff --git a/common/social/xBrowser.ts b/common/social/xBrowser.ts @@ -0,0 +1,242 @@ +// THE BROWSER THAT OPENS THE X SESSION PROFILE (release 16 slice XL). +// +// Why this exists: the Connect window used to be Playwright's bundled Chromium +// launched with its automation signals on — `--enable-automation` (which is +// what draws "Chrome is being controlled by automated test software") and +// `navigator.webdriver === true`, which Playwright's debugging pipe turns on by +// itself. Google's sign-in refuses such a browser ("this browser or app may not +// be secure") and X's own login form stalled in it (2026-10-01). +// +// So the Connect window is the operator's own browser when one is installed — +// ARCHILYZER_X_BROWSER, else the first of chromium / google-chrome / +// google-chrome-stable / chrome on PATH, else the bundled build — and every +// launch here drops the signals: +// - `ignoreDefaultArgs: ["--enable-automation"]` — no automation bar. ONLY +// that one: `ignoreDefaultArgs: true` would also drop Playwright's +// `--password-store=basic`, and a system Chromium would then encrypt the +// profile's cookies with the desktop keyring's key, which the bundled +// headless build that refreshes the jar cannot read. +// - `--disable-blink-features=AutomationControlled` — navigator.webdriver is +// false. Measured: without it, it stays true even with +// `--enable-automation` gone (the pipe sets it). +// - `--test-type` and, for the headed window, the sandbox on — a system +// Chrome draws "You are using an unsupported command-line flag" for the +// blink flag above and for Playwright's default `--no-sandbox`. The +// sandbox removes the second. `--test-type` is Chromium's internal +// test-harness switch: it makes the browser skip its startup bars, the +// bad-flags one among them, and sets no automation signal (measured: +// navigator.webdriver, window.chrome, the user agent and the plugins are +// the same with and without it). ChromeDriver passes +// `--test-type=webdriver`; the bare switch is used here. It is not the +// documented route — that is the `CommandLineFlagSecurityWarningsEnabled` +// policy, which is machine-wide, needs root, and would silence the +// operator's everyday browser too. The bundled build draws neither bar. +// None of this hides the debugging pipe itself; Google's sign-in may still +// refuse an embedded browser, which the Settings section says. +// +// THE PROFILE REMEMBERS WHO WROTE IT. A connect records its executable in the +// profile dir (`archilyzer-browser.json`). The headless refresh keeps the +// bundled build — it never logs in, and its headless shell needs no display — +// and falls back to the recorded executable when the bundled build cannot open +// the profile (a profile written by a newer system browser). Measured +// 2026-10-01: a profile written by system Chromium 153 opens in the bundled +// headless shell 147 with its cookies readable, so the fallback is a guard, +// not the common path. + +import { accessSync, constants, statSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { writeFileAtomic } from "../lib/jsonFile-server"; + +export const X_BROWSER_ENV = "ARCHILYZER_X_BROWSER"; + +// Looked up on PATH in this order when ARCHILYZER_X_BROWSER is unset. +export const SYSTEM_CHROMIUM_NAMES: readonly string[] = [ + "chromium", + "google-chrome", + "google-chrome-stable", + "chrome", +]; + +export type XBrowserChoice = + | { + kind: "system"; + executablePath: string; + // How it was found: the env override, PATH, or the profile's record. + from: "env" | "path" | "recorded"; + } + | { kind: "bundled" }; + +export function isExecutableFile(p: string): boolean { + try { + if (!statSync(p).isFile()) return false; + accessSync(p, constants.X_OK); + return true; + } catch { + return false; + } +} + +function onPath( + name: string, + pathEnv: string | undefined, + isExecutable: (p: string) => boolean, +): string | undefined { + for (const dir of (pathEnv ?? "").split(path.delimiter)) { + if (!dir) continue; + const candidate = path.join(dir, name); + if (isExecutable(candidate)) return candidate; + } + return undefined; +} + +// The Connect window's browser. Throws only when ARCHILYZER_X_BROWSER names +// something that is not an executable: an explicit setting that is wrong is +// said, not silently replaced by a different browser. +export function findXBrowser( + opts: { + env?: Record<string, string | undefined>; + isExecutable?: (p: string) => boolean; + } = {}, +): XBrowserChoice { + const env = opts.env ?? process.env; + const isExecutable = opts.isExecutable ?? isExecutableFile; + const override = env.ARCHILYZER_X_BROWSER?.trim(); + if (override) { + const resolved = override.includes("/") + ? isExecutable(override) + ? override + : undefined + : onPath(override, env.PATH, isExecutable); + if (!resolved) { + throw new Error( + `${X_BROWSER_ENV} names "${override}", which is not an executable file` + + (override.includes("/") ? "." : " on PATH."), + ); + } + return { kind: "system", executablePath: resolved, from: "env" }; + } + for (const name of SYSTEM_CHROMIUM_NAMES) { + const hit = onPath(name, env.PATH, isExecutable); + if (hit) return { kind: "system", executablePath: hit, from: "path" }; + } + return { kind: "bundled" }; +} + +// THE LAUNCH OPTIONS, pure. `headless: false` is the Connect window (the +// operator logs in); `headless: true` is the refresh and the Playwright +// fallback fetcher. `sandbox` turns Chromium's sandbox on — the headed window +// asks for it (no unsupported-flag bar) and retries without it when the host +// cannot start one; a headless launch keeps Playwright's default. +export type XBrowserLaunchOptions = { + headless: boolean; + executablePath?: string; + ignoreDefaultArgs: string[]; + args: string[]; + chromiumSandbox?: boolean; + viewport?: { width: number; height: number }; +}; + +export const X_BROWSER_ARGS: readonly string[] = [ + "--disable-blink-features=AutomationControlled", + "--test-type", +]; + +export function buildXBrowserLaunchOptions(opts: { + browser: XBrowserChoice; + headless: boolean; + sandbox?: boolean; +}): XBrowserLaunchOptions { + const out: XBrowserLaunchOptions = { + headless: opts.headless, + ignoreDefaultArgs: ["--enable-automation"], + args: [...X_BROWSER_ARGS], + }; + if (opts.browser.kind === "system") { + out.executablePath = opts.browser.executablePath; + } + if (opts.sandbox) out.chromiumSandbox = true; + if (!opts.headless) out.viewport = { width: 1280, height: 900 }; + return out; +} + +export function describeXBrowser(b: XBrowserChoice, version?: string): string { + if (b.kind === "bundled") { + return "Playwright's bundled Chromium (no system Chromium or Chrome found)"; + } + const how = + b.from === "env" + ? `from ${X_BROWSER_ENV}` + : b.from === "path" + ? "found on PATH" + : "the browser that created the profile"; + return `${version ? `${version} at ` : ""}${b.executablePath} (${how})`; +} + +// --- The profile's record of who wrote it --------------------------------- + +export const X_BROWSER_RECORD = "archilyzer-browser.json"; + +export type XBrowserRecord = { + // The system executable that opened the Connect window; null = the bundled + // build. + executablePath: string | null; + // `<exe> --version`, when it answered. + version?: string; + recordedAt: string; +}; + +export function xBrowserRecordFile(profileDir: string): string { + return path.join(profileDir, X_BROWSER_RECORD); +} + +export async function readXBrowserRecord( + profileDir: string, +): Promise<XBrowserRecord | null> { + try { + const raw = JSON.parse( + await readFile(xBrowserRecordFile(profileDir), "utf8"), + ) as Record<string, unknown>; + if ( + raw.executablePath !== null && + typeof raw.executablePath !== "string" + ) { + return null; + } + return { + executablePath: raw.executablePath as string | null, + ...(typeof raw.version === "string" ? { version: raw.version } : {}), + recordedAt: typeof raw.recordedAt === "string" ? raw.recordedAt : "", + }; + } catch { + return null; + } +} + +export async function writeXBrowserRecord( + profileDir: string, + browser: XBrowserChoice, + version?: string, +): Promise<void> { + const record: XBrowserRecord = { + executablePath: browser.kind === "system" ? browser.executablePath : null, + ...(version ? { version } : {}), + recordedAt: new Date().toISOString(), + }; + await writeFileAtomic( + xBrowserRecordFile(profileDir), + JSON.stringify(record, null, 2) + "\n", + { mkdir: true }, + ); +} + +// The browser the record names, for the refresh's fallback — undefined when it +// names the bundled build, nothing, or an executable that is gone. +export function recordedXBrowser( + record: XBrowserRecord | null, + isExecutable: (p: string) => boolean = isExecutableFile, +): XBrowserChoice | undefined { + if (!record?.executablePath) return undefined; + if (!isExecutable(record.executablePath)) return undefined; + return { kind: "system", executablePath: record.executablePath, from: "recorded" }; +} diff --git a/common/social/xBrowserLogin.test.ts b/common/social/xBrowserLogin.test.ts @@ -0,0 +1,357 @@ +// Reading the operator's browser login for X, and the status the Settings +// section's "Check" shows (release 16 slice XL). Every store here is a fixture +// written through node:sqlite (__fixtures__/firefoxCookieStore.ts) under a temp +// HOME — no test reads a real browser profile. +// +// Run with: pnpm --filter yt-dlp-transcript-common exec tsx --test social/xBrowserLogin.test.ts + +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { mkdir, readdir, rm, stat, utimes, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { Paths } from "../lib/paths"; +import { + firefoxExpirySeconds, + isOldDomainAuth, + isXComAuth, + readFirefoxXCookies, + readFirefoxXStore, + readXLoginStatus, + xCookiesFromBrowser, +} from "./xBrowserLogin"; +import { xCookieFile, xProfileDir } from "./xSessionBroker"; +import { + insertFixtureCookies, + writeFirefoxCookieStore, + type FixtureCookie, +} from "./__fixtures__/firefoxCookieStore"; + +const TMP = mkdtempSync(path.join(os.tmpdir(), "xl-xbrowserlogin-")); +after(() => rm(TMP, { recursive: true, force: true })); + +let n = 0; +const fresh = (label: string) => path.join(TMP, `${label}-${++n}`); + +const NOW_S = Math.floor(Date.now() / 1000); +const YEAR = 86_400 * 365; +const LAST_USED_US = Date.UTC(2026, 9, 1, 8, 30, 0) * 1000; // 2026-10-01 08:30 UTC + +const X_LOGIN: FixtureCookie[] = [ + { host: ".x.com", name: "auth_token", value: "fixture-auth", lastAccessed: LAST_USED_US }, + { host: ".x.com", name: "ct0", value: "fixture-csrf", isHttpOnly: 0, sameSite: 1 }, + { host: "x.com", name: "lang", value: "en", isSecure: 0, isHttpOnly: 0, sameSite: 0 }, + { host: ".twitter.com", name: "guest_id", value: "v1%3A1", sameSite: 2 }, + // Not X: never selected. + { host: ".example.com", name: "session", value: "not-x" }, + { host: "notx.com", name: "auth_token", value: "lookalike" }, + // Expired: skipped. + { host: ".x.com", name: "old", value: "gone", expiry: NOW_S - 60 }, +]; + +test("reads X's cookies only, mapped to the shape Playwright and cookies.txt take", async () => { + const dir = fresh("profile"); + const { file } = await writeFirefoxCookieStore(dir, X_LOGIN); + const cookies = await readFirefoxXCookies(file, { tmpRoot: TMP }); + const byName = new Map(cookies.map((c) => [c.name, c])); + assert.deepEqual([...byName.keys()].sort(), ["auth_token", "ct0", "guest_id", "lang"]); + const auth = byName.get("auth_token")!; + assert.equal(auth.value, "fixture-auth"); + assert.equal(auth.domain, ".x.com"); + assert.equal(auth.path, "/"); + assert.equal(auth.secure, true); + assert.equal(auth.httpOnly, true); + assert.equal(auth.sameSite, "None"); + assert.equal(auth.lastAccessedMs, LAST_USED_US / 1000); + assert.ok(auth.expires > NOW_S); + assert.equal(byName.get("ct0")!.sameSite, "Lax"); + assert.equal(byName.get("ct0")!.httpOnly, false); + assert.equal(byName.get("guest_id")!.sameSite, "Strict"); + // SameSite=None on a cookie that is not secure is not kept by a browser. + assert.equal(byName.get("lang")!.sameSite, "Lax"); + assert.equal(byName.get("lang")!.domain, "x.com"); +}); + +test("a login still in the WAL of a running Firefox is read", async () => { + const dir = fresh("profile-wal"); + const { file, db } = await writeFirefoxCookieStore(dir, X_LOGIN, { keepOpen: true }); + try { + const listed = await readdir(dir); + assert.ok(listed.includes("cookies.sqlite-wal"), "the fixture leaves its rows in the WAL"); + const cookies = await readFirefoxXCookies(file, { tmpRoot: TMP }); + assert.ok(cookies.some((c) => c.name === "auth_token" && c.value === "fixture-auth")); + } finally { + db?.close(); + } +}); + +test("the browser's store is never written: no file in the profile changes, no temp is left", async () => { + const dir = fresh("profile-ro"); + const { file, db } = await writeFirefoxCookieStore(dir, X_LOGIN, { keepOpen: true }); + try { + const before = await Promise.all( + (await readdir(dir)).sort().map(async (f) => { + const st = await stat(path.join(dir, f)); + return `${f}:${st.size}:${st.mtimeMs}`; + }), + ); + const tmpRoot = fresh("tmproot"); + await mkdir(tmpRoot); + await readFirefoxXCookies(file, { tmpRoot }); + const afterList = await Promise.all( + (await readdir(dir)).sort().map(async (f) => { + const st = await stat(path.join(dir, f)); + return `${f}:${st.size}:${st.mtimeMs}`; + }), + ); + assert.deepEqual(afterList, before); + assert.deepEqual(await readdir(tmpRoot), [], "the private copy is removed"); + } finally { + db?.close(); + } +}); + +test("containers are read as gallery-dl reads them: none by default, ::all, or one by name", async () => { + const dir = fresh("profile-containers"); + const { file } = await writeFirefoxCookieStore(dir, [ + { host: ".x.com", name: "auth_token", value: "default" }, + { host: ".x.com", name: "auth_token", value: "work", originAttributes: "^userContextId=2" }, + { host: ".x.com", name: "auth_token", value: "work-fpd", originAttributes: "^firstPartyDomain=x.com&userContextId=2&x=1" }, + { host: ".x.com", name: "auth_token", value: "personal", originAttributes: "^userContextId=1" }, + { host: ".x.com", name: "auth_token", value: "shopping", originAttributes: "^userContextId=12" }, + ]); + await writeFile( + path.join(dir, "containers.json"), + JSON.stringify({ + version: 5, + identities: [ + { userContextId: 1, public: true, l10nID: "userContextPersonal.label" }, + { userContextId: 2, public: true, name: "Work" }, + { userContextId: 12, public: true, l10nId: "user-context-shopping" }, + ], + }), + ); + const values = async (container?: string) => + (await readFirefoxXCookies(file, { container, tmpRoot: TMP })).map((c) => c.value).sort(); + // No container in the spec: ONLY cookies outside every container (gallery-dl's + // default; yt-dlp would read them all). + assert.deepEqual(await values(), ["default"]); + assert.deepEqual(await values("none"), ["default"]); + assert.deepEqual(await values("all"), ["default", "personal", "shopping", "work", "work-fpd"]); + // By name (`name`), by `l10nID` (userContext<X>.label) and by `l10nId` + // (user-context-<x>) — case-sensitive, as gallery-dl matches. Container 2 does + // not pick up container 12. + assert.deepEqual(await values("Work"), ["work", "work-fpd"]); + assert.deepEqual(await values("Personal"), ["personal"]); + assert.deepEqual(await values("shopping"), ["shopping"]); + await assert.rejects(values("personal"), /No Firefox container named "personal"/); + await assert.rejects(values("Banking"), /No Firefox container named "Banking"/); +}); + +test("the store read has two views: with the WAL (Firefox now) and the main file alone (gallery-dl's)", async () => { + const dir = fresh("profile-views"); + const { file, db } = await writeFirefoxCookieStore(dir, [ + { host: ".x.com", name: "guest_id", value: "g" }, + ]); + // The schema and guest_id are in the main file; the login lands in the WAL + // of the still-open store, as a fresh login does in a running Firefox. + assert.equal(db, undefined); + const live = await writeFirefoxCookieStore(fresh("profile-views-wal"), [], { keepOpen: true }); + try { + insertFixtureCookies(live.db!, [{ host: ".x.com", name: "auth_token", value: "fresh" }]); + const read = await readFirefoxXStore(live.file, { tmpRoot: TMP }); + assert.deepEqual(read.cookies.map((c) => c.value), ["fresh"]); + assert.deepEqual(read.mainFile, []); + } finally { + live.db?.close(); + } + const settled = await readFirefoxXStore(file, { tmpRoot: TMP }); + assert.deepEqual(settled.cookies.map((c) => c.name), ["guest_id"]); + assert.deepEqual(settled.mainFile.map((c) => c.name), ["guest_id"]); +}); + +test("a login counts on x.com only; twitter.com's auth_token is the old domain's", () => { + assert.equal(isXComAuth({ name: "auth_token", domain: ".x.com" }), true); + assert.equal(isXComAuth({ name: "auth_token", domain: "x.com" }), true); + assert.equal(isXComAuth({ name: "auth_token", domain: "api.x.com" }), true); + assert.equal(isXComAuth({ name: "auth_token", domain: ".twitter.com" }), false); + assert.equal(isXComAuth({ name: "auth_token", domain: ".notx.com" }), false); + assert.equal(isXComAuth({ name: "ct0", domain: ".x.com" }), false); + assert.equal(isOldDomainAuth({ name: "auth_token", domain: ".twitter.com" }), true); + assert.equal(isOldDomainAuth({ name: "auth_token", domain: ".x.com" }), false); +}); + +test("Firefox's expiry reads as seconds, or as milliseconds when too large for seconds", () => { + assert.equal(firefoxExpirySeconds(1_800_000_000), 1_800_000_000); + assert.equal(firefoxExpirySeconds(1_800_000_000_123), 1_800_000_000); + assert.equal(firefoxExpirySeconds(0), -1); + assert.equal(firefoxExpirySeconds(null), -1); +}); + +test("discovery: the most recently used store under Firefox's roots, as gallery-dl picks it", async () => { + const home = fresh("home"); + const root = path.join(home, ".mozilla", "firefox"); + const older = await writeFirefoxCookieStore(path.join(root, "aaa.default"), [ + { host: ".x.com", name: "auth_token", value: "older-profile" }, + ]); + const newer = await writeFirefoxCookieStore(path.join(root, "bbb.default-release"), [ + { host: ".x.com", name: "auth_token", value: "newer-profile" }, + ]); + const t = Date.now() / 1000; + await utimes(older.file, t - 3_600, t - 3_600); + await utimes(newer.file, t, t); + + const read = await xCookiesFromBrowser("firefox", { home, tmpRoot: TMP }); + assert.equal(read.ok, true); + assert.ok(read.ok && read.store === newer.file); + assert.ok(read.ok && read.cookies.some((c) => c.value === "newer-profile")); + + // A profile NAME is looked up under the roots; a PATH is used as given. + const named = await xCookiesFromBrowser("firefox:aaa.default", { home, tmpRoot: TMP }); + assert.ok(named.ok && named.store === older.file); + const byPath = await xCookiesFromBrowser(`firefox:${path.join(root, "aaa.default")}`, { + home, + tmpRoot: TMP, + }); + assert.ok(byPath.ok && byPath.store === older.file); +}); + +test("what xCookiesFromBrowser will not read, it says", async () => { + const home = fresh("home-empty"); + await mkdir(home); + const none = await xCookiesFromBrowser("", { home }); + assert.equal(none.ok, false); + assert.ok(!none.ok && none.reason === "no-spec"); + + const chromium = await xCookiesFromBrowser("chromium:Default", { home }); + assert.ok(!chromium.ok && chromium.reason === "unsupported"); + assert.match(!chromium.ok ? chromium.message : "", /gallery-dl/); + + const missing = await xCookiesFromBrowser("firefox", { home }); + assert.ok(!missing.ok && missing.reason === "not-found"); + assert.match(!missing.ok ? missing.message : "", /\.mozilla\/firefox/); +}); + +// --- The status --------------------------------------------------------------- + +function pathsFor(transcriptsDir: string): Paths { + return { transcriptsDir } as Paths; +} + +async function connectProfile(paths: Paths, authed: boolean) { + await mkdir(xProfileDir(paths), { recursive: true }); + await writeFile( + xCookieFile(paths), + "# Netscape HTTP Cookie File\n" + + (authed ? ".x.com\tTRUE\t/\tTRUE\t1900000000\tauth_token\tjar\n" : ".x.com\tTRUE\t/\tTRUE\t1900000000\tct0\tjar\n"), + ); +} + +test("status — browser source by default: the login is visible, with when the browser last used it", async () => { + const home = fresh("home-status"); + await writeFirefoxCookieStore(path.join(home, ".mozilla", "firefox", "p.default"), X_LOGIN); + const paths = pathsFor(fresh("transcripts")); + const s = await readXLoginStatus(paths, { cookiesFromBrowser: "firefox" }, { home, tmpRoot: TMP }); + assert.equal(s.source, "browser"); + assert.equal(s.chosen, false); + assert.equal(s.label, "Browser login (firefox)"); + assert.equal(s.authTokenVisible, true); + assert.equal(s.lastSeenAt, "2026-10-01T08:30:00.000Z"); + assert.equal(s.walOnly, false); + assert.equal(s.oldDomainCookie, false); + assert.equal( + s.summary, + "An X login is visible in firefox, outside its containers (where gallery-dl reads); " + + "its auth_token was last used 2026-10-01 08:30:00 UTC.", + ); +}); + +test("status — a login only in Firefox's write-ahead log says gallery-dl will not see it yet", async () => { + const home = fresh("home-wal"); + const live = await writeFirefoxCookieStore(path.join(home, ".mozilla", "firefox", "p.default"), X_LOGIN, { + keepOpen: true, + }); + try { + const s = await readXLoginStatus(pathsFor(fresh("transcripts")), { cookiesFromBrowser: "firefox" }, { home, tmpRoot: TMP }); + assert.equal(s.authTokenVisible, true); + assert.equal(s.walOnly, true); + assert.match( + s.summary, + /Seen in Firefox's write-ahead log only; gallery-dl will see it after Firefox checkpoints \(closing Firefox does it\)\.$/, + ); + } finally { + live.db?.close(); + } +}); + +test("status — twitter.com's auth_token is reported as the old domain's, never counted", async () => { + const home = fresh("home-olddomain"); + await writeFirefoxCookieStore(path.join(home, ".mozilla", "firefox", "p.default"), [ + { host: ".twitter.com", name: "auth_token", value: "old" }, + ]); + const s = await readXLoginStatus(pathsFor(fresh("transcripts")), { cookiesFromBrowser: "firefox" }, { home, tmpRoot: TMP }); + assert.equal(s.authTokenVisible, false); + assert.equal(s.oldDomainCookie, true); + assert.match(s.summary, /^No X login in firefox, outside its containers \(where gallery-dl reads\): no auth_token cookie for x\.com\./); + assert.match(s.summary, /An old-domain cookie is present too \(an auth_token for twitter\.com\); gallery-dl does not use it\.$/); +}); + +test("status — a login only inside a container is not counted, and the Check says where it is", async () => { + const home = fresh("home-container"); + await writeFirefoxCookieStore(path.join(home, ".mozilla", "firefox", "p.default"), [ + { host: ".x.com", name: "auth_token", value: "in-a-container", originAttributes: "^userContextId=3" }, + ]); + const paths = pathsFor(fresh("transcripts")); + const s = await readXLoginStatus(paths, { cookiesFromBrowser: "firefox" }, { home, tmpRoot: TMP }); + assert.equal(s.authTokenVisible, false); + assert.equal(s.otherContainerLogin, true); + assert.match(s.summary, /One is in another Firefox container; gallery-dl reads it only when cookiesFromBrowser names that container \(firefox::<name>\) or firefox::all\./); + const all = await readXLoginStatus(paths, { cookiesFromBrowser: "firefox::all" }, { home, tmpRoot: TMP }); + assert.equal(all.authTokenVisible, true); + assert.match(all.summary, /^An X login is visible in firefox, in any container/); +}); + +test("status — browser source with no X login in the browser", async () => { + const home = fresh("home-nologin"); + await writeFirefoxCookieStore(path.join(home, ".mozilla", "firefox", "p.default"), [ + { host: ".x.com", name: "guest_id", value: "g" }, + ]); + const s = await readXLoginStatus(pathsFor(fresh("transcripts")), { cookiesFromBrowser: "firefox" }, { home, tmpRoot: TMP }); + assert.equal(s.authTokenVisible, false); + assert.match(s.summary, /No X login in firefox/); +}); + +test("status — a browser that cannot be read here reports null, not false", async () => { + const s = await readXLoginStatus( + pathsFor(fresh("transcripts")), + { cookiesFromBrowser: "chromium", social: { x: { cookieSource: "browser" } } }, + { home: fresh("home-x") }, + ); + assert.equal(s.source, "browser"); + assert.equal(s.chosen, true); + assert.equal(s.authTokenVisible, null); +}); + +test("status — the profile: chosen by default when no browser is set, and when one is connected", async () => { + const paths = pathsFor(fresh("transcripts")); + const none = await readXLoginStatus(paths, { cookiesFromBrowser: "" }); + assert.equal(none.source, "profile"); + assert.equal(none.label, "Connected profile"); + assert.equal(none.authTokenVisible, false); + assert.match(none.summary, /No profile is connected/); + + await connectProfile(paths, false); + const notLoggedIn = await readXLoginStatus(paths, { cookiesFromBrowser: "" }); + assert.equal(notLoggedIn.authTokenVisible, false); + assert.match(notLoggedIn.summary, /not logged in to X/); + + // A connected profile turns the default to "profile" even with a browser set. + await connectProfile(paths, true); + const connected = await readXLoginStatus(paths, { cookiesFromBrowser: "firefox" }, { home: fresh("home-y") }); + assert.equal(connected.source, "profile"); + assert.equal(connected.chosen, false); + assert.equal(connected.authTokenVisible, true); + assert.ok(connected.lastSeenAt); + assert.match(connected.summary, /^The connected profile holds an X login \(cookies exported /); +}); diff --git a/common/social/xBrowserLogin.ts b/common/social/xBrowserLogin.ts @@ -0,0 +1,561 @@ +// THE OPERATOR'S BROWSER LOGIN, read for the X paths that cannot read it +// themselves (release 16 slice XL). +// +// gallery-dl reads a browser's cookie store on its own (`--cookies-from-browser +// <spec>`, xGalleryDlFetcher.ts) — every browser it supports, Chromium's +// encrypted store included. Two things here need the same login without +// gallery-dl: the Playwright fallback fetcher (it hands the cookies to a fresh +// headless context) and the Settings section's "Check" (is an X login visible +// at all, and when was it last used). Both go through `xCookiesFromBrowser`. +// +// FIREFOX ONLY. Its `cookies.sqlite` is plain SQLite with plain values. The +// Chromium family encrypts each value with a key from the desktop keyring +// (libsecret / KWallet) and is left to gallery-dl and yt-dlp, which carry that +// decryption; for such a spec the readers here say so instead of guessing. +// +// READ-ONLY, ALWAYS. The browser's profile is never opened in place and never +// written: `cookies.sqlite` and its `-wal` (Firefox writes ahead, so a fresh +// login may live only in the WAL) are copied into a private temp dir, read +// there, and the dir is removed. Only X's own rows are selected, so no other +// site's cookies leave SQLite. The store is found much as gallery-dl finds it +// (a profile path or name from the spec, else the most recently modified +// `cookies.sqlite` under Firefox's profile roots) — not exactly: see +// findFirefoxCookieDb. The CONTAINER is read exactly as gallery-dl reads it +// (firefoxContainerScope), and so is the domain a login counts on (x.com). + +import { copyFile, mkdir, mkdtemp, readdir, readFile, rm, stat } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { Paths } from "../lib/paths"; +import { loadSqlite } from "./nodeSqlite"; +import { readXSessionStatus } from "./xSessionBroker"; +import { + parseBrowserSpec, + resolveXCookieSource, + SELF_READ_BROWSERS, + xCookieSourceView, + type BrowserSpec, + type ResolvedXCookieSource, + type XCookieSource, + type XCookieSourceView, +} from "./xCookieSource"; + +// X's session cookie: the one whose presence means "logged in". +export const X_AUTH_COOKIE = "auth_token"; + +// A cookie as read from a browser's store, in the shape Playwright's +// `addCookies` and the broker's cookies.txt writer both take. +export type XBrowserCookie = { + name: string; + value: string; + domain: string; + path: string; + // Seconds since the epoch; -1 for a session cookie. + expires: number; + httpOnly: boolean; + secure: boolean; + sameSite?: "Strict" | "Lax" | "None"; + // When the browser last sent it, ms since the epoch (Firefox's lastAccessed). + lastAccessedMs?: number; +}; + +// --- Finding the store ------------------------------------------------------- + +// Where Firefox keeps its profiles: the XDG location newer releases use, the +// classic one, Snap, Flatpak, macOS. gallery-dl's list differs slightly (it +// honours $XDG_CONFIG_HOME and has a second Flatpak root), and for a profile +// NAME it takes the first root holding `<name>/cookies.sqlite` where this takes +// the newest; on a stock Linux Firefox the two pick the same store. +export function firefoxProfileRoots(home: string): string[] { + return [ + path.join(home, ".config", "mozilla", "firefox"), + path.join(home, ".mozilla", "firefox"), + path.join(home, "snap", "firefox", "common", ".mozilla", "firefox"), + path.join(home, ".var", "app", "org.mozilla.firefox", ".mozilla", "firefox"), + path.join(home, "Library", "Application Support", "Firefox", "Profiles"), + ]; +} + +const COOKIE_DB = "cookies.sqlite"; + +// The newest cookies.sqlite at most `depth` directories below `dir`. A profile +// keeps it at its own top level, so two levels covers a root of profiles and a +// profile given directly, without walking a profile's storage tree. +async function newestCookieDb( + dir: string, + depth: number, +): Promise<{ file: string; mtimeMs: number } | undefined> { + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return undefined; + } + let best: { file: string; mtimeMs: number } | undefined; + for (const e of entries) { + const p = path.join(dir, e.name); + if (e.name === COOKIE_DB && e.isFile()) { + const st = await stat(p).catch(() => null); + if (st && (!best || st.mtimeMs > best.mtimeMs)) best = { file: p, mtimeMs: st.mtimeMs }; + } else if (depth > 0 && (e.isDirectory() || e.isSymbolicLink())) { + const hit = await newestCookieDb(p, depth - 1); + if (hit && (!best || hit.mtimeMs > best.mtimeMs)) best = hit; + } + } + return best; +} + +function expandHome(p: string, home: string): string { + return p === "~" ? home : p.startsWith("~/") ? path.join(home, p.slice(2)) : p; +} + +export async function findFirefoxCookieDb( + spec: BrowserSpec, + home: string, +): Promise<{ file?: string; searched: string[] }> { + const profile = spec.profile ? expandHome(spec.profile, home) : undefined; + const searched = profile + ? profile.includes("/") || profile.includes(path.sep) + ? [profile] + : firefoxProfileRoots(home).map((r) => path.join(r, profile)) + : firefoxProfileRoots(home); + let best: { file: string; mtimeMs: number } | undefined; + for (const root of searched) { + const hit = await newestCookieDb(root, 2); + if (hit && (!best || hit.mtimeMs > best.mtimeMs)) best = hit; + } + return { file: best?.file, searched }; +} + +// --- Reading it ---------------------------------------------------------------- + +// WHICH CONTAINER, as gallery-dl reads it (gallery-dl/cookies.py, +// `_firefox_cookies_database`; 1.32.9) — gallery-dl is the fetcher this login +// feeds, so the readers here see what it sees: +// no `::CONTAINER`, or `::none` — only cookies that belong to no container +// (gallery-dl's default; yt-dlp's differs); +// `::all` — every container, no filter; +// `::NAME` — the container containers.json names so, +// matched as gallery-dl matches it (its +// `name`, else its `l10nId`, else `l10nID`; +// case-sensitive). +export type ContainerScope = { kind: "none" } | { kind: "all" } | { kind: "id"; id: number }; + +function extr(text: string, begin: string, end: string): string { + const i = text.indexOf(begin); + if (i < 0) return ""; + const from = i + begin.length; + const j = text.indexOf(end, from); + return j < 0 ? "" : text.slice(from, j); +} + +export async function firefoxContainerScope( + dbFile: string, + container: string | undefined, +): Promise<ContainerScope> { + if (!container || container === "none") return { kind: "none" }; + if (container === "all") return { kind: "all" }; + let identities: Array<Record<string, unknown>> = []; + try { + const raw = JSON.parse( + await readFile(path.join(path.dirname(dbFile), "containers.json"), "utf8"), + ) as { identities?: unknown }; + if (Array.isArray(raw.identities)) identities = raw.identities as Array<Record<string, unknown>>; + } catch { + /* no containers.json: no container can match */ + } + const str = (v: unknown) => (typeof v === "string" && v ? v : undefined); + const hit = identities.find((c) => { + const name = str(c.name); + if (name) return name === container; + const l10nId = str(c.l10nId); + if (l10nId) { + return ( + (l10nId.startsWith("user-context-") && l10nId.slice(13) === container) || + l10nId.slice(l10nId.lastIndexOf("-") + 1) === container + ); + } + const l10nID = str(c.l10nID); + return l10nID ? extr(l10nID, "userContext", ".label") === container : false; + }); + if (typeof hit?.userContextId !== "number") { + throw new Error(`No Firefox container named "${container}" in containers.json`); + } + return { kind: "id", id: hit.userContextId }; +} + +// gallery-dl's SQL, in JS: `NOT INSTR(originAttributes,'userContextId=')` for +// no container, `LIKE '%userContextId=<id>' OR LIKE '%userContextId=<id>&%'` +// for one. +export function inContainerScope(originAttributes: string, scope: ContainerScope): boolean { + if (scope.kind === "all") return true; + if (scope.kind === "none") return !originAttributes.includes("userContextId="); + const tag = `userContextId=${scope.id}`; + return originAttributes.endsWith(tag) || originAttributes.includes(`${tag}&`); +} + +const X_HOSTS = + "(host = 'x.com' OR host = '.x.com' OR host LIKE '%.x.com' OR " + + "host = 'twitter.com' OR host = '.twitter.com' OR host LIKE '%.twitter.com')"; + +// X's own domain. gallery-dl's twitter extractor looks its `auth_token` up on +// `.x.com` (extractor/twitter.py), so a login counts only there; twitter.com's +// is the old domain's, read but not counted. +export function isXComHost(host: string): boolean { + const h = host.replace(/^\./, "").toLowerCase(); + return h === "x.com" || h.endsWith(".x.com"); +} + +export function isXComAuth(c: { name: string; domain: string }): boolean { + return c.name === X_AUTH_COOKIE && isXComHost(c.domain); +} + +export function isOldDomainAuth(c: { name: string; domain: string }): boolean { + if (c.name !== X_AUTH_COOKIE) return false; + const h = c.domain.replace(/^\./, "").toLowerCase(); + return h === "twitter.com" || h.endsWith(".twitter.com"); +} + +function num(v: unknown): number | undefined { + if (typeof v === "number" && Number.isFinite(v)) return v; + if (typeof v === "bigint") return Number(v); + return undefined; +} + +// Firefox has stored `expiry` in seconds; a value too large for seconds is read +// as milliseconds, so a store that changes unit still reads right. +export function firefoxExpirySeconds(v: unknown): number { + const n = num(v); + if (!n || n <= 0) return -1; + return Math.floor(n > 1e11 ? n / 1000 : n); +} + +// `lastAccessed` is PRTime: microseconds since the epoch. +function firefoxTimeMs(v: unknown): number | undefined { + const n = num(v); + if (!n || n <= 0) return undefined; + return Math.floor(n > 1e14 ? n / 1000 : n); +} + +function firefoxSameSite(v: unknown, secure: boolean): XBrowserCookie["sameSite"] { + const n = num(v); + if (n === 2) return "Strict"; + if (n === 1) return "Lax"; + // SameSite=None is only kept by a browser on a secure cookie. + if (n === 0) return secure ? "None" : "Lax"; + return undefined; +} + +type StoreRow = { cookie: XBrowserCookie; originAttributes: string }; + +function readRows( + DatabaseSync: Awaited<ReturnType<typeof loadSqlite>>["DatabaseSync"], + file: string, + nowS: number, +): StoreRow[] { + const db = new DatabaseSync(file); + try { + const rows = db.prepare(`SELECT * FROM moz_cookies WHERE ${X_HOSTS}`).all() as Array< + Record<string, unknown> + >; + const out: StoreRow[] = []; + for (const r of rows) { + if (typeof r.name !== "string" || typeof r.host !== "string") continue; + const secure = num(r.isSecure) === 1; + const expires = firefoxExpirySeconds(r.expiry); + if (expires > 0 && expires < nowS) continue; + const cookie: XBrowserCookie = { + name: r.name, + value: typeof r.value === "string" ? r.value : String(r.value ?? ""), + domain: r.host, + path: typeof r.path === "string" && r.path ? r.path : "/", + expires, + httpOnly: num(r.isHttpOnly) === 1, + secure, + }; + const sameSite = firefoxSameSite(r.sameSite, secure); + if (sameSite) cookie.sameSite = sameSite; + const last = firefoxTimeMs(r.lastAccessed); + if (last) cookie.lastAccessedMs = last; + out.push({ + cookie, + originAttributes: typeof r.originAttributes === "string" ? r.originAttributes : "", + }); + } + return out; + } finally { + db.close(); + } +} + +// ONE READ OF THE STORE, two views of X's rows: +// `cookies` — with the WAL: what Firefox holds now. The Playwright +// fallback uses these (a fresh login is in the WAL until +// Firefox checkpoints). +// `mainFile` — the main file alone: what gallery-dl sees. It opens +// cookies.sqlite `mode=ro&immutable=1`, which ignores the WAL +// (and its fallback copies the main file only). +// Both in the container scope; `otherScopeAuth` says whether an x.com +// auth_token exists OUTSIDE it (in a container the spec did not name). +export type FirefoxXRead = { + cookies: XBrowserCookie[]; + mainFile: XBrowserCookie[]; + otherScopeAuth: boolean; +}; + +export async function readFirefoxXStore( + dbFile: string, + opts: { container?: string; tmpRoot?: string; now?: number } = {}, +): Promise<FirefoxXRead> { + const { DatabaseSync } = await loadSqlite(); + const scope = await firefoxContainerScope(dbFile, opts.container); + // mkdtemp makes the dir 0700: the copies are readable by this user only. + const tmp = await mkdtemp(path.join(opts.tmpRoot ?? os.tmpdir(), "archilyzer-xcookies-")); + try { + // The db and its WAL back to back (the shortest window for a checkpoint + // between them), then a second copy of the db alone, taken from the first. + const withWal = path.join(tmp, "wal", COOKIE_DB); + const mainOnly = path.join(tmp, "main", COOKIE_DB); + await mkdir(path.dirname(withWal)); + await mkdir(path.dirname(mainOnly)); + await copyFile(dbFile, withWal); + await copyFile(`${dbFile}-wal`, `${withWal}-wal`).catch((err: NodeJS.ErrnoException) => { + if (err.code !== "ENOENT") throw err; + }); + await copyFile(withWal, mainOnly); + const nowS = (opts.now ?? Date.now()) / 1000; + const walRows = readRows(DatabaseSync, withWal, nowS); + const mainRows = readRows(DatabaseSync, mainOnly, nowS); + const scoped = (rows: StoreRow[]) => + rows.filter((r) => inContainerScope(r.originAttributes, scope)).map((r) => r.cookie); + return { + cookies: scoped(walRows), + mainFile: scoped(mainRows), + otherScopeAuth: walRows.some( + (r) => !inContainerScope(r.originAttributes, scope) && isXComAuth(r.cookie), + ), + }; + } finally { + await rm(tmp, { recursive: true, force: true }); + } +} + +// X's cookies as Firefox holds them now (the WAL included), in the spec's +// container scope. +export async function readFirefoxXCookies( + dbFile: string, + opts: { container?: string; tmpRoot?: string; now?: number } = {}, +): Promise<XBrowserCookie[]> { + return (await readFirefoxXStore(dbFile, opts)).cookies; +} + +export type BrowserCookieRead = + | ({ + ok: true; + browser: string; + store: string; + // The spec's container, as given (undefined = outside every container). + container?: string; + } & FirefoxXRead) + | { + ok: false; + // no-spec: cookiesFromBrowser is empty. unsupported: a browser this repo + // does not read itself (gallery-dl still does). not-found: no store in + // the places searched. unreadable: the store or the spec could not be read. + reason: "no-spec" | "unsupported" | "not-found" | "unreadable"; + message: string; + }; + +// THE SHARED READ: the X cookies of the browser `spec` names, fresh on every +// call. `spec` is the resolved one — a channel's own cookiesFromBrowser over +// the global — which is why this takes the spec and not the settings. +export async function xCookiesFromBrowser( + spec: string | undefined, + opts: { home?: string; tmpRoot?: string; now?: number } = {}, +): Promise<BrowserCookieRead> { + const trimmed = spec?.trim(); + if (!trimmed) { + return { + ok: false, + reason: "no-spec", + message: "cookiesFromBrowser is empty, so there is no browser to read an X login from.", + }; + } + const parsed = parseBrowserSpec(trimmed); + if (!parsed) { + return { ok: false, reason: "unreadable", message: `Could not read the browser spec "${trimmed}".` }; + } + if (!SELF_READ_BROWSERS.includes(parsed.browser)) { + return { + ok: false, + reason: "unsupported", + message: + `${parsed.browser} keeps its cookies encrypted with the desktop keyring; only gallery-dl ` + + "and yt-dlp read them (gallery-dl does, on every X fetch). This check and the " + + "Playwright fallback read Firefox only.", + }; + } + const home = opts.home ?? os.homedir(); + const found = await findFirefoxCookieDb(parsed, home); + if (!found.file) { + return { + ok: false, + reason: "not-found", + message: `No Firefox cookie store found (looked in ${found.searched.join(", ")}).`, + }; + } + try { + const read = await readFirefoxXStore(found.file, { + container: parsed.container, + tmpRoot: opts.tmpRoot, + now: opts.now, + }); + return { + ok: true, + browser: parsed.browser, + store: found.file, + ...(parsed.container ? { container: parsed.container } : {}), + ...read, + }; + } catch (err) { + return { + ok: false, + reason: "unreadable", + message: `Could not read ${found.file}: ${(err as Error).message}`, + }; + } +} + +// --- The source in use, and whether it holds a login -------------------------- + +// The slice of SiteSettings this module reads. Structural, so settings.ts is +// not imported here. +export type XLoginSettings = { + cookiesFromBrowser?: string; + social?: { x?: { cookieSource?: XCookieSource } }; +}; + +// The source for this host now: reads whether the broker's profile is +// connected. `browserSpec` defaults to the global cookiesFromBrowser; the fetch +// controller passes a channel's own when it has one. +export async function resolveXCookieSourceFor( + paths: Paths, + settings: XLoginSettings, + browserSpec: string | undefined = settings.cookiesFromBrowser, +): Promise<ResolvedXCookieSource> { + const profile = await readXSessionStatus(paths); + return resolveXCookieSource({ + stored: settings.social?.x?.cookieSource, + browserSpec, + profileConnected: profile.looksAuthenticated, + }); +} + +export type XLoginStatus = XCookieSourceView & { + // Whether an auth_token cookie for x.com is visible in the source: null when + // the source could not be read (no spec, an unsupported browser, no store). + authTokenVisible: boolean | null; + // When the login was last seen: for the browser source, when the browser + // last sent its auth_token (the store's lastAccessed); for the profile, when + // the jar was last exported. + lastSeenAt?: string; + // Browser source only. True when the x.com auth_token is in Firefox's + // write-ahead log alone: gallery-dl, which reads the main file, does not see + // it until Firefox checkpoints. + walOnly?: boolean; + // Browser source only. An auth_token for twitter.com (the old domain) is + // present; it is reported, never counted — gallery-dl looks on x.com. + oldDomainCookie?: boolean; + // Browser source only. An x.com auth_token sits in a Firefox container the + // spec does not read. + otherContainerLogin?: boolean; + checkedAt: string; + // A sentence or three for the Settings section. + summary: string; +}; + +function when(iso: string): string { + return iso.replace("T", " ").replace(/\.\d+Z$/, " UTC"); +} + +export async function readXLoginStatus( + paths: Paths, + settings: XLoginSettings, + opts: { home?: string; tmpRoot?: string; now?: number } = {}, +): Promise<XLoginStatus> { + const checkedAt = new Date(opts.now ?? Date.now()).toISOString(); + const resolved = await resolveXCookieSourceFor(paths, settings); + const base = { ...xCookieSourceView(resolved), checkedAt }; + + if (resolved.source === "profile") { + const profile = await readXSessionStatus(paths); + if (profile.looksAuthenticated) { + return { + ...base, + authTokenVisible: true, + lastSeenAt: profile.cookiesUpdatedAt, + summary: + "The connected profile holds an X login" + + (profile.cookiesUpdatedAt ? ` (cookies exported ${when(profile.cookiesUpdatedAt)}).` : "."), + }; + } + return { + ...base, + authTokenVisible: false, + lastSeenAt: profile.cookiesUpdatedAt, + summary: profile.hasProfile + ? "The profile is not logged in to X (no auth_token in its exported cookies). Connect again, or choose the browser login." + : "No profile is connected. Connect an X account, or choose the browser login.", + }; + } + + const read = await xCookiesFromBrowser(resolved.browserSpec, opts); + if (!read.ok) { + return { ...base, authTokenVisible: null, summary: read.message }; + } + // Where the read looked, as gallery-dl looks (see firefoxContainerScope). + const scope = !read.container || read.container === "none" + ? `${read.browser}, outside its containers (where gallery-dl reads)` + : read.container === "all" + ? `${read.browser}, in any container` + : `${read.browser}'s container "${read.container}"`; + const newest = (list: XBrowserCookie[]) => + list.filter(isXComAuth).sort((a, b) => (b.lastAccessedMs ?? 0) - (a.lastAccessedMs ?? 0))[0]; + const auth = newest(read.cookies); + const oldDomainCookie = read.cookies.some(isOldDomainAuth); + const oldDomainLine = oldDomainCookie + ? " An old-domain cookie is present too (an auth_token for twitter.com); gallery-dl does not use it." + : ""; + if (!auth) { + return { + ...base, + authTokenVisible: false, + oldDomainCookie, + otherContainerLogin: read.otherScopeAuth, + summary: + `No X login in ${scope}: no auth_token cookie for x.com.` + + (read.otherScopeAuth + ? ` One is in another Firefox container; gallery-dl reads it only when cookiesFromBrowser ` + + `names that container (${read.browser}::<name>) or ${read.browser}::all.` + : " Log in to x.com in that browser.") + + oldDomainLine, + }; + } + const lastSeenAt = auth.lastAccessedMs ? new Date(auth.lastAccessedMs).toISOString() : undefined; + const walOnly = !read.mainFile.some(isXComAuth); + return { + ...base, + authTokenVisible: true, + lastSeenAt, + walOnly, + oldDomainCookie, + otherContainerLogin: read.otherScopeAuth, + summary: + `An X login is visible in ${scope}` + + (lastSeenAt ? `; its auth_token was last used ${when(lastSeenAt)}.` : ".") + + (walOnly + ? " Seen in Firefox's write-ahead log only; gallery-dl will see it after Firefox checkpoints (closing Firefox does it)." + : "") + + oldDomainLine, + }; +} diff --git a/common/social/xCookieSource.test.ts b/common/social/xCookieSource.test.ts @@ -0,0 +1,102 @@ +// The X login source and its read-time default (release 16 slice XL). +// +// Run with: pnpm --filter yt-dlp-transcript-common exec tsx --test social/xCookieSource.test.ts + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + isXCookieSource, + parseBrowserSpec, + resolveXCookieSource, + sanitizeSocial, + xCookieSourceLabel, +} from "./xCookieSource"; + +test("the default: browser when cookiesFromBrowser is set and no profile is connected", () => { + assert.deepEqual(resolveXCookieSource({ browserSpec: "firefox", profileConnected: false }), { + source: "browser", + chosen: false, + browserSpec: "firefox", + }); +}); + +test("the default: profile when a profile is connected, even with cookiesFromBrowser set", () => { + assert.deepEqual(resolveXCookieSource({ browserSpec: "firefox", profileConnected: true }), { + source: "profile", + chosen: false, + browserSpec: "firefox", + }); +}); + +test("the default: profile when cookiesFromBrowser is empty", () => { + for (const browserSpec of [undefined, "", " "]) { + assert.deepEqual(resolveXCookieSource({ browserSpec, profileConnected: false }), { + source: "profile", + chosen: false, + browserSpec: undefined, + }); + } +}); + +test("a stored choice always wins over the default", () => { + assert.deepEqual( + resolveXCookieSource({ stored: "profile", browserSpec: "firefox", profileConnected: false }), + { source: "profile", chosen: true, browserSpec: "firefox" }, + ); + assert.deepEqual( + resolveXCookieSource({ stored: "browser", browserSpec: "firefox", profileConnected: true }), + { source: "browser", chosen: true, browserSpec: "firefox" }, + ); + // Chosen with nothing to read: still the browser, and the label says why it + // will not log in. + const r = resolveXCookieSource({ stored: "browser", browserSpec: "", profileConnected: true }); + assert.deepEqual(r, { source: "browser", chosen: true, browserSpec: undefined }); + assert.equal(xCookieSourceLabel(r), "Browser login (no cookiesFromBrowser set)"); +}); + +test("the labels", () => { + assert.equal( + xCookieSourceLabel({ source: "browser", chosen: false, browserSpec: "firefox" }), + "Browser login (firefox)", + ); + assert.equal(xCookieSourceLabel({ source: "profile", chosen: true }), "Connected profile"); +}); + +test("isXCookieSource and sanitizeSocial", () => { + assert.equal(isXCookieSource("browser"), true); + assert.equal(isXCookieSource("profile"), true); + assert.equal(isXCookieSource("firefox"), false); + assert.equal(isXCookieSource(undefined), false); + assert.deepEqual(sanitizeSocial(undefined), { x: {} }); + assert.deepEqual(sanitizeSocial({ x: { cookieSource: "profile" } }), { x: { cookieSource: "profile" } }); + assert.deepEqual(sanitizeSocial({ x: { cookieSource: "auto" } }), { x: {} }); +}); + +test("parseBrowserSpec reads yt-dlp's and gallery-dl's syntax", () => { + assert.deepEqual(parseBrowserSpec("firefox"), { browser: "firefox" }); + assert.deepEqual(parseBrowserSpec("Firefox"), { browser: "firefox" }); + assert.deepEqual(parseBrowserSpec("chrome:Default"), { browser: "chrome", profile: "Default" }); + assert.deepEqual(parseBrowserSpec("firefox:/home/u/.mozilla/firefox/abc.default"), { + browser: "firefox", + profile: "/home/u/.mozilla/firefox/abc.default", + }); + assert.deepEqual(parseBrowserSpec("firefox::Work"), { browser: "firefox", container: "Work" }); + assert.deepEqual(parseBrowserSpec("firefox:abc.default::none"), { + browser: "firefox", + profile: "abc.default", + container: "none", + }); + assert.deepEqual(parseBrowserSpec("chromium+gnomekeyring:Profile 1"), { + browser: "chromium", + keyring: "gnomekeyring", + profile: "Profile 1", + }); + // gallery-dl's /DOMAIN. + assert.deepEqual(parseBrowserSpec("firefox/.x.com:default"), { + browser: "firefox", + domain: ".x.com", + profile: "default", + }); + assert.equal(parseBrowserSpec(""), null); + assert.equal(parseBrowserSpec(":profile"), null); +}); diff --git a/common/social/xCookieSource.ts b/common/social/xCookieSource.ts @@ -0,0 +1,131 @@ +// WHERE THE X FETCHERS' LOGIN COMES FROM — one setting, `social.x.cookieSource` +// (release 16 slice XL). +// +// "browser" — the operator's everyday browser, named by `cookiesFromBrowser` +// (the same spec yt-dlp reads). gallery-dl is handed +// `--cookies-from-browser <spec>` and reads the browser's store +// itself on every run; the other X paths read the same store +// through xCookiesFromBrowser (xBrowserLogin.ts). Nothing expires +// while the operator stays logged in to x.com there. +// "profile" — the session broker's persistent profile (xSessionBroker.ts): +// "Connect X account" once, then the jar it exports. +// +// THE DEFAULT IS RESOLVED AT READ TIME, never stored: with no +// `social.x.cookieSource` in settings.json, the source is "browser" when +// `cookiesFromBrowser` is set and no profile is connected (no exported jar +// carrying an auth_token), else "profile". Choosing a source stores it, and a +// stored choice always wins. +// +// Pure and client-safe — no node imports. The settings schema, the fetch +// controller and the editor's Settings section all read it. + +export type XCookieSource = "browser" | "profile"; + +export const X_COOKIE_SOURCES: readonly XCookieSource[] = ["browser", "profile"]; + +export function isXCookieSource(v: unknown): v is XCookieSource { + return v === "browser" || v === "profile"; +} + +// The `social` block of settings.json. Only X has a login to choose today; the +// block is per platform so a second one does not need a second top-level key. +export type XSocialSettings = { + // Absent = the read-time default above. + cookieSource?: XCookieSource; +}; + +export type SocialSettings = { + x: XSocialSettings; +}; + +// Total over `unknown`, as every settings coercion is: anything that is not a +// known source reads as absent (the default), and unknown keys are dropped. +export function sanitizeSocial(value: unknown): SocialSettings { + const r = (value && typeof value === "object" && !Array.isArray(value) + ? value + : {}) as Record<string, unknown>; + const x = (r.x && typeof r.x === "object" && !Array.isArray(r.x) + ? r.x + : {}) as Record<string, unknown>; + return { + x: isXCookieSource(x.cookieSource) ? { cookieSource: x.cookieSource } : {}, + }; +} + +export type ResolvedXCookieSource = { + source: XCookieSource; + // True when settings.json names the source; false when the read-time default + // decided it. + chosen: boolean; + // The browser spec the "browser" source reads (`cookiesFromBrowser`, a + // channel's own when it has one), or undefined when none is set. + browserSpec?: string; +}; + +export function resolveXCookieSource(input: { + stored?: XCookieSource; + browserSpec?: string; + // The session broker's profile holds a login: its exported jar carries an + // auth_token (readXSessionStatus().looksAuthenticated). + profileConnected: boolean; +}): ResolvedXCookieSource { + const browserSpec = input.browserSpec?.trim() || undefined; + if (isXCookieSource(input.stored)) { + return { source: input.stored, chosen: true, browserSpec }; + } + return { + source: browserSpec && !input.profileConnected ? "browser" : "profile", + chosen: false, + browserSpec, + }; +} + +// A browser cookie spec, as `cookiesFromBrowser` holds it. yt-dlp's syntax is +// BROWSER[+KEYRING][:PROFILE][::CONTAINER]; gallery-dl's adds /DOMAIN after the +// browser name. Both programs get the spec verbatim — this parse is only for +// the readers here, which need the browser, the profile and the container. +export type BrowserSpec = { + browser: string; + domain?: string; + keyring?: string; + profile?: string; + container?: string; +}; + +const SPEC_RE = + /^(?<browser>[^/+:]+)(?:\/(?<domain>[^+:]+))?(?:\s*\+\s*(?<keyring>[^:]+))?(?:\s*:\s*(?!:)(?<profile>.+?))?(?:\s*::\s*(?<container>.+))?$/; + +export function parseBrowserSpec(spec: string): BrowserSpec | null { + const m = SPEC_RE.exec(spec.trim()); + if (!m?.groups) return null; + const g = m.groups; + const out: BrowserSpec = { browser: g.browser.trim().toLowerCase() }; + if (g.domain) out.domain = g.domain.trim(); + if (g.keyring) out.keyring = g.keyring.trim(); + if (g.profile) out.profile = g.profile.trim(); + if (g.container) out.container = g.container.trim(); + return out; +} + +// The browsers whose cookie store this repo reads itself (xBrowserLogin.ts). +// Every other browser gallery-dl and yt-dlp support (the Chromium family keeps +// its cookies encrypted with a desktop-keyring key) is read by those programs +// alone; the Playwright fallback and the Settings check say so instead. +export const SELF_READ_BROWSERS: readonly string[] = ["firefox"]; + +// The label the Settings section and the logs use for a source. +export function xCookieSourceLabel(r: ResolvedXCookieSource): string { + if (r.source === "browser") { + return r.browserSpec + ? `Browser login (${r.browserSpec})` + : "Browser login (no cookiesFromBrowser set)"; + } + return "Connected profile"; +} + +// A resolved source with its label: what the Settings section renders. +export type XCookieSourceView = ResolvedXCookieSource & { label: string }; + +export function xCookieSourceView(r: ResolvedXCookieSource): XCookieSourceView { + return { ...r, label: xCookieSourceLabel(r) }; +} diff --git a/common/social/xGalleryDlFetcher.test.ts b/common/social/xGalleryDlFetcher.test.ts @@ -0,0 +1,86 @@ +// gallery-dl's argv per X login source (release 16 slice XL). The rest of +// gallery-dl's argv and parsing is covered in xNormalize.test.ts. +// +// Run with: pnpm --filter yt-dlp-transcript-common exec tsx --test social/xGalleryDlFetcher.test.ts + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { buildGalleryDlArgs, galleryDlCookieChoice } from "./xGalleryDlFetcher"; + +const ACCOUNT = "https://x.com/someaccount"; +const JAR = "/corpus/.x-session/cookies.txt"; + +function argvFor(choice: ReturnType<typeof galleryDlCookieChoice>): string[] { + return buildGalleryDlArgs({ + accountUrl: ACCOUNT, + cookies: choice.cookies, + cookieFile: choice.cookieFile, + }); +} + +function flagValue(argv: string[], flag: string): string | undefined { + const i = argv.indexOf(flag); + return i >= 0 ? argv[i + 1] : undefined; +} + +test("browser source: --cookies-from-browser with the spec, never the jar", () => { + const argv = argvFor( + galleryDlCookieChoice({ source: "browser", browserCookies: "firefox", jarFile: JAR }), + ); + assert.equal(flagValue(argv, "--cookies-from-browser"), "firefox"); + assert.equal(argv.includes("--cookies"), false); + assert.equal(argv[argv.length - 1], "https://x.com/someaccount/timeline"); +}); + +test("browser source: the spec is passed verbatim (profile, container, gallery-dl's /DOMAIN)", () => { + for (const spec of ["firefox:abc.default-release", "firefox::Work", "chromium+gnomekeyring:Default", "firefox/.x.com"]) { + const argv = argvFor(galleryDlCookieChoice({ source: "browser", browserCookies: spec })); + assert.equal(flagValue(argv, "--cookies-from-browser"), spec); + } +}); + +test("browser source: passed whatever the cookieMode — the source governs X", () => { + // No "always"-mode spec (cookieMode when-required or defer), still passed. + const choice = galleryDlCookieChoice({ + source: "browser", + browserCookies: "firefox", + alwaysCookies: undefined, + }); + assert.equal(choice.cookies, "firefox"); + assert.match(choice.note, /the browser \(firefox\)/); +}); + +test("browser source with no spec: a guest run that says why", () => { + const choice = galleryDlCookieChoice({ source: "browser", browserCookies: " ", jarFile: JAR }); + const argv = argvFor(choice); + assert.equal(argv.includes("--cookies-from-browser"), false); + assert.equal(argv.includes("--cookies"), false); + assert.match(choice.note, /cookiesFromBrowser is empty/); +}); + +test("profile source: the jar, and no --cookies-from-browser", () => { + const argv = argvFor( + galleryDlCookieChoice({ source: "profile", browserCookies: "firefox", jarFile: JAR, alwaysCookies: "firefox" }), + ); + assert.equal(flagValue(argv, "--cookies"), JAR); + assert.equal(argv.includes("--cookies-from-browser"), false); +}); + +test("profile source with no logged-in jar: the cookieMode \"always\" spec, else a guest", () => { + const always = argvFor(galleryDlCookieChoice({ source: "profile", browserCookies: "firefox", alwaysCookies: "firefox" })); + assert.equal(flagValue(always, "--cookies-from-browser"), "firefox"); + + const guest = galleryDlCookieChoice({ source: "profile", browserCookies: "firefox" }); + const argv = argvFor(guest); + assert.equal(argv.includes("--cookies-from-browser"), false); + assert.equal(argv.includes("--cookies"), false); + assert.match(guest.note, /guest/); +}); + +test("no source resolved (a caller from before the choice): the jar, else the always-mode spec", () => { + assert.equal(flagValue(argvFor(galleryDlCookieChoice({ jarFile: JAR, alwaysCookies: "firefox" })), "--cookies"), JAR); + assert.equal( + flagValue(argvFor(galleryDlCookieChoice({ alwaysCookies: "firefox" })), "--cookies-from-browser"), + "firefox", + ); +}); diff --git a/common/social/xGalleryDlFetcher.ts b/common/social/xGalleryDlFetcher.ts @@ -29,6 +29,7 @@ import { getPaths } from "../lib/paths"; import type { Post } from "../lib/posts"; import { normalizeXTweets, type XTweetRaw } from "./xNormalize"; import { readXSessionStatus, xCookieFile } from "./xSessionBroker"; +import type { XCookieSource } from "./xCookieSource"; import { registerSocialFetcher, type PostFetchInput, @@ -129,6 +130,50 @@ export function buildGalleryDlArgs(opts: { return args; } +// WHICH LOGIN gallery-dl is handed this run (release 16 slice XL), pure so the +// argv per source is testable: +// "browser" — `--cookies-from-browser <spec>`, always (gallery-dl reads the +// operator's browser itself, fresh on every run); never the jar. +// With no spec, a guest run that says why. +// "profile" — the broker's jar when it holds a login, else the cookieMode +// "always" spec, else a guest run. +// unset — the same as "profile": a caller that resolves no source keeps +// the behaviour from before the choice existed. +export function galleryDlCookieChoice(opts: { + source?: XCookieSource; + // The spec the browser source reads (channel over global, any cookieMode). + browserCookies?: string; + // The broker's exported jar, when it carries an auth_token. + jarFile?: string; + // resolveCookiePolicy()'s "always"-mode spec. + alwaysCookies?: string; +}): { cookies?: string; cookieFile?: string; note: string } { + if (opts.source === "browser") { + const spec = opts.browserCookies?.trim(); + if (spec) { + return { cookies: spec, note: `X login: the browser (${spec}), read by gallery-dl.` }; + } + return { + note: + "X login: the browser source is chosen but cookiesFromBrowser is empty — " + + "running as a guest.", + }; + } + if (opts.jarFile) { + return { + cookieFile: opts.jarFile, + note: "X login: the connected profile (the X session broker's exported cookies).", + }; + } + if (opts.alwaysCookies) { + return { + cookies: opts.alwaysCookies, + note: `X login: no connected profile; the cookieMode "always" browser (${opts.alwaysCookies}).`, + }; + } + return { note: "X login: none (no connected profile) — running as a guest." }; +} + // X ids are 64-bit and gallery-dl emits them as UNQUOTED JSON NUMBERS // (`"tweet_id": 2085320225776427457`). That exceeds 2^53, so a plain // JSON.parse silently rounds it — 2085320225776427457 becomes @@ -256,19 +301,25 @@ export const xGalleryDlFetcher: SocialFetcher = { }, async fetch(input: PostFetchInput): Promise<PostFetchResult> { - const { accountUrl, channelSlug, since, seenIds, cookies, limit, signal, onLog } = + const { accountUrl, channelSlug, since, seenIds, limit, signal, onLog } = input; const paths = getPaths(); const bin = paths.galleryDlBin; - // Prefer the session broker's exported jar when one exists — it is kept - // fresh by a live browser profile, so it survives the cookie expiry that - // otherwise breaks gallery-dl within days. + // The login source (galleryDlCookieChoice): the operator's browser, or the + // session broker's exported jar — kept fresh by a live browser profile, so + // it survives the cookie expiry that otherwise breaks gallery-dl within days. const status = await readXSessionStatus(paths); - const cookieFile = - status.hasCookies && status.looksAuthenticated - ? xCookieFile(paths) - : undefined; - if (cookieFile) onLog?.("Using the X session broker's exported cookies."); + const choice = galleryDlCookieChoice({ + source: input.cookieSource, + browserCookies: input.browserCookies, + jarFile: + status.hasCookies && status.looksAuthenticated + ? xCookieFile(paths) + : undefined, + alwaysCookies: input.cookies, + }); + onLog?.(choice.note); + const { cookies, cookieFile } = choice; const args = buildGalleryDlArgs({ accountUrl, cookies, cookieFile, limit }); onLog?.(`Running ${bin} for ${accountUrl}`); diff --git a/common/social/xPlaywrightFetcher.ts b/common/social/xPlaywrightFetcher.ts @@ -11,7 +11,8 @@ // // Known costs, accepted deliberately: // - Playwright Chromium's JA3/TLS fingerprint matches no real Chrome release -// (plus navigator.webdriver and CDP artifacts), so X CAN detect it. +// (plus the HeadlessChrome user agent and CDP artifacts; navigator.webdriver +// is off since release 16 slice XL, xBrowser.ts), so X CAN detect it. // - Ban risk on the logged-in account is higher than an offline cookie read. // - A browser process per fetch is slow and RAM-hungry. // - It will NOT run in the minimal Docker build container — post fetching @@ -31,9 +32,15 @@ import type { Post } from "../lib/posts"; import { normalizeXTweets, type XTweetRaw } from "./xNormalize"; -import { xProfileDir } from "./xSessionBroker"; +import { launchXProfile } from "./xSessionBroker"; import { getPaths } from "../lib/paths"; -import { importPlaywright } from "./playwrightRuntime"; +import { + importPlaywright, + type BrowserContextLike, + type BrowserLike, +} from "./playwrightRuntime"; +import { buildXBrowserLaunchOptions } from "./xBrowser"; +import { isXComAuth, xCookiesFromBrowser } from "./xBrowserLogin"; import { registerSocialFetcher, type PostFetchInput, @@ -151,11 +158,35 @@ export const xPlaywrightFetcher: SocialFetcher = { const { accountUrl, channelSlug, since, seenIds, limit, signal, onLog } = input; const paths = getPaths(); - const { chromium } = await importPlaywright(); - onLog?.("Launching the authenticated browser profile (fallback path)."); - const context = await chromium.launchPersistentContext(xProfileDir(paths), { - headless: true, - }); + // The login source (xCookieSource.ts). "browser": a fresh headless + // context carrying the operator's browser cookies, read now — the browser + // store this repo reads itself is Firefox's (xBrowserLogin.ts); any other + // is refused by name rather than run logged out. Otherwise the broker's + // persistent profile, as before. + let context: BrowserContextLike; + let browser: BrowserLike | undefined; + if (input.cookieSource === "browser") { + const read = await xCookiesFromBrowser(input.browserCookies); + if (!read.ok) { + throw new Error(`The X login source is the browser, and it cannot be read here: ${read.message}`); + } + const hasAuth = read.cookies.some(isXComAuth); + onLog?.( + `Launching a headless browser with ${read.cookies.length} X cookie(s) from ${read.browser} ` + + `(fallback path)${hasAuth ? "" : " — WARNING: no auth_token for x.com, the browser is not logged in to X"}.`, + ); + const { chromium } = await importPlaywright(); + browser = await chromium.launch( + buildXBrowserLaunchOptions({ browser: { kind: "bundled" }, headless: true }), + ); + context = await browser.newContext({}); + await context.addCookies( + read.cookies.map(({ lastAccessedMs: _unused, ...c }) => c), + ); + } else { + onLog?.("Launching the authenticated browser profile (fallback path)."); + context = await launchXProfile(paths, { onLog }); + } const captured: XTweetRaw[] = []; try { @@ -201,6 +232,7 @@ export const xPlaywrightFetcher: SocialFetcher = { onLog?.(`Captured ${captured.length} tweet record(s) from the timeline.`); } finally { await context.close().catch(() => {}); + await browser?.close().catch(() => {}); } // From here on it is identical to the gallery-dl path — same normalizer, diff --git a/common/social/xSessionBroker.test.ts b/common/social/xSessionBroker.test.ts @@ -0,0 +1,157 @@ +// The headless refresh never replaces a logged-in jar with one without a login +// (release 16 slice XL, review L4). Playwright is replaced by a fake chromium +// that answers per executable: nothing here launches a browser or loads x.com. +// +// Run with: pnpm --filter yt-dlp-transcript-common exec tsx --test social/xSessionBroker.test.ts + +import { test, after } from "node:test"; +import assert from "node:assert/strict"; +import { chmodSync, mkdtempSync, writeFileSync } from "node:fs"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { Paths } from "../lib/paths"; +import type { ChromiumLike } from "./playwrightRuntime"; +import { writeXBrowserRecord } from "./xBrowser"; +import { refreshXCookies, xCookieFile, xProfileDir } from "./xSessionBroker"; + +const TMP = mkdtempSync(path.join(os.tmpdir(), "xl-broker-")); +after(() => rm(TMP, { recursive: true, force: true })); + +// A stand-in for the system browser the profile records: it only has to be an +// executable file. +const SYSTEM_EXE = path.join(TMP, "chromium"); +writeFileSync(SYSTEM_EXE, "#!/bin/sh\nexit 0\n"); +chmodSync(SYSTEM_EXE, 0o755); + +type Cookie = { name: string; value: string; domain: string; path: string; expires: number; httpOnly: boolean; secure: boolean }; +const cookie = (name: string, value: string): Cookie => ({ + name, + value, + domain: ".x.com", + path: "/", + expires: 1_900_000_000, + httpOnly: true, + secure: true, +}); + +// Answers launchPersistentContext by executable: "bundled" when the options +// name none. An Error answer throws, as a launch that cannot open the profile. +function fakeChromium(answers: Record<string, Cookie[] | Error>) { + const launches: string[] = []; + const chromium = { + launch: async () => { + throw new Error("not used"); + }, + launchPersistentContext: async (_dir: string, opts: Record<string, unknown>) => { + const who = typeof opts.executablePath === "string" ? opts.executablePath : "bundled"; + launches.push(who); + const answer = answers[who]; + if (answer instanceof Error) throw answer; + const page = { + goto: async () => undefined, + waitForSelector: async () => undefined, + waitForTimeout: async () => undefined, + evaluate: async () => undefined, + on: () => undefined, + }; + return { + pages: () => [page], + newPage: async () => page, + cookies: async () => answer ?? [], + addCookies: async () => undefined, + close: async () => undefined, + on: () => undefined, + }; + }, + } as unknown as ChromiumLike; + return { chromium, launches }; +} + +let n = 0; +async function corpus(opts: { jar?: "logged-in" | "logged-out"; recorded?: boolean }) { + const paths = { transcriptsDir: path.join(TMP, `corpus-${++n}`) } as Paths; + await mkdir(xProfileDir(paths), { recursive: true }); + if (opts.recorded) { + await writeXBrowserRecord( + xProfileDir(paths), + { kind: "system", executablePath: SYSTEM_EXE, from: "path" }, + "Chromium 153", + ); + } + if (opts.jar) { + await writeFile( + xCookieFile(paths), + "# Netscape HTTP Cookie File\n" + + (opts.jar === "logged-in" + ? ".x.com\tTRUE\t/\tTRUE\t1900000000\tauth_token\tOLD-LOGIN\n" + : ".x.com\tTRUE\t/\tTRUE\t1900000000\tguest_id\tg\n"), + ); + } + return paths; +} + +const jarText = (paths: Paths) => readFile(xCookieFile(paths), "utf8"); + +test("the bundled read has the login: written, the recorded browser never launched", async () => { + const paths = await corpus({ jar: "logged-in", recorded: true }); + const { chromium, launches } = fakeChromium({ bundled: [cookie("auth_token", "FRESH")] }); + const status = await refreshXCookies(paths, { chromium }); + assert.deepEqual(launches, ["bundled"]); + assert.equal(status.looksAuthenticated, true); + assert.match(await jarText(paths), /auth_token\tFRESH/); +}); + +test("an EMPTY bundled read of a logged-in profile is read again with the recorded browser", async () => { + const paths = await corpus({ jar: "logged-in", recorded: true }); + const { chromium, launches } = fakeChromium({ + bundled: [], + [SYSTEM_EXE]: [cookie("auth_token", "FROM-SYSTEM"), cookie("ct0", "c")], + }); + const lines: string[] = []; + await refreshXCookies(paths, { chromium, onLog: (l) => lines.push(l) }); + assert.deepEqual(launches, ["bundled", SYSTEM_EXE]); + assert.match(await jarText(paths), /auth_token\tFROM-SYSTEM/); + assert.ok(lines.some((l) => /read no X login from a profile whose exported jar holds one/.test(l))); +}); + +test("no read finds the login: the logged-in jar is KEPT and the refresh refuses", async () => { + const paths = await corpus({ jar: "logged-in", recorded: true }); + const before = await jarText(paths); + const { chromium, launches } = fakeChromium({ bundled: [cookie("guest_id", "g")], [SYSTEM_EXE]: [] }); + await assert.rejects( + refreshXCookies(paths, { chromium }), + /The profile gave no X login \(no auth_token\), so the logged-in cookie jar exported .* was kept, not replaced\./, + ); + assert.deepEqual(launches, ["bundled", SYSTEM_EXE]); + assert.equal(await jarText(paths), before); +}); + +test("no record to fall back on: the logged-in jar is kept all the same", async () => { + const paths = await corpus({ jar: "logged-in" }); + const before = await jarText(paths); + const { chromium, launches } = fakeChromium({ bundled: [] }); + await assert.rejects(refreshXCookies(paths, { chromium }), /was kept, not replaced/); + assert.deepEqual(launches, ["bundled"]); + assert.equal(await jarText(paths), before); +}); + +test("a jar without a login is replaced as before (nothing to protect)", async () => { + const paths = await corpus({ jar: "logged-out", recorded: true }); + const { chromium, launches } = fakeChromium({ bundled: [cookie("guest_id", "new")] }); + const status = await refreshXCookies(paths, { chromium }); + assert.deepEqual(launches, ["bundled"]); + assert.equal(status.looksAuthenticated, false); + assert.match(await jarText(paths), /guest_id\tnew/); +}); + +test("the bundled build cannot open the profile: the recorded browser reads it", async () => { + const paths = await corpus({ jar: "logged-in", recorded: true }); + const { chromium, launches } = fakeChromium({ + bundled: new Error("profile is from a newer version"), + [SYSTEM_EXE]: [cookie("auth_token", "VIA-RECORD")], + }); + await refreshXCookies(paths, { chromium }); + assert.deepEqual(launches, ["bundled", SYSTEM_EXE]); + assert.match(await jarText(paths), /auth_token\tVIA-RECORD/); +}); diff --git a/common/social/xSessionBroker.ts b/common/social/xSessionBroker.ts @@ -18,12 +18,31 @@ // installed, so this adds NO new dependency. It will NOT run in the minimal // Docker build container — post fetching is an editor-host concern, never a // build-time one. +// +// Release 16 slice XL: the Connect window is the operator's own browser +// without the automation signals (xBrowser.ts), and the profile is one of two +// login sources — the other is the operator's everyday browser, read directly +// (`social.x.cookieSource`, xCookieSource.ts / xBrowserLogin.ts). import path from "node:path"; import { mkdir, readFile, rm, stat } from "node:fs/promises"; +import { execa } from "execa"; import { writeFileAtomic } from "../lib/jsonFile-server"; import type { Paths } from "../lib/paths"; -import { importPlaywright } from "./playwrightRuntime"; +import { + importPlaywright, + type BrowserContextLike, + type ChromiumLike, +} from "./playwrightRuntime"; +import { + buildXBrowserLaunchOptions, + describeXBrowser, + findXBrowser, + readXBrowserRecord, + recordedXBrowser, + writeXBrowserRecord, + type XBrowserChoice, +} from "./xBrowser"; // Where the persistent browser profile lives. One profile per instance: a // single X identity is all the archive needs. @@ -129,26 +148,71 @@ export function isXCookie(c: BrowserCookie): boolean { return d === "x.com" || d === "twitter.com" || d.endsWith(".x.com") || d.endsWith(".twitter.com"); } +// `<exe> --version` ("Chromium 153.0.8010.47 Arch Linux"), for the log and the +// profile's record. Best effort: a browser that does not answer in 5 s is +// simply not versioned. +async function browserVersion(b: XBrowserChoice): Promise<string | undefined> { + if (b.kind !== "system") return undefined; + try { + const res = await execa(b.executablePath, ["--version"], { reject: false, timeout: 5_000 }); + const line = `${res.stdout ?? ""}`.trim().split("\n")[0]; + return res.exitCode === 0 && line ? line : undefined; + } catch { + return undefined; + } +} + +function firstLine(err: unknown): string { + return ((err as Error)?.message ?? String(err)).split("\n")[0]; +} + // Launch a HEADED browser against the persistent profile so a human can log in // (password, 2FA, captcha — all of it). Resolves once the caller closes the // window, then exports the cookie jar. // +// The window is the operator's own browser without the automation signals +// (xBrowser.ts says which, and why Google's sign-in refused the old one). The +// executable is recorded in the profile, so a refresh that cannot open the +// profile with the bundled build can use the one that wrote it. +// // Playwright is imported dynamically: this module must stay importable on a // host with no browsers installed (the Docker build image), where only the // status/read helpers above are ever called. export async function connectXAccount( paths: Paths, - opts: { onLog?: (line: string) => void; timeoutMs?: number } = {}, -): Promise<XSessionStatus> { + opts: { + onLog?: (line: string) => void; + timeoutMs?: number; + env?: Record<string, string | undefined>; + } = {}, +): Promise<XSessionStatus & { browser: string }> { const log = opts.onLog ?? (() => {}); const profileDir = xProfileDir(paths); await mkdir(profileDir, { recursive: true }); + const browser = findXBrowser({ env: opts.env }); + const version = await browserVersion(browser); + const label = describeXBrowser(browser, version); const { chromium } = await importPlaywright(); - log("Opening a browser window. Log in to X, then close the window."); - const context = await chromium.launchPersistentContext(profileDir, { - headless: false, - viewport: { width: 1280, height: 900 }, + log(`Opening ${label}. Log in to X, then close the window.`); + let context: BrowserContextLike; + try { + context = await chromium.launchPersistentContext( + profileDir, + buildXBrowserLaunchOptions({ browser, headless: false, sandbox: true }), + ); + } catch (err) { + // A host that cannot start Chromium's sandbox (no unprivileged user + // namespaces, an AppArmor rule) still gets its window — with the + // unsupported-flag bar a system Chrome draws for `--no-sandbox`. + log(`The sandboxed launch failed (${firstLine(err)}); opening without the sandbox.`); + context = await chromium.launchPersistentContext( + profileDir, + buildXBrowserLaunchOptions({ browser, headless: false, sandbox: false }), + ); + } + await writeXBrowserRecord(profileDir, browser, version).catch((err) => { + log(`Could not record the browser in the profile: ${firstLine(err)}`); }); try { const page = context.pages()[0] ?? (await context.newPage()); @@ -170,40 +234,135 @@ export async function connectXAccount( } finally { await context.close().catch(() => {}); } - return readXSessionStatus(paths); + return { ...(await readXSessionStatus(paths)), browser: label }; } -// Re-export cookies from the stored profile WITHOUT any human interaction — -// this is the call gallery-dl's cookie refresh actually uses. Runs headless. -export async function refreshXCookies( +// THE PROFILE, OPENED HEADLESS — the Playwright fallback fetcher (profile +// source) comes through here, and the refresh below reads the profile the same +// way. The bundled build first (it never logs in, and its headless shell needs +// no display), without the automation signals; when it cannot open the profile +// and the profile records a system executable, that one, headless. +// +// `chromium` is injectable so the choice of browser is testable without one. +type ProfileLaunchOpts = { onLog?: (line: string) => void; chromium?: ChromiumLike }; + +const BUNDLED: XBrowserChoice = { kind: "bundled" }; + +function openProfile( + chromium: ChromiumLike, + profileDir: string, + browser: XBrowserChoice, +): Promise<BrowserContextLike> { + return chromium.launchPersistentContext( + profileDir, + buildXBrowserLaunchOptions({ browser, headless: true }), + ); +} + +export async function launchXProfile( paths: Paths, - opts: { onLog?: (line: string) => void } = {}, -): Promise<XSessionStatus> { + opts: ProfileLaunchOpts = {}, +): Promise<BrowserContextLike> { const log = opts.onLog ?? (() => {}); const profileDir = xProfileDir(paths); - if (!(await exists(profileDir))) { - throw new Error( - "No X session profile yet — run the headed 'Connect X account' flow first.", + const chromium = opts.chromium ?? (await importPlaywright()).chromium; + try { + const context = await openProfile(chromium, profileDir, BUNDLED); + log("Opened the X session profile with Playwright's bundled Chromium (headless)."); + return context; + } catch (err) { + const record = await readXBrowserRecord(profileDir); + const recorded = recordedXBrowser(record); + if (!recorded) throw err; + log( + `Playwright's bundled Chromium could not open the profile (${firstLine(err)}); ` + + `using ${describeXBrowser(recorded, record?.version)}, headless.`, ); + return openProfile(chromium, profileDir, recorded); } - const { chromium } = await importPlaywright(); - const context = await chromium.launchPersistentContext(profileDir, { - headless: true, - }); +} + +// One headless pass: open the profile, touch x.com/home (X rotates/renews the +// session cookies on a visit, which is the whole point of keeping a live +// profile), read X's cookies, close. +async function exportProfileCookies( + chromium: ChromiumLike, + profileDir: string, + browser: XBrowserChoice, + log: (line: string) => void, +): Promise<BrowserCookie[]> { + const context = await openProfile(chromium, profileDir, browser); try { - // Touching the site lets X rotate/renew the session cookies before we read - // them, which is the whole point of keeping a live profile. const page = context.pages()[0] ?? (await context.newPage()); await page .goto("https://x.com/home", { waitUntil: "domcontentloaded", timeout: 45_000 }) .catch(() => { log("Could not load x.com/home; exporting whatever the profile holds."); }); - const cookies = (await context.cookies()) as BrowserCookie[]; - await writeCookieJar(paths, cookies.filter(isXCookie), log); + return ((await context.cookies()) as BrowserCookie[]).filter(isXCookie); } finally { await context.close().catch(() => {}); } +} + +const hasAuthCookie = (cookies: ReadonlyArray<BrowserCookie>) => + cookies.some((c) => c.name === AUTH_COOKIE); + +// Re-export cookies from the stored profile WITHOUT any human interaction — +// this is the call gallery-dl's cookie refresh actually uses. Runs headless. +// +// A LOGGED-IN JAR IS NEVER REPLACED BY ONE WITHOUT A LOGIN. A profile written +// by a newer system browser can open in the bundled build without an error and +// without its cookies (a store that build cannot read). So when the bundled +// read comes back with no auth_token while the jar holds one, the profile is +// read again with the browser that wrote it (the record); and when no read +// finds the login, the jar is kept and the refresh refuses, saying so. +export async function refreshXCookies( + paths: Paths, + opts: ProfileLaunchOpts = {}, +): Promise<XSessionStatus> { + const log = opts.onLog ?? (() => {}); + const profileDir = xProfileDir(paths); + if (!(await exists(profileDir))) { + throw new Error( + "No X session profile yet — run the headed 'Connect X account' flow first.", + ); + } + const chromium = opts.chromium ?? (await importPlaywright()).chromium; + const before = await readXSessionStatus(paths); + const record = await readXBrowserRecord(profileDir); + const recorded = recordedXBrowser(record); + const recordedLabel = recorded ? describeXBrowser(recorded, record?.version) : ""; + + let cookies: BrowserCookie[]; + let readWithRecorded = false; + try { + cookies = await exportProfileCookies(chromium, profileDir, BUNDLED, log); + log("Read the X session profile with Playwright's bundled Chromium (headless)."); + } catch (err) { + if (!recorded) throw err; + log( + `Playwright's bundled Chromium could not open the profile (${firstLine(err)}); ` + + `using ${recordedLabel}, headless.`, + ); + cookies = await exportProfileCookies(chromium, profileDir, recorded, log); + readWithRecorded = true; + } + if (!hasAuthCookie(cookies) && before.looksAuthenticated && recorded && !readWithRecorded) { + log( + "Playwright's bundled Chromium read no X login from a profile whose exported jar holds " + + `one; reading it again with ${recordedLabel}, headless.`, + ); + cookies = await exportProfileCookies(chromium, profileDir, recorded, log); + } + if (!hasAuthCookie(cookies) && before.looksAuthenticated) { + throw new Error( + "The profile gave no X login (no auth_token), so the logged-in cookie jar" + + (before.cookiesUpdatedAt ? ` exported ${before.cookiesUpdatedAt}` : "") + + " was kept, not replaced. If the X session has ended, Connect again (or Forget session).", + ); + } + await writeCookieJar(paths, cookies, log); return readXSessionStatus(paths); } diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -15,6 +15,7 @@ - **A site's Charts tab gives a sixth series its own colour.** The dashboard's charts coloured their series from five colours and started again at the sixth, so a chart broken down by six or more channels drew the sixth in the first one's colour. The sixth now takes the palette's sixth colour, and each from the seventh on a hue of its own; the first five are unchanged. The published sites' charts get the same change with their next build. - **A form whose save is refused keeps what you typed.** Every editor form put its plain fields back to the stored values when its save was refused — a site's ID rejected, a page size out of range, a slug already taken — so everything typed had to be typed again. A refused save now leaves every field as you left it, beside the reason: **Settings**; a site's form (new and existing); the hub's config on `/sites`; **Cut release**; a channel's form (new and **Configure**), **Rename** and **Delete**; a video's **Delete directory**; **Drive health timing** on `/storage`; the backup config on `/saved-videos`; the sync operation's controls; the **Digest**, **Diarization**, **Speaker attribution** and **Speaker work lane** settings; and the worker list on `/workers`. A save that succeeds behaves as before, with one difference you may notice: a drop-down, and a checkbox or choice that the page tracks as you change it (a cadence, a worker's **Enabled**, a social link's **Keep in header**, a site membership, a site's accent), now shows what was saved. A form's own drop-downs used to go back to what the page had loaded with until a reload, and a second save from the same page sent that old choice again; the others went back until the page next refreshed itself (every 5 seconds by default). - **A media move no longer starts over a job that is writing into the channel, holds the channel's writers while it runs, and makes its copy match the source before it verifies — so a transcription or a download during a move cannot fail it.** A move that has waited its turn behind other moves now checks again when it starts: if a job is running on the channel, or an auto-queue lane is working on one of its videos, it stops at once and says which ("a transcription of abc123 is running (Transcribe all, job …) — wait for it or cancel it"), with nothing copied — and a job you have just cancelled counts until it has actually stopped ("is stopping … — wait for it to stop"); **Preview** says the same, and the Storage panel's blocked message now names the job too. While a move's marker stands, the channel is held: every lane skips it, and every job that reads or writes its media (single-video transcriptions, downloads and transcodes and the availability checks now included) refuses to start, including one that was already queued when the move began. The rack shows a **media held** chip in the channel's Tier cell and the Storage panel says "Held: its media is moving"; both go when the move finishes or its marker is cleared. The copy is now followed by a pass that makes the destination copy match the source — files the source no longer has are removed from the copy, never from the source — so a file written or deleted during the copy (a transcriber's scratch folder, say) no longer fails the check, and **Resume move** finishes a move whose copy holds such leftovers. Every file removed from a copy is listed in the move's log, and **Preview** says so when a copy from an earlier attempt is already there. If the source keeps changing, the move stops and lists what differs: extra on the destination, missing there, or changed. A new **Reconcile and resume** button beside **Resume move** lists those differences, makes the copy match and finishes the move, so no file has to be deleted by hand. The saved-video store's move does the same matching and the same check before it starts. Needs a rebuild and restart of the editor. +- **Connecting an X account opens your own browser, and the X fetchers can use your everyday browser's X login instead.** **Settings → X account session → Connect X account** used to open Playwright's bundled Chromium with its automation signals on (the "controlled by automated test software" bar, `navigator.webdriver`): Google's sign-in refused it and X's own login form stalled in it. It now opens your Chromium or Chrome when one is installed (`ARCHILYZER_X_BROWSER` names another; Playwright's bundled Chromium otherwise), without those signals. Google's sign-in may still refuse an embedded browser; X's password login is the reliable path. A new **Login source** choice (`social.x.cookieSource` in `settings.json`) says where the X fetchers' login comes from: **Browser login** hands gallery-dl `--cookies-from-browser` with your `cookiesFromBrowser` on every fetch, so the login lasts as long as you stay logged in to x.com in that browser and no window is needed; **Connected profile** is the session broker, as before. Left on **Automatic**, it is the browser login when `cookiesFromBrowser` is set and no profile is connected, and the profile otherwise. **Check** says which source is in use, whether an X login is visible in it and when it was last used (the browser's cookies are read from a private copy, never written; this reads Firefox's, and gallery-dl reads Chromium's itself). Needs a rebuild and restart of the editor. ## [0.11.0] - 2026-09-30 - **Transcripts that arrived after a video was first seen are counted.** The stats behind the homepage, the hub and every site's charts were cached per video and refreshed only when the video's metadata changed, so a transcript that came later — a Whisper run days after the download, or a video downloaded after the last index build — never reached them, and a video with YouTube captions alone had no transcription date. Counts and charts were low; the homepage could show a site with 0 transcripts, 0 channels and 0 hours while it served its videos. A stat is now also redone whenever the index re-reads the video, every transcript has a date, and a captioned video is dated by when its captions arrived rather than by a later Normalize run, so its place on "Transcribed over time" can move. **After updating, rebuild and restart the editor before anything else:** until then, **Build stats dataset** runs the old code and would undo the new stats, while a site, hub or homepage build already runs the new code — and the first stats build of any kind re-reads every video once (about 10–30 minutes on a large archive; it can be stopped and picks up where it stopped). Then build the index, the stats, the homepage, the hub, and the sites. diff --git a/editor/app/settings/components/XSessionSection.tsx b/editor/app/settings/components/XSessionSection.tsx @@ -1,44 +1,108 @@ "use client"; -// "Connect X account" — the operator-facing half of the X session broker. +// The X account session — where the X fetchers' login comes from, whether a +// login is visible there, and the operator-facing half of the session broker. // -// Why this exists: gallery-dl is the primary X fetcher and its worst flaw is -// that X cookies expire within days. A persistent, logged-in browser profile -// removes that problem without touching the fetch path at all — the fetcher -// just reads a jar this keeps fresh. +// Two sources (release 16 slice XL, common/social/xCookieSource.ts): +// - the operator's everyday browser (`cookiesFromBrowser`): gallery-dl reads +// its X login on every fetch, so nothing expires while the operator stays +// logged in to x.com there — no window at all; +// - a profile connected here once: a persistent, logged-in browser profile +// the fetcher re-exports fresh cookies from, because X cookies otherwise +// expire within days. -import { useState, useTransition } from "react"; +import { useEffect, useState, useTransition } from "react"; import { + checkXLoginAction, clearXSessionAction, connectXAccountAction, refreshXCookiesAction, + setXCookieSourceAction, + type XSessionActionResult, } from "../xSessionActions"; import type { XSessionStatus } from "yt-dlp-transcript-common/social/xSessionBroker"; +import type { XLoginStatus } from "yt-dlp-transcript-common/social/xBrowserLogin"; +import { + resolveXCookieSource, + xCookieSourceLabel, + type XCookieSourceView, +} from "yt-dlp-transcript-common/social/xCookieSource"; -export function XSessionSection({ initial }: { initial: XSessionStatus }) { +export function XSessionSection({ + initial, + initialSource, +}: { + initial: XSessionStatus; + initialSource: XCookieSourceView; +}) { const [status, setStatus] = useState<XSessionStatus>(initial); + const [source, setSource] = useState<XCookieSourceView>(initialSource); + const [login, setLogin] = useState<XLoginStatus | null>(null); const [error, setError] = useState<string | null>(null); const [note, setNote] = useState<string | null>(null); + // Two transitions: the session actions (Connect waits for the window to + // close) and the source controls (Check, the source select). const [pending, startTransition] = useTransition(); + const [checking, startChecking] = useTransition(); + + // A save of cookiesFromBrowser above (or another tab's choice) re-renders the + // page with a new resolved source; take it. + useEffect(() => { + setSource(initialSource); + // Keyed on the values, not the object: every server render makes a new one. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialSource.source, initialSource.chosen, initialSource.browserSpec]); - const run = ( - fn: () => Promise< - { ok: true; status: XSessionStatus } | { ok: false; error: string } - >, - okNote: string, - ) => + const run = (fn: () => Promise<XSessionActionResult>, okNote: (r: { browser?: string }) => string) => startTransition(async () => { setError(null); setNote(null); const res = await fn(); if (res.ok) { setStatus(res.status); - setNote(okNote); + setSource(res.source); + setLogin(null); + setNote(okNote(res)); + } else { + setError(res.error); + } + }); + + const check = () => + startChecking(async () => { + setError(null); + setNote(null); + const res = await checkXLoginAction(); + if (res.ok) { + setLogin(res.login); + setSource(res.login); } else { setError(res.error); } }); + const choose = (choice: string) => + startChecking(async () => { + setError(null); + setNote(null); + const res = await setXCookieSourceAction(choice); + if (res.ok) { + setSource(res.source); + setLogin(null); + setNote("Login source saved."); + } else { + setError(res.error); + } + }); + + // What "Automatic" resolves to right now, for its option's text. + const automatic = xCookieSourceLabel( + resolveXCookieSource({ + browserSpec: source.browserSpec, + profileConnected: status.looksAuthenticated, + }), + ); + return ( <section data-x-session="" @@ -62,12 +126,68 @@ export function XSessionSection({ initial }: { initial: XSessionStatus }) { </div> <p className="text-xs text-muted-foreground"> - X post archiving needs a logged-in session, and X cookies expire within - days. Connecting an account once stores a browser profile on this host; - the fetcher then re-exports fresh cookies from it automatically, so the - session stops being the thing that breaks. + X post archiving needs a logged-in session, from one of two sources.{" "} + <strong>Browser login</strong>: your everyday browser, named by{" "} + <code>cookiesFromBrowser</code> above — gallery-dl reads its X login on + every fetch, so nothing expires while you stay logged in to x.com there, + and no window is needed. <strong>Connected profile</strong>: a browser + profile connected here once; the fetcher re-exports fresh cookies from + it, because X cookies otherwise expire within days. + </p> + + <div className="flex flex-wrap items-center gap-2"> + <label htmlFor="x-cookie-source" className="text-sm"> + Login source + </label> + <select + id="x-cookie-source" + aria-label="x cookie source" + value={source.chosen ? source.source : "auto"} + onChange={(e) => choose(e.target.value)} + disabled={pending || checking} + className="rounded-md border border-border bg-background px-2 py-1 text-sm disabled:opacity-50" + > + <option value="auto">Automatic — now: {automatic}</option> + <option value="browser"> + Browser login{source.browserSpec ? ` (${source.browserSpec})` : ""} + </option> + <option value="profile">Connected profile</option> + </select> + <button + type="button" + onClick={check} + disabled={pending || checking} + aria-label="check x login" + className="rounded-md border border-border px-3 py-1 text-sm hover:bg-muted disabled:opacity-50" + > + {checking ? "Checking…" : "Check"} + </button> + </div> + + <p aria-label="x cookie source in use" className="text-xs text-muted-foreground"> + In use: {source.label} + {source.chosen ? "" : " (automatic)"} </p> + {login && ( + <p + aria-label="x login status" + className={ + "text-xs " + + (login.authTokenVisible === true && !login.walOnly + ? "text-success" + : login.authTokenVisible === false + ? "text-destructive" + : "text-muted-foreground") + } + > + {login.summary}{" "} + <span className="text-muted-foreground"> + (checked {new Date(login.checkedAt).toLocaleTimeString()}) + </span> + </p> + )} + {status.cookiesUpdatedAt && ( <p className="text-xs text-muted-foreground"> Cookies last exported:{" "} @@ -79,7 +199,9 @@ export function XSessionSection({ initial }: { initial: XSessionStatus }) { <button type="button" onClick={() => - run(connectXAccountAction, "Connected. Cookies exported.") + run(connectXAccountAction, (r) => + `Connected${r.browser ? ` with ${r.browser}` : ""}. Cookies exported.`, + ) } disabled={pending} aria-label="connect x account" @@ -89,7 +211,7 @@ export function XSessionSection({ initial }: { initial: XSessionStatus }) { </button> <button type="button" - onClick={() => run(refreshXCookiesAction, "Cookies refreshed.")} + onClick={() => run(refreshXCookiesAction, () => "Cookies refreshed.")} disabled={pending || !status.hasProfile} aria-label="refresh x cookies" className="rounded-md border border-border px-3 py-1 text-sm hover:bg-muted disabled:opacity-50" @@ -98,7 +220,7 @@ export function XSessionSection({ initial }: { initial: XSessionStatus }) { </button> <button type="button" - onClick={() => run(clearXSessionAction, "Session cleared.")} + onClick={() => run(clearXSessionAction, () => "Session cleared.")} disabled={pending || !status.hasProfile} aria-label="clear x session" className="rounded-md border border-border px-3 py-1 text-sm hover:bg-muted disabled:opacity-50" @@ -109,9 +231,13 @@ export function XSessionSection({ initial }: { initial: XSessionStatus }) { <p className="text-xs text-muted-foreground"> Connecting opens a browser window <strong>on the machine running the - editor</strong>; complete the login (2FA and captcha included), then - close the window. The login is never automated — that is what gets - accounts flagged. + editor</strong>: your own Chromium or Chrome when one is installed (else + Playwright&apos;s bundled Chromium), without the automation signals. + Complete the login (2FA and captcha included), then close the window. + Google&apos;s sign-in may still refuse an embedded browser (&ldquo;this + browser or app may not be secure&rdquo;) — <strong>X&apos;s own + password login is the reliable path</strong>. The login is never + automated — that is what gets accounts flagged. </p> {note && ( diff --git a/editor/app/settings/page.tsx b/editor/app/settings/page.tsx @@ -3,6 +3,8 @@ import Link from "next/link"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { readXSessionStatus } from "yt-dlp-transcript-common/social/xSessionBroker"; +import { resolveXCookieSourceFor } from "yt-dlp-transcript-common/social/xBrowserLogin"; +import { xCookieSourceView } from "yt-dlp-transcript-common/social/xCookieSource"; import { SettingsForm } from "./components/SettingsForm"; import { XSessionSection } from "./components/XSessionSection"; @@ -29,8 +31,13 @@ export default async function SettingsPage() { ["galleryDlBin", paths.galleryDlBin], ]; - // The X session broker's current state (see xSessionActions.ts). + // The X session broker's current state and the X login source in use (see + // xSessionActions.ts). The source is resolved here, not read: with no stored + // choice it depends on cookiesFromBrowser and the profile. const xSession = await readXSessionStatus(paths); + const xSource = xCookieSourceView( + await resolveXCookieSourceFor(paths, settings), + ); return ( <div className="flex flex-col gap-8"> @@ -86,7 +93,7 @@ export default async function SettingsPage() { </section> <section className="flex flex-col gap-3 border-t border-border pt-6"> - <XSessionSection initial={xSession} /> + <XSessionSection initial={xSession} initialSource={xSource} /> </section> <section className="flex flex-col gap-3 border-t border-border pt-6"> diff --git a/editor/app/settings/xSessionActions.ts b/editor/app/settings/xSessionActions.ts @@ -1,16 +1,21 @@ "use server"; -// Server actions for the X session broker (common/social/xSessionBroker.ts). +// Server actions for the X session broker (common/social/xSessionBroker.ts) +// and the X login source (common/social/xCookieSource.ts, release 16 slice XL). // // "Connect X account" opens a HEADED browser on the editor host so the operator // can log in by hand — password, 2FA and captcha included. That is the whole // point: automating an X login is what gets accounts flagged, and a human doing -// it once produces a profile that stays valid. +// it once produces a profile that stays valid. The window is the operator's own +// Chromium or Chrome when one is installed, without the automation signals +// (common/social/xBrowser.ts). // // Because it opens a window on the SERVER's display, this is deliberately an // explicit operator action and never something a fetch triggers on its own. +import { revalidatePath } from "next/cache"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { clearXSession, connectXAccount, @@ -18,28 +23,49 @@ import { refreshXCookies, type XSessionStatus, } from "yt-dlp-transcript-common/social/xSessionBroker"; +import { + readXLoginStatus, + resolveXCookieSourceFor, + type XLoginStatus, +} from "yt-dlp-transcript-common/social/xBrowserLogin"; +import { + isXCookieSource, + xCookieSourceView, + type XCookieSourceView, +} from "yt-dlp-transcript-common/social/xCookieSource"; +import { saveSettings } from "./saveSettings"; +// Every session action returns the source in use beside the profile's status: +// connecting or forgetting a profile can move the read-time default. export type XSessionActionResult = - | { ok: true; status: XSessionStatus } + | { ok: true; status: XSessionStatus; source: XCookieSourceView; browser?: string } | { ok: false; error: string }; +async function sourceNow(): Promise<XCookieSourceView> { + return xCookieSourceView(await resolveXCookieSourceFor(getPaths(), getSettings())); +} + export async function xSessionStatusAction(): Promise<XSessionStatus> { return readXSessionStatus(getPaths()); } export async function connectXAccountAction(): Promise<XSessionActionResult> { try { - const status = await connectXAccount(getPaths()); + const { browser, ...status } = await connectXAccount(getPaths(), { + // Which browser opened, and any fallback, in the editor's log. + onLog: (line) => console.log(`[x-session] ${line}`), + }); if (!status.looksAuthenticated) { return { ok: false, error: - "The browser closed without a logged-in X session (no auth_token " + - "cookie was exported). Try again and complete the login before " + - "closing the window.", + `The browser (${browser}) closed without a logged-in X session (no ` + + "auth_token cookie was exported). Try again and complete the login " + + "before closing the window — X's own password login is the reliable " + + "path where Google's sign-in refuses.", }; } - return { ok: true, status }; + return { ok: true, status, source: await sourceNow(), browser }; } catch (e) { return { ok: false, error: (e as Error).message }; } @@ -47,7 +73,10 @@ export async function connectXAccountAction(): Promise<XSessionActionResult> { export async function refreshXCookiesAction(): Promise<XSessionActionResult> { try { - return { ok: true, status: await refreshXCookies(getPaths()) }; + const status = await refreshXCookies(getPaths(), { + onLog: (line) => console.log(`[x-session] ${line}`), + }); + return { ok: true, status, source: await sourceNow() }; } catch (e) { return { ok: false, error: (e as Error).message }; } @@ -56,8 +85,51 @@ export async function refreshXCookiesAction(): Promise<XSessionActionResult> { export async function clearXSessionAction(): Promise<XSessionActionResult> { try { await clearXSession(getPaths()); - return { ok: true, status: await readXSessionStatus(getPaths()) }; + return { + ok: true, + status: await readXSessionStatus(getPaths()), + source: await sourceNow(), + }; + } catch (e) { + return { ok: false, error: (e as Error).message }; + } +} + +// THE CHECK: the source in use, whether an X login (an auth_token for x.com) +// is visible in it, and when it was last seen. Reads the browser's cookie store +// read-only (a private copy) or the profile's exported jar. +export type XLoginCheckResult = + | { ok: true; login: XLoginStatus } + | { ok: false; error: string }; + +export async function checkXLoginAction(): Promise<XLoginCheckResult> { + try { + return { ok: true, login: await readXLoginStatus(getPaths(), getSettings()) }; + } catch (e) { + return { ok: false, error: (e as Error).message }; + } +} + +// THE CHOICE: "browser", "profile", or "auto" — no stored choice, the default +// resolved at read time. Writes `social.x.cookieSource` through the one +// settings writer and nothing else. +export type XSourceChoiceResult = + | { ok: true; source: XCookieSourceView } + | { ok: false; error: string }; + +export async function setXCookieSourceAction( + choice: string, +): Promise<XSourceChoiceResult> { + if (choice !== "auto" && !isXCookieSource(choice)) { + return { ok: false, error: `Unknown X login source "${choice}".` }; + } + try { + await saveSettings({ + social: { x: choice === "auto" ? {} : { cookieSource: choice } }, + }); } catch (e) { return { ok: false, error: (e as Error).message }; } + revalidatePath("/settings"); + return { ok: true, source: await sourceNow() }; } diff --git a/editor/e2e/x-session.spec.ts b/editor/e2e/x-session.spec.ts @@ -1,9 +1,15 @@ -// The X session broker UI (common/social/xSessionBroker.ts). The headed login +// The X session broker UI (common/social/xSessionBroker.ts) and the X login +// source (common/social/xCookieSource.ts, release 16 slice XL). The headed login // is never driven here — it would open a real browser and hit x.com, which the -// suite must never do. +// suite must never do. The browser source reads a FIXTURE Firefox store written +// under the test corpus, never a real browser profile. import { test, expect } from "@playwright/test"; -import { resetData } from "./helpers"; +import { readJson, resetData, resolvePath, writeSettings } from "./helpers"; +// By relative path: a runtime import of the package specifier does not resolve +// under playwright's loader (digest.spec.ts says why). +import { writeFirefoxCookieStore } from "../../common/social/__fixtures__/firefoxCookieStore"; + test("settings page renders the X session section", async ({ page }) => { await resetData("empty"); await page.goto("/settings"); @@ -13,4 +19,60 @@ test("settings page renders the X session section", async ({ page }) => { // never click it (it would open a real browser window and hit x.com). await expect(page.getByLabel("connect x account")).toBeVisible(); await expect(page.getByLabel("refresh x cookies")).toBeDisabled(); + // No cookiesFromBrowser in the fixture settings and no profile: the + // read-time default is the profile. + await expect(page.getByLabel("x cookie source", { exact: true })).toHaveValue("auto"); + await expect(page.getByLabel("x cookie source in use")).toHaveText( + "In use: Connected profile (automatic)", + ); +}); + +test("the login source select persists, and Check shows a status line", async ({ page }) => { + await resetData("empty"); + const profileDir = resolvePath("test-transcripts/firefox-fixture/xl.default-release"); + await writeFirefoxCookieStore(profileDir, [ + { + host: ".x.com", + name: "auth_token", + value: "fixture-not-a-real-token", + lastAccessed: Date.UTC(2026, 9, 1, 8, 30, 0) * 1000, + }, + { host: ".example.com", name: "session", value: "not-x" }, + ]); + const spec = `firefox:${profileDir}`; + await writeSettings({ cookiesFromBrowser: spec }); + + await page.goto("/settings"); + const section = page.locator("[data-x-session]"); + const select = page.getByLabel("x cookie source", { exact: true }); + const inUse = page.getByLabel("x cookie source in use"); + + // cookiesFromBrowser set, no profile connected: the browser, by default. + await expect(select).toHaveValue("auto"); + await expect(inUse).toHaveText(`In use: Browser login (${spec}) (automatic)`); + + await page.getByLabel("check x login").click(); + await expect(page.getByLabel("x login status")).toContainText( + "An X login is visible in firefox, outside its containers (where gallery-dl reads); " + + "its auth_token was last used 2026-10-01 08:30:00 UTC.", + ); + + // Choose the profile: stored, shown, and still chosen after a reload. + await select.selectOption("profile"); + await expect(section.getByRole("status")).toHaveText("Login source saved."); + await expect(inUse).toHaveText("In use: Connected profile"); + expect((await readJson<{ social?: unknown }>("test-settings.json")).social).toEqual({ + x: { cookieSource: "profile" }, + }); + + await page.reload(); + await expect(select).toHaveValue("profile"); + await expect(inUse).toHaveText("In use: Connected profile"); + await page.getByLabel("check x login").click(); + await expect(page.getByLabel("x login status")).toContainText("No profile is connected."); + + // Back to automatic: nothing stored, the default decides again. + await select.selectOption("auto"); + await expect(inUse).toHaveText(`In use: Browser login (${spec}) (automatic)`); + expect((await readJson<{ social?: unknown }>("test-settings.json")).social).toEqual({ x: {} }); }); diff --git a/plans/FACTS.md b/plans/FACTS.md @@ -8193,3 +8193,97 @@ phase deletes from the destination. - **Not covered:** a corpus-wide writer mid-channel when a move starts; a resumed or reconciled move-out is charged the whole tree against the destination's free space, not the remainder (move-back charges the remainder). + +## Connecting an X account (verified 2026-10-01, branch `r16/x-login`) + +- **Why Google refused the Connect window: the automation signals.** Measured on this box under + Xvfb (headed, no window on the operator's display; the slice's `xl-exp/`), Playwright 1.59.1 + driving system Chromium 153 and the bundled build: + - Playwright's defaults: `navigator.webdriver === true` and the bar "Chrome is being controlled + by automated test software" (that is `--enable-automation`). + - `ignoreDefaultArgs: ["--enable-automation"]` alone: the bar goes, **`navigator.webdriver` stays + true** — Playwright's `--remote-debugging-pipe` sets it. `--disable-blink-features= + AutomationControlled` is what turns it off (false, headed and headless). + - A system Chrome then draws "You are using an unsupported command-line flag" for + `--disable-blink-features=AutomationControlled`, and for Playwright's default `--no-sandbox`. + `chromiumSandbox: true` removes the second (the sandbox starts on this box for both builds). + `--test-type` removes the first: it is Chromium's internal test-harness switch, which makes the + browser skip its startup bars (`AddInfoBarsIfNecessary` returns before the bad-flags prompt), and + it sets no automation signal (measured in review: `navigator.webdriver`, `window.chrome`, the + user agent and the plugin count are the same with and without it; `runtime_features.cc` turns + `AutomationControlled` on only for `--enable-automation`, `--headless` and the debugging pipe or + port). ChromeDriver passes `--test-type=webdriver`; the bare switch is used here. It is NOT the + documented route: that is the `CommandLineFlagSecurityWarningsEnabled` policy, machine-wide, + root to set, and it would silence the operator's everyday browser too. The bundled Chrome for + Testing draws neither bar (it honours Playwright's `--disable-infobars`). + - Not hidden by any of this: the debugging pipe itself, and the headless user agent + (`HeadlessChrome/…`). Google's sign-in may still refuse; X's password login is the reliable path. +- **The launch options are one pure builder**, `buildXBrowserLaunchOptions` + (`common/social/xBrowser.ts`): `ignoreDefaultArgs: ["--enable-automation"]` ONLY, `args` + `--disable-blink-features=AutomationControlled` and `--test-type`, the sandbox on for the headed + window (it retries without when the host cannot start one). **Never `ignoreDefaultArgs: true`:** + it also drops `--password-store=basic`, and a system Chromium would then encrypt the profile's + cookies with the desktop keyring's key, which the bundled headless build that refreshes the jar + cannot read. +- **Which browser opens.** `ARCHILYZER_X_BROWSER` (a path, or a name on PATH; one that is not an + executable refuses the connect), else the first of `chromium`, `google-chrome`, + `google-chrome-stable`, `chrome` on PATH, else Playwright's bundled Chromium. Connect logs which + (`[x-session] Opening Chromium 153.0.8010.47 Arch Linux at /usr/bin/chromium (found on PATH)…`). +- **The bundled build is chromium-1217, Chrome for Testing 147.0.7727.15** (and its headless shell, + the same revision): `playwright-core@1.59.1`'s `browsers.json`. `~/.cache/ms-playwright` also holds + chromium-1234 (151) and two older revisions; this Playwright does not use them. +- **The profile/executable pairing.** A connect writes `archilyzer-browser.json` into the profile + dir (`{ executablePath | null, version, recordedAt }`). The headless refresh and the Playwright + fallback fetcher open the profile with the bundled build and fall back to the recorded executable + when the bundled launch throws. **The refresh never replaces a jar holding an `auth_token` with one + that has none:** a bundled read that comes back without the login is read again with the recorded + executable, and when no read finds it the jar is kept and the refresh refuses, saying so — a + profile written by a newer system browser could open in the bundled build without an error and + without its cookies. Measured: a profile written by system Chromium 153 (a cookie added, headed and + headless) opens in the bundled headless shell 147 with the cookie's value readable; `Last Version` + stays `153.0.8010.47`; 153 re-opens it after. So the fallback is a guard, not the common path. +- **The X login source, `social.x.cookieSource`** (`common/social/xCookieSource.ts`, pure). + `"browser"` | `"profile"`; absent = **resolved at read time, never stored**: `"browser"` when + `cookiesFromBrowser` is set and no profile is connected (connected = the broker's exported jar + carries an `auth_token`, `readXSessionStatus().looksAuthenticated`), else `"profile"`. A stored + choice always wins. The fetch resolves it per channel against the channel's own + `cookiesFromBrowser` over the global one (`fetchPosts` → `resolveXCookieSourceFor`). + **The source, not `cookieMode`, governs the X fetchers**: the browser source passes the spec + whatever the mode; the profile source keeps the old order (the jar, else the `"always"`-mode + spec, else a guest run). +- **gallery-dl 1.32.9** takes `--cookies-from-browser BROWSER[/DOMAIN][+KEYRING][:PROFILE][::CONTAINER]` + (yt-dlp's syntax plus `/DOMAIN`) and reads every browser it supports, Chromium's encrypted store + included, on each run. The spec is passed verbatim (`galleryDlCookieChoice`). +- **Reading Firefox's store ourselves** (`common/social/xBrowserLogin.ts`): for the Playwright + fallback and the Settings "Check". The store is found much as gallery-dl finds it — a profile path + or name from the spec, else the most recently modified `cookies.sqlite` under + `~/.config/mozilla/firefox`, `~/.mozilla/firefox`, Snap, Flatpak, macOS (two levels deep). Not + exactly: gallery-dl also honours `$XDG_CONFIG_HOME` and a second Flatpak root + (`~/.var/app/org.mozilla.firefox/config/mozilla/firefox`), and for a profile NAME takes the first + root holding `<name>/cookies.sqlite` where this takes the newest. + `cookies.sqlite` **and its `-wal`** are copied back to back to a private `mkdtemp` dir, and the db + alone copied again from there; both are read and removed; only X's rows are selected; the profile + is never opened in place. **Two views:** with the WAL (what Firefox holds now — the Playwright + fallback uses it) and the main file alone (what gallery-dl sees: it opens `cookies.sqlite` + `mode=ro&immutable=1`, which ignores the WAL, and its fallback copies the db file only). A fresh + login sits in the WAL until Firefox checkpoints (closing Firefox does it); the Check says so when + the `auth_token` is in the WAL only. + **Containers are read as gallery-dl reads them** (`cookies.py` `_firefox_cookies_database`): no + `::CONTAINER`, or `::none`, = only cookies outside every container (gallery-dl's default; yt-dlp + reads them all); `::all` = no filter; `::NAME` = the `userContextId` whose `name` is NAME, else whose + `l10nId` is `user-context-NAME` (or ends `-NAME`), else whose `l10nID` is `userContextNAME.label` — + case-sensitive. The Check says when an x.com login sits only in a container the spec does not read. + **A login counts on x.com only** (`.x.com`, `x.com`, subdomains): gallery-dl's twitter extractor + looks its `auth_token` up on `.x.com`; a twitter.com `auth_token` is reported as the old domain's + and not counted (`ct0` is not needed — gallery-dl makes one). + `expiry` is seconds (read as ms when too large for seconds); `lastAccessed` is PRTime (µs). The + Chromium family is NOT read here (its values are encrypted with a keyring key): the readers say so + and gallery-dl reads it. +- **`node:sqlite`** (Node 22.23 here; unflagged since 22.13, an ExperimentalWarning once per process) + is loaded at run time through an assembled specifier with `webpackIgnore` + (`common/social/nodeSqlite.ts`), like `playwrightRuntime.ts`: the editor's Turbopack build has + nothing to resolve, and the repo's `@types/node` (20) has no types for it, so they are structural. +- **The e2e never reads a real browser.** `x-session.spec.ts` writes a Firefox store under + `test-transcripts/` (`common/social/__fixtures__/firefoxCookieStore.ts`, imported by relative path) + and points `cookiesFromBrowser` at it as `firefox:<abs path>` — a profile PATH in the spec, so no + home directory is searched. diff --git a/plans/release-16.md b/plans/release-16.md @@ -25,7 +25,7 @@ slice's prompt carries its ruling, and this record carries what was built. Rules | DX | `r16/research-setup` | The research-only setup (source → `pnpm install` → `claude mcp add archilyzer` → `/ask`) in one place, the homepage's AI and MCP doc; the sites' and the hub's Use-with-AI page removed and its links pointed at the doc; `README.md` §1/§4 and `mcp/README.md` their own copies (as amended) | `homepage/content/docs/ai-and-mcp.md`, `export/app/use-with-ai/` (removed), the Use with AI links (`export/app/components/{Header,MobileMenu,Footer}.tsx`, `export/app/(workspace)/ask/page.tsx`), `common/lib/{project,corpus}.ts` + `common/bin/compose-site.ts` (what named the page), `mcp/README.md`, `README.md` §1/§4 (wording only), `homepage/e2e/docs.spec.ts`, `export/e2e{,-hub}/use-with-ai-link.spec.ts` and the specs that visited the page | | FK | `r16/forms-keep-input` | Every editor form keeps what was typed when its action fails: actions return the submitted values with the error, the shared field helpers seed from them | new `editor/app/lib/formState.ts` + test; `editor/app/components/forms/Field.tsx` and the local `Field`s in `SiteForm.tsx`, `ChannelForm.tsx`; every action that returns `{ok:false,error}`/`{error}` (sites, settings, operations/settingsActions, scheduler, storage, homepageActions, cutReleaseAction, channels, videoActions); the 15 forms the ruling lists; e2e `forms-keep-input.spec.ts` (new) + the existing `sites-crud`, `settings`, `channels` specs; records: `plans/FACTS.md`. As shipped, also `editor/app/components/forms/Controlled.tsx` (new) and one tag swap each in `DurationField`, `SocialLinksField`, `SiteMembershipsSection`, `WorkersField` ("Slice FK, as shipped") | | RM | `r16/move-holds-writers` | A media move holds the channel's writers and mirrors its copy, so a transcription or download during the move cannot fail it | `common/controller/relocateChannelMedia.ts` (+ the saved-video mover if it shares the code) + tests; the lane runners' per-channel skip (`common/controller/autoRunner.ts`, the backfill/digest/normalize/clip-fetch entry points, `common/lib/channelMediaHold.ts`); `common/jobs/jobKinds.ts` (`needsMedia`); the channel page's Storage panel and the rack's reason text; `editor/e2e/relocate*.spec.ts`; records: `plans/FACTS.md`. As shipped, also `common/controller/{channelWriters,relocateDir}.ts` (+ tests), `common/jobs/streamCommand.ts`, `common/views/channelRow.ts`, `editor/app/channels/lib/{mediaBusy,relocationJob}.ts`, `editor/app/storage/lib/{storeBusy,savedVideosJob}.ts`, `editor/app/api/test/stuck-job/route.ts`; the relocate spec is `editor/e2e/channel-storage.spec.ts` ("Slice RM, as shipped") | -| XL | `r16/x-login` | Connecting an X account works: the Connect window is the operator's own browser without automation signals, and the fetchers can use the operator's browser login directly (`cookiesFromBrowser`) with no window at all | `common/social/{xSessionBroker,xGalleryDlFetcher,fetchers,playwrightRuntime}.ts` + tests; `common/lib/settingsSchema.ts` (one key) + `SETTINGS.md`; `editor/app/settings/{xSessionActions.ts,components/XSessionSection.tsx}`; `editor/e2e/settings*.spec.ts`; records: `plans/FACTS.md`, `ENVIRONMENT.md` if an env var is added | +| XL | `r16/x-login` | Connecting an X account works: the Connect window is the operator's own browser without automation signals, and the fetchers can use the operator's browser login directly (`cookiesFromBrowser`) with no window at all | `common/social/{xSessionBroker,xGalleryDlFetcher,fetchers,playwrightRuntime}.ts` + tests; `common/lib/settingsSchema.ts` (one key) + `SETTINGS.md`; `editor/app/settings/{xSessionActions.ts,components/XSessionSection.tsx}`; `editor/e2e/settings*.spec.ts`; records: `plans/FACTS.md`, `ENVIRONMENT.md` if an env var is added. As shipped, also `common/social/{xBrowser,xCookieSource,xBrowserLogin,nodeSqlite}.ts` and `__fixtures__/firefoxCookieStore.ts` (new), `xPlaywrightFetcher.ts`, `common/controller/fetchPosts.ts` (the source per channel), `common/lib/{settingsDocs,envVars}.ts`, `editor/app/settings/page.tsx`, and `editor/e2e/x-session.spec.ts` — the spec that covers the section ("Slice XL, as shipped") | ## Slice CK — the ruling (2026-09-30) @@ -994,6 +994,260 @@ ask) and found it closed by ordering; that item is struck above. | 5 | `3cc7a5da` | `channel-storage` (13, the stopping case included), `storage-locations`, `channels-storage-columns` | **24 passed**, 1 failed, 5.4 min — `storage-locations` "a volume that came up somewhere else is re-pointed": the first step's `clickUntil` on **Refresh** never saw the volume's identity written to settings in 30 s (the page showed the probe's identity; the action's write did not land). Nothing in that step is the slice's | | 6 | `3cc7a5da` | `storage-locations` × 2 | **18 passed**, 0 failed, 2.0 min | +### Slice XL, as shipped — connecting an X account works (2026-10-01) + +Branch `r16/x-login` off `main` `a2229d68`, worktree `~/Projects/r12-source-mirror` (editor 5101, +test 5111, export 5110), one Opus implementer. Scratch files `xl-*` in the job's `tmp`; the +measurements behind the launch options are `xl-exp/` (two probe scripts, their screenshots, and a +smoke of the slice's own modules). The ruling is above ("Slice XL — the ruling"). + +**Measured first** — Playwright 1.59.1 driving system Chromium 153 and the bundled build, headed +under Xvfb (never on the operator's display) and headless, on scratch profiles; no page ever +loaded x.com or Google: + +| Launch | `navigator.webdriver` | The window's bar | +|---|---|---| +| Playwright's defaults, system Chromium | true | "Chrome is being controlled by automated test software" | +| `ignoreDefaultArgs: ["--enable-automation"]`, sandbox on | **true** — the debugging pipe sets it | none | +| + `--disable-blink-features=AutomationControlled`, Playwright's `--no-sandbox` | false | "You are using an unsupported command-line flag: --no-sandbox" | +| the same, sandbox on | false | "… unsupported command-line flag: --disable-blink-features=AutomationControlled" | +| + `--test-type` | false | none | +| the bundled Chrome for Testing, the same options, sandbox on or off | false | none | + +So the blink flag is what turns `navigator.webdriver` off, and `--test-type` plus the sandbox is +what keeps a system Chrome's window free of a warning bar. **The bundled build is chromium-1217, +Chrome for Testing 147** (`playwright-core@1.59.1`'s `browsers.json`), not the cache's +chromium-1234 (151), which this Playwright does not use. **A profile written by system Chromium 153 +opens in the bundled headless shell 147 with its cookies readable** (a cookie added in 153, read back +by value in 147; `Last Version` stays 153; 153 re-opens it after), so the refresh keeps the bundled +build and the recorded executable is a fallback, not the common path. + +**What it does.** +- **The Connect window is the operator's own browser without the automation signals** + (`common/social/xBrowser.ts`, used by `connectXAccount`): `ARCHILYZER_X_BROWSER` (a path or a name + on PATH; one that is not an executable refuses the connect — it never opens another browser), + else the first of `chromium`, `google-chrome`, `google-chrome-stable`, `chrome` on PATH, else the + bundled Chromium. One pure options builder, `buildXBrowserLaunchOptions`: + `ignoreDefaultArgs: ["--enable-automation"]` only (never `true`: that would drop + `--password-store=basic` and a system Chromium would encrypt the profile's cookies with the desktop + keyring's key, unreadable by the bundled refresh), `--disable-blink-features=AutomationControlled`, + `--test-type`, the sandbox on for the headed window (retried without it, and logged, when the host + cannot start one). Connect logs the browser (`[x-session] Opening Chromium 153.0.8010.47 Arch + Linux at /usr/bin/chromium (found on PATH). …`) and writes it into the profile + (`archilyzer-browser.json`); its success note names it. +- **The profile opens headless the same way for the refresh and the Playwright fallback fetcher** + (`launchXProfile`, and the refresh's own pass): the bundled build with the same signals dropped, + the recorded executable when the bundled launch throws. **The refresh never replaces a logged-in jar + with one without a login** (the review's L4): a bundled read with no `auth_token` while the jar holds + one is read again with the recorded executable, and when no read finds the login the jar is kept + and the refresh refuses, saying so ("…the logged-in cookie jar exported <when> was kept, not + replaced…"). +- **The login source, `social.x.cookieSource`** (`common/social/xCookieSource.ts`, pure; the one new + settings key, `SETTINGS.md` and `settings.json.example` regenerated): `"browser"` | `"profile"`, + absent by default and **resolved at read time** — `"browser"` when `cookiesFromBrowser` is set and + no profile is connected (no exported jar carrying an `auth_token`), else `"profile"`. `fetchPosts` + resolves it per channel (the channel's own `cookiesFromBrowser` over the global) and hands the X + fetchers `cookieSource` and `browserCookies` (`PostFetchInput`, `common/social/fetchers.ts`). + **The source, not `cookieMode`, governs X**: the browser spec is passed whatever the mode. + - **gallery-dl** (`galleryDlCookieChoice`, pure): browser → `--cookies-from-browser <spec>` verbatim + (gallery-dl's `BROWSER[/DOMAIN][+KEYRING][:PROFILE][::CONTAINER]`), never the jar, fresh on every + run; browser with no spec → a guest run that says why; profile → the jar, else the + `"always"`-mode spec, else a guest (the order before the choice existed). The job log names the + source each run. + - **The Playwright fallback**: browser → a fresh headless context carrying the cookies + `xCookiesFromBrowser` reads now; a browser it cannot read refuses by name instead of running + logged out. Profile → `launchXProfile`, as before. + - **Nitter** uses no X login and is unchanged. +- **`xCookiesFromBrowser(spec)` and `readXLoginStatus(paths, settings)`** + (`common/social/xBrowserLogin.ts`). The read takes the resolved SPEC, not the settings, so a + channel's own `cookiesFromBrowser` holds. **Firefox only, read-only**: the store is found much as + gallery-dl finds it (a profile path or name, else the most recently modified `cookies.sqlite` under + the Firefox roots), `cookies.sqlite` and its `-wal` are copied into a private `mkdtemp` dir, X's rows + alone are selected, the copies are removed. **It reads what gallery-dl reads** (the review's M1, L3): + the container as gallery-dl reads it — no `::CONTAINER` (or `::none`) is only the cookies outside + every container, `::all` is every container, `::NAME` one container matched as gallery-dl matches — + and a login counts only as an `auth_token` on x.com. Two views of one copy: with the WAL (what + Firefox holds now; the Playwright fallback uses it) and the main file alone (what gallery-dl's + immutable open sees). **Chromium's encrypted store is not read here** — gallery-dl (and yt-dlp) read + it; for such a spec the Check and the fallback say so. The status: the source in use and whether it + was chosen, whether an `auth_token` for x.com is visible (`null` when the source cannot be read), and + when it was last seen — the browser's own `lastAccessed` for that cookie, or the jar's export time + for the profile; and, for the browser, three plain lines when they apply: the login is in Firefox's + write-ahead log only ("Seen in Firefox's write-ahead log only; gallery-dl will see it after Firefox + checkpoints (closing Firefox does it)."), an old-domain cookie (a twitter.com `auth_token`) is + present and not used, and an x.com login sits only in a container the spec does not read. + `node:sqlite` is loaded at run time (`common/social/nodeSqlite.ts`). +- **The Settings section** (`XSessionSection.tsx`, `xSessionActions.ts`, `settings/page.tsx`): the + intro names the two sources; a **Login source** select (Automatic — now: …, Browser login, + Connected profile) saved on change through `saveSettings` (Automatic stores `social.x` empty); the + source in use; **Check**, whose result is one line; the Connect text says the window is the + operator's own browser, that Google's sign-in may still refuse an embedded browser, and that X's + password login is the reliable path. Every session action also returns the source in use + (connecting or forgetting a profile can move the default). +- `ARCHILYZER_X_BROWSER` is declared in `common/lib/envVars.ts` (runtime) and `ENVIRONMENT.md` + regenerated. + +**New accessible names (contracts from here on):** `x cookie source` (the select — `getByLabel` +needs `{ exact: true }`, the next one contains it), `x cookie source in use`, `check x login`, +`x login status`. Unchanged: `x session state`, `connect x account`, `refresh x cookies`, +`clear x session`, the `data-x-session` hook and the `role="status"` / `role="alert"` lines. + +**Commits** + +| Commit | What | +|---|---| +| `cfb6dd10` | `common:` the Connect browser and its options, the login source, the browser-login reader and status, the fetchers, the `social` settings block; SETTINGS.md, settings.json.example, ENVIRONMENT.md | +| `a266b18a` | `editor:` the X account session's source select, Check and Connect text; the actions; `x-session.spec.ts` | +| `f295c263` | `plans:` this section; FACTS; the editor changelog | +| `457f5961` | `plans:` the e2e run | +| `b3ee652b` | `common:` the review's fixes — M1 (gallery-dl's containers), L1 (the WAL-only line), L3 (x.com-only logins), L4 (the refresh keeps a logged-in jar; `xSessionBroker.test.ts`), I1 (the `--test-type` wording) | +| `9275ec44` | `plans:` the review, its record and FACTS lines, the gates after it | +| `86b48d6b` | merge of `main` `ae23a695` (slice RM) | +| this commit | `plans:` the re-review, the merge, the gates after it | + +#### Gates (logs `$T/xl-*.log`) + +- **tsc** (all workspaces) clean, 126 s, at `a266b18a`'s tree — after deleting the worktree's stale + `export/.next/types` (an old build's, still naming the removed `/use-with-ai` page). +- **common:** 2,439/2,439, 113 s — the new `xBrowser.test.ts` (9), `xCookieSource.test.ts` (7), + `xBrowserLogin.test.ts` (11), `xGalleryDlFetcher.test.ts` (7) and one more `settingsSchema` case; + `settingsDocs`, `envVars` (both generated files) and `architecture` green. + `archilyzer docs files --check` clean. **Editor unit:** 109/109 (unchanged). +- **Build:** the capped editor build with the corpus linked (the worktree's own `transcripts/` set + aside, `ln -sT` the primary's, `systemd-run --scope -p MemoryMax=5G`, `pnpm --filter editor exec + next build`, the link removed and the directory put back): exit 0, 73 s, 1.64 GB peak RSS. +- **Smoke of the slice's modules** (`xl-exp/smoke.mts`, under Xvfb, a scratch corpus, no + navigation): `findXBrowser` picked `/usr/bin/chromium`; the Connect options gave + `navigator.webdriver === false`; the record was written; `launchXProfile` opened the profile with + the bundled headless build and read the cookie back by value, `navigator.webdriver === false`. +- **Numbers tool:** none. + + | Run | At | Specs | Result | + |---|---|---|---| + | 1 | `a266b18a` | `x-session`, `settings`, `cookies-mode`, `forms-keep-input`, `social-channel`, `social-fetcher-picker` | **46 passed**, 0 failed, 3.7 min (after 25 min in the queue behind RM's full suite) | + +**Not tested, and cannot be here:** a real Google or X login in the Connect window, and gallery-dl +or the fallback against x.com — the suite never loads X, and no test reads a real browser profile. +The operator verifies by hand after the restart (below). + +#### Found and left + +- **Google may still refuse.** The debugging pipe Playwright drives the window through is still + there, and a sign-in page can look for more than `navigator.webdriver`. The section says so and + points at X's password login. +- **The headless paths still say `HeadlessChrome`** in their user agent (the refresh and the + Playwright fallback); only `navigator.webdriver` is off there. +- **Chromium-family specs are read by gallery-dl only.** With `cookiesFromBrowser` naming chrome or + chromium, the browser source works for gallery-dl; the Check reports "cannot be read here" (`null`, + not "no login") and the Playwright fallback refuses. Reading Chromium's store would mean the + keyring decryption gallery-dl and yt-dlp already carry. +- **The default reads "connected" as "an exported jar with an auth_token".** A profile that is + logged in but has never exported (Connect closed before the jar was written) counts as not + connected, so the default stays on the browser until a refresh or a connect writes the jar. +- **The ExperimentalWarning for `node:sqlite`** prints once per editor process, the first time the + Check or the fallback reads a browser store. +- **Not run against the operator's own Firefox.** No run read a real browser profile, so whether + `firefox` resolves to the profile the operator logs in to X with (the most recently used one) is + the first thing the Check shows after the restart. +- **Store discovery is close to gallery-dl's, not identical** (review L2). gallery-dl also honours + `$XDG_CONFIG_HOME` and a second Flatpak root (`~/.var/app/org.mozilla.firefox/config/mozilla/firefox`), + and for a profile NAME it takes the first root holding `<name>/cookies.sqlite` where this reader + takes the newest across roots. On a stock Linux Firefox (one root in use) both pick the same store. +- **From the restart on, every X fetch runs as the operator's own X account** (review I2): the live + `settings.json` has `cookiesFromBrowser: "firefox"`, no `social` key and no exported jar, so the + default is the browser and every X fetch, scheduled syncs included, runs logged in as the everyday + account with no click. As ruled; the operator's steps below say it. +- **gallery-dl is handed the whole spec** (review I3): `--cookies-from-browser firefox`, with no + `/DOMAIN`, loads every cookie outside a container from the operator's Firefox into gallery-dl's + process (it sends only X's to X). Giving gallery-dl's argv alone `firefox/.x.com…` would narrow it; + not done. +- **A checkpoint between the two copies** (review I4): the db is copied before its WAL, so a + checkpoint and WAL reset in that instant can pair them wrongly — an "unreadable" result, reported as + such, or a stale view. Rare; no retry was added. +- **The whole `cookies.sqlite` sits in a private temp dir during a read** (review I5): every site's + rows, mode 0700 under `os.tmpdir()`, removed in a `finally`; only a hard kill leaves it behind. + +#### Decisions the operator could overturn + +| What I did | The alternative | +|---|---| +| `--test-type` in the launch args and the sandbox on for the headed window, so a system Chrome draws no warning bar. `--test-type` is Chromium's internal test-harness switch that skips the startup bars, not the documented route (the machine-wide `CommandLineFlagSecurityWarningsEnabled` policy); it sets no automation signal (measured in review). ChromeDriver passes `--test-type=webdriver`; the bare switch is used | The ruling's two flags alone: the window shows "unsupported command-line flag" for the blink flag (or for `--no-sandbox`) | +| The refresh and the fallback drop the same signals headless (`launchXProfile`) | Leave them on Playwright's defaults; the ruling named only the Connect window | +| `ARCHILYZER_X_BROWSER` is a runtime variable read in `xBrowser.ts` | A `paths` entry in `getPaths()` beside `GALLERY_DL_BIN` (it would add a field every `Paths` literal must carry) | +| "Automatic" is a third option in the select, storing nothing | Two options only: the read-time default would apply until the first choice and never again | +| The browser source passes the spec whatever `cookieMode` says | Gate it on `cookieMode` like yt-dlp's cookies (`defer` would then run X as a guest) | +| The browser source's Playwright fallback refuses a browser it cannot read | Fall back to the profile, or run logged out | +| `xCookiesFromBrowser(spec)` takes the resolved spec | The prompt's `(paths, settings)`: a channel's own `cookiesFromBrowser` would be ignored | + +#### After the restart, by hand (the operator) + +0. **Every X fetch, scheduled syncs included, runs as your own account from now on** — your + everyday Firefox's X login, with no click (the default with `cookiesFromBrowser: "firefox"` and no + connected profile). If that is not wanted, choose **Connected profile** on `/settings` straight + after the restart, before the next scheduled X sync. +1. `/settings` → X account session: **In use** should read `Browser login (firefox) (automatic)` + (the live `settings.json` has `cookiesFromBrowser: "firefox"` and no exported jar). **Check**: an + X login visible in firefox, outside its containers, with when its `auth_token` was last used. If it + says none, log in to x.com in that Firefox profile (not in a container — or set + `cookiesFromBrowser` to `firefox::<container>`) and Check again. If it says "Seen in Firefox's + write-ahead log only", gallery-dl will not see the login until Firefox checkpoints: close Firefox + once (or wait), then Check again before step 2. +2. One X channel's **Fetch posts**: the job log says `X login: the browser (firefox), read by + gallery-dl.` before gallery-dl runs. +3. Optionally **Connect X account**: the window should be Chromium 153, with no "controlled by + automated test software" bar; log in with X's username and password (not Google), close the + window. The source stays as chosen; on Automatic it moves to the connected profile. + + +#### Review + +**Verdict: SHIP AFTER FIXES** (`xl-review.md` in the job's scratch): one Medium, four Lows, five +infos. The coordinator ruled M1, L1, L3 and L4 in, the I1 wording, and L2 and I2–I5 into "Found and +left" (I2 also into the operator's steps). + +| Finding | Where | +|---|---| +| M1: with no `::CONTAINER` the reader took cookies from every container (yt-dlp's default); gallery-dl, the fetcher this login feeds, takes only cookies outside every container and accepts `::all` — so the Check could say "visible" for a login gallery-dl never reads | `b3ee652b`: `firefoxContainerScope` / `inContainerScope` mirror gallery-dl 1.32.9's `_firefox_cookies_database` and its SQL (none = no container, `::all` = no filter, `::NAME` by `name`, `l10nId`, `l10nID`, case-sensitive); the container test expects `["default"]` with no container and has `::all`, `l10nId` and a 2-vs-12 case; the Check names a login that sits only in another container | +| L1: the Check read the WAL; gallery-dl opens the db `immutable=1`, which ignores it, so just after a login the Check could say "visible" while gallery-dl runs as a guest | `b3ee652b`: one copy, two views (with the WAL; the main file alone); a login in the WAL only adds "Seen in Firefox's write-ahead log only; gallery-dl will see it after Firefox checkpoints (closing Firefox does it)."; the operator's step 1 says what to do | +| L2: store discovery not exactly gallery-dl's | "Found and left"; FACTS and the module comment no longer say "as gallery-dl and yt-dlp find it" | +| L3: "visible" also counted an `auth_token` on twitter.com; gallery-dl looks on `.x.com` | `b3ee652b`: `isXComAuth` counts x.com only (the Check and the fallback's warning); a twitter.com `auth_token` is reported as "An old-domain cookie is present too … gallery-dl does not use it." | +| L4: the refresh fell back to the recorded browser only on a throw; a bundled read that opens a newer profile without its cookies would replace a logged-in jar with an empty one | `b3ee652b`: a bundled read with no `auth_token` while the jar holds one is read again with the recorded executable; when no read finds it the jar is kept and the refresh refuses, saying so. `refreshXCookies` takes an injectable `chromium`; `xSessionBroker.test.ts` (6 cases: login found, empty bundled read → recorded, nothing found → jar kept, no record → jar kept, a jar without a login replaced, bundled throws → recorded) | +| I1: `--test-type` verified to set no automation signal; the wording "what ChromeDriver passes" was loose, and it is not the documented route | `b3ee652b` (`xBrowser.ts`'s comment), FACTS, the decisions table: Chromium's internal test-harness switch that skips the startup bars; the documented route is the machine-wide `CommandLineFlagSecurityWarningsEnabled` policy; ChromeDriver passes `--test-type=webdriver`, the bare switch is used | +| I2: from the restart every X fetch runs as the operator's account | "Found and left"; the operator's step 0 | +| I3: gallery-dl is handed the whole spec (every non-container cookie loads into its process) | "Found and left" | +| I4: a checkpoint between the db and WAL copies can pair them wrongly | "Found and left" | +| I5: the whole store sits in a private temp dir during a read | "Found and left" | + +**Gates after the review** (at `b3ee652b`; logs `$T/xl-tsc3.log`, `xl-common2.log`, `xl-build2.log`, +`xl-e2e2.log`, `xl-e2e3.log`). `git merge main` was a no-op: `main` was still `a2229d68` (RM had not +landed). +- tsc (all workspaces) clean, 59 s. common **2,450/2,450**, 82 s (+11: the container case rewritten, + the two-views and x.com-domain cases, three status cases, `xSessionBroker.test.ts`'s 6). Editor unit + **109/109**. `archilyzer docs files --check`, `settings example --check`, `docs env --check`: exit 0. +- The capped editor build with the corpus linked: exit 0, 69 s, 1.65 GB peak RSS, no warnings; the link + removed and the worktree's own `transcripts/` put back. + + | Run | At | Specs | Result | + |---|---|---|---| + | 2 | `b3ee652b` | `x-session`, `settings` (also matches `operation-settings`), `social-channel` | 18 passed, **1 failed**, 2.5 min — `social-channel` "fetch-posts writes month-sharded JSONL…" hit the 30 s test timeout. The dev server was cold (started straight after the build), and the case is slow on this host anyway: 23.8 s in run 1, its offline neighbours about 12 s each. The gallery-dl path gained only a `stat` of the session dir | + | 3 | `b3ee652b` | `social-channel`, `x-session` | **7 passed**, 0 failed, 1.8 min (the fetch-posts case 25.7 s) | + + Not measured: the same case on `main`'s code, to say how much of its time is older than this slice. + +#### Re-review and brought to main (2026-10-01) + +**Re-review: SHIP.** `86b48d6b` merges `main` `ae23a695` (slice RM's merge) into `9275ec44`. Three +conflicts, all records, none in code: `plans/FACTS.md` keeps RM's appended section, then XL's; +this file keeps both slices-table rows and RM's record, with this section after it; `editor/CHANGELOG.md` +keeps one `## [Unreleased]`: RM's bullet, then XL's. Nothing RM changed is in a file XL touched. + +**Gates after the merge** (at `86b48d6b`; logs `$T/xl-tsc4.log`, `xl-common3.log`, `xl-build3.log`, +`xl-e2e4.log`): tsc (all workspaces) clean, 155 s; common **2,484/2,484**, 108 s (RM's 2,438 plus +XL's 46); editor unit **109/109**; `docs files --check`, `settings example --check`, `docs env --check` +exit 0; the capped editor build with the corpus linked exit 0, 49 s, 1.65 GB peak, the link removed and +the worktree's own `transcripts/` put back; e2e `x-session` and `channel-storage` (the two slices' +nearest specs): **15 passed**, 0 failed, 1.9 min. + ## Rollout Both slices are export- and homepage-side; the editor and umtool are not rebuilt for this release. diff --git a/settings.json.example b/settings.json.example @@ -5,6 +5,9 @@ "transcriptionApps": {}, "cookiesFromBrowser": "", "cookieMode": "when-required", + "social": { + "x": {} + }, "sleepBetweenDownloadsSeconds": 10, "downloadFormat": "auto", "minFreeDiskGB": 5,