Spaces:
Sleeping
Sleeping
| // Chrome live monitor with a built-in navigator. | |
| // | |
| // Talks to Chrome's DevTools Protocol on 127.0.0.1:9222 and serves a browser-like | |
| // UI on :3001: | |
| // - a live tab strip listing every open Chrome page (known AI services first), | |
| // - an address bar + back / forward / reload to drive the active tab anywhere, | |
| // - new-tab / close-tab buttons, | |
| // - click + type forwarded straight into the active page. | |
| // The active tab is brought to front before every screenshot so background tabs | |
| // still render. Tabs are keyed by their live CDP target id (not hardcoded). | |
| package main | |
| import ( | |
| _ "embed" | |
| "encoding/base64" | |
| "encoding/json" | |
| "fmt" | |
| "io" | |
| "log" | |
| "net/http" | |
| "net/http/httputil" | |
| "net/url" | |
| "os" | |
| "sort" | |
| "strings" | |
| "sync" | |
| "time" | |
| "github.com/gorilla/websocket" | |
| ) | |
| const cdpBase = "http://127.0.0.1:9222" | |
| //go:embed index.html | |
| var indexHTML []byte | |
| // knownTab is one of the AI services we pre-open and label nicely. | |
| type knownTab struct { | |
| Title string | |
| URL string // page to open on startup | |
| Match string // substring used to find an already-open page target | |
| } | |
| var knownTabs = []knownTab{ | |
| {"ChatGPT", "https://chatgpt.com/", "chatgpt.com"}, | |
| {"Gemini", "https://gemini.google.com/", "gemini.google.com"}, | |
| } | |
| // cdpTarget mirrors the relevant fields of an entry from GET /json. | |
| type cdpTarget struct { | |
| ID string `json:"id"` | |
| Type string `json:"type"` | |
| URL string `json:"url"` | |
| Title string `json:"title"` | |
| WS string `json:"webSocketDebuggerUrl"` | |
| } | |
| // tabInfo is what the UI consumes from /api/tabs. | |
| type tabInfo struct { | |
| ID string `json:"id"` | |
| Title string `json:"title"` | |
| URL string `json:"url"` | |
| } | |
| // cdpConn is a serialized request/response wrapper around one DevTools websocket. | |
| type cdpConn struct { | |
| c *websocket.Conn | |
| mu sync.Mutex | |
| id int | |
| } | |
| func (cc *cdpConn) call(method string, params map[string]any) (json.RawMessage, error) { | |
| cc.mu.Lock() | |
| defer cc.mu.Unlock() | |
| cc.id++ | |
| myID := cc.id | |
| if err := cc.c.WriteJSON(map[string]any{"id": myID, "method": method, "params": params}); err != nil { | |
| return nil, err | |
| } | |
| cc.c.SetReadDeadline(time.Now().Add(15 * time.Second)) | |
| for { | |
| _, data, err := cc.c.ReadMessage() | |
| if err != nil { | |
| return nil, err | |
| } | |
| var m struct { | |
| ID int `json:"id"` | |
| Result json.RawMessage `json:"result"` | |
| Error *struct { | |
| Message string `json:"message"` | |
| } `json:"error"` | |
| } | |
| if json.Unmarshal(data, &m) != nil { | |
| continue | |
| } | |
| if m.ID == myID { // ignore CDP events (no matching id) | |
| if m.Error != nil { | |
| return nil, fmt.Errorf("cdp %s: %s", method, m.Error.Message) | |
| } | |
| return m.Result, nil | |
| } | |
| } | |
| } | |
| // connections, one persistent websocket per target id. | |
| var ( | |
| conns = map[string]*cdpConn{} | |
| connMu sync.Mutex | |
| ) | |
| func httpGetJSON(url string, out any) error { | |
| resp, err := http.Get(url) | |
| if err != nil { | |
| return err | |
| } | |
| defer resp.Body.Close() | |
| return json.NewDecoder(resp.Body).Decode(out) | |
| } | |
| func listTargets() ([]cdpTarget, error) { | |
| var t []cdpTarget | |
| err := httpGetJSON(cdpBase+"/json", &t) | |
| return t, err | |
| } | |
| // pageTargets returns the open page targets, known services first then the rest. | |
| func pageTargets() ([]cdpTarget, error) { | |
| targets, err := listTargets() | |
| if err != nil { | |
| return nil, err | |
| } | |
| var pages []cdpTarget | |
| for _, t := range targets { | |
| if t.Type != "page" || strings.HasPrefix(t.URL, "devtools://") { | |
| continue | |
| } | |
| pages = append(pages, t) | |
| } | |
| rank := func(t cdpTarget) int { | |
| for i, k := range knownTabs { | |
| if strings.Contains(t.URL, k.Match) { | |
| return i | |
| } | |
| } | |
| return len(knownTabs) + 1 | |
| } | |
| sort.SliceStable(pages, func(i, j int) bool { return rank(pages[i]) < rank(pages[j]) }) | |
| return pages, nil | |
| } | |
| // friendlyTitle prefers a known service label, then the page title, then the URL. | |
| func friendlyTitle(t cdpTarget) string { | |
| for _, k := range knownTabs { | |
| if strings.Contains(t.URL, k.Match) { | |
| return k.Title | |
| } | |
| } | |
| if t.URL == "" || t.URL == "about:blank" { | |
| return "New Tab" | |
| } | |
| if strings.TrimSpace(t.Title) != "" { | |
| return t.Title | |
| } | |
| return t.URL | |
| } | |
| // browserWS returns the browser-level websocket URL (for Target.* calls). | |
| func browserWS() (string, error) { | |
| var v struct { | |
| WS string `json:"webSocketDebuggerUrl"` | |
| } | |
| if err := httpGetJSON(cdpBase+"/json/version", &v); err != nil { | |
| return "", err | |
| } | |
| return v.WS, nil | |
| } | |
| func dial(ws string) (*cdpConn, error) { | |
| c, _, err := websocket.DefaultDialer.Dial(ws, nil) | |
| if err != nil { | |
| return nil, err | |
| } | |
| return &cdpConn{c: c}, nil | |
| } | |
| // browserCall runs a single browser-level command (createTarget / closeTarget). | |
| func browserCall(method string, params map[string]any) (json.RawMessage, error) { | |
| bws, err := browserWS() | |
| if err != nil { | |
| return nil, err | |
| } | |
| bc, err := dial(bws) | |
| if err != nil { | |
| return nil, err | |
| } | |
| defer bc.c.Close() | |
| return bc.call(method, params) | |
| } | |
| // targetWSByID looks up a page target's websocket URL by its id. | |
| func targetWSByID(id string) (string, error) { | |
| targets, err := listTargets() | |
| if err != nil { | |
| return "", err | |
| } | |
| for _, t := range targets { | |
| if t.ID == id { | |
| return t.WS, nil | |
| } | |
| } | |
| return "", fmt.Errorf("no target %q", id) | |
| } | |
| // ensureKnown finds an already-open page for a known service, creating it if absent. | |
| func ensureKnown(k knownTab) error { | |
| targets, err := listTargets() | |
| if err != nil { | |
| return err | |
| } | |
| for _, tg := range targets { | |
| if tg.Type == "page" && strings.Contains(tg.URL, k.Match) { | |
| return nil | |
| } | |
| } | |
| _, err = browserCall("Target.createTarget", map[string]any{"url": k.URL}) | |
| return err | |
| } | |
| // conn returns a live cdpConn for the given target id, (re)dialing as needed. | |
| func conn(id string) (*cdpConn, error) { | |
| connMu.Lock() | |
| defer connMu.Unlock() | |
| if cc, ok := conns[id]; ok { | |
| if _, err := cc.call("Target.getTargetInfo", nil); err == nil { // cheap liveness probe | |
| return cc, nil | |
| } | |
| cc.c.Close() | |
| delete(conns, id) | |
| } | |
| ws, err := targetWSByID(id) | |
| if err != nil { | |
| return nil, err | |
| } | |
| cc, err := dial(ws) | |
| if err != nil { | |
| return nil, err | |
| } | |
| conns[id] = cc | |
| return cc, nil | |
| } | |
| // connForReq resolves the ?tab= target, writing an error response on failure. | |
| func connForReq(w http.ResponseWriter, r *http.Request) (*cdpConn, bool) { | |
| id := r.URL.Query().Get("tab") | |
| if id == "" { | |
| http.Error(w, "missing tab", http.StatusBadRequest) | |
| return nil, false | |
| } | |
| cc, err := conn(id) | |
| if err != nil { | |
| http.Error(w, err.Error(), http.StatusBadGateway) | |
| return nil, false | |
| } | |
| return cc, true | |
| } | |
| func screenHandler(w http.ResponseWriter, r *http.Request) { | |
| cc, ok := connForReq(w, r) | |
| if !ok { | |
| return | |
| } | |
| cc.call("Page.bringToFront", nil) // ensure background tab renders | |
| res, err := cc.call("Page.captureScreenshot", map[string]any{"format": "png"}) | |
| if err != nil { | |
| http.Error(w, err.Error(), http.StatusBadGateway) | |
| return | |
| } | |
| var out struct { | |
| Data string `json:"data"` | |
| } | |
| if json.Unmarshal(res, &out) != nil || out.Data == "" { | |
| http.Error(w, "no screenshot", http.StatusBadGateway) | |
| return | |
| } | |
| png, err := base64.StdEncoding.DecodeString(out.Data) | |
| if err != nil { | |
| http.Error(w, err.Error(), http.StatusBadGateway) | |
| return | |
| } | |
| w.Header().Set("Content-Type", "image/png") | |
| w.Header().Set("Cache-Control", "no-store") | |
| w.Write(png) | |
| } | |
| func inputHandler(w http.ResponseWriter, r *http.Request) { | |
| var in struct { | |
| Type string `json:"type"` | |
| X float64 `json:"x"` | |
| Y float64 `json:"y"` | |
| Button string `json:"button"` | |
| Text string `json:"text"` | |
| } | |
| if json.NewDecoder(r.Body).Decode(&in) != nil { | |
| http.Error(w, "bad json", http.StatusBadRequest) | |
| return | |
| } | |
| cc, ok := connForReq(w, r) | |
| if !ok { | |
| return | |
| } | |
| switch in.Type { | |
| case "mousedown", "mouseup": | |
| mt := "mousePressed" | |
| if in.Type == "mouseup" { | |
| mt = "mouseReleased" | |
| } | |
| btn := in.Button | |
| if btn == "" { | |
| btn = "left" | |
| } | |
| cc.call("Input.dispatchMouseEvent", map[string]any{ | |
| "type": mt, "x": in.X, "y": in.Y, "button": btn, "clickCount": 1, | |
| }) | |
| case "keydown": | |
| if in.Text == "\r" || in.Text == "\n" { | |
| cc.call("Input.dispatchKeyEvent", map[string]any{ | |
| "type": "keyDown", "key": "Enter", "windowsVirtualKeyCode": 13, "text": "\r", | |
| }) | |
| cc.call("Input.dispatchKeyEvent", map[string]any{"type": "keyUp", "key": "Enter", "windowsVirtualKeyCode": 13}) | |
| } else if in.Text == "\b" { | |
| cc.call("Input.dispatchKeyEvent", map[string]any{"type": "keyDown", "key": "Backspace", "windowsVirtualKeyCode": 8}) | |
| cc.call("Input.dispatchKeyEvent", map[string]any{"type": "keyUp", "key": "Backspace", "windowsVirtualKeyCode": 8}) | |
| } else if in.Text != "" { | |
| cc.call("Input.insertText", map[string]any{"text": in.Text}) | |
| } | |
| } | |
| w.WriteHeader(http.StatusNoContent) | |
| } | |
| // normalizeURL prepends https:// when the user typed a bare host. | |
| func normalizeURL(u string) string { | |
| u = strings.TrimSpace(u) | |
| if u == "" { | |
| return "" | |
| } | |
| if !strings.Contains(u, "://") { | |
| u = "https://" + u | |
| } | |
| return u | |
| } | |
| func navigateHandler(w http.ResponseWriter, r *http.Request) { | |
| var in struct { | |
| URL string `json:"url"` | |
| } | |
| if json.NewDecoder(r.Body).Decode(&in) != nil { | |
| http.Error(w, "bad json", http.StatusBadRequest) | |
| return | |
| } | |
| url := normalizeURL(in.URL) | |
| if url == "" { | |
| http.Error(w, "empty url", http.StatusBadRequest) | |
| return | |
| } | |
| cc, ok := connForReq(w, r) | |
| if !ok { | |
| return | |
| } | |
| if _, err := cc.call("Page.navigate", map[string]any{"url": url}); err != nil { | |
| http.Error(w, err.Error(), http.StatusBadGateway) | |
| return | |
| } | |
| w.WriteHeader(http.StatusNoContent) | |
| } | |
| func reloadHandler(w http.ResponseWriter, r *http.Request) { | |
| cc, ok := connForReq(w, r) | |
| if !ok { | |
| return | |
| } | |
| cc.call("Page.reload", nil) | |
| w.WriteHeader(http.StatusNoContent) | |
| } | |
| // historyStep moves the active tab back (delta -1) or forward (delta +1). | |
| func historyStep(cc *cdpConn, delta int) error { | |
| res, err := cc.call("Page.getNavigationHistory", nil) | |
| if err != nil { | |
| return err | |
| } | |
| var h struct { | |
| CurrentIndex int `json:"currentIndex"` | |
| Entries []struct { | |
| ID int `json:"id"` | |
| } `json:"entries"` | |
| } | |
| if err := json.Unmarshal(res, &h); err != nil { | |
| return err | |
| } | |
| target := h.CurrentIndex + delta | |
| if target < 0 || target >= len(h.Entries) { | |
| return nil // nothing to do at the ends | |
| } | |
| _, err = cc.call("Page.navigateToHistoryEntry", map[string]any{"entryId": h.Entries[target].ID}) | |
| return err | |
| } | |
| func historyHandler(delta int) http.HandlerFunc { | |
| return func(w http.ResponseWriter, r *http.Request) { | |
| cc, ok := connForReq(w, r) | |
| if !ok { | |
| return | |
| } | |
| if err := historyStep(cc, delta); err != nil { | |
| http.Error(w, err.Error(), http.StatusBadGateway) | |
| return | |
| } | |
| w.WriteHeader(http.StatusNoContent) | |
| } | |
| } | |
| func newtabHandler(w http.ResponseWriter, r *http.Request) { | |
| var in struct { | |
| URL string `json:"url"` | |
| } | |
| json.NewDecoder(r.Body).Decode(&in) | |
| url := normalizeURL(in.URL) | |
| if url == "" { | |
| url = "about:blank" | |
| } | |
| res, err := browserCall("Target.createTarget", map[string]any{"url": url}) | |
| if err != nil { | |
| http.Error(w, err.Error(), http.StatusBadGateway) | |
| return | |
| } | |
| var out struct { | |
| TargetID string `json:"targetId"` | |
| } | |
| json.Unmarshal(res, &out) | |
| w.Header().Set("Content-Type", "application/json") | |
| json.NewEncoder(w).Encode(map[string]string{"id": out.TargetID}) | |
| } | |
| func closetabHandler(w http.ResponseWriter, r *http.Request) { | |
| id := r.URL.Query().Get("tab") | |
| if id == "" { | |
| http.Error(w, "missing tab", http.StatusBadRequest) | |
| return | |
| } | |
| if _, err := browserCall("Target.closeTarget", map[string]any{"targetId": id}); err != nil { | |
| http.Error(w, err.Error(), http.StatusBadGateway) | |
| return | |
| } | |
| connMu.Lock() | |
| if cc, ok := conns[id]; ok { | |
| cc.c.Close() | |
| delete(conns, id) | |
| } | |
| connMu.Unlock() | |
| w.WriteHeader(http.StatusNoContent) | |
| } | |
| func tabsHandler(w http.ResponseWriter, r *http.Request) { | |
| pages, err := pageTargets() | |
| if err != nil { | |
| http.Error(w, err.Error(), http.StatusBadGateway) | |
| return | |
| } | |
| out := make([]tabInfo, 0, len(pages)) | |
| for _, t := range pages { | |
| out = append(out, tabInfo{ID: t.ID, Title: friendlyTitle(t), URL: t.URL}) | |
| } | |
| w.Header().Set("Content-Type", "application/json") | |
| json.NewEncoder(w).Encode(out) | |
| } | |
| func chromeLogHandler(w http.ResponseWriter, r *http.Request) { | |
| f, err := os.Open("/home/chrome/chrome.log") | |
| if err != nil { | |
| http.Error(w, "no log", http.StatusNotFound) | |
| return | |
| } | |
| defer f.Close() | |
| w.Header().Set("Content-Type", "text/plain; charset=utf-8") | |
| io.Copy(w, f) | |
| } | |
| // backendLogHandler serves the per-server logs (API-key gated) so we can diagnose | |
| // the gpt/gemini backends without shelling into the container: | |
| // /logs/chatgpt /logs/gemini (also /logs/monitor) | |
| func backendLogHandler(w http.ResponseWriter, r *http.Request) { | |
| if !authOK(w, r) { | |
| return | |
| } | |
| name := strings.TrimPrefix(r.URL.Path, "/logs/") | |
| paths := map[string]string{ | |
| "chatgpt": "/home/chrome/chatgpt.log", | |
| "gemini": "/home/chrome/gemini.log", | |
| "monitor": "/home/chrome/monitor.log", | |
| } | |
| p, ok := paths[name] | |
| if !ok { | |
| http.Error(w, "unknown log (use chatgpt|gemini|monitor)", http.StatusNotFound) | |
| return | |
| } | |
| f, err := os.Open(p) | |
| if err != nil { | |
| http.Error(w, "no log", http.StatusNotFound) | |
| return | |
| } | |
| defer f.Close() | |
| w.Header().Set("Content-Type", "text/plain; charset=utf-8") | |
| io.Copy(w, f) | |
| } | |
| func statsHandler(w http.ResponseWriter, r *http.Request) { | |
| mem := readMeminfo() | |
| w.Header().Set("Content-Type", "application/json") | |
| json.NewEncoder(w).Encode(map[string]any{ | |
| "memUsedGB": mem.usedGB, | |
| "memTotalGB": mem.totalGB, | |
| "memPct": mem.pct, | |
| "cpu": readCPUPercent(), | |
| "uptime": readUptime(), | |
| }) | |
| } | |
| // ---- API gateway ---------------------------------------------------------- | |
| // The monitor doubles as the single public entry point for the backend | |
| // servers that run on localhost inside this same container. HF exposes only one | |
| // port (3001), so /gpt and /gemini are reverse-proxied to the servers' | |
| // loopback HTTP ports. Both are gated by an API key (fail-closed). | |
| var apiKey = os.Getenv("API_KEY") | |
| // authOK enforces the API key on gateway routes. If API_KEY is unset it refuses | |
| // every call (503) so the logged-in accounts are never accidentally wide open. | |
| func authOK(w http.ResponseWriter, r *http.Request) bool { | |
| if apiKey == "" { | |
| http.Error(w, "gateway disabled: API_KEY not set on the server", http.StatusServiceUnavailable) | |
| return false | |
| } | |
| // Check ?key= and the apikey cookie BEFORE the Authorization header. On a | |
| // private HF Space the platform claims Authorization: Bearer <HF_token>, so the | |
| // API key must travel via ?key= or the cookie; only fall back to Authorization | |
| // (public Space / OpenAI-compatible clients) when those are absent. | |
| got := r.URL.Query().Get("key") | |
| if got == "" { | |
| if c, err := r.Cookie("apikey"); err == nil { | |
| got = c.Value | |
| } | |
| } | |
| if got == "" { | |
| got = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") | |
| } | |
| if got != apiKey { | |
| http.Error(w, "unauthorized", http.StatusUnauthorized) | |
| return false | |
| } | |
| return true | |
| } | |
| // gate wraps a handler so it requires the API key (Bearer header, ?key=, or the | |
| // apikey cookie). Every monitor route that can view or drive the logged-in | |
| // browser is gated — nothing is publicly reachable. | |
| func gate(h http.HandlerFunc) http.HandlerFunc { | |
| return func(w http.ResponseWriter, r *http.Request) { | |
| if !authOK(w, r) { | |
| return | |
| } | |
| h(w, r) | |
| } | |
| } | |
| // gatewayHandler builds an API-key-gated reverse proxy that strips prefix and | |
| // forwards to target (e.g. /gpt/api/chat -> http://127.0.0.1:9225/api/chat). | |
| func gatewayHandler(prefix, target string) http.HandlerFunc { | |
| u, err := url.Parse(target) | |
| if err != nil { | |
| log.Fatalf("bad gateway target %q: %v", target, err) | |
| } | |
| proxy := &httputil.ReverseProxy{ | |
| Director: func(r *http.Request) { | |
| r.URL.Scheme = u.Scheme | |
| r.URL.Host = u.Host | |
| r.Host = u.Host | |
| p := strings.TrimPrefix(r.URL.Path, prefix) | |
| if p == "" || p[0] != '/' { | |
| p = "/" + p | |
| } | |
| r.URL.Path = p | |
| }, | |
| ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { | |
| http.Error(w, "backend unavailable: "+err.Error(), http.StatusBadGateway) | |
| }, | |
| } | |
| return func(w http.ResponseWriter, r *http.Request) { | |
| if !authOK(w, r) { | |
| return | |
| } | |
| proxy.ServeHTTP(w, r) | |
| } | |
| } | |
| func main() { | |
| // Pre-open the known AI services in the background so they're ready by first view. | |
| go func() { | |
| for i := 0; i < 60; i++ { | |
| ok := true | |
| for _, k := range knownTabs { | |
| if err := ensureKnown(k); err != nil { | |
| ok = false | |
| } | |
| } | |
| if ok { | |
| log.Println("all known tabs ready") | |
| return | |
| } | |
| time.Sleep(2 * time.Second) | |
| } | |
| }() | |
| // The monitor UI (/, /api/*, /chrome.log) is open — the Space is private, so | |
| // HuggingFace's own auth already gates who can reach it (the owner). The browser | |
| // can't attach ?key= when HF serves the private subdomain, so requiring our key | |
| // here locks the owner out of their own live view. Only the AI gateway | |
| // (/gpt /gemini) and /logs stay API-key-gated for external programmatic use. | |
| http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { | |
| if r.URL.Path != "/" { | |
| http.NotFound(w, r) | |
| return | |
| } | |
| w.Header().Set("Content-Type", "text/html; charset=utf-8") | |
| w.Write(indexHTML) | |
| }) | |
| http.HandleFunc("/api/tabs", tabsHandler) | |
| http.HandleFunc("/api/screen", screenHandler) | |
| http.HandleFunc("/api/input", inputHandler) | |
| http.HandleFunc("/api/navigate", navigateHandler) | |
| http.HandleFunc("/api/reload", reloadHandler) | |
| http.HandleFunc("/api/back", historyHandler(-1)) | |
| http.HandleFunc("/api/forward", historyHandler(+1)) | |
| http.HandleFunc("/api/newtab", newtabHandler) | |
| http.HandleFunc("/api/closetab", closetabHandler) | |
| http.HandleFunc("/api/stats", statsHandler) | |
| http.HandleFunc("/chrome.log", chromeLogHandler) | |
| http.HandleFunc("/logs/", backendLogHandler) | |
| // API gateway → the three backend servers on localhost (API-key gated). | |
| http.HandleFunc("/gpt/", gatewayHandler("/gpt", "http://127.0.0.1:9225")) | |
| http.HandleFunc("/gemini/", gatewayHandler("/gemini", "http://127.0.0.1:8000")) | |
| if apiKey == "" { | |
| log.Println("WARNING: API_KEY not set — /gpt /gemini are disabled (503) until it is set") | |
| } | |
| addr := ":3001" | |
| if p := os.Getenv("MONITOR_PORT"); p != "" { | |
| addr = ":" + p | |
| } | |
| log.Printf("navigator monitor listening on %s", addr) | |
| log.Fatal(http.ListenAndServe(addr, nil)) | |
| } | |