diff --git a/cmd/wsh/cmd/wshcmd-setbg.go b/cmd/wsh/cmd/wshcmd-setbg.go index be57b9abd8..fb5cf0fec0 100644 --- a/cmd/wsh/cmd/wshcmd-setbg.go +++ b/cmd/wsh/cmd/wshcmd-setbg.go @@ -90,7 +90,9 @@ func setBgRun(cmd *cobra.Command, args []string) (rtnErr error) { if setBgOpacity < 0 || setBgOpacity > 1 { return fmt.Errorf("opacity must be between 0.0 and 1.0") } - if cmd.Flags().Changed("opacity") { + if setBgClear { + meta["bg:*"] = true + } else { meta["bg:opacity"] = setBgOpacity } } else if len(args) > 1 { @@ -167,7 +169,11 @@ func setBgRun(cmd *cobra.Command, args []string) (rtnErr error) { } // Resolve tab reference - oRef, err := resolveSimpleId("tab") + id := blockArg + if id == "" { + id = "tab" + } + oRef, err := resolveSimpleId(id) if err != nil { return err } diff --git a/frontend/app/app-bg.tsx b/frontend/app/app-bg.tsx index 2a06f6d220..9ac70a550e 100644 --- a/frontend/app/app-bg.tsx +++ b/frontend/app/app-bg.tsx @@ -1,100 +1,19 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { getWebServerEndpoint } from "@/util/endpoints"; -import * as util from "@/util/util"; +import { computeBgStyleFromMeta } from "@/util/waveutil"; import useResizeObserver from "@react-hook/resize-observer"; -import { generate as generateCSS, parse as parseCSS, walk as walkCSS } from "css-tree"; import { useAtomValue } from "jotai"; import { CSSProperties, useCallback, useLayoutEffect, useRef } from "react"; import { debounce } from "throttle-debounce"; import { atoms, getApi, PLATFORM, WOS } from "./store/global"; import { useWaveObjectValue } from "./store/wos"; -function encodeFileURL(file: string) { - const webEndpoint = getWebServerEndpoint(); - return webEndpoint + `/wave/stream-file?path=${encodeURIComponent(file)}&no404=1`; -} - -function processBackgroundUrls(cssText: string): string { - if (util.isBlank(cssText)) { - return null; - } - cssText = cssText.trim(); - if (cssText.endsWith(";")) { - cssText = cssText.slice(0, -1); - } - const attrRe = /^background(-image)?\s*:\s*/i; - cssText = cssText.replace(attrRe, ""); - const ast = parseCSS("background: " + cssText, { - context: "declaration", - }); - let hasUnsafeUrl = false; - walkCSS(ast, { - visit: "Url", - enter(node) { - const originalUrl = node.value.trim(); - if ( - originalUrl.startsWith("http:") || - originalUrl.startsWith("https:") || - originalUrl.startsWith("data:") - ) { - return; - } - // allow file:/// urls (if they are absolute) - if (originalUrl.startsWith("file://")) { - const path = originalUrl.slice(7); - if (!path.startsWith("/")) { - console.log(`Invalid background, contains a non-absolute file URL: ${originalUrl}`); - hasUnsafeUrl = true; - return; - } - const newUrl = encodeFileURL(path); - node.value = newUrl; - return; - } - // allow absolute paths - if (originalUrl.startsWith("/") || originalUrl.startsWith("~/") || /^[a-zA-Z]:(\/|\\)/.test(originalUrl)) { - const newUrl = encodeFileURL(originalUrl); - node.value = newUrl; - return; - } - hasUnsafeUrl = true; - console.log(`Invalid background, contains an unsafe URL scheme: ${originalUrl}`); - }, - }); - if (hasUnsafeUrl) { - return null; - } - const rtnStyle = generateCSS(ast); - if (rtnStyle == null) { - return null; - } - return rtnStyle.replace(/^background:\s*/, ""); -} - export function AppBackground() { const bgRef = useRef(null); const tabId = useAtomValue(atoms.staticTabId); const [tabData] = useWaveObjectValue(WOS.makeORef("tab", tabId)); - const bgAttr = tabData?.meta?.bg; - const style: CSSProperties = {}; - if (!util.isBlank(bgAttr)) { - try { - const processedBg = processBackgroundUrls(bgAttr); - if (!util.isBlank(processedBg)) { - const opacity = util.boundNumber(tabData?.meta?.["bg:opacity"], 0, 1) ?? 0.5; - style.opacity = opacity; - style.background = processedBg; - const blendMode = tabData?.meta?.["bg:blendmode"]; - if (!util.isBlank(blendMode)) { - style.backgroundBlendMode = blendMode; - } - } - } catch (e) { - console.error("error processing background", e); - } - } + const style: CSSProperties = computeBgStyleFromMeta(tabData?.meta, 0.5) ?? {}; const getAvgColor = useCallback( debounce(30, () => { if ( diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx index 8a0b822a20..47410b19c6 100644 --- a/frontend/app/block/blockframe.tsx +++ b/frontend/app/block/blockframe.tsx @@ -24,6 +24,7 @@ import { MagnifyIcon } from "@/element/magnify"; import { MenuButton } from "@/element/menubutton"; import { NodeModel } from "@/layout/index"; import * as util from "@/util/util"; +import { computeBgStyleFromMeta } from "@/util/waveutil"; import clsx from "clsx"; import * as jotai from "jotai"; import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; @@ -575,15 +576,9 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => { }, [manageConnection, blockData]); const viewIconElem = getViewIconElem(viewIconUnion, blockData); - const innerStyle: React.CSSProperties = {}; - if (!preview && customBg?.bg != null) { - innerStyle.background = customBg.bg; - if (customBg["bg:opacity"] != null) { - innerStyle.opacity = customBg["bg:opacity"]; - } - if (customBg["bg:blendmode"] != null) { - innerStyle.backgroundBlendMode = customBg["bg:blendmode"]; - } + let innerStyle: React.CSSProperties = {}; + if (!preview) { + innerStyle = computeBgStyleFromMeta(customBg); } const previewElem =
{viewIconElem}
; const headerElem = ( diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index 47a0362a92..4d30aa5c74 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -27,6 +27,7 @@ import { import * as services from "@/store/services"; import * as keyutil from "@/util/keyutil"; import { boundNumber, fireAndForget, stringToBase64, useAtomValueSafe } from "@/util/util"; +import { computeBgStyleFromMeta } from "@/util/waveutil"; import { ISearchOptions } from "@xterm/addon-search"; import clsx from "clsx"; import debug from "debug"; @@ -1070,8 +1071,11 @@ const TerminalView = ({ blockId, model }: ViewComponentProps) => blockId: blockId, }; + const termBg = computeBgStyleFromMeta(blockData?.meta); + return (
+ {termBg &&
} diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index 9dbacab01a..46089c4aa7 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -44,6 +44,95 @@ type TermWrapOptions = { sendDataHandler?: (data: string) => void; }; +function handleOscWaveCommand(data: string, blockId: string, loaded: boolean): boolean { + if (!loaded) { + return false; + } + if (!data || data.length === 0) { + console.log("Invalid Wave OSC command received (empty)"); + return false; + } + + // Expected formats: + // "setmeta;{JSONDATA}" + // "setmeta;[wave-id];{JSONDATA}" + const parts = data.split(";"); + if (parts[0] !== "setmeta") { + console.log("Invalid Wave OSC command received (bad command)", data); + return false; + } + let jsonPayload: string; + let waveId: string | undefined; + if (parts.length === 2) { + jsonPayload = parts[1]; + } else if (parts.length >= 3) { + waveId = parts[1]; + jsonPayload = parts.slice(2).join(";"); + } else { + console.log("Invalid Wave OSC command received (1 part)", data); + return false; + } + + let meta: any; + try { + meta = JSON.parse(jsonPayload); + } catch (e) { + console.error("Invalid JSON in Wave OSC command:", e); + return false; + } + + if (waveId) { + // Resolve the wave id to an ORef using our ResolveIdsCommand. + fireAndForget(() => { + return RpcApi.ResolveIdsCommand(TabRpcClient, { blockid: blockId, ids: [waveId] }) + .then((response: { resolvedids: { [key: string]: any } }) => { + const oref = response.resolvedids[waveId]; + if (!oref) { + console.error("Failed to resolve wave id:", waveId); + return; + } + services.ObjectService.UpdateObjectMeta(oref, meta); + }) + .catch((err: any) => { + console.error("Error resolving wave id", waveId, err); + }); + }); + } else { + // No wave id provided; update using the current block id. + fireAndForget(() => { + return services.ObjectService.UpdateObjectMeta(WOS.makeORef("block", blockId), meta); + }); + } + return true; +} + +function handleOsc7Command(data: string, blockId: string, loaded: boolean): boolean { + if (!loaded) { + return false; + } + if (data == null || data.length == 0) { + console.log("Invalid OSC 7 command received (empty)"); + return false; + } + if (data.startsWith("file://")) { + data = data.substring(7); + const nextSlashIdx = data.indexOf("/"); + if (nextSlashIdx == -1) { + console.log("Invalid OSC 7 command received (bad path)", data); + return false; + } + data = data.substring(nextSlashIdx); + } + setTimeout(() => { + fireAndForget(() => + services.ObjectService.UpdateObjectMeta(WOS.makeORef("block", blockId), { + "cmd:cwd": data, + }) + ); + }, 0); + return true; +} + export class TermWrap { blockId: string; ptyOffset: number; @@ -113,29 +202,12 @@ export class TermWrap { loggedWebGL = true; } } + // Register OSC 9283 handler + this.terminal.parser.registerOscHandler(9283, (data: string) => { + return handleOscWaveCommand(data, this.blockId, this.loaded); + }); this.terminal.parser.registerOscHandler(7, (data: string) => { - if (!this.loaded) { - return false; - } - if (data == null || data.length == 0) { - return false; - } - if (data.startsWith("file://")) { - data = data.substring(7); - const nextSlashIdx = data.indexOf("/"); - if (nextSlashIdx == -1) { - return false; - } - data = data.substring(nextSlashIdx); - } - setTimeout(() => { - fireAndForget(() => - services.ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), { - "cmd:cwd": data, - }) - ); - }, 0); - return true; + return handleOsc7Command(data, this.blockId, this.loaded); }); this.terminal.attachCustomKeyEventHandler(waveOptions.keydownHandler); this.connectElem = connectElem; diff --git a/frontend/util/waveutil.ts b/frontend/util/waveutil.ts new file mode 100644 index 0000000000..4b3a1e66f5 --- /dev/null +++ b/frontend/util/waveutil.ts @@ -0,0 +1,88 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0s + +import { getWebServerEndpoint } from "@/util/endpoints"; +import { boundNumber, isBlank } from "@/util/util"; +import { generate as generateCSS, parse as parseCSS, walk as walkCSS } from "css-tree"; + +function encodeFileURL(file: string) { + const webEndpoint = getWebServerEndpoint(); + return webEndpoint + `/wave/stream-file?path=${encodeURIComponent(file)}&no404=1`; +} + +export function processBackgroundUrls(cssText: string): string { + if (isBlank(cssText)) { + return null; + } + cssText = cssText.trim(); + if (cssText.endsWith(";")) { + cssText = cssText.slice(0, -1); + } + const attrRe = /^background(-image)?\s*:\s*/i; + cssText = cssText.replace(attrRe, ""); + const ast = parseCSS("background: " + cssText, { + context: "declaration", + }); + let hasUnsafeUrl = false; + walkCSS(ast, { + visit: "Url", + enter(node) { + const originalUrl = node.value.trim(); + if ( + originalUrl.startsWith("http:") || + originalUrl.startsWith("https:") || + originalUrl.startsWith("data:") + ) { + return; + } + // allow file:/// urls (if they are absolute) + if (originalUrl.startsWith("file://")) { + const path = originalUrl.slice(7); + if (!path.startsWith("/")) { + console.log(`Invalid background, contains a non-absolute file URL: ${originalUrl}`); + hasUnsafeUrl = true; + return; + } + const newUrl = encodeFileURL(path); + node.value = newUrl; + return; + } + // allow absolute paths + if (originalUrl.startsWith("/") || originalUrl.startsWith("~/") || /^[a-zA-Z]:(\/|\\)/.test(originalUrl)) { + const newUrl = encodeFileURL(originalUrl); + node.value = newUrl; + return; + } + hasUnsafeUrl = true; + console.log(`Invalid background, contains an unsafe URL scheme: ${originalUrl}`); + }, + }); + if (hasUnsafeUrl) { + return null; + } + const rtnStyle = generateCSS(ast); + if (rtnStyle == null) { + return null; + } + return rtnStyle.replace(/^background:\s*/, ""); +} + +export function computeBgStyleFromMeta(meta: MetaType, defaultOpacity: number = null): React.CSSProperties { + const bgAttr = meta?.["bg"]; + if (isBlank(bgAttr)) { + return null; + } + try { + const processedBg = processBackgroundUrls(bgAttr); + const rtn: React.CSSProperties = {}; + rtn.background = processedBg; + rtn.opacity = boundNumber(meta["bg:opacity"], 0, 1) ?? defaultOpacity; + if (!isBlank(meta?.["bg:blendmode"])) { + rtn.backgroundBlendMode = meta["bg:blendmode"]; + } + return rtn; + } catch (e) { + console.error("error processing background", e); + return null; + } +}