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__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
||||||
# local runtime output (harvested URLs)
|
# local runtime output (harvested URLs + downloaded assets)
|
||||||
results.jsonl
|
results.jsonl
|
||||||
|
downloads/
|
||||||
|
|
||||||
# saved SPA pages can embed auth tokens / cookies — never commit
|
# saved SPA pages can embed auth tokens / cookies — never commit
|
||||||
Google Flow*.html
|
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.
|
// Background service worker.
|
||||||
// Flow's agent editor ignores synthetic DOM events (isTrusted=false) — the text shows
|
// * Trusted input via the Chrome DevTools Protocol — Flow's editor ignores synthetic
|
||||||
// but the app's state stays empty ("no prompt" on send). CDP Input.* events are trusted,
|
// DOM events (isTrusted=false); CDP Input.* events are trusted so it registers them.
|
||||||
// so the framework actually registers them. Requires the "debugger" permission; the user
|
// * Asset download — fetches the harvested media URL with the page's session cookies
|
||||||
// sees a "started debugging this browser" banner while it runs (expected).
|
// (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();
|
const attached = new Set();
|
||||||
|
|
||||||
@ -20,7 +21,6 @@ async function ensure(tabId) {
|
|||||||
if (attached.has(tabId)) return;
|
if (attached.has(tabId)) return;
|
||||||
try { await attach(tabId); attached.add(tabId); }
|
try { await attach(tabId); attached.add(tabId); }
|
||||||
catch (e) {
|
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);
|
if (/already attached/i.test(String(e.message || e))) attached.add(tabId);
|
||||||
else throw e;
|
else throw e;
|
||||||
}
|
}
|
||||||
@ -32,15 +32,22 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
const tabId = sender.tab && sender.tab.id;
|
const tabId = sender.tab && sender.tab.id;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
if (!tabId) throw new Error('no tab id');
|
|
||||||
await ensure(tabId);
|
|
||||||
if (msg.type === 'insertText') {
|
if (msg.type === 'insertText') {
|
||||||
|
await ensure(tabId);
|
||||||
await cmd(tabId, 'Input.insertText', { text: msg.text });
|
await cmd(tabId, 'Input.insertText', { text: msg.text });
|
||||||
sendResponse({ ok: true });
|
sendResponse({ ok: true });
|
||||||
} else if (msg.type === 'pressEnter') {
|
} else if (msg.type === 'pressEnter') {
|
||||||
|
await ensure(tabId);
|
||||||
await cmd(tabId, 'Input.dispatchKeyEvent', { type: 'keyDown', ...ENTER });
|
await cmd(tabId, 'Input.dispatchKeyEvent', { type: 'keyDown', ...ENTER });
|
||||||
await cmd(tabId, 'Input.dispatchKeyEvent', { type: 'keyUp', ...ENTER });
|
await cmd(tabId, 'Input.dispatchKeyEvent', { type: 'keyUp', ...ENTER });
|
||||||
sendResponse({ ok: true });
|
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') {
|
} else if (msg.type === 'detach') {
|
||||||
if (attached.has(tabId)) { chrome.debugger.detach({ tabId }, () => {}); attached.delete(tabId); }
|
if (attached.has(tabId)) { chrome.debugger.detach({ tabId }, () => {}); attached.delete(tabId); }
|
||||||
sendResponse({ ok: true });
|
sendResponse({ ok: true });
|
||||||
@ -51,7 +58,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
sendResponse({ ok: false, error: String(e.message || e) });
|
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); });
|
chrome.debugger.onDetach.addListener((src) => { if (src.tabId) attached.delete(src.tabId); });
|
||||||
|
|||||||
62
content.js
62
content.js
@ -15,6 +15,7 @@
|
|||||||
let settings = {
|
let settings = {
|
||||||
serverUrl: 'http://localhost:8017',
|
serverUrl: 'http://localhost:8017',
|
||||||
isRunning: false,
|
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.
|
// Optional CSS overrides — leave blank to use the resilient generic finders.
|
||||||
promptSel: '',
|
promptSel: '',
|
||||||
submitSel: '',
|
submitSel: '',
|
||||||
@ -29,6 +30,10 @@ let generationInProgress = false;
|
|||||||
function init() {
|
function init() {
|
||||||
chrome.storage.local.get(settings, (data) => {
|
chrome.storage.local.get(settings, (data) => {
|
||||||
settings = { ...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'}).`);
|
log('system', `Content script connected (${settings.isRunning ? 'ACTIVE' : 'idle'}).`);
|
||||||
if (settings.isRunning) startPolling();
|
if (settings.isRunning) startPolling();
|
||||||
});
|
});
|
||||||
@ -165,14 +170,40 @@ function clickConfirmIfPresent() {
|
|||||||
function snapshotAssets() {
|
function snapshotAssets() {
|
||||||
const scope = (settings.resultSel && document.querySelector(settings.resultSel)) || document;
|
const scope = (settings.resultSel && document.querySelector(settings.resultSel)) || document;
|
||||||
const urls = new Set();
|
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[src]').forEach(v => push(v.src));
|
||||||
scope.querySelectorAll('video source[src]').forEach(s => push(s.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('video[poster]').forEach(v => push(v.poster));
|
||||||
scope.querySelectorAll('img[src]').forEach(i => { if (/googleusercontent|lh3|storage/.test(i.src)) push(i.src); });
|
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;
|
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 ----------------------------------------------------------
|
// ---- queue loop ----------------------------------------------------------
|
||||||
|
|
||||||
function startPolling() {
|
function startPolling() {
|
||||||
@ -217,18 +248,31 @@ async function executeTask(task) {
|
|||||||
await sleep(1500);
|
await sleep(1500);
|
||||||
log('info', 'Submitted. Waiting for generation…');
|
log('info', 'Submitted. Waiting for generation…');
|
||||||
|
|
||||||
// Wait until a NEW asset appears AND the asset count stops growing.
|
// Wait for a new asset, a Flow error (fast-fail), or timeout. Images should be quick.
|
||||||
const appeared = await waitFor(() => snapshotAssets().size > before.size, settings.genTimeoutMs, 2500);
|
const timeoutMs = task.kind === 'image' ? 3 * 60 * 1000 : settings.genTimeoutMs;
|
||||||
if (!appeared) {
|
const outcome = await waitForResultOrError(before.size, timeoutMs);
|
||||||
// last-chance: a quota/credit wall?
|
if (outcome === 'error') throw new Error('Flow errored ("Something went wrong") — skipping.');
|
||||||
const wall = [...document.querySelectorAll('*')].some(e => visible(e) && /credit|quota|limit|run out/i.test(e.textContent || '') && (e.textContent || '').length < 120);
|
if (outcome !== 'asset') {
|
||||||
throw new Error(wall ? 'Stopped: credits/quota wall detected.' : 'Timed out waiting for output.');
|
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 });
|
await waitForStable(() => snapshotAssets().size, { min: before.size + 1 });
|
||||||
|
|
||||||
const after = snapshotAssets();
|
const after = snapshotAssets();
|
||||||
const fresh = [...after].filter(u => !before.has(u));
|
const fresh = [...after].filter(u => !before.has(u));
|
||||||
log('success', `Harvested ${fresh.length} asset(s).`);
|
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);
|
await reportCompletion(task.id, 'success', null, fresh);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log('error', `Task ${task.id} failed: ${err.message}`);
|
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,
|
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.
|
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
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||||
|
|
||||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
QUEUE_FILE = os.path.join(HERE, "queue.jsonl")
|
QUEUE_FILE = os.path.join(HERE, "queue.jsonl")
|
||||||
RESULTS_FILE = os.path.join(HERE, "results.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).
|
# Fallback demo queue if queue.jsonl is absent (so it runs out of the box).
|
||||||
DEMO = [
|
DEMO = [
|
||||||
@ -80,17 +118,28 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return self._send(204) # queue drained
|
return self._send(204) # queue drained
|
||||||
|
|
||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
if self.path != "/task-completed":
|
|
||||||
return self._send(404, {"error": "not found"})
|
|
||||||
n = int(self.headers.get("Content-Length", 0))
|
n = int(self.headers.get("Content-Length", 0))
|
||||||
rec = json.loads(self.rfile.read(n).decode() or "{}")
|
rec = json.loads(self.rfile.read(n).decode() or "{}")
|
||||||
rec["ts"] = time.time()
|
|
||||||
INFLIGHT.pop(rec.get("id"), None)
|
if self.path == "/task-completed":
|
||||||
with open(RESULTS_FILE, "a") as f:
|
rec["ts"] = time.time()
|
||||||
f.write(json.dumps(rec) + "\n")
|
INFLIGHT.pop(rec.get("id"), None)
|
||||||
assets = rec.get("assets") or []
|
with open(RESULTS_FILE, "a") as f:
|
||||||
print(f"[SERVER] <- {rec.get('id')} {str(rec.get('status')).upper()} | {len(assets)} asset(s)")
|
f.write(json.dumps(rec) + "\n")
|
||||||
self._send(200, {"ok": True})
|
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
|
def log_message(self, *a): # quiet the default noise
|
||||||
pass
|
pass
|
||||||
|
|||||||
@ -5,7 +5,8 @@
|
|||||||
"description": "Automate prompt execution, model selection, and video/image generation using local queue integration.",
|
"description": "Automate prompt execution, model selection, and video/image generation using local queue integration.",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"storage",
|
"storage",
|
||||||
"debugger"
|
"debugger",
|
||||||
|
"downloads"
|
||||||
],
|
],
|
||||||
"background": {
|
"background": {
|
||||||
"service_worker": "background.js"
|
"service_worker": "background.js"
|
||||||
|
|||||||
@ -37,6 +37,10 @@
|
|||||||
<button id="toggle-btn" class="btn btn-primary start-state">Start Automation</button>
|
<button id="toggle-btn" class="btn btn-primary start-state">Start Automation</button>
|
||||||
</div>
|
</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 -->
|
<!-- Console / Activity Logs -->
|
||||||
<div class="console-section">
|
<div class="console-section">
|
||||||
<div class="console-header">
|
<div class="console-header">
|
||||||
|
|||||||
4
popup.js
4
popup.js
@ -5,6 +5,7 @@ const DEFAULT_SETTINGS = {
|
|||||||
promptSel: '',
|
promptSel: '',
|
||||||
submitSel: '',
|
submitSel: '',
|
||||||
resultSel: '',
|
resultSel: '',
|
||||||
|
autoStart: false,
|
||||||
pollInterval: 4000,
|
pollInterval: 4000,
|
||||||
logs: []
|
logs: []
|
||||||
};
|
};
|
||||||
@ -16,6 +17,7 @@ const globalStatus = document.getElementById('global-status');
|
|||||||
const statusLabel = globalStatus.querySelector('.status-label');
|
const statusLabel = globalStatus.querySelector('.status-label');
|
||||||
const logConsole = document.getElementById('log-console');
|
const logConsole = document.getElementById('log-console');
|
||||||
const clearLogsBtn = document.getElementById('clear-logs');
|
const clearLogsBtn = document.getElementById('clear-logs');
|
||||||
|
const autoStartInput = document.getElementById('auto-start');
|
||||||
|
|
||||||
// Advanced inputs
|
// Advanced inputs
|
||||||
const selPromptInput = document.getElementById('sel-prompt');
|
const selPromptInput = document.getElementById('sel-prompt');
|
||||||
@ -26,6 +28,7 @@ const pollIntervalInput = document.getElementById('poll-interval');
|
|||||||
// Load settings on startup
|
// Load settings on startup
|
||||||
chrome.storage.local.get(DEFAULT_SETTINGS, (settings) => {
|
chrome.storage.local.get(DEFAULT_SETTINGS, (settings) => {
|
||||||
serverUrlInput.value = settings.serverUrl;
|
serverUrlInput.value = settings.serverUrl;
|
||||||
|
autoStartInput.checked = !!settings.autoStart;
|
||||||
selPromptInput.value = settings.promptSel;
|
selPromptInput.value = settings.promptSel;
|
||||||
selGenerateInput.value = settings.submitSel;
|
selGenerateInput.value = settings.submitSel;
|
||||||
selModelInput.value = settings.resultSel;
|
selModelInput.value = settings.resultSel;
|
||||||
@ -95,6 +98,7 @@ function saveSetting(key, value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
serverUrlInput.addEventListener('change', (e) => saveSetting('serverUrl', e.target.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));
|
selPromptInput.addEventListener('change', (e) => saveSetting('promptSel', e.target.value));
|
||||||
selGenerateInput.addEventListener('change', (e) => saveSetting('submitSel', e.target.value));
|
selGenerateInput.addEventListener('change', (e) => saveSetting('submitSel', e.target.value));
|
||||||
selModelInput.addEventListener('change', (e) => saveSetting('resultSel', e.target.value));
|
selModelInput.addEventListener('change', (e) => saveSetting('resultSel', e.target.value));
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user