mirror of
https://github.com/yume-chan/ya-webadb.git
synced 2025-10-06 03:50:18 +02:00
refactor: use Next.js for demo
This commit is contained in:
parent
5a2870f85c
commit
4a7a22a9d9
67 changed files with 3259 additions and 4604 deletions
35
.vscode/settings.json
vendored
35
.vscode/settings.json
vendored
|
@ -1,34 +1,30 @@
|
|||
{
|
||||
"cSpell.words": [
|
||||
"ADB's",
|
||||
"Backends",
|
||||
"CLASSPATH",
|
||||
"CLSE",
|
||||
"CNXN",
|
||||
"Callout",
|
||||
"Deserialization",
|
||||
"GPRS",
|
||||
"Genymobile's",
|
||||
"HSPA",
|
||||
"Muxer",
|
||||
"PKCS",
|
||||
"RSASSA",
|
||||
"Remux",
|
||||
"Remuxer",
|
||||
"Scrcpy",
|
||||
"WRTE",
|
||||
"addrs",
|
||||
"allowlist",
|
||||
"arraybuffer",
|
||||
"autorun",
|
||||
"Backends",
|
||||
"brotli",
|
||||
"Callout",
|
||||
"carriernetworkchange",
|
||||
"CLASSPATH",
|
||||
"CLSE",
|
||||
"CNXN",
|
||||
"cybojenix",
|
||||
"Deserialization",
|
||||
"endregion",
|
||||
"fluentui",
|
||||
"genymobile",
|
||||
"Genymobile's",
|
||||
"getprop",
|
||||
"GPRS",
|
||||
"hardcode",
|
||||
"hardcodes",
|
||||
"hhmm",
|
||||
"hisi",
|
||||
"HSPA",
|
||||
"jmuxer",
|
||||
"keyof",
|
||||
"killforward",
|
||||
|
@ -38,13 +34,19 @@
|
|||
"localabstract",
|
||||
"lstat",
|
||||
"mitm",
|
||||
"Muxer",
|
||||
"nosim",
|
||||
"opendir",
|
||||
"PKCS",
|
||||
"pluggable",
|
||||
"powersave",
|
||||
"reimplement",
|
||||
"reimplemented",
|
||||
"Remux",
|
||||
"Remuxer",
|
||||
"RSASSA",
|
||||
"runtimes",
|
||||
"Scrcpy",
|
||||
"scrollback",
|
||||
"sendrecv",
|
||||
"streamsaver",
|
||||
|
@ -63,6 +65,7 @@
|
|||
"webusb",
|
||||
"wifi",
|
||||
"wirelessly",
|
||||
"WRTE",
|
||||
"yume",
|
||||
"zstd"
|
||||
],
|
||||
|
|
3
apps/demo/.eslintrc.json
Normal file
3
apps/demo/.eslintrc.json
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
34
apps/demo/.gitignore
vendored
Normal file
34
apps/demo/.gitignore
vendored
Normal file
|
@ -0,0 +1,34 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
|
@ -1,19 +1,34 @@
|
|||
# WebADB Demo
|
||||
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
||||
|
||||
Demo of `@yume-chan/adb` and `@yume-chan/adb-backend-webusb`.
|
||||
## Getting Started
|
||||
|
||||
## Start
|
||||
First, run the development server:
|
||||
|
||||
From root folder:
|
||||
|
||||
```sh
|
||||
npm run start:demo
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
```
|
||||
|
||||
From this folder:
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
```sh
|
||||
npm start
|
||||
```
|
||||
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
Then navigate to `http://localhost:9000`.
|
||||
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
|
||||
|
||||
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
||||
|
|
|
@ -1,30 +1,19 @@
|
|||
import { DefaultButton, Dialog, Dropdown, IDropdownOption, PrimaryButton, ProgressIndicator, Stack, StackItem, TooltipHost } from '@fluentui/react';
|
||||
import { Adb, AdbBackend, AdbLogger } from '@yume-chan/adb';
|
||||
import { Adb, AdbBackend } from '@yume-chan/adb';
|
||||
import AdbWebUsbBackend, { AdbWebCredentialStore, AdbWebUsbBackendWatcher } from '@yume-chan/adb-backend-webusb';
|
||||
import AdbWsBackend from '@yume-chan/adb-backend-ws';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { CommonStackTokens } from '../styles';
|
||||
import { withDisplayName } from '../utils';
|
||||
import { device, logger } from '../state';
|
||||
import { CommonStackTokens } from '../utils';
|
||||
import { ErrorDialogContext } from './error-dialog';
|
||||
|
||||
const DropdownStyles = { dropdown: { width: '100%' } };
|
||||
|
||||
interface ConnectProps {
|
||||
device: Adb | undefined;
|
||||
|
||||
logger?: AdbLogger;
|
||||
|
||||
onDeviceChange: (device: Adb | undefined) => void;
|
||||
}
|
||||
|
||||
const CredentialStore = new AdbWebCredentialStore();
|
||||
|
||||
export const Connect = withDisplayName('Connect')(({
|
||||
device,
|
||||
logger,
|
||||
onDeviceChange,
|
||||
}: ConnectProps): JSX.Element | null => {
|
||||
const supported = AdbWebUsbBackend.isSupported();
|
||||
function _Connect(): JSX.Element | null {
|
||||
const [supported, setSupported] = useState(true);
|
||||
|
||||
const { show: showErrorDialog } = useContext(ErrorDialogContext);
|
||||
|
||||
|
@ -37,29 +26,39 @@ export const Connect = withDisplayName('Connect')(({
|
|||
setUsbBackendList(backendList);
|
||||
return backendList;
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!supported) {
|
||||
showErrorDialog('Your browser does not support WebUSB standard, which is required for this site to work.\n\nLatest version of Google Chrome (for Windows, macOS, Linux and Android), Microsoft Edge (for Windows and macOS), or other Chromium-based browsers should work.');
|
||||
return;
|
||||
}
|
||||
|
||||
updateUsbBackendList();
|
||||
useEffect(
|
||||
() => {
|
||||
// Only run on client
|
||||
const supported = AdbWebUsbBackend.isSupported();
|
||||
setSupported(supported);
|
||||
|
||||
const watcher = new AdbWebUsbBackendWatcher(async (serial?: string) => {
|
||||
const list = await updateUsbBackendList();
|
||||
|
||||
if (serial) {
|
||||
setSelectedBackend(list.find(backend => backend.serial === serial));
|
||||
if (!supported) {
|
||||
showErrorDialog('Your browser does not support WebUSB standard, which is required for this site to work.\n\nLatest version of Google Chrome, Microsoft Edge, or other Chromium-based browsers are required.');
|
||||
return;
|
||||
}
|
||||
});
|
||||
return () => watcher.dispose();
|
||||
}, []);
|
||||
|
||||
updateUsbBackendList();
|
||||
|
||||
const watcher = new AdbWebUsbBackendWatcher(async (serial?: string) => {
|
||||
const list = await updateUsbBackendList();
|
||||
|
||||
if (serial) {
|
||||
setSelectedBackend(list.find(backend => backend.serial === serial));
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
return () => watcher.dispose();
|
||||
},
|
||||
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
||||
[]
|
||||
);
|
||||
|
||||
const [wsBackendList, setWsBackendList] = useState<AdbBackend[]>([]);
|
||||
useEffect(() => {
|
||||
const intervalId = setInterval(async () => {
|
||||
if (connecting || device) {
|
||||
if (connecting || device.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -78,10 +77,10 @@ export const Connect = withDisplayName('Connect')(({
|
|||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [connecting, device]);
|
||||
}, [connecting]);
|
||||
|
||||
const handleSelectedBackendChange = (
|
||||
_e: React.FormEvent<HTMLDivElement>,
|
||||
e: React.FormEvent<HTMLDivElement>,
|
||||
option?: IDropdownOption,
|
||||
) => {
|
||||
setSelectedBackend(option?.data as AdbBackend);
|
||||
|
@ -91,18 +90,18 @@ export const Connect = withDisplayName('Connect')(({
|
|||
const backend = await AdbWebUsbBackend.requestDevice();
|
||||
setSelectedBackend(backend);
|
||||
await updateUsbBackendList();
|
||||
}, []);
|
||||
}, [updateUsbBackendList]);
|
||||
|
||||
const connect = useCallback(async () => {
|
||||
try {
|
||||
if (selectedBackend) {
|
||||
const device = new Adb(selectedBackend, logger);
|
||||
const adb = new Adb(selectedBackend, logger.logger);
|
||||
try {
|
||||
setConnecting(true);
|
||||
await device.connect(CredentialStore);
|
||||
onDeviceChange(device);
|
||||
await adb.connect(CredentialStore);
|
||||
device.setCurrent(adb);
|
||||
} catch (e) {
|
||||
device.dispose();
|
||||
adb.dispose();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
@ -111,20 +110,15 @@ export const Connect = withDisplayName('Connect')(({
|
|||
} finally {
|
||||
setConnecting(false);
|
||||
}
|
||||
}, [selectedBackend, logger, onDeviceChange]);
|
||||
}, [showErrorDialog, selectedBackend]);
|
||||
const disconnect = useCallback(async () => {
|
||||
try {
|
||||
await device!.dispose();
|
||||
onDeviceChange(undefined);
|
||||
await device.current!.dispose();
|
||||
device.setCurrent(undefined);
|
||||
} catch (e: any) {
|
||||
showErrorDialog(e.message);
|
||||
}
|
||||
}, [device]);
|
||||
useEffect(() => {
|
||||
return device?.onDisconnected(() => {
|
||||
onDeviceChange(undefined);
|
||||
});
|
||||
}, [device, onDeviceChange]);
|
||||
}, [showErrorDialog]);
|
||||
|
||||
const backendList = useMemo(
|
||||
() => ([] as AdbBackend[]).concat(usbBackendList, wsBackendList),
|
||||
|
@ -157,7 +151,7 @@ export const Connect = withDisplayName('Connect')(({
|
|||
tokens={{ childrenGap: 8, padding: '0 0 8px 8px' }}
|
||||
>
|
||||
<Dropdown
|
||||
disabled={!!device || backendOptions.length === 0}
|
||||
disabled={!!device.current || backendOptions.length === 0}
|
||||
label="Available devices"
|
||||
placeholder="No available devices"
|
||||
options={backendOptions}
|
||||
|
@ -167,7 +161,7 @@ export const Connect = withDisplayName('Connect')(({
|
|||
onChange={handleSelectedBackendChange}
|
||||
/>
|
||||
|
||||
{!device ? (
|
||||
{!device.current ? (
|
||||
<Stack horizontal tokens={CommonStackTokens}>
|
||||
<StackItem grow shrink>
|
||||
<PrimaryButton
|
||||
|
@ -207,4 +201,6 @@ export const Connect = withDisplayName('Connect')(({
|
|||
</Dialog>
|
||||
</Stack>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const Connect = observer(_Connect);
|
|
@ -21,16 +21,14 @@ function useDemoModeSetting<T>(
|
|||
if (enabled) {
|
||||
onChange(value);
|
||||
}
|
||||
}, [enabled]);
|
||||
}, [enabled, onChange, value]);
|
||||
|
||||
const handleChange = useCallback((value: T) => {
|
||||
setValue(value);
|
||||
if (enabled) {
|
||||
onChange(value);
|
||||
} else {
|
||||
if (!enabled) {
|
||||
setEnabled(true);
|
||||
}
|
||||
}, [enabled, onChange]);
|
||||
}, [enabled, setEnabled]);
|
||||
|
||||
return [value, handleChange];
|
||||
}
|
|
@ -20,7 +20,7 @@ export const ErrorDialogProvider = withDisplayName('ErrorDialogProvider')((props
|
|||
setErrorMessage(message);
|
||||
showErrorDialog();
|
||||
}
|
||||
}), []);
|
||||
}), [showErrorDialog]);
|
||||
|
||||
return (
|
||||
<ErrorDialogContext.Provider value={context}>
|
|
@ -4,11 +4,8 @@ import { withDisplayName } from '../utils/with-display-name';
|
|||
|
||||
export interface ExternalLinkProps {
|
||||
href: string;
|
||||
|
||||
spaceBefore?: boolean;
|
||||
|
||||
spaceAfter?: boolean;
|
||||
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
|
@ -5,4 +5,3 @@ export * from './device-view';
|
|||
export * from './error-dialog';
|
||||
export * from './external-link';
|
||||
export * from './logger';
|
||||
export * from './router';
|
|
@ -1,8 +1,9 @@
|
|||
import { IconButton, IListProps, List, mergeStyles, mergeStyleSets, Stack } from '@fluentui/react';
|
||||
import { AdbLogger, AdbPacket, AdbPacketInit } from '@yume-chan/adb';
|
||||
import { AdbPacketInit } from '@yume-chan/adb';
|
||||
import { decodeUtf8 } from '@yume-chan/adb-backend-webusb';
|
||||
import { DisposableList, EventEmitter } from '@yume-chan/event';
|
||||
import { DisposableList } from '@yume-chan/event';
|
||||
import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { logger } from "../state";
|
||||
import { withDisplayName } from '../utils';
|
||||
import { CommandBar } from './command-bar';
|
||||
|
||||
|
@ -22,28 +23,6 @@ const classNames = mergeStyleSets({
|
|||
},
|
||||
});
|
||||
|
||||
export class AdbEventLogger {
|
||||
private readonly _logger: AdbLogger;
|
||||
public get logger() { return this._logger; }
|
||||
|
||||
private readonly _incomingPacketEvent = new EventEmitter<AdbPacket>();
|
||||
public get onIncomingPacket() { return this._incomingPacketEvent.event; }
|
||||
|
||||
private readonly _outgoingPacketEvent = new EventEmitter<AdbPacketInit>();
|
||||
public get onOutgoingPacket() { return this._outgoingPacketEvent.event; }
|
||||
|
||||
public constructor() {
|
||||
this._logger = {
|
||||
onIncomingPacket: (packet) => {
|
||||
this._incomingPacketEvent.fire(packet);
|
||||
},
|
||||
onOutgoingPacket: (packet) => {
|
||||
this._outgoingPacketEvent.fire(packet);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function serializePacket(packet: AdbPacketInit) {
|
||||
const command = decodeUtf8(new Uint32Array([packet.command]).buffer);
|
||||
|
||||
|
@ -77,7 +56,6 @@ const LoggerLine = withDisplayName('LoggerLine')(({ packet }: { packet: [string,
|
|||
|
||||
export interface LoggerContextValue {
|
||||
visible: boolean;
|
||||
|
||||
onVisibleChange: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
|
@ -119,8 +97,6 @@ export const ToggleLogger = withDisplayName('ToggleLogger')(() => {
|
|||
|
||||
export interface LoggerProps {
|
||||
className?: string;
|
||||
|
||||
logger: AdbEventLogger;
|
||||
}
|
||||
|
||||
function shouldVirtualize(props: IListProps<[string, AdbPacketInit]>) {
|
||||
|
@ -139,7 +115,6 @@ function renderCell(item?: [string, AdbPacketInit]) {
|
|||
|
||||
export const Logger = withDisplayName('Logger')(({
|
||||
className,
|
||||
logger,
|
||||
}: LoggerProps) => {
|
||||
const contextValue = useContext(LoggerContext);
|
||||
const [packets, setPackets] = useState<[string, AdbPacketInit][]>([]);
|
||||
|
@ -162,7 +137,7 @@ export const Logger = withDisplayName('Logger')(({
|
|||
});
|
||||
}));
|
||||
return disposables.dispose;
|
||||
}, [logger]);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const scroller = scrollerRef.current;
|
6
apps/demo/next-env.d.ts
vendored
Normal file
6
apps/demo/next-env.d.ts
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/types/global" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
20
apps/demo/next.config.js
Normal file
20
apps/demo/next.config.js
Normal file
|
@ -0,0 +1,20 @@
|
|||
const withMDX = require('@next/mdx')({
|
||||
extension: /\.mdx?$/,
|
||||
options: {
|
||||
// Disable MDX createElement hack
|
||||
// because we don't need rendering custom elements
|
||||
renderer: `
|
||||
import React from 'react';
|
||||
const mdx = (name, props, ...children) => {
|
||||
return React.createElement(name, props, ...children);
|
||||
}
|
||||
`,
|
||||
}
|
||||
});
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
module.exports = withMDX({
|
||||
pageExtensions: ['js', 'jsx', 'ts', 'tsx', 'md', 'mdx'],
|
||||
reactStrictMode: true,
|
||||
productionBrowserSourceMaps: true,
|
||||
});
|
|
@ -1,69 +1,45 @@
|
|||
{
|
||||
"name": "demo",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"version": "0.0.9",
|
||||
"description": "Demo of `@yume-chan/adb` and `@yume-chan/adb-backend-webusb`.",
|
||||
"author": "Simon Chan <cnsimonchan@live.com>",
|
||||
"homepage": "https://github.com/yume-chan/ya-webadb#readme",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/yume-chan/ya-webadb.git"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "webpack serve",
|
||||
"build": "webpack --mode production",
|
||||
"build:dev": "webpack"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/yume-chan/ya-webadb/issues"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^26.0.23",
|
||||
"@types/react": "^17.0.0",
|
||||
"@types/react-dom": "^17.0.0",
|
||||
"@types/react-router-dom": "^5.1.6",
|
||||
"clean-webpack-plugin": "^4.0.0",
|
||||
"copy-webpack-plugin": "^8.0.0",
|
||||
"css-loader": "^5.2.4",
|
||||
"file-loader": "^6.2.0",
|
||||
"html-webpack-plugin": "^5.3.1",
|
||||
"mini-css-extract-plugin": "^1.4.0",
|
||||
"source-map-loader": "^2.0.1",
|
||||
"ts-loader": "^9.1.2",
|
||||
"tslib": "^2.3.1",
|
||||
"typescript": "^4.4.3",
|
||||
"webpack": "^5.28.0",
|
||||
"webpack-bundle-analyzer": "^4.4.0",
|
||||
"webpack-cli": "^4.7.0",
|
||||
"webpack-dev-server": "^3.11.1",
|
||||
"webpackbar": "^5.0.0-3",
|
||||
"worker-loader": "^3.0.7",
|
||||
"@yume-chan/ts-package-builder": "^1.0.0"
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluentui/font-icons-mdl2": "^8.1.9",
|
||||
"@fluentui/react": "^8.29.1",
|
||||
"@fluentui/react-file-type-icons": "^8.2.3",
|
||||
"@fluentui/react-hooks": "^8.2.7",
|
||||
"@types/node": "^16.9.1",
|
||||
"@fluentui/font-icons-mdl2": "^8.1.14",
|
||||
"@fluentui/react": "^8.36.3",
|
||||
"@fluentui/react-file-type-icons": "^8.4.3",
|
||||
"@fluentui/react-hooks": "^8.3.4",
|
||||
"@yume-chan/adb": "^0.0.9",
|
||||
"@yume-chan/adb-backend-webusb": "^0.0.9",
|
||||
"@yume-chan/adb-backend-ws": "^0.0.9",
|
||||
"@yume-chan/async": "^2.1.4",
|
||||
"@yume-chan/event": "^0.0.9",
|
||||
"@yume-chan/struct": "^0.0.9",
|
||||
"path-browserify": "^1.0.1",
|
||||
"mobx": "^6.3.3",
|
||||
"mobx-react-lite": "^3.2.1",
|
||||
"next": "11.1.2",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"streamsaver": "^2.0.5",
|
||||
"tinyh264": "^0.0.6",
|
||||
"tinyh264": "^0.0.7",
|
||||
"xterm": "^4.14.1",
|
||||
"xterm-addon-fit": "^0.5.0",
|
||||
"xterm-addon-search": "^0.8.1",
|
||||
"xterm-addon-webgl": "^0.11.2",
|
||||
"yuv-buffer": "^1.0.0",
|
||||
"yuv-canvas": "^1.2.6"
|
||||
"yuv-canvas": "^1.2.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mdx-js/loader": "^1.6.22",
|
||||
"@next/mdx": "^11.1.2",
|
||||
"@types/react": "17.0.27",
|
||||
"copy-webpack-plugin": "^9.0.1",
|
||||
"eslint": "7.32.0",
|
||||
"eslint-config-next": "^11.1.3-canary.52",
|
||||
"typescript": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
|
114
apps/demo/pages/_app.tsx
Normal file
114
apps/demo/pages/_app.tsx
Normal file
|
@ -0,0 +1,114 @@
|
|||
import { ActionButton, IComponentAsProps, IconButton, INavButtonProps, INavLink, initializeIcons, Link as FluentLink, 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 React, { useCallback, useEffect, useState } from "react";
|
||||
import { Connect, ErrorDialogProvider, Logger, ToggleLogger } from "../components";
|
||||
import '../styles/globals.css';
|
||||
|
||||
initializeIcons();
|
||||
|
||||
const ROUTES = [
|
||||
{
|
||||
url: '/',
|
||||
name: 'README',
|
||||
},
|
||||
{
|
||||
url: '/device-info',
|
||||
name: 'Device Info',
|
||||
},
|
||||
{
|
||||
url: '/file-manager',
|
||||
name: 'File Manager',
|
||||
},
|
||||
];
|
||||
|
||||
function NavLink({ link, defaultRender: DefaultRender, ...props }: IComponentAsProps<INavButtonProps>) {
|
||||
if (!link) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={link.url} passHref>
|
||||
<DefaultRender {...props} />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function MyApp({ Component, pageProps }: AppProps) {
|
||||
const classNames = mergeStyleSets({
|
||||
'title-container': {
|
||||
borderBottom: '1px solid rgb(243, 242, 241)',
|
||||
},
|
||||
title: {
|
||||
padding: '4px 0',
|
||||
fontSize: 20,
|
||||
textAlign: 'center',
|
||||
},
|
||||
'left-column': {
|
||||
width: 250,
|
||||
paddingRight: 8,
|
||||
borderRight: '1px solid rgb(243, 242, 241)',
|
||||
overflow: 'auto',
|
||||
},
|
||||
'right-column': {
|
||||
borderLeft: '1px solid rgb(243, 242, 241)',
|
||||
}
|
||||
});
|
||||
|
||||
const [leftPanelVisible, setLeftPanelVisible] = useState(false);
|
||||
const toggleLeftPanel = useCallback(() => {
|
||||
setLeftPanelVisible(value => !value);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
setLeftPanelVisible(innerWidth > 650);
|
||||
}, []);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<ErrorDialogProvider>
|
||||
<Stack verticalFill>
|
||||
<Stack className={classNames['title-container']} horizontal verticalAlign="center">
|
||||
<IconButton
|
||||
checked={leftPanelVisible}
|
||||
title="Toggle Menu"
|
||||
iconProps={{ iconName: 'GlobalNavButton' }}
|
||||
onClick={toggleLeftPanel}
|
||||
/>
|
||||
|
||||
<StackItem grow>
|
||||
<div className={classNames.title}>WebADB Demo</div>
|
||||
</StackItem>
|
||||
|
||||
<ToggleLogger />
|
||||
</Stack>
|
||||
|
||||
<Stack grow horizontal verticalFill disableShrink styles={{ root: { minHeight: 0, overflow: 'hidden', lineHeight: '1.5' } }}>
|
||||
<StackItem className={mergeStyles(classNames['left-column'], !leftPanelVisible && { display: 'none' })}>
|
||||
<Connect />
|
||||
|
||||
<Nav
|
||||
groups={[{
|
||||
links: ROUTES.map(route => ({
|
||||
...route,
|
||||
key: route.url,
|
||||
})),
|
||||
}]}
|
||||
linkAs={NavLink}
|
||||
selectedKey={router.pathname}
|
||||
/>
|
||||
</StackItem>
|
||||
|
||||
<StackItem grow styles={{ root: { width: 0 } }}>
|
||||
<Component {...pageProps} />
|
||||
</StackItem>
|
||||
</Stack>
|
||||
|
||||
<Logger className={classNames['right-column']} />
|
||||
</Stack>
|
||||
</ErrorDialogProvider >
|
||||
);
|
||||
}
|
||||
|
||||
export default MyApp;
|
35
apps/demo/pages/_document.tsx
Normal file
35
apps/demo/pages/_document.tsx
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { resetIds, Stylesheet } from "@fluentui/react";
|
||||
import { enableStaticRendering } from 'mobx-react-lite';
|
||||
import Document, { DocumentContext, DocumentInitialProps } from 'next/document';
|
||||
|
||||
enableStaticRendering(true);
|
||||
|
||||
const stylesheet = Stylesheet.getInstance();
|
||||
|
||||
export default class MyDocument extends Document {
|
||||
static async getInitialProps(context: DocumentContext): Promise<DocumentInitialProps> {
|
||||
resetIds();
|
||||
|
||||
const { html, head, styles, ...rest } = await Document.getInitialProps(context);
|
||||
|
||||
return {
|
||||
...rest,
|
||||
html,
|
||||
head: [
|
||||
...(head ?? []),
|
||||
<script key="fluentui" dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
window.FabricConfig = window.FabricConfig || {};
|
||||
window.FabricConfig.serializedStylesheet = ${stylesheet.serialize()};
|
||||
`
|
||||
}} />
|
||||
],
|
||||
styles: (
|
||||
<>
|
||||
{styles}
|
||||
<style dangerouslySetInnerHTML={{ __html: stylesheet.getRules() }} />
|
||||
</>
|
||||
)
|
||||
};
|
||||
}
|
||||
}
|
|
@ -1,8 +1,12 @@
|
|||
import { Icon, MessageBar, Separator, TooltipHost } from '@fluentui/react';
|
||||
import { AdbFeatures } from '@yume-chan/adb';
|
||||
import { ExternalLink } from '../components';
|
||||
import { withDisplayName } from '../utils';
|
||||
import { useAdbDevice } from './type';
|
||||
import { Icon, MessageBar, Separator, Stack, TooltipHost } from "@fluentui/react";
|
||||
import { AdbFeatures } from "@yume-chan/adb";
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import type { NextPage } from 'next';
|
||||
import Head from 'next/head';
|
||||
import React from "react";
|
||||
import { ExternalLink } from "../components";
|
||||
import { device } from '../state';
|
||||
import { RouteStackProps } from "../utils";
|
||||
|
||||
const knownFeatures: Record<string, string> = {
|
||||
'shell_v2': `"shell" command now supports separating child process's stdout and stderr, and returning exit code`,
|
||||
|
@ -23,11 +27,14 @@ const knownFeatures: Record<string, string> = {
|
|||
// 'sendrecv_v2_dry_run_send': '',
|
||||
};
|
||||
|
||||
export const DeviceInfo = withDisplayName('DeviceInfo')((): JSX.Element | null => {
|
||||
const device = useAdbDevice();
|
||||
const DeviceInfo: NextPage = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack {...RouteStackProps}>
|
||||
<Head>
|
||||
<title>Device Info - WebADB</title>
|
||||
</Head>
|
||||
|
||||
<MessageBar>
|
||||
<span>ADB protocol version decides the packet format between client and server. By now it has 2 versions:</span>
|
||||
<br />
|
||||
|
@ -45,7 +52,7 @@ export const DeviceInfo = withDisplayName('DeviceInfo')((): JSX.Element | null =
|
|||
</MessageBar>
|
||||
<span>
|
||||
<span>Protocol Version: </span>
|
||||
<code>{device?.protocolVersion?.toString(16).padStart(8, '0')}</code>
|
||||
<code>{device.current?.protocolVersion?.toString(16).padStart(8, '0')}</code>
|
||||
</span>
|
||||
<Separator />
|
||||
|
||||
|
@ -53,21 +60,21 @@ export const DeviceInfo = withDisplayName('DeviceInfo')((): JSX.Element | null =
|
|||
<code>ro.product.name</code>
|
||||
<span> field in Android Build Props</span>
|
||||
</MessageBar>
|
||||
<span>Product Name: {device?.product}</span>
|
||||
<span>Product Name: {device.current?.product}</span>
|
||||
<Separator />
|
||||
|
||||
<MessageBar>
|
||||
<code>ro.product.model</code>
|
||||
<span> field in Android Build Props</span>
|
||||
</MessageBar>
|
||||
<span>Model Name: {device?.model}</span>
|
||||
<span>Model Name: {device.current?.model}</span>
|
||||
<Separator />
|
||||
|
||||
<MessageBar>
|
||||
<code>ro.product.device</code>
|
||||
<span> field in Android Build Props</span>
|
||||
</MessageBar>
|
||||
<span>Device Name: {device?.device}</span>
|
||||
<span>Device Name: {device.current?.device}</span>
|
||||
<Separator />
|
||||
|
||||
<MessageBar>
|
||||
|
@ -75,29 +82,25 @@ export const DeviceInfo = withDisplayName('DeviceInfo')((): JSX.Element | null =
|
|||
<br />
|
||||
|
||||
<span>For example, it may indicate the availability of a new command, </span>
|
||||
<span>or a workaround for an old bug is not required because it's already been fixed.</span>
|
||||
<span>{`or a workaround for an old bug is not required because it's already been fixed.`}</span>
|
||||
<br />
|
||||
</MessageBar>
|
||||
<span>
|
||||
<span>Features: </span>
|
||||
{device?.features?.map((feature, index) => (
|
||||
<span>
|
||||
{device.current?.features?.map((feature, index) => (
|
||||
<span key={feature}>
|
||||
{index !== 0 && (<span>, </span>)}
|
||||
<span>{feature}</span>
|
||||
{knownFeatures[feature] && (
|
||||
<TooltipHost
|
||||
content={
|
||||
<>
|
||||
<span>{knownFeatures[feature]}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TooltipHost content={<span>{knownFeatures[feature]}</span>}>
|
||||
<Icon style={{ marginLeft: 4 }} iconName="Unknown" />
|
||||
</TooltipHost>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</>
|
||||
</Stack>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default observer(DeviceInfo);
|
|
@ -1,17 +1,25 @@
|
|||
import { Breadcrumb, concatStyleSets, ContextualMenu, ContextualMenuItem, DetailsListLayoutMode, Dialog, DirectionalHint, IBreadcrumbItem, IColumn, Icon, IContextualMenuItem, IDetailsHeaderProps, IDetailsList, IRenderFunction, Layer, MarqueeSelection, mergeStyleSets, Overlay, ProgressIndicator, Selection, ShimmeredDetailsList, StackItem } from '@fluentui/react';
|
||||
import { Breadcrumb, concatStyleSets, ContextualMenu, ContextualMenuItem, DetailsListLayoutMode, Dialog, DirectionalHint, IBreadcrumbItem, IColumn, Icon, IContextualMenuItem, IDetailsHeaderProps, IDetailsList, IRenderFunction, Layer, MarqueeSelection, mergeStyleSets, Overlay, ProgressIndicator, ScrollToMode, Selection, ShimmeredDetailsList, Stack, StackItem } from '@fluentui/react';
|
||||
import { FileIconType, getFileTypeIconProps, initializeFileTypeIcons } from '@fluentui/react-file-type-icons';
|
||||
import { useConst } from '@fluentui/react-hooks';
|
||||
import { AdbSyncEntryResponse, AdbSyncMaxPacketSize, LinuxFileType } from '@yume-chan/adb';
|
||||
import { makeAutoObservable, observable, reaction, runInAction } from "mobx";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { NextPage } from "next";
|
||||
import Head from "next/head";
|
||||
import Router, { useRouter } from "next/router";
|
||||
import path from 'path';
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import StreamSaver from 'streamsaver';
|
||||
import React, { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { CommandBar, ErrorDialogContext } from '../components';
|
||||
import { delay, formatSize, formatSpeed, pickFile, useSpeed, withDisplayName } from '../utils';
|
||||
import { RouteProps, useAdbDevice } from './type';
|
||||
import { device } from '../state';
|
||||
import { chunkFile, formatSize, formatSpeed, pickFile, RouteStackProps, useSpeed } from '../utils';
|
||||
|
||||
let StreamSaver: typeof import('streamsaver');
|
||||
if (typeof window !== 'undefined') {
|
||||
StreamSaver = require('streamsaver');
|
||||
StreamSaver.mitm = 'streamsaver/mitm.html';
|
||||
}
|
||||
|
||||
initializeFileTypeIcons();
|
||||
StreamSaver.mitm = 'streamsaver/mitm.html';
|
||||
|
||||
interface ListItem extends AdbSyncEntryResponse {
|
||||
key: string;
|
||||
|
@ -66,12 +74,6 @@ function createReadableStreamFromBufferIterator(
|
|||
});
|
||||
}
|
||||
|
||||
export async function* chunkFile(file: File): AsyncGenerator<ArrayBuffer, void, void> {
|
||||
for (let i = 0; i < file.size; i += AdbSyncMaxPacketSize) {
|
||||
yield file.slice(i, i + AdbSyncMaxPacketSize, file.type).arrayBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
function compareCaseInsensitively(a: string, b: string) {
|
||||
let result = a.toLocaleLowerCase().localeCompare(b.toLocaleLowerCase());
|
||||
if (result !== 0) {
|
||||
|
@ -81,170 +83,99 @@ function compareCaseInsensitively(a: string, b: string) {
|
|||
}
|
||||
}
|
||||
|
||||
export interface FileManagerProps {
|
||||
currentPath: string;
|
||||
onPathChange: (path: string) => void;
|
||||
}
|
||||
function asyncEffect(effect: (signal: AbortSignal) => Promise<void | (() => void)>) {
|
||||
let cancelLast = () => { };
|
||||
|
||||
function useAsyncEffect(effect: (signal: AbortSignal) => Promise<void | (() => void)>, deps?: unknown[]) {
|
||||
useEffect(() => {
|
||||
const abortController = new AbortController();
|
||||
let cleanup: void | (() => void);
|
||||
|
||||
effect(abortController.signal)
|
||||
.then(result => {
|
||||
cleanup = result;
|
||||
|
||||
// Abortion requested but the effect still finished
|
||||
// Immediately call cleanup
|
||||
if (abortController.signal.aborted) {
|
||||
cleanup?.();
|
||||
}
|
||||
}, err => {
|
||||
if (err instanceof DOMException) {
|
||||
// Ignore errors from AbortSignal-aware APIs
|
||||
// (e.g. `fetch`)
|
||||
if (err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.error(err);
|
||||
});
|
||||
|
||||
return () => {
|
||||
return async () => {
|
||||
cancelLast();
|
||||
cancelLast = () => {
|
||||
// Effect finished before abortion
|
||||
// Call cleanup
|
||||
cleanup?.();
|
||||
if (typeof cleanup === 'function') {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
// Request abortion
|
||||
abortController.abort();
|
||||
};
|
||||
}, deps);
|
||||
|
||||
const abortController = new AbortController();
|
||||
let cleanup: void | (() => void);
|
||||
|
||||
try {
|
||||
cleanup = await effect(abortController.signal);
|
||||
|
||||
// Abortion requested but the effect still finished
|
||||
// Immediately call cleanup
|
||||
if (abortController.signal.aborted) {
|
||||
if (typeof cleanup === 'function') {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException) {
|
||||
// Ignore errors from AbortSignal-aware APIs
|
||||
// (e.g. `fetch`)
|
||||
if (e.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const FileManager = withDisplayName('FileManager')(({
|
||||
currentPath,
|
||||
onPathChange,
|
||||
}: FileManagerProps): JSX.Element | null => {
|
||||
const { show: showErrorDialog } = useContext(ErrorDialogContext);
|
||||
class FileManagerState {
|
||||
path = '/';
|
||||
loading = false;
|
||||
items: ListItem[] = [];
|
||||
sortKey: keyof ListItem = 'name';
|
||||
sortDescending = false;
|
||||
startItemIndexInView = 0;
|
||||
|
||||
const breadcrumbItems = useMemo((): IBreadcrumbItem[] => {
|
||||
constructor() {
|
||||
makeAutoObservable(this, {
|
||||
items: observable.shallow,
|
||||
});
|
||||
reaction(
|
||||
() => device.current,
|
||||
() => this.loadFiles(),
|
||||
{ fireImmediately: true },
|
||||
);
|
||||
}
|
||||
|
||||
get breadcrumbItems(): IBreadcrumbItem[] {
|
||||
let part = '';
|
||||
const list: IBreadcrumbItem[] = currentPath.split('/').filter(Boolean).map(segment => {
|
||||
const list: IBreadcrumbItem[] = this.path.split('/').filter(Boolean).map(segment => {
|
||||
part += '/' + segment;
|
||||
return {
|
||||
key: part,
|
||||
text: segment,
|
||||
onClick: (_e, item) => {
|
||||
onClick: (e, item) => {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
onPathChange(item.key);
|
||||
this.pushPathQuery(item.key);
|
||||
},
|
||||
};
|
||||
});
|
||||
list.unshift({
|
||||
key: '/',
|
||||
text: 'Device',
|
||||
onClick: () => onPathChange('/'),
|
||||
onClick: () => this.pushPathQuery('/'),
|
||||
});
|
||||
list[list.length - 1].isCurrentItem = true;
|
||||
delete list[list.length - 1].onClick;
|
||||
return list;
|
||||
}, [currentPath, onPathChange]);
|
||||
}
|
||||
|
||||
const device = useAdbDevice();
|
||||
// In theory, re-invoke the effect with `setForceReload({})`
|
||||
// will cause a full re-render.
|
||||
// But this is how React was designed.
|
||||
const [forceReload, setForceReload] = useState({});
|
||||
const [items, setItems] = useState<ListItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const listRef = useRef<IDetailsList | null>(null);
|
||||
pushPathQuery(path: string) {
|
||||
Router.push({ query: { ...Router.query, path } });
|
||||
}
|
||||
|
||||
useAsyncEffect(async (signal) => {
|
||||
// Tell eslint we depends on `forceReload`
|
||||
// (even if eslint doesn't support custom hooks)
|
||||
// (and eslint is not enabled for this project)
|
||||
void forceReload;
|
||||
|
||||
setItems([]);
|
||||
|
||||
if (!device) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const sync = await device.sync();
|
||||
|
||||
const items: ListItem[] = [];
|
||||
const linkItems: AdbSyncEntryResponse[] = [];
|
||||
const intervalId = setInterval(() => {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setItems(items.slice());
|
||||
}, 1000);
|
||||
|
||||
try {
|
||||
let lastBreak = Date.now();
|
||||
|
||||
for await (const entry of sync.opendir(currentPath)) {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.name === '.' || entry.name === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.type === LinuxFileType.Link) {
|
||||
linkItems.push(entry);
|
||||
} else {
|
||||
items.push(toListItem(entry));
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastBreak > 16) {
|
||||
await delay(0);
|
||||
lastBreak = now;
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of linkItems) {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await sync.isDirectory(path.resolve(currentPath, entry.name!))) {
|
||||
entry.mode = (LinuxFileType.File << 12) | entry.permission;
|
||||
entry.size = 0;
|
||||
toListItem(entry);
|
||||
}
|
||||
items.push(toListItem(entry));
|
||||
}
|
||||
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
setItems(items);
|
||||
listRef.current?.scrollToIndex(0);
|
||||
} finally {
|
||||
if (!signal.aborted) {
|
||||
setLoading(false);
|
||||
}
|
||||
clearInterval(intervalId);
|
||||
sync.dispose();
|
||||
}
|
||||
}, [forceReload, device, currentPath, onPathChange]);
|
||||
|
||||
const [sortedList, setSortedList] = useState<ListItem[]>([]);
|
||||
const [sortKey, setSortKey] = useState<keyof ListItem>('name');
|
||||
const [sortDescending, setSortDescendent] = useState(false);
|
||||
useEffect(() => {
|
||||
const list = items.slice();
|
||||
get sortedList() {
|
||||
const list = this.items.slice();
|
||||
list.sort((a, b) => {
|
||||
const aIsFile = a.type === LinuxFileType.File ? 1 : 0;
|
||||
const bIsFile = b.type === LinuxFileType.File ? 1 : 0;
|
||||
|
@ -253,8 +184,8 @@ export const FileManager = withDisplayName('FileManager')(({
|
|||
if (aIsFile !== bIsFile) {
|
||||
result = aIsFile - bIsFile;
|
||||
} else {
|
||||
const aSortKey = a[sortKey]!;
|
||||
const bSortKey = b[sortKey]!;
|
||||
const aSortKey = a[this.sortKey]!;
|
||||
const bSortKey = b[this.sortKey]!;
|
||||
|
||||
if (aSortKey === bSortKey) {
|
||||
result = compareCaseInsensitively(a.name!, b.name!);
|
||||
|
@ -265,15 +196,15 @@ export const FileManager = withDisplayName('FileManager')(({
|
|||
}
|
||||
}
|
||||
|
||||
if (sortDescending) {
|
||||
if (this.sortDescending) {
|
||||
result *= -1;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
setSortedList(list);
|
||||
}, [items, sortKey, sortDescending]);
|
||||
return list;
|
||||
}
|
||||
|
||||
const columns: IColumn[] = useMemo(() => {
|
||||
get columns(): IColumn[] {
|
||||
const list: IColumn[] = [
|
||||
{
|
||||
key: 'type',
|
||||
|
@ -342,27 +273,142 @@ export const FileManager = withDisplayName('FileManager')(({
|
|||
];
|
||||
|
||||
for (const item of list) {
|
||||
item.onColumnClick = (_e, column) => {
|
||||
if (sortKey === column.key) {
|
||||
setSortDescendent(!sortDescending);
|
||||
item.onColumnClick = (e, column) => {
|
||||
if (this.sortKey === column.key) {
|
||||
runInAction(() => this.sortDescending = !this.sortDescending);
|
||||
} else {
|
||||
setSortKey(column.key as keyof ListItem);
|
||||
setSortDescendent(false);
|
||||
runInAction(() => {
|
||||
this.sortKey = column.key as keyof ListItem;
|
||||
this.sortDescending = false;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (item.key === sortKey) {
|
||||
if (item.key === this.sortKey) {
|
||||
item.isSorted = true;
|
||||
item.isSortedDescending = sortDescending;
|
||||
item.isSortedDescending = this.sortDescending;
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [sortKey, sortDescending]);
|
||||
}
|
||||
|
||||
changeDirectory(path: string) {
|
||||
if (this.path === path) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.path = path;
|
||||
this.loadFiles();
|
||||
}
|
||||
|
||||
loadFiles = asyncEffect(async (signal) => {
|
||||
const currentPath = this.path;
|
||||
|
||||
runInAction(() => this.items = []);
|
||||
|
||||
if (!device.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
runInAction(() => this.loading = true);
|
||||
|
||||
const sync = await device.current.sync();
|
||||
|
||||
const items: ListItem[] = [];
|
||||
const linkItems: AdbSyncEntryResponse[] = [];
|
||||
const intervalId = setInterval(() => {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
runInAction(() => this.items = items.slice());
|
||||
}, 1000);
|
||||
|
||||
try {
|
||||
for await (const entry of sync.opendir(currentPath)) {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.name === '.' || entry.name === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.type === LinuxFileType.Link) {
|
||||
linkItems.push(entry);
|
||||
} else {
|
||||
items.push(toListItem(entry));
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of linkItems) {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await sync.isDirectory(path.resolve(currentPath, entry.name!))) {
|
||||
entry.mode = (LinuxFileType.File << 12) | entry.permission;
|
||||
entry.size = 0;
|
||||
}
|
||||
|
||||
items.push(toListItem(entry));
|
||||
}
|
||||
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
runInAction(() => this.items = items);
|
||||
} finally {
|
||||
if (!signal.aborted) {
|
||||
runInAction(() => this.loading = false);
|
||||
}
|
||||
clearInterval(intervalId);
|
||||
sync.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const state = new FileManagerState();
|
||||
|
||||
const FileManager: NextPage = (): JSX.Element | null => {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
let pathQuery = router.query.path;
|
||||
if (!pathQuery) {
|
||||
router.replace({ query: { ...router.query, path: state.path } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(pathQuery)) {
|
||||
pathQuery = pathQuery[0];
|
||||
}
|
||||
|
||||
state.changeDirectory(pathQuery);
|
||||
}, [router.query.path]);
|
||||
|
||||
const { show: showErrorDialog } = useContext(ErrorDialogContext);
|
||||
|
||||
const listRef = useRef<IDetailsList | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
const list = listRef.current;
|
||||
return () => {
|
||||
state.startItemIndexInView = list?.getStartItemIndexInView() ?? 0;
|
||||
};
|
||||
}, []);
|
||||
const scrolledRef = useRef(false);
|
||||
const handleListUpdate = useCallback((list?: IDetailsList) => {
|
||||
if (!scrolledRef.current) {
|
||||
console.log('scroll', state.startItemIndexInView);
|
||||
list?.scrollToIndex(state.startItemIndexInView, undefined, ScrollToMode.top);
|
||||
scrolledRef.current = true;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [previewUrl, setPreviewUrl] = useState<string | undefined>();
|
||||
const previewImage = useCallback(async (path: string) => {
|
||||
const sync = await device!.sync();
|
||||
const sync = await device.current!.sync();
|
||||
try {
|
||||
const readableStream = createReadableStreamFromBufferIterator(sync.read(path));
|
||||
const response = new Response(readableStream);
|
||||
|
@ -372,7 +418,7 @@ export const FileManager = withDisplayName('FileManager')(({
|
|||
} finally {
|
||||
sync.dispose();
|
||||
}
|
||||
}, [device]);
|
||||
}, []);
|
||||
const hidePreview = useCallback(() => {
|
||||
setPreviewUrl(undefined);
|
||||
}, []);
|
||||
|
@ -381,7 +427,7 @@ export const FileManager = withDisplayName('FileManager')(({
|
|||
switch (item.type) {
|
||||
case LinuxFileType.Link:
|
||||
case LinuxFileType.Directory:
|
||||
onPathChange(path.resolve(currentPath!, item.name!));
|
||||
state.pushPathQuery(path.resolve(state.path!, item.name!));
|
||||
break;
|
||||
case LinuxFileType.File:
|
||||
switch (path.extname(item.name!)) {
|
||||
|
@ -389,12 +435,12 @@ export const FileManager = withDisplayName('FileManager')(({
|
|||
case '.png':
|
||||
case '.svg':
|
||||
case '.gif':
|
||||
previewImage(path.resolve(currentPath!, item.name!));
|
||||
previewImage(path.resolve(state.path!, item.name!));
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}, [currentPath, onPathChange, previewImage]);
|
||||
}, [previewImage]);
|
||||
|
||||
const [selectedItems, setSelectedItems] = useState<ListItem[]>([]);
|
||||
const selection = useConst(() => new Selection({
|
||||
|
@ -410,15 +456,15 @@ export const FileManager = withDisplayName('FileManager')(({
|
|||
const [uploadTotalSize, setUploadTotalSize] = useState(0);
|
||||
const [debouncedUploadedSize, uploadSpeed] = useSpeed(uploadedSize, uploadTotalSize);
|
||||
const upload = useCallback(async (file: File) => {
|
||||
const sync = await device!.sync();
|
||||
const sync = await device.current!.sync();
|
||||
try {
|
||||
const itemPath = path.resolve(currentPath!, file.name);
|
||||
const itemPath = path.resolve(state.path!, file.name);
|
||||
setUploading(true);
|
||||
setUploadPath(file.name);
|
||||
setUploadTotalSize(file.size);
|
||||
await sync.write(
|
||||
itemPath,
|
||||
chunkFile(file),
|
||||
chunkFile(file, AdbSyncMaxPacketSize),
|
||||
(LinuxFileType.File << 12) | 0o666,
|
||||
file.lastModified / 1000,
|
||||
setUploadedSize,
|
||||
|
@ -427,10 +473,10 @@ export const FileManager = withDisplayName('FileManager')(({
|
|||
showErrorDialog(e instanceof Error ? e.message : `${e}`);
|
||||
} finally {
|
||||
sync.dispose();
|
||||
setForceReload({});
|
||||
state.loadFiles();
|
||||
setUploading(false);
|
||||
}
|
||||
}, [currentPath, device]);
|
||||
}, []);
|
||||
|
||||
const [menuItems, setMenuItems] = useState<IContextualMenuItem[]>([]);
|
||||
useEffect(() => {
|
||||
|
@ -442,7 +488,7 @@ export const FileManager = withDisplayName('FileManager')(({
|
|||
key: 'upload',
|
||||
text: 'Upload',
|
||||
iconProps: { iconName: 'Upload' },
|
||||
disabled: !device,
|
||||
disabled: !device.current,
|
||||
onClick() {
|
||||
(async () => {
|
||||
const files = await pickFile({ multiple: true });
|
||||
|
@ -456,7 +502,6 @@ export const FileManager = withDisplayName('FileManager')(({
|
|||
}
|
||||
});
|
||||
break;
|
||||
// @ts-expect-error
|
||||
case 1:
|
||||
if (selectedItems[0].type === LinuxFileType.File) {
|
||||
result.push({
|
||||
|
@ -465,12 +510,12 @@ export const FileManager = withDisplayName('FileManager')(({
|
|||
iconProps: { iconName: 'Download' },
|
||||
onClick() {
|
||||
(async () => {
|
||||
const sync = await device!.sync();
|
||||
const sync = await device.current!.sync();
|
||||
try {
|
||||
const itemPath = path.resolve(currentPath, selectedItems[0].name!);
|
||||
const itemPath = path.resolve(state.path, selectedItems[0].name!);
|
||||
const readableStream = createReadableStreamFromBufferIterator(sync.read(itemPath));
|
||||
|
||||
const writeableStream = StreamSaver.createWriteStream(selectedItems[0].name!, {
|
||||
const writeableStream = StreamSaver!.createWriteStream(selectedItems[0].name!, {
|
||||
size: selectedItems[0].size,
|
||||
});
|
||||
await readableStream.pipeTo(writeableStream);
|
||||
|
@ -493,7 +538,7 @@ export const FileManager = withDisplayName('FileManager')(({
|
|||
(async () => {
|
||||
try {
|
||||
for (const item of selectedItems) {
|
||||
const output = await device!.rm(path.resolve(currentPath, item.name!));
|
||||
const output = await device.current!.rm(path.resolve(state.path, item.name!));
|
||||
if (output) {
|
||||
showErrorDialog(output);
|
||||
return;
|
||||
|
@ -502,7 +547,7 @@ export const FileManager = withDisplayName('FileManager')(({
|
|||
} catch (e) {
|
||||
showErrorDialog(e instanceof Error ? e.message : `${e}`);
|
||||
} finally {
|
||||
setForceReload({});
|
||||
state.loadFiles();
|
||||
}
|
||||
})();
|
||||
return false;
|
||||
|
@ -512,7 +557,7 @@ export const FileManager = withDisplayName('FileManager')(({
|
|||
}
|
||||
|
||||
setMenuItems(result);
|
||||
}, [selectedItems, device, currentPath]);
|
||||
}, [selectedItems]);
|
||||
|
||||
const [contextMenuTarget, setContextMenuTarget] = useState<MouseEvent>();
|
||||
const showContextMenu = useCallback((
|
||||
|
@ -529,13 +574,17 @@ export const FileManager = withDisplayName('FileManager')(({
|
|||
}
|
||||
|
||||
return false;
|
||||
}, [device, menuItems]);
|
||||
}, [menuItems]);
|
||||
const hideContextMenu = useCallback(() => {
|
||||
setContextMenuTarget(undefined);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack {...RouteStackProps}>
|
||||
<Head>
|
||||
<title>File Manager - WebADB</title>
|
||||
</Head>
|
||||
|
||||
<CommandBar items={menuItems} />
|
||||
|
||||
<StackItem grow styles={{
|
||||
|
@ -546,20 +595,22 @@ export const FileManager = withDisplayName('FileManager')(({
|
|||
}
|
||||
}}>
|
||||
<MarqueeSelection selection={selection}>
|
||||
<Breadcrumb items={breadcrumbItems} />
|
||||
<Breadcrumb items={state.breadcrumbItems} />
|
||||
|
||||
<ShimmeredDetailsList
|
||||
componentRef={listRef}
|
||||
items={sortedList}
|
||||
columns={columns}
|
||||
setKey={currentPath}
|
||||
items={state.sortedList}
|
||||
columns={state.columns}
|
||||
setKey={state.path}
|
||||
selection={selection}
|
||||
layoutMode={DetailsListLayoutMode.justified}
|
||||
enableShimmer={loading && items.length === 0}
|
||||
enableShimmer={state.loading && state.items.length === 0}
|
||||
onItemInvoked={handleItemInvoked}
|
||||
onItemContextMenu={showContextMenu}
|
||||
onRenderDetailsHeader={renderDetailsHeader}
|
||||
onDidUpdate={handleListUpdate}
|
||||
usePageCache
|
||||
useReducedRowRenderer
|
||||
/>
|
||||
</MarqueeSelection>
|
||||
|
||||
|
@ -595,37 +646,8 @@ export const FileManager = withDisplayName('FileManager')(({
|
|||
/>
|
||||
</Dialog>
|
||||
</StackItem>
|
||||
</>
|
||||
</Stack>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Sync current path with url query
|
||||
*/
|
||||
export const FileManagerRoute = withDisplayName('FileManagerRoute')(({ visible }: RouteProps) => {
|
||||
|
||||
const location = useLocation();
|
||||
const path = useMemo(() => new URLSearchParams(location.search).get('path'), [location.search]);
|
||||
|
||||
const history = useHistory();
|
||||
const handlePathChange = useCallback((path: string, replace = false) => {
|
||||
history[replace ? 'replace' : 'push'](`${location.pathname}?path=${path}`);
|
||||
}, [history, location.pathname]);
|
||||
|
||||
const [cachedPath, setCachedPath] = useState('/');
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
handlePathChange(cachedPath, true);
|
||||
} else {
|
||||
setCachedPath(path);
|
||||
}
|
||||
}, [visible, path, cachedPath]);
|
||||
|
||||
return (
|
||||
<FileManager currentPath={cachedPath} onPathChange={handlePathChange} />
|
||||
);
|
||||
});
|
||||
export default observer(FileManager);
|
52
apps/demo/pages/index.mdx
Normal file
52
apps/demo/pages/index.mdx
Normal file
|
@ -0,0 +1,52 @@
|
|||
import { Stack } from "@fluentui/react";
|
||||
import Head from 'next/head';
|
||||
import { ExternalLink } from '../components';
|
||||
import { RouteStackProps } from "../utils";
|
||||
|
||||
This is a demo for my <ExternalLink href="https://github.com/yume-chan/ya-webadb/">ya-webadb</ExternalLink> project, which can use ADB protocol to control Android phones, directly from Web browsers (or Node.js).
|
||||
|
||||
It started because I want to try the <ExternalLink href="https://developer.mozilla.org/en-US/docs/Web/API/USB">WebUSB</ExternalLink> API, and because I have an Android phone. It's not production-ready, and I don't recommend normal users to try it. If you have any questions or suggestions, please file an issue at <ExternalLink href="https://github.com/yume-chan/ya-webadb/issues">here</ExternalLink>.
|
||||
|
||||
It was called "ya-webadb" (Yet Another WebADB), because there have already been several similar projects, for example:
|
||||
|
||||
* <ExternalLink href="https://github.com/webadb/webadb.js">webadb/webadb.js</ExternalLink>
|
||||
* <ExternalLink href="https://github.com/cybojenix/WebADB">cybojenix/WebADB</ExternalLink>
|
||||
|
||||
However, they are all pretty simple and not maintained, so I decided to make my own.
|
||||
|
||||
## Security concerns
|
||||
|
||||
Accessing USB devices (especially your phone) directly from a web page can be **very dangerous**. Firefox developers even refused to implement the WebUSB standard because they <ExternalLink href="https://mozilla.github.io/standards-positions/#webusb">considered it to be **harmful**</ExternalLink>.
|
||||
|
||||
However, I'm pretty confident about this demo, and here is a few reasons:
|
||||
|
||||
1. Unlike native apps, web apps can't access your devices silently. In addition to the connection verification popup that comes with ADB, browsers ask users for permission and web apps can only access the device you choose.
|
||||
2. All source code of this project is open sourced on <ExternalLink href="https://github.com/yume-chan/ya-webadb/">GitHub</ExternalLink>. You can review it yourself (or find someone you trust and knows coding to review it).
|
||||
3. This site is built and deployed by <ExternalLink href="https://github.com/yume-chan/ya-webadb/blob/master/.github/workflows/nodejs.yml" spaceAfter>GitHub Actions</ExternalLink> to ensure that what you see is exactly the same as the source code.
|
||||
|
||||
## Compatibility
|
||||
|
||||
Currently, only Chromium-based browsers (Chrome, Microsoft Edge, Opera) support the WebUSB API. As mentioned before, it's unlikely for Firefox to implement it.
|
||||
|
||||
## FAQ
|
||||
|
||||
### Got "Unable to claim interface" error
|
||||
|
||||
One USB device can only be accessed by one application at a time. Please make sure:
|
||||
|
||||
1. Official ADB server is not running (stop it by `adb kill-server`).
|
||||
2. No other Android management tools are running.
|
||||
3. No other WebADB tabs have already connected to your device.
|
||||
|
||||
### Can I connect my device wirelessly (ADB over WiFi)?
|
||||
|
||||
Extra software is required to bridge the connection. See <ExternalLink href="https://github.com/yume-chan/ya-webadb/discussions/245#discussioncomment-384030">this discussion</ExternalLink>.
|
||||
|
||||
export default ({children}) => (
|
||||
<div style={{ padding: '0 16px' }}>
|
||||
<Head>
|
||||
<title>WebADB</title>
|
||||
</Head>
|
||||
{children}
|
||||
</div>
|
||||
);
|
BIN
apps/demo/public/favicon.ico
Normal file
BIN
apps/demo/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
|
@ -1,107 +0,0 @@
|
|||
import { AnimationClassNames, concatStyleSets, IStackProps, Stack } from '@fluentui/react';
|
||||
import { Children, cloneElement, isValidElement, useMemo, useRef } from 'react';
|
||||
import { match, matchPath, RedirectProps, RouteProps, useLocation, useRouteMatch } from 'react-router-dom';
|
||||
import { withDisplayName } from '../utils';
|
||||
|
||||
export const DefaultStackProps: IStackProps = {
|
||||
tokens: { childrenGap: 8, padding: 16 },
|
||||
verticalFill: true,
|
||||
};
|
||||
|
||||
export const RouteStackProps: IStackProps = {
|
||||
...DefaultStackProps,
|
||||
className: AnimationClassNames.slideUpIn10!,
|
||||
styles: { root: { overflow: 'auto', position: 'relative' } },
|
||||
};
|
||||
|
||||
export interface CacheRouteProps extends RouteProps {
|
||||
noCache?: boolean | undefined;
|
||||
}
|
||||
|
||||
export const CacheRoute = withDisplayName('CacheRoute')((props: CacheRouteProps) => {
|
||||
const match = useRouteMatch(props);
|
||||
|
||||
const everMatched = useRef(false);
|
||||
if (!everMatched.current && match) {
|
||||
everMatched.current = true;
|
||||
}
|
||||
|
||||
const stackProps = useMemo((): IStackProps => ({
|
||||
...RouteStackProps,
|
||||
styles: concatStyleSets(
|
||||
RouteStackProps.styles,
|
||||
{ root: { display: match ? 'flex' : 'none' } }
|
||||
),
|
||||
}), [!!match]);
|
||||
|
||||
if (props.noCache && !match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!everMatched.current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack {...stackProps} disableShrink>
|
||||
{Children.map(
|
||||
props.children,
|
||||
element =>
|
||||
isValidElement(element)
|
||||
? cloneElement(element, { ...element.props, visible: !!match })
|
||||
: element
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
});
|
||||
|
||||
export interface CacheSwitchProps {
|
||||
children: React.ReactNodeArray;
|
||||
}
|
||||
|
||||
export const CacheSwitch = withDisplayName('CacheSwitch')((props: CacheSwitchProps) => {
|
||||
const location = useLocation();
|
||||
let contextMatch = useRouteMatch();
|
||||
|
||||
let element: React.ReactElement | undefined;
|
||||
let computedMatch: match | null | undefined;
|
||||
let cached: React.ReactElement[] = [];
|
||||
Children.forEach(props.children, child => {
|
||||
if (isValidElement<RouteProps & RedirectProps>(child)) {
|
||||
// Always render all cached routes
|
||||
const isCacheRoute = child.type === CacheRoute;
|
||||
if (isCacheRoute) {
|
||||
cached.push(child);
|
||||
}
|
||||
|
||||
// If we already found the matched route,
|
||||
// Don't care about others
|
||||
if (computedMatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = child.props.path ?? child.props.from;
|
||||
const match = path
|
||||
? matchPath(location.pathname, { ...child.props, path })
|
||||
: contextMatch;
|
||||
|
||||
if (match) {
|
||||
computedMatch = match;
|
||||
|
||||
if (isCacheRoute) {
|
||||
// Don't render a CacheRoute twice
|
||||
element = undefined;
|
||||
} else {
|
||||
element = child;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{cached}
|
||||
{element ? cloneElement(element, { location, computedMatch }) : null}
|
||||
</>
|
||||
);
|
||||
});
|
|
@ -1,9 +0,0 @@
|
|||
html, body, #container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ms-Dialog-subText {
|
||||
white-space: pre-wrap;
|
||||
}
|
|
@ -1,188 +0,0 @@
|
|||
import { initializeIcons } from '@fluentui/font-icons-mdl2';
|
||||
import { IconButton, mergeStyles, mergeStyleSets, Nav, Stack, StackItem } from '@fluentui/react';
|
||||
import { Adb } from '@yume-chan/adb';
|
||||
import { ReactElement, useCallback, useMemo, useState } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { HashRouter, Redirect, useLocation } from 'react-router-dom';
|
||||
import { AdbEventLogger, CacheRoute, CacheSwitch, Connect, ErrorDialogProvider, Logger, LoggerContextProvider, ToggleLogger } from './components';
|
||||
import './index.css';
|
||||
import { AdbDeviceProvider, DeviceInfo, FileManagerRoute, FrameBuffer, Install, Intro, Scrcpy, Shell, TcpIp } from './routes';
|
||||
|
||||
initializeIcons();
|
||||
|
||||
const classNames = mergeStyleSets({
|
||||
'title-container': {
|
||||
borderBottom: '1px solid rgb(243, 242, 241)',
|
||||
},
|
||||
title: {
|
||||
padding: '4px 0',
|
||||
fontSize: 20,
|
||||
textAlign: 'center',
|
||||
},
|
||||
'left-column': {
|
||||
width: 250,
|
||||
paddingRight: 8,
|
||||
borderRight: '1px solid rgb(243, 242, 241)',
|
||||
overflow: 'auto',
|
||||
},
|
||||
'right-column': {
|
||||
borderLeft: '1px solid rgb(243, 242, 241)',
|
||||
}
|
||||
});
|
||||
|
||||
interface RouteInfo {
|
||||
path: string;
|
||||
|
||||
exact?: boolean;
|
||||
|
||||
name: string;
|
||||
|
||||
children: JSX.Element | null;
|
||||
|
||||
noCache?: boolean;
|
||||
}
|
||||
|
||||
function App(): JSX.Element | null {
|
||||
const location = useLocation();
|
||||
|
||||
const [logger] = useState(() => new AdbEventLogger());
|
||||
const [device, setDevice] = useState<Adb | undefined>();
|
||||
|
||||
const [leftPanelVisible, setLeftPanelVisible] = useState(() => innerWidth > 650);
|
||||
const toggleLeftPanel = useCallback(() => {
|
||||
setLeftPanelVisible(value => !value);
|
||||
}, []);
|
||||
|
||||
|
||||
const routes = useMemo((): RouteInfo[] => [
|
||||
{
|
||||
path: '/',
|
||||
exact: true,
|
||||
name: 'Introduction',
|
||||
children: (
|
||||
<Intro />
|
||||
)
|
||||
},
|
||||
{
|
||||
path: '/device-info',
|
||||
name: 'Device Info',
|
||||
children: (
|
||||
<DeviceInfo />
|
||||
)
|
||||
},
|
||||
{
|
||||
path: '/adb-over-wifi',
|
||||
name: 'ADB over WiFi',
|
||||
children: (
|
||||
<TcpIp />
|
||||
)
|
||||
},
|
||||
{
|
||||
path: '/shell',
|
||||
name: 'Interactive Shell',
|
||||
children: (
|
||||
<Shell />
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/file-manager',
|
||||
name: 'File Manager',
|
||||
children: (
|
||||
<FileManagerRoute />
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/install',
|
||||
name: 'Install APK',
|
||||
children: (
|
||||
<Install />
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/framebuffer',
|
||||
name: 'Screen Capture',
|
||||
children: (
|
||||
<FrameBuffer />
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/scrcpy',
|
||||
name: 'Scrcpy',
|
||||
noCache: true,
|
||||
children: (
|
||||
<Scrcpy />
|
||||
),
|
||||
},
|
||||
], [device]);
|
||||
|
||||
return (
|
||||
<LoggerContextProvider>
|
||||
<Stack verticalFill>
|
||||
<Stack className={classNames['title-container']} horizontal verticalAlign="center">
|
||||
<IconButton
|
||||
checked={leftPanelVisible}
|
||||
title="Toggle Menu"
|
||||
iconProps={{ iconName: 'GlobalNavButton' }}
|
||||
onClick={toggleLeftPanel}
|
||||
/>
|
||||
|
||||
<StackItem grow>
|
||||
<div className={classNames.title}>WebADB Demo</div>
|
||||
</StackItem>
|
||||
|
||||
<ToggleLogger />
|
||||
</Stack>
|
||||
|
||||
<Stack grow horizontal verticalFill disableShrink styles={{ root: { minHeight: 0, overflow: 'hidden', lineHeight: '1.5' } }}>
|
||||
<StackItem className={mergeStyles(classNames['left-column'], !leftPanelVisible && { display: 'none' })}>
|
||||
<Connect
|
||||
device={device}
|
||||
logger={logger.logger}
|
||||
onDeviceChange={setDevice}
|
||||
/>
|
||||
|
||||
<Nav
|
||||
styles={{ root: {} }}
|
||||
groups={[{
|
||||
links: routes.map(route => ({
|
||||
key: route.path,
|
||||
name: route.name,
|
||||
url: `#${route.path}`,
|
||||
})),
|
||||
}]}
|
||||
selectedKey={location.pathname}
|
||||
/>
|
||||
</StackItem>
|
||||
|
||||
<StackItem grow styles={{ root: { width: 0 } }}>
|
||||
<AdbDeviceProvider value={device}>
|
||||
<CacheSwitch>
|
||||
{routes.map<ReactElement>(route => (
|
||||
<CacheRoute
|
||||
exact={route.exact}
|
||||
path={route.path}
|
||||
noCache={route.noCache}>
|
||||
{route.children}
|
||||
</CacheRoute>
|
||||
))}
|
||||
|
||||
<Redirect to="/" />
|
||||
</CacheSwitch>
|
||||
</AdbDeviceProvider>
|
||||
</StackItem>
|
||||
|
||||
<Logger className={classNames['right-column']} logger={logger} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</LoggerContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.render(
|
||||
<HashRouter>
|
||||
<ErrorDialogProvider>
|
||||
<App />
|
||||
</ErrorDialogProvider>
|
||||
</HashRouter>,
|
||||
document.getElementById('container')
|
||||
);
|
|
@ -1,116 +0,0 @@
|
|||
import { ICommandBarItemProps, Stack } from '@fluentui/react';
|
||||
import { useBoolean } from '@fluentui/react-hooks';
|
||||
import { useCallback, useContext, useMemo, useRef, useState } from 'react';
|
||||
import { CommandBar, DemoMode, DeviceView, ErrorDialogContext } from '../components';
|
||||
import { withDisplayName } from '../utils';
|
||||
import { useAdbDevice } from './type';
|
||||
|
||||
export const FrameBuffer = withDisplayName('FrameBuffer')((): JSX.Element | null => {
|
||||
const { show: showErrorDialog } = useContext(ErrorDialogContext);
|
||||
|
||||
const device = useAdbDevice();
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [hasImage, setHasImage] = useState(false);
|
||||
|
||||
const [width, setWidth] = useState(0);
|
||||
const [height, setHeight] = useState(0);
|
||||
|
||||
const [demoModeVisible, { toggle: toggleDemoModeVisible }] = useBoolean(false);
|
||||
|
||||
const capture = useCallback(async () => {
|
||||
if (!device) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const start = window.performance.now();
|
||||
const framebuffer = await device!.framebuffer();
|
||||
const end = window.performance.now();
|
||||
console.log('time', end - start);
|
||||
|
||||
const { width, height } = framebuffer;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
setWidth(width);
|
||||
setHeight(height);
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const context = canvas.getContext("2d")!;
|
||||
const image = new ImageData(framebuffer.data, width, height);
|
||||
context.putImageData(image, 0, 0);
|
||||
setHasImage(true);
|
||||
} catch (e) {
|
||||
showErrorDialog(e instanceof Error ? e.message : `${e}`);
|
||||
}
|
||||
}, [device]);
|
||||
|
||||
const commandBarItems = useMemo((): ICommandBarItemProps[] => [
|
||||
{
|
||||
key: 'start',
|
||||
disabled: !device,
|
||||
iconProps: { iconName: 'Camera' },
|
||||
text: 'Capture',
|
||||
onClick: capture,
|
||||
},
|
||||
{
|
||||
key: 'Save',
|
||||
disabled: !hasImage,
|
||||
iconProps: { iconName: 'Save' },
|
||||
text: 'Save',
|
||||
onClick: () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = canvas.toDataURL();
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `Screenshot of ${device!.name}.png`;
|
||||
a.click();
|
||||
},
|
||||
}
|
||||
], [device, hasImage]);
|
||||
|
||||
const commandBarFarItems = useMemo((): ICommandBarItemProps[] => [
|
||||
{
|
||||
key: 'DemoMode',
|
||||
iconProps: { iconName: 'Personalize' },
|
||||
checked: demoModeVisible,
|
||||
text: 'Demo Mode Settings',
|
||||
onClick: toggleDemoModeVisible,
|
||||
},
|
||||
{
|
||||
key: 'info',
|
||||
iconProps: { iconName: 'Info' },
|
||||
iconOnly: true,
|
||||
tooltipHostProps: {
|
||||
content: 'Use ADB FrameBuffer command to capture a full-size, high-resolution screenshot.',
|
||||
calloutProps: {
|
||||
calloutMaxWidth: 250,
|
||||
}
|
||||
},
|
||||
}
|
||||
], [demoModeVisible]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CommandBar items={commandBarItems} farItems={commandBarFarItems} />
|
||||
<Stack horizontal grow styles={{ root: { height: 0 } }}>
|
||||
<DeviceView width={width} height={height}>
|
||||
<canvas ref={canvasRef} style={{ display: 'block' }} />
|
||||
</DeviceView>
|
||||
|
||||
<DemoMode
|
||||
device={device}
|
||||
style={{ display: demoModeVisible ? 'block' : 'none' }}
|
||||
/>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
});
|
|
@ -1,9 +0,0 @@
|
|||
export * from './device-info';
|
||||
export * from './file-manager';
|
||||
export * from './framebuffer';
|
||||
export * from './install';
|
||||
export * from './intro';
|
||||
export * from './scrcpy';
|
||||
export * from './shell';
|
||||
export * from './tcpip';
|
||||
export * from './type';
|
|
@ -1,97 +0,0 @@
|
|||
import { DefaultButton, ProgressIndicator, Stack } from "@fluentui/react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { pickFile, withDisplayName } from "../utils";
|
||||
import { chunkFile } from "./file-manager";
|
||||
import { useAdbDevice } from "./type";
|
||||
|
||||
enum Stage {
|
||||
Uploading,
|
||||
|
||||
Installing,
|
||||
|
||||
Completed,
|
||||
}
|
||||
|
||||
interface Progress {
|
||||
filename: string;
|
||||
|
||||
stage: Stage;
|
||||
|
||||
uploadedSize: number;
|
||||
|
||||
totalSize: number;
|
||||
|
||||
value: number | undefined;
|
||||
}
|
||||
|
||||
export const Install = withDisplayName('Install')((): JSX.Element => {
|
||||
const device = useAdbDevice();
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [progress, setProgress] = useState<Progress>();
|
||||
|
||||
const handleOpen = useCallback(async () => {
|
||||
const file = await pickFile({ accept: '.apk' });
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
setInstalling(true);
|
||||
setProgress({
|
||||
filename: file.name,
|
||||
stage: Stage.Uploading,
|
||||
uploadedSize: 0,
|
||||
totalSize: file.size,
|
||||
value: 0,
|
||||
});
|
||||
|
||||
await device!.install(chunkFile(file), uploaded => {
|
||||
if (uploaded !== file.size) {
|
||||
setProgress({
|
||||
filename: file.name,
|
||||
stage: Stage.Uploading,
|
||||
uploadedSize: uploaded,
|
||||
totalSize: file.size,
|
||||
value: uploaded / file.size * 0.8,
|
||||
});
|
||||
} else {
|
||||
setProgress({
|
||||
filename: file.name,
|
||||
stage: Stage.Installing,
|
||||
uploadedSize: uploaded,
|
||||
totalSize: file.size,
|
||||
value: 0.8,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setProgress({
|
||||
filename: file.name,
|
||||
stage: Stage.Completed,
|
||||
uploadedSize: file.size,
|
||||
totalSize: file.size,
|
||||
value: 1,
|
||||
});
|
||||
setInstalling(false);
|
||||
}, [device]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack horizontal>
|
||||
<DefaultButton
|
||||
disabled={!device || installing}
|
||||
text="Open"
|
||||
onClick={handleOpen}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{progress && (
|
||||
<ProgressIndicator
|
||||
styles={{ root: { width: 300 } }}
|
||||
label={progress.filename}
|
||||
percentComplete={progress.value}
|
||||
description={Stage[progress.stage]}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
|
@ -1,143 +0,0 @@
|
|||
import { Callout, DirectionalHint, Link, mergeStyleSets, Text } from '@fluentui/react';
|
||||
import { useBoolean } from '@fluentui/react-hooks';
|
||||
import { ReactNode, useCallback, useRef, MouseEvent } from 'react';
|
||||
import { ExternalLink } from '../components';
|
||||
import { withDisplayName } from '../utils';
|
||||
|
||||
const classNames = mergeStyleSets({
|
||||
callout: {
|
||||
padding: '8px 12px',
|
||||
},
|
||||
});
|
||||
|
||||
const BoldTextStyles = { root: { fontWeight: '600' } };
|
||||
|
||||
interface CopyLinkProps {
|
||||
href: string;
|
||||
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const CopyLink = withDisplayName('CopyLink')(({
|
||||
href,
|
||||
children,
|
||||
}: CopyLinkProps) => {
|
||||
const calloutTarget = useRef<HTMLButtonElement | null>(null);
|
||||
const [calloutVisible, { setTrue: showCallout, setFalse: hideCallout }] = useBoolean(false);
|
||||
const copyLink = useCallback((e: MouseEvent<HTMLAnchorElement | HTMLElement | HTMLButtonElement>) => {
|
||||
navigator.clipboard.writeText(href);
|
||||
calloutTarget.current = e.target as HTMLButtonElement;
|
||||
showCallout();
|
||||
setTimeout(hideCallout, 3000);
|
||||
}, [href]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link onClick={copyLink}>{children || href}</Link>
|
||||
<Callout
|
||||
directionalHint={DirectionalHint.topCenter}
|
||||
hidden={!calloutVisible}
|
||||
target={calloutTarget}
|
||||
onDismiss={hideCallout}
|
||||
>
|
||||
<div className={classNames.callout}>
|
||||
Link copied. Open a new tab and paste into address bar.
|
||||
</div>
|
||||
</Callout>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const Intro = withDisplayName('Intro')(() => {
|
||||
return (
|
||||
<>
|
||||
<Text block>
|
||||
<span>This page is a demo for my</span>
|
||||
<ExternalLink href="https://github.com/yume-chan/ya-webadb/" spaceBefore spaceAfter>ya-webadb</ExternalLink>
|
||||
<span>project, which can connect directly to your phone in browsers, using the</span>
|
||||
<ExternalLink href="https://developer.mozilla.org/en-US/docs/Web/API/USB" spaceBefore spaceAfter>WebUSB</ExternalLink>
|
||||
<span>API</span>
|
||||
</Text>
|
||||
|
||||
<Text block>
|
||||
<span>It started out as a proof of concept. </span>
|
||||
<span>And it was called "ya-webadb" (Yet Another WebADB), because there were several similar projects before this one</span>
|
||||
<span> (for example</span>
|
||||
<ExternalLink href="https://github.com/webadb/webadb.js" spaceBefore spaceAfter>webadb/webadb.js</ExternalLink>
|
||||
<span>and</span>
|
||||
<ExternalLink href="https://github.com/cybojenix/WebADB" spaceBefore>cybojenix/WebADB</ExternalLink>
|
||||
<span>).</span>
|
||||
</Text>
|
||||
|
||||
<Text block styles={BoldTextStyles}>
|
||||
Security concerns:
|
||||
</Text>
|
||||
<Text block>
|
||||
<span>Undeniably, accessing USB devices directly from a web page can be pretty dangerous. </span>
|
||||
<span>Firefox developers even refused to implement the WebUSB standard because they </span>
|
||||
<ExternalLink href="https://mozilla.github.io/standards-positions/#webusb">considered it was harmful</ExternalLink>
|
||||
<span>.</span><br />
|
||||
|
||||
<span>So I don't recommend the average users to try it either.</span>
|
||||
</Text>
|
||||
|
||||
<Text block>
|
||||
<span>However, I believe this one is quite safe. Here are a few reasons why. </span>
|
||||
<span>You can verify it yourself (or find someone you trust to verify it for you)</span>
|
||||
</Text>
|
||||
|
||||
<Text block>
|
||||
<span>1. Unlike native apps, web apps can't access your devices silently. </span>
|
||||
<span>In addition to the connection verification popup that comes with ADB, </span>
|
||||
<span>WebUSB requires the user to permit the connection through a browser-provided UI </span>
|
||||
<span>(which the web page cannot modify or skip).</span><br />
|
||||
|
||||
<span>2. Because it is a proof of concept, I have used only minimal and trustworthy third-party dependencies </span>
|
||||
<span>in the development process, which just minimized</span>
|
||||
<ExternalLink href="https://en.wikipedia.org/wiki/Supply_chain_attack" spaceBefore>supply chain attacks</ExternalLink>
|
||||
<span>.</span><br />
|
||||
|
||||
<span>3. All source code of this project is open sourced on</span>
|
||||
<ExternalLink href="https://github.com/yume-chan/ya-webadb/" spaceBefore>GitHub</ExternalLink>
|
||||
<span>. You can review it at any time.</span><br />
|
||||
|
||||
<span>4. This site is built and deployed by </span>
|
||||
<ExternalLink href="https://github.com/yume-chan/ya-webadb/blob/master/.github/workflows/gh-pages.yml" spaceAfter>GitHub CI</ExternalLink>
|
||||
<span>to ensure that what you see is exactly the same as the source code.</span>
|
||||
</Text>
|
||||
|
||||
<Text block styles={BoldTextStyles}>
|
||||
Compatibility:
|
||||
</Text>
|
||||
<Text block>
|
||||
<span>Currently, only Chromium-based browsers support the WebUSB API.</span>
|
||||
<span> Most recent versions of browsers are recommended.</span><br />
|
||||
|
||||
<span>Google is developing new WebUSB implementations for</span>
|
||||
<ExternalLink href="https://bugs.chromium.org/p/chromium/issues/detail?id=637404" spaceBefore spaceAfter>Windows</ExternalLink>
|
||||
<span>and</span>
|
||||
<ExternalLink href="https://bugs.chromium.org/p/chromium/issues/detail?id=1096743" spaceBefore spaceAfter>macOS</ExternalLink>
|
||||
<span>respectively.</span>
|
||||
<span> The Windows one is already enabled by default since Chrome 87.</span><br />
|
||||
<span>It can be turned on or off manually via <CopyLink href="chrome://flags/#new-usb-backend" /></span>
|
||||
</Text>
|
||||
|
||||
<Text block styles={BoldTextStyles}>
|
||||
Got "Unable to claim interface" error?
|
||||
</Text>
|
||||
<Text block>
|
||||
Only one connection to your device can exist simultaneously. Please make sure<br />
|
||||
1. Native ADB client is not running (run `adb kill-server` to stop it).<br />
|
||||
2. No other Android management tools are running<br />
|
||||
3. No other WebADB tabs have already connected to your device.
|
||||
</Text>
|
||||
|
||||
<Text block styles={BoldTextStyles}>
|
||||
Can I connect my device wirelessly (ADB over WiFi)?
|
||||
</Text>
|
||||
<Text block>
|
||||
No. Web browsers doesn't support TCP connections.
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
});
|
|
@ -1,24 +0,0 @@
|
|||
# Scrcpy client
|
||||
|
||||
## Decoders
|
||||
|
||||
| | JMuxer | TinyH264 |
|
||||
| --------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| Principle | Remux H.264 stream into MP4 container | Decode H.264 stream into image data |
|
||||
| Renderer | Native `<video>`<br/>Hardware-accelerated video decoding by browsers | WebGL for hardware-accelerated color space conversion and rendering |
|
||||
| Tech Stack | Media Source Extensions | WebAssembly, Shared Web Worker, WebGL |
|
||||
| Browser compatibility | Supported by most modern browsers | Supported by most modern browsers |
|
||||
| H.264 compatibility | Depends on browsers<br/>Supports most H.264 profiles and levels | Only supports H.264 baseline profile |
|
||||
| CPU usage | Very little processing and mostly copying<br/>Low | Decode H.264 on CPU<br/>Very High |
|
||||
| Latency | High and unstable | Lower |
|
||||
|
||||
## Encoders
|
||||
|
||||
Scrcpy server version 1.17 supports specifying encoders.
|
||||
|
||||
| Encoder Name | OMX.google.h264.encoder | c2.android.avc.encoder | OMX.qcom.video.encoder.avc | OMX.hisi.video.encoder.avc |
|
||||
| ----------------- | ------------------------ | ------------------------ | -------------------------- | -------------------------------- |
|
||||
| Vendor | Google | UNKNOWN | Qualcomm | Huawei |
|
||||
| Type | Software encoder | UNKNOWN | Hardware encoder | Hardware encoder |
|
||||
| Huawei Mate 9 | Works | Not exist | Not exist | Ignores profile and level config |
|
||||
| Samsung Galaxy S9 | IllegalArgumentException | IllegalArgumentException | Works | Not exist |
|
|
@ -1,13 +0,0 @@
|
|||
import { Disposable } from "@yume-chan/event";
|
||||
import { ValueOrPromise } from "@yume-chan/struct";
|
||||
import { FrameSize } from "./server";
|
||||
|
||||
export interface Decoder extends Disposable {
|
||||
configure(config: FrameSize): ValueOrPromise<void>;
|
||||
|
||||
decode(data: BufferSource): ValueOrPromise<void>;
|
||||
}
|
||||
|
||||
export interface DecoderConstructor {
|
||||
new(canvas: HTMLCanvasElement): Decoder;
|
||||
}
|
|
@ -1,573 +0,0 @@
|
|||
import { Dialog, Dropdown, ICommandBarItemProps, Icon, IconButton, IDropdownOption, LayerHost, Position, ProgressIndicator, SpinButton, Stack, Toggle, TooltipHost } from '@fluentui/react';
|
||||
import { useBoolean, useId } from '@fluentui/react-hooks';
|
||||
import { FormEvent, KeyboardEvent, useCallback, useContext, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { CommandBar, DemoMode, DeviceView, DeviceViewRef, ErrorDialogContext, ExternalLink } from '../../components';
|
||||
import { CommonStackTokens } from '../../styles';
|
||||
import { formatSpeed, useSpeed, withDisplayName } from '../../utils';
|
||||
import { useAdbDevice } from '../type';
|
||||
import { Decoder, DecoderConstructor } from "./decoder";
|
||||
import { AndroidCodecLevel, AndroidCodecProfile, AndroidKeyCode, AndroidMotionEventAction, fetchServer, ScrcpyClient, ScrcpyClientOptions, ScrcpyLogLevel, ScrcpyScreenOrientation, ScrcpyServerVersion } from './server';
|
||||
import { TinyH264DecoderWrapper } from "./tinyh264";
|
||||
import { WebCodecsDecoder } from "./webcodecs/decoder";
|
||||
|
||||
const DeviceServerPath = '/data/local/tmp/scrcpy-server.jar';
|
||||
|
||||
const decoders: { name: string; factory: DecoderConstructor; }[] = [{
|
||||
name: 'TinyH264 (Software)',
|
||||
factory: TinyH264DecoderWrapper,
|
||||
}];
|
||||
|
||||
if (typeof window.VideoDecoder === 'function') {
|
||||
decoders.push({
|
||||
name: 'WebCodecs',
|
||||
factory: WebCodecsDecoder,
|
||||
});
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
if (value < min) {
|
||||
return min;
|
||||
}
|
||||
|
||||
if (value > max) {
|
||||
return max;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export const Scrcpy = withDisplayName('Scrcpy')((): JSX.Element | null => {
|
||||
const { show: showErrorDialog } = useContext(ErrorDialogContext);
|
||||
|
||||
const device = useAdbDevice();
|
||||
const [running, setRunning] = useState(false);
|
||||
|
||||
const [canvasKey, setCanvasKey] = useState(decoders[0].name);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
const [height, setHeight] = useState(0);
|
||||
const handleCanvasRef = useCallback((canvas: HTMLCanvasElement | null) => {
|
||||
canvasRef.current = canvas;
|
||||
if (canvas) {
|
||||
canvas.addEventListener('touchstart', e => {
|
||||
e.preventDefault();
|
||||
});
|
||||
canvas.addEventListener('contextmenu', e => {
|
||||
e.preventDefault();
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
|
||||
const [serverTotalSize, setServerTotalSize] = useState(0);
|
||||
|
||||
const [serverDownloadedSize, setServerDownloadedSize] = useState(0);
|
||||
const [debouncedServerDownloadedSize, serverDownloadSpeed] = useSpeed(serverDownloadedSize, serverTotalSize);
|
||||
|
||||
const [serverUploadedSize, setServerUploadedSize] = useState(0);
|
||||
const [debouncedServerUploadedSize, serverUploadSpeed] = useSpeed(serverUploadedSize, serverTotalSize);
|
||||
|
||||
const [settingsVisible, { toggle: toggleSettingsVisible }] = useBoolean(false);
|
||||
|
||||
const [selectedDecoder, setSelectedDecoder] = useState(decoders[0]);
|
||||
const decoderRef = useRef<Decoder | undefined>(undefined);
|
||||
const handleSelectedDecoderChange = useCallback((e?: FormEvent<HTMLElement>, option?: IDropdownOption) => {
|
||||
if (!option) { return; }
|
||||
setSelectedDecoder(option.data as { name: string; factory: DecoderConstructor; });
|
||||
}, []);
|
||||
useLayoutEffect(() => {
|
||||
if (!running) {
|
||||
// Different decoders may need different canvas context,
|
||||
// but it's impossible to change context type on a canvas element,
|
||||
// so re-render canvas element after stopped
|
||||
setCanvasKey(selectedDecoder.name);
|
||||
}
|
||||
}, [running, selectedDecoder]);
|
||||
|
||||
const [encoders, setEncoders] = useState<string[]>([]);
|
||||
const [currentEncoder, setCurrentEncoder] = useState<string>();
|
||||
const handleCurrentEncoderChange = useCallback((e?: FormEvent<HTMLElement>, option?: IDropdownOption) => {
|
||||
if (!option) { return; }
|
||||
setCurrentEncoder(option.key as string);
|
||||
}, []);
|
||||
|
||||
const [resolution, setResolution] = useState(1080);
|
||||
const handleResolutionChange = useCallback((e: any, value?: string) => {
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
setResolution(+value);
|
||||
}, []);
|
||||
|
||||
const [bitRate, setBitRate] = useState(4_000_000);
|
||||
const handleBitRateChange = useCallback((e: any, value?: string) => {
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
setBitRate(+value);
|
||||
}, []);
|
||||
|
||||
const [tunnelForward, setTunnelForward] = useState(false);
|
||||
const handleTunnelForwardChange = useCallback((event: React.MouseEvent<HTMLElement>, checked?: boolean) => {
|
||||
if (checked === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTunnelForward(checked);
|
||||
}, []);
|
||||
|
||||
const scrcpyClientRef = useRef<ScrcpyClient>();
|
||||
|
||||
const [demoModeVisible, { toggle: toggleDemoModeVisible }] = useBoolean(false);
|
||||
|
||||
const start = useCallback(() => {
|
||||
if (!device) {
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
if (!selectedDecoder) {
|
||||
throw new Error('No available decoder');
|
||||
}
|
||||
|
||||
setServerTotalSize(0);
|
||||
setServerDownloadedSize(0);
|
||||
setServerUploadedSize(0);
|
||||
setConnecting(true);
|
||||
|
||||
const serverBuffer = await fetchServer(([downloaded, total]) => {
|
||||
setServerDownloadedSize(downloaded);
|
||||
setServerTotalSize(total);
|
||||
});
|
||||
|
||||
const sync = await device.sync();
|
||||
await sync.write(
|
||||
DeviceServerPath,
|
||||
serverBuffer,
|
||||
undefined,
|
||||
undefined,
|
||||
setServerUploadedSize
|
||||
);
|
||||
|
||||
let tunnelForward!: boolean;
|
||||
setTunnelForward(current => {
|
||||
tunnelForward = current;
|
||||
return current;
|
||||
});
|
||||
|
||||
const encoders = await ScrcpyClient.getEncoders({
|
||||
device,
|
||||
path: DeviceServerPath,
|
||||
version: ScrcpyServerVersion,
|
||||
logLevel: ScrcpyLogLevel.Debug,
|
||||
bitRate: 4_000_000,
|
||||
tunnelForward,
|
||||
});
|
||||
if (encoders.length === 0) {
|
||||
throw new Error('No available encoder found');
|
||||
}
|
||||
setEncoders(encoders);
|
||||
|
||||
// Run scrcpy once will delete the server file
|
||||
// Re-push it
|
||||
await sync.write(
|
||||
DeviceServerPath,
|
||||
serverBuffer,
|
||||
);
|
||||
|
||||
const options: ScrcpyClientOptions = {
|
||||
device,
|
||||
path: DeviceServerPath,
|
||||
version: ScrcpyServerVersion,
|
||||
logLevel: ScrcpyLogLevel.Debug,
|
||||
maxSize: 1080,
|
||||
bitRate: 4_000_000,
|
||||
orientation: ScrcpyScreenOrientation.Unlocked,
|
||||
tunnelForward,
|
||||
// TinyH264 only supports Baseline profile
|
||||
profile: AndroidCodecProfile.Baseline,
|
||||
level: AndroidCodecLevel.Level4,
|
||||
};
|
||||
|
||||
setCurrentEncoder(current => {
|
||||
if (current) {
|
||||
options.encoder = current;
|
||||
return current;
|
||||
} else {
|
||||
options.encoder = encoders[0];
|
||||
return encoders[0];
|
||||
}
|
||||
});
|
||||
|
||||
setResolution(current => {
|
||||
options.maxSize = current;
|
||||
return current;
|
||||
});
|
||||
|
||||
setBitRate(current => {
|
||||
options.bitRate = current;
|
||||
return current;
|
||||
});
|
||||
|
||||
const client = new ScrcpyClient(options);
|
||||
|
||||
client.onDebug(message => {
|
||||
console.debug('[server] ' + message);
|
||||
});
|
||||
client.onInfo(message => {
|
||||
console.log('[server] ' + message);
|
||||
});
|
||||
client.onError(({ message }) => {
|
||||
showErrorDialog(message);
|
||||
});
|
||||
client.onClose(stop);
|
||||
|
||||
const decoder = new selectedDecoder.factory(canvasRef.current!);
|
||||
decoderRef.current = decoder;
|
||||
|
||||
client.onSizeChanged(async (config) => {
|
||||
const { croppedWidth, croppedHeight, } = config;
|
||||
|
||||
setWidth(croppedWidth);
|
||||
setHeight(croppedHeight);
|
||||
|
||||
const canvas = canvasRef.current!;
|
||||
canvas.width = croppedWidth;
|
||||
canvas.height = croppedHeight;
|
||||
|
||||
await decoder.configure(config);
|
||||
});
|
||||
|
||||
client.onVideoData(async ({ data }) => {
|
||||
await decoder.decode(data);
|
||||
});
|
||||
|
||||
client.onClipboardChange(content => {
|
||||
window.navigator.clipboard.writeText(content);
|
||||
});
|
||||
|
||||
await client.start();
|
||||
scrcpyClientRef.current = client;
|
||||
setRunning(true);
|
||||
} catch (e: any) {
|
||||
showErrorDialog(e.message);
|
||||
} finally {
|
||||
setConnecting(false);
|
||||
}
|
||||
})();
|
||||
}, [device, selectedDecoder]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
(async () => {
|
||||
if (!scrcpyClientRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
await scrcpyClientRef.current.close();
|
||||
scrcpyClientRef.current = undefined;
|
||||
|
||||
decoderRef.current?.dispose();
|
||||
|
||||
setRunning(false);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const deviceViewRef = useRef<DeviceViewRef | null>(null);
|
||||
const commandBarItems = useMemo((): ICommandBarItemProps[] => {
|
||||
const result: ICommandBarItemProps[] = [];
|
||||
|
||||
if (running) {
|
||||
result.push({
|
||||
key: 'stop',
|
||||
iconProps: { iconName: 'Stop' },
|
||||
text: 'Stop',
|
||||
onClick: stop,
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
key: 'start',
|
||||
disabled: !device,
|
||||
iconProps: { iconName: 'Play' },
|
||||
text: 'Start',
|
||||
onClick: start,
|
||||
});
|
||||
}
|
||||
|
||||
result.push({
|
||||
key: 'fullscreen',
|
||||
disabled: !running,
|
||||
iconProps: { iconName: 'Fullscreen' },
|
||||
text: 'Fullscreen',
|
||||
onClick: () => { deviceViewRef.current?.enterFullscreen(); },
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [device, running, start]);
|
||||
|
||||
const commandBarFarItems = useMemo((): ICommandBarItemProps[] => [
|
||||
{
|
||||
key: 'Settings',
|
||||
iconProps: { iconName: 'Settings' },
|
||||
checked: settingsVisible,
|
||||
text: 'Settings',
|
||||
onClick: toggleSettingsVisible,
|
||||
},
|
||||
{
|
||||
key: 'DemoMode',
|
||||
iconProps: { iconName: 'Personalize' },
|
||||
checked: demoModeVisible,
|
||||
text: 'Demo Mode Settings',
|
||||
onClick: toggleDemoModeVisible,
|
||||
},
|
||||
{
|
||||
key: 'info',
|
||||
iconProps: { iconName: 'Info' },
|
||||
iconOnly: true,
|
||||
tooltipHostProps: {
|
||||
content: (
|
||||
<>
|
||||
<p>
|
||||
<ExternalLink href="https://github.com/Genymobile/scrcpy" spaceAfter>Scrcpy</ExternalLink>
|
||||
developed by Genymobile can display the screen with low latency (1~2 frames) and control the device, all without root access.
|
||||
</p>
|
||||
<p>
|
||||
I reimplemented the protocol in JavaScript, a pre-built server binary from Genymobile is used.
|
||||
</p>
|
||||
<p>
|
||||
It uses tinyh264 as decoder to achieve low latency. But since it's a software decoder, high CPU usage and sub-optimal compatibility are expected.
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
calloutProps: {
|
||||
calloutMaxWidth: 300,
|
||||
}
|
||||
},
|
||||
}
|
||||
], [settingsVisible, demoModeVisible]);
|
||||
|
||||
const injectTouch = useCallback((
|
||||
action: AndroidMotionEventAction,
|
||||
e: React.PointerEvent<HTMLCanvasElement>
|
||||
) => {
|
||||
const view = canvasRef.current!.getBoundingClientRect();
|
||||
const pointerViewX = e.clientX - view.x;
|
||||
const pointerViewY = e.clientY - view.y;
|
||||
const pointerScreenX = clamp(pointerViewX / view.width, 0, 1) * width;
|
||||
const pointerScreenY = clamp(pointerViewY / view.height, 0, 1) * height;
|
||||
|
||||
scrcpyClientRef.current?.injectTouch({
|
||||
action,
|
||||
pointerId: BigInt(e.pointerId),
|
||||
pointerX: pointerScreenX,
|
||||
pointerY: pointerScreenY,
|
||||
pressure: e.pressure * 65535,
|
||||
buttons: 0,
|
||||
});
|
||||
}, [width, height]);
|
||||
|
||||
const handlePointerDown = useCallback((e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (e.button !== 0) {
|
||||
return;
|
||||
}
|
||||
canvasRef.current!.focus();
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
injectTouch(AndroidMotionEventAction.Down, e);
|
||||
}, [injectTouch]);
|
||||
|
||||
const handlePointerMove = useCallback((e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (e.buttons !== 1) {
|
||||
return;
|
||||
}
|
||||
injectTouch(AndroidMotionEventAction.Move, e);
|
||||
}, [injectTouch]);
|
||||
|
||||
const handlePointerUp = useCallback((e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (e.button !== 0) {
|
||||
return;
|
||||
}
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
injectTouch(AndroidMotionEventAction.Up, e);
|
||||
}, [injectTouch]);
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent<HTMLCanvasElement>) => {
|
||||
const key = e.key;
|
||||
if (key.match(/^[a-z0-9]$/i)) {
|
||||
scrcpyClientRef.current!.injectText(key);
|
||||
return;
|
||||
}
|
||||
|
||||
const keyCode = ({
|
||||
Backspace: AndroidKeyCode.Delete,
|
||||
} as Record<string, AndroidKeyCode | undefined>)[key];
|
||||
if (keyCode) {
|
||||
scrcpyClientRef.current!.injectKeyCode({
|
||||
keyCode,
|
||||
metaState: 0,
|
||||
repeat: 0,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleBackClick = useCallback(() => {
|
||||
scrcpyClientRef.current!.pressBackOrTurnOnScreen();
|
||||
}, []);
|
||||
|
||||
const handleHomeClick = useCallback(() => {
|
||||
scrcpyClientRef.current!.injectKeyCode({
|
||||
keyCode: AndroidKeyCode.Home,
|
||||
repeat: 0,
|
||||
metaState: 0,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleAppSwitchClick = useCallback(() => {
|
||||
scrcpyClientRef.current!.injectKeyCode({
|
||||
keyCode: AndroidKeyCode.AppSwitch,
|
||||
repeat: 0,
|
||||
metaState: 0,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const bottomElement = (
|
||||
<Stack verticalFill horizontalAlign="center" style={{ background: '#999' }}>
|
||||
<Stack verticalFill horizontal style={{ width: '100%', maxWidth: 300 }} horizontalAlign="space-evenly" verticalAlign="center">
|
||||
<IconButton
|
||||
iconProps={{ iconName: 'Play' }}
|
||||
style={{ transform: 'rotate(180deg)', color: 'white' }}
|
||||
onClick={handleBackClick}
|
||||
/>
|
||||
<IconButton
|
||||
iconProps={{ iconName: 'LocationCircle' }}
|
||||
style={{ color: 'white' }}
|
||||
onClick={handleHomeClick}
|
||||
/>
|
||||
<IconButton
|
||||
iconProps={{ iconName: 'Stop' }}
|
||||
style={{ color: 'white' }}
|
||||
onClick={handleAppSwitchClick}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const layerHostId = useId('layerHost');
|
||||
|
||||
return (
|
||||
<>
|
||||
<CommandBar items={commandBarItems} farItems={commandBarFarItems} />
|
||||
|
||||
<Stack horizontal grow styles={{ root: { height: 0 } }}>
|
||||
<DeviceView
|
||||
ref={deviceViewRef}
|
||||
width={width}
|
||||
height={height}
|
||||
bottomElement={bottomElement}
|
||||
bottomHeight={40}
|
||||
>
|
||||
<canvas
|
||||
key={canvasKey}
|
||||
ref={handleCanvasRef}
|
||||
style={{ display: 'block', outline: 'none' }}
|
||||
tabIndex={-1}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</DeviceView>
|
||||
|
||||
<div style={{ padding: 12, overflow: 'hidden auto', display: settingsVisible ? 'block' : 'none', width: 300 }}>
|
||||
<div>Changes will take effect on next connection</div>
|
||||
|
||||
<Dropdown
|
||||
label="Encoder"
|
||||
options={encoders.map(item => ({ key: item, text: item }))}
|
||||
selectedKey={currentEncoder}
|
||||
placeholder="Connect once to retrieve encoder list"
|
||||
onChange={handleCurrentEncoderChange}
|
||||
/>
|
||||
|
||||
{decoders.length > 1 && (
|
||||
<Dropdown
|
||||
label="Decoder"
|
||||
options={decoders.map(item => ({ key: item.name, text: item.name, data: item }))}
|
||||
selectedKey={selectedDecoder.name}
|
||||
onChange={handleSelectedDecoderChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SpinButton
|
||||
label="Max Resolution (longer side, 0 = unlimited)"
|
||||
labelPosition={Position.top}
|
||||
value={resolution.toString()}
|
||||
min={0}
|
||||
max={2560}
|
||||
step={100}
|
||||
onChange={handleResolutionChange}
|
||||
/>
|
||||
|
||||
<SpinButton
|
||||
label="Max Bit Rate"
|
||||
labelPosition={Position.top}
|
||||
value={bitRate.toString()}
|
||||
min={100}
|
||||
max={10_000_000}
|
||||
step={100}
|
||||
onChange={handleBitRateChange}
|
||||
/>
|
||||
|
||||
<Toggle
|
||||
label={
|
||||
<>
|
||||
<span>Use forward connection{' '}</span>
|
||||
<TooltipHost content="Old Android devices may not support reverse connection when using ADB over WiFi">
|
||||
<Icon iconName="Info" />
|
||||
</TooltipHost>
|
||||
</>
|
||||
}
|
||||
checked={tunnelForward}
|
||||
onChange={handleTunnelForwardChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DemoMode
|
||||
device={device}
|
||||
style={{ display: demoModeVisible ? 'block' : 'none' }}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{connecting && <LayerHost id={layerHostId} style={{ position: 'absolute', top: 0, bottom: 0, left: 0, right: 0, margin: 0 }} />}
|
||||
|
||||
<Dialog
|
||||
hidden={!connecting}
|
||||
modalProps={{ layerProps: { hostId: layerHostId } }}
|
||||
dialogContentProps={{
|
||||
title: 'Connecting...'
|
||||
}}
|
||||
>
|
||||
<Stack tokens={CommonStackTokens}>
|
||||
<ProgressIndicator
|
||||
label="1. Downloading scrcpy server..."
|
||||
percentComplete={serverTotalSize ? serverDownloadedSize / serverTotalSize : undefined}
|
||||
description={formatSpeed(debouncedServerDownloadedSize, serverTotalSize, serverDownloadSpeed)}
|
||||
/>
|
||||
|
||||
<ProgressIndicator
|
||||
label="2. Pushing scrcpy server to device..."
|
||||
progressHidden={serverTotalSize === 0 || serverDownloadedSize !== serverTotalSize}
|
||||
percentComplete={serverUploadedSize / serverTotalSize}
|
||||
description={formatSpeed(debouncedServerUploadedSize, serverTotalSize, serverUploadSpeed)}
|
||||
/>
|
||||
|
||||
<ProgressIndicator
|
||||
label="3. Starting scrcpy server on device..."
|
||||
progressHidden={serverTotalSize === 0 || serverUploadedSize !== serverTotalSize}
|
||||
/>
|
||||
</Stack>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
});
|
|
@ -1,204 +0,0 @@
|
|||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright (C) 2018 Genymobile
|
||||
Copyright (C) 2018-2020 Romain Vimont
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
# scrcpy server binary
|
||||
|
||||
The scrcpy server binary was built by scrcpy team, copied here by me, without any changes.
|
||||
|
||||
Current version is [official build 1.17](https://github.com/Genymobile/scrcpy/releases/tag/v1.17) (Checksum is on the release page)
|
||||
|
||||
Scrcpy is open-sourced under Apache License 2.0. See [LICENSE](LICENSE) file for detail.
|
|
@ -1,590 +0,0 @@
|
|||
import { Adb, AdbBufferedStream, AdbLegacyShell, AdbShell, DataEventEmitter } from '@yume-chan/adb';
|
||||
import { PromiseResolver } from '@yume-chan/async';
|
||||
import { EventEmitter } from '@yume-chan/event';
|
||||
import Struct from '@yume-chan/struct';
|
||||
import { AndroidCodecLevel, AndroidCodecProfile } from './codec';
|
||||
import { ScrcpyClientConnection, ScrcpyClientForwardConnection, ScrcpyClientReverseConnection } from "./connection";
|
||||
import { AndroidKeyEventAction, AndroidMotionEventAction, ScrcpyControlMessageType, ScrcpyInjectKeyCodeControlMessage, ScrcpyInjectTextControlMessage, ScrcpyInjectTouchControlMessage, ScrcpySimpleControlMessage } from './message';
|
||||
import { parse_sequence_parameter_set, SequenceParameterSet } from './sps';
|
||||
|
||||
export enum ScrcpyLogLevel {
|
||||
Debug = 'debug',
|
||||
Info = 'info',
|
||||
Warn = 'warn',
|
||||
Error = 'error',
|
||||
}
|
||||
|
||||
interface ScrcpyError {
|
||||
type: string;
|
||||
|
||||
message: string;
|
||||
|
||||
stackTrace: string[];
|
||||
}
|
||||
|
||||
interface ScrcpyOutput {
|
||||
level: ScrcpyLogLevel;
|
||||
|
||||
message: string;
|
||||
|
||||
error?: ScrcpyError;
|
||||
}
|
||||
|
||||
class LineReader {
|
||||
private readonly text: string;
|
||||
|
||||
private start = 0;
|
||||
|
||||
private peekLine: string | undefined;
|
||||
|
||||
private peekEnd = 0;
|
||||
|
||||
constructor(text: string) {
|
||||
this.text = text;
|
||||
}
|
||||
|
||||
public next(): string | undefined {
|
||||
let result = this.peek();
|
||||
this.start = this.peekEnd;
|
||||
this.peekEnd = 0;
|
||||
return result;
|
||||
}
|
||||
|
||||
public peek(): string | undefined {
|
||||
if (this.peekEnd) {
|
||||
return this.peekLine;
|
||||
}
|
||||
|
||||
const index = this.text.indexOf('\n', this.start);
|
||||
if (index === -1) {
|
||||
this.peekLine = undefined;
|
||||
this.peekEnd = this.text.length;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const line = this.text.substring(this.start, index);
|
||||
this.peekLine = line;
|
||||
this.peekEnd = index + 1;
|
||||
return line;
|
||||
}
|
||||
}
|
||||
|
||||
function* parseScrcpyOutput(text: string): Generator<ScrcpyOutput> {
|
||||
const lines = new LineReader(text);
|
||||
let line: string | undefined;
|
||||
while (line = lines.next()) {
|
||||
if (line === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('[server] ')) {
|
||||
line = line.substring('[server] '.length);
|
||||
|
||||
if (line.startsWith('DEBUG: ')) {
|
||||
yield {
|
||||
level: ScrcpyLogLevel.Debug,
|
||||
message: line.substring('DEBUG: '.length),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('INFO: ')) {
|
||||
yield {
|
||||
level: ScrcpyLogLevel.Info,
|
||||
message: line.substring('INFO: '.length),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('ERROR: ')) {
|
||||
line = line.substring('ERROR: '.length);
|
||||
const message = line;
|
||||
|
||||
let error: ScrcpyError | undefined;
|
||||
if (line.startsWith('Exception on thread')) {
|
||||
if (line = lines.next()) {
|
||||
const [errorType, errorMessage] = line.split(': ', 2);
|
||||
const stackTrace: string[] = [];
|
||||
while (line = lines.peek()) {
|
||||
if (line.startsWith('\t')) {
|
||||
stackTrace.push(line.trim());
|
||||
lines.next();
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
error = {
|
||||
type: errorType,
|
||||
message: errorMessage,
|
||||
stackTrace,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
yield {
|
||||
level: ScrcpyLogLevel.Error,
|
||||
message,
|
||||
error,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
yield {
|
||||
level: ScrcpyLogLevel.Info,
|
||||
message: line,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export enum ScrcpyScreenOrientation {
|
||||
Unlocked = -1,
|
||||
Portrait = 0,
|
||||
Landscape = 1,
|
||||
PortraitFlipped = 2,
|
||||
LandscapeFlipped = 3,
|
||||
}
|
||||
|
||||
const Size =
|
||||
new Struct()
|
||||
.uint16('width')
|
||||
.uint16('height');
|
||||
|
||||
const VideoPacket =
|
||||
new Struct()
|
||||
.int64('pts')
|
||||
.uint32('size')
|
||||
.arrayBuffer('data', { lengthField: 'size' });
|
||||
|
||||
export const NoPts = BigInt(-1);
|
||||
|
||||
export type VideoPacket = typeof VideoPacket['TDeserializeResult'];
|
||||
|
||||
const ClipboardMessage =
|
||||
new Struct()
|
||||
.uint32('length')
|
||||
.string('content', { lengthField: 'length' });
|
||||
|
||||
export interface ScrcpyClientOptions {
|
||||
device: Adb;
|
||||
|
||||
path: string;
|
||||
|
||||
version: string;
|
||||
|
||||
logLevel?: ScrcpyLogLevel;
|
||||
|
||||
/**
|
||||
* The maximum value of both width and height.
|
||||
*/
|
||||
maxSize?: number | undefined;
|
||||
|
||||
bitRate: number;
|
||||
|
||||
maxFps?: number;
|
||||
|
||||
/**
|
||||
* The orientation of the video stream.
|
||||
*
|
||||
* It will not keep the device screen in specific orientation,
|
||||
* only the captured video will in this orientation.
|
||||
*/
|
||||
orientation?: ScrcpyScreenOrientation;
|
||||
|
||||
tunnelForward?: boolean;
|
||||
|
||||
profile?: AndroidCodecProfile;
|
||||
|
||||
level?: AndroidCodecLevel;
|
||||
|
||||
encoder?: string;
|
||||
}
|
||||
|
||||
export interface FrameSize {
|
||||
sequenceParameterSet: SequenceParameterSet;
|
||||
|
||||
width: number;
|
||||
height: number;
|
||||
|
||||
cropLeft: number;
|
||||
cropRight: number;
|
||||
|
||||
cropTop: number;
|
||||
cropBottom: number;
|
||||
|
||||
croppedWidth: number;
|
||||
croppedHeight: number;
|
||||
}
|
||||
|
||||
const encoderRegex = /^\s+scrcpy --encoder-name '(.*?)'/;
|
||||
|
||||
export class ScrcpyClient {
|
||||
public static async getEncoders(options: ScrcpyClientOptions): Promise<string[]> {
|
||||
const client = new ScrcpyClient({
|
||||
...options,
|
||||
// Provide an invalid encoder name
|
||||
// So the server will return all available encoders
|
||||
encoder: '_',
|
||||
});
|
||||
|
||||
const resolver = new PromiseResolver<string[]>();
|
||||
const encoders: string[] = [];
|
||||
client.onError(({ message, error }) => {
|
||||
if (error && error.type !== 'com.genymobile.scrcpy.InvalidEncoderException') {
|
||||
resolver.reject(new Error(`${error.type}: ${error.message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const match = message.match(encoderRegex);
|
||||
if (match) {
|
||||
encoders.push(match[1]);
|
||||
}
|
||||
});
|
||||
|
||||
client.onClose(() => {
|
||||
resolver.resolve(encoders);
|
||||
});
|
||||
|
||||
// Scrcpy server will open connections, before initializing encoder
|
||||
// Thus although an invalid encoder name is given, the start process will success
|
||||
await client.start();
|
||||
|
||||
return resolver.promise;
|
||||
}
|
||||
|
||||
private readonly options: ScrcpyClientOptions;
|
||||
|
||||
public get backend() { return this.options.device.backend; }
|
||||
|
||||
private process: AdbShell | undefined;
|
||||
|
||||
private videoStream: AdbBufferedStream | undefined;
|
||||
|
||||
private controlStream: AdbBufferedStream | undefined;
|
||||
|
||||
private readonly debugEvent = new EventEmitter<string>();
|
||||
public get onDebug() { return this.debugEvent.event; }
|
||||
|
||||
private readonly infoEvent = new EventEmitter<string>();
|
||||
public get onInfo() { return this.infoEvent.event; }
|
||||
|
||||
private readonly errorEvent = new EventEmitter<ScrcpyOutput>();
|
||||
public get onError() { return this.errorEvent.event; }
|
||||
|
||||
private readonly closeEvent = new EventEmitter<void>();
|
||||
public get onClose() { return this.closeEvent.event; }
|
||||
|
||||
private _running = false;
|
||||
public get running() { return this._running; }
|
||||
|
||||
private _screenWidth: number | undefined;
|
||||
public get screenWidth() { return this._screenWidth; }
|
||||
|
||||
private _screenHeight: number | undefined;
|
||||
public get screenHeight() { return this._screenHeight; }
|
||||
|
||||
private readonly sizeChangedEvent = new EventEmitter<FrameSize>();
|
||||
public get onSizeChanged() { return this.sizeChangedEvent.event; }
|
||||
|
||||
private readonly videoDataEvent = new DataEventEmitter<VideoPacket>();
|
||||
public get onVideoData() { return this.videoDataEvent.event; }
|
||||
|
||||
private readonly clipboardChangeEvent = new EventEmitter<string>();
|
||||
public get onClipboardChange() { return this.clipboardChangeEvent.event; }
|
||||
|
||||
private sendingTouchMessage = false;
|
||||
|
||||
public constructor(options: ScrcpyClientOptions) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
const {
|
||||
device,
|
||||
path,
|
||||
version,
|
||||
logLevel = ScrcpyLogLevel.Error,
|
||||
maxSize = 0,
|
||||
bitRate,
|
||||
maxFps = 0,
|
||||
orientation = ScrcpyScreenOrientation.Unlocked,
|
||||
tunnelForward = false,
|
||||
profile = AndroidCodecProfile.Baseline,
|
||||
level = AndroidCodecLevel.Level4,
|
||||
encoder = '-',
|
||||
} = this.options;
|
||||
|
||||
let connection: ScrcpyClientConnection | undefined;
|
||||
let process: AdbShell | undefined;
|
||||
|
||||
try {
|
||||
if (tunnelForward) {
|
||||
connection = new ScrcpyClientForwardConnection(device);
|
||||
} else {
|
||||
connection = new ScrcpyClientReverseConnection(device);
|
||||
}
|
||||
await connection.initialize();
|
||||
|
||||
process = await device.childProcess.spawn([
|
||||
`CLASSPATH=${path}`,
|
||||
'app_process',
|
||||
/* unused */ '/',
|
||||
'com.genymobile.scrcpy.Server',
|
||||
version,
|
||||
logLevel,
|
||||
maxSize.toString(), // (0: unlimited)
|
||||
bitRate.toString(),
|
||||
maxFps.toString(),
|
||||
orientation.toString(),
|
||||
tunnelForward.toString(),
|
||||
/* crop */ '-',
|
||||
/* send_frame_meta */ 'true', // always send frame meta (packet boundaries + timestamp)
|
||||
/* control */ 'true',
|
||||
/* display_id */ '0',
|
||||
/* show_touches */ 'false',
|
||||
/* stay_awake */ 'true',
|
||||
/* codec_options */ `profile=${profile},level=${level}`,
|
||||
encoder,
|
||||
], {
|
||||
// Disable Shell Protocol to simplify processing
|
||||
shells: [AdbLegacyShell],
|
||||
});
|
||||
|
||||
process.onStdout(this.handleProcessOutput, this);
|
||||
|
||||
const resolver = new PromiseResolver<never>();
|
||||
const removeEventListener = process.onExit(() => {
|
||||
resolver.reject('Server died');
|
||||
});
|
||||
|
||||
const [videoStream, controlStream] = await Promise.race([
|
||||
resolver.promise,
|
||||
connection.getStreams(),
|
||||
]);
|
||||
|
||||
removeEventListener();
|
||||
this.process = process;
|
||||
this.process.onExit(this.handleProcessClosed, this);
|
||||
this.videoStream = videoStream;
|
||||
this.controlStream = controlStream;
|
||||
|
||||
this._running = true;
|
||||
this.receiveVideo();
|
||||
this.receiveControl();
|
||||
} catch (e) {
|
||||
await process?.kill();
|
||||
throw e;
|
||||
} finally {
|
||||
connection?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private handleProcessOutput(data: ArrayBuffer) {
|
||||
const string = this.options.device.backend.decodeUtf8(data);
|
||||
for (const output of parseScrcpyOutput(string)) {
|
||||
switch (output.level) {
|
||||
case ScrcpyLogLevel.Debug:
|
||||
this.debugEvent.fire(output.message);
|
||||
break;
|
||||
case ScrcpyLogLevel.Info:
|
||||
this.infoEvent.fire(output.message);
|
||||
break;
|
||||
case ScrcpyLogLevel.Error:
|
||||
this.errorEvent.fire(output);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleProcessClosed() {
|
||||
this._running = false;
|
||||
this.closeEvent.fire();
|
||||
}
|
||||
|
||||
private async receiveVideo() {
|
||||
if (!this.videoStream) {
|
||||
throw new Error('receiveVideo started before initialization');
|
||||
}
|
||||
|
||||
try {
|
||||
// Device name, we don't need it
|
||||
await this.videoStream.read(64);
|
||||
|
||||
// Initial video size
|
||||
const { width, height } = await Size.deserialize(this.videoStream);
|
||||
this._screenWidth = width;
|
||||
this._screenHeight = height;
|
||||
|
||||
let buffer: ArrayBuffer | undefined;
|
||||
while (this._running) {
|
||||
const { pts, data } = await VideoPacket.deserialize(this.videoStream);
|
||||
if (!data || data.byteLength === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pts === NoPts) {
|
||||
const sequenceParameterSet = parse_sequence_parameter_set(data.slice(0));
|
||||
|
||||
const {
|
||||
pic_width_in_mbs_minus1,
|
||||
pic_height_in_map_units_minus1,
|
||||
frame_mbs_only_flag,
|
||||
frame_crop_left_offset,
|
||||
frame_crop_right_offset,
|
||||
frame_crop_top_offset,
|
||||
frame_crop_bottom_offset,
|
||||
} = sequenceParameterSet;
|
||||
const width = (pic_width_in_mbs_minus1 + 1) * 16;
|
||||
const height = (pic_height_in_map_units_minus1 + 1) * (2 - frame_mbs_only_flag) * 16;
|
||||
const cropLeft = frame_crop_left_offset * 2;
|
||||
const cropRight = frame_crop_right_offset * 2;
|
||||
const cropTop = frame_crop_top_offset * 2;
|
||||
const cropBottom = frame_crop_bottom_offset * 2;
|
||||
|
||||
const screenWidth = width - cropLeft - cropRight;
|
||||
const screenHeight = height - cropTop - cropBottom;
|
||||
this._screenWidth = screenWidth;
|
||||
this._screenHeight = screenHeight;
|
||||
|
||||
this.sizeChangedEvent.fire({
|
||||
sequenceParameterSet,
|
||||
width,
|
||||
height,
|
||||
cropLeft: cropLeft,
|
||||
cropRight: cropRight,
|
||||
cropTop: cropTop,
|
||||
cropBottom: cropBottom,
|
||||
croppedWidth: screenWidth,
|
||||
croppedHeight: screenHeight,
|
||||
});
|
||||
|
||||
buffer = data;
|
||||
continue;
|
||||
}
|
||||
|
||||
let array: Uint8Array;
|
||||
if (buffer) {
|
||||
array = new Uint8Array(buffer.byteLength + data!.byteLength);
|
||||
array.set(new Uint8Array(buffer));
|
||||
array.set(new Uint8Array(data!), buffer.byteLength);
|
||||
buffer = undefined;
|
||||
} else {
|
||||
array = new Uint8Array(data!);
|
||||
}
|
||||
|
||||
await this.videoDataEvent.fire({
|
||||
pts,
|
||||
size: array.byteLength,
|
||||
data: array.buffer,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (!this._running) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async receiveControl() {
|
||||
if (!this.controlStream) {
|
||||
throw new Error('receiveControl started before initialization');
|
||||
}
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const type = await this.controlStream.read(1);
|
||||
switch (new Uint8Array(type)[0]) {
|
||||
case 0:
|
||||
const { content } = await ClipboardMessage.deserialize(this.controlStream);
|
||||
this.clipboardChangeEvent.fire(content!);
|
||||
break;
|
||||
default:
|
||||
throw new Error('unknown control message type');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (!this._running) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async injectKeyCode(message: Omit<ScrcpyInjectKeyCodeControlMessage, 'type' | 'action'>) {
|
||||
if (!this.controlStream) {
|
||||
throw new Error('injectKeyCode called before initialization');
|
||||
}
|
||||
|
||||
await this.controlStream.write(ScrcpyInjectKeyCodeControlMessage.serialize({
|
||||
...message,
|
||||
type: ScrcpyControlMessageType.InjectKeycode,
|
||||
action: AndroidKeyEventAction.Down,
|
||||
}, this.backend));
|
||||
|
||||
await this.controlStream.write(ScrcpyInjectKeyCodeControlMessage.serialize({
|
||||
...message,
|
||||
type: ScrcpyControlMessageType.InjectKeycode,
|
||||
action: AndroidKeyEventAction.Up,
|
||||
}, this.backend));
|
||||
}
|
||||
|
||||
public async injectText(text: string) {
|
||||
if (!this.controlStream) {
|
||||
throw new Error('injectText called before initialization');
|
||||
}
|
||||
|
||||
await this.controlStream.write(ScrcpyInjectTextControlMessage.serialize({
|
||||
type: ScrcpyControlMessageType.InjectText,
|
||||
text,
|
||||
}, this.backend));
|
||||
}
|
||||
|
||||
public async injectTouch(message: Omit<ScrcpyInjectTouchControlMessage, 'type' | 'screenWidth' | 'screenHeight'>) {
|
||||
if (!this.controlStream) {
|
||||
throw new Error('injectTouch called before initialization');
|
||||
}
|
||||
|
||||
if (!this.screenWidth || !this.screenHeight) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ADB streams are actually pretty low-bandwidth and laggy
|
||||
// Re-sample move events to avoid flooding the connection
|
||||
if (this.sendingTouchMessage &&
|
||||
message.action === AndroidMotionEventAction.Move) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendingTouchMessage = true;
|
||||
const buffer = ScrcpyInjectTouchControlMessage.serialize({
|
||||
...message,
|
||||
type: ScrcpyControlMessageType.InjectTouch,
|
||||
screenWidth: this.screenWidth,
|
||||
screenHeight: this.screenHeight,
|
||||
}, this.backend);
|
||||
await this.controlStream.write(buffer);
|
||||
this.sendingTouchMessage = false;
|
||||
}
|
||||
|
||||
public async pressBackOrTurnOnScreen() {
|
||||
if (!this.controlStream) {
|
||||
throw new Error('pressBackOrTurnOnScreen called before initialization');
|
||||
}
|
||||
|
||||
const buffer = ScrcpySimpleControlMessage.serialize(
|
||||
{ type: ScrcpyControlMessageType.BackOrScreenOn },
|
||||
this.backend
|
||||
);
|
||||
await this.controlStream.write(buffer);
|
||||
}
|
||||
|
||||
public async close() {
|
||||
if (!this._running) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._running = false;
|
||||
this.videoStream?.close();
|
||||
this.controlStream?.close();
|
||||
await this.process?.kill();
|
||||
}
|
||||
}
|
|
@ -1,36 +0,0 @@
|
|||
|
||||
// See https://developer.android.com/reference/android/media/MediaCodecInfo.CodecProfileLevel
|
||||
export enum AndroidCodecProfile {
|
||||
Baseline = 0x01,
|
||||
Main = 0x02,
|
||||
Extended = 0x04,
|
||||
High = 0x08,
|
||||
High10 = 0x10,
|
||||
High422 = 0x20,
|
||||
High444 = 0x40,
|
||||
ConstrainedBaseline = 0x10000,
|
||||
ConstrainedHigh = 0x80000,
|
||||
}
|
||||
|
||||
export enum AndroidCodecLevel {
|
||||
Level1 = 0x01,
|
||||
Level1b = 0x02,
|
||||
Level11 = 0x04,
|
||||
Level12 = 0x08,
|
||||
Level13 = 0x10,
|
||||
Level2 = 0x20,
|
||||
Level21 = 0x40,
|
||||
Level22 = 0x80,
|
||||
Level3 = 0x100,
|
||||
Level31 = 0x200,
|
||||
Level32 = 0x400,
|
||||
Level4 = 0x800,
|
||||
Level41 = 0x1000,
|
||||
Level42 = 0x2000,
|
||||
Level5 = 0x4000,
|
||||
Level51 = 0x8000,
|
||||
Level52 = 0x10000,
|
||||
Level6 = 0x20000,
|
||||
Level61 = 0x40000,
|
||||
Level62 = 0x80000,
|
||||
}
|
|
@ -1,89 +0,0 @@
|
|||
import { Adb, AdbBufferedStream, AdbSocket, EventQueue } from "@yume-chan/adb";
|
||||
import { Disposable } from "@yume-chan/event";
|
||||
import { ValueOrPromise } from "@yume-chan/struct";
|
||||
import { delay } from "../../../utils";
|
||||
|
||||
export abstract class ScrcpyClientConnection implements Disposable {
|
||||
protected device: Adb;
|
||||
|
||||
public constructor(device: Adb) {
|
||||
this.device = device;
|
||||
}
|
||||
|
||||
public initialize(): ValueOrPromise<void> { }
|
||||
|
||||
public abstract getStreams(): ValueOrPromise<[videoSteam: AdbBufferedStream, controlStream: AdbBufferedStream]>;
|
||||
|
||||
public dispose(): void { }
|
||||
}
|
||||
|
||||
export class ScrcpyClientForwardConnection extends ScrcpyClientConnection {
|
||||
private async connect(): Promise<AdbBufferedStream> {
|
||||
return new AdbBufferedStream(await this.device.createSocket('localabstract:scrcpy'));
|
||||
}
|
||||
|
||||
private async connectAndRetry(): Promise<AdbBufferedStream> {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
try {
|
||||
return await this.connect();
|
||||
} catch (e) {
|
||||
await delay(100);
|
||||
}
|
||||
}
|
||||
throw new Error(`Can't connect to server after 100 retries`);
|
||||
}
|
||||
|
||||
private async connectAndReadByte(): Promise<AdbBufferedStream> {
|
||||
const stream = await this.connectAndRetry();
|
||||
// server will write a `0` to signal connection success
|
||||
await stream.read(1);
|
||||
return stream;
|
||||
}
|
||||
|
||||
public async getStreams(): Promise<[videoSteam: AdbBufferedStream, controlStream: AdbBufferedStream]> {
|
||||
return [
|
||||
await this.connectAndReadByte(),
|
||||
await this.connectAndRetry()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export class ScrcpyClientReverseConnection extends ScrcpyClientConnection {
|
||||
private streams!: EventQueue<AdbSocket>;
|
||||
|
||||
private address!: string;
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
// try to unbind first
|
||||
try {
|
||||
await this.device.reverse.remove('localabstract:scrcpy');
|
||||
} catch {
|
||||
// ignore error
|
||||
}
|
||||
|
||||
this.streams = new EventQueue<AdbSocket>();
|
||||
this.address = await this.device.reverse.add('localabstract:scrcpy', 27183, {
|
||||
onSocket: (packet, stream) => {
|
||||
this.streams.enqueue(stream);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async accept(): Promise<AdbBufferedStream> {
|
||||
return new AdbBufferedStream(await this.streams.dequeue());
|
||||
}
|
||||
|
||||
public async getStreams(): Promise<[videoSteam: AdbBufferedStream, controlStream: AdbBufferedStream]> {
|
||||
return [
|
||||
await this.accept(),
|
||||
await this.accept(),
|
||||
];
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
// Don't await this!
|
||||
// `reverse.remove`'s response will never arrive
|
||||
// before we read all pending data from `videoStream`
|
||||
this.device.reverse.remove(this.address);
|
||||
}
|
||||
}
|
|
@ -1,62 +0,0 @@
|
|||
import { EventEmitter } from "@yume-chan/event";
|
||||
import serverUrl from 'file-loader!./scrcpy-server-v1.17';
|
||||
|
||||
export const ScrcpyServerVersion = '1.17';
|
||||
|
||||
class FetchWithProgress {
|
||||
public readonly promise: Promise<ArrayBuffer>;
|
||||
|
||||
private _downloaded = 0;
|
||||
public get downloaded() { return this._downloaded; }
|
||||
|
||||
private _total = 0;
|
||||
public get total() { return this._total; }
|
||||
|
||||
private progressEvent = new EventEmitter<[download: number, total: number]>();
|
||||
public get onProgress() { return this.progressEvent.event; }
|
||||
|
||||
public constructor(url: string) {
|
||||
this.promise = this.fetch(url);
|
||||
}
|
||||
|
||||
private async fetch(url: string) {
|
||||
const response = await window.fetch(url);
|
||||
this._total = Number.parseInt(response.headers.get('Content-Length') ?? '0', 10);
|
||||
this.progressEvent.fire([this._downloaded, this._total]);
|
||||
|
||||
const reader = response.body!.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
while (true) {
|
||||
const result = await reader.read();
|
||||
if (result.done) {
|
||||
break;
|
||||
}
|
||||
chunks.push(result.value);
|
||||
this._downloaded += result.value.byteLength;
|
||||
this.progressEvent.fire([this._downloaded, this._total]);
|
||||
}
|
||||
|
||||
this._total = chunks.reduce((result, item) => result + item.byteLength, 0);
|
||||
const result = new Uint8Array(this._total);
|
||||
let position = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, position);
|
||||
position += chunk.byteLength;
|
||||
}
|
||||
return result.buffer;
|
||||
}
|
||||
}
|
||||
|
||||
let cachedValue: FetchWithProgress | undefined;
|
||||
export function fetchServer(onProgress?: (e: [downloaded: number, total: number]) => void) {
|
||||
if (!cachedValue) {
|
||||
cachedValue = new FetchWithProgress(serverUrl);
|
||||
}
|
||||
|
||||
if (onProgress) {
|
||||
cachedValue.onProgress(onProgress);
|
||||
onProgress([cachedValue.downloaded, cachedValue.total]);
|
||||
}
|
||||
|
||||
return cachedValue.promise;
|
||||
}
|
|
@ -1,4 +0,0 @@
|
|||
export * from './client';
|
||||
export * from './codec';
|
||||
export * from './fetch';
|
||||
export * from './message';
|
|
@ -1,114 +0,0 @@
|
|||
import Struct, { placeholder } from '@yume-chan/struct';
|
||||
|
||||
export enum ScrcpyControlMessageType {
|
||||
InjectKeycode,
|
||||
InjectText,
|
||||
InjectTouch,
|
||||
InjectScroll,
|
||||
BackOrScreenOn,
|
||||
ExpandNotificationPanel,
|
||||
CollapseNotificationPanel,
|
||||
GetClipboard,
|
||||
SetClipboard,
|
||||
SetScreenPowerMode,
|
||||
RotateDevice,
|
||||
}
|
||||
|
||||
export const ScrcpySimpleControlMessage =
|
||||
new Struct()
|
||||
.uint8('type', placeholder<ScrcpyControlMessageType.BackOrScreenOn>());
|
||||
|
||||
export type ScrcpySimpleControlMessage = typeof ScrcpySimpleControlMessage['TInit'];
|
||||
|
||||
export enum AndroidMotionEventAction {
|
||||
Down,
|
||||
Up,
|
||||
Move,
|
||||
Cancel,
|
||||
Outside,
|
||||
PointerDown,
|
||||
PointerUp,
|
||||
HoverMove,
|
||||
Scroll,
|
||||
HoverEnter,
|
||||
HoverExit,
|
||||
ButtonPress,
|
||||
ButtonRelease,
|
||||
}
|
||||
|
||||
export const ScrcpyInjectTouchControlMessage =
|
||||
new Struct()
|
||||
.uint8('type', ScrcpyControlMessageType.InjectTouch as const)
|
||||
.uint8('action', placeholder<AndroidMotionEventAction>())
|
||||
.uint64('pointerId')
|
||||
.uint32('pointerX')
|
||||
.uint32('pointerY')
|
||||
.uint16('screenWidth')
|
||||
.uint16('screenHeight')
|
||||
.uint16('pressure')
|
||||
.uint32('buttons');
|
||||
|
||||
export type ScrcpyInjectTouchControlMessage = typeof ScrcpyInjectTouchControlMessage['TInit'];
|
||||
|
||||
export const ScrcpyInjectTextControlMessage =
|
||||
new Struct()
|
||||
.uint8('type', ScrcpyControlMessageType.InjectText as const)
|
||||
.uint32('length')
|
||||
.string('text', { lengthField: 'length' });
|
||||
|
||||
export type ScrcpyInjectTextControlMessage =
|
||||
typeof ScrcpyInjectTextControlMessage['TInit'];
|
||||
|
||||
export enum AndroidKeyEventAction {
|
||||
Down = 0,
|
||||
Up = 1,
|
||||
}
|
||||
|
||||
export enum AndroidKeyCode {
|
||||
Home = 3,
|
||||
Back = 4,
|
||||
A = 29,
|
||||
B,
|
||||
C,
|
||||
D,
|
||||
E,
|
||||
F,
|
||||
G,
|
||||
H,
|
||||
I,
|
||||
J,
|
||||
K,
|
||||
L,
|
||||
M,
|
||||
N,
|
||||
O,
|
||||
P,
|
||||
Q,
|
||||
R,
|
||||
S,
|
||||
T,
|
||||
U,
|
||||
V,
|
||||
W,
|
||||
X,
|
||||
Y,
|
||||
Z,
|
||||
Delete = 67,
|
||||
AppSwitch = 187,
|
||||
}
|
||||
|
||||
export const ScrcpyInjectKeyCodeControlMessage =
|
||||
new Struct()
|
||||
.uint8('type', ScrcpyControlMessageType.InjectKeycode as const)
|
||||
.uint8('action', placeholder<AndroidKeyEventAction>())
|
||||
.uint32('keyCode')
|
||||
.uint32('repeat')
|
||||
.uint32('metaState');
|
||||
|
||||
export type ScrcpyInjectKeyCodeControlMessage =
|
||||
typeof ScrcpyInjectKeyCodeControlMessage['TInit'];
|
||||
|
||||
export type ScrcpyControlMessage =
|
||||
ScrcpySimpleControlMessage |
|
||||
ScrcpyInjectTouchControlMessage |
|
||||
ScrcpyInjectKeyCodeControlMessage;
|
Binary file not shown.
|
@ -1,284 +0,0 @@
|
|||
class BitReader {
|
||||
private buffer: Uint8Array;
|
||||
|
||||
private bytePosition = 0;
|
||||
|
||||
private bitPosition = 0;
|
||||
|
||||
public constructor(buffer: Uint8Array) {
|
||||
this.buffer = buffer;
|
||||
}
|
||||
|
||||
public read(length: number): number {
|
||||
let result = 0;
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
result = (result << 1) | this.next();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public next(): number {
|
||||
const value = (this.buffer[this.bytePosition] >> (7 - this.bitPosition)) & 1;
|
||||
this.bitPosition += 1;
|
||||
if (this.bitPosition === 8) {
|
||||
this.bytePosition += 1;
|
||||
this.bitPosition = 0;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
public decodeExponentialGolombNumber(): number {
|
||||
let length = 0;
|
||||
while (this.next() === 0) {
|
||||
length += 1;
|
||||
}
|
||||
if (length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return (1 << length | this.read(length)) - 1;
|
||||
}
|
||||
}
|
||||
|
||||
function* iterateNalu(buffer: Uint8Array): Generator<Uint8Array> {
|
||||
// -1 means we haven't found the first start code
|
||||
let start = -1;
|
||||
let writeIndex = 0;
|
||||
|
||||
// How many `0x00`s in a row we have counted
|
||||
let zeroCount = 0;
|
||||
|
||||
let inEmulation = false;
|
||||
|
||||
for (const byte of buffer) {
|
||||
buffer[writeIndex] = byte;
|
||||
writeIndex += 1;
|
||||
|
||||
if (inEmulation) {
|
||||
if (byte > 0x03) {
|
||||
// `0x00000304` or larger are invalid
|
||||
throw new Error('Invalid data');
|
||||
}
|
||||
|
||||
inEmulation = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (byte == 0x00) {
|
||||
zeroCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const lastZeroCount = zeroCount;
|
||||
zeroCount = 0;
|
||||
|
||||
if (start === -1) {
|
||||
// 0x000001 is the start code
|
||||
// But it can be preceded by any number of zeros
|
||||
// So 2 is the minimal
|
||||
if (lastZeroCount >= 2 && byte === 0x01) {
|
||||
// Found start of first NAL unit
|
||||
writeIndex = 0;
|
||||
start = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Not begin with start code
|
||||
throw new Error('Invalid data');
|
||||
}
|
||||
|
||||
if (lastZeroCount < 2) {
|
||||
// zero or one `0x00`s are acceptable
|
||||
continue;
|
||||
}
|
||||
|
||||
if (byte === 0x01) {
|
||||
// Remove all leading `0x00`s and this `0x01`
|
||||
writeIndex -= lastZeroCount + 1;
|
||||
|
||||
// Found another NAL unit
|
||||
yield buffer.subarray(start, writeIndex);
|
||||
|
||||
start = writeIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lastZeroCount > 2) {
|
||||
// Too much `0x00`s
|
||||
throw new Error('Invalid data');
|
||||
}
|
||||
|
||||
switch (byte) {
|
||||
case 0x02:
|
||||
// Didn't find why, but 7.4.1 NAL unit semantics forbids `0x000002` appearing in NAL units
|
||||
throw new Error('Invalid data');
|
||||
case 0x03:
|
||||
// `0x000003` is the "emulation_prevention_three_byte"
|
||||
// `0x00000300`, `0x00000301`, `0x00000302` and `0x00000303` represent
|
||||
// `0x000000`, `0x000001`, `0x000002` and `0x000003` respectively
|
||||
|
||||
// Remove current byte
|
||||
writeIndex -= 1;
|
||||
|
||||
inEmulation = true;
|
||||
break;
|
||||
default:
|
||||
// `0x000004` or larger are ok
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (inEmulation || zeroCount !== 0) {
|
||||
throw new Error('Invalid data');
|
||||
}
|
||||
|
||||
yield buffer.subarray(start, writeIndex);
|
||||
}
|
||||
|
||||
// 7.3.2.1.1 Sequence parameter set data syntax
|
||||
export function parse_sequence_parameter_set(buffer: ArrayBuffer) {
|
||||
for (const nalu of iterateNalu(new Uint8Array(buffer))) {
|
||||
const reader = new BitReader(nalu);
|
||||
if (reader.next() !== 0) {
|
||||
throw new Error('Invalid data');
|
||||
}
|
||||
|
||||
const nal_ref_idc = reader.read(2);
|
||||
const nal_unit_type = reader.read(5);
|
||||
|
||||
if (nal_unit_type !== 7) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nal_ref_idc === 0) {
|
||||
throw new Error('Invalid data');
|
||||
}
|
||||
|
||||
const profile_idc = reader.read(8);
|
||||
const constraint_set = reader.read(8);
|
||||
|
||||
const constraint_set_reader = new BitReader(new Uint8Array([constraint_set]));
|
||||
const constraint_set0_flag = !!constraint_set_reader.next();
|
||||
const constraint_set1_flag = !!constraint_set_reader.next();
|
||||
const constraint_set2_flag = !!constraint_set_reader.next();
|
||||
const constraint_set3_flag = !!constraint_set_reader.next();
|
||||
const constraint_set4_flag = !!constraint_set_reader.next();
|
||||
const constraint_set5_flag = !!constraint_set_reader.next();
|
||||
|
||||
// reserved_zero_2bits
|
||||
if (constraint_set_reader.read(2) !== 0) {
|
||||
throw new Error('Invalid data');
|
||||
}
|
||||
|
||||
const level_idc = reader.read(8);
|
||||
const seq_parameter_set_id = reader.decodeExponentialGolombNumber();
|
||||
|
||||
if (profile_idc === 100 || profile_idc === 110 ||
|
||||
profile_idc === 122 || profile_idc === 244 || profile_idc === 44 ||
|
||||
profile_idc === 83 || profile_idc === 86 || profile_idc === 118 ||
|
||||
profile_idc === 128 || profile_idc === 138 || profile_idc === 139 ||
|
||||
profile_idc === 134) {
|
||||
const chroma_format_idc = reader.decodeExponentialGolombNumber();
|
||||
if (chroma_format_idc === 3) {
|
||||
const separate_colour_plane_flag = !!reader.next();
|
||||
}
|
||||
|
||||
const bit_depth_luma_minus8 = reader.decodeExponentialGolombNumber();
|
||||
const bit_depth_chroma_minus8 = reader.decodeExponentialGolombNumber();
|
||||
|
||||
const qpprime_y_zero_transform_bypass_flag = !!reader.next();
|
||||
|
||||
const seq_scaling_matrix_present_flag = !!reader.next();
|
||||
if (seq_scaling_matrix_present_flag) {
|
||||
const seq_scaling_list_present_flag: boolean[] = [];
|
||||
for (let i = 0; i < ((chroma_format_idc !== 3) ? 8 : 12); i++) {
|
||||
seq_scaling_list_present_flag[i] = !!reader.next();
|
||||
if (seq_scaling_list_present_flag[i])
|
||||
if (i < 6) {
|
||||
// TODO
|
||||
// scaling_list( ScalingList4x4[ i ], 16,
|
||||
// UseDefaultScalingMatrix4x4Flag[ i ])
|
||||
} else {
|
||||
// TODO
|
||||
// scaling_list( ScalingList8x8[ i − 6 ], 64,
|
||||
// UseDefaultScalingMatrix8x8Flag[ i − 6 ] )
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const log2_max_frame_num_minus4 = reader.decodeExponentialGolombNumber();
|
||||
const pic_order_cnt_type = reader.decodeExponentialGolombNumber();
|
||||
if (pic_order_cnt_type === 0) {
|
||||
const log2_max_pic_order_cnt_lsb_minus4 = reader.decodeExponentialGolombNumber();
|
||||
} else if (pic_order_cnt_type === 1) {
|
||||
const delta_pic_order_always_zero_flag = reader.next();
|
||||
const offset_for_non_ref_pic = reader.decodeExponentialGolombNumber();
|
||||
const offset_for_top_to_bottom_field = reader.decodeExponentialGolombNumber();
|
||||
const num_ref_frames_in_pic_order_cnt_cycle = reader.decodeExponentialGolombNumber();
|
||||
const offset_for_ref_frame: number[] = [];
|
||||
for (let i = 0; i < num_ref_frames_in_pic_order_cnt_cycle; i++) {
|
||||
offset_for_ref_frame[i] = reader.decodeExponentialGolombNumber();
|
||||
}
|
||||
}
|
||||
|
||||
const max_num_ref_frames = reader.decodeExponentialGolombNumber();
|
||||
const gaps_in_frame_num_value_allowed_flag = reader.next();
|
||||
const pic_width_in_mbs_minus1 = reader.decodeExponentialGolombNumber();
|
||||
const pic_height_in_map_units_minus1 = reader.decodeExponentialGolombNumber();
|
||||
|
||||
const frame_mbs_only_flag = reader.next();
|
||||
if (!frame_mbs_only_flag) {
|
||||
const mb_adaptive_frame_field_flag = !!reader.next();
|
||||
}
|
||||
|
||||
const direct_8x8_inference_flag = reader.next();
|
||||
|
||||
const frame_cropping_flag = !!reader.next();
|
||||
let frame_crop_left_offset: number;
|
||||
let frame_crop_right_offset: number;
|
||||
let frame_crop_top_offset: number;
|
||||
let frame_crop_bottom_offset: number;
|
||||
if (frame_cropping_flag) {
|
||||
frame_crop_left_offset = reader.decodeExponentialGolombNumber();
|
||||
frame_crop_right_offset = reader.decodeExponentialGolombNumber();
|
||||
frame_crop_top_offset = reader.decodeExponentialGolombNumber();
|
||||
frame_crop_bottom_offset = reader.decodeExponentialGolombNumber();
|
||||
} else {
|
||||
frame_crop_left_offset = 0;
|
||||
frame_crop_right_offset = 0;
|
||||
frame_crop_top_offset = 0;
|
||||
frame_crop_bottom_offset = 0;
|
||||
}
|
||||
|
||||
const vui_parameters_present_flag = !!reader.next();
|
||||
if (vui_parameters_present_flag) {
|
||||
// TODO
|
||||
// vui_parameters( )
|
||||
}
|
||||
|
||||
return {
|
||||
profile_idc,
|
||||
constraint_set,
|
||||
constraint_set0_flag,
|
||||
constraint_set1_flag,
|
||||
constraint_set2_flag,
|
||||
constraint_set3_flag,
|
||||
constraint_set4_flag,
|
||||
constraint_set5_flag,
|
||||
level_idc,
|
||||
seq_parameter_set_id,
|
||||
pic_width_in_mbs_minus1,
|
||||
pic_height_in_map_units_minus1,
|
||||
frame_mbs_only_flag,
|
||||
frame_cropping_flag,
|
||||
frame_crop_left_offset,
|
||||
frame_crop_right_offset,
|
||||
frame_crop_top_offset,
|
||||
frame_crop_bottom_offset,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Invalid data');
|
||||
}
|
||||
|
||||
export type SequenceParameterSet = ReturnType<typeof parse_sequence_parameter_set>;
|
|
@ -1,154 +0,0 @@
|
|||
import { PromiseResolver } from '@yume-chan/async';
|
||||
import { AutoDisposable, EventEmitter } from '@yume-chan/event';
|
||||
import TinyH264Worker from 'worker-loader!./worker';
|
||||
import YUVBuffer from 'yuv-buffer';
|
||||
import YUVCanvas from 'yuv-canvas';
|
||||
import { Decoder } from "../decoder";
|
||||
import { FrameSize } from "../server";
|
||||
|
||||
let worker: TinyH264Worker | undefined;
|
||||
let workerReady = false;
|
||||
const pendingResolvers: PromiseResolver<TinyH264Decoder>[] = [];
|
||||
let streamId = 0;
|
||||
|
||||
export interface PictureReadyEventArgs {
|
||||
renderStateId: number;
|
||||
|
||||
width: number;
|
||||
|
||||
height: number;
|
||||
|
||||
data: ArrayBuffer;
|
||||
}
|
||||
|
||||
const pictureReadyEvent = new EventEmitter<PictureReadyEventArgs>();
|
||||
|
||||
export class TinyH264Decoder extends AutoDisposable {
|
||||
public readonly streamId: number;
|
||||
|
||||
private readonly pictureReadyEvent = new EventEmitter<PictureReadyEventArgs>();
|
||||
public get pictureReady() { return this.pictureReadyEvent.event; }
|
||||
|
||||
public constructor(streamId: number) {
|
||||
super();
|
||||
|
||||
this.streamId = streamId;
|
||||
this.addDisposable(pictureReadyEvent.event(this.handlePictureReady, this));
|
||||
}
|
||||
|
||||
private handlePictureReady(e: PictureReadyEventArgs) {
|
||||
if (e.renderStateId === this.streamId) {
|
||||
this.pictureReadyEvent.fire(e);
|
||||
}
|
||||
}
|
||||
|
||||
public feed(data: ArrayBuffer) {
|
||||
worker!.postMessage({
|
||||
type: 'decode',
|
||||
data: data,
|
||||
offset: 0,
|
||||
length: data.byteLength,
|
||||
renderStateId: this.streamId,
|
||||
}, [data]);
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
super.dispose();
|
||||
worker!.postMessage({
|
||||
type: 'release',
|
||||
renderStateId: this.streamId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createTinyH264Decoder(): Promise<TinyH264Decoder> {
|
||||
if (!worker) {
|
||||
worker = new TinyH264Worker();
|
||||
worker.addEventListener('message', (e) => {
|
||||
const { data } = e;
|
||||
switch (data.type) {
|
||||
case 'decoderReady':
|
||||
workerReady = true;
|
||||
for (const resolver of pendingResolvers) {
|
||||
resolver.resolve(new TinyH264Decoder(streamId));
|
||||
streamId += 1;
|
||||
}
|
||||
pendingResolvers.length = 0;
|
||||
break;
|
||||
case 'pictureReady':
|
||||
pictureReadyEvent.fire(data);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!workerReady) {
|
||||
const resolver = new PromiseResolver<TinyH264Decoder>();
|
||||
pendingResolvers.push(resolver);
|
||||
return resolver.promise;
|
||||
}
|
||||
|
||||
const decoder = new TinyH264Decoder(streamId);
|
||||
streamId += 1;
|
||||
return Promise.resolve(decoder);
|
||||
}
|
||||
|
||||
export class TinyH264DecoderWrapper implements Decoder {
|
||||
private canvas: HTMLCanvasElement;
|
||||
private decoderPromise: Promise<TinyH264Decoder> | undefined;
|
||||
|
||||
public constructor(canvas: HTMLCanvasElement) {
|
||||
this.canvas = canvas;
|
||||
}
|
||||
|
||||
async configure(config: FrameSize): Promise<void> {
|
||||
this.decoderPromise?.then(decoder => decoder.dispose());
|
||||
|
||||
this.decoderPromise = createTinyH264Decoder();
|
||||
const decoder = await this.decoderPromise;
|
||||
const { cropLeft, cropTop, croppedWidth, croppedHeight } = config;
|
||||
const yuvCanvas = YUVCanvas.attach(this.canvas);
|
||||
decoder.pictureReady((args) => {
|
||||
const { data, width: videoWidth, height: videoHeight } = args;
|
||||
|
||||
const format = YUVBuffer.format({
|
||||
width: videoWidth,
|
||||
height: videoHeight,
|
||||
chromaWidth: videoWidth / 2,
|
||||
chromaHeight: videoHeight / 2,
|
||||
cropLeft,
|
||||
cropTop,
|
||||
cropWidth: croppedWidth,
|
||||
cropHeight: croppedHeight,
|
||||
displayWidth: croppedWidth,
|
||||
displayHeight: croppedHeight,
|
||||
});
|
||||
|
||||
const array = new Uint8Array(data);
|
||||
const frame = YUVBuffer.frame(format,
|
||||
YUVBuffer.lumaPlane(format, array, videoWidth, 0),
|
||||
YUVBuffer.chromaPlane(format, array, videoWidth / 2, videoWidth * videoHeight),
|
||||
YUVBuffer.chromaPlane(format, array, videoWidth / 2, videoWidth * videoHeight + videoWidth * videoHeight / 4)
|
||||
);
|
||||
|
||||
yuvCanvas.drawFrame(frame);
|
||||
});
|
||||
}
|
||||
|
||||
async decode(data: BufferSource): Promise<void> {
|
||||
const decoder = await this.decoderPromise;
|
||||
if (!decoder) {
|
||||
throw new Error('Decoder not configured!');
|
||||
}
|
||||
|
||||
if ('buffer' in data) {
|
||||
decoder.feed(data.buffer);
|
||||
} else {
|
||||
decoder.feed(data);
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.decoderPromise?.then(decoder => decoder.dispose());
|
||||
};
|
||||
}
|
|
@ -1,2 +0,0 @@
|
|||
import { init } from 'tinyh264';
|
||||
init();
|
137
apps/demo/src/routes/scrcpy/types.d.ts
vendored
137
apps/demo/src/routes/scrcpy/types.d.ts
vendored
|
@ -1,137 +0,0 @@
|
|||
declare module 'tinyh264' {
|
||||
export function init(): void;
|
||||
}
|
||||
|
||||
declare module 'yuv-buffer' {
|
||||
/**
|
||||
* Validate and fill out a YUVFormat object structure.
|
||||
*
|
||||
* At least width and height fields are required; other fields will be
|
||||
* derived if left missing or empty:
|
||||
* - chromaWidth and chromaHeight will be copied from width and height as for a 4:4:4 layout
|
||||
* - cropLeft and cropTop will be 0
|
||||
* - cropWidth and cropHeight will be set to whatever of the frame is visible after cropTop and cropLeft are applied
|
||||
* - displayWidth and displayHeight will be set to cropWidth and cropHeight.
|
||||
*
|
||||
* @param {YUVFormat} fields - input fields, must include width and height.
|
||||
* @returns {YUVFormat} - validated structure, with all derivable fields filled out.
|
||||
* @throws exception on invalid fields or missing width/height
|
||||
*/
|
||||
export function format(fields: YUVFormat): YUVFormat;
|
||||
|
||||
/**
|
||||
* Allocate a new YUVPlane object big enough for a luma plane in the given format
|
||||
* @param {YUVFormat} format - target frame format
|
||||
* @param {Uint8Array} source - input byte array; optional (will create empty buffer if missing)
|
||||
* @param {number} stride - row length in bytes; optional (will create a default if missing)
|
||||
* @param {number} offset - offset into source array to extract; optional (will start at 0 if missing)
|
||||
* @returns {YUVPlane} - freshly allocated planar buffer
|
||||
*/
|
||||
export function lumaPlane(format: YUVFormat, source: Uint8Array, stride: number, offset: number): YUVPlane;
|
||||
|
||||
/**
|
||||
* Allocate a new YUVPlane object big enough for a chroma plane in the given format,
|
||||
* optionally copying data from an existing buffer.
|
||||
*
|
||||
* @param {YUVFormat} format - target frame format
|
||||
* @param {Uint8Array} source - input byte array; optional (will create empty buffer if missing)
|
||||
* @param {number} stride - row length in bytes; optional (will create a default if missing)
|
||||
* @param {number} offset - offset into source array to extract; optional (will start at 0 if missing)
|
||||
* @returns {YUVPlane} - freshly allocated planar buffer
|
||||
*/
|
||||
export function chromaPlane(format: YUVFormat, source: Uint8Array, stride: number, offset: number): YUVPlane;
|
||||
|
||||
/**
|
||||
* Allocate a new YUVFrame object big enough for the given format
|
||||
* @param {YUVFormat} format - target frame format
|
||||
* @param {YUVPlane} y - optional Y plane; if missing, fresh one will be allocated
|
||||
* @param {YUVPlane} u - optional U plane; if missing, fresh one will be allocated
|
||||
* @param {YUVPlane} v - optional V plane; if missing, fresh one will be allocated
|
||||
* @returns {YUVFrame} - freshly allocated frame buffer
|
||||
*/
|
||||
export function frame(format: YUVFormat, y: YUVPlane, u: YUVPlane, v: YUVPlane): YUVFrame;
|
||||
|
||||
/**
|
||||
* Duplicate a frame using new buffer memory.
|
||||
* @param {YUVFrame} frame - input frame to copyFrame
|
||||
* @returns {YUVFrame} - freshly allocated and filled frame buffer
|
||||
*/
|
||||
export function copyFrame(frame: YUVFrame): YUVFrame;
|
||||
|
||||
/**
|
||||
* List the backing buffers for the frame's planes for transfer between
|
||||
* threads via Worker.postMessage.
|
||||
* @param {YUVFrame} frame - input frame
|
||||
* @returns {Array} - list of transferable objects
|
||||
*/
|
||||
export function transferables(frame: YUVFrame): (ArrayBuffer | SharedArrayBuffer)[];
|
||||
|
||||
|
||||
/**
|
||||
* Represents metadata about a YUV frame format.
|
||||
* @typedef {Object} YUVFormat
|
||||
* @property {number} width - width of encoded frame in luma pixels
|
||||
* @property {number} height - height of encoded frame in luma pixels
|
||||
* @property {number} chromaWidth - width of encoded frame in chroma pixels
|
||||
* @property {number} chromaHeight - height of encoded frame in chroma pixels
|
||||
* @property {number} cropLeft - upper-left X coordinate of visible crop region, in luma pixels
|
||||
* @property {number} cropTop - upper-left Y coordinate of visible crop region, in luma pixels
|
||||
* @property {number} cropWidth - width of visible crop region, in luma pixels
|
||||
* @property {number} cropHeight - height of visible crop region, in luma pixels
|
||||
* @property {number} displayWidth - final display width of visible region, in luma pixels
|
||||
* @property {number} displayHeight - final display height of visible region, in luma pixels
|
||||
*/
|
||||
export interface YUVFormat {
|
||||
width: number;
|
||||
height: number;
|
||||
chromaWidth: number;
|
||||
chromaHeight: number;
|
||||
cropLeft: number;
|
||||
cropTop: number;
|
||||
cropWidth: number;
|
||||
cropHeight: number;
|
||||
displayWidth: number;
|
||||
displayHeight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents underlying image data for a single luma or chroma plane.
|
||||
* Cannot be interpreted without the format data from a frame buffer.
|
||||
* @typedef {Object} YUVPlane
|
||||
* @property {Uint8Array} bytes - typed array containing image data bytes
|
||||
* @property {number} stride - byte distance between rows in data
|
||||
*/
|
||||
export interface YUVPlane {
|
||||
bytes: Uint8Array;
|
||||
stride: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a YUV image frame buffer, with enough format information
|
||||
* to interpret the data usefully. Buffer objects use generic objects
|
||||
* under the hood and can be transferred between worker threads using
|
||||
* the structured clone algorithm.
|
||||
*
|
||||
* @typedef {Object} YUVFrame
|
||||
* @property {YUVFormat} format
|
||||
* @property {YUVPlane} y
|
||||
* @property {YUVPlane} u
|
||||
* @property {YUVPlane} v
|
||||
*/
|
||||
export interface YUVFrame {
|
||||
format: YUVFormat;
|
||||
y: YUVPlane;
|
||||
u: YUVPlane;
|
||||
v: YUVPlane;
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'yuv-canvas' {
|
||||
import { YUVFrame } from 'yuv-buffer';
|
||||
|
||||
export default class YUVCanvas {
|
||||
public static attach(canvas: HTMLCanvasElement): YUVCanvas;
|
||||
|
||||
public drawFrame(data: YUVFrame): void;
|
||||
}
|
||||
}
|
|
@ -1,49 +0,0 @@
|
|||
/// <reference path="web-codecs.d.ts"/>
|
||||
|
||||
import { ValueOrPromise } from "@yume-chan/struct";
|
||||
import { Decoder } from '../decoder';
|
||||
import { FrameSize } from "../server";
|
||||
|
||||
function toHex(value: number) {
|
||||
return value.toString(16).padStart(2, '0').toUpperCase();
|
||||
}
|
||||
|
||||
export class WebCodecsDecoder implements Decoder {
|
||||
private decoder: VideoDecoder;
|
||||
private context: CanvasRenderingContext2D;
|
||||
|
||||
public constructor(canvas: HTMLCanvasElement) {
|
||||
this.context = canvas.getContext('2d')!;
|
||||
this.decoder = new VideoDecoder({
|
||||
output: (frame) => {
|
||||
this.context.drawImage(frame, 0, 0);
|
||||
frame.close();
|
||||
},
|
||||
error() { },
|
||||
});
|
||||
}
|
||||
|
||||
public configure(config: FrameSize): ValueOrPromise<void> {
|
||||
const { sequenceParameterSet: { profile_idc, constraint_set, level_idc } } = config;
|
||||
|
||||
// https://www.rfc-editor.org/rfc/rfc6381#section-3.3
|
||||
// ISO Base Media File Format Name Space
|
||||
const codec = `avc1.${[profile_idc, constraint_set, level_idc].map(toHex).join('')}`;
|
||||
this.decoder.configure({
|
||||
codec: codec,
|
||||
optimizeForLatency: true,
|
||||
});
|
||||
}
|
||||
|
||||
decode(data: BufferSource): ValueOrPromise<void> {
|
||||
this.decoder.decode(new EncodedVideoChunk({
|
||||
type: 'key',
|
||||
timestamp: 0,
|
||||
data,
|
||||
}));
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
this.decoder.close();
|
||||
}
|
||||
}
|
|
@ -1,81 +0,0 @@
|
|||
type HardwareAcceleration = "no-preference" | "prefer-hardware" | "prefer-software";
|
||||
|
||||
interface VideoDecoderConfig {
|
||||
codec: string;
|
||||
description?: BufferSource | undefined;
|
||||
codedWidth?: number | undefined;
|
||||
codedHeight?: number | undefined;
|
||||
displayAspectWidth?: number | undefined;
|
||||
displayAspectHeight?: number | undefined;
|
||||
colorSpace?: VideoColorSpaceInit | undefined;
|
||||
hardwareAcceleration?: HardwareAcceleration | undefined;
|
||||
optimizeForLatency?: boolean | undefined;
|
||||
}
|
||||
|
||||
interface VideoDecoderSupport {
|
||||
supported: boolean;
|
||||
config: VideoDecoderConfig;
|
||||
}
|
||||
|
||||
class VideoFrame {
|
||||
constructor(image: CanvasImageSource, init?: VideoFrameInit);
|
||||
constructor(image: VideoFrame, init?: VideoFrameInit);
|
||||
constructor(data: BufferSource, init: VideoFrameInit);
|
||||
|
||||
get codedWidth(): number;
|
||||
get codedHeight(): number;
|
||||
get displayWidth(): number;
|
||||
|
||||
close(): void;
|
||||
}
|
||||
|
||||
interface CanvasDrawImage {
|
||||
drawImage(image: VideoFrame, dx: number, dy: number): void;
|
||||
drawImage(image: VideoFrame, dx: number, dy: number, dw: number, dh: number): void;
|
||||
drawImage(image: VideoFrame, sx: number, sy: number, sw: number, sh: number, dx: number, dy: number, dw: number, dh: number): void;
|
||||
}
|
||||
|
||||
interface VideoDecoderInit {
|
||||
output: (output: VideoFrame) => void;
|
||||
error: (error: DOMException) => void;
|
||||
}
|
||||
|
||||
declare class VideoDecoder {
|
||||
static isConfigSupported(config: VideoDecoderConfig): Promise<VideoDecoderSupport>;
|
||||
|
||||
constructor(options: VideoDecoderInit);
|
||||
|
||||
get state(): 'unconfigured' | 'configured' | 'closed';
|
||||
get decodeQueueSize(): number;
|
||||
|
||||
configure(config: VideoDecoderConfig): void;
|
||||
decode(chunk: EncodedVideoChunk): void;
|
||||
flush(): Promise<void>;
|
||||
reset(): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
type EncodedVideoChunkType = 'key' | 'delta';
|
||||
|
||||
interface EncodedVideoChunkInit {
|
||||
type: EncodedVideoChunkType;
|
||||
timestamp: number;
|
||||
duration?: number | undefined;
|
||||
data: BufferSource;
|
||||
}
|
||||
|
||||
class EncodedVideoChunk {
|
||||
constructor(init: EncodedVideoChunkInit);
|
||||
|
||||
get type(): EncodedVideoChunkType;
|
||||
get timestamp(): number;
|
||||
get duration(): number | undefined;
|
||||
get byteLength(): number;
|
||||
|
||||
copyTo(destination: BufferSource): void;
|
||||
}
|
||||
|
||||
declare interface Window {
|
||||
VideoDecoder: typeof VideoDecoder;
|
||||
EncodedVideoChunk: typeof EncodedVideoChunk,
|
||||
}
|
|
@ -1,180 +0,0 @@
|
|||
import { IconButton, SearchBox, Stack, StackItem } from '@fluentui/react';
|
||||
import { AdbShell } from '@yume-chan/adb';
|
||||
import { encodeUtf8 } from '@yume-chan/adb-backend-webusb';
|
||||
import { AutoDisposable } from '@yume-chan/event';
|
||||
import { CSSProperties, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { Terminal } from 'xterm';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import { SearchAddon } from 'xterm-addon-search';
|
||||
import { WebglAddon } from 'xterm-addon-webgl';
|
||||
import 'xterm/css/xterm.css';
|
||||
import { ErrorDialogContext } from '../components/error-dialog';
|
||||
import { ResizeObserver, withDisplayName } from '../utils';
|
||||
import { RouteProps, useAdbDevice } from './type';
|
||||
|
||||
const ResizeObserverStyle: CSSProperties = {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
};
|
||||
|
||||
const UpIconProps = { iconName: 'ChevronUp' };
|
||||
const DownIconProps = { iconName: 'ChevronDown' };
|
||||
|
||||
class AdbTerminal extends AutoDisposable {
|
||||
public terminal: Terminal = new Terminal({
|
||||
scrollback: 9000,
|
||||
});
|
||||
|
||||
public searchAddon = new SearchAddon();
|
||||
|
||||
private readonly fitAddon = new FitAddon();
|
||||
|
||||
private _parent: HTMLElement | undefined;
|
||||
public get parent() { return this._parent; }
|
||||
public set parent(value) {
|
||||
this._parent = value;
|
||||
|
||||
if (value) {
|
||||
this.terminal.open(value);
|
||||
this.terminal.loadAddon(new WebglAddon());
|
||||
// WebGL renderer ignores `cursorBlink` set before it initialized
|
||||
this.terminal.setOption('cursorBlink', true);
|
||||
this.fit();
|
||||
}
|
||||
}
|
||||
|
||||
private _shell: AdbShell | undefined;
|
||||
public get socket() { return this._shell; }
|
||||
public set socket(value) {
|
||||
if (this._shell) {
|
||||
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.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 fit() {
|
||||
this.fitAddon.fit();
|
||||
const { rows, cols } = this.terminal;
|
||||
this._shell?.resize(rows, cols);
|
||||
}
|
||||
}
|
||||
|
||||
export const Shell = withDisplayName('Shell')(({
|
||||
visible,
|
||||
}: RouteProps): JSX.Element | null => {
|
||||
const { show: showErrorDialog } = useContext(ErrorDialogContext);
|
||||
|
||||
const device = useAdbDevice();
|
||||
const terminalRef = useRef(new AdbTerminal());
|
||||
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const handleSearchKeywordChange = useCallback((e, newValue?: string) => {
|
||||
setSearchKeyword(newValue ?? '');
|
||||
if (newValue) {
|
||||
terminalRef.current.searchAddon.findNext(newValue, { incremental: true });
|
||||
}
|
||||
}, []);
|
||||
const findPrevious = useCallback(() => {
|
||||
terminalRef.current.searchAddon.findPrevious(searchKeyword);
|
||||
}, [searchKeyword]);
|
||||
const findNext = useCallback(() => {
|
||||
terminalRef.current.searchAddon.findNext(searchKeyword);
|
||||
}, [searchKeyword]);
|
||||
|
||||
const connectingRef = useRef(false);
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!device) {
|
||||
terminalRef.current.socket = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!visible || !!terminalRef.current.socket || connectingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
connectingRef.current = true;
|
||||
const socket = await device.childProcess.shell();
|
||||
terminalRef.current.socket = socket;
|
||||
} catch (e) {
|
||||
showErrorDialog(e instanceof Error ? e.message : `${e}`);
|
||||
} finally {
|
||||
connectingRef.current = false;
|
||||
}
|
||||
})();
|
||||
}, [visible, device]);
|
||||
|
||||
const handleContainerRef = useCallback((element: HTMLDivElement | null) => {
|
||||
terminalRef.current.parent = element ?? undefined;
|
||||
}, []);
|
||||
|
||||
const handleResize = useCallback(() => {
|
||||
terminalRef.current.fit();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
});
|
|
@ -1,176 +0,0 @@
|
|||
import { MessageBar, StackItem, Text, TextField, Toggle } from '@fluentui/react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { CommandBar } from '../components';
|
||||
import { withDisplayName } from '../utils';
|
||||
import { useAdbDevice } from './type';
|
||||
|
||||
export const TcpIp = withDisplayName('TcpIp')((): JSX.Element | null => {
|
||||
const device = useAdbDevice();
|
||||
|
||||
const [serviceListenAddrs, setServiceListenAddrs] = useState<string[] | undefined>();
|
||||
|
||||
const [servicePortEnabled, setServicePortEnabled] = useState<boolean>(false);
|
||||
const [servicePort, setServicePort] = useState<string>('');
|
||||
|
||||
const [persistPortEnabled, setPersistPortEnabled] = useState<boolean>(false);
|
||||
const [persistPort, setPersistPort] = useState<string>();
|
||||
|
||||
const queryTcpIpInfo = useCallback(() => {
|
||||
if (!device) {
|
||||
setServiceListenAddrs(undefined);
|
||||
|
||||
setServicePortEnabled(false);
|
||||
setServicePort('');
|
||||
|
||||
setPersistPortEnabled(false);
|
||||
setPersistPort(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const listenAddrs = await device.getProp('service.adb.listen_addrs');
|
||||
if (listenAddrs) {
|
||||
setServiceListenAddrs(listenAddrs.split(','));
|
||||
} else {
|
||||
setServiceListenAddrs(undefined);
|
||||
}
|
||||
|
||||
const servicePort = await device.getProp('service.adb.tcp.port');
|
||||
if (servicePort) {
|
||||
setServicePortEnabled(!listenAddrs && servicePort !== '0');
|
||||
setServicePort(servicePort);
|
||||
} else {
|
||||
setServicePortEnabled(false);
|
||||
setServicePort('5555');
|
||||
}
|
||||
|
||||
const persistPort = await device.getProp('persist.adb.tcp.port');
|
||||
if (persistPort) {
|
||||
setPersistPortEnabled(!listenAddrs && !servicePort);
|
||||
setPersistPort(persistPort);
|
||||
} else {
|
||||
setPersistPortEnabled(false);
|
||||
setPersistPort(undefined);
|
||||
}
|
||||
})();
|
||||
}, [device]);
|
||||
|
||||
useEffect(() => {
|
||||
queryTcpIpInfo();
|
||||
}, [queryTcpIpInfo]);
|
||||
|
||||
const applyServicePort = useCallback(() => {
|
||||
if (!device) {
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
if (servicePortEnabled) {
|
||||
await device.tcpip.setPort(Number.parseInt(servicePort, 10));
|
||||
} else {
|
||||
await device.tcpip.disable();
|
||||
}
|
||||
})();
|
||||
}, [device, servicePortEnabled, servicePort]);
|
||||
|
||||
const commandBarItems = useMemo(() => [
|
||||
{
|
||||
key: 'refresh',
|
||||
disabled: !device,
|
||||
iconProps: { iconName: 'Refresh' },
|
||||
text: 'Refresh',
|
||||
onClick: queryTcpIpInfo,
|
||||
},
|
||||
{
|
||||
key: 'apply',
|
||||
disabled: !device,
|
||||
iconProps: { iconName: 'Save' },
|
||||
text: 'Apply',
|
||||
onClick: applyServicePort,
|
||||
}
|
||||
], [device, queryTcpIpInfo, applyServicePort]);
|
||||
|
||||
const handleServicePortEnabledChange = useCallback((e, value?: boolean) => {
|
||||
setServicePortEnabled(!!value);
|
||||
}, []);
|
||||
|
||||
const handleServicePortChange = useCallback((e, value?: string) => {
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
setServicePort(value);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CommandBar items={commandBarItems} />
|
||||
|
||||
<StackItem>
|
||||
<MessageBar>
|
||||
<Text>Although WebADB can enable ADB over WiFi for you, it can't connect to your device wirelessly.</Text>
|
||||
</MessageBar>
|
||||
</StackItem>
|
||||
<StackItem>
|
||||
<MessageBar >
|
||||
<Text>Your device will disconnect after changing ADB over WiFi config.</Text>
|
||||
</MessageBar>
|
||||
</StackItem>
|
||||
|
||||
<StackItem>
|
||||
<Toggle
|
||||
inlineLabel
|
||||
label="service.adb.listen_addrs"
|
||||
disabled
|
||||
checked={!!serviceListenAddrs}
|
||||
onText="Enabled"
|
||||
offText="Disabled"
|
||||
/>
|
||||
{serviceListenAddrs?.map((addr) => (
|
||||
<TextField
|
||||
disabled
|
||||
value={addr}
|
||||
styles={{ root: { width: 300 } }}
|
||||
/>
|
||||
))}
|
||||
</StackItem>
|
||||
|
||||
<StackItem>
|
||||
<Toggle
|
||||
inlineLabel
|
||||
label="service.adb.tcp.port"
|
||||
checked={servicePortEnabled}
|
||||
disabled={!device || !!serviceListenAddrs}
|
||||
onText="Enabled"
|
||||
offText="Disabled"
|
||||
onChange={handleServicePortEnabledChange}
|
||||
/>
|
||||
{device && (
|
||||
<TextField
|
||||
disabled={!!serviceListenAddrs}
|
||||
value={servicePort}
|
||||
styles={{ root: { width: 300 } }}
|
||||
onChange={handleServicePortChange}
|
||||
/>
|
||||
)}
|
||||
</StackItem>
|
||||
|
||||
<StackItem>
|
||||
<Toggle
|
||||
inlineLabel
|
||||
label="persist.adb.tcp.port"
|
||||
disabled
|
||||
checked={persistPortEnabled}
|
||||
onText="Enabled"
|
||||
offText="Disabled"
|
||||
/>
|
||||
{persistPort && (
|
||||
<TextField
|
||||
disabled
|
||||
value={persistPort}
|
||||
styles={{ root: { width: 300 } }}
|
||||
/>
|
||||
)}
|
||||
</StackItem>
|
||||
</>
|
||||
);
|
||||
});
|
|
@ -1,14 +0,0 @@
|
|||
import { Adb } from '@yume-chan/adb';
|
||||
import React, { useContext } from "react";
|
||||
|
||||
export interface RouteProps {
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
const AdbDeviceContext = React.createContext<Adb | undefined>(undefined);
|
||||
|
||||
export const AdbDeviceProvider = AdbDeviceContext.Provider;
|
||||
|
||||
export function useAdbDevice() {
|
||||
return useContext(AdbDeviceContext);
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
export const CommonStackTokens = { childrenGap: 8 };
|
19
apps/demo/state/device.ts
Normal file
19
apps/demo/state/device.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
import { Adb } from "@yume-chan/adb";
|
||||
import { makeAutoObservable } from 'mobx';
|
||||
|
||||
export class Device {
|
||||
current: Adb | undefined;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
setCurrent(device: Adb | undefined) {
|
||||
this.current = device;
|
||||
device?.onDisconnected(() => {
|
||||
this.setCurrent(undefined);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const device = new Device();
|
2
apps/demo/state/index.ts
Normal file
2
apps/demo/state/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export * from './device';
|
||||
export * from './logger';
|
26
apps/demo/state/logger.ts
Normal file
26
apps/demo/state/logger.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { AdbLogger, AdbPacket, AdbPacketInit } from "@yume-chan/adb";
|
||||
import { EventEmitter } from "@yume-chan/event";
|
||||
|
||||
export class AdbEventLogger {
|
||||
private readonly _logger: AdbLogger;
|
||||
public get logger() { return this._logger; }
|
||||
|
||||
private readonly _incomingPacketEvent = new EventEmitter<AdbPacket>();
|
||||
public get onIncomingPacket() { return this._incomingPacketEvent.event; }
|
||||
|
||||
private readonly _outgoingPacketEvent = new EventEmitter<AdbPacketInit>();
|
||||
public get onOutgoingPacket() { return this._outgoingPacketEvent.event; }
|
||||
|
||||
public constructor() {
|
||||
this._logger = {
|
||||
onIncomingPacket: (packet) => {
|
||||
this._incomingPacketEvent.fire(packet);
|
||||
},
|
||||
onOutgoingPacket: (packet) => {
|
||||
this._outgoingPacketEvent.fire(packet);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = new AdbEventLogger();
|
|
@ -39,46 +39,3 @@ declare module 'streamsaver' {
|
|||
|
||||
export = StreamSaver;
|
||||
}
|
||||
|
||||
declare module 'file-loader!*';
|
||||
declare module 'worker-loader!*' {
|
||||
class WebpackWorker extends Worker {
|
||||
constructor();
|
||||
}
|
||||
|
||||
export default WebpackWorker;
|
||||
}
|
||||
|
||||
declare module 'jmuxer' {
|
||||
export interface JMuxerOptions {
|
||||
node: string | HTMLVideoElement;
|
||||
|
||||
mode?: 'video' | 'audio' | 'both';
|
||||
|
||||
flushingTime?: number;
|
||||
|
||||
clearBuffer?: boolean;
|
||||
|
||||
fps?: number;
|
||||
|
||||
onReady?: () => void;
|
||||
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
export interface JMuxerData {
|
||||
video?: Uint8Array;
|
||||
|
||||
audio?: Uint8Array;
|
||||
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export default class JMuxer {
|
||||
constructor(options: JMuxerOptions);
|
||||
|
||||
feed(data: JMuxerData): void;
|
||||
|
||||
destroy(): void;
|
||||
}
|
||||
}
|
27
apps/demo/styles/globals.css
Normal file
27
apps/demo/styles/globals.css
Normal file
|
@ -0,0 +1,27 @@
|
|||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||
}
|
||||
|
||||
#__next {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ms-Dialog-subText {
|
||||
white-space: pre-wrap;
|
||||
}
|
|
@ -1,30 +1,19 @@
|
|||
{
|
||||
"extends": "./node_modules/@yume-chan/ts-package-builder/tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src", // /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
|
||||
"target": "ES2016",
|
||||
"lib": [
|
||||
"ESNext",
|
||||
"DOM"
|
||||
],
|
||||
"jsx": "react-jsx",
|
||||
"composite": false, // /* Enable project compilation */
|
||||
"types": [
|
||||
"node",
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../libraries/adb-backend-webusb/tsconfig.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libraries/adb-backend-ws/tsconfig.json"
|
||||
},
|
||||
]
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve"
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
|
|
@ -1,25 +0,0 @@
|
|||
{
|
||||
"extends": "./node_modules/@yume-chan/ts-package-builder/tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src", // /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
|
||||
"target": "ES2016",
|
||||
"composite": false, // /* Enable project compilation */
|
||||
"declaration": false,
|
||||
"declarationDir": null,
|
||||
"declarationMap": false,
|
||||
"lib": [
|
||||
"ESNext",
|
||||
"DOM"
|
||||
],
|
||||
"jsx": "react-jsx",
|
||||
"types": [
|
||||
"node",
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts"
|
||||
],
|
||||
}
|
|
@ -28,3 +28,9 @@ export function pickFile(options: { multiple?: boolean; } & PickFileOptions): Pr
|
|||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
export async function* chunkFile(file: File, chunkSize: number): AsyncGenerator<ArrayBuffer, void, void> {
|
||||
for (let i = 0; i < file.size; i += chunkSize) {
|
||||
yield file.slice(i, i + chunkSize, file.type).arrayBuffer();
|
||||
}
|
||||
}
|
|
@ -2,3 +2,4 @@ export * from './file-size';
|
|||
export * from './file';
|
||||
export * from './resize-observer';
|
||||
export * from './with-display-name';
|
||||
export * from './styles';
|
15
apps/demo/utils/styles.ts
Normal file
15
apps/demo/utils/styles.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { AnimationClassNames, IStackProps } from "@fluentui/react";
|
||||
|
||||
export const CommonStackTokens = { childrenGap: 8 };
|
||||
|
||||
export const DefaultStackProps: IStackProps = {
|
||||
tokens: { childrenGap: 8, padding: 16 },
|
||||
verticalFill: true,
|
||||
};
|
||||
|
||||
export const RouteStackProps: IStackProps = {
|
||||
...DefaultStackProps,
|
||||
className: AnimationClassNames.slideUpIn10!,
|
||||
styles: { root: { overflow: 'auto', position: 'relative' } },
|
||||
disableShrink: true,
|
||||
};
|
|
@ -1,73 +0,0 @@
|
|||
"use strict";
|
||||
const tslib_1 = require("tslib");
|
||||
const clean_webpack_plugin_1 = require("clean-webpack-plugin");
|
||||
const copy_webpack_plugin_1 = tslib_1.__importDefault(require("copy-webpack-plugin"));
|
||||
const html_webpack_plugin_1 = tslib_1.__importDefault(require("html-webpack-plugin"));
|
||||
const mini_css_extract_plugin_1 = tslib_1.__importDefault(require("mini-css-extract-plugin"));
|
||||
const path_1 = tslib_1.__importDefault(require("path"));
|
||||
const webpack_bundle_analyzer_1 = require("webpack-bundle-analyzer");
|
||||
const webpackbar_1 = tslib_1.__importDefault(require("webpackbar"));
|
||||
const context = path_1.default.resolve(process.cwd());
|
||||
const plugins = [
|
||||
new webpackbar_1.default({}),
|
||||
new mini_css_extract_plugin_1.default({
|
||||
filename: '[name].[contenthash].css',
|
||||
}),
|
||||
new copy_webpack_plugin_1.default({
|
||||
patterns: [
|
||||
{
|
||||
context: path_1.default.dirname(require.resolve('streamsaver')),
|
||||
from: '(mitm.html|sw.js|LICENSE)',
|
||||
to: 'streamsaver'
|
||||
},
|
||||
],
|
||||
}),
|
||||
new html_webpack_plugin_1.default({
|
||||
template: 'www/index.html',
|
||||
scriptLoading: 'defer',
|
||||
}),
|
||||
];
|
||||
if (process.env.ANALYZE) {
|
||||
plugins.push(new webpack_bundle_analyzer_1.BundleAnalyzerPlugin());
|
||||
}
|
||||
const config = (env, argv) => {
|
||||
if (argv.mode === 'production') {
|
||||
plugins.unshift(new clean_webpack_plugin_1.CleanWebpackPlugin());
|
||||
}
|
||||
return {
|
||||
mode: 'development',
|
||||
devtool: argv.mode === 'production' ? 'source-map' : 'eval-source-map',
|
||||
context,
|
||||
target: 'web',
|
||||
entry: {
|
||||
index: './src/index.tsx',
|
||||
},
|
||||
output: {
|
||||
path: path_1.default.resolve(context, 'lib'),
|
||||
filename: '[name].[contenthash].js',
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.ts', '.tsx', '.js'],
|
||||
// @ts-expect-error typing is not up to date
|
||||
fallback: { "path": require.resolve("path-browserify") },
|
||||
},
|
||||
plugins,
|
||||
module: {
|
||||
rules: [
|
||||
{ test: /\.js$/, enforce: 'pre', use: ['source-map-loader'], },
|
||||
{ test: /\.css$/i, use: [mini_css_extract_plugin_1.default.loader, 'css-loader'] },
|
||||
{ test: /\.asset$/, use: { loader: "file-loader" } },
|
||||
{ test: /\.tsx?$/i, loader: 'ts-loader', options: { configFile: 'tsconfig.webpack.json' } },
|
||||
],
|
||||
},
|
||||
watchOptions: {
|
||||
aggregateTimeout: 500,
|
||||
ignored: ['**/*.ts']
|
||||
},
|
||||
devServer: {
|
||||
contentBase: path_1.default.resolve(context, 'lib'),
|
||||
port: 9000,
|
||||
},
|
||||
};
|
||||
};
|
||||
module.exports = config;
|
|
@ -1,15 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>WebADB Demo</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="container" />
|
||||
</body>
|
||||
|
||||
</html>
|
3041
common/config/rush/pnpm-lock.yaml
generated
3041
common/config/rush/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue