From e0f97849224069194ef814813e2a27684090c313 Mon Sep 17 00:00:00 2001 From: Drew Goddyn Date: Sun, 1 Feb 2026 12:30:32 -0800 Subject: [PATCH 1/4] Add per-webview CDP proxy for web widgets Expose a local CDP websocket for webview-backed web blocks via the Electron debugger API. Adds wsh RPC + CLI commands (web cdp start/stop/status) and serves /json/list for DevTools discovery. --- cmd/wsh/cmd/wshcmd-webcdp.go | 159 ++++++++++++ emain/emain-cdp.ts | 395 +++++++++++++++++++++++++++++ emain/emain-wsh.ts | 49 ++++ emain/emain.ts | 10 + frontend/app/store/wshclientapi.ts | 15 ++ frontend/types/gotypes.d.ts | 39 +++ pkg/wshrpc/wshclient/wshclient.go | 18 ++ pkg/wshrpc/wshrpctypes.go | 38 +++ 8 files changed, 723 insertions(+) create mode 100644 cmd/wsh/cmd/wshcmd-webcdp.go create mode 100644 emain/emain-cdp.ts diff --git a/cmd/wsh/cmd/wshcmd-webcdp.go b/cmd/wsh/cmd/wshcmd-webcdp.go new file mode 100644 index 0000000000..3b57335689 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-webcdp.go @@ -0,0 +1,159 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "encoding/json" + "fmt" + + "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", + PersistentPreRunE: preRunSetupRpcClient, +} + +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 webCdpListenHost string +var webCdpPort int +var webCdpIdleTimeoutMs int +var webCdpJson bool + +func init() { + webCdpStartCmd.Flags().StringVar(&webCdpListenHost, "listen", "127.0.0.1", "listen host (default: 127.0.0.1)") + webCdpStartCmd.Flags().IntVar(&webCdpPort, "port", 0, "listen port (0 chooses an ephemeral port)") + webCdpStartCmd.Flags().IntVar(&webCdpIdleTimeoutMs, "idle-timeout-ms", 10*60*1000, "idle timeout in ms (0 disables)") + 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) +} + +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 webCdpStartRun(cmd *cobra.Command, args []string) error { + 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: webCdpPort, + ListenHost: webCdpListenHost, + IdleTimeoutMs: webCdpIdleTimeoutMs, + } + 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) + return nil +} + +func webCdpStopRun(cmd *cobra.Command, args []string) error { + 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/emain/emain-cdp.ts b/emain/emain-cdp.ts new file mode 100644 index 0000000000..adddf84cdc --- /dev/null +++ b/emain/emain-cdp.ts @@ -0,0 +1,395 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import type { WebContents } from "electron"; +import { randomUUID } from "node:crypto"; +import http from "node:http"; +import { URL } from "node:url"; +import WebSocket, { WebSocketServer } from "ws"; + +export type CdpProxyStartOpts = { + host?: string; // default 127.0.0.1 + port?: number; // default 0 (ephemeral) + idleTimeoutMs?: number; // default 10 minutes +}; + +export type WebCdpTargetInfo = { + key: string; + workspaceid: string; + tabid: string; + blockid: string; + + host: string; + port: number; + targetid: string; + wsPath: string; + wsUrl: string; + httpUrl: string; + inspectorUrl: string; +}; + +type CdpProxyInstance = { + key: string; + workspaceid: string; + tabid: string; + blockid: string; + + host: string; + port: number; + targetid: string; + wsPath: string; + + server: http.Server; + wss: WebSocketServer; + + wc: WebContents; + debuggerAttached: boolean; + clients: Set; + idleTimer: NodeJS.Timeout | null; + idleTimeoutMs: number; +}; + +const proxyMap = new Map(); + +function makeKey(workspaceid: string, tabid: string, blockid: string): string { + return `${workspaceid}:${tabid}:${blockid}`; +} + +function safeJsonSend(ws: WebSocket, obj: any) { + if (ws.readyState !== WebSocket.OPEN) return; + try { + ws.send(JSON.stringify(obj)); + } catch (_) {} +} + +function getWsHostForUrl(host: string): string { + // For inspector URLs, 0.0.0.0 is not a valid connect target; use loopback. + if (host === "0.0.0.0") return "127.0.0.1"; + return host; +} + +function refreshIdleTimer(inst: CdpProxyInstance) { + if (inst.idleTimeoutMs <= 0) return; + if (inst.idleTimer) clearTimeout(inst.idleTimer); + inst.idleTimer = setTimeout(() => { + if (inst.clients.size === 0) { + stopWebCdpProxy(inst.key).catch(() => {}); + } + }, inst.idleTimeoutMs); +} + +async function ensureDebuggerAttached(inst: CdpProxyInstance) { + if (inst.debuggerAttached) return; + try { + // "1.3" is the commonly-used version string in Electron docs; Electron will negotiate. + inst.wc.debugger.attach("1.3"); + inst.debuggerAttached = true; + } catch (e: any) { + const msg = e?.message || String(e); + if (msg.includes("already attached")) { + throw new Error( + "CDP attach failed: another debugger is already attached (close DevTools for this webview)" + ); + } + throw new Error(`CDP attach failed: ${msg}`); + } +} + +function attachDebuggerEventForwarders(inst: CdpProxyInstance) { + // Forward CDP events to all connected WS clients. + const onMessage = (_event: any, method: string, params: any) => { + for (const ws of inst.clients) { + safeJsonSend(ws, { method, params }); + } + }; + const onDetach = () => { + inst.debuggerAttached = false; + }; + inst.wc.debugger.on("message", onMessage); + inst.wc.debugger.on("detach", onDetach); + + // Tear down if the target dies. + inst.wc.once("destroyed", () => { + stopWebCdpProxy(inst.key).catch(() => {}); + }); +} + +function makeJsonListEntry(inst: CdpProxyInstance): any { + const hostForUrl = getWsHostForUrl(inst.host); + const wsUrl = `ws://${hostForUrl}:${inst.port}${inst.wsPath}`; + // Provide a devtoolsFrontendUrl that Chrome can open directly. + const devtoolsFrontendUrl = `/devtools/inspector.html?ws=${hostForUrl}:${inst.port}${inst.wsPath}`; + let url = ""; + try { + url = inst.wc.getURL(); + } catch (_) {} + let title = ""; + try { + title = inst.wc.getTitle(); + } catch (_) {} + return { + description: "Wave WebView (web block)", + devtoolsFrontendUrl, + id: inst.targetid, + title: title || "Wave WebView", + type: "page", + url, + webSocketDebuggerUrl: wsUrl, + }; +} + +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); +} + +async function createServer(inst: Omit & { port: number }) { + const server = http.createServer((req, res) => { + if (!req.url) { + respondText(res, 400, "missing url"); + return; + } + const parsed = new URL(req.url, `http://${req.headers.host || "127.0.0.1"}`); + if (req.method === "GET" && parsed.pathname === "/json/version") { + respondJson(res, 200, { + Browser: "Wave (Electron)", + "Protocol-Version": "1.3", + }); + return; + } + if (req.method === "GET" && (parsed.pathname === "/json" || parsed.pathname === "/json/list")) { + const entry = makeJsonListEntry(inst as any); + respondJson(res, 200, [entry]); + return; + } + respondText(res, 404, "not found"); + }); + + const wss = new WebSocketServer({ noServer: true }); + server.on("upgrade", (req, socket, head) => { + try { + const urlObj = new URL(req.url || "", `http://${req.headers.host || "127.0.0.1"}`); + if (urlObj.pathname !== inst.wsPath) { + socket.destroy(); + return; + } + } catch (_) { + socket.destroy(); + return; + } + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit("connection", ws, req); + }); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(inst.port, inst.host, () => resolve()); + }); + + const address = server.address(); + let actualPort = inst.port; + if (address && typeof address === "object") { + actualPort = address.port; + } + + return { server, wss, port: actualPort }; +} + +export async function startWebCdpProxy( + wc: WebContents, + workspaceid: string, + tabid: string, + blockid: string, + opts?: CdpProxyStartOpts +): Promise { + const key = makeKey(workspaceid, tabid, blockid); + const existing = proxyMap.get(key); + if (existing) { + const hostForUrl = getWsHostForUrl(existing.host); + const wsUrl = `ws://${hostForUrl}:${existing.port}${existing.wsPath}`; + const httpUrl = `http://${hostForUrl}:${existing.port}`; + return { + key, + workspaceid, + tabid, + blockid, + host: existing.host, + port: existing.port, + targetid: existing.targetid, + wsPath: existing.wsPath, + wsUrl, + httpUrl, + inspectorUrl: `devtools://devtools/bundled/inspector.html?ws=${hostForUrl}:${existing.port}${existing.wsPath}`, + }; + } + + const host = opts?.host ?? "127.0.0.1"; + const port = opts?.port ?? 0; + const idleTimeoutMs = opts?.idleTimeoutMs ?? 10 * 60 * 1000; + const targetid = randomUUID().replace(/-/g, ""); + const wsPath = `/devtools/page/${targetid}`; + + const instPre: any = { + key, + workspaceid, + tabid, + blockid, + host, + port, + targetid, + wsPath, + wc, + debuggerAttached: false, + clients: new Set(), + idleTimer: null, + idleTimeoutMs, + }; + + const { server, wss, port: actualPort } = await createServer(instPre); + // Important: createServer() closes over instPre for /json/list responses. + // If the caller requested port=0, the OS assigns an ephemeral port. Update instPre.port so /json/list reports + // the actual port instead of ":0". + instPre.port = actualPort; + + const inst: CdpProxyInstance = { + ...instPre, + server, + wss, + port: actualPort, + }; + proxyMap.set(key, inst); + refreshIdleTimer(inst); + + attachDebuggerEventForwarders(inst); + + wss.on("connection", async (ws) => { + inst.clients.add(ws); + refreshIdleTimer(inst); + try { + await ensureDebuggerAttached(inst); + } catch (e: any) { + safeJsonSend(ws, { error: e?.message || String(e) }); + try { + ws.close(); + } catch (_) {} + return; + } + + ws.on("message", async (data) => { + refreshIdleTimer(inst); + 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 inst.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", () => { + inst.clients.delete(ws); + refreshIdleTimer(inst); + }); + }); + + const hostForUrl = getWsHostForUrl(host); + const wsUrl = `ws://${hostForUrl}:${actualPort}${wsPath}`; + const httpUrl = `http://${hostForUrl}:${actualPort}`; + return { + key, + workspaceid, + tabid, + blockid, + host, + port: actualPort, + targetid, + wsPath, + wsUrl, + httpUrl, + inspectorUrl: `devtools://devtools/bundled/inspector.html?ws=${hostForUrl}:${actualPort}${wsPath}`, + }; +} + +export async function stopWebCdpProxy(key: string): Promise { + const inst = proxyMap.get(key); + if (!inst) return; + proxyMap.delete(key); + if (inst.idleTimer) { + clearTimeout(inst.idleTimer); + inst.idleTimer = null; + } + for (const ws of inst.clients) { + try { + ws.close(); + } catch (_) {} + } + inst.clients.clear(); + try { + inst.wss.close(); + } catch (_) {} + await new Promise((resolve) => { + try { + inst.server.close(() => resolve()); + } catch (_) { + resolve(); + } + }); + try { + if (inst.debuggerAttached) { + inst.wc.debugger.detach(); + inst.debuggerAttached = false; + } + } catch (_) {} +} + +export async function stopWebCdpProxyForTarget(workspaceid: string, tabid: string, blockid: string): Promise { + const key = makeKey(workspaceid, tabid, blockid); + return stopWebCdpProxy(key); +} + +export function getWebCdpProxyStatus(): WebCdpTargetInfo[] { + const out: WebCdpTargetInfo[] = []; + for (const inst of proxyMap.values()) { + const hostForUrl = getWsHostForUrl(inst.host); + const wsUrl = `ws://${hostForUrl}:${inst.port}${inst.wsPath}`; + const httpUrl = `http://${hostForUrl}:${inst.port}`; + out.push({ + key: inst.key, + workspaceid: inst.workspaceid, + tabid: inst.tabid, + blockid: inst.blockid, + host: inst.host, + port: inst.port, + targetid: inst.targetid, + wsPath: inst.wsPath, + wsUrl, + httpUrl, + inspectorUrl: `devtools://devtools/bundled/inspector.html?ws=${hostForUrl}:${inst.port}${inst.wsPath}`, + }); + } + return out; +} diff --git a/emain/emain-wsh.ts b/emain/emain-wsh.ts index d17dc2e106..e0c5091dc3 100644 --- a/emain/emain-wsh.ts +++ b/emain/emain-wsh.ts @@ -6,6 +6,7 @@ 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 { getWebCdpProxyStatus, startWebCdpProxy, stopWebCdpProxyForTarget } from "./emain-cdp"; import { unamePlatform } from "./emain-platform"; import { getWebContentsByBlockId, webGetSelector } from "./emain-web"; import { createBrowserWindow, getWaveWindowById, getWaveWindowByWorkspaceId } from "./emain-window"; @@ -31,6 +32,54 @@ 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 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}`); + } + const info = await startWebCdpProxy(wc, data.workspaceid, data.tabid, data.blockid, { + host: data.listenhost, + port: data.port, + idleTimeoutMs: data.idletimeoutms, + }); + 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"); + } + await stopWebCdpProxyForTarget(data.workspaceid, data.tabid, data.blockid); + } + + async handle_webcdpstatus(rh: RpcResponseHelper): Promise { + const status = getWebCdpProxyStatus(); + return status.map((s) => ({ + key: s.key, + workspaceid: s.workspaceid, + tabid: s.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..bcab6ce76a 100644 --- a/emain/emain.ts +++ b/emain/emain.ts @@ -361,6 +361,16 @@ async function appMain() { console.log("disabling hardware acceleration, per launch settings"); electronApp.disableHardwareAcceleration(); } + // Optional: expose Electron's global remote debugging port for inspecting the main window/renderer. + // NOTE: this does not directly solve per- debugging; see emain-cdp.ts for that. + const remoteDebugPort = launchSettings?.["debug:remotedebugport"]; + if (remoteDebugPort != null) { + const portStr = String(remoteDebugPort); + console.log("enabling remote debugging port", portStr); + electronApp.commandLine.appendSwitch("remote-debugging-port", portStr); + // default to loopback to avoid exposing CDP to the LAN unless the user explicitly forwards it. + electronApp.commandLine.appendSwitch("remote-debugging-address", "127.0.0.1"); + } const startTs = Date.now(); const instanceLock = electronApp.requestSingleInstanceLock(); if (!instanceLock) { 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/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index a865f41313..cdd9347358 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -671,6 +671,32 @@ declare global { streammeta: StreamMeta; }; + // wshrpc.CommandWebCdpStartData + type CommandWebCdpStartData = { + workspaceid: string; + blockid: string; + tabid: string; + port?: number; + listenhost?: string; + 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; @@ -2005,6 +2031,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/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..5a337c0380 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,41 @@ 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 + ListenHost string `json:"listenhost,omitempty"` // default 127.0.0.1 + 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"` From 631d8b299be031af157e49ea1f2e3d0f5ba7a72c Mon Sep 17 00:00:00 2001 From: Drew Goddyn Date: Sun, 1 Feb 2026 14:06:56 -0800 Subject: [PATCH 2/4] Harden web widget CDP proxy behind config gate Require debug:webcdp to enable wsh web cdp, add secret-prefixed endpoints, and bind proxy to localhost only. Also document new debug settings and validate debug:remotedebugport. --- cmd/wsh/cmd/wshcmd-webcdp.go | 6 ++---- docs/docs/config.mdx | 2 ++ emain/emain-cdp.ts | 28 ++++++++++++++++++++-------- emain/emain-wsh.ts | 7 ++++++- emain/emain.ts | 15 ++++++++++----- frontend/types/gotypes.d.ts | 3 ++- pkg/wconfig/metaconsts.go | 2 ++ pkg/wconfig/settingsconfig.go | 2 ++ pkg/wshrpc/wshrpctypes.go | 1 - schema/settings.json | 6 ++++++ 10 files changed, 52 insertions(+), 20 deletions(-) diff --git a/cmd/wsh/cmd/wshcmd-webcdp.go b/cmd/wsh/cmd/wshcmd-webcdp.go index 3b57335689..98afd8fe03 100644 --- a/cmd/wsh/cmd/wshcmd-webcdp.go +++ b/cmd/wsh/cmd/wshcmd-webcdp.go @@ -17,6 +17,7 @@ import ( 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, } @@ -41,15 +42,13 @@ var webCdpStatusCmd = &cobra.Command{ RunE: webCdpStatusRun, } -var webCdpListenHost string var webCdpPort int var webCdpIdleTimeoutMs int var webCdpJson bool func init() { - webCdpStartCmd.Flags().StringVar(&webCdpListenHost, "listen", "127.0.0.1", "listen host (default: 127.0.0.1)") webCdpStartCmd.Flags().IntVar(&webCdpPort, "port", 0, "listen port (0 chooses an ephemeral port)") - webCdpStartCmd.Flags().IntVar(&webCdpIdleTimeoutMs, "idle-timeout-ms", 10*60*1000, "idle timeout in ms (0 disables)") + webCdpStartCmd.Flags().IntVar(&webCdpIdleTimeoutMs, "idle-timeout-ms", 5*60*1000, "idle timeout in ms (0 disables)") webCdpStartCmd.Flags().BoolVar(&webCdpJson, "json", false, "output as json") webCdpStatusCmd.Flags().BoolVar(&webCdpJson, "json", false, "output as json") @@ -87,7 +86,6 @@ func webCdpStartRun(cmd *cobra.Command, args []string) error { BlockId: fullORef.OID, TabId: blockInfo.TabId, Port: webCdpPort, - ListenHost: webCdpListenHost, IdleTimeoutMs: webCdpIdleTimeoutMs, } resp, err := wshclient.WebCdpStartCommand(RpcClient, req, &wshrpc.RpcOpts{ diff --git a/docs/docs/config.mdx b/docs/docs/config.mdx index 778fd1c1bf..7280b782d7 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:remotedebugport | int | (debug) enable Electron's global remote debugging port for inspecting the main Wave UI (CDP). bound to `127.0.0.1`. requires app restart. | +| 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. | For reference, this is the current default configuration (v0.11.5): diff --git a/emain/emain-cdp.ts b/emain/emain-cdp.ts index adddf84cdc..9aeb9c0f32 100644 --- a/emain/emain-cdp.ts +++ b/emain/emain-cdp.ts @@ -8,9 +8,8 @@ import { URL } from "node:url"; import WebSocket, { WebSocketServer } from "ws"; export type CdpProxyStartOpts = { - host?: string; // default 127.0.0.1 port?: number; // default 0 (ephemeral) - idleTimeoutMs?: number; // default 10 minutes + idleTimeoutMs?: number; // default 5 minutes }; export type WebCdpTargetInfo = { @@ -47,6 +46,8 @@ type CdpProxyInstance = { clients: Set; idleTimer: NodeJS.Timeout | null; idleTimeoutMs: number; + secret: string; + basePath: string; }; const proxyMap = new Map(); @@ -73,6 +74,7 @@ function refreshIdleTimer(inst: CdpProxyInstance) { if (inst.idleTimer) clearTimeout(inst.idleTimer); inst.idleTimer = setTimeout(() => { if (inst.clients.size === 0) { + console.log("webcdp auto-stop (idle)", inst.key); stopWebCdpProxy(inst.key).catch(() => {}); } }, inst.idleTimeoutMs); @@ -110,6 +112,7 @@ function attachDebuggerEventForwarders(inst: CdpProxyInstance) { // Tear down if the target dies. inst.wc.once("destroyed", () => { + console.log("webcdp auto-stop (webcontents destroyed)", inst.key); stopWebCdpProxy(inst.key).catch(() => {}); }); } @@ -118,7 +121,7 @@ function makeJsonListEntry(inst: CdpProxyInstance): any { const hostForUrl = getWsHostForUrl(inst.host); const wsUrl = `ws://${hostForUrl}:${inst.port}${inst.wsPath}`; // Provide a devtoolsFrontendUrl that Chrome can open directly. - const devtoolsFrontendUrl = `/devtools/inspector.html?ws=${hostForUrl}:${inst.port}${inst.wsPath}`; + const devtoolsFrontendUrl = `${inst.basePath}/devtools/inspector.html?ws=${hostForUrl}:${inst.port}${inst.wsPath}`; let url = ""; try { url = inst.wc.getURL(); @@ -160,14 +163,17 @@ async function createServer(inst: Omit(), @@ -338,6 +349,7 @@ export async function stopWebCdpProxy(key: string): Promise { const inst = proxyMap.get(key); if (!inst) return; proxyMap.delete(key); + console.log("webcdp stop", key); if (inst.idleTimer) { clearTimeout(inst.idleTimer); inst.idleTimer = null; diff --git a/emain/emain-wsh.ts b/emain/emain-wsh.ts index e0c5091dc3..c3a3406a4a 100644 --- a/emain/emain-wsh.ts +++ b/emain/emain-wsh.ts @@ -36,6 +36,10 @@ export class ElectronWshClientType extends WshClient { 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 ww = getWaveWindowByWorkspaceId(data.workspaceid); if (ww == null) { throw new Error(`no window found with workspace ${data.workspaceid}`); @@ -44,8 +48,8 @@ export class ElectronWshClientType extends WshClient { if (wc == null) { throw new Error(`no webcontents found with blockid ${data.blockid}`); } + console.log("webcdpstart", data.workspaceid, data.tabid, data.blockid, "port=", data.port); const info = await startWebCdpProxy(wc, data.workspaceid, data.tabid, data.blockid, { - host: data.listenhost, port: data.port, idleTimeoutMs: data.idletimeoutms, }); @@ -62,6 +66,7 @@ export class ElectronWshClientType extends WshClient { if (!data.tabid || !data.blockid || !data.workspaceid) { throw new Error("workspaceid, tabid and blockid are required"); } + console.log("webcdpstop", data.workspaceid, data.tabid, data.blockid); await stopWebCdpProxyForTarget(data.workspaceid, data.tabid, data.blockid); } diff --git a/emain/emain.ts b/emain/emain.ts index bcab6ce76a..9a3ca873e2 100644 --- a/emain/emain.ts +++ b/emain/emain.ts @@ -365,11 +365,16 @@ async function appMain() { // NOTE: this does not directly solve per- debugging; see emain-cdp.ts for that. const remoteDebugPort = launchSettings?.["debug:remotedebugport"]; if (remoteDebugPort != null) { - const portStr = String(remoteDebugPort); - console.log("enabling remote debugging port", portStr); - electronApp.commandLine.appendSwitch("remote-debugging-port", portStr); - // default to loopback to avoid exposing CDP to the LAN unless the user explicitly forwards it. - electronApp.commandLine.appendSwitch("remote-debugging-address", "127.0.0.1"); + const portNum = typeof remoteDebugPort === "number" ? remoteDebugPort : parseInt(String(remoteDebugPort), 10); + if (!Number.isFinite(portNum) || portNum < 1 || portNum > 65535) { + console.log("invalid debug:remotedebugport (expected 1-65535), skipping:", remoteDebugPort); + } else { + const portStr = String(portNum); + console.log("enabling remote debugging port", portStr); + electronApp.commandLine.appendSwitch("remote-debugging-port", portStr); + // default to loopback to avoid exposing CDP to the LAN unless the user explicitly forwards it. + electronApp.commandLine.appendSwitch("remote-debugging-address", "127.0.0.1"); + } } const startTs = Date.now(); const instanceLock = electronApp.requestSingleInstanceLock(); diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index cdd9347358..ea501aaa33 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -677,7 +677,6 @@ declare global { blockid: string; tabid: string; port?: number; - listenhost?: string; idletimeoutms?: number; }; @@ -1324,6 +1323,8 @@ declare global { "conn:askbeforewshinstall"?: boolean; "conn:wshenabled"?: boolean; "debug:*"?: boolean; + "debug:webcdp"?: boolean; + "debug:remotedebugport"?: number; "debug:pprofport"?: number; "debug:pprofmemprofilerate"?: number; "tsunami:*"?: boolean; diff --git a/pkg/wconfig/metaconsts.go b/pkg/wconfig/metaconsts.go index 98b9b2ab33..49284bc343 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_DebugRemoteDebugPort = "debug:remotedebugport" ConfigKey_DebugPprofPort = "debug:pprofport" ConfigKey_DebugPprofMemProfileRate = "debug:pprofmemprofilerate" diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index 0d392606b6..8e71bd0566 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"` + DebugRemoteDebugPort *int `json:"debug:remotedebugport,omitempty"` DebugPprofPort *int `json:"debug:pprofport,omitempty"` DebugPprofMemProfileRate *int `json:"debug:pprofmemprofilerate,omitempty"` diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 5a337c0380..9874ef2a6b 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -470,7 +470,6 @@ type CommandWebCdpStartData struct { BlockId string `json:"blockid"` TabId string `json:"tabid"` Port int `json:"port,omitempty"` // 0 means choose an ephemeral port - ListenHost string `json:"listenhost,omitempty"` // default 127.0.0.1 IdleTimeoutMs int `json:"idletimeoutms,omitempty"` // 0 disables idle shutdown } diff --git a/schema/settings.json b/schema/settings.json index 1685b2bf17..54dc7bc0fe 100644 --- a/schema/settings.json +++ b/schema/settings.json @@ -272,6 +272,12 @@ "debug:*": { "type": "boolean" }, + "debug:webcdp": { + "type": "boolean" + }, + "debug:remotedebugport": { + "type": "integer" + }, "debug:pprofport": { "type": "integer" }, From a04796bf832adca381e76818ac94c4c77ddcfb13 Mon Sep 17 00:00:00 2001 From: Drew Goddyn Date: Sun, 1 Feb 2026 19:41:37 -0800 Subject: [PATCH 3/4] Improve web CDP UX in wsh and UI Add `wsh web cdp` listing for web widgets, support `wsh web open --cdp` (with retry for webview readiness), and highlight web widgets with active CDP. --- cmd/wsh/cmd/wshcmd-web.go | 52 +++++++++++ cmd/wsh/cmd/wshcmd-webcdp.go | 122 +++++++++++++++++++++++++ docs/docs/wsh-reference.mdx | 31 ++++++- frontend/app/view/webview/webcdp.ts | 51 +++++++++++ frontend/app/view/webview/webview.scss | 7 ++ frontend/app/view/webview/webview.tsx | 8 +- 6 files changed, 267 insertions(+), 4 deletions(-) create mode 100644 frontend/app/view/webview/webcdp.ts 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 index 98afd8fe03..9be0a48662 100644 --- a/cmd/wsh/cmd/wshcmd-webcdp.go +++ b/cmd/wsh/cmd/wshcmd-webcdp.go @@ -6,6 +6,10 @@ package cmd import ( "encoding/json" "fmt" + "os" + "sort" + "strings" + "text/tabwriter" "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/waveobj" @@ -19,6 +23,7 @@ var webCdpCmd = &cobra.Command{ 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{ @@ -61,6 +66,105 @@ func init() { 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 { @@ -73,6 +177,24 @@ func mustBeWebBlock(fullORef *waveobj.ORef) (*wshrpc.BlockInfoData, error) { } func webCdpStartRun(cmd *cobra.Command, args []string) error { + // If the user did not specify -b, try to start CDP for the current block if it's a web widget; + // otherwise list available web widgets in the workspace. + if strings.TrimSpace(blockArg) == "" { + thisORef, err := resolveSimpleId("this") + if err == nil { + if _, err2 := mustBeWebBlock(thisORef); err2 == nil { + blockArg = "this" + } else { + 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 err2 + } + } + } + fullORef, err := resolveBlockArg() if err != nil { return fmt.Errorf("resolving blockid: %w", err) diff --git a/docs/docs/wsh-reference.mdx b/docs/docs/wsh-reference.mdx index 1aa28c8c50..38a79233cc 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,33 @@ 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 +wsh web cdp start -b + +# Stop CDP +wsh web cdp stop -b + +# List active CDP proxies +wsh web cdp status +``` + +Note: CDP is a powerful interface (DOM/JS/cookies). It is gated behind `debug:webcdp=true` in `settings.json`. + --- ## notify @@ -855,6 +881,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 +905,6 @@ wsh blocks list --workspace=12d0c067-378e-454c-872e-77a314248114 wsh blocks list --json ``` - --- ## secret @@ -988,4 +1014,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/frontend/app/view/webview/webcdp.ts b/frontend/app/view/webview/webcdp.ts new file mode 100644 index 0000000000..bc34856940 --- /dev/null +++ b/frontend/app/view/webview/webcdp.ts @@ -0,0 +1,51 @@ +// Copyright 2026, 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; + } + try { + const status = await RpcApi.WebCdpStatusCommand(TabRpcClient, null, { 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) { + // Fail closed: don't show the indicator if we can't confirm active status. + globalStore.set(webCdpActiveMapAtom, {}); + } +} + +export function ensureWebCdpPollerStarted() { + if (pollerStarted) return; + pollerStarted = true; + // do one immediate poll, then periodic + pollOnce(); + pollerHandle = window.setInterval(pollOnce, 2500); +} + +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..75885c163a 100644 --- a/frontend/app/view/webview/webview.scss +++ b/frontend/app/view/webview/webview.scss @@ -19,6 +19,13 @@ will-change: transform; } +.webview.cdp-active { + // Border-like indicator without using border/outline (both are disabled above). + box-shadow: + inset 0 0 0 2px var(--accent-color), + 0 0 12px color-mix(in srgb, var(--accent-color) 35%, transparent); +} + .webview-error { display: flex; position: absolute; diff --git a/frontend/app/view/webview/webview.tsx b/frontend/app/view/webview/webview.tsx index bfa3476bd3..60e840d65b 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); }; @@ -1056,7 +1060,7 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps) Date: Sun, 1 Feb 2026 20:14:55 -0800 Subject: [PATCH 4/4] Tweak web CDP indicator styling Show a CONTROLLED badge and use an amber highlight for web widgets with active CDP. --- frontend/app/view/webview/webcdp.ts | 11 ++++--- frontend/app/view/webview/webview.scss | 43 +++++++++++++++++++++----- frontend/app/view/webview/webview.tsx | 29 +++++++++-------- 3 files changed, 59 insertions(+), 24 deletions(-) diff --git a/frontend/app/view/webview/webcdp.ts b/frontend/app/view/webview/webcdp.ts index bc34856940..ce8dddb610 100644 --- a/frontend/app/view/webview/webcdp.ts +++ b/frontend/app/view/webview/webcdp.ts @@ -18,8 +18,12 @@ async function pollOnce() { 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, null, { route: "electron", timeout: 2000 }); + const status = await RpcApi.WebCdpStatusCommand(TabRpcClient, { route: "electron", timeout: 2000 }); const next: Record = {}; for (const e of status ?? []) { if (e?.blockid) { @@ -28,8 +32,7 @@ async function pollOnce() { } globalStore.set(webCdpActiveMapAtom, next); } catch (_e) { - // Fail closed: don't show the indicator if we can't confirm active status. - globalStore.set(webCdpActiveMapAtom, {}); + // Avoid flicker on transient errors; keep last known value. } } @@ -38,7 +41,7 @@ export function ensureWebCdpPollerStarted() { pollerStarted = true; // do one immediate poll, then periodic pollOnce(); - pollerHandle = window.setInterval(pollOnce, 2500); + pollerHandle = window.setInterval(pollOnce, 750); } export function stopWebCdpPollerForTests() { diff --git a/frontend/app/view/webview/webview.scss b/frontend/app/view/webview/webview.scss index 75885c163a..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,11 +17,42 @@ will-change: transform; } -.webview.cdp-active { - // Border-like indicator without using border/outline (both are disabled above). +.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 var(--accent-color), - 0 0 12px color-mix(in srgb, var(--accent-color) 35%, transparent); + 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 { diff --git a/frontend/app/view/webview/webview.tsx b/frontend/app/view/webview/webview.tsx index 60e840d65b..33c11a694b 100644 --- a/frontend/app/view/webview/webview.tsx +++ b/frontend/app/view/webview/webview.tsx @@ -1058,19 +1058,22 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps) return ( - +
+ {cdpActive &&
CONTROLLED
} + +
{errorText && (
{errorText}