commit df945a23403c034c7e1f2a9afada915a414d77ad
parent 0512803e29a0d64c2b0c75f8b5aba6bb1d37a33f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 11:27:37 -0400
common: X login — the Connect window is the operator's own browser without the automation signals, and the X fetchers can use the operator's browser login (social.x.cookieSource)
- social/xBrowser.ts: the Connect window's browser (ARCHILYZER_X_BROWSER, else
chromium / google-chrome / google-chrome-stable / chrome on PATH, else the
bundled build) and one pure launch-options builder: ignoreDefaultArgs
--enable-automation only, --disable-blink-features=AutomationControlled,
--test-type, the sandbox on for the headed window. The profile records the
executable that wrote it; the headless refresh keeps the bundled build and
falls back to the recorded one when it cannot open the profile.
- social/xCookieSource.ts: social.x.cookieSource "browser" | "profile", the
default resolved at read time (browser when cookiesFromBrowser is set and no
profile is connected, else profile), the spec parser.
- social/xBrowserLogin.ts: xCookiesFromBrowser — Firefox's cookies.sqlite (and
its WAL) copied to a private temp dir, X's rows only, never written; the
Chromium family is left to gallery-dl. readXLoginStatus: the source in use,
whether an auth_token for x.com is visible, when it was last seen.
- gallery-dl gets --cookies-from-browser <spec> on the browser source; the
Playwright fallback gets the same cookies in a fresh context; fetchPosts
resolves the source per channel.
- settings: the `social` block (SETTINGS.md, settings.json.example regenerated);
ENVIRONMENT.md: ARCHILYZER_X_BROWSER.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
23 files changed, 1840 insertions(+), 36 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,235 @@
+// 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`;
+// `--test-type` (what ChromeDriver passes) suppresses the first, the
+// sandbox removes the second. The bundled build draws neither.
+// 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,256 @@
+// 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,
+ readFirefoxXCookies,
+ readXLoginStatus,
+ xCookiesFromBrowser,
+} from "./xBrowserLogin";
+import { xCookieFile, xProfileDir } from "./xSessionBroker";
+import { 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("a Firefox container narrows the read; ::none reads outside every container", 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" },
+ ]);
+ 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" },
+ ],
+ }),
+ );
+ const values = async (container?: string) =>
+ (await readFirefoxXCookies(file, { container, tmpRoot: TMP })).map((c) => c.value).sort();
+ assert.deepEqual(await values(), ["default", "personal", "work", "work-fpd"]);
+ assert.deepEqual(await values("Work"), ["work", "work-fpd"]);
+ assert.deepEqual(await values("personal"), ["personal"]);
+ assert.deepEqual(await values("none"), ["default"]);
+ await assert.rejects(values("Shopping"), /No Firefox container named "Shopping"/);
+});
+
+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.match(s.summary, /^An X login is visible in firefox \(its auth_token last used 2026-10-01 08:30:00 UTC\)\.$/);
+});
+
+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,410 @@
+// 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 same discovery gallery-dl and yt-dlp use
+// picks the store: a profile path or name from the spec, else the most
+// recently modified `cookies.sqlite` under Firefox's profile roots.
+
+import { copyFile, 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, in the order gallery-dl and yt-dlp search
+// them: the XDG location newer releases use, the classic one, Snap, Flatpak,
+// macOS.
+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 ----------------------------------------------------------------
+
+// A Firefox container (`::NAME` in the spec) is a userContextId, named in
+// containers.json beside cookies.sqlite. "none" means cookies outside every
+// container. Mirrors yt-dlp's lookup.
+async function containerFilter(
+ dbFile: string,
+ container: string | undefined,
+): Promise<{ where: string; params: unknown[] }> {
+ if (!container) return { where: "", params: [] };
+ if (container.toLowerCase() === "none") {
+ return { where: "NOT INSTR(originAttributes, 'userContextId=')", params: [] };
+ }
+ 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 want = container.toLowerCase();
+ const hit = identities.find((c) => {
+ const name = typeof c.name === "string" ? c.name.toLowerCase() : undefined;
+ const l10n = typeof c.l10nID === "string" ? c.l10nID.toLowerCase() : undefined;
+ return name === want || l10n === `usercontext${want}.label`;
+ });
+ if (typeof hit?.userContextId !== "number") {
+ throw new Error(`No Firefox container named "${container}" in containers.json`);
+ }
+ const id = hit.userContextId;
+ return {
+ where: "(originAttributes LIKE ? OR originAttributes LIKE ?)",
+ params: [`%userContextId=${id}`, `%userContextId=${id}&%`],
+ };
+}
+
+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')";
+
+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;
+}
+
+export async function readFirefoxXCookies(
+ dbFile: string,
+ opts: { container?: string; tmpRoot?: string; now?: number } = {},
+): Promise<XBrowserCookie[]> {
+ const { DatabaseSync } = await loadSqlite();
+ const filter = await containerFilter(dbFile, opts.container);
+ // mkdtemp makes the dir 0700: the copy is readable by this user only.
+ const tmp = await mkdtemp(path.join(opts.tmpRoot ?? os.tmpdir(), "archilyzer-xcookies-"));
+ try {
+ const copy = path.join(tmp, COOKIE_DB);
+ await copyFile(dbFile, copy);
+ await copyFile(`${dbFile}-wal`, `${copy}-wal`).catch((err: NodeJS.ErrnoException) => {
+ if (err.code !== "ENOENT") throw err;
+ });
+ const db = new DatabaseSync(copy);
+ try {
+ const where = [X_HOSTS, filter.where].filter(Boolean).join(" AND ");
+ const rows = db.prepare(`SELECT * FROM moz_cookies WHERE ${where}`).all(...filter.params) as Array<
+ Record<string, unknown>
+ >;
+ const now = (opts.now ?? Date.now()) / 1000;
+ const out: XBrowserCookie[] = [];
+ 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 < now) 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);
+ }
+ return out;
+ } finally {
+ db.close();
+ }
+ } finally {
+ await rm(tmp, { recursive: true, force: true });
+ }
+}
+
+export type BrowserCookieRead =
+ | { ok: true; cookies: XBrowserCookie[]; browser: string; store: string }
+ | {
+ 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 cookies = await readFirefoxXCookies(found.file, {
+ container: parsed.container,
+ tmpRoot: opts.tmpRoot,
+ now: opts.now,
+ });
+ return { ok: true, cookies, browser: parsed.browser, store: found.file };
+ } 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;
+ checkedAt: string;
+ // One sentence 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 };
+ }
+ const auth = read.cookies
+ .filter((c) => c.name === X_AUTH_COOKIE)
+ .sort((a, b) => (b.lastAccessedMs ?? 0) - (a.lastAccessedMs ?? 0))[0];
+ if (!auth) {
+ return {
+ ...base,
+ authTokenVisible: false,
+ summary: `No X login in ${read.browser}: no auth_token cookie for x.com. Log in to x.com in that browser.`,
+ };
+ }
+ const lastSeenAt = auth.lastAccessedMs ? new Date(auth.lastAccessedMs).toISOString() : undefined;
+ return {
+ ...base,
+ authTokenVisible: true,
+ lastSeenAt,
+ summary:
+ `An X login is visible in ${read.browser}` +
+ (lastSeenAt ? ` (its auth_token last used ${when(lastSeenAt)}).` : "."),
+ };
+}
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 { X_AUTH_COOKIE, 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((c) => c.name === X_AUTH_COOKIE);
+ onLog?.(
+ `Launching a headless browser with ${read.cookies.length} X cookie(s) from ${read.browser} ` +
+ `(fallback path)${hasAuth ? "" : " — WARNING: no auth_token, 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.ts b/common/social/xSessionBroker.ts
@@ -18,12 +18,27 @@
// 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 } 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 +144,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,7 +230,42 @@ export async function connectXAccount(
} finally {
await context.close().catch(() => {});
}
- return readXSessionStatus(paths);
+ return { ...(await readXSessionStatus(paths)), browser: label };
+}
+
+// THE PROFILE, OPENED HEADLESS — the refresh below and the Playwright fallback
+// fetcher (profile source) both come through here. 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.
+export async function launchXProfile(
+ paths: Paths,
+ opts: { onLog?: (line: string) => void } = {},
+): Promise<BrowserContextLike> {
+ const log = opts.onLog ?? (() => {});
+ const profileDir = xProfileDir(paths);
+ const { chromium } = await importPlaywright();
+ const bundled: XBrowserChoice = { kind: "bundled" };
+ try {
+ const context = await chromium.launchPersistentContext(
+ profileDir,
+ buildXBrowserLaunchOptions({ browser: bundled, headless: true }),
+ );
+ 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 chromium.launchPersistentContext(
+ profileDir,
+ buildXBrowserLaunchOptions({ browser: recorded, headless: true }),
+ );
+ }
}
// Re-export cookies from the stored profile WITHOUT any human interaction —
@@ -186,10 +281,7 @@ export async function refreshXCookies(
"No X session profile yet — run the headed 'Connect X account' flow first.",
);
}
- const { chromium } = await importPlaywright();
- const context = await chromium.launchPersistentContext(profileDir, {
- headless: true,
- });
+ const context = await launchXProfile(paths, { onLog: log });
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.
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,