// Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { WshServer } from "@/app/store/wshserver"; import { PLATFORM, WOS, atoms, fetchWaveFile, getFileSubject, globalStore, openLink, sendWSCommand, } from "@/store/global"; import * as services from "@/store/services"; import * as util from "@/util/util"; import { base64ToArray, fireAndForget } from "@/util/util"; import { SerializeAddon } from "@xterm/addon-serialize"; import { WebLinksAddon } from "@xterm/addon-web-links"; import { WebglAddon } from "@xterm/addon-webgl"; import * as TermTypes from "@xterm/xterm"; import { Terminal } from "@xterm/xterm"; import debug from "debug"; import { debounce } from "throttle-debounce"; import { FitAddon } from "./fitaddon"; const dlog = debug("wave:termwrap"); const TermFileName = "term"; const TermCacheFileName = "cache:term:full"; // detect webgl support function detectWebGLSupport(): boolean { try { const canvas = document.createElement("canvas"); const ctx = canvas.getContext("webgl"); return !!ctx; } catch (e) { return false; } } const WebGLSupported = detectWebGLSupport(); let loggedWebGL = false; type TermWrapOptions = { keydownHandler?: (e: KeyboardEvent) => boolean; useWebGl?: boolean; }; export class TermWrap { blockId: string; ptyOffset: number; dataBytesProcessed: number; terminal: Terminal; connectElem: HTMLDivElement; fitAddon: FitAddon; serializeAddon: SerializeAddon; mainFileSubject: SubjectWithRef; loaded: boolean; heldData: Uint8Array[]; handleResize_debounced: () => void; isRunning: boolean; hasResized: boolean; constructor( blockId: string, connectElem: HTMLDivElement, options: TermTypes.ITerminalOptions & TermTypes.ITerminalInitOnlyOptions, waveOptions: TermWrapOptions ) { this.loaded = false; this.blockId = blockId; this.ptyOffset = 0; this.dataBytesProcessed = 0; this.hasResized = false; this.terminal = new Terminal(options); this.fitAddon = new FitAddon(); this.fitAddon.noScrollbar = PLATFORM == "darwin"; this.serializeAddon = new SerializeAddon(); this.terminal.loadAddon(this.fitAddon); this.terminal.loadAddon(this.serializeAddon); this.terminal.loadAddon( new WebLinksAddon((e, uri) => { e.preventDefault(); switch (PLATFORM) { case "darwin": if (e.metaKey) { fireAndForget(() => openLink(uri)); } break; default: if (e.ctrlKey) { fireAndForget(() => openLink(uri)); } break; } }) ); if (WebGLSupported && waveOptions.useWebGl) { const webglAddon = new WebglAddon(); webglAddon.onContextLoss(() => { webglAddon.dispose(); }); this.terminal.loadAddon(webglAddon); if (!loggedWebGL) { console.log("loaded webgl!"); loggedWebGL = true; } } 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(() => { services.ObjectService.UpdateObjectMeta(WOS.makeORef("block", this.blockId), { "cmd:cwd": data }); }, 0); return true; }); this.terminal.attachCustomKeyEventHandler(waveOptions.keydownHandler); this.connectElem = connectElem; this.mainFileSubject = null; this.heldData = []; this.handleResize_debounced = debounce(50, this.handleResize.bind(this)); this.terminal.open(this.connectElem); this.handleResize(); this.isRunning = true; } async initTerminal() { this.terminal.onData(this.handleTermData.bind(this)); this.mainFileSubject = getFileSubject(this.blockId, TermFileName); this.mainFileSubject.subscribe(this.handleNewFileSubjectData.bind(this)); try { await this.loadInitialTerminalData(); } finally { this.loaded = true; } this.runProcessIdleTimeout(); } setIsRunning(isRunning: boolean) { this.isRunning = isRunning; } dispose() { this.terminal.dispose(); this.mainFileSubject.release(); } handleTermData(data: string) { const b64data = util.stringToBase64(data); WshServer.ControllerInputCommand({ blockid: this.blockId, inputdata64: b64data }); } addFocusListener(focusFn: () => void) { this.terminal.textarea.addEventListener("focus", focusFn); } handleNewFileSubjectData(msg: WSFileEventData) { if (msg.fileop == "truncate") { this.terminal.clear(); this.heldData = []; } else if (msg.fileop == "append") { const decodedData = base64ToArray(msg.data64); if (this.loaded) { this.doTerminalWrite(decodedData, null); } else { this.heldData.push(decodedData); } } else { console.log("bad fileop for terminal", msg); return; } } doTerminalWrite(data: string | Uint8Array, setPtyOffset?: number): Promise { let resolve: () => void = null; let prtn = new Promise((presolve, _) => { resolve = presolve; }); this.terminal.write(data, () => { if (setPtyOffset != null) { this.ptyOffset = setPtyOffset; } else { this.ptyOffset += data.length; this.dataBytesProcessed += data.length; } resolve(); }); return prtn; } async loadInitialTerminalData(): Promise { let startTs = Date.now(); const { data: cacheData, fileInfo: cacheFile } = await fetchWaveFile(this.blockId, TermCacheFileName); let ptyOffset = 0; if (cacheFile != null) { ptyOffset = cacheFile.meta["ptyoffset"] ?? 0; if (cacheData.byteLength > 0) { this.doTerminalWrite(cacheData, ptyOffset); } } const { data: mainData, fileInfo: mainFile } = await fetchWaveFile(this.blockId, TermFileName, ptyOffset); console.log( `terminal loaded cachefile:${cacheData?.byteLength ?? 0} main:${mainData?.byteLength ?? 0} bytes, ${Date.now() - startTs}ms` ); if (mainFile != null) { await this.doTerminalWrite(mainData, null); } } async resyncController(reason: string) { dlog("resync controller", this.blockId, reason); const tabId = globalStore.get(atoms.activeTabId); const rtOpts: RuntimeOpts = { termsize: { rows: this.terminal.rows, cols: this.terminal.cols } }; try { await WshServer.ControllerResyncCommand({ tabid: tabId, blockid: this.blockId, rtopts: rtOpts }); } catch (e) { console.log(`error controller resync (${reason})`, this.blockId, e); } } handleResize() { const oldRows = this.terminal.rows; const oldCols = this.terminal.cols; this.fitAddon.fit(); if (oldRows !== this.terminal.rows || oldCols !== this.terminal.cols) { const termSize: TermSize = { rows: this.terminal.rows, cols: this.terminal.cols }; const wsCommand: SetBlockTermSizeWSCommand = { wscommand: "setblocktermsize", blockid: this.blockId, termsize: termSize, }; sendWSCommand(wsCommand); } dlog("resize", `${this.terminal.rows}x${this.terminal.cols}`, `${oldRows}x${oldCols}`, this.hasResized); if (!this.hasResized) { this.hasResized = true; this.resyncController("initial resize"); } } processAndCacheData() { if (this.dataBytesProcessed < 10 * 1024) { return; } const serializedOutput = this.serializeAddon.serialize(); console.log("idle timeout term", this.dataBytesProcessed, serializedOutput.length); services.BlockService.SaveTerminalState(this.blockId, serializedOutput, "full", this.ptyOffset); this.dataBytesProcessed = 0; } runProcessIdleTimeout() { setTimeout(() => { window.requestIdleCallback(() => { this.processAndCacheData(); this.runProcessIdleTimeout(); }); }, 5000); } }