diff --git a/Taskfile.yml b/Taskfile.yml index 27ae3a03ac..2f47c49a73 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -166,6 +166,7 @@ tasks: - "cmd/server/*.go" - "pkg/**/*.go" - "pkg/**/*.json" + - tsunami/**/*.go generates: - dist/bin/wavesrv.* @@ -194,6 +195,7 @@ tasks: - "cmd/server/*.go" - "pkg/**/*.go" - "pkg/**/*.json" + - "tsunami/**/*.go" generates: - dist/bin/wavesrv.* @@ -485,6 +487,19 @@ tasks: cmds: - task: tsunami:scaffold:internal + tsunami:scaffold:packagejson: + desc: Create package.json for tsunami scaffold using npm commands + dir: tsunami/frontend/scaffold + cmds: + - cmd: "{{.RM}} package.json" + ignore_error: true + - npm --no-workspaces init -y --init-license Apache-2.0 + - npm pkg set name=tsunami-scaffold + - npm pkg delete author + - npm pkg set author.name="Command Line Inc" + - npm pkg set author.email="info@commandline.dev" + - npm --no-workspaces install tailwindcss@4.1.13 @tailwindcss/cli@4.1.13 + tsunami:scaffold:internal: desc: Internal task to create scaffold directory structure dir: tsunami/frontend @@ -493,12 +508,8 @@ tasks: - cmd: "{{.RMRF}} scaffold" ignore_error: true - mkdir scaffold - - cd scaffold && npm --no-workspaces init -y --init-license Apache-2.0 - - cd scaffold && npm pkg set name=tsunami-scaffold - - cd scaffold && npm pkg delete author - - cd scaffold && npm pkg set author.name="Command Line Inc" - - cd scaffold && npm pkg set author.email="info@commandline.dev" - - cd scaffold && npm --no-workspaces install tailwindcss @tailwindcss/cli + - cp ../templates/package.json.tmpl scaffold/package.json + - cd scaffold && npm install - cp -r dist scaffold/ - cp ../templates/app-main.go.tmpl scaffold/app-main.go - cp ../templates/tailwind.css scaffold/ diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 9ee2965117..acbf7c667d 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -78,7 +78,7 @@ export default defineConfig({ server: { open: false, watch: { - ignored: ["dist/**", "**/*.go", "**/go.mod", "**/go.sum", "**/*.md", "**/*.json"], + ignored: ["dist/**", "**/*.go", "**/go.mod", "**/go.sum", "**/*.md", "**/*.json", "emain/**"], }, }, css: { diff --git a/emain/emain-util.ts b/emain/emain-util.ts index a24a4e3c62..89ef975a50 100644 --- a/emain/emain-util.ts +++ b/emain/emain-util.ts @@ -5,6 +5,11 @@ import * as electron from "electron"; import { getWebServerEndpoint } from "../frontend/util/endpoints"; export const WaveAppPathVarName = "WAVETERM_APP_PATH"; +export const WaveAppElectronExecPath = "WAVETERM_ELECTRONEXECPATH"; + +export function getElectronExecPath(): string { + return process.execPath; +} // not necessarily exact, but we use this to help get us unstuck in certain cases let lastCtrlShiftSate: boolean = false; @@ -57,8 +62,13 @@ export function handleCtrlShiftState(sender: Electron.WebContents, waveEvent: Wa export function shNavHandler(event: Electron.Event, url: string) { const isDev = !electron.app.isPackaged; - if (isDev && (url.startsWith("http://127.0.0.1:5173/index.html") || url.startsWith("http://localhost:5173/index.html") || - url.startsWith("http://127.0.0.1:5174/index.html") || url.startsWith("http://localhost:5174/index.html"))) { + if ( + isDev && + (url.startsWith("http://127.0.0.1:5173/index.html") || + url.startsWith("http://localhost:5173/index.html") || + url.startsWith("http://127.0.0.1:5174/index.html") || + url.startsWith("http://localhost:5174/index.html")) + ) { // this is a dev-mode hot-reload, ignore it console.log("allowing hot-reload of index.html"); return; @@ -97,6 +107,30 @@ export function shFrameNavHandler(event: Electron.Event= 2 ? nameParts[1] : null; + + try { + const tsunamiUrl = new URL(url); + if ( + tsunamiUrl.protocol === "http:" && + tsunamiUrl.hostname === "localhost" && + expectedPort && + tsunamiUrl.port === expectedPort + ) { + // allowed + return; + } + // If navigation is not to expected port, open externally + event.preventDefault(); + electron.shell.openExternal(url); + return; + } catch (e) { + // Invalid URL, fall through to prevent navigation + } + } event.preventDefault(); console.log("frame navigation canceled"); } diff --git a/emain/emain-wavesrv.ts b/emain/emain-wavesrv.ts index beca7d684a..686ee87f76 100644 --- a/emain/emain-wavesrv.ts +++ b/emain/emain-wavesrv.ts @@ -7,7 +7,7 @@ import * as readline from "readline"; import { WebServerEndpointVarName, WSServerEndpointVarName } from "../frontend/util/endpoints"; import { AuthKey, WaveAuthKeyEnv } from "./authkey"; import { setForceQuit } from "./emain-activity"; -import { WaveAppPathVarName } from "./emain-util"; +import { WaveAppPathVarName, WaveAppElectronExecPath, getElectronExecPath } from "./emain-util"; import { getElectronAppUnpackedBasePath, getWaveConfigDir, @@ -59,6 +59,7 @@ export function runWaveSrv(handleWSEvent: (evtMsg: WSEventType) => void): Promis envCopy["XDG_CURRENT_DESKTOP"] = xdgCurrentDesktop; } envCopy[WaveAppPathVarName] = getElectronAppUnpackedBasePath(); + envCopy[WaveAppElectronExecPath] = getElectronExecPath(); envCopy[WaveAuthKeyEnv] = AuthKey; envCopy[WaveDataHomeVarName] = getWaveDataDir(); envCopy[WaveConfigHomeVarName] = getWaveConfigDir(); diff --git a/frontend/app/block/block.tsx b/frontend/app/block/block.tsx index 1aa507f4f8..350f984563 100644 --- a/frontend/app/block/block.tsx +++ b/frontend/app/block/block.tsx @@ -12,6 +12,7 @@ import { import { LauncherViewModel } from "@/app/view/launcher/launcher"; import { PreviewModel } from "@/app/view/preview/preview-model"; import { SysinfoViewModel } from "@/app/view/sysinfo/sysinfo"; +import { TsunamiViewModel } from "@/app/view/tsunami/tsunami"; import { VDomModel } from "@/app/view/vdom/vdom-model"; import { ErrorBoundary } from "@/element/errorboundary"; import { CenteredDiv } from "@/element/quickelems"; @@ -48,6 +49,7 @@ BlockRegistry.set("vdom", VDomModel); BlockRegistry.set("tips", QuickTipsViewModel); BlockRegistry.set("help", HelpViewModel); BlockRegistry.set("launcher", LauncherViewModel); +BlockRegistry.set("tsunami", TsunamiViewModel); function makeViewModel(blockId: string, blockView: string, nodeModel: BlockNodeModel): ViewModel { const ctor = BlockRegistry.get(blockView); diff --git a/frontend/app/view/tsunami/tsunami.tsx b/frontend/app/view/tsunami/tsunami.tsx new file mode 100644 index 0000000000..19121ff61f --- /dev/null +++ b/frontend/app/view/tsunami/tsunami.tsx @@ -0,0 +1,175 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { BlockNodeModel } from "@/app/block/blocktypes"; +import { atoms, globalStore, WOS } from "@/app/store/global"; +import { waveEventSubscribe } from "@/app/store/wps"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import * as services from "@/store/services"; +import * as jotai from "jotai"; +import { memo, useEffect } from "react"; + +class TsunamiViewModel implements ViewModel { + viewType: string; + blockAtom: jotai.Atom; + blockId: string; + viewIcon: jotai.Atom; + viewName: jotai.Atom; + shellProcFullStatus: jotai.PrimitiveAtom; + shellProcStatusUnsubFn: () => void; + isRestarting: jotai.PrimitiveAtom; + + constructor(blockId: string, nodeModel: BlockNodeModel) { + this.viewType = "tsunami"; + this.blockId = blockId; + this.blockAtom = WOS.getWaveObjectAtom(`block:${blockId}`); + this.viewIcon = jotai.atom("cube"); + this.viewName = jotai.atom("Tsunami"); + this.isRestarting = jotai.atom(false); + + this.shellProcFullStatus = jotai.atom(null) as jotai.PrimitiveAtom; + const initialShellProcStatus = services.BlockService.GetControllerStatus(blockId); + initialShellProcStatus.then((rts) => { + this.updateShellProcStatus(rts); + }); + this.shellProcStatusUnsubFn = waveEventSubscribe({ + eventType: "controllerstatus", + scope: WOS.makeORef("block", blockId), + handler: (event) => { + let bcRTS: BlockControllerRuntimeStatus = event.data; + this.updateShellProcStatus(bcRTS); + }, + }); + } + + get viewComponent(): ViewComponent { + return TsunamiView; + } + + updateShellProcStatus(fullStatus: BlockControllerRuntimeStatus) { + console.log("tsunami-status", fullStatus); + if (fullStatus == null) { + return; + } + const curStatus = globalStore.get(this.shellProcFullStatus); + if (curStatus == null || curStatus.version < fullStatus.version) { + globalStore.set(this.shellProcFullStatus, fullStatus); + } + } + + triggerRestartAtom() { + globalStore.set(this.isRestarting, true); + setTimeout(() => { + globalStore.set(this.isRestarting, false); + }, 300); + } + + resyncController() { + const prtn = RpcApi.ControllerResyncCommand(TabRpcClient, { + tabid: globalStore.get(atoms.staticTabId), + blockid: this.blockId, + forcerestart: false, + }); + prtn.catch((e) => console.log("error controller resync", e)); + } + + forceRestartController() { + if (globalStore.get(this.isRestarting)) { + return; + } + this.triggerRestartAtom(); + const prtn = RpcApi.ControllerResyncCommand(TabRpcClient, { + tabid: globalStore.get(atoms.staticTabId), + blockid: this.blockId, + forcerestart: true, + }); + prtn.catch((e) => console.log("error controller resync (force restart)", e)); + } + + dispose() { + if (this.shellProcStatusUnsubFn) { + this.shellProcStatusUnsubFn(); + } + } + + getSettingsMenuItems(): ContextMenuItem[] { + return []; + } +} + +type TsunamiViewProps = { + model: TsunamiViewModel; +}; + +const TsunamiView = memo(({ model }: TsunamiViewProps) => { + const shellProcFullStatus = jotai.useAtomValue(model.shellProcFullStatus); + const blockData = jotai.useAtomValue(model.blockAtom); + const isRestarting = jotai.useAtomValue(model.isRestarting); + + useEffect(() => { + model.resyncController(); + }, [model]); + + const appPath = blockData?.meta?.["tsunami:apppath"]; + const controller = blockData?.meta?.controller; + + // Check for configuration errors + const errors = []; + if (!appPath) { + errors.push("App path must be set (tsunami:apppath)"); + } + if (controller !== "tsunami") { + errors.push("Invalid controller (must be 'tsunami')"); + } + + // Show errors if any exist + if (errors.length > 0) { + return ( +
+

Tsunami

+
+ {errors.map((error, index) => ( +
+ {error} +
+ ))} +
+
+ ); + } + + // Check if we should show the iframe + const shouldShowIframe = + shellProcFullStatus?.shellprocstatus === "running" && + shellProcFullStatus?.tsunamiport && + shellProcFullStatus.tsunamiport !== 0; + + if (shouldShowIframe) { + const iframeUrl = `http://localhost:${shellProcFullStatus.tsunamiport}/?clientid=wave:${model.blockId}`; + return