Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions cmd/wsh/cmd/wshcmd-setbg.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
85 changes: 2 additions & 83 deletions frontend/app/app-bg.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null);
const tabId = useAtomValue(atoms.staticTabId);
const [tabData] = useWaveObjectValue<Tab>(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 (
Expand Down
13 changes: 4 additions & 9 deletions frontend/app/block/blockframe.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 = <div className="block-frame-preview">{viewIconElem}</div>;
const headerElem = (
Expand Down
4 changes: 4 additions & 0 deletions frontend/app/view/term/term.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1070,8 +1071,11 @@ const TerminalView = ({ blockId, model }: ViewComponentProps<TermViewModel>) =>
blockId: blockId,
};

const termBg = computeBgStyleFromMeta(blockData?.meta);

return (
<div className={clsx("view-term", "term-mode-" + termMode)} ref={viewRef}>
{termBg && <div className="absolute inset-0 z-0 pointer-events-none" style={termBg} />}
<TermResyncHandler blockId={blockId} model={model} />
<TermThemeUpdater blockId={blockId} model={model} termRef={model.termRef} />
<TermStickers config={stickerConfig} />
Expand Down
116 changes: 94 additions & 22 deletions frontend/app/view/term/termwrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
88 changes: 88 additions & 0 deletions frontend/util/waveutil.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading