feat(demo): use tabby as terminal emulator (#541)

This commit is contained in:
Simon Chan 2023-04-23 23:24:29 +08:00 committed by GitHub
parent 58794f0d69
commit 04d7c08b78
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
51 changed files with 2429 additions and 390 deletions

View file

@ -10,6 +10,7 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@angular/compiler": "^15.2.6",
"@fluentui/react": "^8.107.5", "@fluentui/react": "^8.107.5",
"@fluentui/react-file-type-icons": "^8.8.13", "@fluentui/react-file-type-icons": "^8.8.13",
"@fluentui/react-hooks": "^8.6.20", "@fluentui/react-hooks": "^8.6.20",
@ -18,6 +19,7 @@
"@griffel/react": "^1.5.7", "@griffel/react": "^1.5.7",
"@yume-chan/adb": "workspace:^0.0.19", "@yume-chan/adb": "workspace:^0.0.19",
"@yume-chan/adb-backend-direct-sockets": "workspace:^0.0.9", "@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-webusb": "workspace:^0.0.19",
"@yume-chan/adb-backend-ws": "workspace:^0.0.9", "@yume-chan/adb-backend-ws": "workspace:^0.0.9",
"@yume-chan/adb-credential-web": "workspace:^0.0.19", "@yume-chan/adb-credential-web": "workspace:^0.0.19",
@ -34,18 +36,23 @@
"@yume-chan/stream-extra": "workspace:^0.0.19", "@yume-chan/stream-extra": "workspace:^0.0.19",
"@yume-chan/stream-saver": "^2.0.6", "@yume-chan/stream-saver": "^2.0.6",
"@yume-chan/struct": "workspace:^0.0.19", "@yume-chan/struct": "workspace:^0.0.19",
"@yume-chan/tabby-tango": "workspace:^0.0.19",
"@yume-chan/undici-browser": "5.21.2-mod.9", "@yume-chan/undici-browser": "5.21.2-mod.9",
"fflate": "^0.7.4", "fflate": "^0.7.4",
"yaml": "^2.2.1",
"mobx": "^6.7.0", "mobx": "^6.7.0",
"mobx-react-lite": "^3.4.3", "mobx-react-lite": "^3.4.3",
"next": "13.3.0", "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": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"webm-muxer": "^2.2.3", "rxjs": "^7.8.0",
"xterm": "^5.1.0", "webm-muxer": "^2.2.3"
"xterm-addon-fit": "^0.7.0",
"xterm-addon-search": "^0.11.0",
"xterm-addon-webgl": "^0.14.0"
}, },
"devDependencies": { "devDependencies": {
"@mdx-js/loader": "^2.2.1", "@mdx-js/loader": "^2.2.1",

View file

@ -27,3 +27,27 @@ fs.writeFileSync(
), ),
"utf8" "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();"
)
);

View file

@ -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');

View file

@ -1,11 +1,12 @@
export * from './command-bar'; export * from "./command-bar";
export * from './connect'; export * from "./connect";
export * from './demo-mode-panel'; export * from "./demo-mode-panel";
export * from './device-view'; export * from "./device-view";
export * from './error-dialog'; export * from "./error-dialog";
export * from './external-link'; export * from "./external-link";
export * from './grid'; export * from "./grid";
export * from './hex-viewer'; export * from "./hex-viewer";
export * from "./list-selection"; export * from "./list-selection";
export * from './log-view'; export * from "./log-view";
export * from './resize-observer'; export * from "./resize-observer";
export * from "./tabby-frame-manager";

View file

@ -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);
}

View file

@ -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<Uint8Array>({
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);
}
}

View file

@ -105,6 +105,7 @@ function ChromeDevToolsFrame() {
document.body.appendChild(script); document.body.appendChild(script);
}, []); }, []);
// DevTools will set `document.title` to debugged page's title.
return ( return (
<Stack <Stack
className={classes.body} className={classes.body}
@ -117,5 +118,7 @@ function ChromeDevToolsFrame() {
</Stack> </Stack>
); );
} }
ChromeDevToolsFrame.noLayout = true; ChromeDevToolsFrame.noLayout = true;
export default ChromeDevToolsFrame; export default ChromeDevToolsFrame;

View file

@ -1,192 +1,40 @@
import { IconButton, SearchBox, Stack, StackItem } from "@fluentui/react"; import { makeStyles } from "@griffel/react";
import { makeStyles, shorthands } from "@griffel/react";
import { action, autorun, makeAutoObservable, runInAction } from "mobx";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { NextPage } from "next"; import { NextPage } from "next";
import Head from "next/head"; import Head from "next/head";
import { useCallback, useEffect } from "react"; import { useCallback } from "react";
import { ISearchOptions } from "xterm-addon-search"; import { attachTabbyFrame } from "../components";
import "xterm/css/xterm.css";
import { ResizeObserver } from "../components";
import { GLOBAL_STATE } from "../state";
import { Icons, RouteStackProps } from "../utils";
const useClasses = makeStyles({ const useClasses = makeStyles({
count: { container: {
...shorthands.padding("0", "8px"), 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 Shell: NextPage = (): JSX.Element | null => {
const classes = useClasses(); const classes = useClasses();
const handleSearchKeywordChange = useCallback(
(e: unknown, value?: string) => {
state.setSearchKeyword(value ?? "");
},
[]
);
const handleResize = useCallback(() => {
terminal!.fit();
}, []);
const handleContainerRef = useCallback( const handleContainerRef = useCallback(
(container: HTMLDivElement | null) => { (container: HTMLDivElement | null) => {
if (container) { // invoke it with `null` to hide the iframe
terminal!.setContainer(container); attachTabbyFrame(container);
}
}, },
[] []
); );
useEffect(() => {
state.setVisible(true);
return () => {
state.setVisible(false);
};
}, []);
return ( return (
<Stack {...RouteStackProps}> <>
<Head> <Head>
<title>Interactive Shell - Tango</title> <title>Interactive Shell - Tango</title>
</Head> </Head>
<StackItem> <div ref={handleContainerRef} className={classes.container}>
<Stack horizontal> <div>Loading Tabby...</div>
<StackItem grow> </div>
<SearchBox </>
placeholder="Find"
value={state.searchKeyword}
onChange={handleSearchKeywordChange}
onSearch={state.searchNext}
/>
</StackItem>
{state.count === 0 ? (
<StackItem className={classes.count} align="center">
No results
</StackItem>
) : state.count !== undefined ? (
<StackItem className={classes.count} align="center">
{state.index! + 1} of {state.count}
</StackItem>
) : null}
<StackItem>
<IconButton
disabled={!state.searchKeyword}
iconProps={UpIconProps}
onClick={state.searchPrevious}
/>
</StackItem>
<StackItem>
<IconButton
disabled={!state.searchKeyword}
iconProps={DownIconProps}
onClick={state.searchNext}
/>
</StackItem>
</Stack>
</StackItem>
<StackItem
grow
styles={{ root: { position: "relative", minHeight: 0 } }}
>
<ResizeObserver onResize={handleResize} />
<div ref={handleContainerRef} style={{ height: "100%" }} />
</StackItem>
</Stack>
); );
}; };

View file

@ -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<void>();
data$ = new Subject<Buffer>();
error$ = new Subject<Error>();
close$ = new Subject<Buffer>();
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<string> {
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<void> {
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<void>;
}
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 (
<div>
<style id="custom-css" />
{/* @ts-expect-error */}
<app-root />
</div>
);
}
TabbyFrame.noLayout = true;
export default TabbyFrame;

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
// DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush.
{ {
"pnpmShrinkwrapHash": "e870870fc8dda7ac7abb3a0c8dcebb664c086262", "pnpmShrinkwrapHash": "197ab75f0fe12b6ece795fc8734be482ac57e9aa",
"preferredVersionsHash": "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f" "preferredVersionsHash": "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f"
} }

View file

@ -34,7 +34,7 @@
"dependencies": { "dependencies": {
"@yume-chan/adb": "workspace:^0.0.19", "@yume-chan/adb": "workspace:^0.0.19",
"@yume-chan/stream-extra": "workspace:^0.0.19", "@yume-chan/stream-extra": "workspace:^0.0.19",
"tslib": "^2.4.1" "tslib": "^2.5.0"
}, },
"devDependencies": { "devDependencies": {
"@yume-chan/eslint-config": "workspace:^1.0.0", "@yume-chan/eslint-config": "workspace:^1.0.0",

View file

@ -0,0 +1,11 @@
module.exports = {
"extends": [
"@yume-chan"
],
parserOptions: {
tsconfigRootDir: __dirname,
project: [
"./tsconfig.build.json"
],
},
}

View file

@ -0,0 +1,16 @@
.rush
# Test
coverage
**/*.spec.ts
**/*.spec.js
**/*.spec.js.map
**/__helpers__
jest.config.js
.eslintrc.cjs
tsconfig.json
tsconfig.test.json
# Logs
*.log

View file

@ -0,0 +1,5 @@
# `@yume-chan/adb-backend-proxy`
Proxies streams to another `Adb` instance, so multiple clients can access the same device.
Prototype of `adb-backend-server` to support connecting to ADB servers.

View file

@ -0,0 +1,49 @@
{
"name": "@yume-chan/adb-backend-proxy",
"private": true,
"version": "0.0.9",
"description": "Backend for `@yume-chan/adb` by proxy sockets to another `Adb` instance.",
"keywords": [
"adb",
"proxy"
],
"author": {
"name": "Simon Chan",
"email": "cnsimonchan@live.com",
"url": "https://chensi.moe/blog"
},
"homepage": "https://github.com/yume-chan/ya-webadb/tree/main/libraries/adb-backend-proxy#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/yume-chan/ya-webadb.git",
"directory": "libraries/adb-backend-proxy"
},
"bugs": {
"url": "https://github.com/yume-chan/ya-webadb/issues"
},
"license": "MIT",
"type": "module",
"main": "esm/index.js",
"types": "esm/index.d.ts",
"scripts": {
"build": "tsc -b tsconfig.build.json",
"build:watch": "tsc -b tsconfig.build.json",
"lint": "eslint src/**/*.ts --fix && prettier src/**/*.ts --write --tab-width 4",
"prepublishOnly": "npm run build"
},
"dependencies": {
"@yume-chan/adb": "workspace:^0.0.19",
"@yume-chan/async": "^2.2.0",
"@yume-chan/event": "workspace:^0.0.19",
"@yume-chan/stream-extra": "workspace:^0.0.19",
"@yume-chan/struct": "workspace:^0.0.19",
"tslib": "^2.5.0"
},
"devDependencies": {
"@yume-chan/eslint-config": "workspace:^1.0.0",
"@yume-chan/tsconfig": "workspace:^1.0.0",
"eslint": "^8.36.0",
"prettier": "^2.8.4",
"typescript": "^5.0.3"
}
}

View file

@ -0,0 +1,104 @@
import type { AdbBackend, AdbPacketData, AdbPacketInit } from "@yume-chan/adb";
import { AdbCommand } from "@yume-chan/adb";
import type { Consumable, ReadableWritablePair } from "@yume-chan/stream-extra";
import {
DuplexStreamFactory,
ReadableStream,
WritableStream,
} from "@yume-chan/stream-extra";
import { EMPTY_UINT8_ARRAY } from "@yume-chan/struct";
class AdbProxyConnection
implements ReadableWritablePair<AdbPacketData, Consumable<AdbPacketInit>>
{
public readonly readable: ReadableStream<AdbPacketData>;
public readonly writable: WritableStream<Consumable<AdbPacketInit>>;
public constructor(port: MessagePort) {
const duplex = new DuplexStreamFactory<
AdbPacketData,
Consumable<AdbPacketInit>
>({
close: () => {
// CNXN means disconnected
port.postMessage({
command: AdbCommand.Connect,
arg0: 0,
arg1: 0,
payload: EMPTY_UINT8_ARRAY,
} satisfies AdbPacketData);
port.close();
},
});
this.readable = duplex.wrapReadable(
new ReadableStream({
start(controller) {
port.onmessage = (e) => {
const data = e.data as AdbPacketData;
switch (data.command) {
case AdbCommand.Auth:
case AdbCommand.Connect:
controller.close();
break;
case AdbCommand.Open:
case AdbCommand.Close:
case AdbCommand.OK:
case AdbCommand.Write:
controller.enqueue(data);
break;
}
};
},
})
);
this.writable = duplex.createWritable(
new WritableStream({
write(chunk) {
switch (chunk.value.command) {
case AdbCommand.Auth:
throw new Error(
`Can't send AUTH packet through proxy`
);
case AdbCommand.Connect:
throw new Error(
`Can't send CNXN packet through proxy`
);
case AdbCommand.Open:
case AdbCommand.Close:
case AdbCommand.OK:
case AdbCommand.Write: {
// Can't transfer `chunk.payload`, will break clients
port.postMessage(chunk.value);
chunk.consume();
break;
}
}
},
})
);
}
}
export class AdbProxyBackend implements AdbBackend {
public static isSupported(): boolean {
return true;
}
private readonly port: MessagePort;
public readonly serial: string;
public name: string | undefined;
public constructor(port: MessagePort, name?: string) {
this.port = port;
this.serial = `proxy`;
this.name = name;
}
public connect() {
return new AdbProxyConnection(this.port);
}
}

View file

@ -0,0 +1,2 @@
export * from "./client.js";
export * from "./server.js";

View file

@ -0,0 +1,267 @@
import type { Adb, AdbPacketData, AdbSocket } from "@yume-chan/adb";
import { AdbCommand } from "@yume-chan/adb";
import { PromiseResolver } from "@yume-chan/async";
import { AutoDisposable, EventEmitter } from "@yume-chan/event";
import type {
Consumable,
WritableStreamDefaultWriter,
} from "@yume-chan/stream-extra";
import {
ConsumableWritableStream,
WritableStream,
} from "@yume-chan/stream-extra";
import { EMPTY_UINT8_ARRAY, decodeUtf8, encodeUtf8 } from "@yume-chan/struct";
const NOOP = () => {
// no-op
};
class AdbProxyServerSocket {
public handle: AdbProxyServerConnection;
public localId: number;
public pendingRead?: PromiseResolver<void>;
private _writer: WritableStreamDefaultWriter<Consumable<Uint8Array>>;
public constructor(
handle: AdbProxyServerConnection,
socket: AdbSocket,
localId: number
) {
this.handle = handle;
this.localId = localId;
this._writer = socket.writable.getWriter();
socket.readable
.pipeTo(
new WritableStream({
write: this.handleData.bind(this),
})
)
.finally(this.handleClose.bind(this));
}
/**
* Handle data from server
*/
private async handleData(payload: Uint8Array) {
const promiseResolver = new PromiseResolver<void>();
this.pendingRead = promiseResolver;
this.handle.postMessage(AdbCommand.Write, 1, this.localId, payload);
await promiseResolver.promise;
}
/**
* Handle close from server
*/
private handleClose() {
this.handle.postMessage(AdbCommand.Close, 1, this.localId);
}
/**
* Write data to server
*/
public async write(payload: Uint8Array) {
await ConsumableWritableStream.write(this._writer, payload);
}
/**
* Send close to server
*/
public async close() {
await this._writer.close();
}
}
export interface AdbProxyServerInfo {
port: MessagePort;
version: number;
maxPayloadSize: number;
banner: string;
}
class AdbProxyServerConnection {
public adb: Adb;
public port: MessagePort;
private _closed = new EventEmitter<void>();
public get closed() {
return this._closed.event;
}
private _pendingSockets = new Map<number, PromiseResolver<boolean>>();
private _sockets = new Map<number, AdbProxyServerSocket>();
public constructor(adb: Adb, port: MessagePort) {
this.adb = adb;
this.port = port;
port.onmessage = this.handleMessage.bind(this);
}
/**
* Handle messages from client
*/
private async handleMessage(e: MessageEvent) {
const data = e.data as AdbPacketData;
switch (data.command) {
case AdbCommand.Auth:
throw new Error(`Can't send AUTH packet through proxy`);
case AdbCommand.Connect:
throw new Error(`Can't send CNXN packet through proxy`);
case AdbCommand.Open:
await this.handleOpen(data);
break;
case AdbCommand.Write: {
const socket = this._sockets.get(data.arg0);
if (!socket) {
break;
}
await socket.write(data.payload);
this.postMessage(AdbCommand.OK, 1, data.arg0);
break;
}
case AdbCommand.Close: {
if (data.arg0 === 0) {
this._pendingSockets.get(data.arg1)?.resolve(false);
break;
}
const socket = this._sockets.get(data.arg0);
if (!socket) {
break;
}
await socket.close();
this._sockets.delete(data.arg0);
this.postMessage(AdbCommand.Close, 1, data.arg0);
break;
}
case AdbCommand.OK: {
const socket = this._sockets.get(data.arg1);
if (!socket) {
break;
}
socket.pendingRead?.resolve();
break;
}
}
}
/**
* Handle open from client
*/
private async handleOpen(data: AdbPacketData) {
if (data.arg1 !== 0) {
this._pendingSockets.get(data.arg1)?.resolve(true);
return;
}
const localId = data.arg0;
try {
const service = decodeUtf8(data.payload);
const socket = await this.adb.createSocket(service);
this._sockets.set(
localId,
new AdbProxyServerSocket(this, socket, localId)
);
// remoteId doesn't matter
this.postMessage(AdbCommand.OK, 1, localId);
} catch {
this.postMessage(AdbCommand.Close, 0, localId);
}
}
/**
* Send message to client
*/
public postMessage(
command: AdbCommand,
arg0: number,
arg1: number,
payload = EMPTY_UINT8_ARRAY
) {
this.port.postMessage({
command,
arg0,
arg1,
payload,
} satisfies AdbPacketData);
}
/**
* Wait for client to accept the incoming socket
*/
public async waitOpenResult(remoteId: number) {
const promiseResolver = new PromiseResolver<boolean>();
this._pendingSockets.set(remoteId, promiseResolver);
return promiseResolver.promise;
}
/**
* Close the connection to both client and server
*/
public async close(): Promise<void> {
for (const socket of this._sockets.values()) {
await socket.close();
}
this.postMessage(AdbCommand.Connect, 0, 0);
this.port.close();
this._closed.fire();
}
}
export class AdbProxyServer extends AutoDisposable {
private _adb: Adb;
private _ports: Set<AdbProxyServerConnection> = new Set();
constructor(adb: Adb) {
super();
this._adb = adb;
this.addDisposable(
this._adb.onIncomingSocket(async (socket) => {
for (const port of this._ports) {
port.postMessage(
AdbCommand.Open,
0,
socket.remoteId,
encodeUtf8(socket.serviceString)
);
if (await port.waitOpenResult(socket.remoteId)) {
return true;
}
}
return false;
})
);
this._adb.disconnected.then(async () => {
for (const port of this._ports) {
// CNXN means disconnected
port.postMessage(AdbCommand.Connect, 0, 0);
await port.close();
}
}, NOOP);
}
/**
* Create a port that can be used to create a client
*/
public createPort(): AdbProxyServerInfo {
const channel = new MessageChannel();
const port = new AdbProxyServerConnection(this._adb, channel.port1);
this._ports.add(port);
port.closed(() => {
this._ports.delete(port);
});
return {
port: channel.port2,
version: this._adb.protocolVersion,
maxPayloadSize: this._adb.maxPayloadSize,
banner: this._adb.banner,
};
}
}

View file

@ -0,0 +1,9 @@
{
"extends": "./node_modules/@yume-chan/tsconfig/tsconfig.base.json",
"compilerOptions": {
"lib": [
"ESNext",
"DOM"
],
}
}

View file

@ -0,0 +1,19 @@
{
"references": [
{
"path": "../adb/tsconfig.build.json"
},
{
"path": "../event/tsconfig.build.json"
},
{
"path": "../stream-extra/tsconfig.build.json"
},
{
"path": "../struct/tsconfig.build.json"
},
{
"path": "./tsconfig.build.json"
},
]
}

View file

@ -35,7 +35,7 @@
"@yume-chan/adb": "workspace:^0.0.19", "@yume-chan/adb": "workspace:^0.0.19",
"@yume-chan/stream-extra": "workspace:^0.0.19", "@yume-chan/stream-extra": "workspace:^0.0.19",
"@yume-chan/struct": "workspace:^0.0.19", "@yume-chan/struct": "workspace:^0.0.19",
"tslib": "^2.4.1" "tslib": "^2.5.0"
}, },
"devDependencies": { "devDependencies": {
"@yume-chan/eslint-config": "workspace:^1.0.0", "@yume-chan/eslint-config": "workspace:^1.0.0",

View file

@ -33,7 +33,7 @@
"dependencies": { "dependencies": {
"@yume-chan/adb": "workspace:^0.0.19", "@yume-chan/adb": "workspace:^0.0.19",
"@yume-chan/stream-extra": "workspace:^0.0.19", "@yume-chan/stream-extra": "workspace:^0.0.19",
"tslib": "^2.4.1" "tslib": "^2.5.0"
}, },
"devDependencies": { "devDependencies": {
"@yume-chan/eslint-config": "workspace:^1.0.0", "@yume-chan/eslint-config": "workspace:^1.0.0",

View file

@ -31,7 +31,7 @@
}, },
"dependencies": { "dependencies": {
"@yume-chan/adb": "workspace:^0.0.19", "@yume-chan/adb": "workspace:^0.0.19",
"tslib": "^2.4.1" "tslib": "^2.5.0"
}, },
"devDependencies": { "devDependencies": {
"@yume-chan/eslint-config": "workspace:^1.0.0", "@yume-chan/eslint-config": "workspace:^1.0.0",

View file

@ -39,7 +39,7 @@
"@yume-chan/scrcpy": "workspace:^0.0.19", "@yume-chan/scrcpy": "workspace:^0.0.19",
"@yume-chan/stream-extra": "workspace:^0.0.19", "@yume-chan/stream-extra": "workspace:^0.0.19",
"@yume-chan/struct": "workspace:^0.0.19", "@yume-chan/struct": "workspace:^0.0.19",
"tslib": "^2.4.1" "tslib": "^2.5.0"
}, },
"devDependencies": { "devDependencies": {
"@jest/globals": "^29.5.0", "@jest/globals": "^29.5.0",

View file

@ -37,7 +37,7 @@
"@yume-chan/event": "workspace:^0.0.19", "@yume-chan/event": "workspace:^0.0.19",
"@yume-chan/stream-extra": "workspace:^0.0.19", "@yume-chan/stream-extra": "workspace:^0.0.19",
"@yume-chan/struct": "workspace:^0.0.19", "@yume-chan/struct": "workspace:^0.0.19",
"tslib": "^2.4.1" "tslib": "^2.5.0"
}, },
"devDependencies": { "devDependencies": {
"@jest/globals": "^29.5.0", "@jest/globals": "^29.5.0",

View file

@ -193,6 +193,11 @@ export class Adb implements Closeable {
return this._maxPayloadSize; return this._maxPayloadSize;
} }
private _banner: string;
public get banner() {
return this._banner;
}
private _product: string | undefined; private _product: string | undefined;
public get product() { public get product() {
return this._product; return this._product;
@ -227,6 +232,7 @@ export class Adb implements Closeable {
maxPayloadSize: number, maxPayloadSize: number,
banner: string banner: string
) { ) {
this._banner = banner;
this.parseBanner(banner); this.parseBanner(banner);
let calculateChecksum: boolean; let calculateChecksum: boolean;

View file

@ -34,7 +34,7 @@
"@yume-chan/adb": "workspace:^0.0.19", "@yume-chan/adb": "workspace:^0.0.19",
"@yume-chan/stream-extra": "workspace:^0.0.19", "@yume-chan/stream-extra": "workspace:^0.0.19",
"@yume-chan/struct": "workspace:^0.0.19", "@yume-chan/struct": "workspace:^0.0.19",
"tslib": "^2.4.1" "tslib": "^2.5.0"
}, },
"devDependencies": { "devDependencies": {
"@yume-chan/eslint-config": "workspace:^1.0.0", "@yume-chan/eslint-config": "workspace:^1.0.0",

View file

@ -34,7 +34,7 @@
"prepublishOnly": "npm run build" "prepublishOnly": "npm run build"
}, },
"dependencies": { "dependencies": {
"tslib": "^2.4.1" "tslib": "^2.5.0"
}, },
"devDependencies": { "devDependencies": {
"@jest/globals": "^29.5.0", "@jest/globals": "^29.5.0",

View file

@ -36,7 +36,7 @@
"prepublishOnly": "npm run build" "prepublishOnly": "npm run build"
}, },
"dependencies": { "dependencies": {
"tslib": "^2.4.1" "tslib": "^2.5.0"
}, },
"devDependencies": { "devDependencies": {
"@yume-chan/eslint-config": "workspace:^1.0.0", "@yume-chan/eslint-config": "workspace:^1.0.0",

View file

@ -34,7 +34,7 @@
}, },
"dependencies": { "dependencies": {
"@yume-chan/async": "^2.2.0", "@yume-chan/async": "^2.2.0",
"tslib": "^2.4.1" "tslib": "^2.5.0"
}, },
"devDependencies": { "devDependencies": {
"@jest/globals": "^29.5.0", "@jest/globals": "^29.5.0",

View file

@ -1,3 +1,30 @@
# @yume-chan/pcm-player # @yume-chan/pcm-player
Play raw audio sample stream using Web Audio API Play raw audio sample stream using Web Audio API.
Only support stereo audio.
TODO:
- [ ] resample audio to compensate for audio buffer underrun
## Usage
Depends on the sample format, there are multiple player classes:
- `Int16PcmPlayer` (little endian)
- `Float32PcmPlayer`
- `Float32PlanerPcmPlayer`
No `Planer`: audio samples are interleaved (left channel first).
With `Planer`: audio samples are in a two-dimensional array (left channel first).
The constructors require user activation (must be invoked in a user event handler, e.g. `onclick`), because they create `AudioContext`s.
```ts
var player = Int16PcmPlayer(44100);
player.start();
player.feed(new Int16Array([0, 0, 0, 0, 0, 0, 0, 0]));
player.stop(); // `AudioContext` will be closed, so can't be restarted
```

View file

@ -31,7 +31,7 @@
"prepublishOnly": "npm run build" "prepublishOnly": "npm run build"
}, },
"dependencies": { "dependencies": {
"tslib": "^2.4.1" "tslib": "^2.5.0"
}, },
"devDependencies": { "devDependencies": {
"@jest/globals": "^29.5.0", "@jest/globals": "^29.5.0",

View file

@ -39,7 +39,7 @@
"@yume-chan/scrcpy": "workspace:^0.0.19", "@yume-chan/scrcpy": "workspace:^0.0.19",
"@yume-chan/stream-extra": "workspace:^0.0.19", "@yume-chan/stream-extra": "workspace:^0.0.19",
"tinyh264": "^0.0.7", "tinyh264": "^0.0.7",
"tslib": "^2.4.1", "tslib": "^2.5.0",
"yuv-buffer": "^1.0.0", "yuv-buffer": "^1.0.0",
"yuv-canvas": "^1.2.11" "yuv-canvas": "^1.2.11"
}, },

View file

@ -38,7 +38,7 @@
"@yume-chan/scrcpy": "workspace:^0.0.19", "@yume-chan/scrcpy": "workspace:^0.0.19",
"@yume-chan/scrcpy-decoder-tinyh264": "workspace:^0.0.19", "@yume-chan/scrcpy-decoder-tinyh264": "workspace:^0.0.19",
"@yume-chan/stream-extra": "workspace:^0.0.19", "@yume-chan/stream-extra": "workspace:^0.0.19",
"tslib": "^2.4.1" "tslib": "^2.5.0"
}, },
"devDependencies": { "devDependencies": {
"@jest/globals": "^29.5.0", "@jest/globals": "^29.5.0",

View file

@ -38,7 +38,7 @@
"dependencies": { "dependencies": {
"@yume-chan/stream-extra": "workspace:^0.0.19", "@yume-chan/stream-extra": "workspace:^0.0.19",
"@yume-chan/struct": "workspace:^0.0.19", "@yume-chan/struct": "workspace:^0.0.19",
"tslib": "^2.4.1" "tslib": "^2.5.0"
}, },
"devDependencies": { "devDependencies": {
"@jest/globals": "^29.5.0", "@jest/globals": "^29.5.0",

View file

@ -34,7 +34,7 @@
"dependencies": { "dependencies": {
"@yume-chan/async": "^2.2.0", "@yume-chan/async": "^2.2.0",
"@yume-chan/struct": "workspace:^0.0.19", "@yume-chan/struct": "workspace:^0.0.19",
"tslib": "^2.4.1", "tslib": "^2.5.0",
"web-streams-polyfill": "^4.0.0-beta.3" "web-streams-polyfill": "^4.0.0-beta.3"
}, },
"devDependencies": { "devDependencies": {

View file

@ -11,12 +11,12 @@ interface Console {
createTask(name: string): Task; createTask(name: string): Task;
} }
interface GlobalEx { interface GlobalExtension {
console: Console; console: Console;
} }
// `createTask` allows browser DevTools to track the call stack across async boundaries. // `createTask` allows browser DevTools to track the call stack across async boundaries.
const { console } = globalThis as unknown as GlobalEx; const { console } = globalThis as unknown as GlobalExtension;
const createTask: Console["createTask"] = const createTask: Console["createTask"] =
console.createTask?.bind(console) ?? console.createTask?.bind(console) ??
(() => ({ (() => ({

View file

@ -35,7 +35,7 @@
}, },
"dependencies": { "dependencies": {
"@yume-chan/dataview-bigint-polyfill": "workspace:^0.0.19", "@yume-chan/dataview-bigint-polyfill": "workspace:^0.0.19",
"tslib": "^2.4.1" "tslib": "^2.5.0"
}, },
"devDependencies": { "devDependencies": {
"@jest/globals": "^29.5.0", "@jest/globals": "^29.5.0",

View file

@ -0,0 +1,42 @@
{
"name": "@yume-chan/tabby-tango",
"version": "0.0.19",
"main": "dist/index.js",
"typings": "dist/index.d.ts",
"scripts": {
"build": "webpack --progress --color",
"build:watch": "webpack --progress --color",
"watch": "webpack --progress --color --watch"
},
"files": [
"data",
"dist"
],
"author": "Eugene Pankov",
"license": "MIT",
"devDependencies": {
"@angular/compiler": "^15.2.6",
"@angular/compiler-cli": "^15.2.6",
"@angular/core": "^15.2.6",
"@angular/forms": "^15.2.6",
"@angular/platform-browser": "^15.2.6",
"@ng-bootstrap/ng-bootstrap": "^14.1.0",
"@ngtools/webpack": "^15.2.5",
"@types/node": "^18.15.3",
"@types/webpack-env": "^1.16.0",
"@yume-chan/adb": "workspace:^0.0.19",
"@yume-chan/stream-extra": "workspace:^0.0.19",
"babel-loader": "^9.1.2",
"mobx": "^6.7.0",
"source-map-loader": "^4.0.1",
"tabby-core": "^1.0.197-nightly.0",
"tabby-terminal": "^1.0.197-nightly.0",
"tslib": "^2.5.0",
"webpack": "^5.75.0",
"webpack-bundle-analyzer": "^4.7.0",
"webpack-cli": "^5.0.1"
},
"dependencies": {
"svg-inline-loader": "^0.8.2"
}
}

View file

@ -0,0 +1,44 @@
import { Injectable } from "@angular/core";
import {
AppService,
NotificationsService,
ProfilesService,
ToolbarButton,
ToolbarButtonProvider,
} from "tabby-core";
import { AdbState } from "./state";
@Injectable()
export class ButtonProvider extends ToolbarButtonProvider {
constructor(
app: AppService,
private profile: ProfilesService,
private notification: NotificationsService
) {
super();
app.ready$.subscribe(() => {
this.launchProfile(false);
});
}
private launchProfile(showError: boolean) {
if (AdbState.value) {
this.profile.openNewTabForProfile({ type: "adb", name: "Shell" });
} else if (showError) {
this.notification.error("Please connect your device first");
}
}
provide(): ToolbarButton[] {
return [
{
icon: require("./icons/plus.svg"),
title: "New tab",
click: () => {
this.launchProfile(true);
},
},
];
}
}

View file

@ -0,0 +1,44 @@
import { Component, Injector } from "@angular/core";
import {
BaseSession,
BaseTerminalProfile,
BaseTerminalTabComponent,
} from "tabby-terminal";
import { AdbSession } from "../session";
@Component({
selector: "adbTerminalTab",
template: BaseTerminalTabComponent.template,
styles: BaseTerminalTabComponent.styles,
animations: BaseTerminalTabComponent.animations,
})
export class AdbTerminalTabComponent extends BaseTerminalTabComponent<BaseTerminalProfile> {
constructor(injector: Injector) {
super(injector);
}
ngOnInit(): void {
this.logger = this.log.create("terminalTab");
this.session = new AdbSession(
this.injector,
this.logger
) as unknown as BaseSession;
super.ngOnInit();
}
protected onFrontendReady(): void {
this.initializeSession();
super.onFrontendReady();
}
initializeSession(): void {
this.session!.start(undefined);
this.attachSessionHandlers(true);
this.recoveryStateChangedHint.next();
}
ngOnDestroy(): void {
super.ngOnDestroy();
this.session?.destroy();
}
}

View file

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fal" data-icon="plus" class="svg-inline--fa fa-plus" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M432 256C432 264.8 424.8 272 416 272h-176V448c0 8.844-7.156 16.01-16 16.01S208 456.8 208 448V272H32c-8.844 0-16-7.15-16-15.99C16 247.2 23.16 240 32 240h176V64c0-8.844 7.156-15.99 16-15.99S240 55.16 240 64v176H416C424.8 240 432 247.2 432 256z"></path></svg>

After

Width:  |  Height:  |  Size: 462 B

View file

@ -0,0 +1,36 @@
import { NgModule } from "@angular/core";
import { FormsModule } from "@angular/forms";
import { NgbModule } from "@ng-bootstrap/ng-bootstrap";
import TabbyCorePlugin, {
AppService,
ProfileProvider,
ToolbarButtonProvider,
} from "tabby-core";
import TabbyTerminalModule from "tabby-terminal";
import { ButtonProvider } from "./buttonProvider";
import { AdbTerminalTabComponent } from "./components/terminalTab.component";
import { AdbProfilesService } from "./profiles";
import { AdbState } from "./state";
export { AdbState };
@NgModule({
imports: [FormsModule, NgbModule, TabbyCorePlugin, TabbyTerminalModule],
providers: [
{
provide: ProfileProvider,
useClass: AdbProfilesService,
multi: true,
},
{
provide: ToolbarButtonProvider,
useClass: ButtonProvider,
multi: true,
},
],
declarations: [AdbTerminalTabComponent],
exports: [AdbTerminalTabComponent],
})
export default class AdbTerminalModule {
constructor(app: AppService) {}
}

View file

@ -0,0 +1,41 @@
import { Injectable } from "@angular/core";
import {
NewTabParameters,
PartialProfile,
Profile,
ProfileProvider,
} from "tabby-core";
import { AdbTerminalTabComponent } from "./components/terminalTab.component";
@Injectable({ providedIn: "root" })
export class AdbProfilesService extends ProfileProvider<Profile> {
id = "adb";
name = "Adb shell";
async getBuiltinProfiles(): Promise<PartialProfile<Profile>[]> {
return [
{
id: "adb",
type: "adb",
name: "ADB shell",
icon: "fas fa-microchip",
isBuiltin: true,
},
];
}
async getNewTabParameters(
profile: Profile
): Promise<NewTabParameters<AdbTerminalTabComponent>> {
return {
type: AdbTerminalTabComponent,
inputs: {
profile,
},
};
}
getDescription(_profile: Profile): string {
return "";
}
}

View file

@ -0,0 +1,65 @@
import type { Injector } from "@angular/core";
import { AdbSubprocessProtocol } from "@yume-chan/adb";
import {
Consumable,
ConsumableWritableStream,
WritableStream,
WritableStreamDefaultWriter,
} from "@yume-chan/stream-extra";
import type { Logger } from "tabby-core";
import { BaseSession } from "tabby-terminal";
import { AdbState } from "./state";
export class AdbSession extends BaseSession {
private shell!: AdbSubprocessProtocol;
private writer!: WritableStreamDefaultWriter<Consumable<Uint8Array>>;
constructor(injector: Injector, logger: Logger) {
super(logger);
}
async start(): Promise<void> {
const adb = AdbState.value;
if (!adb) {
return;
}
this.open = true;
this.shell = await adb.subprocess.shell();
this.writer = this.shell.stdin.getWriter();
this.shell.stdout.pipeTo(
new WritableStream({
write: (chunk) => {
this.emitOutput(Buffer.from(chunk));
},
})
);
adb.disconnected.then(() => {
this.emitOutput(Buffer.from("\n[Disconnected]\n", "utf8"));
});
}
resize(columns: number, rows: number): void {
this.shell?.resize(columns, rows);
}
write(data: Buffer): void {
ConsumableWritableStream.write(this.writer, data);
}
kill(_signal?: string): void {
this.shell?.kill();
}
async gracefullyKillProcess(): Promise<void> {}
supportsWorkingDirectory(): boolean {
return false;
}
async getWorkingDirectory(): Promise<string | null> {
return null;
}
}

View file

@ -0,0 +1,6 @@
import { Adb } from "@yume-chan/adb";
import { makeAutoObservable } from "mobx";
export const AdbState = makeAutoObservable<{ value: Adb | null }>({
value: null,
});

View file

@ -0,0 +1,37 @@
{
"compilerOptions": {
"baseUrl": "src",
"module": "es2015",
"target": "es2016",
"moduleResolution": "node",
"noImplicitAny": false,
"removeComments": false,
"emitDeclarationOnly": false,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"sourceMap": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"importHelpers": true,
"strictNullChecks": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES7",
"ES2015",
"ES2017",
"ES2019",
"ES2021"
],
},
"angularCompilerOptions": {
"strictTemplates": true,
"enableResourceInlining": true,
"strictInjectionParameters": true,
}
}

View file

@ -0,0 +1,10 @@
import * as url from "url";
const __dirname = url.fileURLToPath(new URL(".", import.meta.url));
import config from "./webpack.plugin.config.mjs";
export default () =>
config({
name: "web-demo",
dirname: __dirname,
});

View file

@ -0,0 +1,182 @@
import { AngularWebpackPlugin } from "@ngtools/webpack";
import * as fs from "fs";
import * as path from "path";
import { BundleAnalyzerPlugin } from "webpack-bundle-analyzer";
const bundleAnalyzer = new BundleAnalyzerPlugin({
analyzerPort: 0,
});
import { createEs2015LinkerPlugin } from "@angular/compiler-cli/linker/babel";
const linkerPlugin = createEs2015LinkerPlugin({
linkerJitMode: true,
fileSystem: {
resolve: path.resolve,
exists: fs.existsSync,
dirname: path.dirname,
relative: path.relative,
readFile: fs.readFileSync,
},
});
export default (options) => {
const isDev = !!process.env.TABBY_DEV;
const config = {
target: "node",
entry: "./src/index.ts",
context: options.dirname,
devtool: "source-map",
output: {
path: path.resolve(options.dirname, "dist"),
filename: "index.js",
pathinfo: true,
libraryTarget: "umd",
publicPath: "auto",
},
mode: isDev ? "development" : "production",
optimization: {
minimize: false,
},
cache: !isDev
? false
: {
type: "filesystem",
cacheDirectory: path.resolve(
options.dirname,
"node_modules",
".webpack-cache"
),
},
resolve: {
alias: options.alias ?? {},
extensions: [".ts", ".js"],
mainFields: ["esm2015", "browser", "module", "main"],
},
ignoreWarnings: [/Failed to parse source map/],
module: {
rules: [
...(options.rules ?? []),
{
test: /.*\.m?js$/,
enforce: "pre",
use: ["source-map-loader"],
},
{
test: /\.m?js$/,
loader: "babel-loader",
options: {
plugins: [linkerPlugin],
compact: false,
cacheDirectory: true,
},
resolve: {
fullySpecified: false,
},
},
{
test: /\.ts$/,
use: [
{
loader: "@ngtools/webpack",
},
],
},
{
test: /\.pug$/,
use: [
"apply-loader",
{
loader: "pug-loader",
options: {
pretty: true,
},
},
],
},
{
test: /\.scss$/,
use: [
"@tabby-gang/to-string-loader",
"css-loader",
"sass-loader",
],
include: /(theme.*|component)\.scss/,
},
{
test: /\.scss$/,
use: ["style-loader", "css-loader", "sass-loader"],
exclude: /(theme.*|component)\.scss/,
},
{
test: /\.css$/,
use: ["@tabby-gang/to-string-loader", "css-loader"],
include: /component\.css/,
},
{
test: /\.css$/,
use: ["style-loader", "css-loader"],
exclude: /component\.css/,
},
{ test: /\.yaml$/, use: ["yaml-loader"] },
{ test: /\.svg/, use: ["svg-inline-loader"] },
{
test: /\.(eot|otf|woff|woff2|ogg)(\?v=[0-9]\.[0-9]\.[0-9])?$/,
type: "asset",
},
{
test: /\.ttf$/,
type: "asset/inline",
},
{
test: /\.po$/,
use: [
{ loader: "json-loader" },
{ loader: "po-gettext-loader" },
],
},
],
},
externals: [
"@electron/remote",
"@serialport/bindings",
"@serialport/bindings-cpp",
"any-promise",
"child_process",
"electron-promise-ipc",
"electron-updater",
"electron",
"fontmanager-redux",
"fs",
"keytar",
"macos-native-processlist",
"native-process-working-directory",
"net",
"ngx-toastr",
"os",
"path",
"readline",
"@luminati-io/socksv5",
"stream",
"windows-native-registry",
"windows-process-tree",
"windows-process-tree/build/Release/windows_process_tree.node",
/^@angular(?!\/common\/locales)/,
/^@ng-bootstrap/,
/^rxjs/,
/^tabby-/,
...(options.externals || []),
],
plugins: [
new AngularWebpackPlugin({
tsconfig: path.resolve(options.dirname, "tsconfig.json"),
directTemplateLoading: false,
jitMode: true,
}),
],
};
if (process.env.PLUGIN_BUNDLE_ANALYZER === options.name) {
config.plugins.push(bundleAnalyzer);
config.cache = false;
}
return config;
};

View file

@ -476,5 +476,13 @@
"projectFolder": "libraries/adb-scrcpy", "projectFolder": "libraries/adb-scrcpy",
"versionPolicyName": "adb" "versionPolicyName": "adb"
}, },
{
"packageName": "@yume-chan/tabby-tango",
"projectFolder": "libraries/tabby-tango"
},
{
"packageName": "@yume-chan/adb-backend-proxy",
"projectFolder": "libraries/adb-backend-proxy"
}
] ]
} }