diff --git a/cmd/wsh/cmd/wshcmd-html.go b/cmd/wsh/cmd/wshcmd-html.go index 6c8345128..e28ac73ae 100644 --- a/cmd/wsh/cmd/wshcmd-html.go +++ b/cmd/wsh/cmd/wshcmd-html.go @@ -81,9 +81,14 @@ func StyleTag(ctx context.Context, props map[string]any) any { `, nil) } -func BgItemTag(ctx context.Context, props map[string]any) any { +type BgItemProps struct { + Bg string + Label string +} + +func BgItemTag(ctx context.Context, props BgItemProps) any { clickFn := func() { - log.Printf("bg item clicked %q\n", props["bg"]) + log.Printf("bg item clicked %q\n", props.Bg) blockInfo, err := wshclient.BlockInfoCommand(GlobalVDomClient.RpcClient, GlobalVDomClient.RpcContext.BlockId, nil) if err != nil { log.Printf("error getting block info: %v\n", err) @@ -92,7 +97,7 @@ func BgItemTag(ctx context.Context, props map[string]any) any { log.Printf("block info: tabid=%q\n", blockInfo.TabId) err = wshclient.SetMetaCommand(GlobalVDomClient.RpcClient, wshrpc.CommandSetMetaData{ ORef: waveobj.ORef{OType: "tab", OID: blockInfo.TabId}, - Meta: map[string]any{"bg": props["bg"]}, + Meta: map[string]any{"bg": props.Bg}, }, nil) if err != nil { log.Printf("error setting meta: %v\n", err) @@ -100,8 +105,8 @@ func BgItemTag(ctx context.Context, props map[string]any) any { // wshclient.SetMetaCommand(GlobalVDomClient.RpcClient) } params := map[string]any{ - "bg": props["bg"], - "label": props["label"], + "bg": props.Bg, + "label": props.Label, "clickHandler": clickFn, } return vdom.Bind(` @@ -141,7 +146,10 @@ func MakeVDom() *vdom.VDomElem {
- + +
+
+
` @@ -169,6 +177,7 @@ func htmlRun(cmd *cobra.Command, args []string) error { client.RegisterComponent("StyleTag", StyleTag) client.RegisterComponent("BgItemTag", BgItemTag) client.RegisterComponent("AllBgItemsTag", AllBgItemsTag) + client.RegisterFileHandler("/test.png", "~/Downloads/IMG_1939.png") client.SetRootElem(MakeVDom()) err = client.CreateVDomContext(&vdom.VDomTarget{NewBlock: htmlCmdNewBlock}) if err != nil { diff --git a/emain/emain-vdomhandler.ts b/emain/emain-vdomhandler.ts new file mode 100644 index 000000000..69b9d7d0e --- /dev/null +++ b/emain/emain-vdomhandler.ts @@ -0,0 +1,100 @@ +import { protocol } from "electron"; +import { RpcApi } from "../frontend/app/store/wshclientapi"; +import { base64ToArray } from "../frontend/util/util"; +import { ElectronWshClient } from "./emain-wsh"; + +export function setupVdomUrlHandler() { + protocol.handle("vdom", async (request) => { + // Only handle GET requests for now + if (request.method !== "GET") { + return new Response(null, { + status: 405, + headers: { + "Content-Type": "text/plain", + }, + }); + } + + const parts = request.url.split("/"); + const uuid = parts[2]; + // simple error checking for uuid + if (!uuid || uuid.length !== 36) { + return new Response(null, { + status: 400, + headers: { + "Content-Type": "text/plain", + }, + }); + } + const path = "/" + parts.slice(3).join("/"); + + // Convert Headers object to plain object + const headers: Record = {}; + for (const [key, value] of request.headers.entries()) { + headers[key] = value; + } + + const data: VDomUrlRequestData = { + method: "GET", + url: path, + headers: headers, + }; + + try { + const respStream = RpcApi.VDomUrlRequestCommand(ElectronWshClient, data, { + route: `proc:${uuid}`, + }); + + // Get iterator for the stream + const iterator = respStream[Symbol.asyncIterator](); + + // Get first chunk to extract headers and status + const firstChunk = await iterator.next(); + if (firstChunk.done) { + throw new Error("No response received from backend"); + } + + const firstResp = firstChunk.value as VDomUrlRequestResponse; + const statusCode = firstResp.statuscode ?? 200; + const responseHeaders = firstResp.headers ?? {}; + + const stream = new ReadableStream({ + async start(controller) { + try { + // Enqueue the body from the first chunk if it exists + if (firstResp.body) { + controller.enqueue(base64ToArray(firstResp.body)); + } + + // Process the rest of the stream + while (true) { + const chunk = await iterator.next(); + if (chunk.done) break; + + const resp = chunk.value as VDomUrlRequestResponse; + if (resp.body) { + controller.enqueue(base64ToArray(resp.body)); + } + } + controller.close(); + } catch (err) { + controller.error(err); + } + }, + }); + + return new Response(stream, { + status: statusCode, + headers: responseHeaders, + }); + } catch (err) { + console.error("VDOM URL handler error:", err); + return new Response(null, { + status: 500, + headers: { + "Content-Type": "text/plain", + }, + }); + } + }); +} diff --git a/emain/emain.ts b/emain/emain.ts index 6dfa19742..b55d46198 100644 --- a/emain/emain.ts +++ b/emain/emain.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import * as electron from "electron"; +import { setupVdomUrlHandler } from "emain/emain-vdomhandler"; import { FastAverageColor } from "fast-average-color"; import fs from "fs"; import * as child_process from "node:child_process"; @@ -708,6 +709,7 @@ async function appMain() { const ready = await getWaveSrvReady(); console.log("wavesrv ready signal received", ready, Date.now() - startTs, "ms"); await electronApp.whenReady(); + setupVdomUrlHandler(); configureAuthKeyRequestInjection(electron.session.defaultSession); const fullConfig = await services.FileService.GetFullConfig(); ensureHotSpareTab(fullConfig); @@ -721,7 +723,6 @@ async function appMain() { console.log("error initializing wshrpc", e); } await configureAutoUpdater(); - setGlobalIsStarting(false); if (fullConfig?.settings?.["window:maxtabcachesize"] != null) { setMaxTabCacheSize(fullConfig.settings["window:maxtabcachesize"]); diff --git a/frontend/app/block/block.less b/frontend/app/block/block.less index 75c44a1fd..377cbea55 100644 --- a/frontend/app/block/block.less +++ b/frontend/app/block/block.less @@ -405,7 +405,7 @@ &.block-no-highlight, &.block-preview { .block-mask { - border: 2px solid rgba(255, 255, 255, 0.1); + border: 2px solid rgba(255, 255, 255, 0.1) !important; } } } diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 8c07fe5cd..9a6cea770 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -252,6 +252,11 @@ class RpcApiType { return client.wshRpcCall("vdomrender", data, opts); } + // command "vdomurlrequest" [responsestream] + VDomUrlRequestCommand(client: WshClient, data: VDomUrlRequestData, opts?: RpcOpts): AsyncGenerator { + return client.wshRpcStream("vdomurlrequest", data, opts); + } + // command "waitforroute" [call] WaitForRouteCommand(client: WshClient, data: CommandWaitForRouteData, opts?: RpcOpts): Promise { return client.wshRpcCall("waitforroute", data, opts); diff --git a/frontend/app/view/term/term.less b/frontend/app/view/term/term.less index 4351ca11c..20b7c18ef 100644 --- a/frontend/app/view/term/term.less +++ b/frontend/app/view/term/term.less @@ -46,24 +46,8 @@ min-height: 0; overflow: hidden; - .term-htmlelem-focus { - height: 0; - width: 0; - input { - width: 0; - height: 0; - opacity: 0; - pointer-events: none; - } - } - - .term-htmlelem-content { - display: flex; - flex-direction: row; - width: 100%; - flex-grow: 1; - min-height: 0; - overflow: hidden; + .block-content { + padding: 0; } } diff --git a/frontend/app/view/vdom/vdom-model.tsx b/frontend/app/view/vdom/vdom-model.tsx index 07f301fdb..44ab2e0f6 100644 --- a/frontend/app/view/vdom/vdom-model.tsx +++ b/frontend/app/view/vdom/vdom-model.tsx @@ -592,4 +592,12 @@ export class VDomModel { } return feUpdate; } + + getBackendRouteId(): string { + const fullRoute = globalStore.get(this.backendRoute); + if (fullRoute == null || !fullRoute.startsWith("proc:")) { + return null; + } + return fullRoute?.split(":")[1]; + } } diff --git a/frontend/app/view/vdom/vdom-utils.tsx b/frontend/app/view/vdom/vdom-utils.tsx index 4df721a63..c4bc7f2e9 100644 --- a/frontend/app/view/vdom/vdom-utils.tsx +++ b/frontend/app/view/vdom/vdom-utils.tsx @@ -46,6 +46,33 @@ export function validateAndWrapCss(model: VDomModel, cssText: string, wrapperCla if (node.type === "IdSelector") { node.name = convertVDomId(model, node.name); } + + // Transform url(#id) references in filter and mask properties (svg) + if (node.type === "Declaration" && ["filter", "mask"].includes(node.property)) { + if (node.value && node.value.type === "Value" && "children" in node.value) { + const urlNode = node.value.children + .toArray() + .find( + (child: CssNode): child is CssNode & { value: string } => + child && child.type === "Url" && typeof (child as any).value === "string" + ); + if (urlNode && urlNode.value && urlNode.value.startsWith("#")) { + urlNode.value = "#" + convertVDomId(model, urlNode.value.substring(1)); + } + } + } + // transform url(vdom:///foo.jpg) => url(vdom://blockId/foo.jpg) + if (node.type === "Url") { + const url = node.value; + if (url != null && url.startsWith("vdom://")) { + const absUrl = url.substring(7); + if (!absUrl.startsWith("/")) { + list.remove(item); + } else { + node.value = "vdom://" + model.blockId + url.substring(7); + } + } + } }, }); const sanitizedCss = csstree.generate(ast); @@ -56,3 +83,56 @@ export function validateAndWrapCss(model: VDomModel, cssText: string, wrapperCla return null; } } + +function cssTransformStyleValue(model: VDomModel, property: string, value: string): string { + try { + const ast = csstree.parse(value, { context: "value" }); + csstree.walk(ast, { + enter(node) { + // Transform url(#id) in filter/mask properties + if (node.type === "Url" && (property === "filter" || property === "mask")) { + if (node.value.startsWith("#")) { + node.value = `#${convertVDomId(model, node.value.substring(1))}`; + } + } + + // Transform vdom:/// URLs + if (node.type === "Url" && node.value.startsWith("vdom:///")) { + const absUrl = node.value.substring(7); + if (absUrl.startsWith("/")) { + node.value = `vdom://${model.blockId}${absUrl}`; + } + } + }, + }); + + return csstree.generate(ast); + } catch (error) { + console.error("Error processing style value:", error); + return value; + } +} + +export function validateAndWrapReactStyle(model: VDomModel, style: Record): Record { + const sanitizedStyle: Record = {}; + let updated = false; + for (const [property, value] of Object.entries(style)) { + if (value == null || value === "") { + continue; + } + if (typeof value !== "string") { + sanitizedStyle[property] = value; // For non-string values, just copy as-is + continue; + } + if (value.includes("vdom://") || value.includes("url(#")) { + updated = true; + sanitizedStyle[property] = cssTransformStyleValue(model, property, value); + } else { + sanitizedStyle[property] = value; + } + } + if (!updated) { + return style; + } + return sanitizedStyle; +} diff --git a/frontend/app/view/vdom/vdom.less b/frontend/app/view/vdom/vdom.less index 3e959889d..b8cd5f300 100644 --- a/frontend/app/view/vdom/vdom.less +++ b/frontend/app/view/vdom/vdom.less @@ -2,4 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 .view-vdom { + overflow: auto; + width: 100%; + min-height: 100%; } diff --git a/frontend/app/view/vdom/vdom.tsx b/frontend/app/view/vdom/vdom.tsx index 08adfee7e..89fd6f14d 100644 --- a/frontend/app/view/vdom/vdom.tsx +++ b/frontend/app/view/vdom/vdom.tsx @@ -10,7 +10,12 @@ import * as jotai from "jotai"; import * as React from "react"; import { BlockNodeModel } from "@/app/block/blocktypes"; -import { convertVDomId, getTextChildren, validateAndWrapCss } from "@/app/view/vdom/vdom-utils"; +import { + convertVDomId, + getTextChildren, + validateAndWrapCss, + validateAndWrapReactStyle, +} from "@/app/view/vdom/vdom-utils"; import "./vdom.less"; const TextTag = "#text"; @@ -68,6 +73,50 @@ const AllowedSimpleTags: { [tagName: string]: boolean } = { code: true, }; +const AllowedSvgTags = { + // SVG tags + svg: true, + circle: true, + ellipse: true, + line: true, + path: true, + polygon: true, + polyline: true, + rect: true, + g: true, + text: true, + tspan: true, + textPath: true, + use: true, + defs: true, + linearGradient: true, + radialGradient: true, + stop: true, + clipPath: true, + mask: true, + pattern: true, + image: true, + marker: true, + symbol: true, + filter: true, + feBlend: true, + feColorMatrix: true, + feComponentTransfer: true, + feComposite: true, + feConvolveMatrix: true, + feDiffuseLighting: true, + feDisplacementMap: true, + feFlood: true, + feGaussianBlur: true, + feImage: true, + feMerge: true, + feMorphology: true, + feOffset: true, + feSpecularLighting: true, + feTile: true, + feTurbulence: true, +}; + const IdAttributes = { id: true, for: true, @@ -81,6 +130,18 @@ const IdAttributes = { list: true, }; +const SvgUrlIdAttributes = { + "clip-path": true, + mask: true, + filter: true, + fill: true, + stroke: true, + "marker-start": true, + "marker-mid": true, + "marker-end": true, + "text-decoration": true, +}; + function convertVDomFunc(model: VDomModel, fnDecl: VDomFunc, compId: string, propName: string): (e: any) => void { return (e: any) => { if ((propName == "onKeyDown" || propName == "onKeyDownCapture") && fnDecl["#keys"]) { @@ -193,28 +254,62 @@ function convertProps(elem: VDomElem, model: VDomModel): [GenericPropsType, Set< } } } - // fallthrough to set props[key] = val + val = validateAndWrapReactStyle(model, val); + props[key] = val; + continue; } if (IdAttributes[key]) { props[key] = convertVDomId(model, val); continue; } + if (AllowedSvgTags[elem.tag]) { + if ((elem.tag == "use" && key == "href") || (elem.tag == "textPath" && key == "href")) { + if (val == null || !val.startsWith("#")) { + continue; + } + props[key] = convertVDomId(model, "#" + val.substring(1)); + continue; + } + if (SvgUrlIdAttributes[key]) { + if (val == null || !val.startsWith("url(#") || !val.endsWith(")")) { + continue; + } + props[key] = "url(#" + convertVDomId(model, val.substring(4, val.length - 1)) + ")"; + continue; + } + } + if (key == "src" && val != null && val.startsWith("vdom://")) { + // we're going to convert vdom:///foo.jpg to vdom://blockid/foo.jpg. if it doesn't start with "/" it is not valid + const vdomUrl = val.substring(7); + if (!vdomUrl.startsWith("/")) { + continue; + } + const backendRouteId = model.getBackendRouteId(); + if (backendRouteId == null) { + continue; + } + props[key] = "vdom://" + backendRouteId + vdomUrl; + continue; + } props[key] = val; } return [props, atomKeys]; } function convertChildren(elem: VDomElem, model: VDomModel): (string | JSX.Element)[] { - let childrenComps: (string | JSX.Element)[] = []; - if (elem.children == null) { - return childrenComps; + if (elem.children == null || elem.children.length == 0) { + return null; } + let childrenComps: (string | JSX.Element)[] = []; for (let child of elem.children) { if (child == null) { continue; } childrenComps.push(convertElemToTag(child, model)); } + if (childrenComps.length == 0) { + return null; + } return childrenComps; } @@ -286,7 +381,7 @@ function VDomTag({ elem, model }: { elem: VDomElem; model: VDomModel }) { if (elem.tag == StyleTagName) { return ; } - if (!AllowedSimpleTags[elem.tag]) { + if (!AllowedSimpleTags[elem.tag] && !AllowedSvgTags[elem.tag]) { return
{"Invalid Tag <" + elem.tag + ">"}
; } let childrenComps = convertChildren(elem, model); @@ -345,7 +440,7 @@ function VDomView({ blockId, model }: VDomViewProps) { model.viewRef = viewRef; const vdomClass = "vdom-" + blockId; return ( -
+
); diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 5955eb25a..8c0f80e93 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -782,6 +782,21 @@ declare global { magnified?: boolean; }; + // wshrpc.VDomUrlRequestData + type VDomUrlRequestData = { + method: string; + url: string; + headers: {[key: string]: string}; + body?: string; + }; + + // wshrpc.VDomUrlRequestResponse + type VDomUrlRequestResponse = { + statuscode?: number; + headers?: {[key: string]: string}; + body?: string; + }; + type WSCommandType = { wscommand: string; } & ( SetBlockTermSizeWSCommand | BlockInputWSCommand | WSRpcCommand ); diff --git a/go.mod b/go.mod index 7645166bc..4e442886b 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,7 @@ require ( github.com/skeema/knownhosts v1.3.0 github.com/spf13/cobra v1.8.1 github.com/ubuntu/gowsl v0.0.0-20240906163211-049fd49bd93b - github.com/wavetermdev/htmltoken v0.1.0 + github.com/wavetermdev/htmltoken v0.2.0 golang.org/x/crypto v0.28.0 golang.org/x/sys v0.26.0 golang.org/x/term v0.25.0 diff --git a/go.sum b/go.sum index cb329ca3f..121013e50 100644 --- a/go.sum +++ b/go.sum @@ -86,8 +86,8 @@ github.com/ubuntu/decorate v0.0.0-20230125165522-2d5b0a9bb117 h1:XQpsQG5lqRJlx4m github.com/ubuntu/decorate v0.0.0-20230125165522-2d5b0a9bb117/go.mod h1:mx0TjbqsaDD9DUT5gA1s3hw47U6RIbbIBfvGzR85K0g= github.com/ubuntu/gowsl v0.0.0-20240906163211-049fd49bd93b h1:wFBKF5k5xbJQU8bYgcSoQ/ScvmYyq6KHUabAuVUjOWM= github.com/ubuntu/gowsl v0.0.0-20240906163211-049fd49bd93b/go.mod h1:N1CYNinssZru+ikvYTgVbVeSi21thHUTCoJ9xMvWe+s= -github.com/wavetermdev/htmltoken v0.1.0 h1:RMdA9zTfnYa5jRC4RRG3XNoV5NOP8EDxpaVPjuVz//Q= -github.com/wavetermdev/htmltoken v0.1.0/go.mod h1:5FM0XV6zNYiNza2iaTcFGj+hnMtgqumFHO31Z8euquk= +github.com/wavetermdev/htmltoken v0.2.0 h1:sFVPPemlDv7/jg7n4Hx1AEF2m9MVAFjFpELWfhi/DlM= +github.com/wavetermdev/htmltoken v0.2.0/go.mod h1:5FM0XV6zNYiNza2iaTcFGj+hnMtgqumFHO31Z8euquk= github.com/wavetermdev/ssh_config v0.0.0-20241027232332-ed124367682d h1:ArHaUBaiQWUqBzM2G/oLlm3Be0kwUMDt9vTNOWIfOd0= github.com/wavetermdev/ssh_config v0.0.0-20241027232332-ed124367682d/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= diff --git a/pkg/tsgen/tsgen.go b/pkg/tsgen/tsgen.go index 503e4e890..bf17a2f41 100644 --- a/pkg/tsgen/tsgen.go +++ b/pkg/tsgen/tsgen.go @@ -130,6 +130,10 @@ func TypeToTSType(t reflect.Type, tsTypesMap map[reflect.Type]string) (string, [ case reflect.Bool: return "boolean", nil case reflect.Slice, reflect.Array: + // special case for byte slice, marshals to base64 encoded string + if t.Elem().Kind() == reflect.Uint8 { + return "string", nil + } elemType, subTypes := TypeToTSType(t.Elem(), tsTypesMap) if elemType == "" { return "", nil diff --git a/pkg/util/utilfn/compare.go b/pkg/util/utilfn/compare.go index d9c96a24e..6d9d30cbd 100644 --- a/pkg/util/utilfn/compare.go +++ b/pkg/util/utilfn/compare.go @@ -58,6 +58,9 @@ func CompareAsFloat64(a, b any) bool { // Convert various numeric types to float64 for comparison func ToFloat64(val any) (float64, bool) { + if val == nil { + return 0, false + } switch v := val.(type) { case int: return float64(v), true @@ -87,3 +90,57 @@ func ToFloat64(val any) (float64, bool) { return 0, false } } + +func ToInt64(val any) (int64, bool) { + if val == nil { + return 0, false + } + switch v := val.(type) { + case int: + return int64(v), true + case int8: + return int64(v), true + case int16: + return int64(v), true + case int32: + return int64(v), true + case int64: + return v, true + case uint: + return int64(v), true + case uint8: + return int64(v), true + case uint16: + return int64(v), true + case uint32: + return int64(v), true + case uint64: + return int64(v), true + case float32: + return int64(v), true + case float64: + return int64(v), true + default: + return 0, false + } +} + +func ToInt(val any) (int, bool) { + i, ok := ToInt64(val) + if !ok { + return 0, false + } + return int(i), true +} + +func ToStr(val any) (string, bool) { + if val == nil { + return "", false + } + switch v := val.(type) { + case string: + return v, true + default: + return "", false + } +} diff --git a/pkg/vdom/vdom.go b/pkg/vdom/vdom.go index 0503c46b5..d7c66177a 100644 --- a/pkg/vdom/vdom.go +++ b/pkg/vdom/vdom.go @@ -25,8 +25,6 @@ type Hook struct { Deps []any } -type CFunc = func(ctx context.Context, props map[string]any) any - func (e *VDomElem) Key() string { keyVal, ok := e.Props[KeyPropKey] if !ok { diff --git a/pkg/vdom/vdom_html.go b/pkg/vdom/vdom_html.go index f6ab228bb..67721efc2 100644 --- a/pkg/vdom/vdom_html.go +++ b/pkg/vdom/vdom_html.go @@ -73,18 +73,6 @@ func finalizeStack(stack []*VDomElem) *VDomElem { return rtnElem } -func attrVal(attr htmltoken.Attribute) (any, error) { - // if !attr.IsJson { - // return attr.Val, nil - // } - var val any - err := json.Unmarshal([]byte(attr.Val), &val) - if err != nil { - return nil, fmt.Errorf("error parsing json attr %q: %v", attr.Key, err) - } - return val, nil -} - // returns value, isjson func getAttrString(token htmltoken.Token, key string) string { for _, attr := range token.Attr { @@ -96,6 +84,19 @@ func getAttrString(token htmltoken.Token, key string) string { } func attrToProp(attrVal string, isJson bool, params map[string]any) any { + if isJson { + var val any + err := json.Unmarshal([]byte(attrVal), &val) + if err != nil { + return nil + } + unmStrVal, ok := val.(string) + if !ok { + return val + } + attrVal = unmStrVal + // fallthrough using the json str val + } if strings.HasPrefix(attrVal, Html_ParamPrefix) { bindKey := attrVal[len(Html_ParamPrefix):] bindVal, ok := params[bindKey] @@ -134,7 +135,7 @@ func tokenToElem(token htmltoken.Token, params map[string]any) *VDomElem { if attr.Key == "" || attr.Val == "" { continue } - propVal := attrToProp(attr.Val, false, params) + propVal := attrToProp(attr.Val, attr.IsJson, params) elem.Props[attr.Key] = propVal } return elem diff --git a/pkg/vdom/vdom_root.go b/pkg/vdom/vdom_root.go index acbe67fd0..3f26bc660 100644 --- a/pkg/vdom/vdom_root.go +++ b/pkg/vdom/vdom_root.go @@ -32,7 +32,7 @@ type Atom struct { type RootElem struct { OuterCtx context.Context Root *Component - CFuncs map[string]CFunc + CFuncs map[string]any CompMap map[string]*Component // component waveid -> component EffectWorkQueue []*EffectWorkElem NeedsRenderMap map[string]bool @@ -63,7 +63,7 @@ func (r *RootElem) AddEffectWork(id string, effectIndex int) { func MakeRoot() *RootElem { return &RootElem{ Root: nil, - CFuncs: make(map[string]CFunc), + CFuncs: make(map[string]any), CompMap: make(map[string]*Component), Atoms: make(map[string]*Atom), } @@ -112,8 +112,42 @@ func (r *RootElem) SetOuterCtx(ctx context.Context) { r.OuterCtx = ctx } -func (r *RootElem) RegisterComponent(name string, cfunc CFunc) { +func validateCFunc(cfunc any) error { + if cfunc == nil { + return fmt.Errorf("Component function cannot b nil") + } + rval := reflect.ValueOf(cfunc) + if rval.Kind() != reflect.Func { + return fmt.Errorf("Component function must be a function") + } + rtype := rval.Type() + if rtype.NumIn() != 2 { + return fmt.Errorf("Component function must take exactly 2 arguments") + } + if rtype.NumOut() != 1 { + return fmt.Errorf("Component function must return exactly 1 value") + } + // first arg must be context.Context + if rtype.In(0) != reflect.TypeOf((*context.Context)(nil)).Elem() { + return fmt.Errorf("Component function first argument must be context.Context") + } + // second can a map, or a struct, or ptr to struct (we'll reflect the value into it) + arg2Type := rtype.In(1) + if arg2Type.Kind() == reflect.Ptr { + arg2Type = arg2Type.Elem() + } + if arg2Type.Kind() != reflect.Map && arg2Type.Kind() != reflect.Struct { + return fmt.Errorf("Component function second argument must be a map or a struct") + } + return nil +} + +func (r *RootElem) RegisterComponent(name string, cfunc any) error { + if err := validateCFunc(cfunc); err != nil { + return err + } r.CFuncs[name] = cfunc + return nil } func (r *RootElem) Render(elem *VDomElem) { @@ -321,7 +355,19 @@ func getRenderContext(ctx context.Context) *VDomContextVal { return v.(*VDomContextVal) } -func (r *RootElem) renderComponent(cfunc CFunc, elem *VDomElem, comp **Component) { +func callCFunc(cfunc any, ctx context.Context, props map[string]any) any { + rval := reflect.ValueOf(cfunc) + arg2Type := rval.Type().In(1) + arg2Val := reflect.New(arg2Type) + utilfn.ReUnmarshal(arg2Val.Interface(), props) + rtnVal := rval.Call([]reflect.Value{reflect.ValueOf(ctx), arg2Val.Elem()}) + if len(rtnVal) == 0 { + return nil + } + return rtnVal[0].Interface() +} + +func (r *RootElem) renderComponent(cfunc any, elem *VDomElem, comp **Component) { if (*comp).Children != nil { for _, child := range (*comp).Children { r.unmount(&child) @@ -334,7 +380,7 @@ func (r *RootElem) renderComponent(cfunc CFunc, elem *VDomElem, comp **Component } props[ChildrenPropKey] = elem.Children ctx := r.makeRenderContext(*comp) - renderedElem := cfunc(ctx, props) + renderedElem := callCFunc(cfunc, ctx, props) rtnElemArr := partToElems(renderedElem) if len(rtnElemArr) == 0 { r.unmount(&(*comp).Comp) diff --git a/pkg/vdom/vdom_test.go b/pkg/vdom/vdom_test.go index 2be63fa41..42e8214d8 100644 --- a/pkg/vdom/vdom_test.go +++ b/pkg/vdom/vdom_test.go @@ -5,7 +5,10 @@ import ( "encoding/json" "fmt" "log" + "reflect" "testing" + + "github.com/wavetermdev/waveterm/pkg/util/utilfn" ) type renderContextKeyType struct{} @@ -118,3 +121,65 @@ func TestBind(t *testing.T) { jsonBytes, _ = json.MarshalIndent(elem, "", " ") log.Printf("%s\n", string(jsonBytes)) } + +func TestJsonBind(t *testing.T) { + elem := Bind(`
`, nil) + if elem == nil { + t.Fatalf("elem is nil") + } + if elem.Tag != "div" { + t.Fatalf("elem.Tag: %s (expected 'div')\n", elem.Tag) + } + if elem.Props == nil || len(elem.Props) != 3 { + t.Fatalf("elem.Props: %v\n", elem.Props) + } + data1Val, ok := elem.Props["data1"] + if !ok { + t.Fatalf("data1 not found\n") + } + _, ok = data1Val.(float64) + if !ok { + t.Fatalf("data1: %T\n", data1Val) + } + data1Int, ok := utilfn.ToInt(data1Val) + if !ok || data1Int != 5 { + t.Fatalf("data1: %v\n", data1Val) + } + data2Val, ok := elem.Props["data2"] + if !ok { + t.Fatalf("data2 not found\n") + } + d2type := reflect.TypeOf(data2Val) + if d2type.Kind() != reflect.Slice { + t.Fatalf("data2: %T\n", data2Val) + } + data2Arr := data2Val.([]any) + if len(data2Arr) != 3 { + t.Fatalf("data2: %v\n", data2Val) + } + d2v2, ok := data2Arr[1].(float64) + if !ok || d2v2 != 2 { + t.Fatalf("data2: %v\n", data2Val) + } + data3Val, ok := elem.Props["data3"] + if !ok || data3Val == nil { + t.Fatalf("data3 not found\n") + } + d3type := reflect.TypeOf(data3Val) + if d3type.Kind() != reflect.Map { + t.Fatalf("data3: %T\n", data3Val) + } + data3Map := data3Val.(map[string]any) + if len(data3Map) != 1 { + t.Fatalf("data3: %v\n", data3Val) + } + d3v1, ok := data3Map["a"] + if !ok { + t.Fatalf("data3: %v\n", data3Val) + } + mval, ok := utilfn.ToInt(d3v1) + if !ok || mval != 1 { + t.Fatalf("data3: %v\n", data3Val) + } + log.Printf("elem: %v\n", elem) +} diff --git a/pkg/vdom/vdomclient/streamingresp.go b/pkg/vdom/vdomclient/streamingresp.go new file mode 100644 index 000000000..6a8456786 --- /dev/null +++ b/pkg/vdom/vdomclient/streamingresp.go @@ -0,0 +1,129 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vdomclient + +import ( + "bytes" + "net/http" + + "github.com/wavetermdev/waveterm/pkg/wshrpc" +) + +const maxChunkSize = 64 * 1024 // 64KB maximum chunk size + +// StreamingResponseWriter implements http.ResponseWriter interface to stream response +// data through a channel rather than buffering it in memory. This is particularly +// useful for handling large responses like video streams or file downloads. +type StreamingResponseWriter struct { + header http.Header + statusCode int + respChan chan<- wshrpc.RespOrErrorUnion[wshrpc.VDomUrlRequestResponse] + headerSent bool + buffer *bytes.Buffer +} + +func NewStreamingResponseWriter(respChan chan<- wshrpc.RespOrErrorUnion[wshrpc.VDomUrlRequestResponse]) *StreamingResponseWriter { + return &StreamingResponseWriter{ + header: make(http.Header), + statusCode: http.StatusOK, + respChan: respChan, + headerSent: false, + buffer: bytes.NewBuffer(make([]byte, 0, maxChunkSize)), + } +} + +func (w *StreamingResponseWriter) Header() http.Header { + return w.header +} + +func (w *StreamingResponseWriter) WriteHeader(statusCode int) { + if w.headerSent { + return + } + + w.statusCode = statusCode + w.headerSent = true + + headers := make(map[string]string) + for key, values := range w.header { + if len(values) > 0 { + headers[key] = values[0] + } + } + + w.respChan <- wshrpc.RespOrErrorUnion[wshrpc.VDomUrlRequestResponse]{ + Response: wshrpc.VDomUrlRequestResponse{ + StatusCode: w.statusCode, + Headers: headers, + }, + } +} + +// sendChunk sends a single chunk of exactly maxChunkSize (or less) +func (w *StreamingResponseWriter) sendChunk(data []byte) { + if len(data) == 0 { + return + } + chunk := make([]byte, len(data)) + copy(chunk, data) + w.respChan <- wshrpc.RespOrErrorUnion[wshrpc.VDomUrlRequestResponse]{ + Response: wshrpc.VDomUrlRequestResponse{ + Body: chunk, + }, + } +} + +func (w *StreamingResponseWriter) Write(data []byte) (int, error) { + if !w.headerSent { + w.WriteHeader(http.StatusOK) + } + + originalLen := len(data) + + // If we already have data in the buffer + if w.buffer.Len() > 0 { + // Fill the buffer up to maxChunkSize + spaceInBuffer := maxChunkSize - w.buffer.Len() + if spaceInBuffer > 0 { + // How much of the new data can fit in the buffer + toBuffer := spaceInBuffer + if toBuffer > len(data) { + toBuffer = len(data) + } + w.buffer.Write(data[:toBuffer]) + data = data[toBuffer:] // Advance data slice + } + + // If buffer is full, send it + if w.buffer.Len() == maxChunkSize { + w.sendChunk(w.buffer.Bytes()) + w.buffer.Reset() + } + } + + // Send any full chunks from data + for len(data) >= maxChunkSize { + w.sendChunk(data[:maxChunkSize]) + data = data[maxChunkSize:] + } + + // Buffer any remaining data + if len(data) > 0 { + w.buffer.Write(data) + } + + return originalLen, nil +} + +func (w *StreamingResponseWriter) Close() error { + if !w.headerSent { + w.WriteHeader(http.StatusOK) + } + + if w.buffer.Len() > 0 { + w.sendChunk(w.buffer.Bytes()) + w.buffer.Reset() + } + return nil +} diff --git a/pkg/vdom/vdomclient/vdomclient.go b/pkg/vdom/vdomclient/vdomclient.go index 79ee5d743..b21bfdbed 100644 --- a/pkg/vdom/vdomclient/vdomclient.go +++ b/pkg/vdom/vdomclient/vdomclient.go @@ -4,15 +4,17 @@ package vdomclient import ( - "context" "fmt" "log" + "net/http" "os" "sync" "time" "github.com/google/uuid" + "github.com/gorilla/mux" "github.com/wavetermdev/waveterm/pkg/vdom" + "github.com/wavetermdev/waveterm/pkg/wavebase" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" @@ -33,42 +35,8 @@ type Client struct { DoneCh chan struct{} Opts vdom.VDomBackendOpts GlobalEventHandler func(client *Client, event vdom.VDomEvent) -} - -type VDomServerImpl struct { - Client *Client - BlockId string -} - -func (*VDomServerImpl) WshServerImpl() {} - -func (impl *VDomServerImpl) VDomRenderCommand(ctx context.Context, feUpdate vdom.VDomFrontendUpdate) (*vdom.VDomBackendUpdate, error) { - if feUpdate.Dispose { - log.Printf("got dispose from frontend\n") - impl.Client.doShutdown("got dispose from frontend") - return nil, nil - } - if impl.Client.GetIsDone() { - return nil, nil - } - // set atoms - for _, ss := range feUpdate.StateSync { - impl.Client.Root.SetAtomVal(ss.Atom, ss.Value, false) - } - // run events - for _, event := range feUpdate.Events { - if event.WaveId == "" { - if impl.Client.GlobalEventHandler != nil { - impl.Client.GlobalEventHandler(impl.Client, event) - } - } else { - impl.Client.Root.Event(event.WaveId, event.EventType, event.EventData) - } - } - if feUpdate.Resync { - return impl.Client.fullRender() - } - return impl.Client.incrementalRender() + UrlHandlerMux *mux.Router + OverrideUrlHandler http.Handler } func (c *Client) GetIsDone() bool { @@ -92,11 +60,16 @@ func (c *Client) SetGlobalEventHandler(handler func(client *Client, event vdom.V c.GlobalEventHandler = handler } +func (c *Client) SetOverrideUrlHandler(handler http.Handler) { + c.OverrideUrlHandler = handler +} + func MakeClient(opts *vdom.VDomBackendOpts) (*Client, error) { client := &Client{ - Lock: &sync.Mutex{}, - Root: vdom.MakeRoot(), - DoneCh: make(chan struct{}), + Lock: &sync.Mutex{}, + Root: vdom.MakeRoot(), + DoneCh: make(chan struct{}), + UrlHandlerMux: mux.NewRouter(), } if opts != nil { client.Opts = *opts @@ -197,8 +170,8 @@ func makeNullVDom() *vdom.VDomElem { return &vdom.VDomElem{WaveId: uuid.New().String(), Tag: vdom.WaveNullTag} } -func (c *Client) RegisterComponent(name string, cfunc vdom.CFunc) { - c.Root.RegisterComponent(name, cfunc) +func (c *Client) RegisterComponent(name string, cfunc any) error { + return c.Root.RegisterComponent(name, cfunc) } func (c *Client) fullRender() (*vdom.VDomBackendUpdate, error) { @@ -236,3 +209,14 @@ func (c *Client) incrementalRender() (*vdom.VDomBackendUpdate, error) { StateSync: c.Root.GetStateSync(false), }, nil } + +func (c *Client) RegisterUrlPathHandler(path string, handler http.Handler) { + c.UrlHandlerMux.Handle(path, handler) +} + +func (c *Client) RegisterFileHandler(path string, fileName string) { + fileName = wavebase.ExpandHomeDirSafe(fileName) + c.UrlHandlerMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, fileName) + }) +} diff --git a/pkg/vdom/vdomclient/vdomserverimpl.go b/pkg/vdom/vdomclient/vdomserverimpl.go new file mode 100644 index 000000000..2d8e352b8 --- /dev/null +++ b/pkg/vdom/vdomclient/vdomserverimpl.go @@ -0,0 +1,95 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package vdomclient + +import ( + "bytes" + "context" + "fmt" + "log" + "net/http" + + "github.com/wavetermdev/waveterm/pkg/vdom" + "github.com/wavetermdev/waveterm/pkg/wshrpc" +) + +type VDomServerImpl struct { + Client *Client + BlockId string +} + +func (*VDomServerImpl) WshServerImpl() {} + +func (impl *VDomServerImpl) VDomRenderCommand(ctx context.Context, feUpdate vdom.VDomFrontendUpdate) (*vdom.VDomBackendUpdate, error) { + if feUpdate.Dispose { + log.Printf("got dispose from frontend\n") + impl.Client.doShutdown("got dispose from frontend") + return nil, nil + } + if impl.Client.GetIsDone() { + return nil, nil + } + // set atoms + for _, ss := range feUpdate.StateSync { + impl.Client.Root.SetAtomVal(ss.Atom, ss.Value, false) + } + // run events + for _, event := range feUpdate.Events { + if event.WaveId == "" { + if impl.Client.GlobalEventHandler != nil { + impl.Client.GlobalEventHandler(impl.Client, event) + } + } else { + impl.Client.Root.Event(event.WaveId, event.EventType, event.EventData) + } + } + if feUpdate.Resync { + return impl.Client.fullRender() + } + return impl.Client.incrementalRender() +} + +func (impl *VDomServerImpl) VDomUrlRequestCommand(ctx context.Context, data wshrpc.VDomUrlRequestData) chan wshrpc.RespOrErrorUnion[wshrpc.VDomUrlRequestResponse] { + log.Printf("VDomUrlRequestCommand: url=%q\n", data.URL) + respChan := make(chan wshrpc.RespOrErrorUnion[wshrpc.VDomUrlRequestResponse]) + writer := NewStreamingResponseWriter(respChan) + + go func() { + defer func() { + if r := recover(); r != nil { + // On panic, send 500 status code + writer.WriteHeader(http.StatusInternalServerError) + writer.Write([]byte(fmt.Sprintf("internal server error: %v", r))) + } + close(respChan) + }() + + // Create an HTTP request from the RPC request data + var bodyReader *bytes.Reader + if data.Body != nil { + bodyReader = bytes.NewReader(data.Body) + } else { + bodyReader = bytes.NewReader([]byte{}) + } + + httpReq, err := http.NewRequest(data.Method, data.URL, bodyReader) + if err != nil { + writer.WriteHeader(http.StatusInternalServerError) + writer.Write([]byte(err.Error())) + return + } + + for key, value := range data.Headers { + httpReq.Header.Set(key, value) + } + + if impl.Client.OverrideUrlHandler != nil { + impl.Client.OverrideUrlHandler.ServeHTTP(writer, httpReq) + return + } + impl.Client.UrlHandlerMux.ServeHTTP(writer, httpReq) + }() + + return respChan +} diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 86cbac5c3..536550d67 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -303,6 +303,11 @@ func VDomRenderCommand(w *wshutil.WshRpc, data vdom.VDomFrontendUpdate, opts *ws return resp, err } +// command "vdomurlrequest", wshserver.VDomUrlRequestCommand +func VDomUrlRequestCommand(w *wshutil.WshRpc, data wshrpc.VDomUrlRequestData, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[wshrpc.VDomUrlRequestResponse] { + return sendRpcRequestResponseStreamHelper[wshrpc.VDomUrlRequestResponse](w, "vdomurlrequest", data, opts) +} + // command "waitforroute", wshserver.WaitForRouteCommand func WaitForRouteCommand(w *wshutil.WshRpc, data wshrpc.CommandWaitForRouteData, opts *wshrpc.RpcOpts) (bool, error) { resp, err := sendRpcRequestCallHelper[bool](w, "waitforroute", data, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index eefa6c00c..e1e74d1da 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -81,6 +81,7 @@ const ( Command_VDomCreateContext = "vdomcreatecontext" Command_VDomAsyncInitiation = "vdomasyncinitiation" Command_VDomRender = "vdomrender" + Command_VDomUrlRequest = "vdomurlrequest" ) type RespOrErrorUnion[T any] struct { @@ -157,6 +158,7 @@ type WshRpcInterface interface { // proc VDomRenderCommand(ctx context.Context, data vdom.VDomFrontendUpdate) (*vdom.VDomBackendUpdate, error) + VDomUrlRequestCommand(ctx context.Context, data VDomUrlRequestData) chan RespOrErrorUnion[VDomUrlRequestResponse] } // for frontend @@ -431,6 +433,19 @@ type WaveNotificationOptions struct { Silent bool `json:"silent,omitempty"` } +type VDomUrlRequestData struct { + Method string `json:"method"` + URL string `json:"url"` + Headers map[string]string `json:"headers"` + Body []byte `json:"body,omitempty"` +} + +type VDomUrlRequestResponse struct { + StatusCode int `json:"statuscode,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Body []byte `json:"body,omitempty"` +} + type WaveInfoData struct { Version string `json:"version"` BuildTime string `json:"buildtime"`