392 lines
12 KiB
TypeScript
392 lines
12 KiB
TypeScript
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 {
|
|
encodeTerminalOutput,
|
|
TerminalEmulatorRuntime,
|
|
} from "../runtime/terminal-emulator-runtime";
|
|
import type {
|
|
TerminalLocalFileLinkSource,
|
|
TerminalLocalFileLinkTarget,
|
|
} from "../local-links/terminal-local-link-provider";
|
|
|
|
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: "restoreOutput"; 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: "resolveLocalFileLinkResponse";
|
|
streamKey: string;
|
|
requestId: number;
|
|
target: TerminalLocalFileLinkTarget | null;
|
|
};
|
|
|
|
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: "resolveLocalFileLink";
|
|
streamKey: string;
|
|
requestId: number;
|
|
source: TerminalLocalFileLinkSource;
|
|
}
|
|
| {
|
|
type: "openLocalFileLink";
|
|
streamKey: string;
|
|
target: TerminalLocalFileLinkTarget;
|
|
disposition: "main" | "side";
|
|
}
|
|
| { 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 nextLocalFileLinkRequestId = 1;
|
|
private readonly pendingLocalFileLinkResolutions = new Map<
|
|
number,
|
|
(target: TerminalLocalFileLinkTarget | null) => void
|
|
>();
|
|
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;
|
|
}
|
|
if (message.type === "resolveLocalFileLinkResponse") {
|
|
this.resolveLocalFileLinkRequest(message.requestId, message.target);
|
|
return;
|
|
}
|
|
this.receiveMounted(message);
|
|
}
|
|
|
|
private receiveMounted(
|
|
message: Exclude<
|
|
InboundMessage,
|
|
MountMessage | { type: "unmount" } | { type: "resolveLocalFileLinkResponse" }
|
|
>,
|
|
): void {
|
|
switch (message.type) {
|
|
case "writeOutput":
|
|
this.runtime?.write({ data: encodeTerminalOutput(message.text) });
|
|
break;
|
|
case "restoreOutput":
|
|
this.runtime?.restoreOutput({ data: encodeTerminalOutput(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 }),
|
|
onResolveLocalFileLink: (source) => this.requestLocalFileLinkResolution(source),
|
|
onOpenLocalFileLink: (target, disposition) =>
|
|
sendToNative({
|
|
type: "openLocalFileLink",
|
|
streamKey: message.streamKey,
|
|
target,
|
|
disposition,
|
|
}),
|
|
},
|
|
});
|
|
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;
|
|
this.resolveAllLocalFileLinkRequests(null);
|
|
if (previousStreamKey && (!streamKey || streamKey === previousStreamKey)) {
|
|
sendToNative({ type: "rendererReady", streamKey: previousStreamKey, isReady: false });
|
|
}
|
|
}
|
|
|
|
private matches(streamKey: string): boolean {
|
|
return this.streamKey === streamKey;
|
|
}
|
|
|
|
private requestLocalFileLinkResolution(
|
|
source: TerminalLocalFileLinkSource,
|
|
): Promise<TerminalLocalFileLinkTarget | null> {
|
|
const streamKey = this.streamKey;
|
|
if (!streamKey) {
|
|
return Promise.resolve(null);
|
|
}
|
|
|
|
const requestId = this.nextLocalFileLinkRequestId++;
|
|
return new Promise((resolve) => {
|
|
this.pendingLocalFileLinkResolutions.set(requestId, resolve);
|
|
sendToNative({
|
|
type: "resolveLocalFileLink",
|
|
streamKey,
|
|
requestId,
|
|
source,
|
|
});
|
|
});
|
|
}
|
|
|
|
private resolveLocalFileLinkRequest(
|
|
requestId: number,
|
|
target: TerminalLocalFileLinkTarget | null,
|
|
): void {
|
|
const resolve = this.pendingLocalFileLinkResolutions.get(requestId);
|
|
if (!resolve) {
|
|
return;
|
|
}
|
|
this.pendingLocalFileLinkResolutions.delete(requestId);
|
|
resolve(target);
|
|
}
|
|
|
|
private resolveAllLocalFileLinkRequests(target: TerminalLocalFileLinkTarget | null): void {
|
|
const requests = Array.from(this.pendingLocalFileLinkResolutions.values());
|
|
this.pendingLocalFileLinkResolutions.clear();
|
|
for (const resolve of requests) {
|
|
resolve(target);
|
|
}
|
|
}
|
|
|
|
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" });
|