mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-15 11:43:10 +03:00
* fix(adobe-firefly): durable session, off-screen Chrome recovery, browser sign-in
Rebuild x-arp-session-id from Cookie pieces (sid/ark/forter) so aux_sid is never
sent as ARP. Sticky ARP + submit spacing reduce mid-batch colligo 408 thrash.
Add optional managed Chrome warm (off-screen headed by default; Forter rejects
headless) and POST /api/providers/{id}/login browser sign-in that returns JWT+Cookie
after a fresh SSO. Visible sign-in resets off-screen window placement and clears
prior Adobe session when adding another account.
* fix(adobe-firefly): cast Node Buffer to ArrayBuffer and harden chrome runtime null close
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
* fix(docs): sync docs-counts gate and env var contract for adobe-firefly
Update executor/OAuth-provider counts in ARCHITECTURE.md and
CODEBASE_DOCUMENTATION.md to match the real code (89 executors, 21
OAuth providers), and document the Adobe Firefly Chrome-driven
session-refresh env vars in .env.example and ENVIRONMENT.md so the
env/docs contract tests pass.
Co-authored-by: artickc <artickc@users.noreply.github.com>
---------
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: artickc <artickc@users.noreply.github.com>
1201 lines
40 KiB
TypeScript
1201 lines
40 KiB
TypeScript
/**
|
|
* Adobe Firefly optional Chrome (CDP) session runtime.
|
|
*
|
|
* Default product path is the same as other OmniRoute web-cookie providers
|
|
* (notion-web, perplexity-web, …): pure HTTP with the pasted Cookie/JWT — NO browser.
|
|
*
|
|
* Browser warm is OPT-IN for proactive use (`ADOBE_FIREFLY_BROWSER_REFRESH=1`) and may
|
|
* also run mid-batch 408 recovery via `allowWithoutEnvOptIn`.
|
|
*
|
|
* **Mode (UI + colligo):** background warm defaults to **offscreen headed** (parked off
|
|
* display + minimized) so Forter tokens work. True `--headless=new` is opt-in only
|
|
* (`ADOBE_FIREFLY_CHROME_HEADLESS=1`) and typically yields generate HTTP 408 while a real
|
|
* browser still works. Interactive sign-in uses modeOverride=visible.
|
|
*/
|
|
|
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import {
|
|
buildAdobeArpSessionIdFromCookies,
|
|
extractAdobeForterTimestampMs,
|
|
mergeAdobeCookieHeaders,
|
|
type AdobeFireflySession,
|
|
} from "./adobeFireflySession.ts";
|
|
import {
|
|
extractAdobeCookieHeader,
|
|
isAdobeUserAccessToken,
|
|
looksLikeAdobeJwt,
|
|
decodeAdobeJwtPayload,
|
|
} from "./adobeFireflyClient.ts";
|
|
|
|
const DEFAULT_CDP_PORT = Number(process.env.ADOBE_FIREFLY_CHROME_CDP_PORT || 9334);
|
|
const PROFILE_DIR_NAME = "adobe-chrome-profile";
|
|
|
|
type Log = { info?: (...a: unknown[]) => void; warn?: (...a: unknown[]) => void };
|
|
|
|
type RuntimeState = {
|
|
port: number;
|
|
profileDir: string;
|
|
chromeProc: ChildProcess | null;
|
|
browser: import("playwright").Browser | null;
|
|
context: import("playwright").BrowserContext | null;
|
|
page: import("playwright").Page | null;
|
|
lastWarmAt: number;
|
|
lastCookieSeed: string;
|
|
/** "offscreen" | "visible" | "headless" */
|
|
mode: string;
|
|
};
|
|
|
|
let runtime: RuntimeState | null = null;
|
|
let warmChain: Promise<void> = Promise.resolve();
|
|
let startingChrome: Promise<RuntimeState> | null = null;
|
|
/** Temporary mode override (e.g. force a visible window for interactive sign-in). */
|
|
let modeOverride: "offscreen" | "visible" | "headless" | null = null;
|
|
|
|
/**
|
|
* Background cookie/JWT work should not flash a normal desktop window.
|
|
* - default / HEADED / OFFSCREEN → offscreen headed (Forter-safe; colligo accepts)
|
|
* - HEADLESS=1 → true headless (often 408 on generate — debug only)
|
|
* - VISIBLE=1 → on-screen (debug only; interactive sign-in uses modeOverride)
|
|
*/
|
|
function resolveChromeMode(): "offscreen" | "visible" | "headless" {
|
|
if (modeOverride) return modeOverride;
|
|
if (process.env.ADOBE_FIREFLY_CHROME_VISIBLE === "1") return "visible";
|
|
// True headless is opt-in only — colligo rejects its Forter tokens (API 408, browser OK).
|
|
if (process.env.ADOBE_FIREFLY_CHROME_HEADLESS === "1") return "headless";
|
|
return "offscreen";
|
|
}
|
|
|
|
async function safePageWait(page: import("playwright").Page, ms: number): Promise<void> {
|
|
try {
|
|
if (page.isClosed()) return;
|
|
await page.waitForTimeout(ms);
|
|
} catch {
|
|
/* page closed / target destroyed — caller will re-acquire */
|
|
}
|
|
}
|
|
|
|
async function ensureLivePage(
|
|
context: import("playwright").BrowserContext,
|
|
preferred: import("playwright").Page | null
|
|
): Promise<import("playwright").Page> {
|
|
if (preferred && !preferred.isClosed()) {
|
|
try {
|
|
// Touch the page; if target is dead this throws
|
|
void preferred.url();
|
|
return preferred;
|
|
} catch {
|
|
/* fall through */
|
|
}
|
|
}
|
|
const existing =
|
|
context.pages().find((p) => !p.isClosed() && /firefly\.adobe\.com/i.test(p.url())) ||
|
|
context.pages().find((p) => !p.isClosed());
|
|
if (existing) return existing;
|
|
return context.newPage();
|
|
}
|
|
|
|
function dataDir(): string {
|
|
return (
|
|
String(process.env.DATA_DIR || process.env.OMNIROUTE_DATA_DIR || "").trim() ||
|
|
join(process.cwd(), ".data")
|
|
);
|
|
}
|
|
|
|
function profileDir(): string {
|
|
// Prefer LOCALAPPDATA when present so the managed Chrome profile survives restarts.
|
|
const local = process.env.LOCALAPPDATA || process.env.HOME || process.env.USERPROFILE || "";
|
|
if (local) {
|
|
const p = join(local, "OmniRoute", PROFILE_DIR_NAME);
|
|
try {
|
|
mkdirSync(p, { recursive: true });
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
return p;
|
|
}
|
|
const p = join(dataDir(), PROFILE_DIR_NAME);
|
|
try {
|
|
mkdirSync(p, { recursive: true });
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
return p;
|
|
}
|
|
|
|
function findChromeExecutable(): string | null {
|
|
if (process.env.CHROME_PATH && existsSync(process.env.CHROME_PATH)) {
|
|
return process.env.CHROME_PATH;
|
|
}
|
|
const candidates = [
|
|
"C:\\\\Program Files\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe",
|
|
"C:\\\\Program Files (x86)\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe",
|
|
join(process.env.LOCALAPPDATA || "", "Google", "Chrome", "Application", "chrome.exe"),
|
|
"/usr/bin/google-chrome",
|
|
"/usr/bin/chromium-browser",
|
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
];
|
|
for (const c of candidates) {
|
|
if (c && existsSync(c)) return c;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function waitForCdp(port: number, timeoutMs: number): Promise<void> {
|
|
const start = Date.now();
|
|
while (Date.now() - start < timeoutMs) {
|
|
try {
|
|
const r = await fetch(`http://127.0.0.1:${port}/json/version`);
|
|
if (r.ok) return;
|
|
} catch {
|
|
/* retry */
|
|
}
|
|
await new Promise((r) => setTimeout(r, 350));
|
|
}
|
|
throw new Error(`Chrome CDP not ready on port ${port}`);
|
|
}
|
|
|
|
async function killPortOwner(port: number): Promise<void> {
|
|
if (process.platform !== "win32") return;
|
|
try {
|
|
const { execSync } = await import("node:child_process");
|
|
execSync(
|
|
`powershell -NoProfile -Command "Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }"`,
|
|
{ stdio: "ignore", timeout: 8000 }
|
|
);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
function parseCookieHeader(cookieHeader: string): Array<{ name: string; value: string }> {
|
|
const out: Array<{ name: string; value: string }> = [];
|
|
for (const part of String(cookieHeader || "").split(";")) {
|
|
const idx = part.indexOf("=");
|
|
if (idx <= 0) continue;
|
|
let name = part.slice(0, idx).trim();
|
|
let value = part.slice(idx + 1).trim();
|
|
try {
|
|
name = decodeURIComponent(name);
|
|
} catch {
|
|
/* keep */
|
|
}
|
|
if (
|
|
(value.startsWith('"') && value.endsWith('"')) ||
|
|
(value.startsWith("'") && value.endsWith("'"))
|
|
) {
|
|
value = value.slice(1, -1);
|
|
}
|
|
if (!name || /[\r\n\0]/.test(value)) continue;
|
|
out.push({ name, value });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Detect whether the process listening on `port` was started with --headless. */
|
|
async function isPortChromeHeadless(port: number): Promise<boolean | null> {
|
|
if (process.platform !== "win32") return null;
|
|
try {
|
|
const { execSync } = await import("node:child_process");
|
|
const out = execSync(
|
|
`powershell -NoProfile -Command "$c=Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1; if(-not $c){exit 2}; $p=Get-CimInstance Win32_Process -Filter (\\"ProcessId=$($c.OwningProcess)\\"); if($p.CommandLine -match 'headless'){Write-Output 'headless'}else{Write-Output 'headed'}"`,
|
|
{ encoding: "utf8", timeout: 8000, stdio: ["ignore", "pipe", "ignore"] }
|
|
).trim();
|
|
if (out === "headless") return true;
|
|
if (out === "headed") return false;
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function tryConnectExistingCdp(
|
|
chromium: typeof import("playwright").chromium,
|
|
port: number,
|
|
dir: string,
|
|
desiredMode: string,
|
|
log?: Log
|
|
): Promise<RuntimeState | null> {
|
|
try {
|
|
const r = await fetch(`http://127.0.0.1:${port}/json/version`);
|
|
if (!r.ok) return null;
|
|
|
|
// Match process headless-ness to desiredMode:
|
|
// - headless desired: never reuse a headed process (would flash a real window).
|
|
// - offscreen/visible desired: never reuse headless (wrong Forter/profile mode).
|
|
const headless = await isPortChromeHeadless(port);
|
|
if (desiredMode === "headless" && headless === false) {
|
|
log?.warn?.(
|
|
"ADOBE-FIREFLY",
|
|
`existing CDP on ${port} is headed — killing and restarting as headless (no UI)`
|
|
);
|
|
await killPortOwner(port);
|
|
return null;
|
|
}
|
|
if (desiredMode !== "headless" && headless === true) {
|
|
log?.warn?.(
|
|
"ADOBE-FIREFLY",
|
|
`existing CDP on ${port} is headless — killing and restarting as ${desiredMode}`
|
|
);
|
|
await killPortOwner(port);
|
|
return null;
|
|
}
|
|
|
|
const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`);
|
|
const context = browser.contexts()[0] || (await browser.newContext());
|
|
const page = await ensureLivePage(context, null);
|
|
log?.info?.(
|
|
"ADOBE-FIREFLY",
|
|
`reused existing Chrome CDP port=${port} desiredMode=${desiredMode} pages=${context.pages().length}`
|
|
);
|
|
return {
|
|
port,
|
|
profileDir: dir,
|
|
chromeProc: null,
|
|
browser,
|
|
context,
|
|
page,
|
|
lastWarmAt: 0,
|
|
lastCookieSeed: "",
|
|
mode: desiredMode,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Chrome remembers last window bounds in the profile. Off-screen warms park the window at
|
|
* ~(-32000,-32000) / secondary-monitor coords — a later "visible" sign-in then opens Firefly
|
|
* off-screen and the user sees nothing. Reset placement on disk before a visible spawn.
|
|
*/
|
|
function resetChromeWindowPlacementOnDisk(dir: string, log?: Log): void {
|
|
const candidates = [join(dir, "Default", "Preferences"), join(dir, "Preferences")];
|
|
const onScreen = {
|
|
bottom: 960,
|
|
left: 80,
|
|
maximized: false,
|
|
right: 1360,
|
|
top: 60,
|
|
work_area_bottom: 1080,
|
|
work_area_left: 0,
|
|
work_area_right: 1920,
|
|
work_area_top: 0,
|
|
};
|
|
for (const path of candidates) {
|
|
if (!existsSync(path)) continue;
|
|
try {
|
|
const raw = readFileSync(path, "utf8");
|
|
const obj = JSON.parse(raw) as Record<string, unknown>;
|
|
const browser = (
|
|
obj.browser && typeof obj.browser === "object"
|
|
? (obj.browser as Record<string, unknown>)
|
|
: {}
|
|
) as Record<string, unknown>;
|
|
browser.window_placement = onScreen;
|
|
browser.window_placement_popup = onScreen;
|
|
obj.browser = browser;
|
|
// Avoid session restore putting us back off-screen.
|
|
if (obj.profile && typeof obj.profile === "object") {
|
|
(obj.profile as Record<string, unknown>).exit_type = "Normal";
|
|
(obj.profile as Record<string, unknown>).exited_cleanly = true;
|
|
}
|
|
writeFileSync(path, JSON.stringify(obj), "utf8");
|
|
log?.info?.("ADOBE-FIREFLY", `reset Chrome window_placement on disk (${path})`);
|
|
} catch (err) {
|
|
log?.warn?.(
|
|
"ADOBE-FIREFLY",
|
|
`could not reset window_placement: ${err instanceof Error ? err.message : String(err)}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** After CDP connect, force the browser window onto the primary work area (visible sign-in). */
|
|
async function forceChromeWindowOnScreen(
|
|
browser: import("playwright").Browser,
|
|
page: import("playwright").Page,
|
|
log?: Log
|
|
): Promise<void> {
|
|
try {
|
|
const cdp = await page.context().newCDPSession(page);
|
|
const { windowId } = (await cdp.send(
|
|
"Browser.getWindowForTarget" as "Browser.getWindowForTarget"
|
|
)) as {
|
|
windowId: number;
|
|
};
|
|
await cdp.send("Browser.setWindowBounds" as "Browser.setWindowBounds", {
|
|
windowId,
|
|
bounds: {
|
|
left: 80,
|
|
top: 60,
|
|
width: 1280,
|
|
height: 900,
|
|
windowState: "normal",
|
|
},
|
|
});
|
|
await page.bringToFront().catch(() => {});
|
|
// Best-effort Windows focus (Chrome can open behind the host app).
|
|
if (process.platform === "win32") {
|
|
try {
|
|
const { execSync } = await import("node:child_process");
|
|
execSync(
|
|
`powershell -NoProfile -Command "$p=Get-Process chrome -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowTitle -match 'Firefly|Adobe|Chrome' } | Select-Object -First 1; if($p){ Add-Type -Name W -Namespace N -MemberDefinition '[DllImport(\\\"user32.dll\\\")] public static extern bool SetForegroundWindow(IntPtr h); [DllImport(\\\"user32.dll\\\")] public static extern bool ShowWindow(IntPtr h,int n);'; [N.W]::ShowWindow($p.MainWindowHandle,9) | Out-Null; [N.W]::SetForegroundWindow($p.MainWindowHandle) | Out-Null }"`,
|
|
{ stdio: "ignore", timeout: 5000 }
|
|
);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
log?.info?.("ADOBE-FIREFLY", "forced Chrome window on-screen (80,60 1280x900)");
|
|
} catch (err) {
|
|
log?.warn?.(
|
|
"ADOBE-FIREFLY",
|
|
`forceChromeWindowOnScreen failed: ${err instanceof Error ? err.message : String(err)}`
|
|
);
|
|
}
|
|
}
|
|
|
|
async function ensureChromeStarted(
|
|
log?: Log,
|
|
opts?: { forceRestart?: boolean }
|
|
): Promise<RuntimeState> {
|
|
const mode = resolveChromeMode();
|
|
|
|
// Always kill the CDP port on forceRestart (even if in-memory runtime is null — leftover
|
|
// off-screen Chrome from a prior warm is the usual "browser didn't appear" case).
|
|
if (opts?.forceRestart) {
|
|
try {
|
|
await runtime?.browser?.close();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
runtime = null;
|
|
await killPortOwner(DEFAULT_CDP_PORT);
|
|
}
|
|
|
|
if (runtime?.browser && runtime.context) {
|
|
// Mode mismatch: always restart so we never keep a headed UI when silent headless
|
|
// is required, and never keep headless when offscreen/visible is required.
|
|
if (runtime.mode !== mode) {
|
|
log?.warn?.(
|
|
"ADOBE-FIREFLY",
|
|
`cached Chrome mode=${runtime.mode} desired=${mode} — restarting`
|
|
);
|
|
try {
|
|
await runtime.browser?.close();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
runtime = null;
|
|
await killPortOwner(DEFAULT_CDP_PORT);
|
|
} else {
|
|
try {
|
|
await fetch(`http://127.0.0.1:${runtime.port}/json/version`);
|
|
// Live process must still match headless/headed expectation.
|
|
const hl = await isPortChromeHeadless(runtime.port);
|
|
const mismatch =
|
|
(mode === "headless" && hl === false) || (mode !== "headless" && hl === true);
|
|
if (mismatch) {
|
|
log?.warn?.("ADOBE-FIREFLY", `live CDP headless=${hl} desired=${mode} — restarting`);
|
|
try {
|
|
await runtime.browser?.close();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
runtime = null;
|
|
await killPortOwner(DEFAULT_CDP_PORT);
|
|
} else {
|
|
runtime.page = await ensureLivePage(runtime.context, runtime.page);
|
|
return runtime;
|
|
}
|
|
} catch {
|
|
try {
|
|
await runtime?.browser?.close();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
runtime = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (startingChrome) return startingChrome;
|
|
|
|
if (process.env.ADOBE_FIREFLY_BROWSER_REFRESH === "0") {
|
|
throw new Error("ADOBE_FIREFLY_BROWSER_REFRESH=0");
|
|
}
|
|
|
|
startingChrome = (async () => {
|
|
const chromePath = findChromeExecutable();
|
|
if (!chromePath) throw new Error("Google Chrome not found (set CHROME_PATH)");
|
|
|
|
let chromium: typeof import("playwright").chromium;
|
|
try {
|
|
chromium = (await import("playwright")).chromium;
|
|
} catch {
|
|
throw new Error("playwright package not available for CDP connect");
|
|
}
|
|
|
|
const port = DEFAULT_CDP_PORT;
|
|
const dir = profileDir();
|
|
|
|
// Prefer reusing a healthy CDP only when mode matches (headless vs headed).
|
|
// Mismatched reuse is rejected inside tryConnectExistingCdp.
|
|
if (!opts?.forceRestart) {
|
|
const existing = await tryConnectExistingCdp(chromium, port, dir, mode, log);
|
|
if (existing) {
|
|
runtime = existing;
|
|
return existing;
|
|
}
|
|
}
|
|
|
|
// Kill stale listener before spawn (headless leftover / force restart).
|
|
await killPortOwner(port);
|
|
|
|
// Visible sign-in: wipe off-screen bounds left by prior off-screen warms.
|
|
if (mode === "visible") {
|
|
resetChromeWindowPlacementOnDisk(dir, log);
|
|
}
|
|
|
|
// Default headless: zero UI for cookie/JWT warm. Offscreen/visible are opt-in only.
|
|
const args = [
|
|
`--remote-debugging-port=${port}`,
|
|
"--remote-debugging-address=127.0.0.1",
|
|
"--remote-allow-origins=*",
|
|
`--user-data-dir=${dir}`,
|
|
"--no-first-run",
|
|
"--no-default-browser-check",
|
|
"--disable-blink-features=AutomationControlled",
|
|
"--disable-features=TranslateUI",
|
|
"--disable-session-crashed-bubble",
|
|
"--hide-crash-restore-bubble",
|
|
...(mode === "headless"
|
|
? ["--headless=new", "--disable-gpu", "--window-size=1280,900"]
|
|
: mode === "offscreen"
|
|
? [
|
|
"--window-position=-32000,-32000",
|
|
"--window-size=1280,900",
|
|
// Start minimized as extra belt-and-suspenders (Windows may still create a taskbar entry).
|
|
"--start-minimized",
|
|
]
|
|
: [
|
|
// Explicit on-screen position — profile restore alone is not enough.
|
|
"--window-position=80,60",
|
|
"--window-size=1280,900",
|
|
"--start-maximized",
|
|
]),
|
|
mode === "visible"
|
|
? "https://firefly.adobe.com/"
|
|
: "https://firefly.adobe.com/generate/image",
|
|
];
|
|
|
|
log?.info?.(
|
|
"ADOBE-FIREFLY",
|
|
`starting Chrome CDP profile=${dir} port=${port} mode=${mode} (headless=silent; offscreen=headed parked; visible=on-screen sign-in)`
|
|
);
|
|
const chromeProc = spawn(chromePath, args, {
|
|
stdio: "ignore",
|
|
detached: true,
|
|
// Only interactive sign-in may show a window host; silent refresh stays hidden.
|
|
windowsHide: mode !== "visible",
|
|
});
|
|
chromeProc.unref();
|
|
|
|
await waitForCdp(port, 45_000);
|
|
const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`);
|
|
const context = browser.contexts()[0] || (await browser.newContext());
|
|
const page = await ensureLivePage(context, null);
|
|
|
|
if (mode === "visible") {
|
|
await forceChromeWindowOnScreen(browser, page, log);
|
|
}
|
|
|
|
runtime = {
|
|
port,
|
|
profileDir: dir,
|
|
chromeProc,
|
|
browser,
|
|
context,
|
|
page,
|
|
lastWarmAt: 0,
|
|
lastCookieSeed: "",
|
|
mode,
|
|
};
|
|
return runtime;
|
|
})();
|
|
|
|
try {
|
|
return await startingChrome;
|
|
} finally {
|
|
startingChrome = null;
|
|
}
|
|
}
|
|
|
|
async function seedCookies(
|
|
context: import("playwright").BrowserContext,
|
|
cookieHeader: string
|
|
): Promise<number> {
|
|
const pairs = parseCookieHeader(cookieHeader);
|
|
let n = 0;
|
|
for (const { name, value } of pairs) {
|
|
for (const domain of [".adobe.com", "firefly.adobe.com", ".firefly.adobe.com"]) {
|
|
try {
|
|
await context.addCookies([
|
|
{ name, value, domain, path: "/", secure: true, sameSite: "Lax" },
|
|
]);
|
|
n++;
|
|
break;
|
|
} catch {
|
|
/* try next domain */
|
|
}
|
|
}
|
|
}
|
|
return n;
|
|
}
|
|
|
|
function extractUserJwtFromStorageRaw(raw: string): string {
|
|
const matches =
|
|
String(raw || "").match(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g) || [];
|
|
for (const tok of matches) {
|
|
if (looksLikeAdobeJwt(tok) && isAdobeUserAccessToken(tok)) return tok;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
async function readSpaUserJwt(page: import("playwright").Page): Promise<string> {
|
|
const tokens = await page.evaluate(() => {
|
|
const out: string[] = [];
|
|
for (const key of Object.keys(sessionStorage)) {
|
|
if (!/adobeid_ims_access_token|clio-playground/i.test(key)) continue;
|
|
out.push(sessionStorage.getItem(key) || "");
|
|
}
|
|
return out;
|
|
});
|
|
for (const raw of tokens) {
|
|
const tok = extractUserJwtFromStorageRaw(raw);
|
|
if (tok) return tok;
|
|
}
|
|
// broader scan
|
|
const all = await page.evaluate(() => {
|
|
const out: string[] = [];
|
|
for (const key of Object.keys(sessionStorage)) out.push(sessionStorage.getItem(key) || "");
|
|
return out;
|
|
});
|
|
for (const raw of all) {
|
|
const tok = extractUserJwtFromStorageRaw(raw);
|
|
if (tok) return tok;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
async function injectUserJwt(page: import("playwright").Page, token: string): Promise<void> {
|
|
if (!token) return;
|
|
await page
|
|
.evaluate((t) => {
|
|
for (const key of Object.keys(sessionStorage)) {
|
|
if (!key.includes("adobeid_ims_access_token")) continue;
|
|
try {
|
|
const obj = JSON.parse(sessionStorage.getItem(key) || "{}") as Record<string, unknown>;
|
|
obj.tokenValue = t;
|
|
obj.access_token = t;
|
|
obj.valid = true;
|
|
obj.expire = Date.now() + 20 * 3600 * 1000;
|
|
obj.expires_in = 86400000;
|
|
obj.client_id = "clio-playground-web";
|
|
sessionStorage.setItem(key, JSON.stringify(obj));
|
|
} catch {
|
|
/* skip */
|
|
}
|
|
}
|
|
}, token)
|
|
.catch(() => {});
|
|
}
|
|
|
|
async function humanize(page: import("playwright").Page): Promise<void> {
|
|
try {
|
|
if (page.isClosed()) return;
|
|
for (let i = 0; i < 16; i++) {
|
|
if (page.isClosed()) return;
|
|
await page.mouse.move(100 + i * 45, 160 + (i % 5) * 35, { steps: 4 });
|
|
await safePageWait(page, 80);
|
|
}
|
|
// Light scroll nudges Forter / passive listeners on real headed Chrome.
|
|
await page.mouse.wheel(0, 240).catch(() => {});
|
|
await safePageWait(page, 200);
|
|
await page.mouse.wheel(0, -120).catch(() => {});
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
/** Poll jar until forterToken timestamp advances past `minTs`, or timeout. */
|
|
async function waitForFresherForter(
|
|
context: import("playwright").BrowserContext,
|
|
minTs: number,
|
|
timeoutMs: number,
|
|
log?: Log
|
|
): Promise<number> {
|
|
const start = Date.now();
|
|
let best = 0;
|
|
while (Date.now() - start < timeoutMs) {
|
|
const cookie = await jarCookieHeader(context);
|
|
const ts = extractAdobeForterTimestampMs(cookie);
|
|
if (ts > best) best = ts;
|
|
if (ts > minTs) {
|
|
log?.info?.("ADOBE-FIREFLY", `Chrome forter refreshed (ts=${ts}, deltaMs=${ts - minTs})`);
|
|
return ts;
|
|
}
|
|
await new Promise((r) => setTimeout(r, 1500));
|
|
}
|
|
log?.warn?.(
|
|
"ADOBE-FIREFLY",
|
|
`Chrome forter did not advance past ${minTs} within ${timeoutMs}ms (best=${best})`
|
|
);
|
|
return best;
|
|
}
|
|
|
|
async function jarCookieHeader(context: import("playwright").BrowserContext): Promise<string> {
|
|
const jar = await context.cookies();
|
|
// Prefer firefly-relevant cookies; keep full jar for rebuild pieces
|
|
return jar.map((c) => `${c.name}=${c.value}`).join("; ");
|
|
}
|
|
|
|
async function buildArpFromContext(
|
|
context: import("playwright").BrowserContext,
|
|
page: import("playwright").Page
|
|
): Promise<{ arp: string; cookie: string }> {
|
|
const cookie = await jarCookieHeader(context);
|
|
const ls = await page
|
|
.evaluate(() => ({
|
|
bfp: localStorage.getItem("bfp") || "",
|
|
fpjs: localStorage.getItem("fpjs") || "",
|
|
}))
|
|
.catch(() => ({ bfp: "", fpjs: "" }));
|
|
let blob = cookie;
|
|
if (ls.bfp && !/(?:^|;\s*)bfp=/.test(blob)) blob = mergeAdobeCookieHeaders(blob, `bfp=${ls.bfp}`);
|
|
if (ls.fpjs && !/(?:^|;\s*)fpjs=/.test(blob)) {
|
|
blob = mergeAdobeCookieHeaders(blob, `fpjs=${encodeURIComponent(ls.fpjs)}`);
|
|
}
|
|
const arp =
|
|
buildAdobeArpSessionIdFromCookies(blob, {
|
|
bfp: ls.bfp || undefined,
|
|
fpjs: ls.fpjs || undefined,
|
|
}) || "";
|
|
return { arp, cookie: extractAdobeCookieHeader(blob) || blob };
|
|
}
|
|
|
|
/**
|
|
* Warm (or create) the durable Chrome Firefly session.
|
|
* Returns accessToken + cookie + arpSessionId ready for generate-async.
|
|
*/
|
|
export async function warmAdobeFireflyViaChrome(opts: {
|
|
cookie: string;
|
|
accessToken?: string;
|
|
log?: Log;
|
|
/** Wait for interactive login if only guest JWT is present (ms, 0 = don't wait). */
|
|
waitForLoginMs?: number;
|
|
/**
|
|
* Mid-batch 408 recovery: allow warm without ADOBE_FIREFLY_BROWSER_REFRESH=1.
|
|
* Uses headless Chrome by default (no UI). Opt into headed offscreen with
|
|
* ADOBE_FIREFLY_CHROME_HEADED=1 if diagnosing colligo.
|
|
*/
|
|
allowWithoutEnvOptIn?: boolean;
|
|
/** When true (or ADOBE_FIREFLY_CHROME_PING=1), prove ARP with in-page generate-async. */
|
|
proveWithPing?: boolean;
|
|
}): Promise<AdobeFireflySession | null> {
|
|
// Kill switch
|
|
if (process.env.ADOBE_FIREFLY_BROWSER_REFRESH === "0") return null;
|
|
// Default OFF for proactive use; recovery may pass allowWithoutEnvOptIn.
|
|
if (!opts.allowWithoutEnvOptIn && process.env.ADOBE_FIREFLY_BROWSER_REFRESH !== "1") return null;
|
|
if (process.env.NODE_ENV === "test" || process.env.VITEST || process.env.NODE_TEST_CONTEXT) {
|
|
return null;
|
|
}
|
|
|
|
const run = warmChain.then(async () => {
|
|
const log = opts.log;
|
|
const cookieIn = extractAdobeCookieHeader(opts.cookie) || opts.cookie;
|
|
if (!cookieIn?.trim() && !opts.accessToken) return null;
|
|
|
|
const forterBefore = extractAdobeForterTimestampMs(cookieIn);
|
|
// Force restart on recovery so we never reuse a half-dead CDP; mode is still headless
|
|
// by default (no popup). ADOBE_FIREFLY_CHROME_HEADED=1 opts into offscreen headed.
|
|
const rt = await ensureChromeStarted(log, {
|
|
forceRestart:
|
|
Boolean(opts.allowWithoutEnvOptIn) ||
|
|
process.env.ADOBE_FIREFLY_CHROME_FORCE_RESTART === "1",
|
|
});
|
|
const context = rt.context!;
|
|
let page = await ensureLivePage(context, rt.page);
|
|
|
|
if (cookieIn && cookieIn !== rt.lastCookieSeed) {
|
|
const n = await seedCookies(context, cookieIn);
|
|
rt.lastCookieSeed = cookieIn;
|
|
log?.info?.("ADOBE-FIREFLY", `Chrome seeded ${n} cookie entries`);
|
|
}
|
|
|
|
// Navigate / reload with page-closed recovery (prior flaky "Target page closed").
|
|
const gotoFirefly = async () => {
|
|
page = await ensureLivePage(context, page);
|
|
if (!/firefly\.adobe\.com/i.test(page.url())) {
|
|
await page.goto("https://firefly.adobe.com/generate/image", {
|
|
waitUntil: "domcontentloaded",
|
|
timeout: 90_000,
|
|
});
|
|
} else {
|
|
await page.reload({ waitUntil: "domcontentloaded", timeout: 90_000 }).catch(async () => {
|
|
page = await ensureLivePage(context, null);
|
|
await page.goto("https://firefly.adobe.com/generate/image", {
|
|
waitUntil: "domcontentloaded",
|
|
timeout: 90_000,
|
|
});
|
|
});
|
|
}
|
|
};
|
|
|
|
await gotoFirefly();
|
|
await safePageWait(page, 8_000);
|
|
await humanize(page);
|
|
|
|
let jwt = await readSpaUserJwt(page).catch(() => "");
|
|
if (!jwt && opts.accessToken && isAdobeUserAccessToken(opts.accessToken)) {
|
|
page = await ensureLivePage(context, page);
|
|
await injectUserJwt(page, opts.accessToken);
|
|
await page.reload({ waitUntil: "domcontentloaded", timeout: 90_000 }).catch(() => {});
|
|
await safePageWait(page, 6_000);
|
|
await humanize(page);
|
|
jwt = (await readSpaUserJwt(page).catch(() => "")) || opts.accessToken;
|
|
log?.info?.("ADOBE-FIREFLY", "Chrome injected cached user JWT into SPA sessionStorage");
|
|
}
|
|
|
|
// Wait for interactive login if still no user JWT (one-time profile SSO)
|
|
const waitMs = opts.waitForLoginMs ?? Number(process.env.ADOBE_FIREFLY_LOGIN_WAIT_MS || 0);
|
|
if (!jwt && waitMs > 0) {
|
|
log?.warn?.(
|
|
"ADOBE-FIREFLY",
|
|
`No user JWT yet — sign in to Firefly in the Chrome window (wait ${Math.round(waitMs / 1000)}s)`
|
|
);
|
|
const start = Date.now();
|
|
while (Date.now() - start < waitMs) {
|
|
await safePageWait(page, 2000);
|
|
page = await ensureLivePage(context, page);
|
|
jwt = await readSpaUserJwt(page).catch(() => "");
|
|
if (jwt) break;
|
|
}
|
|
}
|
|
|
|
if (!jwt && opts.accessToken && isAdobeUserAccessToken(opts.accessToken)) {
|
|
jwt = opts.accessToken;
|
|
}
|
|
if (!jwt || !isAdobeUserAccessToken(jwt)) {
|
|
log?.warn?.("ADOBE-FIREFLY", "Chrome warm: still no AdobeID user JWT (cookie-only guest)");
|
|
// Still return ARP if possible — caller may already have JWT
|
|
if (!opts.accessToken) return null;
|
|
jwt = opts.accessToken;
|
|
}
|
|
|
|
// Give Forter SDK time to mint a NEW forterToken (stale paste is the usual 408 root cause).
|
|
const forterWaitMs = Number(process.env.ADOBE_FIREFLY_FORTER_WAIT_MS || 45_000);
|
|
await waitForFresherForter(context, forterBefore, forterWaitMs, log);
|
|
|
|
// Second humanize + short settle after token land
|
|
page = await ensureLivePage(context, page);
|
|
await humanize(page);
|
|
await safePageWait(page, 2_000);
|
|
|
|
let { arp, cookie } = await buildArpFromContext(context, page);
|
|
if (!arp) {
|
|
log?.warn?.("ADOBE-FIREFLY", "Chrome warm: could not rebuild ARP from jar — one more reload");
|
|
await gotoFirefly();
|
|
await safePageWait(page, 8_000);
|
|
await humanize(page);
|
|
await waitForFresherForter(context, forterBefore, 20_000, log);
|
|
({ arp, cookie } = await buildArpFromContext(context, page));
|
|
}
|
|
if (!arp) {
|
|
log?.warn?.("ADOBE-FIREFLY", "Chrome warm: could not rebuild ARP from jar");
|
|
return null;
|
|
}
|
|
|
|
// Prove colligo accepts this ARP. Default ON for recovery path; env can force either way.
|
|
const shouldPing =
|
|
opts.proveWithPing === true ||
|
|
process.env.ADOBE_FIREFLY_CHROME_PING === "1" ||
|
|
(opts.allowWithoutEnvOptIn && process.env.ADOBE_FIREFLY_CHROME_PING !== "0");
|
|
if (shouldPing) {
|
|
page = await ensureLivePage(context, page);
|
|
const ok = await pingGenerateInPage(page, jwt, arp, log);
|
|
if (!ok) {
|
|
log?.warn?.(
|
|
"ADOBE-FIREFLY",
|
|
"Chrome ping generate failed — waiting for forter once more and rebuilding ARP"
|
|
);
|
|
await waitForFresherForter(context, extractAdobeForterTimestampMs(cookie), 20_000, log);
|
|
({ arp, cookie } = await buildArpFromContext(context, page));
|
|
if (arp) {
|
|
page = await ensureLivePage(context, page);
|
|
const ok2 = await pingGenerateInPage(page, jwt, arp, log);
|
|
if (!ok2) {
|
|
log?.warn?.("ADOBE-FIREFLY", "Chrome ping still failed — returning ARP for node retry");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
rt.page = page;
|
|
rt.lastWarmAt = Date.now();
|
|
const ftrTs = extractAdobeForterTimestampMs(cookie);
|
|
log?.info?.(
|
|
"ADOBE-FIREFLY",
|
|
`Chrome warm OK (mode=${rt.mode}, arpLen=${arp.length}, forterTs=${ftrTs || 0}, forterDeltaMs=${ftrTs && forterBefore ? ftrTs - forterBefore : "n/a"}, user=${String(decodeAdobeJwtPayload(jwt)?.user_id || "").slice(0, 20)})`
|
|
);
|
|
|
|
return {
|
|
accessToken: jwt,
|
|
cookie,
|
|
arpSessionId: arp,
|
|
tokenExpiresAt: (() => {
|
|
const p = decodeAdobeJwtPayload(jwt);
|
|
const created = Number(p?.created_at || 0);
|
|
const exp = Number(p?.expires_in || 0);
|
|
return created && exp ? created + exp : Date.now() + 20 * 3600_000;
|
|
})(),
|
|
updatedAt: Date.now(),
|
|
fingerprint: "chrome",
|
|
source: "browser" as const,
|
|
};
|
|
});
|
|
|
|
// Serialize warms
|
|
warmChain = run.then(
|
|
() => undefined,
|
|
() => undefined
|
|
);
|
|
try {
|
|
return await run;
|
|
} catch (err) {
|
|
opts.log?.warn?.(
|
|
"ADOBE-FIREFLY",
|
|
`Chrome warm failed: ${err instanceof Error ? err.message : String(err)}`
|
|
);
|
|
// Soft-reset page/browser handle but do not kill Chrome process — reuse next warm.
|
|
if (runtime) {
|
|
runtime.page = null;
|
|
try {
|
|
await runtime.browser?.close();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
runtime.browser = null;
|
|
runtime.context = null;
|
|
}
|
|
runtime = null;
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wipe Adobe SSO from the managed profile so "Add Account" can log into a *new* identity
|
|
* instead of silently reusing the previous Adobe session.
|
|
*/
|
|
async function clearAdobeBrowserSession(
|
|
context: import("playwright").BrowserContext,
|
|
page: import("playwright").Page,
|
|
log?: Log
|
|
): Promise<void> {
|
|
try {
|
|
await context.clearCookies();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
try {
|
|
await page.goto("https://firefly.adobe.com/", {
|
|
waitUntil: "domcontentloaded",
|
|
timeout: 60_000,
|
|
});
|
|
await page
|
|
.evaluate(() => {
|
|
try {
|
|
sessionStorage.clear();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
try {
|
|
localStorage.clear();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
// Best-effort IMS logout so the next load shows the sign-in UI.
|
|
try {
|
|
await page.goto(
|
|
"https://auth.services.adobe.com/en_US/index.html?callback=https%3A%2F%2Ffirefly.adobe.com%2F",
|
|
{
|
|
waitUntil: "domcontentloaded",
|
|
timeout: 45_000,
|
|
}
|
|
);
|
|
await safePageWait(page, 1500);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
log?.info?.("ADOBE-FIREFLY", "sign-in: cleared prior Adobe session for a fresh login");
|
|
}
|
|
|
|
/**
|
|
* Interactive one-time sign-in for the "browser session" credential model.
|
|
* Opens a VISIBLE managed Chrome (persistent profile), navigates to Firefly, and waits for the
|
|
* user to log in. Returns the IMS JWT + cookie jar so generate works immediately without
|
|
* depending on sessionStorage surviving a browser close.
|
|
* Never throws — returns { success:false } on timeout / unavailable.
|
|
*/
|
|
export async function loginAdobeFireflyViaChrome(opts: {
|
|
cookie?: string;
|
|
/** Max time to wait for the user to complete login (ms). Default 5 min. */
|
|
waitForLoginMs?: number;
|
|
/**
|
|
* When true (default for "Add Account"), wipe the prior Adobe SSO so a *new* account can be
|
|
* signed in instead of reopening the previous logged-in profile.
|
|
*/
|
|
freshSession?: boolean;
|
|
log?: Log;
|
|
}): Promise<{
|
|
success: boolean;
|
|
account?: string;
|
|
accessToken?: string;
|
|
cookie?: string;
|
|
arpSessionId?: string;
|
|
}> {
|
|
if (process.env.ADOBE_FIREFLY_BROWSER_REFRESH === "0") {
|
|
return { success: false };
|
|
}
|
|
const log = opts.log;
|
|
const prev = modeOverride;
|
|
modeOverride = "visible";
|
|
const fresh = opts.freshSession !== false; // default true for multi-account Add Account
|
|
try {
|
|
// Fresh visible window (a cached off-screen CDP would be parked off-display for login).
|
|
// forceRestart ALWAYS kills port 9334 + restarts with on-screen bounds.
|
|
const rt = await ensureChromeStarted(log, { forceRestart: true });
|
|
const context = rt.context!;
|
|
let page = await ensureLivePage(context, rt.page);
|
|
|
|
// Re-assert on-screen + foreground (profile may re-apply bad bounds after first paint).
|
|
await forceChromeWindowOnScreen(rt.browser!, page, log);
|
|
|
|
if (fresh) {
|
|
await clearAdobeBrowserSession(context, page, log);
|
|
page = await ensureLivePage(context, null);
|
|
rt.lastCookieSeed = "";
|
|
} else {
|
|
const cookieIn = opts.cookie ? extractAdobeCookieHeader(opts.cookie) || opts.cookie : "";
|
|
if (cookieIn) {
|
|
const n = await seedCookies(context, cookieIn);
|
|
rt.lastCookieSeed = cookieIn;
|
|
log?.info?.("ADOBE-FIREFLY", `sign-in: seeded ${n} cookie entries as a hint`);
|
|
}
|
|
}
|
|
|
|
await page
|
|
.goto("https://firefly.adobe.com/", { waitUntil: "domcontentloaded", timeout: 90_000 })
|
|
.catch(() => {});
|
|
page = await ensureLivePage(context, page);
|
|
await forceChromeWindowOnScreen(rt.browser!, page, log);
|
|
log?.info?.(
|
|
"ADOBE-FIREFLY",
|
|
`sign-in: Chrome window open ON-SCREEN (fresh=${fresh}) — waiting for Adobe login…`
|
|
);
|
|
|
|
const waitMs =
|
|
opts.waitForLoginMs ?? Number(process.env.ADOBE_FIREFLY_LOGIN_WAIT_MS || 300_000);
|
|
const start = Date.now();
|
|
let jwt = "";
|
|
while (Date.now() - start < waitMs) {
|
|
await safePageWait(page, 2500);
|
|
page = await ensureLivePage(context, page);
|
|
jwt = await readSpaUserJwt(page).catch(() => "");
|
|
if (jwt && isAdobeUserAccessToken(jwt)) break;
|
|
}
|
|
const ok = Boolean(jwt && isAdobeUserAccessToken(jwt));
|
|
const account = ok ? String(decodeAdobeJwtPayload(jwt)?.user_id || "") : undefined;
|
|
|
|
// Capture durable credentials BEFORE closing the window (sessionStorage JWT dies with the tab).
|
|
let cookie = "";
|
|
let arpSessionId = "";
|
|
if (ok) {
|
|
try {
|
|
const built = await buildArpFromContext(context, page);
|
|
cookie = extractAdobeCookieHeader(built.cookie) || built.cookie || "";
|
|
arpSessionId = built.arp || "";
|
|
} catch {
|
|
cookie = (await jarCookieHeader(context).catch(() => "")) || "";
|
|
}
|
|
}
|
|
|
|
log?.info?.(
|
|
"ADOBE-FIREFLY",
|
|
ok
|
|
? `sign-in OK (account=${account?.slice(0, 24)}, cookieLen=${cookie.length}, arpLen=${arpSessionId.length})`
|
|
: "sign-in timed out — no AdobeID session"
|
|
);
|
|
|
|
// Close the visible window; the persistent profile keeps the SSO for later headless warms.
|
|
try {
|
|
await rt.browser?.close();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
runtime = null;
|
|
return {
|
|
success: ok,
|
|
account,
|
|
accessToken: ok ? jwt : undefined,
|
|
cookie: ok ? cookie : undefined,
|
|
arpSessionId: ok ? arpSessionId : undefined,
|
|
};
|
|
} catch (err) {
|
|
log?.warn?.(
|
|
"ADOBE-FIREFLY",
|
|
`sign-in failed: ${err instanceof Error ? err.message : String(err)}`
|
|
);
|
|
try {
|
|
await runtime?.browser?.close();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
runtime = null;
|
|
return { success: false };
|
|
} finally {
|
|
modeOverride = prev;
|
|
}
|
|
}
|
|
|
|
async function pingGenerateInPage(
|
|
page: import("playwright").Page,
|
|
token: string,
|
|
arp: string,
|
|
log?: Log
|
|
): Promise<boolean> {
|
|
try {
|
|
const res = await page.evaluate(
|
|
async ({ token, arp }) => {
|
|
const claims = JSON.parse(
|
|
atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))
|
|
) as { user_id?: string };
|
|
const prompt = "ping";
|
|
const data = new TextEncoder().encode(String(claims.user_id || "") + "-" + prompt);
|
|
const hash = await crypto.subtle.digest("SHA-256", data);
|
|
const nonce = [...new Uint8Array(hash)]
|
|
.map((b) => b.toString(16).padStart(2, "0"))
|
|
.join("");
|
|
const r = await fetch("https://firefly-3p.ff.adobe.io/v2/3p-images/generate-async", {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: "Bearer " + token,
|
|
"x-api-key": "clio-playground-web",
|
|
"content-type": "application/json",
|
|
accept: "*/*",
|
|
"x-nonce": nonce,
|
|
"x-arp-session-id": arp,
|
|
},
|
|
credentials: "include",
|
|
body: JSON.stringify({
|
|
n: 1,
|
|
seeds: [1],
|
|
output: { storeInputs: true },
|
|
prompt,
|
|
referenceBlobs: [],
|
|
modelSpecificPayload: { size: "auto" },
|
|
modelId: "gpt-image",
|
|
modelVersion: "2",
|
|
generationMetadata: { module: "text2image", submodule: "ff-image-generate" },
|
|
generationSettings: { detailLevel: 1 },
|
|
}),
|
|
});
|
|
return { status: r.status, body: (await r.text()).slice(0, 120) };
|
|
},
|
|
{ token, arp }
|
|
);
|
|
log?.info?.("ADOBE-FIREFLY", `Chrome ping generate status=${res.status}`);
|
|
return res.status === 200 || res.status === 202;
|
|
} catch (e) {
|
|
log?.warn?.(
|
|
"ADOBE-FIREFLY",
|
|
`Chrome ping error: ${e instanceof Error ? e.message : String(e)}`
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Submit generate-async inside the warmed Chrome page (same TLS/cookie jar as SPA).
|
|
* Falls back to null so caller can use node fetch with the warmed ARP.
|
|
*/
|
|
export async function adobeFireflyGenerateInChrome(opts: {
|
|
accessToken: string;
|
|
arpSessionId: string;
|
|
payload: Record<string, unknown>;
|
|
prompt: string;
|
|
log?: Log;
|
|
}): Promise<{ status: number; body: string; headers: Record<string, string> } | null> {
|
|
if (!runtime?.page) return null;
|
|
try {
|
|
const res = await runtime.page.evaluate(
|
|
async ({ token, arp, payload, prompt }) => {
|
|
const claims = JSON.parse(
|
|
atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))
|
|
) as { user_id?: string };
|
|
const data = new TextEncoder().encode(
|
|
String(claims.user_id || "") + "-" + String(prompt || "").slice(0, 256)
|
|
);
|
|
const hash = await crypto.subtle.digest("SHA-256", data);
|
|
const nonce = [...new Uint8Array(hash)]
|
|
.map((b) => b.toString(16).padStart(2, "0"))
|
|
.join("");
|
|
const r = await fetch("https://firefly-3p.ff.adobe.io/v2/3p-images/generate-async", {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: "Bearer " + token,
|
|
"x-api-key": "clio-playground-web",
|
|
"content-type": "application/json",
|
|
accept: "*/*",
|
|
"x-nonce": nonce,
|
|
"x-arp-session-id": arp,
|
|
},
|
|
credentials: "include",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const headers: Record<string, string> = {};
|
|
r.headers.forEach((v, k) => {
|
|
headers[k] = v;
|
|
});
|
|
return { status: r.status, body: await r.text(), headers };
|
|
},
|
|
{
|
|
token: opts.accessToken,
|
|
arp: opts.arpSessionId,
|
|
payload: opts.payload,
|
|
prompt: opts.prompt,
|
|
}
|
|
);
|
|
return res;
|
|
} catch (e) {
|
|
opts.log?.warn?.(
|
|
"ADOBE-FIREFLY",
|
|
`in-Chrome generate failed: ${e instanceof Error ? e.message : String(e)}`
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Test helper */
|
|
export function __resetAdobeFireflyChromeRuntimeForTests(): void {
|
|
runtime = null;
|
|
warmChain = Promise.resolve();
|
|
}
|