diff --git a/apps/demo/components/demo-mode.tsx b/apps/demo/components/demo-mode.tsx index 56cb55a7..df60bfd2 100644 --- a/apps/demo/components/demo-mode.tsx +++ b/apps/demo/components/demo-mode.tsx @@ -233,7 +233,7 @@ const features: FeatureDefinition[][] = [ step: 1, initial: 34, onChange: (value) => device.current!.demoMode.setTime(state.features.get('hour') as number | undefined ?? 34, value as number) - } + }, ], ]; diff --git a/apps/demo/components/terminal.tsx b/apps/demo/components/terminal.tsx new file mode 100644 index 00000000..9344dc14 --- /dev/null +++ b/apps/demo/components/terminal.tsx @@ -0,0 +1,84 @@ + +import { AdbShell } from "@yume-chan/adb"; +import { encodeUtf8 } from "@yume-chan/adb-backend-webusb"; +import { AutoDisposable } from "@yume-chan/event"; +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({ + scrollback: 9000, + }); + + public searchAddon = new SearchAddon(); + + private readonly fitAddon = new FitAddon(); + + private _shell: AdbShell | undefined; + public get socket() { return this._shell; } + public set socket(value) { + if (this._shell) { + // Remove event listeners + this.dispose(); + } + + this._shell = value; + + if (value) { + this.terminal.clear(); + this.terminal.reset(); + + this.addDisposable(value.onStdout(data => { + this.terminal.write(new Uint8Array(data)); + })); + this.addDisposable(value.onStderr(data => { + this.terminal.write(new Uint8Array(data)); + })); + this.addDisposable(this.terminal.onData(data => { + const buffer = encodeUtf8(data); + value.write(buffer); + })); + + this.fit(); + } + } + + public constructor() { + super(); + + this.element.style.width = '100%'; + this.element.style.height = '100%'; + this.element.style.overflow = 'hidden'; + + this.terminal.setOption('fontFamily', '"Cascadia Code", Consolas, monospace, "Source Han Sans SC", "Microsoft YaHei"'); + this.terminal.setOption('letterSpacing', 1); + this.terminal.setOption('cursorStyle', 'bar'); + 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); + this.terminal.loadAddon(new WebglAddon()); + // WebGL renderer ignores `cursorBlink` set before it initialized + this.terminal.setOption('cursorBlink', true); + this.fit(); + } + } + + public fit() { + this.fitAddon.fit(); + // workaround https://github.com/xtermjs/xterm.js/issues/3504 + (this.terminal as any)._core.viewport._refresh(); + // Resize remote terminal + const { rows, cols } = this.terminal; + this._shell?.resize(rows, cols); + } +} diff --git a/apps/demo/pages/_app.tsx b/apps/demo/pages/_app.tsx index f2e4baa1..64245c31 100644 --- a/apps/demo/pages/_app.tsx +++ b/apps/demo/pages/_app.tsx @@ -1,7 +1,7 @@ -import { ActionButton, IComponentAsProps, IconButton, INavButtonProps, INavLink, initializeIcons, Link as FluentLink, mergeStyles, mergeStyleSets, Nav, Stack, StackItem } from "@fluentui/react"; +import { IComponentAsProps, IconButton, INavButtonProps, initializeIcons, mergeStyles, mergeStyleSets, Nav, Stack, StackItem } from "@fluentui/react"; import type { AppProps } from 'next/app'; -import { useRouter } from 'next/router'; import Link from 'next/link'; +import { useRouter } from 'next/router'; import React, { useCallback, useEffect, useState } from "react"; import { Connect, ErrorDialogProvider, Logger, ToggleLogger } from "../components"; import '../styles/globals.css'; @@ -24,7 +24,11 @@ const ROUTES = [ { url: '/framebuffer', name: 'Screen Capture', - } + }, + { + url: '/shell', + name: 'Interactive Shell', + }, ]; function NavLink({ link, defaultRender: DefaultRender, ...props }: IComponentAsProps) { diff --git a/apps/demo/pages/file-manager.tsx b/apps/demo/pages/file-manager.tsx index 657e92b1..c2b0c0f4 100644 --- a/apps/demo/pages/file-manager.tsx +++ b/apps/demo/pages/file-manager.tsx @@ -476,7 +476,7 @@ const FileManager: NextPage = (): JSX.Element | null => { state.loadFiles(); setUploading(false); } - }, []); + }, [showErrorDialog]); const [menuItems, setMenuItems] = useState([]); useEffect(() => { @@ -557,7 +557,7 @@ const FileManager: NextPage = (): JSX.Element | null => { } setMenuItems(result); - }, [selectedItems]); + }, [selectedItems, upload, showErrorDialog]); const [contextMenuTarget, setContextMenuTarget] = useState(); const showContextMenu = useCallback(( @@ -618,7 +618,7 @@ const FileManager: NextPage = (): JSX.Element | null => {
- +
diff --git a/apps/demo/pages/framebuffer.tsx b/apps/demo/pages/framebuffer.tsx index c620e332..78f4ac41 100644 --- a/apps/demo/pages/framebuffer.tsx +++ b/apps/demo/pages/framebuffer.tsx @@ -51,7 +51,7 @@ const FrameBuffer: NextPage = (): JSX.Element | null => { } catch (e) { showErrorDialog(e instanceof Error ? e.message : `${e}`); } - }, []); + }, [showErrorDialog]); useEffect(() => { return autorun(() => { diff --git a/apps/demo/pages/shell.tsx b/apps/demo/pages/shell.tsx new file mode 100644 index 00000000..833446ac --- /dev/null +++ b/apps/demo/pages/shell.tsx @@ -0,0 +1,125 @@ +import { IconButton, SearchBox, Stack, StackItem } from '@fluentui/react'; +import { reaction } from "mobx"; +import { observer } from "mobx-react-lite"; +import { NextPage } from "next"; +import Head from "next/head"; +import React, { CSSProperties, useCallback, useContext, useEffect, useRef, useState } from 'react'; +import 'xterm/css/xterm.css'; +import { ErrorDialogContext } from '../components/error-dialog'; +import { device } from "../state"; +import { ResizeObserver, RouteStackProps } from '../utils'; + +let terminal: import('../components/terminal').AdbTerminal; +if (typeof window !== 'undefined') { + const AdbTerminal: typeof import('../components/terminal').AdbTerminal = require('../components/terminal').AdbTerminal; + terminal = new AdbTerminal(); +} + +const ResizeObserverStyle: CSSProperties = { + width: '100%', + height: '100%', +}; + +const UpIconProps = { iconName: 'ChevronUp' }; +const DownIconProps = { iconName: 'ChevronDown' }; + +const Shell: NextPage = (): JSX.Element | null => { + const { show: showErrorDialog } = useContext(ErrorDialogContext); + + const [searchKeyword, setSearchKeyword] = useState(''); + const handleSearchKeywordChange = useCallback((e, newValue?: string) => { + setSearchKeyword(newValue ?? ''); + if (newValue) { + terminal.searchAddon.findNext(newValue, { incremental: true }); + } + }, []); + const findPrevious = useCallback(() => { + terminal.searchAddon.findPrevious(searchKeyword); + }, [searchKeyword]); + const findNext = useCallback(() => { + terminal.searchAddon.findNext(searchKeyword); + }, [searchKeyword]); + + const connectingRef = useRef(false); + useEffect(() => { + return reaction( + () => device.current, + async () => { + if (!device.current) { + terminal.socket = undefined; + return; + } + + if (!!terminal.socket || connectingRef.current) { + return; + } + + try { + connectingRef.current = true; + const socket = await device.current.childProcess.shell(); + terminal.socket = socket; + } catch (e) { + showErrorDialog(e instanceof Error ? e.message : `${e}`); + } finally { + connectingRef.current = false; + } + }, + { + fireImmediately: true, + } + ); + }, [showErrorDialog]); + + const handleResize = useCallback(() => { + terminal.fit(); + }, []); + + const handleContainerRef = useCallback((container: HTMLDivElement | null) => { + if (container) { + terminal.setContainer(container); + } + }, []); + + return ( + + + Screen Capture - WebADB + + + + + + + + + + + + + + + + + + +
+ + + + ); +}; + +export default observer(Shell);