feat(app): native terminal on WebView with xterm.js
Replace the Expo DOM-backed terminal on iOS and Android with a managed WebView running xterm.js. Web and desktop continue to use the existing DOM implementation. Mobile mounted tabs switch from display:none to opacity:0 for the hidden slot on purpose: terminals stay mounted under the LRU tab cache, and keeping the WebView in the layer tree avoids cold-starting it every time the user returns to a terminal tab. EAS native builds rebuild the WebView bundle post-install so the generated HTML stays in sync with the entry source. This does not fix the WS 1006 / NSPOSIXErrorDomain Code=54 host-disconnect symptom that prompted the investigation. After restoring the baseline, that symptom could not be replicated; do not read this commit as its root cause or fix.
This commit is contained in:
@@ -5,9 +5,10 @@
|
|||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "cross-env APP_VARIANT=development expo start",
|
"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",
|
"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",
|
"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": "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: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",
|
"android:production": "cross-env APP_VARIANT=production expo prebuild --platform android --non-interactive && cross-env APP_VARIANT=production expo run:android --variant=release",
|
||||||
|
|||||||
@@ -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 = `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script>${js}</script>
|
||||||
|
</body>
|
||||||
|
</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)`);
|
||||||
@@ -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<TerminalEmulatorHandle>;
|
||||||
|
streamKey: string;
|
||||||
|
testId?: string;
|
||||||
|
xtermTheme?: ITheme;
|
||||||
|
scrollbackLines: number;
|
||||||
|
swipeGesturesEnabled?: boolean;
|
||||||
|
onSwipeLeft?: () => void;
|
||||||
|
onSwipeRight?: () => void;
|
||||||
|
initialSnapshot?: TerminalState | null;
|
||||||
|
onInput?: (data: string) => Promise<void> | void;
|
||||||
|
onResize?: (input: { rows: number; cols: number }) => Promise<void> | void;
|
||||||
|
onTerminalKey?: (input: {
|
||||||
|
key: string;
|
||||||
|
ctrl: boolean;
|
||||||
|
shift: boolean;
|
||||||
|
alt: boolean;
|
||||||
|
meta: boolean;
|
||||||
|
}) => Promise<void> | void;
|
||||||
|
onPendingModifiersConsumed?: () => Promise<void> | void;
|
||||||
|
onInputModeChange?: (state: TerminalInputModeState) => Promise<void> | 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<typeof WebView>;
|
||||||
|
|
||||||
|
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<WebView>(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<BridgeInboundMessage[]>([]);
|
||||||
|
const mountedStreamKeyRef = useRef<string | null>(null);
|
||||||
|
const bridgeReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const rendererReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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<BridgeOutboundMessage, { type: "bridgeReady" } | { type: "rendererReady" }>,
|
||||||
|
) => {
|
||||||
|
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<NonNullable<WebViewProps["onLoadStart"]>>(() => {
|
||||||
|
bridgeReadyRef.current = false;
|
||||||
|
mountedStreamKeyRef.current = null;
|
||||||
|
scheduleBridgeReadyWatchdog();
|
||||||
|
}, [scheduleBridgeReadyWatchdog]);
|
||||||
|
|
||||||
|
const handleContentProcessDidTerminate = useCallback<
|
||||||
|
NonNullable<WebViewProps["onContentProcessDidTerminate"]>
|
||||||
|
>(() => {
|
||||||
|
resetWebViewDocument();
|
||||||
|
}, [resetWebViewDocument]);
|
||||||
|
|
||||||
|
const handleRenderProcessGone = useCallback<
|
||||||
|
NonNullable<WebViewProps["onRenderProcessGone"]>
|
||||||
|
>(() => {
|
||||||
|
resetWebViewDocument();
|
||||||
|
}, [resetWebViewDocument]);
|
||||||
|
|
||||||
|
const webViewStyle = useMemo<StyleProp<ViewStyle>>(
|
||||||
|
() => [styles.webView, { backgroundColor: xtermTheme.background ?? "#0b0b0b" }],
|
||||||
|
[xtermTheme.background],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.root} testID={testId}>
|
||||||
|
<WebView
|
||||||
|
key={webViewEpoch}
|
||||||
|
ref={webViewRef}
|
||||||
|
source={TERMINAL_WEBVIEW_SOURCE}
|
||||||
|
style={webViewStyle}
|
||||||
|
containerStyle={styles.webViewContainer}
|
||||||
|
originWhitelist={TERMINAL_WEBVIEW_ORIGIN_WHITELIST}
|
||||||
|
scrollEnabled
|
||||||
|
nestedScrollEnabled
|
||||||
|
bounces={false}
|
||||||
|
overScrollMode="never"
|
||||||
|
keyboardDisplayRequiresUserAction={false}
|
||||||
|
automaticallyAdjustContentInsets={false}
|
||||||
|
contentInsetAdjustmentBehavior="never"
|
||||||
|
textInteractionEnabled={false}
|
||||||
|
allowsLinkPreview={false}
|
||||||
|
setSupportMultipleWindows={false}
|
||||||
|
setBuiltInZoomControls={false}
|
||||||
|
setDisplayZoomControls={false}
|
||||||
|
textZoom={100}
|
||||||
|
onMessage={handleMessage}
|
||||||
|
onLoadStart={handleLoadStart}
|
||||||
|
onContentProcessDidTerminate={handleContentProcessDidTerminate}
|
||||||
|
onRenderProcessGone={handleRenderProcessGone}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -714,13 +714,12 @@ const MobileMountedTabSlot = memo(function MobileMountedTabSlot({
|
|||||||
[buildPaneContentModel, paneId, tabDescriptor],
|
[buildPaneContentModel, paneId, tabDescriptor],
|
||||||
);
|
);
|
||||||
|
|
||||||
const slotStyle = useMemo(
|
const slotStyle = isVisible
|
||||||
() => ({ display: isVisible ? ("flex" as const) : ("none" as const), flex: 1 }),
|
? styles.mobileMountedTabSlotVisible
|
||||||
[isVisible],
|
: styles.mobileMountedTabSlotHidden;
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={slotStyle}>
|
<View style={slotStyle} pointerEvents={isVisible ? "auto" : "none"}>
|
||||||
<WorkspacePaneContent
|
<WorkspacePaneContent
|
||||||
content={content}
|
content={content}
|
||||||
isWorkspaceFocused={isWorkspaceFocused}
|
isWorkspaceFocused={isWorkspaceFocused}
|
||||||
@@ -2854,7 +2853,7 @@ function WorkspaceScreenContent({
|
|||||||
const focusedPaneTabIds = useMemo(() => tabs.map((tab) => tab.tabId), [tabs]);
|
const focusedPaneTabIds = useMemo(() => tabs.map((tab) => tab.tabId), [tabs]);
|
||||||
const focusedPaneTabDescriptorMap = useStableTabDescriptorMap(tabs);
|
const focusedPaneTabDescriptorMap = useStableTabDescriptorMap(tabs);
|
||||||
const { mountedTabIds: mountedFocusedPaneTabIdsSet } = useMountedTabSet({
|
const { mountedTabIds: mountedFocusedPaneTabIdsSet } = useMountedTabSet({
|
||||||
activeTabId: activeTabDescriptor?.tabId ?? null,
|
activeTabId,
|
||||||
allTabIds: focusedPaneTabIds,
|
allTabIds: focusedPaneTabIds,
|
||||||
cap: 3,
|
cap: 3,
|
||||||
});
|
});
|
||||||
@@ -3654,6 +3653,15 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
backgroundColor: theme.colors.surface0,
|
backgroundColor: theme.colors.surface0,
|
||||||
|
position: "relative",
|
||||||
|
},
|
||||||
|
mobileMountedTabSlotVisible: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
mobileMountedTabSlotHidden: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
opacity: 0,
|
||||||
},
|
},
|
||||||
contentPlaceholder: {
|
contentPlaceholder: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -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<InboundMessage, MountMessage | { type: "unmount" }>,
|
||||||
|
): 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" });
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user