diff --git a/packages/app/package.json b/packages/app/package.json index 34cd6414d..42e509fed 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -5,9 +5,10 @@ "main": "index.ts", "scripts": { "start": "cross-env APP_VARIANT=development expo start", + "build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs", "reset-project": "node ./scripts/reset-project.js", "build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/expo-two-way-audio", - "eas-build-post-install": "npm run build:workspace-deps", + "eas-build-post-install": "npm run build:workspace-deps && npm run build:terminal-webview", "android": "npm run android:development", "android:development": "cross-env APP_VARIANT=development expo prebuild --platform android --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug", "android:production": "cross-env APP_VARIANT=production expo prebuild --platform android --non-interactive && cross-env APP_VARIANT=production expo run:android --variant=release", diff --git a/packages/app/scripts/build-terminal-webview-html.mjs b/packages/app/scripts/build-terminal-webview-html.mjs new file mode 100644 index 000000000..d3b02336b --- /dev/null +++ b/packages/app/scripts/build-terminal-webview-html.mjs @@ -0,0 +1,89 @@ +import esbuild from "esbuild"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const appRoot = path.resolve(__dirname, ".."); +const repoRoot = path.resolve(appRoot, "../.."); +const entry = path.join(appRoot, "src/terminal/webview/terminal-emulator-webview-entry.ts"); +const output = path.join(appRoot, "src/terminal/webview/terminal-emulator-webview-html.ts"); + +async function resolveTsPath(basePath) { + const candidates = [ + basePath, + `${basePath}.ts`, + `${basePath}.tsx`, + `${basePath}.js`, + `${basePath}.jsx`, + path.join(basePath, "index.ts"), + path.join(basePath, "index.tsx"), + path.join(basePath, "index.js"), + path.join(basePath, "index.jsx"), + ]; + for (const candidate of candidates) { + try { + const stat = await fs.stat(candidate); + if (stat.isFile()) return candidate; + } catch { + // try next candidate + } + } + return basePath; +} + +const aliasPlugin = { + name: "paseo-alias", + setup(build) { + build.onResolve({ filter: /^@\// }, async (args) => ({ + path: await resolveTsPath(path.join(appRoot, "src", args.path.slice(2))), + })); + build.onResolve({ filter: /^@server\// }, async (args) => ({ + path: await resolveTsPath( + path.join(repoRoot, "packages/server/src", args.path.slice("@server/".length)), + ), + })); + }, +}; + +const result = await esbuild.build({ + entryPoints: [entry], + bundle: true, + write: false, + format: "iife", + platform: "browser", + target: ["ios15", "chrome100"], + loader: { + ".css": "text", + }, + plugins: [aliasPlugin], + logLevel: "info", +}); + +const js = result.outputFiles[0]?.text; +if (!js) { + throw new Error("terminal webview bundle produced no JavaScript"); +} + +const html = ` + + + + + + + + +`; + +const contents = `// Generated by packages/app/scripts/build-terminal-webview-html.mjs. +// Do not edit by hand. + +export const terminalEmulatorWebViewHtml = ${JSON.stringify(html)}; +`; + +await fs.writeFile(output, contents); +console.log(`Wrote ${path.relative(repoRoot, output)} (${html.length} bytes)`); diff --git a/packages/app/src/components/terminal-emulator.native.tsx b/packages/app/src/components/terminal-emulator.native.tsx new file mode 100644 index 000000000..036667e73 --- /dev/null +++ b/packages/app/src/components/terminal-emulator.native.tsx @@ -0,0 +1,510 @@ +import { + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, + type ComponentProps, + type Ref, +} from "react"; +import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native"; +import { WebView, type WebViewMessageEvent } from "react-native-webview"; +import type { ITheme } from "@xterm/xterm"; +import type { TerminalState } from "@server/shared/messages"; +import type { TerminalInputModeState } from "@server/shared/terminal-input-mode"; +import { terminalEmulatorWebViewHtml } from "../terminal/webview/terminal-emulator-webview-html"; +import type { PendingTerminalModifiers } from "../utils/terminal-keys"; +import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness"; +import { openExternalUrl } from "../utils/open-external-url"; + +export interface TerminalEmulatorHandle { + writeOutput: (text: string) => void; + renderSnapshot: (state: TerminalState | null) => void; + clear: () => void; +} + +interface TerminalEmulatorProps { + dom?: unknown; + ref: Ref; + streamKey: string; + testId?: string; + xtermTheme?: ITheme; + scrollbackLines: number; + swipeGesturesEnabled?: boolean; + onSwipeLeft?: () => void; + onSwipeRight?: () => void; + initialSnapshot?: TerminalState | null; + onInput?: (data: string) => Promise | void; + onResize?: (input: { rows: number; cols: number }) => Promise | void; + onTerminalKey?: (input: { + key: string; + ctrl: boolean; + shift: boolean; + alt: boolean; + meta: boolean; + }) => Promise | void; + onPendingModifiersConsumed?: () => Promise | void; + onInputModeChange?: (state: TerminalInputModeState) => Promise | void; + onRendererReadyChange?: (change: TerminalRendererReadyChange) => void; + pendingModifiers?: PendingTerminalModifiers; + focusRequestToken?: number; + resizeRequestToken?: number; +} + +type BridgeInboundMessage = + | { + type: "mount"; + streamKey: string; + initialSnapshot: TerminalState | null; + scrollbackLines: number; + theme: ITheme; + pendingModifiers: PendingTerminalModifiers; + swipeGesturesEnabled: boolean; + } + | { type: "unmount"; streamKey: string } + | { type: "writeOutput"; streamKey: string; text: string } + | { type: "renderSnapshot"; streamKey: string; state: TerminalState | null } + | { type: "clear"; streamKey: string } + | { type: "focus"; streamKey: string } + | { type: "resize"; streamKey: string } + | { type: "setTheme"; streamKey: string; theme: ITheme } + | { type: "setScrollback"; streamKey: string; lines: number } + | { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers } + | { type: "setSwipeGesturesEnabled"; streamKey: string; enabled: boolean }; + +type BridgeOutboundMessage = + | { type: "bridgeReady" } + | { type: "rendererReady"; streamKey: string; isReady: boolean } + | { type: "input"; streamKey: string; data: string } + | { type: "resize"; streamKey: string; rows: number; cols: number } + | { + type: "terminalKey"; + streamKey: string; + key: string; + ctrl: boolean; + shift: boolean; + alt: boolean; + meta: boolean; + } + | { type: "pendingModifiersConsumed"; streamKey: string } + | { type: "inputModeChange"; streamKey: string; state: TerminalInputModeState } + | { type: "openExternalUrl"; streamKey: string; url: string } + | { type: "swipeLeft"; streamKey: string } + | { type: "swipeRight"; streamKey: string } + | { type: "debug"; message: string; details?: unknown }; + +const TERMINAL_WEBVIEW_SOURCE = { html: terminalEmulatorWebViewHtml }; +const TERMINAL_WEBVIEW_ORIGIN_WHITELIST = ["*"]; +const BRIDGE_READY_TIMEOUT_MS = 2_500; +const RENDERER_READY_TIMEOUT_MS = 2_500; +type WebViewProps = ComponentProps; + +function buildThemeKey(theme: ITheme): string { + return JSON.stringify(theme); +} + +function serializeForInjectedJavaScript(message: BridgeInboundMessage): string { + return JSON.stringify(message).replace(/<\/script/gi, "<\\/script"); +} + +function createMountMessage(input: { + streamKey: string; + initialSnapshot: TerminalState | null; + scrollbackLines: number; + theme: ITheme; + pendingModifiers: PendingTerminalModifiers; + swipeGesturesEnabled: boolean; +}): BridgeInboundMessage { + return { + type: "mount", + streamKey: input.streamKey, + initialSnapshot: input.initialSnapshot, + scrollbackLines: input.scrollbackLines, + theme: input.theme, + pendingModifiers: input.pendingModifiers, + swipeGesturesEnabled: input.swipeGesturesEnabled, + }; +} + +export default function TerminalEmulator({ + ref, + streamKey, + testId = "terminal-surface", + xtermTheme = { + background: "#0b0b0b", + foreground: "#e6e6e6", + cursor: "#e6e6e6", + }, + scrollbackLines, + swipeGesturesEnabled = false, + onSwipeLeft, + onSwipeRight, + initialSnapshot = null, + onInput, + onResize, + onTerminalKey, + onPendingModifiersConsumed, + onInputModeChange, + onRendererReadyChange, + pendingModifiers = { ctrl: false, shift: false, alt: false }, + focusRequestToken = 0, + resizeRequestToken = 0, +}: TerminalEmulatorProps) { + const webViewRef = useRef(null); + const [webViewEpoch, setWebViewEpoch] = useState(0); + const [bridgeReadyVersion, setBridgeReadyVersion] = useState(0); + const bridgeReadyRef = useRef(false); + const bridgeReadyVersionRef = useRef(0); + const rendererReadyVersionRef = useRef(0); + const pendingMessagesRef = useRef([]); + const mountedStreamKeyRef = useRef(null); + const bridgeReadyTimeoutRef = useRef | null>(null); + const rendererReadyTimeoutRef = useRef | null>(null); + const mountConfigRef = useRef({ + streamKey, + initialSnapshot, + scrollbackLines, + theme: xtermTheme, + pendingModifiers, + swipeGesturesEnabled, + }); + mountConfigRef.current = { + streamKey, + initialSnapshot, + scrollbackLines, + theme: xtermTheme, + pendingModifiers, + swipeGesturesEnabled, + }; + const callbacksRef = useRef({ + onInput, + onResize, + onTerminalKey, + onPendingModifiersConsumed, + onInputModeChange, + onRendererReadyChange, + onSwipeLeft, + onSwipeRight, + }); + callbacksRef.current = { + onInput, + onResize, + onTerminalKey, + onPendingModifiersConsumed, + onInputModeChange, + onRendererReadyChange, + onSwipeLeft, + onSwipeRight, + }; + + const clearBridgeReadyTimeout = useCallback(() => { + if (bridgeReadyTimeoutRef.current === null) return; + clearTimeout(bridgeReadyTimeoutRef.current); + bridgeReadyTimeoutRef.current = null; + }, []); + + const clearRendererReadyTimeout = useCallback(() => { + if (rendererReadyTimeoutRef.current === null) return; + clearTimeout(rendererReadyTimeoutRef.current); + rendererReadyTimeoutRef.current = null; + }, []); + + const resetWebViewDocument = useCallback(() => { + clearBridgeReadyTimeout(); + clearRendererReadyTimeout(); + bridgeReadyRef.current = false; + pendingMessagesRef.current = []; + mountedStreamKeyRef.current = null; + callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false }); + setWebViewEpoch((value) => value + 1); + }, [clearBridgeReadyTimeout, clearRendererReadyTimeout, streamKey]); + + const scheduleBridgeReadyWatchdog = useCallback(() => { + clearBridgeReadyTimeout(); + const expectedBridgeReadyVersion = bridgeReadyVersionRef.current; + bridgeReadyTimeoutRef.current = setTimeout(() => { + bridgeReadyTimeoutRef.current = null; + if (bridgeReadyVersionRef.current !== expectedBridgeReadyVersion || bridgeReadyRef.current) { + return; + } + resetWebViewDocument(); + }, BRIDGE_READY_TIMEOUT_MS); + }, [clearBridgeReadyTimeout, resetWebViewDocument]); + + const scheduleRendererReadyWatchdog = useCallback(() => { + clearRendererReadyTimeout(); + const expectedRendererReadyVersion = rendererReadyVersionRef.current; + rendererReadyTimeoutRef.current = setTimeout(() => { + rendererReadyTimeoutRef.current = null; + if ( + rendererReadyVersionRef.current !== expectedRendererReadyVersion || + mountedStreamKeyRef.current === streamKey + ) { + return; + } + resetWebViewDocument(); + }, RENDERER_READY_TIMEOUT_MS); + }, [clearRendererReadyTimeout, resetWebViewDocument, streamKey]); + + const flushPendingMessages = useCallback(() => { + if (!bridgeReadyRef.current || !webViewRef.current) return; + const pending = pendingMessagesRef.current.splice(0); + for (const message of pending) { + const payload = serializeForInjectedJavaScript(message); + webViewRef.current.injectJavaScript( + `window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__ && window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__(${payload}); true;`, + ); + } + }, []); + + const sendToWebView = useCallback((message: BridgeInboundMessage) => { + if (!bridgeReadyRef.current || !webViewRef.current) { + pendingMessagesRef.current.push(message); + return; + } + const payload = serializeForInjectedJavaScript(message); + webViewRef.current.injectJavaScript( + `window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__ && window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__(${payload}); true;`, + ); + }, []); + + useImperativeHandle( + ref, + (): TerminalEmulatorHandle => ({ + writeOutput: (text: string) => { + sendToWebView({ type: "writeOutput", streamKey, text }); + }, + renderSnapshot: (state: TerminalState | null) => { + sendToWebView({ type: "renderSnapshot", streamKey, state }); + }, + clear: () => { + sendToWebView({ type: "clear", streamKey }); + }, + }), + [sendToWebView, streamKey], + ); + + useEffect(() => { + if (bridgeReadyVersion <= 0) return; + const mountMessage = createMountMessage(mountConfigRef.current); + mountedStreamKeyRef.current = streamKey; + sendToWebView(mountMessage); + flushPendingMessages(); + scheduleRendererReadyWatchdog(); + }, [ + bridgeReadyVersion, + flushPendingMessages, + scheduleRendererReadyWatchdog, + sendToWebView, + streamKey, + ]); + + const themeKey = useMemo(() => buildThemeKey(xtermTheme), [xtermTheme]); + useEffect(() => { + if (!mountedStreamKeyRef.current) return; + sendToWebView({ type: "setTheme", streamKey, theme: xtermTheme }); + }, [sendToWebView, streamKey, themeKey, xtermTheme]); + + useEffect(() => { + if (!mountedStreamKeyRef.current) return; + sendToWebView({ type: "setScrollback", streamKey, lines: scrollbackLines }); + }, [scrollbackLines, sendToWebView, streamKey]); + + useEffect(() => { + if (!mountedStreamKeyRef.current) return; + sendToWebView({ type: "setPendingModifiers", streamKey, pendingModifiers }); + }, [pendingModifiers, sendToWebView, streamKey]); + + useEffect(() => { + if (!mountedStreamKeyRef.current) return; + sendToWebView({ type: "setSwipeGesturesEnabled", streamKey, enabled: swipeGesturesEnabled }); + }, [sendToWebView, streamKey, swipeGesturesEnabled]); + + useEffect(() => { + if (focusRequestToken <= 0) return; + sendToWebView({ type: "resize", streamKey }); + sendToWebView({ type: "focus", streamKey }); + webViewRef.current?.requestFocus(); + }, [focusRequestToken, sendToWebView, streamKey]); + + useEffect(() => { + if (resizeRequestToken <= 0) return; + sendToWebView({ type: "resize", streamKey }); + }, [resizeRequestToken, sendToWebView, streamKey]); + + useEffect(() => { + return () => { + if (mountedStreamKeyRef.current) { + const previousStreamKey = mountedStreamKeyRef.current; + callbacksRef.current.onRendererReadyChange?.({ + streamKey: previousStreamKey, + isReady: false, + }); + sendToWebView({ type: "unmount", streamKey: previousStreamKey }); + } + bridgeReadyRef.current = false; + pendingMessagesRef.current = []; + mountedStreamKeyRef.current = null; + clearBridgeReadyTimeout(); + clearRendererReadyTimeout(); + }; + }, [clearBridgeReadyTimeout, clearRendererReadyTimeout, sendToWebView]); + + const handleLifecycleMessage = useCallback( + (message: BridgeOutboundMessage): boolean => { + if (message.type === "bridgeReady") { + bridgeReadyRef.current = true; + bridgeReadyVersionRef.current += 1; + clearBridgeReadyTimeout(); + setBridgeReadyVersion((value) => value + 1); + return true; + } + if (message.type === "rendererReady") { + mountedStreamKeyRef.current = message.isReady ? message.streamKey : null; + if (message.isReady) { + rendererReadyVersionRef.current += 1; + clearRendererReadyTimeout(); + } + callbacksRef.current.onRendererReadyChange?.({ + streamKey: message.streamKey, + isReady: message.isReady, + }); + return true; + } + return false; + }, + [clearBridgeReadyTimeout, clearRendererReadyTimeout], + ); + + const handleTerminalMessage = useCallback( + ( + message: Exclude, + ) => { + switch (message.type) { + case "input": + callbacksRef.current.onInput?.(message.data); + break; + case "resize": + callbacksRef.current.onResize?.({ rows: message.rows, cols: message.cols }); + break; + case "terminalKey": + callbacksRef.current.onTerminalKey?.({ + key: message.key, + ctrl: message.ctrl, + shift: message.shift, + alt: message.alt, + meta: message.meta, + }); + break; + case "pendingModifiersConsumed": + callbacksRef.current.onPendingModifiersConsumed?.(); + break; + case "inputModeChange": + callbacksRef.current.onInputModeChange?.(message.state); + break; + case "openExternalUrl": + void openExternalUrl(message.url); + break; + case "swipeLeft": + callbacksRef.current.onSwipeLeft?.(); + break; + case "swipeRight": + callbacksRef.current.onSwipeRight?.(); + break; + case "debug": + break; + } + }, + [], + ); + + const handleMessage = useCallback( + (event: WebViewMessageEvent) => { + let message: BridgeOutboundMessage; + try { + message = JSON.parse(event.nativeEvent.data) as BridgeOutboundMessage; + } catch { + return; + } + + if (message.type === "bridgeReady" || message.type === "rendererReady") { + handleLifecycleMessage(message); + return; + } + handleTerminalMessage(message); + }, + [handleLifecycleMessage, handleTerminalMessage], + ); + + const handleLoadStart = useCallback>(() => { + bridgeReadyRef.current = false; + mountedStreamKeyRef.current = null; + scheduleBridgeReadyWatchdog(); + }, [scheduleBridgeReadyWatchdog]); + + const handleContentProcessDidTerminate = useCallback< + NonNullable + >(() => { + resetWebViewDocument(); + }, [resetWebViewDocument]); + + const handleRenderProcessGone = useCallback< + NonNullable + >(() => { + resetWebViewDocument(); + }, [resetWebViewDocument]); + + const webViewStyle = useMemo>( + () => [styles.webView, { backgroundColor: xtermTheme.background ?? "#0b0b0b" }], + [xtermTheme.background], + ); + + return ( + + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + minHeight: 0, + minWidth: 0, + overflow: "hidden", + backgroundColor: "#0b0b0b", + }, + webView: { + flex: 1, + backgroundColor: "#0b0b0b", + }, + webViewContainer: { + flex: 1, + backgroundColor: "#0b0b0b", + }, +}); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index fa3ab1333..be2681780 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -714,13 +714,12 @@ const MobileMountedTabSlot = memo(function MobileMountedTabSlot({ [buildPaneContentModel, paneId, tabDescriptor], ); - const slotStyle = useMemo( - () => ({ display: isVisible ? ("flex" as const) : ("none" as const), flex: 1 }), - [isVisible], - ); + const slotStyle = isVisible + ? styles.mobileMountedTabSlotVisible + : styles.mobileMountedTabSlotHidden; return ( - + tabs.map((tab) => tab.tabId), [tabs]); const focusedPaneTabDescriptorMap = useStableTabDescriptorMap(tabs); const { mountedTabIds: mountedFocusedPaneTabIdsSet } = useMountedTabSet({ - activeTabId: activeTabDescriptor?.tabId ?? null, + activeTabId, allTabIds: focusedPaneTabIds, cap: 3, }); @@ -3654,6 +3653,15 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, minHeight: 0, backgroundColor: theme.colors.surface0, + position: "relative", + }, + mobileMountedTabSlotVisible: { + ...StyleSheet.absoluteFillObject, + opacity: 1, + }, + mobileMountedTabSlotHidden: { + ...StyleSheet.absoluteFillObject, + opacity: 0, }, contentPlaceholder: { flex: 1, diff --git a/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts b/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts new file mode 100644 index 000000000..20b7f4226 --- /dev/null +++ b/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts @@ -0,0 +1,301 @@ +import type { ITheme } from "@xterm/xterm"; +import xtermCss from "@xterm/xterm/css/xterm.css"; +import type { TerminalState } from "@server/shared/messages"; +import type { TerminalInputModeState } from "@server/shared/terminal-input-mode"; +import type { PendingTerminalModifiers } from "@/utils/terminal-keys"; +import { TerminalEmulatorRuntime } from "../runtime/terminal-emulator-runtime"; + +interface MountMessage { + type: "mount"; + streamKey: string; + initialSnapshot: TerminalState | null; + scrollbackLines: number; + theme: ITheme; + pendingModifiers: PendingTerminalModifiers; + swipeGesturesEnabled: boolean; +} + +type InboundMessage = + | MountMessage + | { type: "unmount"; streamKey: string } + | { type: "writeOutput"; streamKey: string; text: string } + | { type: "renderSnapshot"; streamKey: string; state: TerminalState | null } + | { type: "clear"; streamKey: string } + | { type: "focus"; streamKey: string } + | { type: "resize"; streamKey: string } + | { type: "setTheme"; streamKey: string; theme: ITheme } + | { type: "setScrollback"; streamKey: string; lines: number } + | { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers } + | { type: "setSwipeGesturesEnabled"; streamKey: string; enabled: boolean }; + +type OutboundMessage = + | { type: "bridgeReady" } + | { type: "rendererReady"; streamKey: string; isReady: boolean } + | { type: "input"; streamKey: string; data: string } + | { type: "resize"; streamKey: string; rows: number; cols: number } + | { + type: "terminalKey"; + streamKey: string; + key: string; + ctrl: boolean; + shift: boolean; + alt: boolean; + meta: boolean; + } + | { type: "pendingModifiersConsumed"; streamKey: string } + | { type: "inputModeChange"; streamKey: string; state: TerminalInputModeState } + | { type: "openExternalUrl"; streamKey: string; url: string } + | { type: "swipeLeft"; streamKey: string } + | { type: "swipeRight"; streamKey: string } + | { type: "debug"; message: string; details?: unknown }; + +declare global { + interface Window { + ReactNativeWebView?: { + postMessage?: (data: string) => void; + }; + __PASEO_TERMINAL_WEBVIEW_RECEIVE__?: (message: InboundMessage) => void; + } +} + +const sendToNative = (message: OutboundMessage): void => { + window.ReactNativeWebView?.postMessage?.(JSON.stringify(message)); +}; + +const installStyles = (): void => { + const style = document.createElement("style"); + style.textContent = ` +${xtermCss} +html, +body, +#terminal-root { + width: 100%; + height: 100%; + margin: 0; + padding: 0; + overflow: hidden; + overscroll-behavior: none; + background: #0b0b0b; +} +#terminal-root { + display: flex; + min-width: 0; + min-height: 0; +} +#terminal-host { + flex: 1; + min-width: 0; + min-height: 0; + width: 100%; + height: 100%; + overflow: hidden; +} +[data-terminal-scrollbar-root="true"] .xterm-viewport { + scrollbar-width: none; + -ms-overflow-style: none; +} +[data-terminal-scrollbar-root="true"] .xterm-viewport::-webkit-scrollbar { + width: 0; + height: 0; +} +`; + document.head.appendChild(style); +}; + +class TerminalWebViewBridge { + private runtime: TerminalEmulatorRuntime | null = null; + private streamKey: string | null = null; + private swipeGesturesEnabled = false; + private trackingSwipe = false; + private activePointerId: number | null = null; + private startX = 0; + private startY = 0; + private firedSwipe = false; + + constructor( + private readonly root: HTMLDivElement, + private readonly host: HTMLDivElement, + ) { + this.root.addEventListener("pointerdown", this.handlePointerDown, { passive: true }); + this.root.addEventListener("pointermove", this.handlePointerMove, { passive: false }); + this.root.addEventListener("pointerup", this.handlePointerUp, { passive: true }); + this.root.addEventListener("pointercancel", this.handlePointerUp, { passive: true }); + } + + receive = (message: InboundMessage): void => { + try { + this.receiveUnsafe(message); + } catch (error) { + sendToNative({ + type: "debug", + message: "terminal webview receive failed", + details: error instanceof Error ? { message: error.message, stack: error.stack } : error, + }); + } + }; + + private receiveUnsafe(message: InboundMessage): void { + if (message.type === "mount") { + this.mount(message); + return; + } + if (message.type === "unmount") { + this.unmount(message.streamKey); + return; + } + if (!this.matches(message.streamKey)) { + return; + } + this.receiveMounted(message); + } + + private receiveMounted( + message: Exclude, + ): void { + switch (message.type) { + case "writeOutput": + this.runtime?.write({ text: message.text }); + break; + case "renderSnapshot": + this.runtime?.renderSnapshot({ state: message.state }); + break; + case "clear": + this.runtime?.clear(); + break; + case "focus": + this.runtime?.focus(); + break; + case "resize": + this.runtime?.resize({ force: true }); + break; + case "setTheme": + this.runtime?.setTheme({ theme: message.theme }); + break; + case "setScrollback": + this.runtime?.setScrollback({ lines: message.lines }); + break; + case "setPendingModifiers": + this.runtime?.setPendingModifiers({ pendingModifiers: message.pendingModifiers }); + break; + case "setSwipeGesturesEnabled": + this.swipeGesturesEnabled = message.enabled; + break; + } + } + + private mount(message: MountMessage): void { + this.unmount(this.streamKey); + this.streamKey = message.streamKey; + this.swipeGesturesEnabled = message.swipeGesturesEnabled; + document.body.style.backgroundColor = message.theme.background ?? "#0b0b0b"; + + const runtime = new TerminalEmulatorRuntime(); + this.runtime = runtime; + runtime.setCallbacks({ + callbacks: { + onInput: (data) => sendToNative({ type: "input", streamKey: message.streamKey, data }), + onResize: ({ rows, cols }) => + sendToNative({ type: "resize", streamKey: message.streamKey, rows, cols }), + onTerminalKey: (input) => + sendToNative({ type: "terminalKey", streamKey: message.streamKey, ...input }), + onPendingModifiersConsumed: () => + sendToNative({ type: "pendingModifiersConsumed", streamKey: message.streamKey }), + onInputModeChange: (state) => + sendToNative({ type: "inputModeChange", streamKey: message.streamKey, state }), + onOpenExternalUrl: (url) => + sendToNative({ type: "openExternalUrl", streamKey: message.streamKey, url }), + }, + }); + runtime.setPendingModifiers({ pendingModifiers: message.pendingModifiers }); + runtime.mount({ + root: this.root, + host: this.host, + initialSnapshot: message.initialSnapshot, + scrollback: message.scrollbackLines, + theme: message.theme, + }); + sendToNative({ type: "rendererReady", streamKey: message.streamKey, isReady: true }); + } + + private unmount(streamKey: string | null): void { + if (!this.runtime) { + return; + } + const previousStreamKey = this.streamKey; + this.runtime.unmount(); + this.runtime = null; + this.streamKey = null; + if (previousStreamKey && (!streamKey || streamKey === previousStreamKey)) { + sendToNative({ type: "rendererReady", streamKey: previousStreamKey, isReady: false }); + } + } + + private matches(streamKey: string): boolean { + return this.streamKey === streamKey; + } + + private handlePointerDown = (event: PointerEvent): void => { + if (!this.swipeGesturesEnabled || !event.isPrimary) { + return; + } + this.trackingSwipe = true; + this.firedSwipe = false; + this.activePointerId = event.pointerId; + this.startX = event.clientX; + this.startY = event.clientY; + }; + + private handlePointerMove = (event: PointerEvent): void => { + if (!this.trackingSwipe || this.firedSwipe || !this.streamKey) { + return; + } + if (this.activePointerId !== null && event.pointerId !== this.activePointerId) { + return; + } + + const dx = event.clientX - this.startX; + const dy = event.clientY - this.startY; + const absDx = Math.abs(dx); + const absDy = Math.abs(dy); + if (absDy >= 12 && absDy > absDx) { + this.resetSwipe(); + return; + } + if (absDx < 22 || (absDy !== 0 && absDx / absDy < 1.2)) { + return; + } + + this.firedSwipe = true; + sendToNative({ type: dx > 0 ? "swipeRight" : "swipeLeft", streamKey: this.streamKey }); + if (event.cancelable) event.preventDefault(); + }; + + private handlePointerUp = (event: PointerEvent): void => { + if (this.activePointerId !== null && event.pointerId !== this.activePointerId) { + return; + } + this.resetSwipe(); + }; + + private resetSwipe(): void { + this.trackingSwipe = false; + this.activePointerId = null; + this.startX = 0; + this.startY = 0; + this.firedSwipe = false; + } +} + +installStyles(); + +const root = document.createElement("div"); +root.id = "terminal-root"; +root.dataset.terminalScrollbarRoot = "true"; +const host = document.createElement("div"); +host.id = "terminal-host"; +root.appendChild(host); +document.body.appendChild(root); + +const bridge = new TerminalWebViewBridge(root, host); +window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__ = bridge.receive; +sendToNative({ type: "bridgeReady" }); diff --git a/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts b/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts new file mode 100644 index 000000000..6ff3a7554 --- /dev/null +++ b/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts @@ -0,0 +1,5 @@ +// Generated by packages/app/scripts/build-terminal-webview-html.mjs. +// Do not edit by hand. + +export const terminalEmulatorWebViewHtml = + '\n\n \n \n \n \n \n \n \n';