Flow rinse: browser downloads, fast-fail, autoStart, launcher + self-healing launchd
- background.js: chrome.downloads via page session (no CSP/base64) - content.js: fast-fail on Flow error banner, image vs video timeouts, autoStart - local_server_example.py: /enqueue + /save, downloads/ output, resume-skip - launch.sh: idempotent server-up + caffeinate + single-tab focus - install.sh + plist: self-healing agent every 10 min (TCC-safe App Support copy) - flowclient.py: enqueue/wait_for helper for game projects - README: full setup + macOS TCC gotcha Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
7ae0a73db0
commit
501431bf59
3
.gitignore
vendored
3
.gitignore
vendored
@ -2,8 +2,9 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# local runtime output (harvested URLs)
|
||||
# local runtime output (harvested URLs + downloaded assets)
|
||||
results.jsonl
|
||||
downloads/
|
||||
|
||||
# saved SPA pages can embed auth tokens / cookies — never commit
|
||||
Google Flow*.html
|
||||
|
||||
66
README.md
Normal file
66
README.md
Normal file
@ -0,0 +1,66 @@
|
||||
# Flow Auto-Pilot
|
||||
|
||||
A Brave extension + local queue that drains a prompt bank through Google Labs
|
||||
**Flow** (Nano Banana 2 images, Veo videos) on the webui-only credits you can't
|
||||
reach via API. The extension types prompts into the Flow agent box with
|
||||
**trusted CDP input** (Flow ignores synthetic DOM events), waits for the media
|
||||
grid to grow, harvests the new asset URLs, and downloads them with the page's
|
||||
session cookies.
|
||||
|
||||
Not headless: Brave must be running and logged into the Flow account. But once
|
||||
the tab is open with **Auto-start** ticked, it runs unattended.
|
||||
|
||||
## Parts
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `manifest.json`, `background.js`, `content.js`, `popup.*` | the Brave MV3 extension |
|
||||
| `local_server_example.py` | queue server on `:8017` — `queue.jsonl` in, `results.jsonl` out |
|
||||
| `queue.jsonl` | the prompt bank (one task per line) |
|
||||
| `flowclient.py` | `enqueue()` / `wait_for()` so a game project can request assets |
|
||||
| `launch.sh` | start server + keep Mac awake + open/focus one Flow tab |
|
||||
| `install.sh` + `games.monsterrobot.flowrinse.plist` | self-healing launchd agent |
|
||||
|
||||
## Run it by hand
|
||||
|
||||
```
|
||||
./launch.sh https://labs.google/fx/tools/flow/project/<PROJECT_ID>
|
||||
```
|
||||
|
||||
Server comes up (under `caffeinate`), Brave focuses the Flow tab, and with
|
||||
Auto-start ticked the rinse begins. Files land in
|
||||
`~/Downloads/flowrinse/<category>/`.
|
||||
|
||||
## Run it unattended (launchd)
|
||||
|
||||
```
|
||||
./install.sh
|
||||
```
|
||||
|
||||
Fires `launch.sh` at login and every 10 min. Because `launch.sh` is idempotent
|
||||
(port check + tab reuse), the repeat runs are no-ops that self-heal a dead
|
||||
server or a closed tab.
|
||||
|
||||
**macOS TCC gotcha** (same as the vault-backup job): launchd can't execute
|
||||
scripts inside `~/Documents`, so `install.sh` runs a **copy** at
|
||||
`~/Library/Application Support/flowrinse/`. That copy owns the live
|
||||
`queue.jsonl` / `results.jsonl` / `downloads/` once the agent is running.
|
||||
Re-run `./install.sh` after editing `launch.sh` or `local_server_example.py`
|
||||
to resync the copy. First run pops one Automation prompt (allow control of
|
||||
Brave) — click Allow on the rig's screen.
|
||||
|
||||
```
|
||||
launchctl kickstart -k gui/$(id -u)/games.monsterrobot.flowrinse # run now
|
||||
launchctl bootout gui/$(id -u)/games.monsterrobot.flowrinse # stop it
|
||||
```
|
||||
|
||||
## Enqueue from another project
|
||||
|
||||
```python
|
||||
from flowclient import enqueue
|
||||
enqueue("Sierra EGA record-store back room, 160x168, 16-color",
|
||||
kind="image", category="morp2_bg")
|
||||
```
|
||||
|
||||
Or over the wire: `POST http://localhost:8017/enqueue` with
|
||||
`{"prompt","kind","model","category","dest"}`.
|
||||
@ -1,8 +1,9 @@
|
||||
// Background service worker: performs TRUSTED input via the Chrome DevTools Protocol.
|
||||
// Flow's agent 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,
|
||||
// so the framework actually registers them. Requires the "debugger" permission; the user
|
||||
// sees a "started debugging this browser" banner while it runs (expected).
|
||||
// Background service worker.
|
||||
// * Trusted input via the Chrome DevTools Protocol — Flow's editor ignores synthetic
|
||||
// DOM events (isTrusted=false); CDP Input.* events are trusted so it registers them.
|
||||
// * Asset download — fetches the harvested media URL with the page's session cookies
|
||||
// (host_permission covers labs.google) and hands the bytes to the local server. Runs
|
||||
// in the worker, so it's not blocked by the page's CSP.
|
||||
|
||||
const attached = new Set();
|
||||
|
||||
@ -20,7 +21,6 @@ async function ensure(tabId) {
|
||||
if (attached.has(tabId)) return;
|
||||
try { await attach(tabId); attached.add(tabId); }
|
||||
catch (e) {
|
||||
// "Another debugger is already attached" => DevTools open on the tab; treat as attached-ish
|
||||
if (/already attached/i.test(String(e.message || e))) attached.add(tabId);
|
||||
else throw e;
|
||||
}
|
||||
@ -32,15 +32,22 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
const tabId = sender.tab && sender.tab.id;
|
||||
(async () => {
|
||||
try {
|
||||
if (!tabId) throw new Error('no tab id');
|
||||
await ensure(tabId);
|
||||
if (msg.type === 'insertText') {
|
||||
await ensure(tabId);
|
||||
await cmd(tabId, 'Input.insertText', { text: msg.text });
|
||||
sendResponse({ ok: true });
|
||||
} else if (msg.type === 'pressEnter') {
|
||||
await ensure(tabId);
|
||||
await cmd(tabId, 'Input.dispatchKeyEvent', { type: 'keyDown', ...ENTER });
|
||||
await cmd(tabId, 'Input.dispatchKeyEvent', { type: 'keyUp', ...ENTER });
|
||||
sendResponse({ ok: true });
|
||||
} else if (msg.type === 'download') {
|
||||
// Browser-native download: uses the page's session cookies, follows the media
|
||||
// redirect, no CSP/CORS/base64. Lands under the browser's Downloads dir.
|
||||
const dlId = await new Promise((res, rej) => chrome.downloads.download(
|
||||
{ url: msg.url, filename: msg.filename, conflictAction: 'uniquify', saveAs: false },
|
||||
(id) => chrome.runtime.lastError ? rej(chrome.runtime.lastError) : res(id)));
|
||||
sendResponse({ ok: dlId != null, id: dlId });
|
||||
} else if (msg.type === 'detach') {
|
||||
if (attached.has(tabId)) { chrome.debugger.detach({ tabId }, () => {}); attached.delete(tabId); }
|
||||
sendResponse({ ok: true });
|
||||
@ -51,7 +58,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
sendResponse({ ok: false, error: String(e.message || e) });
|
||||
}
|
||||
})();
|
||||
return true; // keep the message channel open for the async response
|
||||
return true; // async response
|
||||
});
|
||||
|
||||
chrome.debugger.onDetach.addListener((src) => { if (src.tabId) attached.delete(src.tabId); });
|
||||
|
||||
62
content.js
62
content.js
@ -15,6 +15,7 @@
|
||||
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: '',
|
||||
@ -29,6 +30,10 @@ 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();
|
||||
});
|
||||
@ -165,14 +170,40 @@ function clickConfirmIfPresent() {
|
||||
function snapshotAssets() {
|
||||
const scope = (settings.resultSel && document.querySelector(settings.resultSel)) || document;
|
||||
const urls = new Set();
|
||||
const push = (u) => { if (u && /^https?:/.test(u) && !/^data:/.test(u)) urls.add(u.split('#')[0]); };
|
||||
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('a[href*="googleusercontent"], a[download][href]').forEach(a => push(a.href));
|
||||
scope.querySelectorAll('img[src]').forEach(i => { if (/googleusercontent|lh3|storage/.test(i.src)) push(i.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);
|
||||
}
|
||||
|
||||
// Poll for: a new asset (win), a Flow error banner (fast-fail), or timeout.
|
||||
// The grace period avoids catching a stale error banner from the previous turn.
|
||||
async function waitForResultOrError(baseCount, maxMs) {
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < maxMs) {
|
||||
if (snapshotAssets().size > baseCount) return 'asset';
|
||||
if (Date.now() - t0 > 8000 && flowError()) return 'error';
|
||||
await sleep(2500);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- queue loop ----------------------------------------------------------
|
||||
|
||||
function startPolling() {
|
||||
@ -217,18 +248,31 @@ async function executeTask(task) {
|
||||
await sleep(1500);
|
||||
log('info', 'Submitted. Waiting for generation…');
|
||||
|
||||
// Wait until a NEW asset appears AND the asset count stops growing.
|
||||
const appeared = await waitFor(() => snapshotAssets().size > before.size, settings.genTimeoutMs, 2500);
|
||||
if (!appeared) {
|
||||
// last-chance: a quota/credit wall?
|
||||
const wall = [...document.querySelectorAll('*')].some(e => visible(e) && /credit|quota|limit|run out/i.test(e.textContent || '') && (e.textContent || '').length < 120);
|
||||
throw new Error(wall ? 'Stopped: credits/quota wall detected.' : 'Timed out waiting for output.');
|
||||
// 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") — 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/<category>/.
|
||||
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}`);
|
||||
|
||||
49
flowclient.py
Normal file
49
flowclient.py
Normal file
@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tiny client so OpShopGame / 90sDJsim can REQUEST Flow assets and RETRIEVE them.
|
||||
|
||||
The running Flow extension is the bridge to Google — the games never touch it.
|
||||
|
||||
from flowclient import enqueue, wait_for
|
||||
|
||||
r = enqueue("Low-poly vinyl crate icon, transparent bg, 90s UI",
|
||||
kind="image", category="djsim_ui",
|
||||
dest="/Users/johnking/Documents/90sDJsim/assets/ui")
|
||||
path = wait_for(r["dest"], r["id"]) # blocks until the file lands
|
||||
|
||||
Request -> appended to the queue; the extension drains it, generates, downloads.
|
||||
Retrieve -> the file appears in <dest> (and in flowext/downloads/<category>/).
|
||||
"""
|
||||
import json, os, sys, time, urllib.request
|
||||
|
||||
SERVER = os.environ.get("FLOW_SERVER", "http://localhost:8017")
|
||||
|
||||
|
||||
def enqueue(prompt, kind="image", model=None, category="request", dest=None, server=SERVER):
|
||||
body = json.dumps({"prompt": prompt, "kind": kind, "model": model,
|
||||
"category": category, "dest": dest}).encode()
|
||||
req = urllib.request.Request(server + "/enqueue", body, {"Content-Type": "application/json"})
|
||||
out = json.load(urllib.request.urlopen(req, timeout=10)) # {"ok":True,"id":...}
|
||||
out["dest"] = dest
|
||||
return out
|
||||
|
||||
|
||||
def wait_for(dest, task_id, timeout=1800, poll=10):
|
||||
"""Block until a file for task_id lands in <dest>; return its path (or None)."""
|
||||
if not dest:
|
||||
return None
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
if os.path.isdir(dest):
|
||||
for f in sorted(os.listdir(dest)):
|
||||
if f.startswith(task_id):
|
||||
return os.path.join(dest, f)
|
||||
time.sleep(poll)
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if "--demo" in sys.argv:
|
||||
print(enqueue("Low-poly flat-shaded vinyl record crate icon, transparent background, 90s UI",
|
||||
kind="image", category="djsim_ui"))
|
||||
else:
|
||||
print(__doc__)
|
||||
36
games.monsterrobot.flowrinse.plist
Normal file
36
games.monsterrobot.flowrinse.plist
Normal file
@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>games.monsterrobot.flowrinse</string>
|
||||
|
||||
<!-- sh -c so $HOME expands; launchd doesn't expand ~ in ProgramArguments -->
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/bin/sh</string>
|
||||
<string>-c</string>
|
||||
<string>exec "$HOME/Library/Application Support/flowrinse/launch.sh"</string>
|
||||
</array>
|
||||
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>FLOW_URL</key>
|
||||
<string>https://labs.google/fx/tools/flow/project/4085fe36-7540-4359-810f-12c69506f78d</string>
|
||||
<key>PATH</key>
|
||||
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
||||
</dict>
|
||||
|
||||
<!-- self-healing: idempotent launch.sh every 10 min (nc check + tab reuse
|
||||
make repeat runs no-ops), so a dead server or closed tab comes back -->
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>StartInterval</key>
|
||||
<integer>600</integer>
|
||||
|
||||
<key>StandardOutPath</key>
|
||||
<string>/tmp/flowrinse.launchd.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/tmp/flowrinse.launchd.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
40
install.sh
Normal file
40
install.sh
Normal file
@ -0,0 +1,40 @@
|
||||
#!/bin/sh
|
||||
# Install (or resync) the self-healing launchd agent for the Flow rinse.
|
||||
# Run ON the rig Mac, from this directory: ./install.sh
|
||||
#
|
||||
# Mirrors the vault-backup pattern (mrpgi-vault/backup.sh): launchd can't
|
||||
# execute scripts inside ~/Documents (macOS TCC blocks it), so the agent runs
|
||||
# a COPY of the runtime at "~/Library/Application Support/flowrinse/".
|
||||
# Once the agent owns the server, queue.jsonl / results.jsonl / downloads
|
||||
# live THERE — enqueue via http://localhost:8017/enqueue as usual.
|
||||
#
|
||||
# Re-run after editing launch.sh / local_server_example.py to resync the copy.
|
||||
#
|
||||
# One-time macOS prompts on first run (click Allow on the rig's screen):
|
||||
# * Automation: "sh"/"osascript" wants to control "Brave Browser"
|
||||
#
|
||||
# Manage the job:
|
||||
# launchctl kickstart -k gui/$(id -u)/games.monsterrobot.flowrinse # run now
|
||||
# launchctl bootout gui/$(id -u)/games.monsterrobot.flowrinse # stop it
|
||||
set -e
|
||||
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
APP="$HOME/Library/Application Support/flowrinse"
|
||||
LABEL=games.monsterrobot.flowrinse
|
||||
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
|
||||
|
||||
# runtime copy (code always refreshed; live state never overwritten)
|
||||
mkdir -p "$APP"
|
||||
cp "$DIR/launch.sh" "$DIR/local_server_example.py" "$DIR/flowclient.py" "$APP/"
|
||||
chmod +x "$APP/launch.sh"
|
||||
for f in queue.jsonl results.jsonl; do
|
||||
[ -f "$APP/$f" ] || { [ -f "$DIR/$f" ] && cp "$DIR/$f" "$APP/$f" || true; }
|
||||
done
|
||||
|
||||
mkdir -p "$HOME/Library/LaunchAgents"
|
||||
cp "$DIR/$LABEL.plist" "$PLIST"
|
||||
launchctl bootout gui/$(id -u)/$LABEL 2>/dev/null || true
|
||||
launchctl bootstrap gui/$(id -u) "$PLIST"
|
||||
|
||||
echo "installed $LABEL — fires at load + every 10 min"
|
||||
echo "run now: launchctl kickstart -k gui/$(id -u)/$LABEL"
|
||||
57
launch.sh
Executable file
57
launch.sh
Executable file
@ -0,0 +1,57 @@
|
||||
#!/bin/bash
|
||||
# One-command Flow rinse launcher (macOS). Not headless — Brave must be logged in —
|
||||
# but fully unattended once the tab is open and Auto-start is ticked in the popup.
|
||||
#
|
||||
# ./launch.sh https://labs.google/fx/tools/flow/project/XXXXXXXX
|
||||
# FLOW_URL=... ./launch.sh
|
||||
#
|
||||
# Does three things: start the queue server if it's down, keep the Mac awake, and
|
||||
# open OR focus a single Flow tab in Brave (never spawns duplicates).
|
||||
#
|
||||
# Scheduled every 10 min via launchd for self-healing (see install.sh).
|
||||
# NOTE: launchd can't execute scripts inside ~/Documents (macOS TCC blocks it),
|
||||
# so the agent runs a COPY at "~/Library/Application Support/flowrinse/" —
|
||||
# after editing this file or local_server_example.py, re-run ./install.sh to resync.
|
||||
|
||||
set -e
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
URL="${1:-$FLOW_URL}"
|
||||
if [ -z "$URL" ]; then
|
||||
echo "Usage: ./launch.sh <flow_project_url> (or set FLOW_URL)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 1. queue server on 8017 (nc check avoids consuming a task via /next-task)
|
||||
if ! nc -z localhost 8017 2>/dev/null; then
|
||||
( cd "$DIR" && caffeinate -s python3 -B local_server_example.py >/tmp/flowrinse.log 2>&1 & )
|
||||
echo "started queue server + caffeinate (log: /tmp/flowrinse.log)"
|
||||
sleep 1
|
||||
else
|
||||
echo "queue server already up on 8017"
|
||||
fi
|
||||
|
||||
# 2. open or focus ONE Flow tab (reuses an existing one so it can't double-run).
|
||||
# Only `activate` (steal system focus) when opening fresh — on the 10-min launchd
|
||||
# tick an existing tab is re-selected inside Brave without yanking you out of
|
||||
# whatever you're doing. Selecting the tab also keeps Memory Saver from discarding it.
|
||||
osascript <<OSA
|
||||
tell application "Brave Browser"
|
||||
set found to false
|
||||
repeat with w in windows
|
||||
set i to 0
|
||||
repeat with t in tabs of w
|
||||
set i to i + 1
|
||||
if (URL of t) contains "tools/flow/project" then
|
||||
set active tab index of w to i
|
||||
set index of w to 1
|
||||
set found to true
|
||||
end if
|
||||
end repeat
|
||||
end repeat
|
||||
if not found then
|
||||
activate
|
||||
open location "$URL"
|
||||
end if
|
||||
end tell
|
||||
OSA
|
||||
echo "Brave focused on Flow. With Auto-start ticked, the rinse begins on load."
|
||||
@ -13,12 +13,50 @@ Behaviour:
|
||||
Harvested `assets` are googleusercontent URLs; they need your logged-in session to download,
|
||||
so this just records them. ponytail: fetch+save the blobs from the extension later if you want local files.
|
||||
"""
|
||||
import json, os, time
|
||||
import base64, json, os, time
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
QUEUE_FILE = os.path.join(HERE, "queue.jsonl")
|
||||
RESULTS_FILE = os.path.join(HERE, "results.jsonl")
|
||||
DOWNLOADS = os.path.join(HERE, "downloads")
|
||||
|
||||
EXT = {"video/mp4": "mp4", "image/png": "png", "image/jpeg": "jpg", "image/webp": "webp", "image/gif": "gif"}
|
||||
|
||||
|
||||
def save_asset(rec):
|
||||
"""Write downloaded bytes to downloads/<category>/<id>_<idx>.<ext> (+ dest copy)."""
|
||||
data = base64.b64decode(rec.get("dataBase64", ""))
|
||||
ct = (rec.get("contentType") or "").split(";")[0].strip()
|
||||
ext = EXT.get(ct, "mp4" if "video" in ct else "png")
|
||||
name = f'{rec.get("id", "asset")}_{rec.get("idx", 0)}.{ext}'
|
||||
outdir = os.path.join(DOWNLOADS, rec.get("category") or "misc")
|
||||
os.makedirs(outdir, exist_ok=True)
|
||||
path = os.path.join(outdir, name)
|
||||
with open(path, "wb") as f:
|
||||
f.write(data)
|
||||
dest = rec.get("dest") # game asked for a copy in its own folder
|
||||
if dest:
|
||||
os.makedirs(dest, exist_ok=True)
|
||||
with open(os.path.join(dest, name), "wb") as f:
|
||||
f.write(data)
|
||||
return path
|
||||
|
||||
|
||||
def enqueue_task(rec):
|
||||
"""Append a game-requested task to the queue (persisted, picked up on next poll)."""
|
||||
t = {
|
||||
"id": rec.get("id") or f'req_{int(time.time() * 1000)}',
|
||||
"kind": rec.get("kind", "image"),
|
||||
"model": rec.get("model") or ("Veo 3.1 - Fast" if rec.get("kind") == "video" else "Nano Banana 2"),
|
||||
"prompt": rec["prompt"],
|
||||
"category": rec.get("category", "request"),
|
||||
}
|
||||
if rec.get("dest"):
|
||||
t["dest"] = rec["dest"]
|
||||
with open(QUEUE_FILE, "a") as f:
|
||||
f.write(json.dumps(t) + "\n")
|
||||
return t
|
||||
|
||||
# Fallback demo queue if queue.jsonl is absent (so it runs out of the box).
|
||||
DEMO = [
|
||||
@ -80,17 +118,28 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return self._send(204) # queue drained
|
||||
|
||||
def do_POST(self):
|
||||
if self.path != "/task-completed":
|
||||
return self._send(404, {"error": "not found"})
|
||||
n = int(self.headers.get("Content-Length", 0))
|
||||
rec = json.loads(self.rfile.read(n).decode() or "{}")
|
||||
rec["ts"] = time.time()
|
||||
INFLIGHT.pop(rec.get("id"), None)
|
||||
with open(RESULTS_FILE, "a") as f:
|
||||
f.write(json.dumps(rec) + "\n")
|
||||
assets = rec.get("assets") or []
|
||||
print(f"[SERVER] <- {rec.get('id')} {str(rec.get('status')).upper()} | {len(assets)} asset(s)")
|
||||
self._send(200, {"ok": True})
|
||||
|
||||
if self.path == "/task-completed":
|
||||
rec["ts"] = time.time()
|
||||
INFLIGHT.pop(rec.get("id"), None)
|
||||
with open(RESULTS_FILE, "a") as f:
|
||||
f.write(json.dumps(rec) + "\n")
|
||||
print(f"[SERVER] <- {rec.get('id')} {str(rec.get('status')).upper()} | {len(rec.get('assets') or [])} asset(s)")
|
||||
return self._send(200, {"ok": True})
|
||||
|
||||
if self.path == "/save":
|
||||
path = save_asset(rec)
|
||||
print(f"[SERVER] saved {rec.get('id')} -> {path}")
|
||||
return self._send(200, {"ok": True, "path": path})
|
||||
|
||||
if self.path == "/enqueue":
|
||||
t = enqueue_task(rec)
|
||||
print(f"[SERVER] enqueued {t['id']} ({t['kind']})")
|
||||
return self._send(200, {"ok": True, "id": t["id"]})
|
||||
|
||||
return self._send(404, {"error": "not found"})
|
||||
|
||||
def log_message(self, *a): # quiet the default noise
|
||||
pass
|
||||
|
||||
@ -5,7 +5,8 @@
|
||||
"description": "Automate prompt execution, model selection, and video/image generation using local queue integration.",
|
||||
"permissions": [
|
||||
"storage",
|
||||
"debugger"
|
||||
"debugger",
|
||||
"downloads"
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
|
||||
@ -37,6 +37,10 @@
|
||||
<button id="toggle-btn" class="btn btn-primary start-state">Start Automation</button>
|
||||
</div>
|
||||
|
||||
<label class="form-group" style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="auto-start"> Auto-start when the Flow tab opens
|
||||
</label>
|
||||
|
||||
<!-- Console / Activity Logs -->
|
||||
<div class="console-section">
|
||||
<div class="console-header">
|
||||
|
||||
4
popup.js
4
popup.js
@ -5,6 +5,7 @@ const DEFAULT_SETTINGS = {
|
||||
promptSel: '',
|
||||
submitSel: '',
|
||||
resultSel: '',
|
||||
autoStart: false,
|
||||
pollInterval: 4000,
|
||||
logs: []
|
||||
};
|
||||
@ -16,6 +17,7 @@ const globalStatus = document.getElementById('global-status');
|
||||
const statusLabel = globalStatus.querySelector('.status-label');
|
||||
const logConsole = document.getElementById('log-console');
|
||||
const clearLogsBtn = document.getElementById('clear-logs');
|
||||
const autoStartInput = document.getElementById('auto-start');
|
||||
|
||||
// Advanced inputs
|
||||
const selPromptInput = document.getElementById('sel-prompt');
|
||||
@ -26,6 +28,7 @@ const pollIntervalInput = document.getElementById('poll-interval');
|
||||
// Load settings on startup
|
||||
chrome.storage.local.get(DEFAULT_SETTINGS, (settings) => {
|
||||
serverUrlInput.value = settings.serverUrl;
|
||||
autoStartInput.checked = !!settings.autoStart;
|
||||
selPromptInput.value = settings.promptSel;
|
||||
selGenerateInput.value = settings.submitSel;
|
||||
selModelInput.value = settings.resultSel;
|
||||
@ -95,6 +98,7 @@ function saveSetting(key, value) {
|
||||
}
|
||||
|
||||
serverUrlInput.addEventListener('change', (e) => saveSetting('serverUrl', e.target.value));
|
||||
autoStartInput.addEventListener('change', (e) => saveSetting('autoStart', e.target.checked));
|
||||
selPromptInput.addEventListener('change', (e) => saveSetting('promptSel', e.target.value));
|
||||
selGenerateInput.addEventListener('change', (e) => saveSetting('submitSel', e.target.value));
|
||||
selModelInput.addEventListener('change', (e) => saveSetting('resultSel', e.target.value));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user