// Flow Auto-Pilot v2 — drives the Google Flow AGENT panel and HARVESTS outputs. // // Reuses DEALGOD's "poll until the SPA settles" discipline (waitForStable), NOT its // full selector-discovery engine — Flow is one known page, not the open web, so // auto-recipe/variance scoring would be over-engineering here. // // Loop: poll local queue -> type prompt into the agent box -> submit -> // wait for a NEW asset to appear + the media grid to stop growing -> // harvest the new video/image URL(s) -> report them back to the queue. // // Prereq you set ONCE by hand in Flow: Agent settings -> Confirm before generating // = "Never", model + x4 defaults. The driver still clicks a confirm button if one // shows up (covers both modes), but "Never" is what makes it truly unattended. let settings = { serverUrl: 'http://localhost:8017', isRunning: false, autoStart: false, // begin polling the moment the Flow tab loads (for scripted launch) // Optional CSS overrides — leave blank to use the resilient generic finders. promptSel: '', submitSel: '', resultSel: '', // scope for harvesting (e.g. the media library container) pollInterval: 4000, genTimeoutMs: 8 * 60 * 1000 // Veo clips can take minutes }; let pollTimeout = null; let generationInProgress = false; function init() { chrome.storage.local.get(settings, (data) => { settings = { ...settings, ...data }; if (settings.autoStart && !settings.isRunning) { settings.isRunning = true; chrome.storage.local.set({ isRunning: true }); // so the popup reflects it } log('system', `Content script connected (${settings.isRunning ? 'ACTIVE' : 'idle'}).`); if (settings.isRunning) startPolling(); }); } // ---- helpers ------------------------------------------------------------- const sleep = (ms) => new Promise(r => setTimeout(r, ms)); const visible = (el) => !!el && el.offsetWidth > 0 && el.offsetHeight > 0; // Poll a predicate until truthy or timeout. Returns the truthy value or null. async function waitFor(pred, maxMs = 15000, step = 400) { const t0 = Date.now(); while (Date.now() - t0 < maxMs) { let v; try { v = pred(); } catch (_) { v = null; } if (v) return v; await sleep(step); } return null; } // DEALGOD discipline: call countFn() every `step`ms; resolve once the value is // the SAME for `stableFor` consecutive polls (the grid stopped growing), or cap. async function waitForStable(countFn, { maxMs = settings.genTimeoutMs, step = 1500, stableFor = 2, min = 1 } = {}) { const t0 = Date.now(); let prev = -1, stable = 0; while (Date.now() - t0 < maxMs) { const c = countFn(); if (c >= min && c === prev) { if (++stable >= stableFor) return c; } else { stable = 0; } prev = c; await sleep(step); } return prev; } // React-aware value injection (kept from v1 — this part was correct). function setElementValue(el, value) { if (!el) return false; el.focus(); if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') { const setter = Object.getOwnPropertyDescriptor(el.__proto__, 'value')?.set; setter ? setter.call(el, value) : (el.value = value); // bypass React's value shadowing el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); } else { el.innerText = value; el.dispatchEvent(new InputEvent('input', { bubbles: true, cancelable: true, inputType: 'insertText', data: value })); } return true; } // Read whatever text is currently in the editor (textarea value or contenteditable text). function editorText(el) { const t = (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') ? el.value : (el.innerText || el.textContent || ''); return (t || '').trim(); } // Ask the background worker for a TRUSTED action via the DevTools Protocol. // Flow's editor ignores synthetic DOM events (isTrusted=false): the text shows but // the app's state stays empty ("no prompt" on send). CDP Input.* events are trusted. function bg(msg) { return new Promise((resolve) => { try { chrome.runtime.sendMessage(msg, (r) => resolve(chrome.runtime.lastError ? null : r)); } catch (_) { resolve(null); } }); } // Focus, clear, then insert the prompt as trusted input. Falls back to DOM injection // if the debugger path is unavailable. Returns chars that landed (0 = failed). async function typeIntoEditor(el, text) { el.focus(); await sleep(120); try { document.execCommand('selectAll', false, null); document.execCommand('delete', false, null); } catch (_) {} const r = await bg({ type: 'insertText', text }); if (!r || !r.ok) { try { document.execCommand('insertText', false, text); } catch (_) {} // fallback if (!editorText(el).length) setElementValue(el, text); } await sleep(200); return editorText(el).length; } // Submit via a trusted Enter (what works by hand). Falls back to synthetic keys. async function submitEditor(el) { el.focus(); const r = await bg({ type: 'pressEnter' }); if (!r || !r.ok) { const o = { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true }; el.dispatchEvent(new KeyboardEvent('keydown', o)); el.dispatchEvent(new KeyboardEvent('keypress', o)); el.dispatchEvent(new KeyboardEvent('keyup', o)); } } // ---- element finders (few, known targets; overrides win) ----------------- function findAgentInput() { if (settings.promptSel) { const el = document.querySelector(settings.promptSel); if (visible(el)) return el; } // Flow's agent box is a visible contenteditable[role=textbox] or textarea. const boxes = [ ...document.querySelectorAll('div[contenteditable="true"][role="textbox"]'), ...document.querySelectorAll('div[contenteditable="true"]'), ...document.querySelectorAll('textarea') ]; return boxes.find(visible) || null; } // The submit control is the unlabeled arrow near the input. Find the nearest // enabled button after the input, preferring aria-labels that read like "send". function findSubmit(inputEl) { if (settings.submitSel) { const el = document.querySelector(settings.submitSel); if (visible(el) && !el.disabled) return el; } const scope = inputEl?.closest('form, [class*="composer"], [class*="session"], [class*="input"]') || document; const btns = [...scope.querySelectorAll('button, [role="button"]')].filter(b => visible(b) && !b.disabled); const byLabel = btns.find(b => /send|submit|create|generate|run/i.test((b.getAttribute('aria-label') || '') + b.innerText)); if (byLabel) return byLabel; // else: the last enabled icon-only button in the composer (the → arrow) const iconOnly = btns.filter(b => !b.innerText.trim() && b.querySelector('svg')); return iconOnly[iconOnly.length - 1] || btns[btns.length - 1] || null; } // If a confirm/generate dialog appears (Always mode), click it. No-op under Never. function clickConfirmIfPresent() { const b = [...document.querySelectorAll('button, [role="button"]')] .find(x => visible(x) && !x.disabled && /^(generate|create|confirm|continue|yes)\b/i.test(x.innerText.trim())); if (b) { b.click(); return true; } return false; } // Snapshot real media URLs currently on the page (the harvest surface). function snapshotAssets() { const scope = (settings.resultSel && document.querySelector(settings.resultSel)) || document; const urls = new Set(); const ok = (u) => u && !/^data:/.test(u) && (/^https?:/.test(u) || /^blob:/.test(u)); const push = (u) => { if (ok(u)) urls.add(u.split('#')[0]); }; scope.querySelectorAll('video[src]').forEach(v => push(v.src)); scope.querySelectorAll('video source[src]').forEach(s => push(s.src)); scope.querySelectorAll('video[poster]').forEach(v => push(v.poster)); scope.querySelectorAll('a[href]').forEach(a => { if (a.hasAttribute('download') || /googleusercontent|media\.getMediaUrl|\/media\//i.test(a.href)) push(a.href); }); // images: match media hosts + the redirect API + blobs (diffed before/after, so only NEW ones count) scope.querySelectorAll('img[src]').forEach(i => { if (/googleusercontent|lh3|ggpht|storage|media\.getMediaUrl|^blob:/i.test(i.src)) push(i.src); }); return urls; } // Detect Flow's own failure banner so we can skip fast instead of waiting the full timeout. function flowError() { return [...document.querySelectorAll('*')].some(e => visible(e) && /something went wrong|please try again|couldn'?t generate|failed to generate|generation failed/i.test(e.textContent || '') && (e.textContent || '').length < 90); } // Flow's "Try again" button, shown beside the "Something went wrong" banner. // Clicking it re-runs the SAME prompt (no re-injection needed) — the manual fix. function findTryAgain() { return [...document.querySelectorAll('button, [role="button"]')] .find(b => visible(b) && !b.disabled && /^\s*try again\s*$/i.test(b.innerText || '')); } // Poll for: a new asset (win), or Flow's transient "Something went wrong". Flow // errors constantly under load, so instead of failing we AUTO-CLICK "Try again" // (what works by hand) up to maxRetries, with a cooldown so a lingering banner // doesn't burn all the retries at once. Returns 'asset' | 'error' | null(timeout). async function waitForResultOrError(baseCount, maxMs, maxRetries = 3) { const t0 = Date.now(); let retries = 0, cooldownUntil = 0; while (Date.now() - t0 < maxMs) { if (snapshotAssets().size > baseCount) return 'asset'; const now = Date.now(); if (now > cooldownUntil) { const btn = findTryAgain(); if (btn) { if (retries >= maxRetries) return 'error'; // gave it maxRetries shots retries++; log('info', `Flow hiccup — clicking "Try again" (${retries}/${maxRetries}).`); btn.click(); cooldownUntil = now + 15000; // let it re-generate before re-checking await sleep(4000); continue; } if (now - t0 > 8000 && flowError()) return 'error'; // errored, no retry offered } await sleep(2500); } return null; } // ---- queue loop ---------------------------------------------------------- function startPolling() { if (pollTimeout) clearTimeout(pollTimeout); if (!settings.isRunning) return; pollTimeout = setTimeout(async () => { if (generationInProgress) return startPolling(); try { const res = await fetch(`${settings.serverUrl}/next-task`); if (res.status === 204) return startPolling(); if (!res.ok) throw new Error(`HTTP ${res.status}`); const task = await res.json(); if (task && task.id) { log('info', `Task ${task.id}: ${String(task.prompt).slice(0, 60)}…`); await executeTask(task); } else startPolling(); } catch (err) { log('error', `Poll failed: ${err.message}`); startPolling(); } }, settings.pollInterval); } async function executeTask(task) { generationInProgress = true; chrome.storage.local.set({ status: 'working' }); try { const input = await waitFor(findAgentInput, 20000); if (!input) throw new Error('Agent input box not found — are you on the Flow project page?'); const before = snapshotAssets(); const landed = await typeIntoEditor(input, task.prompt); if (!landed) throw new Error('Prompt did not register in the editor (injection failed) — nothing submitted.'); log('info', `Prompt set (${landed} chars). Submitting…`); await sleep(300); await submitEditor(input); // trusted Enter via the DevTools Protocol await sleep(700); const submit = findSubmit(input); // arrow button, as a fallback if (submit) submit.click(); clickConfirmIfPresent(); // harmless under "Never" await sleep(1500); log('info', 'Submitted. Waiting for generation…'); // Wait for a new asset, a Flow error (fast-fail), or timeout. Images should be quick. const timeoutMs = task.kind === 'image' ? 3 * 60 * 1000 : settings.genTimeoutMs; const outcome = await waitForResultOrError(before.size, timeoutMs); if (outcome === 'error') throw new Error('Flow errored ("Something went wrong") after retries — skipping.'); if (outcome !== 'asset') { const wall = [...document.querySelectorAll('*')].some(e => visible(e) && /out of credits|no credits|insufficient|quota exceeded|run out of|daily limit|reached your limit/i.test(e.textContent || '') && (e.textContent || '').length < 160); throw new Error(wall ? 'Real credit/quota wall detected.' : 'Timed out — no new asset (harvest miss or Flow queue).'); } await waitForStable(() => snapshotAssets().size, { min: before.size + 1 }); const after = snapshotAssets(); const fresh = [...after].filter(u => !before.has(u)); log('success', `Harvested ${fresh.length} asset(s).`); // Browser download — uses your session, no CSP/CORS. Lands in Downloads/flowrinse//. const ext = task.kind === 'video' ? 'mp4' : 'png'; const cat = (task.category || 'misc').replace(/[^a-z0-9_\-]/gi, '_'); let saved = 0; for (let i = 0; i < fresh.length; i++) { const r = await bg({ type: 'download', url: fresh[i], filename: `flowrinse/${cat}/${task.id}_${i}.${ext}` }); if (r && r.ok) saved++; } if (fresh.length) log(saved === fresh.length ? 'success' : 'info', `Downloaded ${saved}/${fresh.length} → Downloads/flowrinse/${cat}/`); await reportCompletion(task.id, 'success', null, fresh); } catch (err) { log('error', `Task ${task.id} failed: ${err.message}`); await reportCompletion(task.id, 'failed', err.message, []); } finally { generationInProgress = false; chrome.storage.local.set({ status: settings.isRunning ? 'running' : 'idle' }); startPolling(); } } async function reportCompletion(id, status, error, assets) { try { await fetch(`${settings.serverUrl}/task-completed`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, status, error, assets }) // assets = harvested URLs }); } catch (err) { log('error', `Report failed: ${err.message}`); } } function log(level, text) { console.log(`[Flow][${level}] ${text}`); chrome.storage.local.get({ logs: [] }, (r) => { chrome.storage.local.set({ logs: [...r.logs, { level, text, timestamp: Date.now() }].slice(-100) }); }); } chrome.storage.onChanged.addListener((c, area) => { if (area !== 'local') return; for (const k of Object.keys(c)) if (k in settings) settings[k] = c[k].newValue; if (c.isRunning) { c.isRunning.newValue ? startPolling() : (pollTimeout && clearTimeout(pollTimeout)); } }); init();