diff --git a/frontend/app/app.less b/frontend/app/app.less index eee11170a..c2c476b93 100644 --- a/frontend/app/app.less +++ b/frontend/app/app.less @@ -9,10 +9,10 @@ body { flex-direction: row; width: 100vw; height: 100vh; - background-color: var(--main-bg-color); color: var(--main-text-color); font: var(--base-font); overflow: hidden; + background: var(--main-bg-color); -webkit-font-smoothing: auto; backface-visibility: hidden; transform: translateZ(0); @@ -51,6 +51,15 @@ body { flex-direction: column; width: 100%; height: 100%; + + .app-background { + pointer-events: none; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } } .error-boundary { diff --git a/frontend/app/app.tsx b/frontend/app/app.tsx index aaf5962c5..364f130d7 100644 --- a/frontend/app/app.tsx +++ b/frontend/app/app.tsx @@ -8,6 +8,7 @@ import * as services from "@/store/services"; import * as keyutil from "@/util/keyutil"; import * as layoututil from "@/util/layoututil"; import * as util from "@/util/util"; +import * as csstree from "css-tree"; import { getLayoutStateAtomForTab, globalLayoutTransformsMap } from "frontend/layout/lib/layoutAtom"; import * as jotai from "jotai"; import * as React from "react"; @@ -15,6 +16,8 @@ import { DndProvider } from "react-dnd"; import { HTML5Backend } from "react-dnd-html5-backend"; import { CenteredDiv } from "./element/quickelems"; +import { useWaveObjectValue } from "@/app/store/wos"; +import { getWebServerEndpoint } from "@/util/endpoints"; import clsx from "clsx"; import Color from "color"; import "overlayscrollbars/overlayscrollbars.css"; @@ -208,7 +211,6 @@ function AppSettingsUpdater() { const opacity = util.boundNumber(settings?.window?.opacity ?? 0.8, 0, 1); let baseBgColor = settings?.window?.bgcolor; console.log("window settings", settings.window); - if (isTransparentOrBlur) { document.body.classList.add("is-transparent"); const rootStyles = getComputedStyle(document.documentElement); @@ -226,6 +228,76 @@ function AppSettingsUpdater() { return null; } +function encodeFileURL(file: string) { + const webEndpoint = getWebServerEndpoint(); + return webEndpoint + `/wave/stream-file?path=${encodeURIComponent(file)}&no404=1`; +} + +(window as any).csstree = csstree; + +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*/; + cssText = cssText.replace(attrRe, ""); + const ast = csstree.parse("background: " + cssText, { + context: "declaration", + }); + let hasJSUrl = false; + csstree.walk(ast, { + visit: "Url", + enter(node) { + const originalUrl = node.value.trim(); + if (originalUrl.startsWith("javascript:")) { + hasJSUrl = true; + return; + } + const newUrl = encodeFileURL(originalUrl); + node.value = newUrl; + }, + }); + if (hasJSUrl) { + console.log("invalid background, contains a 'javascript' protocol url which is not allowed"); + return null; + } + const rtnStyle = csstree.generate(ast); + if (rtnStyle == null) { + return null; + } + return rtnStyle.replace(/^background:\s*/, ""); +} + +const backgroundAttr = "url(/Users/mike/Downloads/wave-logo_appicon.png) repeat-x fixed"; + +function AppBackground() { + const tabId = jotai.useAtomValue(atoms.activeTabId); + const [tabData] = useWaveObjectValue(WOS.makeORef("tab", tabId)); + const bgAttr = tabData?.meta?.bg; + const style: React.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); + } + } + return
; +} + const AppInner = () => { const client = jotai.useAtomValue(atoms.client); const windowData = jotai.useAtomValue(atoms.waveWindow); @@ -233,6 +305,7 @@ const AppInner = () => { if (client == null || windowData == null) { return (
+ invalid configuration, client or window was not loaded
); @@ -277,6 +350,7 @@ const AppInner = () => { const isFullScreen = jotai.useAtomValue(atoms.isFullScreen); return (
+ diff --git a/frontend/app/tab/tab.less b/frontend/app/tab/tab.less index b0810f6e0..b2eae8d91 100644 --- a/frontend/app/tab/tab.less +++ b/frontend/app/tab/tab.less @@ -16,7 +16,7 @@ height: 100%; white-space: nowrap; border-radius: 6px; - background: rgba(255, 255, 255, 0.05); + background: rgba(255, 255, 255, 0.1); } &.animate { diff --git a/frontend/app/tab/tabcontent.tsx b/frontend/app/tab/tabcontent.tsx index 7eba645b4..549be9f0c 100644 --- a/frontend/app/tab/tabcontent.tsx +++ b/frontend/app/tab/tabcontent.tsx @@ -7,7 +7,7 @@ import * as services from "@/store/services"; import * as WOS from "@/store/wos"; import * as React from "react"; -import { CenteredDiv, CenteredLoadingDiv } from "@/element/quickelems"; +import { CenteredDiv } from "@/element/quickelems"; import { TileLayout } from "frontend/layout/index"; import { getLayoutStateAtomForTab } from "frontend/layout/lib/layoutAtom"; import { useAtomValue } from "jotai"; @@ -56,7 +56,11 @@ const TabContent = React.memo(({ tabId }: { tabId: string }) => { }, []); if (tabLoading) { - return ; + return ( +
+ Tab Loading +
+ ); } if (!tabData) { diff --git a/frontend/util/util.ts b/frontend/util/util.ts index 01c025478..9688a03ab 100644 --- a/frontend/util/util.ts +++ b/frontend/util/util.ts @@ -35,6 +35,9 @@ function base64ToArray(b64: string): Uint8Array { } function boundNumber(num: number, min: number, max: number): number { + if (num == null || typeof num != "number" || isNaN(num)) { + return null; + } return Math.min(Math.max(num, min), max); } diff --git a/package.json b/package.json index 3234e2641..ad89c8da3 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@storybook/react": "^8.2.6", "@storybook/react-vite": "^8.2.6", "@storybook/test": "^8.2.6", + "@types/css-tree": "^2", "@types/electron": "^1.6.10", "@types/node": "^20.14.12", "@types/papaparse": "^5", @@ -84,6 +85,7 @@ "base64-js": "^1.5.1", "clsx": "^2.1.1", "color": "^4.2.3", + "css-tree": "^2.3.1", "dayjs": "^1.11.12", "electron-updater": "6.3.1", "html-to-image": "^1.11.11", diff --git a/pkg/blockcontroller/blockcontroller.go b/pkg/blockcontroller/blockcontroller.go index c99be69c0..7ebcf752c 100644 --- a/pkg/blockcontroller/blockcontroller.go +++ b/pkg/blockcontroller/blockcontroller.go @@ -307,7 +307,7 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta map[str if bc.ControllerType == BlockController_Shell { cmdOpts.Interactive = true cmdOpts.Login = true - cmdOpts.Cwd, _ = blockMeta["cwd"].(string) + cmdOpts.Cwd, _ = blockMeta["cmd:cwd"].(string) if cmdOpts.Cwd != "" { cmdOpts.Cwd = wavebase.ExpandHomeDir(cmdOpts.Cwd) } @@ -317,8 +317,8 @@ func (bc *BlockController) DoRunShellCommand(rc *RunShellOpts, blockMeta map[str } else { return fmt.Errorf("missing cmd in block meta") } - if _, ok := blockMeta["cwd"].(string); ok { - cmdOpts.Cwd = blockMeta["cwd"].(string) + if _, ok := blockMeta["cmd:cwd"].(string); ok { + cmdOpts.Cwd = blockMeta["cmd:cwd"].(string) if cmdOpts.Cwd != "" { cmdOpts.Cwd = wavebase.ExpandHomeDir(cmdOpts.Cwd) } diff --git a/pkg/web/web.go b/pkg/web/web.go index cad06929f..d9e82f290 100644 --- a/pkg/web/web.go +++ b/pkg/web/web.go @@ -27,6 +27,8 @@ import ( type WebFnType = func(http.ResponseWriter, *http.Request) +const TransparentGif64 = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" + // Header constants const ( CacheControlHeaderKey = "Cache-Control" @@ -55,6 +57,45 @@ type WebFnOpts struct { JsonErrors bool } +func copyHeaders(dst, src http.Header) { + for key, values := range src { + for _, value := range values { + dst.Add(key, value) + } + } +} + +type notFoundBlockingResponseWriter struct { + w http.ResponseWriter + status int + headers http.Header +} + +func (rw *notFoundBlockingResponseWriter) Header() http.Header { + return rw.headers +} + +func (rw *notFoundBlockingResponseWriter) WriteHeader(status int) { + if status == http.StatusNotFound { + rw.status = status + return + } + rw.status = status + copyHeaders(rw.w.Header(), rw.headers) + rw.w.WriteHeader(status) +} + +func (rw *notFoundBlockingResponseWriter) Write(b []byte) (int, error) { + if rw.status == http.StatusNotFound { + // Block the write if it's a 404 + return len(b), nil + } + if rw.status == 0 { + rw.WriteHeader(http.StatusOK) + } + return rw.w.Write(b) +} + func handleService(w http.ResponseWriter, r *http.Request) { bodyData, err := io.ReadAll(r.Body) if err != nil { @@ -160,14 +201,36 @@ func handleWaveFile(w http.ResponseWriter, r *http.Request) { } } +func serveTransparentGIF(w http.ResponseWriter) { + gifBytes, _ := base64.StdEncoding.DecodeString(TransparentGif64) + w.Header().Set("Content-Type", "image/gif") + w.WriteHeader(http.StatusOK) + w.Write(gifBytes) +} + func handleStreamFile(w http.ResponseWriter, r *http.Request) { fileName := r.URL.Query().Get("path") if fileName == "" { http.Error(w, "path is required", http.StatusBadRequest) return } - fileName = wavebase.ExpandHomeDir(fileName) - http.ServeFile(w, r, fileName) + no404 := r.URL.Query().Get("no404") + log.Printf("got no404: %q\n", no404) + if no404 != "" { + log.Printf("streaming file w/no404: %q\n", fileName) + // use the custom response writer + rw := ¬FoundBlockingResponseWriter{w: w, headers: http.Header{}} + // Serve the file using http.ServeFile + http.ServeFile(rw, r, fileName) + // if the file was not found, serve the transparent GIF + log.Printf("got streamfile status: %d\n", rw.status) + if rw.status == http.StatusNotFound { + serveTransparentGIF(w) + } + } else { + fileName = wavebase.ExpandHomeDir(fileName) + http.ServeFile(w, r, fileName) + } } func WebFnWrap(opts WebFnOpts, fn WebFnType) WebFnType { diff --git a/pkg/wstore/wstore_types.go b/pkg/wstore/wstore_types.go index c0e5194ae..3217f568c 100644 --- a/pkg/wstore/wstore_types.go +++ b/pkg/wstore/wstore_types.go @@ -28,10 +28,38 @@ const ( MetaKey_CmdRunOnStart = "cmd:runonstart" MetaKey_CmdClearOnStart = "cmd:clearonstart" MetaKey_CmdClearOnRestart = "cmd:clearonrestart" - MetaKey_CmdEnv = "env" - MetaKey_CmdCwd = "cwd" + MetaKey_CmdEnv = "cmd:env" + MetaKey_CmdCwd = "cmd:cwd" ) +type MetaType struct { + View string `json:"view,omitempty"` + Controller string `json:"controller,omitempty"` + Title string `json:"title,omitempty"` + File string `json:"file,omitempty"` + Url string `json:"url,omitempty"` + + Icon string `json:"icon,omitempty"` + IconColor string `json:"icon:color,omitempty"` + + Frame bool `json:"frame,omitempty"` + FrameBorderColor string `json:"frame:bordercolor,omitempty"` + FrameBorderColor_Focused string `json:"frame:bordercolor:focused,omitempty"` + + Cmd string `json:"cmd,omitempty"` + CmdInteractive bool `json:"cmd:interactive,omitempty"` + CmdLogin bool `json:"cmd:login,omitempty"` + CmdRunOnStart bool `json:"cmd:runonstart,omitempty"` + CmdClearOnStart bool `json:"cmd:clearonstart,omitempty"` + CmdClearOnRestart bool `json:"cmd:clearonrestart,omitempty"` + CmdEnv map[string]string `json:"cmd:env,omitempty"` + CmdCwd string `json:"cmd:cwd,omitempty"` + + Bg string `json:"bg,omitempty"` + BgOpacity float64 `json:"bg:opacity,omitempty"` + BgBlendMode string `json:"bg:blendmode,omitempty"` +} + type UIContext struct { WindowId string `json:"windowid"` ActiveTabId string `json:"activetabid"` diff --git a/yarn.lock b/yarn.lock index 7d91b6b32..7b53efbd8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3589,6 +3589,13 @@ __metadata: languageName: node linkType: hard +"@types/css-tree@npm:^2": + version: 2.3.8 + resolution: "@types/css-tree@npm:2.3.8" + checksum: 10c0/fafa7ad516b64481a031aceb3c30762074e1e0bfd67e0f0655e46b8c1b7b3c39660f8285811ca6aac11229ef477c65ca61ee118d2f9264145d5db8fe26f1a721 + languageName: node + linkType: hard + "@types/debug@npm:^4.0.0, @types/debug@npm:^4.1.6": version: 4.1.12 resolution: "@types/debug@npm:4.1.12" @@ -5510,6 +5517,16 @@ __metadata: languageName: node linkType: hard +"css-tree@npm:^2.3.1": + version: 2.3.1 + resolution: "css-tree@npm:2.3.1" + dependencies: + mdn-data: "npm:2.0.30" + source-map-js: "npm:^1.0.1" + checksum: 10c0/6f8c1a11d5e9b14bf02d10717fc0351b66ba12594166f65abfbd8eb8b5b490dd367f5c7721db241a3c792d935fc6751fbc09f7e1598d421477ad9fadc30f4f24 + languageName: node + linkType: hard + "css.escape@npm:^1.5.1": version: 1.5.1 resolution: "css.escape@npm:1.5.1" @@ -9080,6 +9097,13 @@ __metadata: languageName: node linkType: hard +"mdn-data@npm:2.0.30": + version: 2.0.30 + resolution: "mdn-data@npm:2.0.30" + checksum: 10c0/a2c472ea16cee3911ae742593715aa4c634eb3d4b9f1e6ada0902aa90df13dcbb7285d19435f3ff213ebaa3b2e0c0265c1eb0e3fb278fda7f8919f046a410cd9 + languageName: node + linkType: hard + "media-typer@npm:0.3.0": version: 0.3.0 resolution: "media-typer@npm:0.3.0" @@ -11434,7 +11458,7 @@ __metadata: languageName: node linkType: hard -"source-map-js@npm:^1.2.0": +"source-map-js@npm:^1.0.1, source-map-js@npm:^1.2.0": version: 1.2.0 resolution: "source-map-js@npm:1.2.0" checksum: 10c0/7e5f896ac10a3a50fe2898e5009c58ff0dc102dcb056ed27a354623a0ece8954d4b2649e1a1b2b52ef2e161d26f8859c7710350930751640e71e374fe2d321a4 @@ -11818,6 +11842,7 @@ __metadata: "@table-nav/react": "npm:^0.0.7" "@tanstack/react-table": "npm:^8.19.3" "@types/color": "npm:^3.0.6" + "@types/css-tree": "npm:^2" "@types/electron": "npm:^1.6.10" "@types/node": "npm:^20.14.12" "@types/papaparse": "npm:^5" @@ -11835,6 +11860,7 @@ __metadata: base64-js: "npm:^1.5.1" clsx: "npm:^2.1.1" color: "npm:^4.2.3" + css-tree: "npm:^2.3.1" dayjs: "npm:^1.11.12" electron: "npm:^31.3.0" electron-builder: "npm:^24.13.3"