Files
juxtapo 1ac06c1bbe v0.3: dashboard local mode, openbridge tab reuse, tab activation fix
- dashboard.html: local dashboard.json mode (10s poll) alongside artifact db mode;
  clickable cards behind an http(s)-only safeUrl filter; meta charset; mode-aware
  freshness line
- openbridge.py: localhost shim exposing open_link so dashboard clicks reuse the
  platform's warm tab instead of spawning one per click
- cdp.py: Tab.activate(); get_or_open_tab activates reused tabs — a backgrounded
  tab never finishes rendering, which made YouTube's login check read a signed-in
  account as signed out
- README/MANIFEST: corrected CDP port (9336, was documented as 9222), removed the
  false claim that the server writes dashboard.json, documented the reboot/VPS
  requirements
- .gitignore: __pycache__/, *.pyc, state.json, Archived/; removed stale tracked
  pycache and state.json from index
2026-09-11 10:18:38 +08:00

135 lines
4.8 KiB
Python

"""openbridge — a localhost shim so the dashboard can reuse warm tabs.
The dashboard is a static page served off a plain file server; it cannot speak
MCP. This exposes exactly one capability of the socialmag server — `open_link` —
over 127.0.0.1, so clicking a card navigates the platform's existing tab instead
of spawning a new one on every click.
It does not reimplement open_link. It imports `tool_open_link` and calls it, so
validation (unknown platform, missing url, non-http(s) scheme) and tab matching
stay in one place: change server.py and this follows.
Run:
python3 -m socialmag.openbridge # 127.0.0.1:8322
python3 -m socialmag.openbridge --port N
Endpoints:
GET /health -> {"ok": true, "platforms": [...]}
GET /open?platform=X&url=Y -> the open_link result, or {"error": ...}
The dashboard treats an unreachable bridge as "just open a new tab" — it falls
back to the anchor's normal behaviour, so the page works with this not running.
"""
import argparse
import asyncio
import json
import sys
import threading
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from .queue import PostQueue
from .server import PLATFORM_DOMAINS, tool_open_link
# The dashboard's clicks are human-paced and must feel instant. The posting
# queue's 3-6s inter-job spacing exists to look human while publishing; applied
# to a click it would just look broken.
CLICK_DELAY = (0.0, 0.0)
class _Loop:
"""One asyncio loop on a background thread, with a started PostQueue.
Clicks still go through a queue: two fast clicks must not open two CDP
connections racing for the same tab.
"""
def __init__(self):
self.loop = asyncio.new_event_loop()
self.queue = PostQueue(inter_platform_delay=CLICK_DELAY)
self._ready = threading.Event()
self.thread = threading.Thread(target=self._run, name="openbridge", daemon=True)
self.thread.start()
if not self._ready.wait(timeout=10):
raise RuntimeError("openbridge: event loop failed to start within 10s")
def _run(self):
asyncio.set_event_loop(self.loop)
self.loop.run_until_complete(self.queue.start())
self.loop.call_soon(self._ready.set)
self.loop.run_forever()
def call(self, coro, timeout=120):
return asyncio.run_coroutine_threadsafe(coro, self.loop).result(timeout)
class Handler(BaseHTTPRequestHandler):
server_version = "socialmag-openbridge"
bridge = None # set in serve()
def _send(self, code, payload):
body = json.dumps(payload).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
# The dashboard is served from a different port, so this is cross-origin.
# The listener is bound to 127.0.0.1, so the reachable surface is local.
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def do_GET(self):
parts = urllib.parse.urlsplit(self.path)
query = urllib.parse.parse_qs(parts.query)
if parts.path == "/health":
return self._send(200, {"ok": True, "platforms": sorted(PLATFORM_DOMAINS)})
if parts.path != "/open":
return self._send(404, {"error": f"no such endpoint: {parts.path}"})
platform = (query.get("platform") or [None])[0]
url = (query.get("url") or [None])[0]
try:
result = self.bridge.call(
tool_open_link({"platform": platform, "url": url}, self.bridge.queue)
)
except ValueError as e:
# open_link's own validation — a bad request, not a broken bridge.
return self._send(400, {"error": str(e)})
except Exception as e:
return self._send(502, {"error": f"{type(e).__name__}: {e}"})
return self._send(200, result)
def log_message(self, fmt, *args):
sys.stderr.write("[openbridge] %s\n" % (fmt % args))
def serve(port):
Handler.bridge = _Loop()
# No port fallback on purpose: if 8322 is taken, something else is already
# answering there and silently moving would point the dashboard at nothing.
httpd = ThreadingHTTPServer(("127.0.0.1", port), Handler)
print(
f"[openbridge] listening on http://127.0.0.1:{port} "
f"(/health, /open?platform=&url=)",
file=sys.stderr,
flush=True,
)
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("[openbridge] stopping", file=sys.stderr)
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--port", type=int, default=8322)
serve(ap.parse_args().port)
if __name__ == "__main__":
main()