From e0f97849224069194ef814813e2a27684090c313 Mon Sep 17 00:00:00 2001 From: Drew Goddyn Date: Sun, 1 Feb 2026 12:30:32 -0800 Subject: [PATCH 1/6] 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/6] 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/6] 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/6] 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}
From 9008a3585b9583c693d21793e85a0c5d90e931b9 Mon Sep 17 00:00:00 2001 From: Drew Goddyn Date: Tue, 3 Feb 2026 20:10:12 -0800 Subject: [PATCH 5/6] Rebuild web widget CDP as Chrome /json server Expose web widgets as CDP targets via a shared localhost-only server with /json discovery and /devtools/page websockets. Add debug:webcdpport and remove global app remote-debugging support. --- cmd/wsh/cmd/wshcmd-webcdp.go | 9 +- docs/docs/config.mdx | 2 +- docs/docs/wsh-reference.mdx | 8 +- emain/emain-cdp.ts | 731 +++++++++++++++++++++------------- emain/emain-wsh.ts | 26 +- emain/emain.ts | 51 ++- emain/preload.ts | 16 + frontend/types/gotypes.d.ts | 2 +- pkg/wconfig/metaconsts.go | 2 +- pkg/wconfig/settingsconfig.go | 2 +- schema/settings.json | 2 +- 11 files changed, 541 insertions(+), 310 deletions(-) diff --git a/cmd/wsh/cmd/wshcmd-webcdp.go b/cmd/wsh/cmd/wshcmd-webcdp.go index 9be0a48662..92d312d25d 100644 --- a/cmd/wsh/cmd/wshcmd-webcdp.go +++ b/cmd/wsh/cmd/wshcmd-webcdp.go @@ -47,13 +47,9 @@ var webCdpStatusCmd = &cobra.Command{ RunE: webCdpStatusRun, } -var webCdpPort int -var webCdpIdleTimeoutMs int var webCdpJson bool func init() { - webCdpStartCmd.Flags().IntVar(&webCdpPort, "port", 0, "listen port (0 chooses an ephemeral port)") - 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") @@ -207,8 +203,8 @@ func webCdpStartRun(cmd *cobra.Command, args []string) error { WorkspaceId: blockInfo.WorkspaceId, BlockId: fullORef.OID, TabId: blockInfo.TabId, - Port: webCdpPort, - IdleTimeoutMs: webCdpIdleTimeoutMs, + Port: 0, + IdleTimeoutMs: 0, } resp, err := wshclient.WebCdpStartCommand(RpcClient, req, &wshrpc.RpcOpts{ Route: wshutil.ElectronRoute, @@ -228,6 +224,7 @@ func webCdpStartRun(cmd *cobra.Command, args []string) error { 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 } diff --git a/docs/docs/config.mdx b/docs/docs/config.mdx index 7280b782d7..1f09fee3fb 100644 --- a/docs/docs/config.mdx +++ b/docs/docs/config.mdx @@ -101,8 +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. | +| 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 38a79233cc..b5ae768fd1 100644 --- a/docs/docs/wsh-reference.mdx +++ b/docs/docs/wsh-reference.mdx @@ -393,10 +393,16 @@ wsh web cdp start -b # Stop CDP wsh web cdp stop -b -# List active CDP proxies +# List active controlled web widgets wsh web cdp status ``` +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 +``` + Note: CDP is a powerful interface (DOM/JS/cookies). It is gated behind `debug:webcdp=true` in `settings.json`. --- diff --git a/emain/emain-cdp.ts b/emain/emain-cdp.ts index 9aeb9c0f32..820020368f 100644 --- a/emain/emain-cdp.ts +++ b/emain/emain-cdp.ts @@ -1,60 +1,122 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { ipcMain, webContents } from "electron"; 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 = { - port?: number; // default 0 (ephemeral) - idleTimeoutMs?: number; // default 5 minutes +// ---- Public API (used by emain.ts / emain-wsh.ts) --------------------------- + +export type WebCdpServerConfig = { + enabled: boolean; + port: number; // default 9222 + idleDetachMs?: number; // default 30000 }; -export type WebCdpTargetInfo = { - key: string; - workspaceid: string; - tabid: string; - blockid: string; +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; }; -type CdpProxyInstance = { - key: string; - workspaceid: string; - tabid: string; - blockid: string; +// Configure (injected) block creation/deletion handlers. +let blockOps: WebCdpBlockOps = {}; +export function setWebCdpBlockOps(ops: WebCdpBlockOps) { + blockOps = ops ?? {}; +} - host: string; - port: number; - targetid: string; - wsPath: string; +// 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(); +} - server: http.Server; - wss: WebSocketServer; +// 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; - debuggerAttached: boolean; clients: Set; + debuggerAttached: boolean; idleTimer: NodeJS.Timeout | null; - idleTimeoutMs: number; - secret: string; - basePath: string; + destroyedUnsub: (() => void) | null; + dbgMsgHandler: ((event: any, method: string, params: any) => void) | null; + dbgDetachHandler: (() => void) | null; + controlled: () => boolean; }; -const proxyMap = new Map(); +const HOST = "127.0.0.1"; +const WS_PAGE_PREFIX = "/devtools/page/"; -function makeKey(workspaceid: string, tabid: string, blockid: string): string { - return `${workspaceid}:${tabid}:${blockid}`; -} +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; @@ -63,232 +125,391 @@ function safeJsonSend(ws: WebSocket, obj: any) { } 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 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 refreshIdleTimer(inst: CdpProxyInstance) { - if (inst.idleTimeoutMs <= 0) return; - 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); +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)}`; } -async function ensureDebuggerAttached(inst: CdpProxyInstance) { - if (inst.debuggerAttached) return; +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 { - // "1.3" is the commonly-used version string in Electron docs; Electron will negotiate. - inst.wc.debugger.attach("1.3"); - inst.debuggerAttached = true; + 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: another debugger is already attached (close DevTools for this webview)" - ); + 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 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 }); +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 onDetach = () => { - inst.debuggerAttached = false; + + const onDestroyed = () => { + unregisterTarget(blockid); + }; + wc.once("destroyed", onDestroyed); + t.destroyedUnsub = () => { + try { + wc.removeListener("destroyed", onDestroyed as any); + } catch (_) {} }; - inst.wc.debugger.on("message", onMessage); - inst.wc.debugger.on("detach", onDetach); - // Tear down if the target dies. - inst.wc.once("destroyed", () => { - console.log("webcdp auto-stop (webcontents destroyed)", inst.key); - stopWebCdpProxy(inst.key).catch(() => {}); - }); + targetsById.set(blockid, t); + return t; } -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 = `${inst.basePath}/devtools/inspector.html?ws=${hostForUrl}:${inst.port}${inst.wsPath}`; - let url = ""; - try { - url = inst.wc.getURL(); - } catch (_) {} - let title = ""; +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 { - title = inst.wc.getTitle(); + t.destroyedUnsub?.(); } 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 startDiscoveryPoller() { + if (discoveryPoller) return; + discoveryPoller = setInterval(() => { + refreshTargetsFromRenderers().catch(() => {}); + }, 750); + refreshTargetsFromRenderers().catch(() => {}); } -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 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 createServer(inst: Omit & { port: number }) { - const server = http.createServer((req, res) => { +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 || "127.0.0.1"}`); - if (req.method === "GET" && parsed.pathname === `${inst.basePath}/json/version`) { + 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; } - if ( - req.method === "GET" && - (parsed.pathname === `${inst.basePath}/json` || parsed.pathname === `${inst.basePath}/json/list`) - ) { - const entry = makeJsonListEntry(inst as any); - respondJson(res, 200, [entry]); + + // 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 || "127.0.0.1"}`); - if (urlObj.pathname !== inst.wsPath) { - socket.destroy(); - return; - } + 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, req); + wss.emit("connection", ws, targetId); }); }); - 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}`, - }; - } - - // Always bind locally to loopback for security. - const host = "127.0.0.1"; - const port = opts?.port ?? 0; - const idleTimeoutMs = opts?.idleTimeoutMs ?? 5 * 60 * 1000; - const targetid = randomUUID().replace(/-/g, ""); - const secret = randomUUID().replace(/-/g, ""); - const basePath = `/__wave_cdp/${secret}`; - const wsPath = `${basePath}/devtools/page/${targetid}`; - - const instPre: any = { - key, - workspaceid, - tabid, - blockid, - host, - port, - targetid, - wsPath, - secret, - basePath, - 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); + 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(inst); + await ensureDebuggerAttached(t); } catch (e: any) { safeJsonSend(ws, { error: e?.message || String(e) }); try { @@ -298,7 +519,6 @@ export async function startWebCdpProxy( } ws.on("message", async (data) => { - refreshIdleTimer(inst); let msg: any; try { msg = JSON.parse(data.toString()); @@ -314,7 +534,7 @@ export async function startWebCdpProxy( return; } try { - const result = await inst.wc.debugger.sendCommand(method, params); + 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) } }); @@ -322,86 +542,53 @@ export async function startWebCdpProxy( }); ws.on("close", () => { - inst.clients.delete(ws); - refreshIdleTimer(inst); + t.clients.delete(ws); + if (t.clients.size === 0) { + scheduleIdleDetach(t); + } }); }); - 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}`, - }; -} + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(serverCfg.port, HOST, () => resolve()); + }); -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; + httpServer = server; + wsServer = wss; + const addr = server.address(); + if (addr && typeof addr === "object") { + actualPort = addr.port; + } else { + actualPort = serverCfg.port; } - for (const ws of inst.clients) { - try { - ws.close(); - } catch (_) {} + + console.log("webcdp server listening", `${HOST}:${actualPort}`); + startDiscoveryPoller(); +} + +async function stopSharedServer() { + stopDiscoveryPoller(); + + for (const id of Array.from(targetsById.keys())) { + unregisterTarget(id); } - 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; - } + wsServer?.close(); } catch (_) {} -} + wsServer = null; -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}`, + const srv = httpServer; + httpServer = null; + actualPort = null; + if (srv) { + await new Promise((resolve) => { + try { + srv.close(() => resolve()); + } catch (_) { + resolve(); + } }); } - return out; } diff --git a/emain/emain-wsh.ts b/emain/emain-wsh.ts index c3a3406a4a..1f75d95b97 100644 --- a/emain/emain-wsh.ts +++ b/emain/emain-wsh.ts @@ -6,7 +6,12 @@ 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 { + configureWebCdpServer, + getControlledWebCdpTargets, + registerWebCdpTarget, + stopWebCdpForBlock, +} from "./emain-cdp"; import { unamePlatform } from "./emain-platform"; import { getWebContentsByBlockId, webGetSelector } from "./emain-web"; import { createBrowserWindow, getWaveWindowById, getWaveWindowByWorkspaceId } from "./emain-window"; @@ -40,6 +45,8 @@ export class ElectronWshClientType extends WshClient { 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}`); @@ -48,11 +55,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, { - port: data.port, - idleTimeoutMs: data.idletimeoutms, - }); + console.log("webcdpstart", data.workspaceid, data.tabid, data.blockid); + const info = registerWebCdpTarget(data.blockid, wc); return { host: info.host, port: info.port, @@ -67,15 +71,15 @@ export class ElectronWshClientType extends WshClient { 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); + stopWebCdpForBlock(data.blockid); } async handle_webcdpstatus(rh: RpcResponseHelper): Promise { - const status = getWebCdpProxyStatus(); + const status = getControlledWebCdpTargets(); return status.map((s) => ({ - key: s.key, - workspaceid: s.workspaceid, - tabid: s.tabid, + key: s.targetid, + workspaceid: "", + tabid: "", blockid: s.blockid, host: s.host, port: s.port, diff --git a/emain/emain.ts b/emain/emain.ts index 9a3ca873e2..15aaed068f 100644 --- a/emain/emain.ts +++ b/emain/emain.ts @@ -49,6 +49,7 @@ import { relaunchBrowserWindows, WaveBrowserWindow, } from "./emain-window"; +import { configureWebCdpServer, setWebCdpBlockOps } from "./emain-cdp"; import { ElectronWshClient, initElectronWshClient } from "./emain-wsh"; import { getLaunchSettings } from "./launchsettings"; import { configureAutoUpdater, updater } from "./updater"; @@ -361,21 +362,6 @@ 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 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(); if (!instanceLock) { @@ -404,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/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index ea501aaa33..4a740ea3b8 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -1324,7 +1324,7 @@ declare global { "conn:wshenabled"?: boolean; "debug:*"?: boolean; "debug:webcdp"?: boolean; - "debug:remotedebugport"?: number; + "debug:webcdpport"?: number; "debug:pprofport"?: number; "debug:pprofmemprofilerate"?: number; "tsunami:*"?: boolean; diff --git a/pkg/wconfig/metaconsts.go b/pkg/wconfig/metaconsts.go index 49284bc343..3d3849ddee 100644 --- a/pkg/wconfig/metaconsts.go +++ b/pkg/wconfig/metaconsts.go @@ -111,7 +111,7 @@ const ( ConfigKey_DebugClear = "debug:*" ConfigKey_DebugWebCdp = "debug:webcdp" - ConfigKey_DebugRemoteDebugPort = "debug:remotedebugport" + 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 8e71bd0566..46efa3cebf 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -158,7 +158,7 @@ type SettingsType struct { DebugClear bool `json:"debug:*,omitempty"` DebugWebCdp bool `json:"debug:webcdp,omitempty"` - DebugRemoteDebugPort *int `json:"debug:remotedebugport,omitempty"` + DebugWebCdpPort *int `json:"debug:webcdpport,omitempty"` DebugPprofPort *int `json:"debug:pprofport,omitempty"` DebugPprofMemProfileRate *int `json:"debug:pprofmemprofilerate,omitempty"` diff --git a/schema/settings.json b/schema/settings.json index 54dc7bc0fe..10d8613bb2 100644 --- a/schema/settings.json +++ b/schema/settings.json @@ -275,7 +275,7 @@ "debug:webcdp": { "type": "boolean" }, - "debug:remotedebugport": { + "debug:webcdpport": { "type": "integer" }, "debug:pprofport": { From 72f9b5644704d7c173ac0f413058b93d4a1b67da Mon Sep 17 00:00:00 2001 From: Drew Goddyn Date: Wed, 11 Feb 2026 21:08:46 -0800 Subject: [PATCH 6/6] Clean up CDP proxy for PR review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace console.log with Winston logger in emain-cdp.ts and emain-wsh.ts - Fix copyright year in webcdp.ts (2026 → 2025) - Simplify nested error handling in webCdpStartRun via resolveBlockArgFromContext helper - Fix UX bug: wsh web cdp stop now auto-resolves current block like start does - Document --json flag and auto-resolve behavior in wsh-reference.mdx - Run prettier (import ordering in emain-cdp.ts, emain.ts) Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/wsh/cmd/wshcmd-webcdp.go | 39 ++++++++++++++++++----------- docs/docs/wsh-reference.mdx | 10 +++++--- emain/emain-cdp.ts | 5 ++-- emain/emain-wsh.ts | 5 ++-- emain/emain.ts | 2 +- frontend/app/view/webview/webcdp.ts | 2 +- 6 files changed, 39 insertions(+), 24 deletions(-) diff --git a/cmd/wsh/cmd/wshcmd-webcdp.go b/cmd/wsh/cmd/wshcmd-webcdp.go index 92d312d25d..b4b51fb3d7 100644 --- a/cmd/wsh/cmd/wshcmd-webcdp.go +++ b/cmd/wsh/cmd/wshcmd-webcdp.go @@ -172,22 +172,28 @@ func mustBeWebBlock(fullORef *waveobj.ORef) (*wshrpc.BlockInfoData, error) { 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 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 - } + if err := resolveBlockArgFromContext(); err != nil { + return err } } @@ -229,6 +235,11 @@ func webCdpStartRun(cmd *cobra.Command, args []string) error { } 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) diff --git a/docs/docs/wsh-reference.mdx b/docs/docs/wsh-reference.mdx index b5ae768fd1..508d34b005 100644 --- a/docs/docs/wsh-reference.mdx +++ b/docs/docs/wsh-reference.mdx @@ -387,14 +387,14 @@ Wave can expose a local Chrome DevTools Protocol (CDP) websocket for web widgets # 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 +# 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 +wsh web cdp stop [-b ] # List active controlled web widgets -wsh web cdp status +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: @@ -403,6 +403,8 @@ When enabled, Wave exposes a Chrome-style remote debugging endpoint on `127.0.0. 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`. --- diff --git a/emain/emain-cdp.ts b/emain/emain-cdp.ts index 820020368f..a05ae70f8c 100644 --- a/emain/emain-cdp.ts +++ b/emain/emain-cdp.ts @@ -1,12 +1,13 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { ipcMain, webContents } from "electron"; 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) --------------------------- @@ -563,7 +564,7 @@ async function ensureSharedServer() { actualPort = serverCfg.port; } - console.log("webcdp server listening", `${HOST}:${actualPort}`); + log("webcdp server listening", `${HOST}:${actualPort}`); startDiscoveryPoller(); } diff --git a/emain/emain-wsh.ts b/emain/emain-wsh.ts index 1f75d95b97..0ed3f30331 100644 --- a/emain/emain-wsh.ts +++ b/emain/emain-wsh.ts @@ -12,6 +12,7 @@ import { 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"; @@ -55,7 +56,7 @@ 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); + log("webcdpstart", data.workspaceid, data.tabid, data.blockid); const info = registerWebCdpTarget(data.blockid, wc); return { host: info.host, @@ -70,7 +71,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); + log("webcdpstop", data.workspaceid, data.tabid, data.blockid); stopWebCdpForBlock(data.blockid); } diff --git a/emain/emain.ts b/emain/emain.ts index 15aaed068f..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"; @@ -49,7 +50,6 @@ import { relaunchBrowserWindows, WaveBrowserWindow, } from "./emain-window"; -import { configureWebCdpServer, setWebCdpBlockOps } from "./emain-cdp"; import { ElectronWshClient, initElectronWshClient } from "./emain-wsh"; import { getLaunchSettings } from "./launchsettings"; import { configureAutoUpdater, updater } from "./updater"; diff --git a/frontend/app/view/webview/webcdp.ts b/frontend/app/view/webview/webcdp.ts index ce8dddb610..fae9d0e9da 100644 --- a/frontend/app/view/webview/webcdp.ts +++ b/frontend/app/view/webview/webcdp.ts @@ -1,4 +1,4 @@ -// Copyright 2026, Command Line Inc. +// Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { getSettingsKeyAtom } from "@/app/store/global";