mirror of
https://github.com/yume-chan/ya-webadb.git
synced 2025-10-06 03:50:18 +02:00
feat: add shell page
This commit is contained in:
parent
868e3d3f10
commit
2053ee3238
6 changed files with 221 additions and 8 deletions
|
@ -233,7 +233,7 @@ const features: FeatureDefinition[][] = [
|
||||||
step: 1,
|
step: 1,
|
||||||
initial: 34,
|
initial: 34,
|
||||||
onChange: (value) => device.current!.demoMode.setTime(state.features.get('hour') as number | undefined ?? 34, value as number)
|
onChange: (value) => device.current!.demoMode.setTime(state.features.get('hour') as number | undefined ?? 34, value as number)
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
84
apps/demo/components/terminal.tsx
Normal file
84
apps/demo/components/terminal.tsx
Normal file
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 type { AppProps } from 'next/app';
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import { Connect, ErrorDialogProvider, Logger, ToggleLogger } from "../components";
|
import { Connect, ErrorDialogProvider, Logger, ToggleLogger } from "../components";
|
||||||
import '../styles/globals.css';
|
import '../styles/globals.css';
|
||||||
|
@ -24,7 +24,11 @@ const ROUTES = [
|
||||||
{
|
{
|
||||||
url: '/framebuffer',
|
url: '/framebuffer',
|
||||||
name: 'Screen Capture',
|
name: 'Screen Capture',
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
url: '/shell',
|
||||||
|
name: 'Interactive Shell',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function NavLink({ link, defaultRender: DefaultRender, ...props }: IComponentAsProps<INavButtonProps>) {
|
function NavLink({ link, defaultRender: DefaultRender, ...props }: IComponentAsProps<INavButtonProps>) {
|
||||||
|
|
|
@ -476,7 +476,7 @@ const FileManager: NextPage = (): JSX.Element | null => {
|
||||||
state.loadFiles();
|
state.loadFiles();
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [showErrorDialog]);
|
||||||
|
|
||||||
const [menuItems, setMenuItems] = useState<IContextualMenuItem[]>([]);
|
const [menuItems, setMenuItems] = useState<IContextualMenuItem[]>([]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -557,7 +557,7 @@ const FileManager: NextPage = (): JSX.Element | null => {
|
||||||
}
|
}
|
||||||
|
|
||||||
setMenuItems(result);
|
setMenuItems(result);
|
||||||
}, [selectedItems]);
|
}, [selectedItems, upload, showErrorDialog]);
|
||||||
|
|
||||||
const [contextMenuTarget, setContextMenuTarget] = useState<MouseEvent>();
|
const [contextMenuTarget, setContextMenuTarget] = useState<MouseEvent>();
|
||||||
const showContextMenu = useCallback((
|
const showContextMenu = useCallback((
|
||||||
|
@ -618,7 +618,7 @@ const FileManager: NextPage = (): JSX.Element | null => {
|
||||||
<Layer>
|
<Layer>
|
||||||
<Overlay onClick={hidePreview}>
|
<Overlay onClick={hidePreview}>
|
||||||
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<img src={previewUrl} style={{ maxWidth: '100%', maxHeight: '100%' }} />
|
<img src={previewUrl} alt="" style={{ maxWidth: '100%', maxHeight: '100%' }} />
|
||||||
</div>
|
</div>
|
||||||
</Overlay>
|
</Overlay>
|
||||||
</Layer>
|
</Layer>
|
||||||
|
|
|
@ -51,7 +51,7 @@ const FrameBuffer: NextPage = (): JSX.Element | null => {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showErrorDialog(e instanceof Error ? e.message : `${e}`);
|
showErrorDialog(e instanceof Error ? e.message : `${e}`);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [showErrorDialog]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return autorun(() => {
|
return autorun(() => {
|
||||||
|
|
125
apps/demo/pages/shell.tsx
Normal file
125
apps/demo/pages/shell.tsx
Normal file
|
@ -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 (
|
||||||
|
<Stack {...RouteStackProps}>
|
||||||
|
<Head>
|
||||||
|
<title>Screen Capture - WebADB</title>
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<StackItem>
|
||||||
|
<Stack horizontal>
|
||||||
|
<StackItem grow>
|
||||||
|
<SearchBox
|
||||||
|
placeholder="Find"
|
||||||
|
value={searchKeyword}
|
||||||
|
onChange={handleSearchKeywordChange}
|
||||||
|
onSearch={findNext}
|
||||||
|
/>
|
||||||
|
</StackItem>
|
||||||
|
<StackItem>
|
||||||
|
<IconButton
|
||||||
|
disabled={!searchKeyword}
|
||||||
|
iconProps={UpIconProps}
|
||||||
|
onClick={findPrevious}
|
||||||
|
/>
|
||||||
|
</StackItem>
|
||||||
|
<StackItem>
|
||||||
|
<IconButton
|
||||||
|
disabled={!searchKeyword}
|
||||||
|
iconProps={DownIconProps}
|
||||||
|
onClick={findNext}
|
||||||
|
/>
|
||||||
|
</StackItem>
|
||||||
|
</Stack>
|
||||||
|
</StackItem>
|
||||||
|
|
||||||
|
<StackItem grow styles={{ root: { minHeight: 0 } }}>
|
||||||
|
<ResizeObserver style={ResizeObserverStyle} onResize={handleResize}>
|
||||||
|
<div ref={handleContainerRef} style={{ height: '100%' }} />
|
||||||
|
</ResizeObserver>
|
||||||
|
</StackItem>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default observer(Shell);
|
Loading…
Add table
Add a link
Reference in a new issue