From 04d7c08b7813a1cf50e8008783bcbc66a9700669 Mon Sep 17 00:00:00 2001 From: Simon Chan <1330321+yume-chan@users.noreply.github.com> Date: Sun, 23 Apr 2023 23:24:29 +0800 Subject: [PATCH] feat(demo): use tabby as terminal emulator (#541) --- apps/demo/package.json | 17 +- apps/demo/scripts/manifest.mjs | 24 + apps/demo/scripts/postinstall.js | 19 - apps/demo/src/components/index.ts | 21 +- .../src/components/tabby-frame-manager.tsx | 80 ++ apps/demo/src/components/terminal.tsx | 112 -- apps/demo/src/pages/chrome-devtools-frame.tsx | 3 + apps/demo/src/pages/shell.tsx | 182 +-- apps/demo/src/pages/tabby-frame.tsx | 235 ++++ common/config/rush/pnpm-lock.yaml | 1055 ++++++++++++++++- common/config/rush/repo-state.json | 2 +- .../adb-backend-direct-sockets/package.json | 2 +- libraries/adb-backend-proxy/.eslintrc.cjs | 11 + libraries/adb-backend-proxy/.npmignore | 16 + libraries/adb-backend-proxy/README.md | 5 + libraries/adb-backend-proxy/package.json | 49 + libraries/adb-backend-proxy/src/client.ts | 104 ++ libraries/adb-backend-proxy/src/index.ts | 2 + libraries/adb-backend-proxy/src/server.ts | 267 +++++ .../adb-backend-proxy/tsconfig.build.json | 9 + libraries/adb-backend-proxy/tsconfig.json | 19 + libraries/adb-backend-webusb/package.json | 2 +- libraries/adb-backend-ws/package.json | 2 +- libraries/adb-credential-web/package.json | 2 +- libraries/adb-scrcpy/package.json | 2 +- libraries/adb/package.json | 2 +- libraries/adb/src/adb.ts | 6 + libraries/android-bin/package.json | 2 +- libraries/b-tree/package.json | 2 +- .../dataview-bigint-polyfill/package.json | 2 +- libraries/event/package.json | 2 +- libraries/pcm-player/README.md | 29 +- libraries/pcm-player/package.json | 2 +- .../scrcpy-decoder-tinyh264/package.json | 2 +- .../scrcpy-decoder-webcodecs/package.json | 2 +- libraries/scrcpy/package.json | 2 +- libraries/stream-extra/package.json | 2 +- libraries/stream-extra/src/consumable.ts | 4 +- libraries/struct/package.json | 2 +- libraries/tabby-tango/package.json | 42 + libraries/tabby-tango/src/buttonProvider.ts | 44 + .../src/components/terminalTab.component.ts | 44 + libraries/tabby-tango/src/icons/plus.svg | 1 + libraries/tabby-tango/src/index.ts | 36 + libraries/tabby-tango/src/profiles.ts | 41 + libraries/tabby-tango/src/session.ts | 65 + libraries/tabby-tango/src/state.ts | 6 + libraries/tabby-tango/tsconfig.json | 37 + libraries/tabby-tango/webpack.config.mjs | 10 + .../tabby-tango/webpack.plugin.config.mjs | 182 +++ rush.json | 8 + 51 files changed, 2429 insertions(+), 390 deletions(-) delete mode 100644 apps/demo/scripts/postinstall.js create mode 100644 apps/demo/src/components/tabby-frame-manager.tsx delete mode 100644 apps/demo/src/components/terminal.tsx create mode 100644 apps/demo/src/pages/tabby-frame.tsx create mode 100644 libraries/adb-backend-proxy/.eslintrc.cjs create mode 100644 libraries/adb-backend-proxy/.npmignore create mode 100644 libraries/adb-backend-proxy/README.md create mode 100644 libraries/adb-backend-proxy/package.json create mode 100644 libraries/adb-backend-proxy/src/client.ts create mode 100644 libraries/adb-backend-proxy/src/index.ts create mode 100644 libraries/adb-backend-proxy/src/server.ts create mode 100644 libraries/adb-backend-proxy/tsconfig.build.json create mode 100644 libraries/adb-backend-proxy/tsconfig.json create mode 100644 libraries/tabby-tango/package.json create mode 100644 libraries/tabby-tango/src/buttonProvider.ts create mode 100644 libraries/tabby-tango/src/components/terminalTab.component.ts create mode 100644 libraries/tabby-tango/src/icons/plus.svg create mode 100644 libraries/tabby-tango/src/index.ts create mode 100644 libraries/tabby-tango/src/profiles.ts create mode 100644 libraries/tabby-tango/src/session.ts create mode 100644 libraries/tabby-tango/src/state.ts create mode 100644 libraries/tabby-tango/tsconfig.json create mode 100644 libraries/tabby-tango/webpack.config.mjs create mode 100644 libraries/tabby-tango/webpack.plugin.config.mjs diff --git a/apps/demo/package.json b/apps/demo/package.json index f988ec0e..7d18cd31 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -10,6 +10,7 @@ "lint": "next lint" }, "dependencies": { + "@angular/compiler": "^15.2.6", "@fluentui/react": "^8.107.5", "@fluentui/react-file-type-icons": "^8.8.13", "@fluentui/react-hooks": "^8.6.20", @@ -18,6 +19,7 @@ "@griffel/react": "^1.5.7", "@yume-chan/adb": "workspace:^0.0.19", "@yume-chan/adb-backend-direct-sockets": "workspace:^0.0.9", + "@yume-chan/adb-backend-proxy": "workspace:^0.0.9", "@yume-chan/adb-backend-webusb": "workspace:^0.0.19", "@yume-chan/adb-backend-ws": "workspace:^0.0.9", "@yume-chan/adb-credential-web": "workspace:^0.0.19", @@ -34,18 +36,23 @@ "@yume-chan/stream-extra": "workspace:^0.0.19", "@yume-chan/stream-saver": "^2.0.6", "@yume-chan/struct": "workspace:^0.0.19", + "@yume-chan/tabby-tango": "workspace:^0.0.19", "@yume-chan/undici-browser": "5.21.2-mod.9", "fflate": "^0.7.4", + "yaml": "^2.2.1", "mobx": "^6.7.0", "mobx-react-lite": "^3.4.3", "next": "13.3.0", + "tabby-core": "^1.0.197-nightly.0", + "tabby-settings": "^1.0.197-nightly.0", + "tabby-terminal": "^1.0.197-nightly.0", + "tabby-community-color-schemes": "^1.0.197-nightly.0", + "tabby-web": "^1.0.197-nightly.0", + "tabby-web-container": "^1.0.197-nightly.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "webm-muxer": "^2.2.3", - "xterm": "^5.1.0", - "xterm-addon-fit": "^0.7.0", - "xterm-addon-search": "^0.11.0", - "xterm-addon-webgl": "^0.14.0" + "rxjs": "^7.8.0", + "webm-muxer": "^2.2.3" }, "devDependencies": { "@mdx-js/loader": "^2.2.1", diff --git a/apps/demo/scripts/manifest.mjs b/apps/demo/scripts/manifest.mjs index 33a57edc..c59a7419 100644 --- a/apps/demo/scripts/manifest.mjs +++ b/apps/demo/scripts/manifest.mjs @@ -27,3 +27,27 @@ fs.writeFileSync( ), "utf8" ); + +fs.writeFileSync( + new URL( + "../node_modules/tabby-web-container/dist/preload.mjs", + import.meta.url + ), + "export {};\n" + + fs + .readFileSync( + new URL( + "../node_modules/tabby-web-container/dist/preload.js", + import.meta.url + ), + "utf8" + ) + .replaceAll(/__webpack_require__\.p \+ "(.+)"/g, (_, match) => { + return `new URL("./${match}", import.meta.url).toString()`; + }) + .replaceAll(/__webpack_require__/g, "__webpack_require_nested__") + .replace( + "var scriptUrl;", + "var scriptUrl = import.meta.url.toString();" + ) +); diff --git a/apps/demo/scripts/postinstall.js b/apps/demo/scripts/postinstall.js deleted file mode 100644 index f2d92454..00000000 --- a/apps/demo/scripts/postinstall.js +++ /dev/null @@ -1,19 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -const PublicFolder = path.resolve(__dirname, '..', 'public'); - -const SourceFolder = path.dirname(require.resolve('streamsaver')); - -const DistFolder = path.resolve(PublicFolder, 'StreamSaver'); - -if (!fs.existsSync(DistFolder)) { - fs.mkdirSync(DistFolder); -} - -function copyFile(name) { - fs.copyFileSync(path.resolve(SourceFolder, name), path.resolve(DistFolder, name)); -} - -copyFile('mitm.html'); -copyFile('sw.js'); diff --git a/apps/demo/src/components/index.ts b/apps/demo/src/components/index.ts index 662793ae..ed4285e5 100644 --- a/apps/demo/src/components/index.ts +++ b/apps/demo/src/components/index.ts @@ -1,11 +1,12 @@ -export * from './command-bar'; -export * from './connect'; -export * from './demo-mode-panel'; -export * from './device-view'; -export * from './error-dialog'; -export * from './external-link'; -export * from './grid'; -export * from './hex-viewer'; +export * from "./command-bar"; +export * from "./connect"; +export * from "./demo-mode-panel"; +export * from "./device-view"; +export * from "./error-dialog"; +export * from "./external-link"; +export * from "./grid"; +export * from "./hex-viewer"; export * from "./list-selection"; -export * from './log-view'; -export * from './resize-observer'; +export * from "./log-view"; +export * from "./resize-observer"; +export * from "./tabby-frame-manager"; diff --git a/apps/demo/src/components/tabby-frame-manager.tsx b/apps/demo/src/components/tabby-frame-manager.tsx new file mode 100644 index 00000000..00e3c740 --- /dev/null +++ b/apps/demo/src/components/tabby-frame-manager.tsx @@ -0,0 +1,80 @@ +import { AdbProxyServer } from "@yume-chan/adb-backend-proxy"; +import { autorun } from "mobx"; +import getConfig from "next/config"; +import { GLOBAL_STATE } from "../state"; + +let proxy: AdbProxyServer | undefined; +let resizeObserver: ResizeObserver | undefined; +let frame: HTMLIFrameElement | undefined; + +function syncDevice() { + if (proxy) { + proxy.dispose(); + proxy = undefined; + } + + if (GLOBAL_STATE.device && frame) { + const proxy = new AdbProxyServer(GLOBAL_STATE.device); + const info = proxy.createPort(); + frame.contentWindow?.postMessage( + { + type: "adb", + ...info, + }, + "*", + [info.port] + ); + } +} + +export function attachTabbyFrame(container: HTMLDivElement | null) { + if (container === null) { + if (resizeObserver !== undefined) { + resizeObserver.disconnect(); + } + if (frame !== undefined) { + frame.style.visibility = "hidden"; + } + return; + } + + if (!frame) { + const { + publicRuntimeConfig: { basePath }, + } = getConfig(); + + frame = document.createElement("iframe"); + frame.src = `${basePath}/tabby-frame`; + frame.style.display = "block"; + frame.style.position = "fixed"; + frame.style.border = "none"; + document.body.appendChild(frame); + + window.addEventListener("message", (e) => { + // Wait for Tabby to be ready + if (e.source === frame?.contentWindow && e.data === "adb") { + syncDevice(); + } + }); + + // Sync device when it's changed + autorun(syncDevice); + } + + // Because re-parent an iframe will cause it to reload, + // use visibility to show/hide it + // and use a ResizeObserver to put it in the right place. + frame.style.visibility = "visible"; + resizeObserver = new ResizeObserver(() => { + const { top, left, width, height } = container.getBoundingClientRect(); + if (width === 0 || height === 0) { + // zero size makes xterm.js wrap lines incorrectly + return; + } + frame!.style.top = `${top}px`; + frame!.style.left = `${left}px`; + frame!.style.width = `${width}px`; + frame!.style.height = `${height}px`; + }); + resizeObserver.observe(container); +} diff --git a/apps/demo/src/components/terminal.tsx b/apps/demo/src/components/terminal.tsx deleted file mode 100644 index 7ebc1e8c..00000000 --- a/apps/demo/src/components/terminal.tsx +++ /dev/null @@ -1,112 +0,0 @@ -// cspell: ignore scrollback - -import { AdbSubprocessProtocol, encodeUtf8 } from "@yume-chan/adb"; -import { AutoDisposable } from "@yume-chan/event"; -import { - AbortController, - Consumable, - WritableStream, -} from "@yume-chan/stream-extra"; -import { Terminal } from "xterm"; -import { FitAddon } from "xterm-addon-fit"; -import { SearchAddon } from "xterm-addon-search"; -import { WebglAddon } from "xterm-addon-webgl"; - -export class AdbTerminal extends AutoDisposable { - private element = document.createElement("div"); - - public terminal: Terminal = new Terminal({ - allowProposedApi: true, - allowTransparency: true, - cursorStyle: "bar", - cursorBlink: true, - fontFamily: - '"Cascadia Code", Consolas, monospace, "Source Han Sans SC", "Microsoft YaHei"', - letterSpacing: 1, - scrollback: 9000, - smoothScrollDuration: 50, - overviewRulerWidth: 20, - }); - - public searchAddon = new SearchAddon(); - - private readonly fitAddon = new FitAddon(); - - private _socket: AdbSubprocessProtocol | undefined; - private _socketAbortController: AbortController | undefined; - public get socket() { - return this._socket; - } - public set socket(value) { - if (this._socket) { - // Remove event listeners - this.dispose(); - this._socketAbortController?.abort(); - } - - this._socket = value; - - if (value) { - this.terminal.clear(); - this.terminal.reset(); - - this._socketAbortController = new AbortController(); - - // pty mode only has one stream - value.stdout - .pipeTo( - new WritableStream({ - write: (chunk) => { - this.terminal.write(chunk); - }, - }), - { - signal: this._socketAbortController.signal, - } - ) - .catch(() => {}); - - const _writer = value.stdin.getWriter(); - this.addDisposable( - this.terminal.onData((data) => { - const buffer = encodeUtf8(data); - const output = new Consumable(buffer); - _writer.write(output); - }) - ); - - this.fit(); - } - } - - public constructor() { - super(); - - this.element.style.width = "100%"; - this.element.style.height = "100%"; - this.element.style.overflow = "hidden"; - - this.terminal.loadAddon(this.searchAddon); - this.terminal.loadAddon(this.fitAddon); - } - - public setContainer(container: HTMLDivElement) { - container.appendChild(this.element); - if (!this.terminal.element) { - void this.element.offsetWidth; - this.terminal.open(this.element); - // WebGL addon requires terminal to be attached to DOM - this.terminal.loadAddon(new WebglAddon()); - // WebGL renderer ignores `cursorBlink` set before it initialized - this.terminal.options.cursorBlink = true; - this.fit(); - } - } - - public fit() { - this.fitAddon.fit(); - // Resize remote terminal - const { rows, cols } = this.terminal; - this._socket?.resize(rows, cols); - } -} diff --git a/apps/demo/src/pages/chrome-devtools-frame.tsx b/apps/demo/src/pages/chrome-devtools-frame.tsx index ceea1d63..3c66d3e1 100644 --- a/apps/demo/src/pages/chrome-devtools-frame.tsx +++ b/apps/demo/src/pages/chrome-devtools-frame.tsx @@ -105,6 +105,7 @@ function ChromeDevToolsFrame() { document.body.appendChild(script); }, []); + // DevTools will set `document.title` to debugged page's title. return ( ); } + ChromeDevToolsFrame.noLayout = true; + export default ChromeDevToolsFrame; diff --git a/apps/demo/src/pages/shell.tsx b/apps/demo/src/pages/shell.tsx index a0dff1c5..a8314004 100644 --- a/apps/demo/src/pages/shell.tsx +++ b/apps/demo/src/pages/shell.tsx @@ -1,192 +1,40 @@ -import { IconButton, SearchBox, Stack, StackItem } from "@fluentui/react"; -import { makeStyles, shorthands } from "@griffel/react"; -import { action, autorun, makeAutoObservable, runInAction } from "mobx"; +import { makeStyles } from "@griffel/react"; import { observer } from "mobx-react-lite"; import { NextPage } from "next"; import Head from "next/head"; -import { useCallback, useEffect } from "react"; -import { ISearchOptions } from "xterm-addon-search"; -import "xterm/css/xterm.css"; -import { ResizeObserver } from "../components"; -import { GLOBAL_STATE } from "../state"; -import { Icons, RouteStackProps } from "../utils"; +import { useCallback } from "react"; +import { attachTabbyFrame } from "../components"; const useClasses = makeStyles({ - count: { - ...shorthands.padding("0", "8px"), + container: { + height: "100%", + display: "flex", + alignItems: "center", + justifyContent: "center", }, }); -let terminal: import("../components/terminal").AdbTerminal | undefined; -if (typeof window !== "undefined") { - const { AdbTerminal } = require("../components/terminal"); - terminal = new AdbTerminal(); -} - -const SEARCH_OPTIONS: ISearchOptions = { - decorations: { - matchBackground: "#42557b", - matchOverviewRuler: "#d18616", - activeMatchBackground: "#6199ff2f", - activeMatchColorOverviewRuler: "#d186167e", - }, -}; - -const state = makeAutoObservable( - { - visible: false, - index: undefined as number | undefined, - count: undefined as number | undefined, - setVisible(value: boolean) { - this.visible = value; - }, - - searchKeyword: "", - setSearchKeyword(value: string) { - this.searchKeyword = value; - terminal!.searchAddon.findNext(value, { - ...SEARCH_OPTIONS, - incremental: true, - }); - }, - - searchPrevious() { - terminal!.searchAddon.findPrevious( - this.searchKeyword, - SEARCH_OPTIONS - ); - }, - searchNext() { - terminal!.searchAddon.findNext(this.searchKeyword, SEARCH_OPTIONS); - }, - }, - { - searchPrevious: action.bound, - searchNext: action.bound, - } -); - -if (terminal) { - terminal.searchAddon.onDidChangeResults((e) => { - console.log(e); - - runInAction(() => { - if (e) { - state.index = e.resultIndex; - state.count = e.resultCount; - } else { - state.index = undefined; - state.count = undefined; - } - }); - }); -} - -autorun(() => { - if (!terminal) { - return; - } - - if (!GLOBAL_STATE.device) { - terminal.socket = undefined; - return; - } - - if (!terminal.socket && state.visible) { - GLOBAL_STATE.device.subprocess.shell().then( - action((shell) => { - terminal!.socket = shell; - }), - (e) => { - GLOBAL_STATE.showErrorDialog(e); - } - ); - } -}); - -const UpIconProps = { iconName: Icons.ChevronUp }; -const DownIconProps = { iconName: Icons.ChevronDown }; - const Shell: NextPage = (): JSX.Element | null => { const classes = useClasses(); - const handleSearchKeywordChange = useCallback( - (e: unknown, value?: string) => { - state.setSearchKeyword(value ?? ""); - }, - [] - ); - - const handleResize = useCallback(() => { - terminal!.fit(); - }, []); - const handleContainerRef = useCallback( (container: HTMLDivElement | null) => { - if (container) { - terminal!.setContainer(container); - } + // invoke it with `null` to hide the iframe + attachTabbyFrame(container); }, [] ); - useEffect(() => { - state.setVisible(true); - return () => { - state.setVisible(false); - }; - }, []); - return ( - + <> Interactive Shell - Tango - - - - - - {state.count === 0 ? ( - - No results - - ) : state.count !== undefined ? ( - - {state.index! + 1} of {state.count} - - ) : null} - - - - - - - - - - - -
- - +
+
Loading Tabby...
+
+ ); }; diff --git a/apps/demo/src/pages/tabby-frame.tsx b/apps/demo/src/pages/tabby-frame.tsx new file mode 100644 index 00000000..5307bdee --- /dev/null +++ b/apps/demo/src/pages/tabby-frame.tsx @@ -0,0 +1,235 @@ +import { Adb } from "@yume-chan/adb"; +import { + AdbProxyBackend, + AdbProxyServerInfo, +} from "@yume-chan/adb-backend-proxy"; +import { useEffect } from "react"; +import { Subject } from "rxjs"; +import Yaml from "yaml"; + +export class NotImplementedSocket { + connect$ = new Subject(); + data$ = new Subject(); + error$ = new Subject(); + close$ = new Subject(); + + async connect() { + this.error$.next(new Error("Socket is not implemented in Web")); + } +} + +// Usage of connector is scattered around the Tabby codebase, +// but these is all methods that are required. +class WebConnector { + constructor() {} + + async loadConfig(): Promise { + let config; + + const text = localStorage.getItem("tabby-config"); + if (text) { + config = Yaml.parse(text); + } else { + config = { + recoverTabs: false, + web: { + preventAccidentalTabClosure: false, + }, + terminal: { + fontSize: 11, + }, + }; + } + + config.providerBlacklist = [ + ...(config.providerBlacklist ?? []), + "settings:ProfilesSettingsTabProvider", + ]; + config.commandBlacklist = [ + ...(config.commandBlacklist ?? []), + "core:profile-selector", + ]; + + return Yaml.stringify(config); + } + + async saveConfig(content: string): Promise { + localStorage.setItem("tabby-config", content); + } + + getAppVersion(): string { + return "1.0.197-nightly.0"; + } + + createSocket() { + return new NotImplementedSocket(); + } +} + +interface TabbyWeb { + registerPluginModule(packageName: string, exports: unknown): void; + bootstrap(options: unknown): Promise; +} + +interface TabbyPluginModule { + pluginName: string; +} + +interface GlobalExtension { + __connector__: WebConnector | undefined; + module: any; + exports: any; + Tabby: TabbyWeb; + pluginModules: TabbyPluginModule[]; +} + +const globalExtension = globalThis as unknown as GlobalExtension; + +async function start() { + const connector = new WebConnector(); + globalExtension["__connector__"] = connector; + + await import("@angular/compiler"); + // @ts-expect-error + await import("tabby-web-container/dist/preload.mjs"); + // @ts-expect-error + await import("tabby-web-container/dist/bundle.js"); + + async function webRequire(url: string | URL) { + if (typeof url === "object") { + url = url.toString(); + } + + console.log(`Loading ${url}`); + const e = document.createElement("script"); + globalExtension["module"] = { exports: {} }; + globalExtension["exports"] = globalExtension["module"].exports; + await new Promise((resolve) => { + e.onload = resolve; + e.src = url as string; + document.head.appendChild(e); + }); + return globalExtension["module"].exports; + } + + async function prefetchURL(url: string | URL) { + await (await fetch(url)).text(); + } + + const tabby = globalExtension.Tabby; + + const pluginInfos: { + pluginName: string; + packageName: string; + url: URL; + }[] = [ + { + pluginName: "core", + packageName: "tabby-core", + url: new URL("tabby-core/dist/index.js", import.meta.url), + }, + { + pluginName: "settings", + packageName: "tabby-settings", + url: new URL("tabby-settings/dist/index.js", import.meta.url), + }, + { + pluginName: "terminal", + packageName: "tabby-terminal", + url: new URL("tabby-terminal/dist/index.js", import.meta.url), + }, + { + pluginName: "community-color-schemes", + packageName: "tabby-community-color-schemes", + url: new URL( + "tabby-community-color-schemes/dist/index.js", + import.meta.url + ), + }, + { + pluginName: "web", + packageName: "tabby-web", + url: new URL("tabby-web/dist/index.js", import.meta.url), + }, + ]; + + await Promise.all( + pluginInfos.map((pluginInfo) => prefetchURL(pluginInfo.url)) + ); + + const pluginModules = []; + for (const info of pluginInfos) { + const result = await webRequire(info.url); + result.pluginName = info.pluginName; + tabby.registerPluginModule(info.packageName, result); + pluginModules.push(result); + } + + const TabbyTango = await webRequire( + new URL("@yume-chan/tabby-tango/dist/index.js", import.meta.url) + ); + TabbyTango.pluginName = "tango"; + + window.addEventListener("message", (e) => { + if ("type" in e.data && e.data.type === "adb") { + const { port, version, maxPayloadSize, banner } = + e.data as AdbProxyServerInfo; + const backend = new AdbProxyBackend(port); + const connection = backend.connect(); + TabbyTango.AdbState.value = new Adb( + connection, + version, + maxPayloadSize, + banner + ); + } + }); + window.parent.postMessage("adb", "*"); + + pluginModules.push(TabbyTango); + + globalExtension["pluginModules"] = pluginModules.map((plugin) => { + if ("default" in plugin) { + plugin.default.pluginName = plugin.pluginName; + return plugin.default; + } + return plugin; + }); + + const config = connector.loadConfig(); + await tabby.bootstrap({ + packageModules: pluginModules, + bootstrapData: { + config, + executable: "web", + isFirstWindow: true, + windowID: 1, + installedPlugins: [], + userPluginsPath: "/", + }, + debugMode: false, + connector, + }); +} + +function TabbyFrame() { + useEffect(() => { + // Only run at client side. + start().catch((e) => { + console.error(e); + }); + }, []); + + return ( +
+