diff --git a/cmd/wsh/cmd/wshcmd-web.go b/cmd/wsh/cmd/wshcmd-web.go index bfda76b82c..a025707aae 100644 --- a/cmd/wsh/cmd/wshcmd-web.go +++ b/cmd/wsh/cmd/wshcmd-web.go @@ -6,6 +6,8 @@ package cmd import ( "encoding/json" "fmt" + "strings" + "time" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/waveobj" @@ -40,10 +42,12 @@ var webGetAll bool var webGetJson bool var webOpenMagnified bool var webOpenReplaceBlock string +var webOpenCdp bool func init() { webOpenCmd.Flags().BoolVarP(&webOpenMagnified, "magnified", "m", false, "open view in magnified mode") webOpenCmd.Flags().StringVarP(&webOpenReplaceBlock, "replace", "r", "", "replace block") + webOpenCmd.Flags().BoolVarP(&webOpenCdp, "cdp", "c", false, "start CDP for the created web widget (requires debug:webcdp=true)") webCmd.AddCommand(webOpenCmd) webGetCmd.Flags().BoolVarP(&webGetInner, "inner", "", false, "get inner html (instead of outer)") webGetCmd.Flags().BoolVarP(&webGetAll, "all", "", false, "get all matches (querySelectorAll)") @@ -137,5 +141,53 @@ func webOpenRun(cmd *cobra.Command, args []string) (rtnErr error) { return fmt.Errorf("creating block: %w", err) } WriteStdout("created block %s\n", oref) + + if webOpenCdp { + // Fetch workspace/tab info for the newly-created block then start CDP. + blockInfo, err := wshclient.BlockInfoCommand(RpcClient, oref.OID, nil) + if err != nil { + return fmt.Errorf("getting block info for created web widget: %w", err) + } + req := wshrpc.CommandWebCdpStartData{ + WorkspaceId: blockInfo.WorkspaceId, + BlockId: oref.OID, + TabId: blockInfo.TabId, + Port: 0, + IdleTimeoutMs: int((5 * time.Minute) / time.Millisecond), + } + + // Web blocks are created asynchronously in the UI; the underlying WebContents may not exist yet. + // Retry briefly so `wsh web open --cdp` works reliably. + var cdpResp *wshrpc.CommandWebCdpStartRtnData + var cdpErr error + deadline := time.Now().Add(7 * time.Second) + for { + cdpResp, cdpErr = wshclient.WebCdpStartCommand( + RpcClient, + req, + &wshrpc.RpcOpts{Route: wshutil.ElectronRoute, Timeout: 5000}, + ) + if cdpErr == nil { + break + } + errStr := cdpErr.Error() + // Only retry the “not ready yet” cases. Fail fast for config gating or other errors. + if strings.Contains(errStr, "no webcontents found") || strings.Contains(errStr, "timeout waiting for response") { + if time.Now().After(deadline) { + break + } + time.Sleep(200 * time.Millisecond) + continue + } + break + } + if cdpErr != nil { + // Preserve the created block output so user can recover; then return error. + return fmt.Errorf("starting cdp for created web widget: %w", cdpErr) + } + WriteStdout("cdp wsurl: %s\n", cdpResp.WsUrl) + WriteStdout("inspector: %s\n", cdpResp.InspectorUrl) + WriteStdout("host=%s port=%d targetid=%s\n", cdpResp.Host, cdpResp.Port, cdpResp.TargetId) + } return nil } diff --git a/cmd/wsh/cmd/wshcmd-webcdp.go b/cmd/wsh/cmd/wshcmd-webcdp.go new file mode 100644 index 0000000000..b4b51fb3d7 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-webcdp.go @@ -0,0 +1,287 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "sort" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" + "github.com/wavetermdev/waveterm/pkg/wshutil" +) + +var webCdpCmd = &cobra.Command{ + Use: "cdp [start|stop|status]", + Short: "Expose a CDP websocket for a web widget", + Long: "Expose a local Chrome DevTools Protocol (CDP) websocket for a web widget. WARNING: CDP grants full control of the web widget (DOM, cookies, JS execution).", + PersistentPreRunE: preRunSetupRpcClient, + RunE: webCdpListRun, +} + +var webCdpStartCmd = &cobra.Command{ + Use: "start", + Short: "Start a local CDP websocket proxy for a web widget", + Args: cobra.NoArgs, + RunE: webCdpStartRun, +} + +var webCdpStopCmd = &cobra.Command{ + Use: "stop", + Short: "Stop a local CDP websocket proxy for a web widget", + Args: cobra.NoArgs, + RunE: webCdpStopRun, +} + +var webCdpStatusCmd = &cobra.Command{ + Use: "status", + Short: "List active CDP websocket proxies", + Args: cobra.NoArgs, + RunE: webCdpStatusRun, +} + +var webCdpJson bool + +func init() { + webCdpStartCmd.Flags().BoolVar(&webCdpJson, "json", false, "output as json") + + webCdpStatusCmd.Flags().BoolVar(&webCdpJson, "json", false, "output as json") + + webCdpCmd.AddCommand(webCdpStartCmd) + webCdpCmd.AddCommand(webCdpStopCmd) + webCdpCmd.AddCommand(webCdpStatusCmd) + + // attach under: wsh web cdp ... + webCmd.AddCommand(webCdpCmd) +} + +type webCdpListEntry struct { + BlockId string + TabId string + Url string + CdpActive bool + CdpWsUrl string + WorkspaceId string +} + +func getCurrentWorkspaceId() (string, error) { + // Prefer resolving from current block context if available. + if os.Getenv("WAVETERM_BLOCKID") != "" { + oref, err := resolveSimpleId("this") + if err != nil { + return "", err + } + bi, err := wshclient.BlockInfoCommand(RpcClient, oref.OID, nil) + if err != nil { + return "", err + } + return bi.WorkspaceId, nil + } + return "", fmt.Errorf("no WAVETERM_BLOCKID set (run inside a Wave session or pass -b )") +} + +func listWebBlocksInCurrentWorkspace() ([]webCdpListEntry, error) { + wsId, err := getCurrentWorkspaceId() + if err != nil { + return nil, err + } + blocks, err := wshclient.BlocksListCommand(RpcClient, wshrpc.BlocksListRequest{WorkspaceId: wsId}, &wshrpc.RpcOpts{Timeout: 5000}) + if err != nil { + return nil, err + } + status, err := wshclient.WebCdpStatusCommand(RpcClient, &wshrpc.RpcOpts{Route: wshutil.ElectronRoute, Timeout: 5000}) + if err != nil { + return nil, err + } + activeMap := make(map[string]wshrpc.WebCdpStatusEntry) + for _, s := range status { + activeMap[s.BlockId] = s + } + var out []webCdpListEntry + for _, b := range blocks { + if b.Meta.GetString(waveobj.MetaKey_View, "") != "web" { + continue + } + ent := webCdpListEntry{ + BlockId: b.BlockId, + TabId: b.TabId, + WorkspaceId: b.WorkspaceId, + Url: b.Meta.GetString(waveobj.MetaKey_Url, ""), + } + if st, ok := activeMap[b.BlockId]; ok { + ent.CdpActive = true + ent.CdpWsUrl = st.WsUrl + } + out = append(out, ent) + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].TabId != out[j].TabId { + return out[i].TabId < out[j].TabId + } + return out[i].BlockId < out[j].BlockId + }) + return out, nil +} + +func printWebCdpList(entries []webCdpListEntry) { + w := tabwriter.NewWriter(WrappedStdout, 0, 0, 2, ' ', 0) + defer w.Flush() + fmt.Fprintf(w, "BLOCK ID\tTAB ID\tURL\tCDP\tWSURL\n") + for _, e := range entries { + cdp := "no" + wsurl := "" + if e.CdpActive { + cdp = "yes" + wsurl = e.CdpWsUrl + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", e.BlockId, e.TabId, e.Url, cdp, wsurl) + } +} + +func webCdpListRun(cmd *cobra.Command, args []string) error { + if len(args) != 0 { + return fmt.Errorf("unexpected arguments") + } + entries, err := listWebBlocksInCurrentWorkspace() + if err != nil { + return err + } + if len(entries) == 0 { + WriteStdout("No web widgets found in this workspace\n") + return nil + } + printWebCdpList(entries) + return nil +} + +func mustBeWebBlock(fullORef *waveobj.ORef) (*wshrpc.BlockInfoData, error) { + blockInfo, err := wshclient.BlockInfoCommand(RpcClient, fullORef.OID, nil) + if err != nil { + return nil, fmt.Errorf("getting block info: %w", err) + } + if blockInfo.Block.Meta.GetString(waveobj.MetaKey_View, "") != "web" { + return nil, fmt.Errorf("block %s is not a web block", fullORef.OID) + } + return blockInfo, nil +} + +func resolveBlockArgFromContext() error { + thisORef, err := resolveSimpleId("this") + if err != nil { + return nil + } + _, err = mustBeWebBlock(thisORef) + if err == nil { + blockArg = "this" + return nil + } + entries, lerr := listWebBlocksInCurrentWorkspace() + if lerr == nil && len(entries) > 0 { + printWebCdpList(entries) + return fmt.Errorf("no -b specified and current block is not a web widget; use: wsh web cdp start -b ") + } + return err +} + +func webCdpStartRun(cmd *cobra.Command, args []string) error { + if strings.TrimSpace(blockArg) == "" { + if err := resolveBlockArgFromContext(); err != nil { + return err + } + } + + fullORef, err := resolveBlockArg() + if err != nil { + return fmt.Errorf("resolving blockid: %w", err) + } + blockInfo, err := mustBeWebBlock(fullORef) + if err != nil { + return err + } + req := wshrpc.CommandWebCdpStartData{ + WorkspaceId: blockInfo.WorkspaceId, + BlockId: fullORef.OID, + TabId: blockInfo.TabId, + Port: 0, + IdleTimeoutMs: 0, + } + resp, err := wshclient.WebCdpStartCommand(RpcClient, req, &wshrpc.RpcOpts{ + Route: wshutil.ElectronRoute, + Timeout: 5000, + }) + if err != nil { + return err + } + if webCdpJson { + barr, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return fmt.Errorf("json encoding: %w", err) + } + WriteStdout("%s\n", string(barr)) + return nil + } + WriteStdout("cdp wsurl: %s\n", resp.WsUrl) + WriteStdout("inspector: %s\n", resp.InspectorUrl) + WriteStdout("host=%s port=%d targetid=%s\n", resp.Host, resp.Port, resp.TargetId) + WriteStdout("http: http://%s:%d (try /json)\n", resp.Host, resp.Port) + return nil +} + +func webCdpStopRun(cmd *cobra.Command, args []string) error { + if strings.TrimSpace(blockArg) == "" { + if err := resolveBlockArgFromContext(); err != nil { + return err + } + } + fullORef, err := resolveBlockArg() + if err != nil { + return fmt.Errorf("resolving blockid: %w", err) + } + blockInfo, err := mustBeWebBlock(fullORef) + if err != nil { + return err + } + req := wshrpc.CommandWebCdpStopData{ + WorkspaceId: blockInfo.WorkspaceId, + BlockId: fullORef.OID, + TabId: blockInfo.TabId, + } + err = wshclient.WebCdpStopCommand(RpcClient, req, &wshrpc.RpcOpts{ + Route: wshutil.ElectronRoute, + Timeout: 5000, + }) + if err != nil { + return err + } + WriteStdout("stopped cdp proxy for block %s\n", fullORef.OID) + return nil +} + +func webCdpStatusRun(cmd *cobra.Command, args []string) error { + resp, err := wshclient.WebCdpStatusCommand(RpcClient, &wshrpc.RpcOpts{ + Route: wshutil.ElectronRoute, + Timeout: 5000, + }) + if err != nil { + return err + } + if webCdpJson { + barr, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return fmt.Errorf("json encoding: %w", err) + } + WriteStdout("%s\n", string(barr)) + return nil + } + for _, e := range resp { + WriteStdout("%s %s\n", e.BlockId, e.WsUrl) + } + return nil +} diff --git a/docs/docs/config.mdx b/docs/docs/config.mdx index 778fd1c1bf..1f09fee3fb 100644 --- a/docs/docs/config.mdx +++ b/docs/docs/config.mdx @@ -101,6 +101,8 @@ wsh editconfig | window:confirmonclose | bool | when `true`, a prompt will ask a user to confirm that they want to close a window if it has an unsaved workspace with more than one tab (defaults to `true`) | | window:dimensions | string | set the default dimensions for new windows using the format "WIDTHxHEIGHT" (e.g. "1920x1080"). when a new window is created, these dimensions will be automatically applied. The width and height values should be specified in pixels. | | telemetry:enabled | bool | set to enable/disable telemetry | +| debug:webcdp | bool | (debug) enable `wsh web cdp` to expose a CDP websocket for web widgets. **This grants full control of the web widget (DOM, cookies, JS execution).** disabled by default. | +| debug:webcdpport | int | (debug) set the shared WebWidget CDP server port (Chrome-style `/json` endpoints), bound to `127.0.0.1`. default 9222. requires app restart. | For reference, this is the current default configuration (v0.11.5): diff --git a/docs/docs/wsh-reference.mdx b/docs/docs/wsh-reference.mdx index 1aa28c8c50..508d34b005 100644 --- a/docs/docs/wsh-reference.mdx +++ b/docs/docs/wsh-reference.mdx @@ -139,12 +139,14 @@ wsh ai architecture.png api-spec.pdf server.go -m "review the system design" ``` **File Size Limits:** + - Text files: 200KB maximum - PDF files: 5MB maximum - Image files: 7MB maximum (accounts for base64 encoding overhead) - Maximum 15 files per command **Flags:** + - `-m, --message ` - Add message text along with files - `-s, --submit` - Auto-submit immediately (default waits for user) - `-n, --new` - Clear current chat and start fresh conversation @@ -345,7 +347,7 @@ This will connect to a WSL distribution on the local machine. It will use the de The `web` command opens URLs in a web block within Wave Terminal. ```sh -wsh web open [url] [-m] [-r blockid] +wsh web open [url] [-m] [-r blockid] [--cdp|-c] ``` You can open a specific URL or perform a search using the configured search engine. @@ -354,6 +356,7 @@ Flags: - `-m, --magnified` - open the web block in magnified mode - `-r, --replace ` - replace an existing block instead of creating a new one +- `--cdp, -c` - start a local CDP websocket for the created web widget (requires `debug:webcdp=true` and app restart) Examples: @@ -369,10 +372,41 @@ wsh web open -m https://github.com # Replace an existing block wsh web open -r 2 https://example.com + +# Create web widget with CDP enabled +wsh web open --cdp https://example.com ``` The command will open a new web block with the desired page, or replace an existing block if the `-r` flag is used. Note that `--replace` and `--magnified` cannot be used together. +### CDP + +Wave can expose a local Chrome DevTools Protocol (CDP) websocket for web widgets. + +```sh +# List web widgets (in current workspace) and show which have CDP active +wsh web cdp + +# Start CDP for a specific web widget (or current block if it's a web widget) +wsh web cdp start [-b ] + +# Stop CDP +wsh web cdp stop [-b ] + +# List active controlled web widgets +wsh web cdp status [--json] +``` + +When enabled, Wave exposes a Chrome-style remote debugging endpoint on `127.0.0.1` (see `debug:webcdpport`). Tools can discover targets via: + +```sh +curl http://127.0.0.1:/json +``` + +When no `-b` flag is given, `start` and `stop` auto-resolve to the current block if it is a web widget. Use `--json` with `start` or `status` to get machine-readable output. + +Note: CDP is a powerful interface (DOM/JS/cookies). It is gated behind `debug:webcdp=true` in `settings.json`. + --- ## notify @@ -855,6 +889,7 @@ wsh blocks list [flags] List all blocks with optional filtering by workspace, window, tab, or view type. Output can be formatted as a table (default) or JSON for scripting. Flags: + - `--workspace ` - restrict to specific workspace id - `--window ` - restrict to specific window id - `--tab ` - restrict to specific tab id @@ -878,7 +913,6 @@ wsh blocks list --workspace=12d0c067-378e-454c-872e-77a314248114 wsh blocks list --json ``` - --- ## secret @@ -988,4 +1022,5 @@ The secrets UI provides a convenient visual way to browse, add, edit, and delete :::tip Use secrets in your scripts to avoid hardcoding sensitive values. Secrets work across remote machines - store an API key locally with `wsh secret set`, then access it from any SSH or WSL connection with `wsh secret get`. The secret is securely retrieved from your local machine without needing to duplicate it on remote systems. ::: + diff --git a/emain/emain-cdp.ts b/emain/emain-cdp.ts new file mode 100644 index 0000000000..a05ae70f8c --- /dev/null +++ b/emain/emain-cdp.ts @@ -0,0 +1,595 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import type { WebContents } from "electron"; +import { ipcMain, webContents } from "electron"; +import { randomUUID } from "node:crypto"; +import http from "node:http"; +import { URL } from "node:url"; +import WebSocket, { WebSocketServer } from "ws"; +import { log } from "./emain-log"; + +// ---- Public API (used by emain.ts / emain-wsh.ts) --------------------------- + +export type WebCdpServerConfig = { + enabled: boolean; + port: number; // default 9222 + idleDetachMs?: number; // default 30000 +}; + +export type WebCdpBlockOps = { + createWebBlock?: (url: string) => Promise; // returns blockId + deleteBlock?: (blockId: string) => Promise; +}; + +export type WebCdpTargetInfo = { + host: string; + port: number; + targetid: string; + blockid: string; + wsPath: string; + wsUrl: string; + httpUrl: string; + inspectorUrl: string; + controlled: boolean; +}; + +// Configure (injected) block creation/deletion handlers. +let blockOps: WebCdpBlockOps = {}; +export function setWebCdpBlockOps(ops: WebCdpBlockOps) { + blockOps = ops ?? {}; +} + +// Start/stop shared server from emain.ts once config is known. +export async function configureWebCdpServer(cfg: WebCdpServerConfig) { + serverCfg = { + enabled: !!cfg?.enabled, + port: cfg?.port ?? 9222, + idleDetachMs: cfg?.idleDetachMs ?? 30_000, + }; + if (!serverCfg.enabled) { + await stopSharedServer(); + return; + } + await ensureSharedServer(); +} + +// For WSH/UI: list targets that are currently controlled. +export function getControlledWebCdpTargets(): WebCdpTargetInfo[] { + const out: WebCdpTargetInfo[] = []; + for (const t of targetsById.values()) { + if (!t.controlled()) continue; + out.push(makeTargetInfo(t)); + } + return out; +} + +// For WSH/UI: return connection info for a specific block (even if not controlled). +export function getWebCdpTargetForBlock(blockid: string): WebCdpTargetInfo | null { + const t = targetsById.get(blockid); + if (!t) return null; + return makeTargetInfo(t); +} + +// For WSH: explicitly register a target when the caller already has WebContents. +export function registerWebCdpTarget(blockid: string, wc: WebContents): WebCdpTargetInfo { + const t = registerTarget(blockid, wc); + return makeTargetInfo(t); +} + +// For WSH: drop control for a target (disconnect clients + detach debugger). +export function stopWebCdpForBlock(blockid: string) { + const t = targetsById.get(blockid); + if (!t) return; + for (const ws of t.clients) { + try { + ws.close(); + } catch (_) {} + } + t.clients.clear(); + detachDebugger(t); +} + +// ---- Internal implementation ------------------------------------------------ + +type TargetInstance = { + id: string; // Chrome target id; we use blockid + blockid: string; + wc: WebContents; + clients: Set; + debuggerAttached: boolean; + idleTimer: NodeJS.Timeout | null; + destroyedUnsub: (() => void) | null; + dbgMsgHandler: ((event: any, method: string, params: any) => void) | null; + dbgDetachHandler: (() => void) | null; + controlled: () => boolean; +}; + +const HOST = "127.0.0.1"; +const WS_PAGE_PREFIX = "/devtools/page/"; + +let serverCfg: WebCdpServerConfig = { enabled: false, port: 9222, idleDetachMs: 30_000 }; + +let httpServer: http.Server | null = null; +let wsServer: WebSocketServer | null = null; +let actualPort: number | null = null; + +// blockId == targetId for now +const targetsById = new Map(); + +let discoveryPoller: NodeJS.Timeout | null = null; + +function safeJsonSend(ws: WebSocket, obj: any) { + if (ws.readyState !== WebSocket.OPEN) return; + try { + ws.send(JSON.stringify(obj)); + } catch (_) {} +} + +function respondJson(res: http.ServerResponse, status: number, obj: any) { + const body = JSON.stringify(obj); + res.statusCode = status; + res.setHeader("content-type", "application/json; charset=utf-8"); + res.setHeader("cache-control", "no-store"); + res.end(body); +} + +function respondText(res: http.ServerResponse, status: number, text: string) { + res.statusCode = status; + res.setHeader("content-type", "text/plain; charset=utf-8"); + res.setHeader("cache-control", "no-store"); + res.end(text); +} + +function makeWsPath(targetId: string) { + return `${WS_PAGE_PREFIX}${targetId}`; +} + +function makeWsUrl(targetId: string) { + const port = actualPort ?? serverCfg.port; + return `ws://${HOST}:${port}${makeWsPath(targetId)}`; +} + +function makeHttpUrl() { + const port = actualPort ?? serverCfg.port; + return `http://${HOST}:${port}`; +} + +function makeTargetInfo(t: TargetInstance): WebCdpTargetInfo { + const wsPath = makeWsPath(t.id); + const wsUrl = makeWsUrl(t.id); + const httpUrl = makeHttpUrl(); + return { + host: HOST, + port: actualPort ?? serverCfg.port, + targetid: t.id, + blockid: t.blockid, + wsPath, + wsUrl, + httpUrl, + inspectorUrl: `devtools://devtools/bundled/inspector.html?ws=${HOST}:${actualPort ?? serverCfg.port}${wsPath}`, + controlled: t.controlled(), + }; +} + +function makeChromeJsonEntry(t: TargetInstance): any { + let url = ""; + let title = ""; + try { + url = t.wc.getURL(); + } catch (_) {} + try { + title = t.wc.getTitle(); + } catch (_) {} + return { + description: "Wave WebView (web widget)", + id: t.id, + title: title || "Wave WebView", + type: "page", + url, + webSocketDebuggerUrl: makeWsUrl(t.id), + }; +} + +async function ensureDebuggerAttached(t: TargetInstance) { + if (t.debuggerAttached) return; + try { + t.wc.debugger.attach("1.3"); + t.debuggerAttached = true; + } catch (e: any) { + const msg = e?.message || String(e); + if (msg.includes("already attached")) { + throw new Error("CDP attach failed: target already has a debugger attached"); + } + throw new Error(`CDP attach failed: ${msg}`); + } + + // Attach forwarders once. + if (!t.dbgMsgHandler) { + t.dbgMsgHandler = (_event: any, method: string, params: any) => { + for (const ws of t.clients) { + safeJsonSend(ws, { method, params }); + } + }; + t.wc.debugger.on("message", t.dbgMsgHandler); + } + if (!t.dbgDetachHandler) { + t.dbgDetachHandler = () => { + t.debuggerAttached = false; + }; + t.wc.debugger.on("detach", t.dbgDetachHandler); + } +} + +function detachDebugger(t: TargetInstance) { + if (t.idleTimer) { + clearTimeout(t.idleTimer); + t.idleTimer = null; + } + try { + if (t.debuggerAttached) { + t.wc.debugger.detach(); + } + } catch (_) {} + t.debuggerAttached = false; + // Remove listeners to avoid leaks if this webcontents gets re-used. + try { + if (t.dbgMsgHandler) { + t.wc.debugger.removeListener("message", t.dbgMsgHandler as any); + } + if (t.dbgDetachHandler) { + t.wc.debugger.removeListener("detach", t.dbgDetachHandler as any); + } + } catch (_) {} + t.dbgMsgHandler = null; + t.dbgDetachHandler = null; +} + +function scheduleIdleDetach(t: TargetInstance) { + const idleMs = serverCfg.idleDetachMs ?? 30_000; + if (idleMs <= 0) return; + if (t.idleTimer) clearTimeout(t.idleTimer); + t.idleTimer = setTimeout(() => { + if (t.clients.size === 0) { + detachDebugger(t); + } + }, idleMs); +} + +function registerTarget(blockid: string, wc: WebContents) { + const existing = targetsById.get(blockid); + if (existing) { + existing.wc = wc; + return existing; + } + const t: TargetInstance = { + id: blockid, + blockid, + wc, + clients: new Set(), + debuggerAttached: false, + idleTimer: null, + destroyedUnsub: null, + dbgMsgHandler: null, + dbgDetachHandler: null, + // A widget is considered "controlled" when there is an active CDP client connection. + // (Debugger may remain attached briefly for idle-detach smoothing, but that does not imply control.) + controlled: () => t.clients.size > 0, + }; + + const onDestroyed = () => { + unregisterTarget(blockid); + }; + wc.once("destroyed", onDestroyed); + t.destroyedUnsub = () => { + try { + wc.removeListener("destroyed", onDestroyed as any); + } catch (_) {} + }; + + targetsById.set(blockid, t); + return t; +} + +function unregisterTarget(blockid: string) { + const t = targetsById.get(blockid); + if (!t) return; + targetsById.delete(blockid); + for (const ws of t.clients) { + try { + ws.close(); + } catch (_) {} + } + t.clients.clear(); + detachDebugger(t); + try { + t.destroyedUnsub?.(); + } catch (_) {} +} + +function startDiscoveryPoller() { + if (discoveryPoller) return; + discoveryPoller = setInterval(() => { + refreshTargetsFromRenderers().catch(() => {}); + }, 750); + refreshTargetsFromRenderers().catch(() => {}); +} + +function stopDiscoveryPoller() { + if (!discoveryPoller) return; + clearInterval(discoveryPoller); + discoveryPoller = null; +} + +async function refreshTargetsFromRenderers() { + // Ask any Wave tab renderer to report currently-mounted webviews. + // This only discovers web widgets that are currently loaded (i.e. have a live WebContents). + const all = webContents.getAllWebContents(); + const seen = new Map(); // blockId -> webContentsId + + await Promise.all( + all.map(async (wc) => { + // Skip contents themselves; ask their host renderers. + try { + if ((wc as any).getType?.() === "webview") return; + } catch (_) {} + + const reqId = randomUUID().replace(/-/g, ""); + const respCh = `webviews-list-resp-${reqId}`; + const p = new Promise((resolve) => { + const timeout = setTimeout(() => { + ipcMain.removeAllListeners(respCh); + resolve(); + }, 200); + ipcMain.once(respCh, (_evt, payload) => { + clearTimeout(timeout); + try { + for (const item of payload ?? []) { + const bid = item?.blockId; + const wcId = item?.webContentsId; + if (!bid || !wcId) continue; + const n = parseInt(String(wcId), 10); + if (!Number.isFinite(n)) continue; + seen.set(bid, n); + } + } catch (_) {} + resolve(); + }); + }); + try { + wc.send("webviews-list", respCh); + } catch (_) { + ipcMain.removeAllListeners(respCh); + return; + } + await p; + }) + ); + + // Register/update targets + for (const [blockId, wcId] of seen.entries()) { + const wv = webContents.fromId(wcId); + if (!wv) continue; + registerTarget(blockId, wv); + } + + // Remove targets that no longer exist (webcontents destroyed or unmounted) + for (const [blockId, t] of Array.from(targetsById.entries())) { + if (seen.has(blockId)) continue; + // If currently controlled, keep it until it disconnects/destroys; it should still be visible. + if (t.clients.size > 0) continue; + // If not controlled and not seen, drop it. + unregisterTarget(blockId); + } +} + +async function ensureSharedServer() { + if (httpServer && wsServer && actualPort != null) { + startDiscoveryPoller(); + return; + } + + const server = http.createServer(async (req, res) => { + if (!req.url) { + respondText(res, 400, "missing url"); + return; + } + const parsed = new URL(req.url, `http://${req.headers.host || HOST}`); + + if (req.method === "GET" && (parsed.pathname === "/json" || parsed.pathname === "/json/list")) { + const targets = Array.from(targetsById.values()); + targets.sort((a, b) => a.blockid.localeCompare(b.blockid)); + const entries = targets.map(makeChromeJsonEntry); + respondJson(res, 200, entries); + return; + } + if (req.method === "GET" && parsed.pathname === "/json/version") { + respondJson(res, 200, { + Browser: "Wave (Electron)", + "Protocol-Version": "1.3", + }); + return; + } + + // Chrome-ish: PUT /json/new? + if (req.method === "PUT" && parsed.pathname === "/json/new") { + const encodedUrl = parsed.search ? parsed.search.slice(1) : ""; + let url = "about:blank"; + try { + if (encodedUrl) url = decodeURIComponent(encodedUrl); + } catch (_) { + url = encodedUrl || "about:blank"; + } + if (!blockOps.createWebBlock) { + respondText(res, 500, "createWebBlock not configured"); + return; + } + try { + const blockId = await blockOps.createWebBlock(url); + // Wait briefly for renderer to mount and report webcontents id. + const deadline = Date.now() + 4000; + while (Date.now() < deadline) { + await refreshTargetsFromRenderers(); + const t = targetsById.get(blockId); + if (t) { + respondJson(res, 200, makeChromeJsonEntry(t)); + return; + } + await new Promise((r) => setTimeout(r, 150)); + } + respondText(res, 504, "created block but webview not ready"); + return; + } catch (e: any) { + respondText(res, 500, e?.message || String(e)); + return; + } + } + + // Chrome-ish: GET /json/close/ + if (req.method === "GET" && parsed.pathname.startsWith("/json/close/")) { + const id = parsed.pathname.slice("/json/close/".length); + if (!id) { + respondText(res, 400, "missing id"); + return; + } + if (!blockOps.deleteBlock) { + respondText(res, 500, "deleteBlock not configured"); + return; + } + try { + await blockOps.deleteBlock(id); + } catch (e: any) { + respondText(res, 500, e?.message || String(e)); + return; + } + // Best-effort cleanup locally. + unregisterTarget(id); + respondText(res, 200, "Target is closing"); + return; + } + + respondText(res, 404, "not found"); + }); + + const wss = new WebSocketServer({ noServer: true }); + server.on("upgrade", (req, socket, head) => { + let pathname = ""; + try { + const urlObj = new URL(req.url || "", `http://${req.headers.host || HOST}`); + pathname = urlObj.pathname; + } catch (_) { + socket.destroy(); + return; + } + if (!pathname.startsWith(WS_PAGE_PREFIX)) { + socket.destroy(); + return; + } + const targetId = pathname.slice(WS_PAGE_PREFIX.length); + const target = targetsById.get(targetId); + if (!target) { + socket.destroy(); + return; + } + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit("connection", ws, targetId); + }); + }); + + wss.on("connection", async (ws: WebSocket, targetId: any) => { + const t = targetsById.get(String(targetId)); + if (!t) { + try { + ws.close(); + } catch (_) {} + return; + } + t.clients.add(ws); + if (t.idleTimer) { + clearTimeout(t.idleTimer); + t.idleTimer = null; + } + try { + await ensureDebuggerAttached(t); + } catch (e: any) { + safeJsonSend(ws, { error: e?.message || String(e) }); + try { + ws.close(); + } catch (_) {} + return; + } + + ws.on("message", async (data) => { + let msg: any; + try { + msg = JSON.parse(data.toString()); + } catch (_) { + safeJsonSend(ws, { id: null, error: { code: -32700, message: "Parse error" } }); + return; + } + const id = msg?.id; + const method = msg?.method; + const params = msg?.params; + if (id == null || typeof method !== "string") { + safeJsonSend(ws, { id: id ?? null, error: { code: -32600, message: "Invalid Request" } }); + return; + } + try { + const result = await t.wc.debugger.sendCommand(method, params); + safeJsonSend(ws, { id, result }); + } catch (e: any) { + safeJsonSend(ws, { id, error: { code: -32000, message: e?.message || String(e) } }); + } + }); + + ws.on("close", () => { + t.clients.delete(ws); + if (t.clients.size === 0) { + scheduleIdleDetach(t); + } + }); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(serverCfg.port, HOST, () => resolve()); + }); + + httpServer = server; + wsServer = wss; + const addr = server.address(); + if (addr && typeof addr === "object") { + actualPort = addr.port; + } else { + actualPort = serverCfg.port; + } + + log("webcdp server listening", `${HOST}:${actualPort}`); + startDiscoveryPoller(); +} + +async function stopSharedServer() { + stopDiscoveryPoller(); + + for (const id of Array.from(targetsById.keys())) { + unregisterTarget(id); + } + + try { + wsServer?.close(); + } catch (_) {} + wsServer = null; + + const srv = httpServer; + httpServer = null; + actualPort = null; + if (srv) { + await new Promise((resolve) => { + try { + srv.close(() => resolve()); + } catch (_) { + resolve(); + } + }); + } +} diff --git a/emain/emain-wsh.ts b/emain/emain-wsh.ts index d17dc2e106..0ed3f30331 100644 --- a/emain/emain-wsh.ts +++ b/emain/emain-wsh.ts @@ -6,6 +6,13 @@ import { RpcResponseHelper, WshClient } from "@/app/store/wshclient"; import { RpcApi } from "@/app/store/wshclientapi"; import { Notification, net, safeStorage, shell } from "electron"; import { getResolvedUpdateChannel } from "emain/updater"; +import { + configureWebCdpServer, + getControlledWebCdpTargets, + registerWebCdpTarget, + stopWebCdpForBlock, +} from "./emain-cdp"; +import { log } from "./emain-log"; import { unamePlatform } from "./emain-platform"; import { getWebContentsByBlockId, webGetSelector } from "./emain-web"; import { createBrowserWindow, getWaveWindowById, getWaveWindowByWorkspaceId } from "./emain-window"; @@ -31,6 +38,58 @@ export class ElectronWshClientType extends WshClient { return rtn; } + async handle_webcdpstart(rh: RpcResponseHelper, data: CommandWebCdpStartData): Promise { + if (!data.tabid || !data.blockid || !data.workspaceid) { + throw new Error("workspaceid, tabid and blockid are required"); + } + const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); + if (!fullConfig?.settings?.["debug:webcdp"]) { + throw new Error("web cdp is disabled (enable debug:webcdp in settings.json)"); + } + const cdpPort = fullConfig?.settings?.["debug:webcdpport"] ?? 9222; + await configureWebCdpServer({ enabled: true, port: cdpPort }); + const ww = getWaveWindowByWorkspaceId(data.workspaceid); + if (ww == null) { + throw new Error(`no window found with workspace ${data.workspaceid}`); + } + const wc = await getWebContentsByBlockId(ww, data.tabid, data.blockid); + if (wc == null) { + throw new Error(`no webcontents found with blockid ${data.blockid}`); + } + log("webcdpstart", data.workspaceid, data.tabid, data.blockid); + const info = registerWebCdpTarget(data.blockid, wc); + return { + host: info.host, + port: info.port, + wsurl: info.wsUrl, + inspectorurl: info.inspectorUrl, + targetid: info.targetid, + }; + } + + async handle_webcdpstop(rh: RpcResponseHelper, data: CommandWebCdpStopData): Promise { + if (!data.tabid || !data.blockid || !data.workspaceid) { + throw new Error("workspaceid, tabid and blockid are required"); + } + log("webcdpstop", data.workspaceid, data.tabid, data.blockid); + stopWebCdpForBlock(data.blockid); + } + + async handle_webcdpstatus(rh: RpcResponseHelper): Promise { + const status = getControlledWebCdpTargets(); + return status.map((s) => ({ + key: s.targetid, + workspaceid: "", + tabid: "", + blockid: s.blockid, + host: s.host, + port: s.port, + wsurl: s.wsUrl, + inspectorurl: s.inspectorUrl, + targetid: s.targetid, + })); + } + async handle_notify(rh: RpcResponseHelper, notificationOptions: WaveNotificationOptions) { new Notification({ title: notificationOptions.title, diff --git a/emain/emain.ts b/emain/emain.ts index 58187e5293..6f2e9d0b5e 100644 --- a/emain/emain.ts +++ b/emain/emain.ts @@ -23,6 +23,7 @@ import { setWasActive, setWasInFg, } from "./emain-activity"; +import { configureWebCdpServer, setWebCdpBlockOps } from "./emain-cdp"; import { initIpcHandlers } from "./emain-ipc"; import { log } from "./emain-log"; import { initMenuEventSubscriptions, makeAndSetAppMenu, makeDockTaskbar } from "./emain-menu"; @@ -389,6 +390,41 @@ async function appMain() { console.log("error initializing wshrpc", e); } const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); + + // Web widget CDP server (Chrome-style /json endpoints), bound to 127.0.0.1 only. + // This is primarily used by automation clients (e.g. MCP tools) that expect /json/list discovery. + setWebCdpBlockOps({ + createWebBlock: async (url: string) => { + const tabId = + focusedWaveWindow?.activeTabView?.waveTabId ?? getAllWaveWindows()?.[0]?.activeTabView?.waveTabId; + if (!tabId) { + throw new Error("no active tab available to create a web widget"); + } + const oref = await RpcApi.CreateBlockCommand( + ElectronWshClient, + { + tabid: tabId, + blockdef: { + meta: { + view: "web", + url, + }, + }, + focused: true, + }, + { timeout: 5000 } + ); + return oref.oid; + }, + deleteBlock: async (blockId: string) => { + await RpcApi.DeleteBlockCommand(ElectronWshClient, { blockid: blockId }, { timeout: 5000 }); + }, + }); + await configureWebCdpServer({ + enabled: !!fullConfig?.settings?.["debug:webcdp"], + port: fullConfig?.settings?.["debug:webcdpport"] ?? 9222, + }); + checkIfRunningUnderARM64Translation(fullConfig); if (fullConfig?.settings?.["app:confirmquit"] != null) { confirmQuit = fullConfig.settings["app:confirmquit"]; diff --git a/emain/preload.ts b/emain/preload.ts index c6bdf14988..614249cf57 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -81,3 +81,19 @@ ipcRenderer.on("webcontentsid-from-blockid", (e, blockId, responseCh) => { const wcId = webviewElem?.dataset?.webcontentsid; ipcRenderer.send(responseCh, wcId); }); + +ipcRenderer.on("webviews-list", (_e, responseCh) => { + try { + const out: Array<{ blockId: string; webContentsId: string }> = []; + const nodes: NodeListOf = document.querySelectorAll("div[data-blockid] webview"); + for (const wv of Array.from(nodes)) { + const blockId = (wv.closest("div[data-blockid]") as any)?.dataset?.blockid; + const webContentsId = (wv as any)?.dataset?.webcontentsid; + if (!blockId || !webContentsId) continue; + out.push({ blockId, webContentsId }); + } + ipcRenderer.send(responseCh, out); + } catch (_err) { + ipcRenderer.send(responseCh, []); + } +}); diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index bd0e5405eb..f4b4a72f4f 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -782,6 +782,21 @@ class RpcApiType { return client.wshRpcCall("waveinfo", null, opts); } + // command "webcdpstart" [call] + WebCdpStartCommand(client: WshClient, data: CommandWebCdpStartData, opts?: RpcOpts): Promise { + return client.wshRpcCall("webcdpstart", data, opts); + } + + // command "webcdpstatus" [call] + WebCdpStatusCommand(client: WshClient, opts?: RpcOpts): Promise { + return client.wshRpcCall("webcdpstatus", null, opts); + } + + // command "webcdpstop" [call] + WebCdpStopCommand(client: WshClient, data: CommandWebCdpStopData, opts?: RpcOpts): Promise { + return client.wshRpcCall("webcdpstop", data, opts); + } + // command "webselector" [call] WebSelectorCommand(client: WshClient, data: CommandWebSelectorData, opts?: RpcOpts): Promise { return client.wshRpcCall("webselector", data, opts); diff --git a/frontend/app/view/webview/webcdp.ts b/frontend/app/view/webview/webcdp.ts new file mode 100644 index 0000000000..fae9d0e9da --- /dev/null +++ b/frontend/app/view/webview/webcdp.ts @@ -0,0 +1,54 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { getSettingsKeyAtom } from "@/app/store/global"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { globalStore } from "@/store/global"; +import { atom } from "jotai"; + +export const webCdpActiveMapAtom = atom>({}); + +let pollerStarted = false; +let pollerHandle: number | null = null; + +async function pollOnce() { + const enabled = globalStore.get(getSettingsKeyAtom("debug:webcdp")) ?? false; + if (!enabled) { + globalStore.set(webCdpActiveMapAtom, {}); + return; + } + // TabRpcClient may not be initialized yet during early startup; try again next tick. + if (!TabRpcClient) { + return; + } + try { + const status = await RpcApi.WebCdpStatusCommand(TabRpcClient, { route: "electron", timeout: 2000 }); + const next: Record = {}; + for (const e of status ?? []) { + if (e?.blockid) { + next[e.blockid] = true; + } + } + globalStore.set(webCdpActiveMapAtom, next); + } catch (_e) { + // Avoid flicker on transient errors; keep last known value. + } +} + +export function ensureWebCdpPollerStarted() { + if (pollerStarted) return; + pollerStarted = true; + // do one immediate poll, then periodic + pollOnce(); + pollerHandle = window.setInterval(pollOnce, 750); +} + +export function stopWebCdpPollerForTests() { + if (pollerHandle != null) { + window.clearInterval(pollerHandle); + pollerHandle = null; + } + pollerStarted = false; + globalStore.set(webCdpActiveMapAtom, {}); +} diff --git a/frontend/app/view/webview/webview.scss b/frontend/app/view/webview/webview.scss index 62d68ae8dd..78d8a12b96 100644 --- a/frontend/app/view/webview/webview.scss +++ b/frontend/app/view/webview/webview.scss @@ -1,12 +1,10 @@ // Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -.webview, .webview-container { height: 100%; width: 100%; - border: none !important; - outline: none !important; + position: relative; overflow: hidden; padding: 0; margin: 0; @@ -19,6 +17,44 @@ will-change: transform; } +.webview { + height: 100%; + width: 100%; + border: none !important; + outline: none !important; + overflow: hidden; + padding: 0; + margin: 0; + user-select: none; + border-radius: 0 0 var(--block-border-radius) var(--block-border-radius); +} + +.webview-container.cdp-active::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + border-radius: 0 0 var(--block-border-radius) var(--block-border-radius); + box-shadow: + inset 0 0 0 2px #f59e0b, + 0 0 12px color-mix(in srgb, #f59e0b 35%, transparent); +} + +.webview-cdp-badge { + position: absolute; + top: 8px; + left: 8px; + z-index: 200; + pointer-events: none; + padding: 2px 6px; + border-radius: 6px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; + color: var(--main-text-color, white); + background: color-mix(in srgb, #f59e0b 45%, rgba(0, 0, 0, 0.6)); +} + .webview-error { display: flex; position: absolute; diff --git a/frontend/app/view/webview/webview.tsx b/frontend/app/view/webview/webview.tsx index bfa3476bd3..33c11a694b 100644 --- a/frontend/app/view/webview/webview.tsx +++ b/frontend/app/view/webview/webview.tsx @@ -2,11 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { BlockNodeModel } from "@/app/block/blocktypes"; -import type { TabModel } from "@/app/store/tab-model"; import { Search, useSearch } from "@/app/element/search"; import { createBlock, getApi, getBlockMetaKeyAtom, getSettingsKeyAtom, openLink } from "@/app/store/global"; import { getSimpleControlShiftAtom } from "@/app/store/keymodel"; import { ObjectService } from "@/app/store/services"; +import type { TabModel } from "@/app/store/tab-model"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { @@ -21,6 +21,7 @@ import clsx from "clsx"; import { WebviewTag } from "electron"; import { Atom, PrimitiveAtom, atom, useAtomValue, useSetAtom } from "jotai"; import { Fragment, createRef, memo, useCallback, useEffect, useRef, useState } from "react"; +import { ensureWebCdpPollerStarted, webCdpActiveMapAtom } from "./webcdp"; import "./webview.scss"; // User agent strings for mobile emulation @@ -881,6 +882,8 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps) const [webContentsId, setWebContentsId] = useState(null); const domReady = useAtomValue(model.domReady); + const cdpActiveMap = useAtomValue(webCdpActiveMapAtom); + const cdpActive = !!cdpActiveMap?.[model.blockId]; const [errorText, setErrorText] = useState(""); @@ -909,6 +912,7 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps) } useEffect(() => { + ensureWebCdpPollerStarted(); return () => { globalStore.set(model.domReady, false); }; @@ -1054,19 +1058,22 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps) return ( - +
+ {cdpActive &&
CONTROLLED
} + +
{errorText && (
{errorText}
diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index a865f41313..4a740ea3b8 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -671,6 +671,31 @@ declare global { streammeta: StreamMeta; }; + // wshrpc.CommandWebCdpStartData + type CommandWebCdpStartData = { + workspaceid: string; + blockid: string; + tabid: string; + port?: number; + idletimeoutms?: number; + }; + + // wshrpc.CommandWebCdpStartRtnData + type CommandWebCdpStartRtnData = { + host: string; + port: number; + wsurl: string; + inspectorurl: string; + targetid: string; + }; + + // wshrpc.CommandWebCdpStopData + type CommandWebCdpStopData = { + workspaceid: string; + blockid: string; + tabid: string; + }; + // wshrpc.CommandWebSelectorData type CommandWebSelectorData = { workspaceid: string; @@ -1298,6 +1323,8 @@ declare global { "conn:askbeforewshinstall"?: boolean; "conn:wshenabled"?: boolean; "debug:*"?: boolean; + "debug:webcdp"?: boolean; + "debug:webcdpport"?: number; "debug:pprofport"?: number; "debug:pprofmemprofilerate"?: number; "tsunami:*"?: boolean; @@ -2005,6 +2032,19 @@ declare global { args: any[]; }; + // wshrpc.WebCdpStatusEntry + type WebCdpStatusEntry = { + key: string; + workspaceid: string; + blockid: string; + tabid: string; + host: string; + port: number; + wsurl: string; + inspectorurl: string; + targetid: string; + }; + // service.WebReturnType type WebReturnType = { success?: boolean; diff --git a/pkg/wconfig/metaconsts.go b/pkg/wconfig/metaconsts.go index 98b9b2ab33..3d3849ddee 100644 --- a/pkg/wconfig/metaconsts.go +++ b/pkg/wconfig/metaconsts.go @@ -110,6 +110,8 @@ const ( ConfigKey_ConnWshEnabled = "conn:wshenabled" ConfigKey_DebugClear = "debug:*" + ConfigKey_DebugWebCdp = "debug:webcdp" + ConfigKey_DebugWebCdpPort = "debug:webcdpport" ConfigKey_DebugPprofPort = "debug:pprofport" ConfigKey_DebugPprofMemProfileRate = "debug:pprofmemprofilerate" diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index 0d392606b6..46efa3cebf 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -157,6 +157,8 @@ type SettingsType struct { ConnWshEnabled bool `json:"conn:wshenabled,omitempty"` DebugClear bool `json:"debug:*,omitempty"` + DebugWebCdp bool `json:"debug:webcdp,omitempty"` + DebugWebCdpPort *int `json:"debug:webcdpport,omitempty"` DebugPprofPort *int `json:"debug:pprofport,omitempty"` DebugPprofMemProfileRate *int `json:"debug:pprofmemprofilerate,omitempty"` diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index afc7b59dca..b2cc61d898 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -936,6 +936,24 @@ func WaveInfoCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (*wshrpc.WaveInfoD return resp, err } +// command "webcdpstart", wshserver.WebCdpStartCommand +func WebCdpStartCommand(w *wshutil.WshRpc, data wshrpc.CommandWebCdpStartData, opts *wshrpc.RpcOpts) (*wshrpc.CommandWebCdpStartRtnData, error) { + resp, err := sendRpcRequestCallHelper[*wshrpc.CommandWebCdpStartRtnData](w, "webcdpstart", data, opts) + return resp, err +} + +// command "webcdpstatus", wshserver.WebCdpStatusCommand +func WebCdpStatusCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]wshrpc.WebCdpStatusEntry, error) { + resp, err := sendRpcRequestCallHelper[[]wshrpc.WebCdpStatusEntry](w, "webcdpstatus", nil, opts) + return resp, err +} + +// command "webcdpstop", wshserver.WebCdpStopCommand +func WebCdpStopCommand(w *wshutil.WshRpc, data wshrpc.CommandWebCdpStopData, opts *wshrpc.RpcOpts) error { + _, err := sendRpcRequestCallHelper[any](w, "webcdpstop", data, opts) + return err +} + // command "webselector", wshserver.WebSelectorCommand func WebSelectorCommand(w *wshutil.WshRpc, data wshrpc.CommandWebSelectorData, opts *wshrpc.RpcOpts) ([]string, error) { resp, err := sendRpcRequestCallHelper[[]string](w, "webselector", data, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index b8cb8ecbbf..9874ef2a6b 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -119,6 +119,9 @@ type WshRpcInterface interface { // emain WebSelectorCommand(ctx context.Context, data CommandWebSelectorData) ([]string, error) + WebCdpStartCommand(ctx context.Context, data CommandWebCdpStartData) (*CommandWebCdpStartRtnData, error) + WebCdpStopCommand(ctx context.Context, data CommandWebCdpStopData) error + WebCdpStatusCommand(ctx context.Context) ([]WebCdpStatusEntry, error) NotifyCommand(ctx context.Context, notificationOptions WaveNotificationOptions) error FocusWindowCommand(ctx context.Context, windowId string) error ElectronEncryptCommand(ctx context.Context, data CommandElectronEncryptData) (*CommandElectronEncryptRtnData, error) @@ -462,6 +465,40 @@ type CommandWebSelectorData struct { Opts *WebSelectorOpts `json:"opts,omitempty"` } +type CommandWebCdpStartData struct { + WorkspaceId string `json:"workspaceid"` + BlockId string `json:"blockid"` + TabId string `json:"tabid"` + Port int `json:"port,omitempty"` // 0 means choose an ephemeral port + IdleTimeoutMs int `json:"idletimeoutms,omitempty"` // 0 disables idle shutdown +} + +type CommandWebCdpStartRtnData struct { + Host string `json:"host"` + Port int `json:"port"` + WsUrl string `json:"wsurl"` + InspectorUrl string `json:"inspectorurl"` + TargetId string `json:"targetid"` +} + +type CommandWebCdpStopData struct { + WorkspaceId string `json:"workspaceid"` + BlockId string `json:"blockid"` + TabId string `json:"tabid"` +} + +type WebCdpStatusEntry struct { + Key string `json:"key"` + WorkspaceId string `json:"workspaceid"` + BlockId string `json:"blockid"` + TabId string `json:"tabid"` + Host string `json:"host"` + Port int `json:"port"` + WsUrl string `json:"wsurl"` + InspectorUrl string `json:"inspectorurl"` + TargetId string `json:"targetid"` +} + type BlockInfoData struct { BlockId string `json:"blockid"` TabId string `json:"tabid"` diff --git a/schema/settings.json b/schema/settings.json index 1685b2bf17..10d8613bb2 100644 --- a/schema/settings.json +++ b/schema/settings.json @@ -272,6 +272,12 @@ "debug:*": { "type": "boolean" }, + "debug:webcdp": { + "type": "boolean" + }, + "debug:webcdpport": { + "type": "integer" + }, "debug:pprofport": { "type": "integer" },