[] = list.map((item) => {
+ const button = new ToolBoxButton(item.title, item.icon, {
+ name: item.name,
+ });
+ button.addEventListener('click', handler);
+ return button;
+ });
+ if (player.supportsScreenshot) {
+ const screenshot = new ToolBoxButton('Take screenshot', SvgImage.Icon.CAMERA);
+ screenshot.addEventListener('click', () => {
+ player.createScreenshot(client.getDeviceName());
+ });
+ elements.push(screenshot);
+ }
+
+ if (moreBox) {
+ const more = new ToolBoxCheckbox('More', SvgImage.Icon.MORE, `show_more_${udid}_${playerName}`);
+ more.addEventListener('click', (_, el) => {
+ const element = el.getElement();
+ moreBox.style.display = element.checked ? 'block' : 'none';
+ });
+ elements.unshift(more);
+ }
+ return new ApplToolBox(elements);
+ }
+}
diff --git a/src/app/client/BaseClient.ts b/src/app/client/BaseClient.ts
new file mode 100644
index 0000000..423ec57
--- /dev/null
+++ b/src/app/client/BaseClient.ts
@@ -0,0 +1,40 @@
+import { EventMap, TypedEmitter } from '../../common/TypedEmitter';
+import { ParamsBase } from '../../types/ParamsBase';
+import Util from '../Util';
+
+export class BaseClient extends TypedEmitter {
+ protected title = 'BaseClient';
+ protected params: P;
+
+ protected constructor(params: P) {
+ super();
+ this.params = params;
+ }
+
+ public static parseParameters(query: URLSearchParams): ParamsBase {
+ const action = Util.parseStringEnv(query.get('action'));
+ if (!action) {
+ throw TypeError('Invalid action');
+ }
+ return {
+ action: action,
+ useProxy: Util.parseBooleanEnv(query.get('useProxy')),
+ secure: Util.parseBooleanEnv(query.get('secure')),
+ hostname: Util.parseStringEnv(query.get('hostname')),
+ port: Util.parseIntEnv(query.get('port')),
+ pathname: Util.parseStringEnv(query.get('pathname')),
+ };
+ }
+
+ public setTitle(text = this.title): void {
+ let titleTag: HTMLTitleElement | null = document.querySelector('head > title');
+ if (!titleTag) {
+ titleTag = document.createElement('title');
+ }
+ titleTag.innerText = text;
+ }
+
+ public setBodyClass(text: string): void {
+ document.body.className = text;
+ }
+}
diff --git a/src/app/client/BaseDeviceTracker.ts b/src/app/client/BaseDeviceTracker.ts
new file mode 100644
index 0000000..53229eb
--- /dev/null
+++ b/src/app/client/BaseDeviceTracker.ts
@@ -0,0 +1,280 @@
+import { ManagerClient } from './ManagerClient';
+import { Message } from '../../types/Message';
+import { BaseDeviceDescriptor } from '../../types/BaseDeviceDescriptor';
+import { DeviceTrackerEvent } from '../../types/DeviceTrackerEvent';
+import { DeviceTrackerEventList } from '../../types/DeviceTrackerEventList';
+import { html } from '../ui/HtmlTag';
+import { ParamsDeviceTracker } from '../../types/ParamsDeviceTracker';
+import { HostItem } from '../../types/Configuration';
+import { Tool } from './Tool';
+import Util from '../Util';
+import { EventMap } from '../../common/TypedEmitter';
+
+const TAG = '[BaseDeviceTracker]';
+
+export abstract class BaseDeviceTracker extends ManagerClient<
+ ParamsDeviceTracker,
+ TE
+> {
+ public static readonly ACTION_LIST = 'devicelist';
+ public static readonly ACTION_DEVICE = 'device';
+ public static readonly HOLDER_ELEMENT_ID = 'devices';
+ public static readonly AttributePrefixInterfaceSelectFor = 'interface_select_for_';
+ public static readonly AttributePlayerFullName = 'data-player-full-name';
+ public static readonly AttributePlayerCodeName = 'data-player-code-name';
+ public static readonly AttributePrefixPlayerFor = 'player_for_';
+ protected static tools: Set = new Set();
+ protected static instanceId = 0;
+
+ public static registerTool(tool: Tool): void {
+ this.tools.add(tool);
+ }
+
+ public static buildUrl(item: HostItem): URL {
+ const { secure, port, hostname } = item;
+ const pathname = item.pathname ?? '/';
+ const protocol = secure ? 'wss:' : 'ws:';
+ const url = new URL(`${protocol}//${hostname}${pathname}`);
+ if (port) {
+ url.port = port.toString();
+ }
+ return url;
+ }
+
+ public static buildUrlForTracker(params: HostItem): URL {
+ const wsUrl = this.buildUrl(params);
+ wsUrl.searchParams.set('action', this.ACTION);
+ return wsUrl;
+ }
+
+ public static buildLink(q: any, text: string, params: ParamsDeviceTracker): HTMLAnchorElement {
+ let { hostname } = params;
+ let port: string | number | undefined = params.port;
+ let pathname = params.pathname ?? location.pathname;
+ let protocol = params.secure ? 'https:' : 'http:';
+ if (params.useProxy) {
+ q.hostname = hostname;
+ q.port = port;
+ q.pathname = pathname;
+ q.secure = params.secure;
+ q.useProxy = true;
+ protocol = location.protocol;
+ hostname = location.hostname;
+ port = location.port;
+ pathname = location.pathname;
+ }
+ const hash = `#!${new URLSearchParams(q).toString()}`;
+ const a = document.createElement('a');
+ a.setAttribute('href', `${protocol}//${hostname}:${port}${pathname}${hash}`);
+ a.setAttribute('rel', 'noopener noreferrer');
+ a.setAttribute('target', '_blank');
+ a.classList.add(`link-${q.action}`);
+ a.innerText = text;
+ return a;
+ }
+
+ protected title = 'Device list';
+ protected tableId = 'base_device_list';
+ protected descriptors: DD[] = [];
+ protected elementId: string;
+ protected trackerName = '';
+ protected id = '';
+ private created = false;
+ private messageId = 0;
+
+ protected constructor(params: ParamsDeviceTracker, protected readonly directUrl: string) {
+ super(params);
+ this.elementId = `tracker_instance${++BaseDeviceTracker.instanceId}`;
+ this.trackerName = `Unavailable. Host: ${params.hostname}, type: ${params.type}`;
+ this.setBodyClass('list');
+ this.setTitle();
+ }
+
+ public static parseParameters(params: URLSearchParams): ParamsDeviceTracker {
+ const typedParams = super.parseParameters(params);
+ const type = Util.parseString(params, 'type', true);
+ if (type !== 'android' && type !== 'ios') {
+ throw Error('Incorrect type');
+ }
+ return { ...typedParams, type };
+ }
+
+ protected getNextId(): number {
+ return ++this.messageId;
+ }
+
+ protected buildDeviceTable(): void {
+ const data = this.descriptors;
+ const devices = this.getOrCreateTableHolder();
+ const tbody = this.getOrBuildTableBody(devices);
+
+ const block = this.getOrCreateTrackerBlock(tbody, this.trackerName);
+ data.forEach((item) => {
+ this.buildDeviceRow(block, item);
+ });
+ }
+
+ private setNameValue(parent: Element | null, name: string): void {
+ if (!parent) {
+ return;
+ }
+ const nameBlockId = `${this.elementId}_name`;
+ let nameEl = document.getElementById(nameBlockId);
+ if (!nameEl) {
+ nameEl = document.createElement('div');
+ nameEl.id = nameBlockId;
+ nameEl.className = 'tracker-name';
+ }
+ nameEl.innerText = name;
+ parent.insertBefore(nameEl, parent.firstChild);
+ }
+
+ private getOrCreateTrackerBlock(parent: Element, controlCenterName: string): Element {
+ let el = document.getElementById(this.elementId);
+ if (!el) {
+ el = document.createElement('div');
+ el.id = this.elementId;
+ parent.appendChild(el);
+ this.created = true;
+ } else {
+ while (el.children.length) {
+ el.removeChild(el.children[0]);
+ }
+ }
+ this.setNameValue(el, controlCenterName);
+ return el;
+ }
+
+ protected abstract buildDeviceRow(tbody: Element, device: DD): void;
+
+ protected onSocketClose(event: CloseEvent): void {
+ if (this.destroyed) {
+ return;
+ }
+ console.log(TAG, `Connection closed: ${event.reason}`);
+ setTimeout(() => {
+ this.openNewConnection();
+ }, 2000);
+ }
+
+ protected onSocketMessage(event: MessageEvent): void {
+ console.log("接收到的参数3", event.data)
+
+ let message: Message;
+ try {
+ message = JSON.parse(event.data);
+ } catch (error: any) {
+ console.error(TAG, error.message);
+ console.log(TAG, error.data);
+ return;
+ }
+ switch (message.type) {
+ case BaseDeviceTracker.ACTION_LIST: {
+ const event = message.data as DeviceTrackerEventList;
+ this.descriptors = event.list;
+ this.setIdAndHostName(event.id, event.name);
+ this.buildDeviceTable();
+ break;
+ }
+ case BaseDeviceTracker.ACTION_DEVICE: {
+ const event = message.data as DeviceTrackerEvent;
+ this.setIdAndHostName(event.id, event.name);
+ this.updateDescriptor(event.device);
+ this.buildDeviceTable();
+ break;
+ }
+ default:
+ console.log(TAG, `Unknown message type: ${message.type}`);
+ }
+ }
+
+ protected setIdAndHostName(id: string, trackerName: string): void {
+ if (this.id === id && this.trackerName === trackerName) {
+ return;
+ }
+ this.id = id;
+ this.trackerName = trackerName;
+ this.setNameValue(document.getElementById(this.elementId), trackerName);
+ }
+
+ protected getOrCreateTableHolder(): HTMLElement {
+ const id = BaseDeviceTracker.HOLDER_ELEMENT_ID;
+ let devices = document.getElementById(id);
+ if (!devices) {
+ devices = document.createElement('div');
+ devices.id = id;
+ devices.className = 'table-wrapper';
+ document.body.appendChild(devices);
+ }
+ return devices;
+ }
+
+ protected updateDescriptor(descriptor: DD): void {
+ const idx = this.descriptors.findIndex((item: DD) => {
+ return item.udid === descriptor.udid;
+ });
+ if (idx !== -1) {
+ this.descriptors[idx] = descriptor;
+ } else {
+ this.descriptors.push(descriptor);
+ }
+ }
+
+ protected getOrBuildTableBody(parent: HTMLElement): Element {
+ const className = 'device-list';
+ let tbody = document.querySelector(
+ `#${BaseDeviceTracker.HOLDER_ELEMENT_ID} #${this.tableId}.${className}`,
+ ) as Element;
+ if (!tbody) {
+ const fragment = html`
`.content;
+ parent.appendChild(fragment);
+ const last = parent.children.item(parent.children.length - 1);
+ if (last) {
+ tbody = last;
+ }
+ }
+ return tbody;
+ }
+
+ public getDescriptorByUdid(udid: string): DD | undefined {
+ if (!this.descriptors.length) {
+ return;
+ }
+ return this.descriptors.find((descriptor: DD) => {
+ return descriptor.udid === udid;
+ });
+ }
+
+ public destroy(): void {
+ super.destroy();
+ if (this.created) {
+ const el = document.getElementById(this.elementId);
+ if (el) {
+ const { parentElement } = el;
+ el.remove();
+ if (parentElement && !parentElement.children.length) {
+ parentElement.remove();
+ }
+ }
+ }
+ const holder = document.getElementById(BaseDeviceTracker.HOLDER_ELEMENT_ID);
+ if (holder && !holder.children.length) {
+ holder.remove();
+ }
+ }
+
+ protected supportMultiplexing(): boolean {
+ return true;
+ }
+
+ protected getChannelCode(): string {
+ throw Error('Not implemented. Must override');
+ }
+
+ protected getChannelInitData(): Buffer {
+ const code = this.getChannelCode();
+ const buffer = Buffer.alloc(code.length);
+ buffer.write(code, 'ascii');
+ return buffer;
+ }
+}
diff --git a/src/app/client/HostTracker.ts b/src/app/client/HostTracker.ts
new file mode 100644
index 0000000..05de478
--- /dev/null
+++ b/src/app/client/HostTracker.ts
@@ -0,0 +1,127 @@
+import { ManagerClient } from './ManagerClient';
+import { Message } from '../../types/Message';
+import { MessageError, MessageHosts, MessageType } from '../../common/HostTrackerMessage';
+import { ACTION } from '../../common/Action';
+import { DeviceTracker as GoogDeviceTracker } from '../googDevice/client/DeviceTracker';
+import { DeviceTracker as ApplDeviceTracker } from '../applDevice/client/DeviceTracker';
+import { ParamsBase } from '../../types/ParamsBase';
+import { HostItem } from '../../types/Configuration';
+import { ChannelCode } from '../../common/ChannelCode';
+
+const TAG = '[HostTracker]';
+
+export interface HostTrackerEvents {
+ // hosts: HostItem[];
+ disconnected: CloseEvent;
+ error: string;
+}
+
+export class HostTracker extends ManagerClient {
+ private static instance?: HostTracker;
+
+ public static start(): void {
+ this.getInstance();
+ }
+
+ public static getInstance(): HostTracker {
+ if (!this.instance) {
+ this.instance = new HostTracker();
+ }
+ return this.instance;
+ }
+
+ private trackers: Array = [];
+
+ constructor() {
+ super({ action: ACTION.LIST_HOSTS });
+ this.openNewConnection();
+ if (this.ws) {
+ this.ws.binaryType = 'arraybuffer';
+ }
+ }
+
+ protected onSocketClose(ev: CloseEvent): void {
+ console.log(TAG, 'WS closed');
+ this.emit('disconnected', ev);
+ }
+
+ protected onSocketMessage(event: MessageEvent): void {
+ console.log("接收到的参数4", event.data)
+
+ let message: Message;
+ try {
+ // TODO: rewrite to binary
+ message = JSON.parse(event.data);
+ } catch (error: any) {
+ console.error(TAG, error.message);
+ console.log(TAG, error.data);
+ return;
+ }
+ switch (message.type) {
+ case MessageType.ERROR: {
+ const msg = message as MessageError;
+ console.error(TAG, msg.data);
+ this.emit('error', msg.data);
+ break;
+ }
+ case MessageType.HOSTS: {
+ const msg = message as MessageHosts;
+ // this.emit('hosts', msg.data);
+ if (msg.data.local) {
+ msg.data.local.forEach(({ type }) => {
+ const secure = location.protocol === 'https:';
+ const port = location.port ? parseInt(location.port, 10) : secure ? 443 : 80;
+ const { hostname, pathname } = location;
+ if (type !== 'android' && type !== 'ios') {
+ console.warn(TAG, `Unsupported host type: "${type}"`);
+ return;
+ }
+ const hostItem: HostItem = { useProxy: false, secure, port, hostname, pathname, type };
+ this.startTracker(hostItem);
+ });
+ }
+ if (msg.data.remote) {
+ msg.data.remote.forEach((item) => this.startTracker(item));
+ }
+ break;
+ }
+ default:
+ console.log(TAG, `Unknown message type: ${message.type}`);
+ }
+ }
+
+ private startTracker(hostItem: HostItem): void {
+ switch (hostItem.type) {
+ case 'android':
+ this.trackers.push(GoogDeviceTracker.start(hostItem));
+ break;
+ case 'ios':
+ this.trackers.push(ApplDeviceTracker.start(hostItem));
+ break;
+ default:
+ console.warn(TAG, `Unsupported host type: "${hostItem.type}"`);
+ }
+ }
+
+ protected onSocketOpen(): void {
+ // do nothing
+ }
+
+ public destroy(): void {
+ super.destroy();
+ this.trackers.forEach((tracker) => {
+ tracker.destroy();
+ });
+ this.trackers.length = 0;
+ }
+
+ protected supportMultiplexing(): boolean {
+ return true;
+ }
+
+ protected getChannelInitData(): Buffer {
+ const buffer = Buffer.alloc(4);
+ buffer.write(ChannelCode.HSTS, 'ascii');
+ return buffer;
+ }
+}
diff --git a/src/app/client/ManagerClient.ts b/src/app/client/ManagerClient.ts
new file mode 100644
index 0000000..9adc75f
--- /dev/null
+++ b/src/app/client/ManagerClient.ts
@@ -0,0 +1,133 @@
+import { BaseClient } from './BaseClient';
+import { ACTION } from '../../common/Action';
+import { ParamsBase } from '../../types/ParamsBase';
+import Util from '../Util';
+import { Multiplexer } from '../../packages/multiplexer/Multiplexer';
+import { EventMap } from '../../common/TypedEmitter';
+
+export abstract class ManagerClient extends BaseClient
{
+ public static ACTION = 'unknown';
+ public static CODE = 'NONE';
+ public static sockets: Map = new Map();
+ protected destroyed = false;
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars
+ public static start(..._rest: any[]): void {
+ throw Error('Not implemented');
+ }
+
+ protected readonly action?: string;
+ protected url: URL;
+ protected ws?: Multiplexer | WebSocket;
+
+ protected constructor(params: P) {
+ super(params);
+ this.action = Util.parseStringEnv(params.action);
+ this.url = this.buildWebSocketUrl();
+ }
+
+ public hasConnection(): boolean {
+ return !!(this.ws && this.ws.readyState === this.ws.OPEN);
+ }
+
+ protected openNewConnection(): Multiplexer | WebSocket {
+ if (this.ws && this.ws.readyState === this.ws.OPEN) {
+ this.ws.close();
+ delete this.ws;
+ }
+ const url = this.url.toString();
+ console.log(`连接到的websocket链接 ${url}`);
+ if (this.supportMultiplexing()) {
+ let openedMultiplexer = ManagerClient.sockets.get(url);
+ if (!openedMultiplexer) {
+ const ws = new WebSocket(url);
+ ws.addEventListener('close', () => {
+ ManagerClient.sockets.delete(url);
+ });
+ const newMultiplexer = Multiplexer.wrap(ws);
+ newMultiplexer.on('empty', () => {
+ newMultiplexer.close();
+ });
+ ManagerClient.sockets.set(url, newMultiplexer);
+ openedMultiplexer = newMultiplexer;
+ }
+ const ws = openedMultiplexer.createChannel(this.getChannelInitData());
+ ws.addEventListener('open', this.onSocketOpen.bind(this));
+ ws.addEventListener('message', this.onSocketMessage.bind(this));
+ ws.addEventListener('close', this.onSocketClose.bind(this));
+ this.ws = ws;
+ } else {
+ const ws = new WebSocket(url);
+ ws.addEventListener('open', this.onSocketOpen.bind(this));
+ ws.addEventListener('message', this.onSocketMessage.bind(this));
+ ws.addEventListener('close', this.onSocketClose.bind(this));
+ this.ws = ws;
+ }
+ return this.ws;
+ }
+
+ public destroy(): void {
+ if (this.destroyed) {
+ console.error(new Error('Already disposed'));
+ return;
+ }
+ this.destroyed = true;
+ if (this.ws) {
+ if (this.ws.readyState === this.ws.OPEN) {
+ this.ws.close();
+ }
+ }
+ }
+
+ protected buildWebSocketUrl(): URL {
+ const directUrl = this.buildDirectWebSocketUrl();
+ if (this.params.useProxy && !this.supportMultiplexing()) {
+ return this.wrapInProxy(directUrl);
+ }
+ return directUrl;
+ }
+
+ protected buildDirectWebSocketUrl(): URL {
+ const { hostname, port, secure, action } = this.params;
+ const pathname = this.params.pathname ?? location.pathname;
+ let urlString: string;
+ if (typeof hostname === 'string' && typeof port === 'number') {
+ const protocol = secure ? 'wss:' : 'ws:';
+ urlString = `${protocol}//${hostname}:${port}${pathname}`;
+ } else {
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
+
+ // location.host includes hostname and port
+ urlString = `${protocol}${location.host}${pathname}`;
+ }
+ const directUrl = new URL(urlString);
+ if (this.supportMultiplexing()) {
+ directUrl.searchParams.set('action', ACTION.MULTIPLEX);
+ } else {
+ if (action) {
+ directUrl.searchParams.set('action', action);
+ }
+ }
+ return directUrl;
+ }
+
+ protected wrapInProxy(directUrl: URL): URL {
+ const localProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
+ const localUrl = new URL(`${localProtocol}//${location.host}`);
+ localUrl.searchParams.set('action', ACTION.PROXY_WS);
+ localUrl.searchParams.set('ws', directUrl.toString());
+ return localUrl;
+ }
+
+ protected supportMultiplexing(): boolean {
+ return false;
+ }
+
+ protected getChannelInitData(): Buffer {
+ return Buffer.from(ManagerClient.CODE);
+ }
+
+ protected abstract onSocketOpen(event: Event): void;
+ protected abstract onSocketMessage(event: MessageEvent): void;
+ protected abstract onSocketClose(event: CloseEvent): void;
+}
diff --git a/src/app/client/StreamReceiver.ts b/src/app/client/StreamReceiver.ts
new file mode 100644
index 0000000..1eca5d1
--- /dev/null
+++ b/src/app/client/StreamReceiver.ts
@@ -0,0 +1,252 @@
+import { ManagerClient } from './ManagerClient';
+import { ControlMessage } from '../controlMessage/ControlMessage';
+import DeviceMessage from '../googDevice/DeviceMessage';
+import VideoSettings from '../VideoSettings';
+import ScreenInfo from '../ScreenInfo';
+import Util from '../Util';
+import { DisplayInfo } from '../DisplayInfo';
+import { ParamsStream } from '../../types/ParamsStream';
+// import WebSocket, { Server } from 'ws'; // 新增WebSocket服务端依赖
+
+const DEVICE_NAME_FIELD_LENGTH = 64;
+const MAGIC_BYTES_INITIAL = Util.stringToUtf8ByteArray('scrcpy_initial');
+
+export type ClientsStats = {
+ deviceName: string;
+ clientId: number;
+};
+
+export type DisplayCombinedInfo = {
+ displayInfo: DisplayInfo;
+ videoSettings?: VideoSettings;
+ screenInfo?: ScreenInfo;
+ connectionCount: number;
+};
+
+interface StreamReceiverEvents {
+ video: ArrayBuffer;
+ deviceMessage: DeviceMessage;
+ displayInfo: DisplayCombinedInfo[];
+ clientsStats: ClientsStats;
+ encoders: string[];
+ connected: void;
+ disconnected: CloseEvent;
+}
+
+const TAG = '[StreamReceiver]';
+
+export class StreamReceiver extends ManagerClient {
+ // 新增WebSocket服务端相关属性
+ // private wss: Server | null = null;
+ // private clientConnections: Set = new Set();
+
+ private events: ControlMessage[] = [];
+ private encodersSet: Set = new Set();
+ private clientId = -1;
+ private deviceName = '';
+ private readonly displayInfoMap: Map = new Map();
+ private readonly connectionCountMap: Map = new Map();
+ private readonly screenInfoMap: Map = new Map();
+ private readonly videoSettingsMap: Map = new Map();
+ private hasInitialInfo = false;
+
+ constructor(params: P) {
+ super(params);
+
+ // 新增WebSocket服务端初始化
+ // this.startWebSocketServer();
+
+ this.openNewConnection();
+ if (this.ws) {
+ this.ws.binaryType = 'arraybuffer';
+ }
+ }
+
+
+ // 新增方法:启动WebSocket服务端
+ // private startWebSocketServer(): void {
+ // this.wss = new WebSocket.Server({ port: 8765 });
+
+ // this.wss.on('connection', (ws: WebSocket) => {
+ // console.log(`连接成功`);
+ // this.clientConnections.add(ws);
+
+ // // 设置消息处理器
+ // ws.on('message', (data: WebSocket.Data) => {
+ // console.log(data, '接收到')
+ // });
+
+ // // 处理连接关闭
+ // ws.on('close', () => {
+ // console.log(`${TAG} Client disconnected from server`);
+ // this.clientConnections.delete(ws);
+ // });
+
+ // // 发送初始信息给新客户端
+ // });
+ // }
+
+ private handleInitialInfo(data: ArrayBuffer): void {
+ let offset = MAGIC_BYTES_INITIAL.length;
+ let nameBytes = new Uint8Array(data, offset, DEVICE_NAME_FIELD_LENGTH);
+ offset += DEVICE_NAME_FIELD_LENGTH;
+ let rest: Buffer = Buffer.from(new Uint8Array(data, offset));
+ const displaysCount = rest.readInt32BE(0);
+ this.displayInfoMap.clear();
+ this.connectionCountMap.clear();
+ this.screenInfoMap.clear();
+ this.videoSettingsMap.clear();
+ rest = rest.slice(4);
+ for (let i = 0; i < displaysCount; i++) {
+ const displayInfoBuffer = rest.slice(0, DisplayInfo.BUFFER_LENGTH);
+ const displayInfo = DisplayInfo.fromBuffer(displayInfoBuffer);
+ const { displayId } = displayInfo;
+ this.displayInfoMap.set(displayId, displayInfo);
+ rest = rest.slice(DisplayInfo.BUFFER_LENGTH);
+ this.connectionCountMap.set(displayId, rest.readInt32BE(0));
+ rest = rest.slice(4);
+ const screenInfoBytesCount = rest.readInt32BE(0);
+ rest = rest.slice(4);
+ if (screenInfoBytesCount) {
+ this.screenInfoMap.set(displayId, ScreenInfo.fromBuffer(rest.slice(0, screenInfoBytesCount)));
+ rest = rest.slice(screenInfoBytesCount);
+ }
+ const videoSettingsBytesCount = rest.readInt32BE(0);
+ rest = rest.slice(4);
+ if (videoSettingsBytesCount) {
+ this.videoSettingsMap.set(displayId, VideoSettings.fromBuffer(rest.slice(0, videoSettingsBytesCount)));
+ rest = rest.slice(videoSettingsBytesCount);
+ }
+ }
+ this.encodersSet.clear();
+ const encodersCount = rest.readInt32BE(0);
+ rest = rest.slice(4);
+ for (let i = 0; i < encodersCount; i++) {
+ const nameLength = rest.readInt32BE(0);
+ rest = rest.slice(4);
+ const nameBytes = rest.slice(0, nameLength);
+ rest = rest.slice(nameLength);
+ const name = Util.utf8ByteArrayToString(nameBytes);
+ this.encodersSet.add(name);
+ }
+ this.clientId = rest.readInt32BE(0);
+ nameBytes = Util.filterTrailingZeroes(nameBytes);
+ this.deviceName = Util.utf8ByteArrayToString(nameBytes);
+ this.hasInitialInfo = true;
+ this.triggerInitialInfoEvents();
+ }
+
+ private static EqualArrays(a: ArrayLike, b: ArrayLike): boolean {
+ if (a.length !== b.length) {
+ return false;
+ }
+ for (let i = 0, l = a.length; i < l; i++) {
+ if (a[i] !== b[i]) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ protected buildDirectWebSocketUrl(): URL {
+ const localUrl = super.buildDirectWebSocketUrl();
+ if (this.supportMultiplexing()) {
+ return localUrl;
+ }
+ localUrl.searchParams.set('udid', this.params.udid);
+ return localUrl;
+ }
+
+ protected onSocketClose(ev: CloseEvent): void {
+ console.log(`${TAG}. WS closed: ${ev.reason}`);
+ this.emit('disconnected', ev);
+ }
+
+ protected onSocketMessage(event: MessageEvent): void {
+ // console.log("屏幕接收到的参数", event.data)
+
+ if (event.data instanceof ArrayBuffer) {
+
+ // works only because MAGIC_BYTES_INITIAL and MAGIC_BYTES_MESSAGE have same length
+ if (event.data.byteLength > MAGIC_BYTES_INITIAL.length) {
+ const magicBytes = new Uint8Array(event.data, 0, MAGIC_BYTES_INITIAL.length);
+ if (StreamReceiver.EqualArrays(magicBytes, MAGIC_BYTES_INITIAL)) {
+ this.handleInitialInfo(event.data);
+ return;
+ }
+ if (StreamReceiver.EqualArrays(magicBytes, DeviceMessage.MAGIC_BYTES_MESSAGE)) {
+ const message = DeviceMessage.fromBuffer(event.data);
+ console.log('屏幕接收到22', message)
+ this.emit('deviceMessage', message);
+ return;
+ }
+ }
+
+ this.emit('video', new Uint8Array(event.data));
+ }
+ }
+
+ protected onSocketOpen(): void {
+ this.emit('connected', void 0);
+ let e = this.events.shift();
+ while (e) {
+ this.sendEvent(e);
+ e = this.events.shift();
+ }
+ }
+
+ public sendEvent(event: ControlMessage): void {
+
+ console.log('发送消息2', event)
+
+
+
+
+ if (this.ws && this.ws.readyState === this.ws.OPEN) {
+ // console.log('发送的参数2');
+
+ this.ws.send(event.toBuffer());
+ } else {
+ // this.events.push(event);
+ }
+ }
+
+ public stop(): void {
+ if (this.ws && this.ws.readyState === this.ws.OPEN) {
+ this.ws.close();
+ }
+ this.events.length = 0;
+ }
+
+ public getEncoders(): string[] {
+ return Array.from(this.encodersSet.values());
+ }
+
+ public getDeviceName(): string {
+ return this.deviceName;
+ }
+
+ public triggerInitialInfoEvents(): void {
+ if (this.hasInitialInfo) {
+ const encoders = this.getEncoders();
+ this.emit('encoders', encoders);
+ const { clientId, deviceName } = this;
+ this.emit('clientsStats', { clientId, deviceName });
+ const infoArray: DisplayCombinedInfo[] = [];
+ this.displayInfoMap.forEach((displayInfo: DisplayInfo, displayId: number) => {
+ const connectionCount = this.connectionCountMap.get(displayId) || 0;
+ infoArray.push({
+ displayInfo,
+ videoSettings: this.videoSettingsMap.get(displayId),
+ screenInfo: this.screenInfoMap.get(displayId),
+ connectionCount,
+ });
+ });
+ this.emit('displayInfo', infoArray);
+ }
+ }
+
+ public getDisplayInfo(displayId: number): DisplayInfo | undefined {
+ return this.displayInfoMap.get(displayId);
+ }
+}
diff --git a/src/app/client/Tool.d.ts b/src/app/client/Tool.d.ts
new file mode 100644
index 0000000..29b53e8
--- /dev/null
+++ b/src/app/client/Tool.d.ts
@@ -0,0 +1,12 @@
+import { ParamsDeviceTracker } from '../../types/ParamsDeviceTracker';
+import { BaseDeviceDescriptor } from '../../types/BaseDeviceDescriptor';
+
+type Entry = HTMLElement | DocumentFragment;
+
+export interface Tool {
+ createEntryForDeviceList(
+ descriptor: BaseDeviceDescriptor,
+ blockClass: string,
+ params: ParamsDeviceTracker,
+ ): Array | Entry | undefined;
+}
diff --git a/src/app/controlMessage/CommandControlMessage.ts b/src/app/controlMessage/CommandControlMessage.ts
new file mode 100644
index 0000000..b086ab8
--- /dev/null
+++ b/src/app/controlMessage/CommandControlMessage.ts
@@ -0,0 +1,226 @@
+import { ControlMessage } from './ControlMessage';
+import VideoSettings from '../VideoSettings';
+import Util from '../Util';
+
+export enum FilePushState {
+ NEW,
+ START,
+ APPEND,
+ FINISH,
+ CANCEL,
+}
+
+type FilePushParams = {
+ id: number;
+ state: FilePushState;
+ chunk?: Uint8Array;
+ fileName?: string;
+ fileSize?: number;
+};
+
+export class CommandControlMessage extends ControlMessage {
+ public static PAYLOAD_LENGTH = 0;
+
+ public static Commands: Map = new Map([
+ [ControlMessage.TYPE_EXPAND_NOTIFICATION_PANEL, 'Expand notifications'],
+ [ControlMessage.TYPE_EXPAND_SETTINGS_PANEL, 'Expand settings'],
+ [ControlMessage.TYPE_COLLAPSE_PANELS, 'Collapse panels'],
+ [ControlMessage.TYPE_GET_CLIPBOARD, 'Get clipboard'],
+ [ControlMessage.TYPE_SET_CLIPBOARD, 'Set clipboard'],
+ [ControlMessage.TYPE_ROTATE_DEVICE, 'Rotate device'],
+ [ControlMessage.TYPE_CHANGE_STREAM_PARAMETERS, 'Change video settings'],
+ ]);
+
+ public static createSetVideoSettingsCommand(videoSettings: VideoSettings): CommandControlMessage {
+ const temp = videoSettings.toBuffer();
+ const event = new CommandControlMessage(ControlMessage.TYPE_CHANGE_STREAM_PARAMETERS);
+ const offset = CommandControlMessage.PAYLOAD_LENGTH + 1;
+ const buffer = Buffer.alloc(offset + temp.length);
+ buffer.writeUInt8(event.type, 0);
+ temp.forEach((byte, index) => {
+ buffer.writeUInt8(byte, index + offset);
+ });
+ event.buffer = buffer;
+ return event;
+ }
+
+ public static createSetClipboardCommand(text: string, paste = true): CommandControlMessage {
+ const event = new CommandControlMessage(ControlMessage.TYPE_SET_CLIPBOARD);
+ const textBytes: Uint8Array | null = text ? Util.stringToUtf8ByteArray(text) : null;
+ const textLength = textBytes ? textBytes.length : 0;
+ let offset = 0;
+ const buffer = Buffer.alloc(1 + 1 + 4 + textLength);
+ offset = buffer.writeInt8(event.type, offset);
+ offset = buffer.writeInt8(paste ? 1 : 0, offset);
+ offset = buffer.writeInt32BE(textLength, offset);
+ if (textBytes) {
+ textBytes.forEach((byte: number, index: number) => {
+ buffer.writeUInt8(byte, index + offset);
+ });
+ }
+ event.buffer = buffer;
+ return event;
+ }
+
+ public static createSetScreenPowerModeCommand(mode: boolean): CommandControlMessage {
+ const event = new CommandControlMessage(ControlMessage.TYPE_SET_SCREEN_POWER_MODE);
+ let offset = 0;
+ const buffer = Buffer.alloc(1 + 1);
+ offset = buffer.writeInt8(event.type, offset);
+ buffer.writeUInt8(mode ? 1 : 0, offset);
+ event.buffer = buffer;
+ return event;
+ }
+
+ public static createPushFileCommand(params: FilePushParams): CommandControlMessage {
+ const { id, fileName, fileSize, chunk, state } = params;
+
+ if (state === FilePushState.START) {
+ return this.createPushFileStartCommand(id, fileName as string, fileSize as number);
+ }
+ if (state === FilePushState.APPEND) {
+ if (!chunk) {
+ throw TypeError('Invalid type');
+ }
+ return this.createPushFileChunkCommand(id, chunk);
+ }
+ if (state === FilePushState.CANCEL || state === FilePushState.FINISH || state === FilePushState.NEW) {
+ return this.createPushFileOtherCommand(id, state);
+ }
+
+ throw TypeError(`Unsupported state: "${state}"`);
+ }
+
+ private static createPushFileStartCommand(id: number, fileName: string, fileSize: number): CommandControlMessage {
+ const event = new CommandControlMessage(ControlMessage.TYPE_PUSH_FILE);
+ const text = Util.stringToUtf8ByteArray(fileName);
+ const typeField = 1;
+ const idField = 2;
+ const stateField = 1;
+ const sizeField = 4;
+ const textLengthField = 2;
+ const textLength = text.length;
+ let offset = CommandControlMessage.PAYLOAD_LENGTH;
+
+ const buffer = Buffer.alloc(
+ offset + typeField + idField + stateField + sizeField + textLengthField + textLength,
+ );
+ buffer.writeUInt8(event.type, offset);
+ offset += typeField;
+ buffer.writeInt16BE(id, offset);
+ offset += idField;
+ buffer.writeInt8(FilePushState.START, offset);
+ offset += stateField;
+ buffer.writeUInt32BE(fileSize, offset);
+ offset += sizeField;
+ buffer.writeUInt16BE(textLength, offset);
+ offset += textLengthField;
+ text.forEach((byte, index) => {
+ buffer.writeUInt8(byte, index + offset);
+ });
+ event.buffer = buffer;
+ return event;
+ }
+
+ private static createPushFileChunkCommand(id: number, chunk: Uint8Array): CommandControlMessage {
+ const event = new CommandControlMessage(ControlMessage.TYPE_PUSH_FILE);
+ const typeField = 1;
+ const idField = 2;
+ const stateField = 1;
+ const chunkLengthField = 4;
+ const chunkLength = chunk.byteLength;
+ let offset = CommandControlMessage.PAYLOAD_LENGTH;
+
+ const buffer = Buffer.alloc(offset + typeField + idField + stateField + chunkLengthField + chunkLength);
+ buffer.writeUInt8(event.type, offset);
+ offset += typeField;
+ buffer.writeInt16BE(id, offset);
+ offset += idField;
+ buffer.writeInt8(FilePushState.APPEND, offset);
+ offset += stateField;
+ buffer.writeUInt32BE(chunkLength, offset);
+ offset += chunkLengthField;
+ Array.from(chunk).forEach((byte, index) => {
+ buffer.writeUInt8(byte, index + offset);
+ });
+ event.buffer = buffer;
+ return event;
+ }
+
+ private static createPushFileOtherCommand(id: number, state: FilePushState): CommandControlMessage {
+ const event = new CommandControlMessage(ControlMessage.TYPE_PUSH_FILE);
+ const typeField = 1;
+ const idField = 2;
+ const stateField = 1;
+ let offset = CommandControlMessage.PAYLOAD_LENGTH;
+ const buffer = Buffer.alloc(offset + typeField + idField + stateField);
+ buffer.writeUInt8(event.type, offset);
+ offset += typeField;
+ buffer.writeInt16BE(id, offset);
+ offset += idField;
+ buffer.writeInt8(state, offset);
+ event.buffer = buffer;
+ return event;
+ }
+
+ public static pushFileCommandFromBuffer(buffer: Buffer): {
+ id: number;
+ state: FilePushState;
+ chunk?: Buffer;
+ fileSize?: number;
+ fileName?: string;
+ } {
+ let offset = 0;
+ const type = buffer.readUInt8(offset);
+ offset += 1;
+ if (type !== CommandControlMessage.TYPE_PUSH_FILE) {
+ throw TypeError(`Incorrect type: "${type}"`);
+ }
+ const id = buffer.readInt16BE(offset);
+ offset += 2;
+ const state = buffer.readInt8(offset);
+ offset += 1;
+ let chunk: Buffer | undefined;
+ let fileSize: number | undefined;
+ let fileName: string | undefined;
+ if (state === FilePushState.APPEND) {
+ const chunkLength = buffer.readUInt32BE(offset);
+ offset += 4;
+ chunk = buffer.slice(offset, offset + chunkLength);
+ } else if (state === FilePushState.START) {
+ fileSize = buffer.readUInt32BE(offset);
+ offset += 4;
+ const textLength = buffer.readUInt16BE(offset);
+ offset += 2;
+ fileName = Util.utf8ByteArrayToString(buffer.slice(offset, offset + textLength));
+ }
+ return { id, state, chunk, fileName, fileSize };
+ }
+
+ private buffer?: Buffer;
+
+ constructor(readonly type: number) {
+ super(type);
+ }
+
+ /**
+ * @override
+ */
+ public toBuffer(): Buffer {
+ console.log("this.buffer", this.buffer)
+ if (!this.buffer) {
+ console.log('CommandControlMessage.PAYLOAD_LENGTH', CommandControlMessage.PAYLOAD_LENGTH)
+ const buffer = Buffer.alloc(CommandControlMessage.PAYLOAD_LENGTH + 1);
+ buffer.writeUInt8(this.type, 0);
+ this.buffer = buffer;
+ }
+ console.log("转换comm", this.buffer)
+
+ return this.buffer;
+ }
+
+ public toString(): string {
+ const buffer = this.buffer ? `, buffer=[${this.buffer.join(',')}]` : '';
+ return `CommandControlMessage{action=${this.type}${buffer}}`;
+ }
+}
diff --git a/src/app/controlMessage/ControlMessage.ts b/src/app/controlMessage/ControlMessage.ts
new file mode 100644
index 0000000..e3cab8d
--- /dev/null
+++ b/src/app/controlMessage/ControlMessage.ts
@@ -0,0 +1,36 @@
+export interface ControlMessageInterface {
+ type: number;
+}
+
+export class ControlMessage {
+ public static TYPE_KEYCODE = 0;
+ public static TYPE_TEXT = 1;
+ public static TYPE_TOUCH = 2;
+ public static TYPE_SCROLL = 3;
+ public static TYPE_BACK_OR_SCREEN_ON = 4;
+ public static TYPE_EXPAND_NOTIFICATION_PANEL = 5;
+ public static TYPE_EXPAND_SETTINGS_PANEL = 6;
+ public static TYPE_COLLAPSE_PANELS = 7;
+ public static TYPE_GET_CLIPBOARD = 8;
+ public static TYPE_SET_CLIPBOARD = 9;
+ public static TYPE_SET_SCREEN_POWER_MODE = 10;
+ public static TYPE_ROTATE_DEVICE = 11;
+ public static TYPE_CHANGE_STREAM_PARAMETERS = 101;
+ public static TYPE_PUSH_FILE = 102;
+
+ constructor(readonly type: number) {}
+
+ public toBuffer(): Buffer {
+ throw Error('Not implemented');
+ }
+
+ public toString(): string {
+ return 'ControlMessage';
+ }
+
+ public toJSON(): ControlMessageInterface {
+ return {
+ type: this.type,
+ };
+ }
+}
diff --git a/src/app/controlMessage/KeyCodeControlMessage.ts b/src/app/controlMessage/KeyCodeControlMessage.ts
new file mode 100644
index 0000000..2ad1ba2
--- /dev/null
+++ b/src/app/controlMessage/KeyCodeControlMessage.ts
@@ -0,0 +1,53 @@
+import { Buffer } from 'buffer';
+import { ControlMessage, ControlMessageInterface } from './ControlMessage';
+
+export interface KeyCodeControlMessageInterface extends ControlMessageInterface {
+ action: number;
+ keycode: number;
+ repeat: number;
+ metaState: number;
+}
+
+export class KeyCodeControlMessage extends ControlMessage {
+ public static PAYLOAD_LENGTH = 13;
+
+ constructor(
+ readonly action: number,
+ readonly keycode: number,
+ readonly repeat: number,
+ readonly metaState: number,
+ ) {
+ super(ControlMessage.TYPE_KEYCODE);
+ }
+
+ /**
+ * @override
+ */
+ public toBuffer(): Buffer {
+ const buffer = Buffer.alloc(KeyCodeControlMessage.PAYLOAD_LENGTH + 1);
+ let offset = 0;
+ offset = buffer.writeInt8(this.type, offset);
+ offset = buffer.writeInt8(this.action, offset);
+ offset = buffer.writeInt32BE(this.keycode, offset);
+ offset = buffer.writeInt32BE(this.repeat, offset);
+ buffer.writeInt32BE(this.metaState, offset);
+ console.log("转换按钮", buffer)
+ console.log("鼠标按下的key", this.keycode)
+
+ return buffer;
+ }
+
+ public toString(): string {
+ return `KeyCodeControlMessage{action=${this.action}, keycode=${this.keycode}, metaState=${this.metaState}}`;
+ }
+
+ public toJSON(): KeyCodeControlMessageInterface {
+ return {
+ type: this.type,
+ action: this.action,
+ keycode: this.keycode,
+ metaState: this.metaState,
+ repeat: this.repeat,
+ };
+ }
+}
diff --git a/src/app/controlMessage/ScrollControlMessage.ts b/src/app/controlMessage/ScrollControlMessage.ts
new file mode 100644
index 0000000..a0b4902
--- /dev/null
+++ b/src/app/controlMessage/ScrollControlMessage.ts
@@ -0,0 +1,47 @@
+import { ControlMessage, ControlMessageInterface } from './ControlMessage';
+import Position, { PositionInterface } from '../Position';
+
+export interface ScrollControlMessageInterface extends ControlMessageInterface {
+ position: PositionInterface;
+ hScroll: number;
+ vScroll: number;
+}
+
+export class ScrollControlMessage extends ControlMessage {
+ public static PAYLOAD_LENGTH = 20;
+
+ constructor(readonly position: Position, readonly hScroll: number, readonly vScroll: number) {
+ super(ControlMessage.TYPE_SCROLL);
+ }
+
+ /**
+ * @override
+ */
+ public toBuffer(): Buffer {
+ const buffer = Buffer.alloc(ScrollControlMessage.PAYLOAD_LENGTH + 1);
+ let offset = 0;
+ offset = buffer.writeUInt8(this.type, offset);
+ offset = buffer.writeUInt32BE(this.position.point.x, offset);
+ offset = buffer.writeUInt32BE(this.position.point.y, offset);
+ offset = buffer.writeUInt16BE(this.position.screenSize.width, offset);
+ offset = buffer.writeUInt16BE(this.position.screenSize.height, offset);
+ offset = buffer.writeInt32BE(this.hScroll, offset);
+ buffer.writeInt32BE(this.vScroll, offset);
+
+ console.log("转换text", buffer)
+ return buffer;
+ }
+
+ public toString(): string {
+ return `ScrollControlMessage{hScroll=${this.hScroll}, vScroll=${this.vScroll}, position=${this.position}}`;
+ }
+
+ public toJSON(): ScrollControlMessageInterface {
+ return {
+ type: this.type,
+ position: this.position.toJSON(),
+ hScroll: this.hScroll,
+ vScroll: this.vScroll,
+ };
+ }
+}
diff --git a/src/app/controlMessage/TextControlMessage.ts b/src/app/controlMessage/TextControlMessage.ts
new file mode 100644
index 0000000..00a7aff
--- /dev/null
+++ b/src/app/controlMessage/TextControlMessage.ts
@@ -0,0 +1,43 @@
+import { Buffer } from 'buffer';
+import { ControlMessage, ControlMessageInterface } from './ControlMessage';
+
+export interface TextControlMessageInterface extends ControlMessageInterface {
+ text: string;
+}
+
+export class TextControlMessage extends ControlMessage {
+ private static TEXT_SIZE_FIELD_LENGTH = 4;
+ constructor(readonly text: string) {
+ super(ControlMessage.TYPE_TEXT);
+ }
+
+ public getText(): string {
+ return this.text;
+ }
+
+ /**
+ * @override
+ */
+ public toBuffer(): Buffer {
+ const length = this.text.length;
+ const buffer = Buffer.alloc(length + 1 + TextControlMessage.TEXT_SIZE_FIELD_LENGTH);
+ let offset = 0;
+ offset = buffer.writeUInt8(this.type, offset);
+ offset = buffer.writeUInt32BE(length, offset);
+ buffer.write(this.text, offset);
+ console.log("转换text", buffer)
+
+ return buffer;
+ }
+
+ public toString(): string {
+ return `TextControlMessage{text=${this.text}}`;
+ }
+
+ public toJSON(): TextControlMessageInterface {
+ return {
+ type: this.type,
+ text: this.text,
+ };
+ }
+}
diff --git a/src/app/controlMessage/TouchControlMessage.ts b/src/app/controlMessage/TouchControlMessage.ts
new file mode 100644
index 0000000..9cebe40
--- /dev/null
+++ b/src/app/controlMessage/TouchControlMessage.ts
@@ -0,0 +1,77 @@
+import { ControlMessage, ControlMessageInterface } from './ControlMessage';
+import Position, { PositionInterface } from '../Position';
+
+export interface TouchControlMessageInterface extends ControlMessageInterface {
+ type: number;
+ action: number;
+ pointerId: number;
+ position: PositionInterface;
+ pressure: number;
+ buttons: number;
+}
+
+export class TouchControlMessage extends ControlMessage {
+ public static PAYLOAD_LENGTH = 28;
+ /**
+ * - For a touch screen or touch pad, reports the approximate pressure
+ * applied to the surface by a finger or other tool. The value is
+ * normalized to a range from 0 (no pressure at all) to 1 (normal pressure),
+ * although values higher than 1 may be generated depending on the
+ * calibration of the input device.
+ * - For a trackball, the value is set to 1 if the trackball button is pressed
+ * or 0 otherwise.
+ * - For a mouse, the value is set to 1 if the primary mouse button is pressed
+ * or 0 otherwise.
+ *
+ * - scrcpy server expects signed short (2 bytes) for a pressure value
+ * - in browser TouchEvent has `force` property (values in 0..1 range), we
+ * use it as "pressure" for scrcpy
+ */
+ public static readonly MAX_PRESSURE_VALUE = 0xffff;
+
+ constructor(
+ readonly action: number,
+ readonly pointerId: number,
+ readonly position: Position,
+ readonly pressure: number,
+ readonly buttons: number,
+ ) {
+ super(ControlMessage.TYPE_TOUCH);
+ }
+
+ /**
+ * @override
+ */
+ public toBuffer(): Buffer {
+ const buffer: Buffer = Buffer.alloc(TouchControlMessage.PAYLOAD_LENGTH + 1);
+ let offset = 0;
+ offset = buffer.writeUInt8(this.type, offset);
+ offset = buffer.writeUInt8(this.action, offset);
+ offset = buffer.writeUInt32BE(0, offset); // pointerId is `long` (8 bytes) on java side
+ offset = buffer.writeUInt32BE(this.pointerId, offset);
+ offset = buffer.writeUInt32BE(this.position.point.x, offset);
+ offset = buffer.writeUInt32BE(this.position.point.y, offset);
+ offset = buffer.writeUInt16BE(this.position.screenSize.width, offset);
+ offset = buffer.writeUInt16BE(this.position.screenSize.height, offset);
+ offset = buffer.writeUInt16BE(this.pressure * TouchControlMessage.MAX_PRESSURE_VALUE, offset);
+ buffer.writeUInt32BE(this.buttons, offset);
+ // console.log("转换屏幕点击滑动事件", buffer)
+
+ return buffer;
+ }
+
+ public toString(): string {
+ return `TouchControlMessage{action=${this.action}, pointerId=${this.pointerId}, position=${this.position}, pressure=${this.pressure}, buttons=${this.buttons}}`;
+ }
+
+ public toJSON(): TouchControlMessageInterface {
+ return {
+ type: this.type,
+ action: this.action,
+ pointerId: this.pointerId,
+ position: this.position.toJSON(),
+ pressure: this.pressure,
+ buttons: this.buttons,
+ };
+ }
+}
diff --git a/src/app/googDevice/DeviceMessage.ts b/src/app/googDevice/DeviceMessage.ts
new file mode 100644
index 0000000..7cbf040
--- /dev/null
+++ b/src/app/googDevice/DeviceMessage.ts
@@ -0,0 +1,54 @@
+import Util from '../Util';
+
+export default class DeviceMessage {
+ public static TYPE_CLIPBOARD = 0;
+ public static TYPE_PUSH_RESPONSE = 101;
+
+ public static readonly MAGIC_BYTES_MESSAGE = Util.stringToUtf8ByteArray('scrcpy_message');
+
+ constructor(public readonly type: number, protected readonly buffer: Buffer) { }
+
+ public static fromBuffer(data: ArrayBuffer): DeviceMessage {
+ const magicSize = this.MAGIC_BYTES_MESSAGE.length;
+ const buffer = Buffer.from(data, magicSize, data.byteLength - magicSize);
+ const type = buffer.readUInt8(0);
+ return new DeviceMessage(type, buffer);
+ }
+
+ public getText(): string {
+ if (this.type !== DeviceMessage.TYPE_CLIPBOARD) {
+ throw TypeError(`Wrong message type: ${this.type}`);
+ }
+ if (!this.buffer) {
+ throw Error('Empty buffer');
+ }
+ let offset = 1;
+ const length = this.buffer.readInt32BE(offset);
+ offset += 4;
+ const textBytes = this.buffer.slice(offset, offset + length);
+ return Util.utf8ByteArrayToString(textBytes);
+ }
+
+ public getPushStats(): { id: number; code: number } {
+ if (this.type !== DeviceMessage.TYPE_PUSH_RESPONSE) {
+ throw TypeError(`Wrong message type: ${this.type}`);
+ }
+ if (!this.buffer) {
+ throw Error('Empty buffer');
+ }
+ console.log("从设备获取到的信息", this.buffer)
+ const id = this.buffer.readInt16BE(1);
+ const code = this.buffer.readInt8(3);
+ return { id, code };
+ }
+
+ public toString(): string {
+ let desc: string;
+ if (this.type === DeviceMessage.TYPE_CLIPBOARD && this.buffer) {
+ desc = `, text=[${this.getText()}]`;
+ } else {
+ desc = this.buffer ? `, buffer=[${this.buffer.join(',')}]` : '';
+ }
+ return `DeviceMessage{type=${this.type}${desc}}`;
+ }
+}
diff --git a/src/app/googDevice/DragAndDropHandler.ts b/src/app/googDevice/DragAndDropHandler.ts
new file mode 100644
index 0000000..789329f
--- /dev/null
+++ b/src/app/googDevice/DragAndDropHandler.ts
@@ -0,0 +1,102 @@
+export interface DragEventListener {
+ onDragEnter: () => boolean;
+ onDragLeave: () => boolean;
+ onFilesDrop: (files: File[]) => boolean;
+ getElement: () => HTMLElement;
+}
+
+export class DragAndDropHandler {
+ private static readonly listeners: Set = new Set();
+ private static dropHandler = (ev: DragEvent): boolean => {
+ if (!ev.dataTransfer) {
+ return false;
+ }
+
+ const files: File[] = [];
+ if (ev.dataTransfer.items) {
+ for (let i = 0; i < ev.dataTransfer.items.length; i++) {
+ if (ev.dataTransfer.items[i].kind === 'file') {
+ const file = ev.dataTransfer.items[i].getAsFile();
+
+ if (file) {
+ files.push(file);
+ }
+ }
+ }
+ } else {
+ for (let i = 0; i < ev.dataTransfer.files.length; i++) {
+ files.push(ev.dataTransfer.files[i]);
+ }
+ }
+ let handled = false;
+ DragAndDropHandler.listeners.forEach((listener) => {
+ const element = listener.getElement();
+ if (element === ev.currentTarget) {
+ handled = handled || listener.onFilesDrop(files);
+ }
+ });
+ if (handled) {
+ ev.preventDefault();
+ return true;
+ }
+ return false;
+ };
+ private static dragOverHandler = (ev: DragEvent): void => {
+ ev.preventDefault();
+ };
+ private static dragLeaveHandler = (ev: DragEvent): boolean => {
+ let handled = false;
+ DragAndDropHandler.listeners.forEach((listener) => {
+ const element = listener.getElement();
+ if (element === ev.currentTarget) {
+ handled = handled || listener.onDragLeave();
+ }
+ });
+ if (handled) {
+ ev.preventDefault();
+ return true;
+ }
+ return false;
+ };
+ private static dragEnterHandler = (ev: DragEvent): boolean => {
+ let handled = false;
+ DragAndDropHandler.listeners.forEach((listener) => {
+ const element = listener.getElement();
+ if (element === ev.currentTarget) {
+ handled = handled || listener.onDragEnter();
+ }
+ });
+ if (handled) {
+ ev.preventDefault();
+ return true;
+ }
+ return false;
+ };
+ private static attachListeners(element: HTMLElement): void {
+ element.addEventListener('drop', this.dropHandler);
+ element.addEventListener('dragover', this.dragOverHandler);
+ element.addEventListener('dragleave', this.dragLeaveHandler);
+ element.addEventListener('dragenter', this.dragEnterHandler);
+ }
+ private static detachListeners(element: HTMLElement): void {
+ element.removeEventListener('drop', this.dropHandler);
+ element.removeEventListener('dragover', this.dragOverHandler);
+ element.removeEventListener('dragleave', this.dragLeaveHandler);
+ element.removeEventListener('dragenter', this.dragEnterHandler);
+ }
+
+ public static addEventListener(listener: DragEventListener): void {
+ if (this.listeners.has(listener)) {
+ return;
+ }
+ this.attachListeners(listener.getElement());
+ this.listeners.add(listener);
+ }
+ public static removeEventListener(listener: DragEventListener): void {
+ if (!this.listeners.has(listener)) {
+ return;
+ }
+ this.detachListeners(listener.getElement());
+ this.listeners.delete(listener);
+ }
+}
diff --git a/src/app/googDevice/DragAndPushLogger.ts b/src/app/googDevice/DragAndPushLogger.ts
new file mode 100644
index 0000000..3567796
--- /dev/null
+++ b/src/app/googDevice/DragAndPushLogger.ts
@@ -0,0 +1,129 @@
+import FilePushHandler, { DragAndPushListener, PushUpdateParams } from './filePush/FilePushHandler';
+
+const TAG = '[DragAndPushLogger]';
+
+export default class DragAndPushLogger implements DragAndPushListener {
+ private static readonly X: number = 20;
+ private static readonly Y: number = 40;
+ private static readonly HEIGHT = 12;
+ private static readonly LOG_BACKGROUND: string = 'rgba(0,0,0, 0.5)';
+ private static readonly DEBUG_COLOR: string = 'hsl(136, 85%,50%)';
+ private static readonly ERROR_COLOR: string = 'hsl(336,85%,50%)';
+
+ private readonly ctx: CanvasRenderingContext2D | null = null;
+ private timeoutMap: Map = new Map();
+ private dirtyMap: Map = new Map();
+ private pushLineMap: Map = new Map();
+ private linePushMap: Map = new Map();
+ private dirtyLines: boolean[] = [];
+ constructor(element: HTMLElement) {
+ if (element instanceof HTMLCanvasElement) {
+ const canvas = element as HTMLCanvasElement;
+ this.ctx = canvas.getContext('2d');
+ }
+ }
+ cleanDirtyLine = (line: number): void => {
+ if (!this.ctx) {
+ return;
+ }
+ const { X, Y, HEIGHT } = DragAndPushLogger;
+ const x = X;
+ const y = Y + HEIGHT * line * 2;
+ const dirty = this.dirtyMap.get(line);
+ if (dirty) {
+ const p = DragAndPushLogger.HEIGHT / 2;
+ const d = p * 2;
+ this.ctx.clearRect(x - p, y - HEIGHT - p, dirty + d, HEIGHT + d);
+ }
+ this.dirtyLines[line] = false;
+ };
+ private logText(text: string, line: number, scheduleCleanup = false, error = false): void {
+ if (!this.ctx) {
+ error ? console.error(TAG, text) : console.log(TAG, text);
+ return;
+ }
+ if (error) {
+ console.error(TAG, text);
+ }
+ this.cleanDirtyLine(line);
+
+ const { X, Y, HEIGHT } = DragAndPushLogger;
+ const x = X;
+ const y = Y + HEIGHT * line * 2;
+ this.ctx.save();
+ this.ctx.font = `${HEIGHT}px monospace`;
+ const textMetrics = this.ctx.measureText(text);
+ const width = Math.abs(textMetrics.actualBoundingBoxLeft) + Math.abs(textMetrics.actualBoundingBoxRight);
+ this.dirtyMap.set(line, width);
+ this.ctx.fillStyle = DragAndPushLogger.LOG_BACKGROUND;
+ const p = DragAndPushLogger.HEIGHT / 2 - 1;
+ const d = p * 2;
+ this.ctx.fillRect(x - p, y - HEIGHT - p, width + d, HEIGHT + d);
+ this.ctx.fillStyle = error ? DragAndPushLogger.ERROR_COLOR : DragAndPushLogger.DEBUG_COLOR;
+ this.ctx.fillText(text, x, y);
+ this.ctx.restore();
+ if (scheduleCleanup) {
+ this.dirtyLines[line] = true;
+ let timeout = this.timeoutMap.get(line);
+ if (timeout) {
+ clearTimeout(timeout);
+ }
+ timeout = window.setTimeout(() => {
+ this.cleanDirtyLine(line);
+ const key = this.linePushMap.get(line);
+ if (typeof key === 'string') {
+ this.linePushMap.delete(line);
+ this.pushLineMap.delete(key);
+ }
+ }, 5000);
+ this.timeoutMap.set(line, timeout);
+ }
+ }
+
+ public onDragEnter(): boolean {
+ this.logText('Drop APK files here', 1);
+ return true;
+ }
+
+ public onDragLeave(): boolean {
+ this.cleanDirtyLine(1);
+ return true;
+ }
+
+ public onDrop(): boolean {
+ this.cleanDirtyLine(1);
+ return true;
+ }
+
+ public onError(error: Error | string): void {
+ const text = typeof error === 'string' ? error : error.message;
+ this.logText(text, 1, true);
+ }
+
+ onFilePushUpdate(data: PushUpdateParams): void {
+ const { pushId, message, fileName, error } = data;
+ const key = `${pushId}/${fileName}`;
+ const firstKey = `${FilePushHandler.REQUEST_NEW_PUSH_ID}/${fileName}`;
+ let line: number | undefined = this.pushLineMap.get(key);
+ let update = false;
+ if (typeof line === 'undefined' && key !== firstKey) {
+ line = this.pushLineMap.get(firstKey);
+ if (typeof line !== 'undefined') {
+ this.pushLineMap.delete(firstKey);
+ update = true;
+ }
+ }
+ if (typeof line === 'undefined') {
+ line = 2;
+ while (this.dirtyLines[line]) {
+ line++;
+ }
+ update = true;
+ }
+ if (update) {
+ this.pushLineMap.set(key, line);
+ this.linePushMap.set(line, key);
+ }
+ this.logText(`Upload "${fileName}": ${message}`, line, true, error);
+ }
+}
diff --git a/src/app/googDevice/Entry.ts b/src/app/googDevice/Entry.ts
new file mode 100644
index 0000000..a80a353
--- /dev/null
+++ b/src/app/googDevice/Entry.ts
@@ -0,0 +1,11 @@
+import { Stats } from './Stats';
+
+export class Entry extends Stats {
+ constructor(public name: string, mode: number, size: number, mtime: number) {
+ super(mode, size, mtime);
+ }
+
+ public toString(): string {
+ return this.name;
+ }
+}
diff --git a/src/app/googDevice/KeyInputHandler.ts b/src/app/googDevice/KeyInputHandler.ts
new file mode 100644
index 0000000..41aaaac
--- /dev/null
+++ b/src/app/googDevice/KeyInputHandler.ts
@@ -0,0 +1,79 @@
+import { KeyCodeControlMessage } from '../controlMessage/KeyCodeControlMessage';
+import KeyEvent from './android/KeyEvent';
+import { KeyToCodeMap } from './KeyToCodeMap';
+
+export interface KeyEventListener {
+ onKeyEvent: (event: KeyCodeControlMessage) => void;
+}
+
+export class KeyInputHandler {
+ private static readonly repeatCounter: Map = new Map();
+ private static readonly listeners: Set = new Set();
+ private static handler = (event: Event): void => {
+ const keyboardEvent = event as KeyboardEvent;
+ const keyCode = KeyToCodeMap.get(keyboardEvent.code);
+ if (!keyCode) {
+ return;
+ }
+ let action: typeof KeyEvent.ACTION_DOWN | typeof KeyEvent.ACTION_DOWN;
+ let repeatCount = 0;
+ console.log('键盘按下', keyCode)
+ if (keyboardEvent.type === 'keydown') {
+ action = KeyEvent.ACTION_DOWN;
+ if (keyboardEvent.repeat) {
+ let count = KeyInputHandler.repeatCounter.get(keyCode);
+ if (typeof count !== 'number') {
+ count = 1;
+ } else {
+ count++;
+ }
+ repeatCount = count;
+ KeyInputHandler.repeatCounter.set(keyCode, count);
+ }
+ } else if (keyboardEvent.type === 'keyup') {
+ action = KeyEvent.ACTION_UP;
+ KeyInputHandler.repeatCounter.delete(keyCode);
+ } else {
+ return;
+ }
+ const metaState =
+ (keyboardEvent.getModifierState('Alt') ? KeyEvent.META_ALT_ON : 0) |
+ (keyboardEvent.getModifierState('Shift') ? KeyEvent.META_SHIFT_ON : 0) |
+ (keyboardEvent.getModifierState('Control') ? KeyEvent.META_CTRL_ON : 0) |
+ (keyboardEvent.getModifierState('Meta') ? KeyEvent.META_META_ON : 0) |
+ (keyboardEvent.getModifierState('CapsLock') ? KeyEvent.META_CAPS_LOCK_ON : 0) |
+ (keyboardEvent.getModifierState('ScrollLock') ? KeyEvent.META_SCROLL_LOCK_ON : 0) |
+ (keyboardEvent.getModifierState('NumLock') ? KeyEvent.META_NUM_LOCK_ON : 0);
+
+ const controlMessage: KeyCodeControlMessage = new KeyCodeControlMessage(
+ action,
+ keyCode,
+ repeatCount,
+ metaState,
+ );
+ KeyInputHandler.listeners.forEach((listener) => {
+ listener.onKeyEvent(controlMessage);
+ });
+ event.preventDefault();
+ };
+ private static attachListeners(): void {
+ document.body.addEventListener('keydown', this.handler);
+ document.body.addEventListener('keyup', this.handler);
+ }
+ private static detachListeners(): void {
+ document.body.removeEventListener('keydown', this.handler);
+ document.body.removeEventListener('keyup', this.handler);
+ }
+ public static addEventListener(listener: KeyEventListener): void {
+ if (!this.listeners.size) {
+ this.attachListeners();
+ }
+ this.listeners.add(listener);
+ }
+ public static removeEventListener(listener: KeyEventListener): void {
+ this.listeners.delete(listener);
+ if (!this.listeners.size) {
+ this.detachListeners();
+ }
+ }
+}
diff --git a/src/app/googDevice/KeyToCodeMap.ts b/src/app/googDevice/KeyToCodeMap.ts
new file mode 100644
index 0000000..f964eda
--- /dev/null
+++ b/src/app/googDevice/KeyToCodeMap.ts
@@ -0,0 +1,118 @@
+import KeyEvent from './android/KeyEvent';
+import UIEventsCode from '../UIEventsCode';
+
+export const KeyToCodeMap = new Map([
+ [UIEventsCode.Backquote, KeyEvent.KEYCODE_GRAVE],
+ [UIEventsCode.Backslash, KeyEvent.KEYCODE_BACKSLASH],
+ [UIEventsCode.BracketLeft, KeyEvent.KEYCODE_LEFT_BRACKET],
+ [UIEventsCode.BracketRight, KeyEvent.KEYCODE_RIGHT_BRACKET],
+ [UIEventsCode.Comma, KeyEvent.KEYCODE_COMMA],
+ [UIEventsCode.Digit0, KeyEvent.KEYCODE_0],
+ [UIEventsCode.Digit1, KeyEvent.KEYCODE_1],
+ [UIEventsCode.Digit2, KeyEvent.KEYCODE_2],
+ [UIEventsCode.Digit3, KeyEvent.KEYCODE_3],
+ [UIEventsCode.Digit4, KeyEvent.KEYCODE_4],
+ [UIEventsCode.Digit5, KeyEvent.KEYCODE_5],
+ [UIEventsCode.Digit6, KeyEvent.KEYCODE_6],
+ [UIEventsCode.Digit7, KeyEvent.KEYCODE_7],
+ [UIEventsCode.Digit8, KeyEvent.KEYCODE_8],
+ [UIEventsCode.Digit9, KeyEvent.KEYCODE_9],
+ [UIEventsCode.Equal, KeyEvent.KEYCODE_EQUALS],
+ [UIEventsCode.IntlRo, KeyEvent.KEYCODE_RO],
+ [UIEventsCode.IntlYen, KeyEvent.KEYCODE_YEN],
+ [UIEventsCode.KeyA, KeyEvent.KEYCODE_A],
+ [UIEventsCode.KeyB, KeyEvent.KEYCODE_B],
+ [UIEventsCode.KeyC, KeyEvent.KEYCODE_C],
+ [UIEventsCode.KeyD, KeyEvent.KEYCODE_D],
+ [UIEventsCode.KeyE, KeyEvent.KEYCODE_E],
+ [UIEventsCode.KeyF, KeyEvent.KEYCODE_F],
+ [UIEventsCode.KeyG, KeyEvent.KEYCODE_G],
+ [UIEventsCode.KeyH, KeyEvent.KEYCODE_H],
+ [UIEventsCode.KeyI, KeyEvent.KEYCODE_I],
+ [UIEventsCode.KeyJ, KeyEvent.KEYCODE_J],
+ [UIEventsCode.KeyK, KeyEvent.KEYCODE_K],
+ [UIEventsCode.KeyL, KeyEvent.KEYCODE_L],
+ [UIEventsCode.KeyM, KeyEvent.KEYCODE_M],
+ [UIEventsCode.KeyN, KeyEvent.KEYCODE_N],
+ [UIEventsCode.KeyO, KeyEvent.KEYCODE_O],
+ [UIEventsCode.KeyP, KeyEvent.KEYCODE_P],
+ [UIEventsCode.KeyQ, KeyEvent.KEYCODE_Q],
+ [UIEventsCode.KeyR, KeyEvent.KEYCODE_R],
+ [UIEventsCode.KeyS, KeyEvent.KEYCODE_S],
+ [UIEventsCode.KeyT, KeyEvent.KEYCODE_T],
+ [UIEventsCode.KeyU, KeyEvent.KEYCODE_U],
+ [UIEventsCode.KeyV, KeyEvent.KEYCODE_V],
+ [UIEventsCode.KeyW, KeyEvent.KEYCODE_W],
+ [UIEventsCode.KeyX, KeyEvent.KEYCODE_X],
+ [UIEventsCode.KeyY, KeyEvent.KEYCODE_Y],
+ [UIEventsCode.KeyZ, KeyEvent.KEYCODE_Z],
+ [UIEventsCode.Minus, KeyEvent.KEYCODE_MINUS],
+ [UIEventsCode.Period, KeyEvent.KEYCODE_PERIOD],
+ [UIEventsCode.Quote, KeyEvent.KEYCODE_APOSTROPHE],
+ [UIEventsCode.Semicolon, KeyEvent.KEYCODE_SEMICOLON],
+ [UIEventsCode.Slash, KeyEvent.KEYCODE_SLASH],
+ [UIEventsCode.KanaMode, KeyEvent.KEYCODE_KANA],
+ [UIEventsCode.Delete, KeyEvent.KEYCODE_FORWARD_DEL],
+ [UIEventsCode.End, KeyEvent.KEYCODE_MOVE_END],
+ [UIEventsCode.Help, KeyEvent.KEYCODE_HELP],
+ [UIEventsCode.Home, KeyEvent.KEYCODE_MOVE_HOME],
+ [UIEventsCode.Insert, KeyEvent.KEYCODE_INSERT],
+ [UIEventsCode.PageDown, KeyEvent.KEYCODE_PAGE_DOWN],
+ [UIEventsCode.PageUp, KeyEvent.KEYCODE_PAGE_UP],
+ [UIEventsCode.AltLeft, KeyEvent.KEYCODE_ALT_LEFT],
+ [UIEventsCode.AltRight, KeyEvent.KEYCODE_ALT_RIGHT],
+ [UIEventsCode.Backspace, KeyEvent.KEYCODE_DEL],
+ [UIEventsCode.CapsLock, KeyEvent.KEYCODE_CAPS_LOCK],
+ [UIEventsCode.ControlLeft, KeyEvent.KEYCODE_CTRL_LEFT],
+ [UIEventsCode.ControlRight, KeyEvent.KEYCODE_CTRL_RIGHT],
+ [UIEventsCode.Enter, KeyEvent.KEYCODE_ENTER],
+ [UIEventsCode.MetaLeft, KeyEvent.KEYCODE_META_LEFT],
+ [UIEventsCode.MetaRight, KeyEvent.KEYCODE_META_RIGHT],
+ [UIEventsCode.ShiftLeft, KeyEvent.KEYCODE_SHIFT_LEFT],
+ [UIEventsCode.ShiftRight, KeyEvent.KEYCODE_SHIFT_RIGHT],
+ [UIEventsCode.Space, KeyEvent.KEYCODE_SPACE],
+ [UIEventsCode.Tab, KeyEvent.KEYCODE_TAB],
+ [UIEventsCode.ArrowLeft, KeyEvent.KEYCODE_DPAD_LEFT],
+ [UIEventsCode.ArrowUp, KeyEvent.KEYCODE_DPAD_UP],
+ [UIEventsCode.ArrowRight, KeyEvent.KEYCODE_DPAD_RIGHT],
+ [UIEventsCode.ArrowDown, KeyEvent.KEYCODE_DPAD_DOWN],
+
+ [UIEventsCode.NumLock, KeyEvent.KEYCODE_NUM_LOCK],
+ [UIEventsCode.Numpad0, KeyEvent.KEYCODE_NUMPAD_0],
+ [UIEventsCode.Numpad1, KeyEvent.KEYCODE_NUMPAD_1],
+ [UIEventsCode.Numpad2, KeyEvent.KEYCODE_NUMPAD_2],
+ [UIEventsCode.Numpad3, KeyEvent.KEYCODE_NUMPAD_3],
+ [UIEventsCode.Numpad4, KeyEvent.KEYCODE_NUMPAD_4],
+ [UIEventsCode.Numpad5, KeyEvent.KEYCODE_NUMPAD_5],
+ [UIEventsCode.Numpad6, KeyEvent.KEYCODE_NUMPAD_6],
+ [UIEventsCode.Numpad7, KeyEvent.KEYCODE_NUMPAD_7],
+ [UIEventsCode.Numpad8, KeyEvent.KEYCODE_NUMPAD_8],
+ [UIEventsCode.Numpad9, KeyEvent.KEYCODE_NUMPAD_9],
+ [UIEventsCode.NumpadAdd, KeyEvent.KEYCODE_NUMPAD_ADD],
+ [UIEventsCode.NumpadComma, KeyEvent.KEYCODE_NUMPAD_COMMA],
+ [UIEventsCode.NumpadDecimal, KeyEvent.KEYCODE_NUMPAD_DOT],
+ [UIEventsCode.NumpadDivide, KeyEvent.KEYCODE_NUMPAD_DIVIDE],
+ [UIEventsCode.NumpadEnter, KeyEvent.KEYCODE_NUMPAD_ENTER],
+ [UIEventsCode.NumpadEqual, KeyEvent.KEYCODE_NUMPAD_EQUALS],
+ [UIEventsCode.NumpadMultiply, KeyEvent.KEYCODE_NUMPAD_MULTIPLY],
+ [UIEventsCode.NumpadParenLeft, KeyEvent.KEYCODE_NUMPAD_LEFT_PAREN],
+ [UIEventsCode.NumpadParenRight, KeyEvent.KEYCODE_NUMPAD_RIGHT_PAREN],
+ [UIEventsCode.NumpadSubtract, KeyEvent.KEYCODE_NUMPAD_SUBTRACT],
+
+ [UIEventsCode.Escape, KeyEvent.KEYCODE_ESCAPE],
+ [UIEventsCode.F1, KeyEvent.KEYCODE_F1],
+ [UIEventsCode.F2, KeyEvent.KEYCODE_F2],
+ [UIEventsCode.F3, KeyEvent.KEYCODE_F3],
+ [UIEventsCode.F4, KeyEvent.KEYCODE_F4],
+ [UIEventsCode.F5, KeyEvent.KEYCODE_F5],
+ [UIEventsCode.F6, KeyEvent.KEYCODE_F6],
+ [UIEventsCode.F7, KeyEvent.KEYCODE_F7],
+ [UIEventsCode.F8, KeyEvent.KEYCODE_F8],
+ [UIEventsCode.F9, KeyEvent.KEYCODE_F9],
+ [UIEventsCode.F10, KeyEvent.KEYCODE_F10],
+ [UIEventsCode.F11, KeyEvent.KEYCODE_F11],
+ [UIEventsCode.F12, KeyEvent.KEYCODE_F12],
+ [UIEventsCode.Fn, KeyEvent.KEYCODE_FUNCTION],
+ [UIEventsCode.PrintScreen, KeyEvent.KEYCODE_SYSRQ],
+ [UIEventsCode.Pause, KeyEvent.KEYCODE_BREAK],
+]);
diff --git a/src/app/googDevice/Stats.ts b/src/app/googDevice/Stats.ts
new file mode 100644
index 0000000..01ba124
--- /dev/null
+++ b/src/app/googDevice/Stats.ts
@@ -0,0 +1,67 @@
+export class Stats {
+ // The following constant were extracted from `man 2 stat` on Ubuntu 12.10.
+ public static S_IFMT = 0o170000; // bit mask for the file type bit fields
+
+ public static S_IFSOCK = 0o140000; // socket
+
+ public static S_IFLNK = 0o120000; // symbolic link
+
+ public static S_IFREG = 0o100000; // regular file
+
+ public static S_IFBLK = 0o060000; // block device
+
+ public static S_IFDIR = 0o040000; // directory
+
+ public static S_IFCHR = 0o020000; // character device
+
+ public static S_IFIFO = 0o010000; // FIFO
+
+ public static S_ISUID = 0o004000; // set UID bit
+
+ public static S_ISGID = 0o002000; // set-group-ID bit (see below)
+
+ public static S_ISVTX = 0o001000; // sticky bit (see below)
+
+ public static S_IRWXU = 0o0700; // mask for file owner permissions
+
+ public static S_IRUSR = 0o0400; // owner has read permission
+
+ public static S_IWUSR = 0o0200; // owner has write permission
+
+ public static S_IXUSR = 0o0100; // owner has execute permission
+
+ public static S_IRWXG = 0o0070; // mask for group permissions
+
+ public static S_IRGRP = 0o0040; // group has read permission
+
+ public readonly mtime: Date;
+
+ constructor(public readonly mode: number, public readonly size: number, mtime: number) {
+ this.mtime = new Date(mtime * 1000);
+ }
+
+ private checkModeProperty(property: number): boolean {
+ return (this.mode & Stats.S_IFMT) === property;
+ }
+ public isBlockDevice(): boolean {
+ return this.checkModeProperty(Stats.S_IFBLK);
+ }
+ public isCharacterDevice(): boolean {
+ return this.checkModeProperty(Stats.S_IFCHR);
+ }
+ public isDirectory(): boolean {
+ return this.checkModeProperty(Stats.S_IFDIR);
+ }
+ public isFIFO(): boolean {
+ return this.checkModeProperty(Stats.S_IFIFO);
+ }
+ public isisSocket(): boolean {
+ return this.checkModeProperty(Stats.S_IFSOCK);
+ }
+ public isSymbolicLink(): boolean {
+ return this.checkModeProperty(Stats.S_IFLNK);
+ }
+ public isFile(): boolean {
+ return this.checkModeProperty(Stats.S_IFREG);
+ }
+}
diff --git a/src/app/googDevice/android/KeyEvent.ts b/src/app/googDevice/android/KeyEvent.ts
new file mode 100644
index 0000000..c8be54d
--- /dev/null
+++ b/src/app/googDevice/android/KeyEvent.ts
@@ -0,0 +1,317 @@
+export default class KeyEvent {
+ // 定义按键常量
+ public static readonly ACTION_DOWN: number = 0;
+ public static readonly ACTION_UP: number = 1;
+
+ public static readonly KEYCODE_0: number = 7;
+ public static readonly KEYCODE_1: number = 8;
+ public static readonly KEYCODE_11: number = 227;
+ public static readonly KEYCODE_12: number = 228;
+ public static readonly KEYCODE_2: number = 9;
+ public static readonly KEYCODE_3: number = 10;
+ public static readonly KEYCODE_3D_MODE: number = 206;
+ public static readonly KEYCODE_4: number = 11;
+ public static readonly KEYCODE_5: number = 12;
+ public static readonly KEYCODE_6: number = 13;
+ public static readonly KEYCODE_7: number = 14;
+ public static readonly KEYCODE_8: number = 15;
+ public static readonly KEYCODE_9: number = 16;
+ public static readonly KEYCODE_A: number = 29;
+ public static readonly KEYCODE_ALL_APPS: number = 284;
+ public static readonly KEYCODE_ALT_LEFT: number = 57;
+ public static readonly KEYCODE_ALT_RIGHT: number = 58;
+ public static readonly KEYCODE_APOSTROPHE: number = 75;
+ public static readonly KEYCODE_APP_SWITCH: number = 187;
+ public static readonly KEYCODE_ASSIST: number = 219;
+ public static readonly KEYCODE_AT: number = 77;
+ public static readonly KEYCODE_AVR_INPUT: number = 182;
+ public static readonly KEYCODE_AVR_POWER: number = 181;
+ public static readonly KEYCODE_B: number = 30;
+ public static readonly KEYCODE_BACK: number = 4;
+ public static readonly KEYCODE_BACKSLASH: number = 73;
+ public static readonly KEYCODE_BOOKMARK: number = 174;
+ public static readonly KEYCODE_BREAK: number = 121;
+ public static readonly KEYCODE_BRIGHTNESS_DOWN: number = 220;
+ public static readonly KEYCODE_BRIGHTNESS_UP: number = 221;
+ public static readonly KEYCODE_BUTTON_1: number = 188;
+ public static readonly KEYCODE_BUTTON_10: number = 197;
+ public static readonly KEYCODE_BUTTON_11: number = 198;
+ public static readonly KEYCODE_BUTTON_12: number = 199;
+ public static readonly KEYCODE_BUTTON_13: number = 200;
+ public static readonly KEYCODE_BUTTON_14: number = 201;
+ public static readonly KEYCODE_BUTTON_15: number = 202;
+ public static readonly KEYCODE_BUTTON_16: number = 203;
+ public static readonly KEYCODE_BUTTON_2: number = 189;
+ public static readonly KEYCODE_BUTTON_3: number = 190;
+ public static readonly KEYCODE_BUTTON_4: number = 191;
+ public static readonly KEYCODE_BUTTON_5: number = 192;
+ public static readonly KEYCODE_BUTTON_6: number = 193;
+ public static readonly KEYCODE_BUTTON_7: number = 194;
+ public static readonly KEYCODE_BUTTON_8: number = 195;
+ public static readonly KEYCODE_BUTTON_9: number = 196;
+ public static readonly KEYCODE_BUTTON_A: number = 96;
+ public static readonly KEYCODE_BUTTON_B: number = 97;
+ public static readonly KEYCODE_BUTTON_C: number = 98;
+ public static readonly KEYCODE_BUTTON_L1: number = 102;
+ public static readonly KEYCODE_BUTTON_L2: number = 104;
+ public static readonly KEYCODE_BUTTON_MODE: number = 110;
+ public static readonly KEYCODE_BUTTON_R1: number = 103;
+ public static readonly KEYCODE_BUTTON_R2: number = 105;
+ public static readonly KEYCODE_BUTTON_SELECT: number = 109;
+ public static readonly KEYCODE_BUTTON_START: number = 108;
+ public static readonly KEYCODE_BUTTON_THUMBL: number = 106;
+ public static readonly KEYCODE_BUTTON_THUMBR: number = 107;
+ public static readonly KEYCODE_BUTTON_X: number = 99;
+ public static readonly KEYCODE_BUTTON_Y: number = 100;
+ public static readonly KEYCODE_BUTTON_Z: number = 101;
+ public static readonly KEYCODE_C: number = 31;
+ public static readonly KEYCODE_CALCULATOR: number = 210;
+ public static readonly KEYCODE_CALENDAR: number = 208;
+ public static readonly KEYCODE_CALL: number = 5;
+ public static readonly KEYCODE_CAMERA: number = 27;
+ public static readonly KEYCODE_CAPS_LOCK: number = 115;
+ public static readonly KEYCODE_CAPTIONS: number = 175;
+ public static readonly KEYCODE_CHANNEL_DOWN: number = 167;
+ public static readonly KEYCODE_CHANNEL_UP: number = 166;
+ public static readonly KEYCODE_CLEAR: number = 28;
+ public static readonly KEYCODE_COMMA: number = 55;
+ public static readonly KEYCODE_CONTACTS: number = 207;
+ public static readonly KEYCODE_COPY: number = 278;
+ public static readonly KEYCODE_CTRL_LEFT: number = 113;
+ public static readonly KEYCODE_CTRL_RIGHT: number = 114;
+ public static readonly KEYCODE_CUT: number = 277;
+ public static readonly KEYCODE_D: number = 32;
+ public static readonly KEYCODE_DEL: number = 67;
+ public static readonly KEYCODE_DPAD_CENTER: number = 23;
+ public static readonly KEYCODE_DPAD_DOWN: number = 20;
+ public static readonly KEYCODE_DPAD_DOWN_LEFT: number = 269;
+ public static readonly KEYCODE_DPAD_DOWN_RIGHT: number = 271;
+ public static readonly KEYCODE_DPAD_LEFT: number = 21;
+ public static readonly KEYCODE_DPAD_RIGHT: number = 22;
+ public static readonly KEYCODE_DPAD_UP: number = 19;
+ public static readonly KEYCODE_DPAD_UP_LEFT: number = 268;
+ public static readonly KEYCODE_DPAD_UP_RIGHT: number = 270;
+ public static readonly KEYCODE_DVR: number = 173;
+ public static readonly KEYCODE_E: number = 33;
+ public static readonly KEYCODE_EISU: number = 212;
+ public static readonly KEYCODE_ENDCALL: number = 6;
+ public static readonly KEYCODE_ENTER: number = 66;
+ public static readonly KEYCODE_ENVELOPE: number = 65;
+ public static readonly KEYCODE_EQUALS: number = 70;
+ public static readonly KEYCODE_ESCAPE: number = 111;
+ public static readonly KEYCODE_EXPLORER: number = 64;
+ public static readonly KEYCODE_F: number = 34;
+ public static readonly KEYCODE_F1: number = 131;
+ public static readonly KEYCODE_F10: number = 140;
+ public static readonly KEYCODE_F11: number = 141;
+ public static readonly KEYCODE_F12: number = 142;
+ public static readonly KEYCODE_F2: number = 132;
+ public static readonly KEYCODE_F3: number = 133;
+ public static readonly KEYCODE_F4: number = 134;
+ public static readonly KEYCODE_F5: number = 135;
+ public static readonly KEYCODE_F6: number = 136;
+ public static readonly KEYCODE_F7: number = 137;
+ public static readonly KEYCODE_F8: number = 138;
+ public static readonly KEYCODE_F9: number = 139;
+ public static readonly KEYCODE_FOCUS: number = 80;
+ public static readonly KEYCODE_FORWARD: number = 125;
+ public static readonly KEYCODE_FORWARD_DEL: number = 112;
+ public static readonly KEYCODE_FUNCTION: number = 119;
+ public static readonly KEYCODE_G: number = 35;
+ public static readonly KEYCODE_GRAVE: number = 68;
+ public static readonly KEYCODE_GUIDE: number = 172;
+ public static readonly KEYCODE_H: number = 36;
+ public static readonly KEYCODE_HEADSETHOOK: number = 79;
+ public static readonly KEYCODE_HELP: number = 259;
+ public static readonly KEYCODE_HENKAN: number = 214;
+ public static readonly KEYCODE_HOME: number = 3;
+ public static readonly KEYCODE_I: number = 37;
+ public static readonly KEYCODE_INFO: number = 165;
+ public static readonly KEYCODE_INSERT: number = 124;
+ public static readonly KEYCODE_J: number = 38;
+ public static readonly KEYCODE_K: number = 39;
+ public static readonly KEYCODE_KANA: number = 218;
+ public static readonly KEYCODE_KATAKANA_HIRAGANA: number = 215;
+ public static readonly KEYCODE_L: number = 40;
+ public static readonly KEYCODE_LANGUAGE_SWITCH: number = 204;
+ public static readonly KEYCODE_LAST_CHANNEL: number = 229;
+ public static readonly KEYCODE_LEFT_BRACKET: number = 71;
+ public static readonly KEYCODE_M: number = 41;
+ public static readonly KEYCODE_MANNER_MODE: number = 205;
+ public static readonly KEYCODE_MEDIA_AUDIO_TRACK: number = 222;
+ public static readonly KEYCODE_MEDIA_CLOSE: number = 128;
+ public static readonly KEYCODE_MEDIA_EJECT: number = 129;
+ public static readonly KEYCODE_MEDIA_FAST_FORWARD: number = 90;
+ public static readonly KEYCODE_MEDIA_NEXT: number = 87;
+ public static readonly KEYCODE_MEDIA_PAUSE: number = 127;
+ public static readonly KEYCODE_MEDIA_PLAY: number = 126;
+ public static readonly KEYCODE_MEDIA_PLAY_PAUSE: number = 85;
+ public static readonly KEYCODE_MEDIA_PREVIOUS: number = 88;
+ public static readonly KEYCODE_MEDIA_RECORD: number = 130;
+ public static readonly KEYCODE_MEDIA_REWIND: number = 89;
+ public static readonly KEYCODE_MEDIA_SKIP_BACKWARD: number = 273;
+ public static readonly KEYCODE_MEDIA_SKIP_FORWARD: number = 272;
+ public static readonly KEYCODE_MEDIA_STEP_BACKWARD: number = 275;
+ public static readonly KEYCODE_MEDIA_STEP_FORWARD: number = 274;
+ public static readonly KEYCODE_MEDIA_STOP: number = 86;
+ public static readonly KEYCODE_MEDIA_TOP_MENU: number = 226;
+ public static readonly KEYCODE_MENU: number = 82;
+ public static readonly KEYCODE_META_LEFT: number = 117;
+ public static readonly KEYCODE_META_RIGHT: number = 118;
+ public static readonly KEYCODE_MINUS: number = 69;
+ public static readonly KEYCODE_MOVE_END: number = 123;
+ public static readonly KEYCODE_MOVE_HOME: number = 122;
+ public static readonly KEYCODE_MUHENKAN: number = 213;
+ public static readonly KEYCODE_MUSIC: number = 209;
+ public static readonly KEYCODE_MUTE: number = 91;
+ public static readonly KEYCODE_N: number = 42;
+ public static readonly KEYCODE_NAVIGATE_IN: number = 262;
+ public static readonly KEYCODE_NAVIGATE_NEXT: number = 261;
+ public static readonly KEYCODE_NAVIGATE_OUT: number = 263;
+ public static readonly KEYCODE_NAVIGATE_PREVIOUS: number = 260;
+ public static readonly KEYCODE_NOTIFICATION: number = 83;
+ public static readonly KEYCODE_NUM: number = 78;
+ public static readonly KEYCODE_NUMPAD_0: number = 144;
+ public static readonly KEYCODE_NUMPAD_1: number = 145;
+ public static readonly KEYCODE_NUMPAD_2: number = 146;
+ public static readonly KEYCODE_NUMPAD_3: number = 147;
+ public static readonly KEYCODE_NUMPAD_4: number = 148;
+ public static readonly KEYCODE_NUMPAD_5: number = 149;
+ public static readonly KEYCODE_NUMPAD_6: number = 150;
+ public static readonly KEYCODE_NUMPAD_7: number = 151;
+ public static readonly KEYCODE_NUMPAD_8: number = 152;
+ public static readonly KEYCODE_NUMPAD_9: number = 153;
+ public static readonly KEYCODE_NUMPAD_ADD: number = 157;
+ public static readonly KEYCODE_NUMPAD_COMMA: number = 159;
+ public static readonly KEYCODE_NUMPAD_DIVIDE: number = 154;
+ public static readonly KEYCODE_NUMPAD_DOT: number = 158;
+ public static readonly KEYCODE_NUMPAD_ENTER: number = 160;
+ public static readonly KEYCODE_NUMPAD_EQUALS: number = 161;
+ public static readonly KEYCODE_NUMPAD_LEFT_PAREN: number = 162;
+ public static readonly KEYCODE_NUMPAD_MULTIPLY: number = 155;
+ public static readonly KEYCODE_NUMPAD_RIGHT_PAREN: number = 163;
+ public static readonly KEYCODE_NUMPAD_SUBTRACT: number = 156;
+ public static readonly KEYCODE_NUM_LOCK: number = 143;
+ public static readonly KEYCODE_O: number = 43;
+ public static readonly KEYCODE_P: number = 44;
+ public static readonly KEYCODE_PAGE_DOWN: number = 93;
+ public static readonly KEYCODE_PAGE_UP: number = 92;
+ public static readonly KEYCODE_PAIRING: number = 225;
+ public static readonly KEYCODE_PASTE: number = 279;
+ public static readonly KEYCODE_PERIOD: number = 56;
+ public static readonly KEYCODE_PICTSYMBOLS: number = 94;
+ public static readonly KEYCODE_PLUS: number = 81;
+ public static readonly KEYCODE_POUND: number = 18;
+ public static readonly KEYCODE_POWER: number = 26;
+ public static readonly KEYCODE_PROFILE_SWITCH: number = 288;
+ public static readonly KEYCODE_PROG_BLUE: number = 186;
+ public static readonly KEYCODE_PROG_GREEN: number = 184;
+ public static readonly KEYCODE_PROG_RED: number = 183;
+ public static readonly KEYCODE_PROG_YELLOW: number = 185;
+ public static readonly KEYCODE_Q: number = 45;
+ public static readonly KEYCODE_R: number = 46;
+ public static readonly KEYCODE_REFRESH: number = 285;
+ public static readonly KEYCODE_RIGHT_BRACKET: number = 72;
+ public static readonly KEYCODE_RO: number = 217;
+ public static readonly KEYCODE_S: number = 47;
+ public static readonly KEYCODE_SCROLL_LOCK: number = 116;
+ public static readonly KEYCODE_SEARCH: number = 84;
+ public static readonly KEYCODE_SEMICOLON: number = 74;
+ public static readonly KEYCODE_SETTINGS: number = 176;
+ public static readonly KEYCODE_SHIFT_LEFT: number = 59;
+ public static readonly KEYCODE_SHIFT_RIGHT: number = 60;
+ public static readonly KEYCODE_SLASH: number = 76;
+ public static readonly KEYCODE_SLEEP: number = 223;
+ public static readonly KEYCODE_SOFT_LEFT: number = 1;
+ public static readonly KEYCODE_SOFT_RIGHT: number = 2;
+ public static readonly KEYCODE_SOFT_SLEEP: number = 276;
+ public static readonly KEYCODE_SPACE: number = 62;
+ public static readonly KEYCODE_STAR: number = 17;
+ public static readonly KEYCODE_STB_INPUT: number = 180;
+ public static readonly KEYCODE_STB_POWER: number = 179;
+ public static readonly KEYCODE_STEM_1: number = 265;
+ public static readonly KEYCODE_STEM_2: number = 266;
+ public static readonly KEYCODE_STEM_3: number = 267;
+ public static readonly KEYCODE_STEM_PRIMARY: number = 264;
+ public static readonly KEYCODE_SWITCH_CHARSET: number = 95;
+ public static readonly KEYCODE_SYM: number = 63;
+ public static readonly KEYCODE_SYSRQ: number = 120;
+ public static readonly KEYCODE_SYSTEM_NAVIGATION_DOWN: number = 281;
+ public static readonly KEYCODE_SYSTEM_NAVIGATION_LEFT: number = 282;
+ public static readonly KEYCODE_SYSTEM_NAVIGATION_RIGHT: number = 283;
+ public static readonly KEYCODE_SYSTEM_NAVIGATION_UP: number = 280;
+ public static readonly KEYCODE_T: number = 48;
+ public static readonly KEYCODE_TAB: number = 61;
+ public static readonly KEYCODE_THUMBS_DOWN: number = 287;
+ public static readonly KEYCODE_THUMBS_UP: number = 286;
+ public static readonly KEYCODE_TV: number = 170;
+ public static readonly KEYCODE_TV_ANTENNA_CABLE: number = 242;
+ public static readonly KEYCODE_TV_AUDIO_DESCRIPTION: number = 252;
+ public static readonly KEYCODE_TV_AUDIO_DESCRIPTION_MIX_DOWN: number = 254;
+ public static readonly KEYCODE_TV_AUDIO_DESCRIPTION_MIX_UP: number = 253;
+ public static readonly KEYCODE_TV_CONTENTS_MENU: number = 256;
+ public static readonly KEYCODE_TV_DATA_SERVICE: number = 230;
+ public static readonly KEYCODE_TV_INPUT: number = 178;
+ public static readonly KEYCODE_TV_INPUT_COMPONENT_1: number = 249;
+ public static readonly KEYCODE_TV_INPUT_COMPONENT_2: number = 250;
+ public static readonly KEYCODE_TV_INPUT_COMPOSITE_1: number = 247;
+ public static readonly KEYCODE_TV_INPUT_COMPOSITE_2: number = 248;
+ public static readonly KEYCODE_TV_INPUT_HDMI_1: number = 243;
+ public static readonly KEYCODE_TV_INPUT_HDMI_2: number = 244;
+ public static readonly KEYCODE_TV_INPUT_HDMI_3: number = 245;
+ public static readonly KEYCODE_TV_INPUT_HDMI_4: number = 246;
+ public static readonly KEYCODE_TV_INPUT_VGA_1: number = 251;
+ public static readonly KEYCODE_TV_MEDIA_CONTEXT_MENU: number = 257;
+ public static readonly KEYCODE_TV_NETWORK: number = 241;
+ public static readonly KEYCODE_TV_NUMBER_ENTRY: number = 234;
+ public static readonly KEYCODE_TV_POWER: number = 177;
+ public static readonly KEYCODE_TV_RADIO_SERVICE: number = 232;
+ public static readonly KEYCODE_TV_SATELLITE: number = 237;
+ public static readonly KEYCODE_TV_SATELLITE_BS: number = 238;
+ public static readonly KEYCODE_TV_SATELLITE_CS: number = 239;
+ public static readonly KEYCODE_TV_SATELLITE_SERVICE: number = 240;
+ public static readonly KEYCODE_TV_TELETEXT: number = 233;
+ public static readonly KEYCODE_TV_TERRESTRIAL_ANALOG: number = 235;
+ public static readonly KEYCODE_TV_TERRESTRIAL_DIGITAL: number = 236;
+ public static readonly KEYCODE_TV_TIMER_PROGRAMMING: number = 258;
+ public static readonly KEYCODE_TV_ZOOM_MODE: number = 255;
+ public static readonly KEYCODE_U: number = 49;
+ public static readonly KEYCODE_UNKNOWN: number = 0;
+ public static readonly KEYCODE_V: number = 50;
+ public static readonly KEYCODE_VOICE_ASSIST: number = 231;
+ public static readonly KEYCODE_VOLUME_DOWN: number = 25;
+ public static readonly KEYCODE_VOLUME_MUTE: number = 164;
+ public static readonly KEYCODE_VOLUME_UP: number = 24;
+ public static readonly KEYCODE_W: number = 51;
+ public static readonly KEYCODE_WAKEUP: number = 224;
+ public static readonly KEYCODE_WINDOW: number = 171;
+ public static readonly KEYCODE_X: number = 52;
+ public static readonly KEYCODE_Y: number = 53;
+ public static readonly KEYCODE_YEN: number = 216;
+ public static readonly KEYCODE_Z: number = 54;
+ public static readonly KEYCODE_ZENKAKU_HANKAKU: number = 211;
+ public static readonly KEYCODE_ZOOM_IN: number = 168;
+ public static readonly KEYCODE_ZOOM_OUT: number = 169;
+
+ public static readonly META_ALT_LEFT_ON: number = 16;
+ public static readonly META_ALT_MASK: number = 50;
+ public static readonly META_ALT_ON: number = 2;
+ public static readonly META_ALT_RIGHT_ON: number = 32;
+ public static readonly META_CAPS_LOCK_ON: number = 1048576;
+ public static readonly META_CTRL_LEFT_ON: number = 8192;
+ public static readonly META_CTRL_MASK: number = 28672;
+ public static readonly META_CTRL_ON: number = 4096;
+ public static readonly META_CTRL_RIGHT_ON: number = 16384;
+ public static readonly META_FUNCTION_ON: number = 8;
+ public static readonly META_META_LEFT_ON: number = 131072;
+ public static readonly META_META_MASK: number = 458752;
+ public static readonly META_META_ON: number = 65536;
+ public static readonly META_META_RIGHT_ON: number = 262144;
+ public static readonly META_NUM_LOCK_ON: number = 2097152;
+ public static readonly META_SCROLL_LOCK_ON: number = 4194304;
+ public static readonly META_SHIFT_LEFT_ON: number = 64;
+ public static readonly META_SHIFT_MASK: number = 193;
+ public static readonly META_SHIFT_ON: number = 1;
+ public static readonly META_SHIFT_RIGHT_ON: number = 128;
+ public static readonly META_SYM_ON: number = 4;
+}
diff --git a/src/app/googDevice/android/MediaFormat.ts b/src/app/googDevice/android/MediaFormat.ts
new file mode 100644
index 0000000..0df3307
--- /dev/null
+++ b/src/app/googDevice/android/MediaFormat.ts
@@ -0,0 +1,935 @@
+export default class MediaFormat {
+ public static readonly MIMETYPE_VIDEO_VP8: string = 'video/x-vnd.on2.vp8';
+ public static readonly MIMETYPE_VIDEO_VP9: string = 'video/x-vnd.on2.vp9';
+ public static readonly MIMETYPE_VIDEO_AV1: string = 'video/av01';
+ public static readonly MIMETYPE_VIDEO_AVC: string = 'video/avc';
+ public static readonly MIMETYPE_VIDEO_HEVC: string = 'video/hevc';
+ public static readonly MIMETYPE_VIDEO_MPEG4: string = 'video/mp4v-es';
+ public static readonly MIMETYPE_VIDEO_H263: string = 'video/3gpp';
+ public static readonly MIMETYPE_VIDEO_MPEG2: string = 'video/mpeg2';
+ public static readonly MIMETYPE_VIDEO_RAW: string = 'video/raw';
+ public static readonly MIMETYPE_VIDEO_DOLBY_VISION: string = 'video/dolby-vision';
+ public static readonly MIMETYPE_VIDEO_SCRAMBLED: string = 'video/scrambled';
+
+ public static readonly MIMETYPE_AUDIO_AMR_NB: string = 'audio/3gpp';
+ public static readonly MIMETYPE_AUDIO_AMR_WB: string = 'audio/amr-wb';
+ public static readonly MIMETYPE_AUDIO_MPEG: string = 'audio/mpeg';
+ public static readonly MIMETYPE_AUDIO_AAC: string = 'audio/mp4a-latm';
+ public static readonly MIMETYPE_AUDIO_QCELP: string = 'audio/qcelp';
+ public static readonly MIMETYPE_AUDIO_VORBIS: string = 'audio/vorbis';
+ public static readonly MIMETYPE_AUDIO_OPUS: string = 'audio/opus';
+ public static readonly MIMETYPE_AUDIO_G711_ALAW: string = 'audio/g711-alaw';
+ public static readonly MIMETYPE_AUDIO_G711_MLAW: string = 'audio/g711-mlaw';
+ public static readonly MIMETYPE_AUDIO_RAW: string = 'audio/raw';
+ public static readonly MIMETYPE_AUDIO_FLAC: string = 'audio/flac';
+ public static readonly MIMETYPE_AUDIO_MSGSM: string = 'audio/gsm';
+ public static readonly MIMETYPE_AUDIO_AC3: string = 'audio/ac3';
+ public static readonly MIMETYPE_AUDIO_EAC3: string = 'audio/eac3';
+ public static readonly MIMETYPE_AUDIO_EAC3_JOC: string = 'audio/eac3-joc';
+ public static readonly MIMETYPE_AUDIO_AC4: string = 'audio/ac4';
+ public static readonly MIMETYPE_AUDIO_SCRAMBLED: string = 'audio/scrambled';
+
+ /**
+ * MIME type for HEIF still image data encoded in HEVC.
+ *
+ * To decode such an image, {@link MediaCodec} decoder for
+ * {@link #MIMETYPE_VIDEO_HEVC} shall be used. The client needs to form
+ * the correct {@link #MediaFormat} based on additional information in
+ * the track format, and send it to {@link MediaCodec#configure}.
+ *
+ * The track's MediaFormat will come with {@link #KEY_WIDTH} and
+ * {@link #KEY_HEIGHT} keys, which describes the width and height
+ * of the image. If the image doesn't contain grid (i.e. none of
+ * {@link #KEY_TILE_WIDTH}, {@link #KEY_TILE_HEIGHT},
+ * {@link #KEY_GRID_ROWS}, {@link #KEY_GRID_COLUMNS} are present}), the
+ * track will contain a single sample of coded data for the entire image,
+ * and the image width and height should be used to set up the decoder.
+ *
+ * If the image does come with grid, each sample from the track will
+ * contain one tile in the grid, of which the size is described by
+ * {@link #KEY_TILE_WIDTH} and {@link #KEY_TILE_HEIGHT}. This size
+ * (instead of {@link #KEY_WIDTH} and {@link #KEY_HEIGHT}) should be
+ * used to set up the decoder. The track contains {@link #KEY_GRID_ROWS}
+ * by {@link #KEY_GRID_COLUMNS} samples in row-major, top-row first,
+ * left-to-right order. The output image should be reconstructed by
+ * first tiling the decoding results of the tiles in the correct order,
+ * then trimming (before rotation is applied) on the bottom and right
+ * side, if the tiled area is larger than the image width and height.
+ */
+ public static readonly MIMETYPE_IMAGE_ANDROID_HEIC: string = 'image/vnd.android.heic';
+
+ /**
+ * MIME type for WebVTT subtitle data.
+ */
+ public static readonly MIMETYPE_TEXT_VTT: string = 'text/vtt';
+
+ /**
+ * MIME type for SubRip (SRT) container.
+ */
+ public static readonly MIMETYPE_TEXT_SUBRIP: string = 'application/x-subrip';
+
+ /**
+ * MIME type for CEA-608 closed caption data.
+ */
+ public static readonly MIMETYPE_TEXT_CEA_608: string = 'text/cea-608';
+
+ /**
+ * MIME type for CEA-708 closed caption data.
+ */
+ public static readonly MIMETYPE_TEXT_CEA_708: string = 'text/cea-708';
+
+ // private mMap: Map = new Map();
+
+ /**
+ * A key describing the mime type of the MediaFormat.
+ * The associated value is a string.
+ */
+ public static readonly KEY_MIME: string = 'mime';
+
+ /**
+ * A key describing the language of the content, using either ISO 639-1
+ * or 639-2/T codes. The associated value is a string.
+ */
+ public static readonly KEY_LANGUAGE: string = 'language';
+
+ /**
+ * A key describing the sample rate of an audio format.
+ * The associated value is an integer
+ */
+ public static readonly KEY_SAMPLE_RATE: string = 'sample-rate';
+
+ /**
+ * A key describing the number of channels in an audio format.
+ * The associated value is an integer
+ */
+ public static readonly KEY_CHANNEL_COUNT: string = 'channel-count';
+
+ /**
+ * A key describing the width of the content in a video format.
+ * The associated value is an integer
+ */
+ public static readonly KEY_WIDTH: string = 'width';
+
+ /**
+ * A key describing the height of the content in a video format.
+ * The associated value is an integer
+ */
+ public static readonly KEY_HEIGHT: string = 'height';
+
+ /**
+ * A key describing the maximum expected width of the content in a video
+ * decoder format, in case there are resolution changes in the video content.
+ * The associated value is an integer
+ */
+ public static readonly KEY_MAX_WIDTH: string = 'max-width';
+
+ /**
+ * A key describing the maximum expected height of the content in a video
+ * decoder format, in case there are resolution changes in the video content.
+ * The associated value is an integer
+ */
+ public static readonly KEY_MAX_HEIGHT: string = 'max-height';
+
+ /** A key describing the maximum size in bytes of a buffer of data
+ * described by this MediaFormat.
+ * The associated value is an integer
+ */
+ public static readonly KEY_MAX_INPUT_SIZE: string = 'max-input-size';
+
+ /**
+ * A key describing the average bitrate in bits/sec.
+ * The associated value is an integer
+ */
+ public static readonly KEY_BIT_RATE: string = 'bitrate';
+
+ /**
+ * A key describing the max bitrate in bits/sec.
+ * This is usually over a one-second sliding window (e.g. over any window of one second).
+ * The associated value is an integer
+ * @hide
+ */
+ public static readonly KEY_MAX_BIT_RATE: string = 'max-bitrate';
+
+ /**
+ * A key describing the color format of the content in a video format.
+ * Constants are declared in {@link android.media.MediaCodecInfo.CodecCapabilities}.
+ */
+ public static readonly KEY_COLOR_FORMAT: string = 'color-format';
+
+ /**
+ * A key describing the frame rate of a video format in frames/sec.
+ * The associated value is normally an integer when the value is used by the platform,
+ * but video codecs also accept float configuration values.
+ * Specifically, {@link MediaExtractor#getTrackFormat MediaExtractor} provides an integer
+ * value corresponding to the frame rate information of the track if specified and non-zero.
+ * Otherwise, this key is not present. {@link MediaCodec#configure MediaCodec} accepts both
+ * float and integer values. This represents the desired operating frame rate if the
+ * {@link #KEY_OPERATING_RATE} is not present and {@link #KEY_PRIORITY} is {@code 0}
+ * (realtime). For video encoders this value corresponds to the intended frame rate,
+ * although encoders are expected
+ * to support variable frame rate based on {@link MediaCodec.BufferInfo#presentationTimeUs
+ * buffer timestamp}. This key is not used in the {@code MediaCodec}
+ * {@link MediaCodec#getInputFormat input}/{@link MediaCodec#getOutputFormat output} formats,
+ * nor by {@link MediaMuxer#addTrack MediaMuxer}.
+ */
+ public static readonly KEY_FRAME_RATE: string = 'frame-rate';
+
+ /**
+ * A key describing the width (in pixels) of each tile of the content in a
+ * {@link #MIMETYPE_IMAGE_ANDROID_HEIC} track. The associated value is an integer.
+ *
+ * Refer to {@link #MIMETYPE_IMAGE_ANDROID_HEIC} on decoding instructions of such tracks.
+ *
+ * @see #KEY_TILE_HEIGHT
+ * @see #KEY_GRID_ROWS
+ * @see #KEY_GRID_COLUMNS
+ */
+ public static readonly KEY_TILE_WIDTH: string = 'tile-width';
+
+ /**
+ * A key describing the height (in pixels) of each tile of the content in a
+ * {@link #MIMETYPE_IMAGE_ANDROID_HEIC} track. The associated value is an integer.
+ *
+ * Refer to {@link #MIMETYPE_IMAGE_ANDROID_HEIC} on decoding instructions of such tracks.
+ *
+ * @see #KEY_TILE_WIDTH
+ * @see #KEY_GRID_ROWS
+ * @see #KEY_GRID_COLUMNS
+ */
+ public static readonly KEY_TILE_HEIGHT: string = 'tile-height';
+
+ /**
+ * A key describing the number of grid rows in the content in a
+ * {@link #MIMETYPE_IMAGE_ANDROID_HEIC} track. The associated value is an integer.
+ *
+ * Refer to {@link #MIMETYPE_IMAGE_ANDROID_HEIC} on decoding instructions of such tracks.
+ *
+ * @see #KEY_TILE_WIDTH
+ * @see #KEY_TILE_HEIGHT
+ * @see #KEY_GRID_COLUMNS
+ */
+ public static readonly KEY_GRID_ROWS: string = 'grid-rows';
+
+ /**
+ * A key describing the number of grid columns in the content in a
+ * {@link #MIMETYPE_IMAGE_ANDROID_HEIC} track. The associated value is an integer.
+ *
+ * Refer to {@link #MIMETYPE_IMAGE_ANDROID_HEIC} on decoding instructions of such tracks.
+ *
+ * @see #KEY_TILE_WIDTH
+ * @see #KEY_TILE_HEIGHT
+ * @see #KEY_GRID_ROWS
+ */
+ public static readonly KEY_GRID_COLUMNS: string = 'grid-cols';
+
+ /**
+ * A key describing the raw audio sample encoding/format.
+ *
+ * The associated value is an integer, using one of the
+ * {@link AudioFormat}.ENCODING_PCM_ values.
+ *
+ * This is an optional key for audio decoders and encoders specifying the
+ * desired raw audio sample format during {@link MediaCodec#configure
+ * MediaCodec.configure(…)} call. Use {@link MediaCodec#getInputFormat
+ * MediaCodec.getInput}/{@link MediaCodec#getOutputFormat OutputFormat(…)}
+ * to confirm the actual format. For the PCM decoder this key specifies both
+ * input and output sample encodings.
+ *
+ * This key is also used by {@link MediaExtractor} to specify the sample
+ * format of audio data, if it is specified.
+ *
+ * If this key is missing, the raw audio sample format is signed 16-bit short.
+ */
+ public static readonly KEY_PCM_ENCODING: string = 'pcm-encoding';
+
+ /**
+ * A key describing the capture rate of a video format in frames/sec.
+ *
+ * When capture rate is different than the frame rate, it means that the
+ * video is acquired at a different rate than the playback, which produces
+ * slow motion or timelapse effect during playback. Application can use the
+ * value of this key to tell the relative speed ratio between capture and
+ * playback rates when the video was recorded.
+ *
+ *
+ * The associated value is an integer or a float.
+ *
+ */
+ public static readonly KEY_CAPTURE_RATE: string = 'capture-rate';
+
+ /**
+ * A key describing the frequency of key frames expressed in seconds between key frames.
+ *
+ * This key is used by video encoders.
+ * A negative value means no key frames are requested after the first frame.
+ * A zero value means a stream containing all key frames is requested.
+ *
+ * Most video encoders will convert this value of the number of non-key-frames between
+ * key-frames, using the {@linkplain #KEY_FRAME_RATE frame rate} information; therefore,
+ * if the actual frame rate differs (e.g. input frames are dropped or the frame rate
+ * changes), the time interval between key frames will not be the
+ * configured value.
+ *
+ * The associated value is an integer (or float since
+ * {@link android.os.Build.VERSION_CODES#N_MR1}).
+ */
+ public static readonly KEY_I_FRAME_INTERVAL: string = 'i-frame-interval';
+
+ /**
+ * An optional key describing the period of intra refresh in frames. This is an
+ * optional parameter that applies only to video encoders. If encoder supports it
+ * ({@link MediaCodecInfo.CodecCapabilities#FEATURE_IntraRefresh}), the whole
+ * frame is completely refreshed after the specified period. Also for each frame,
+ * a fix subset of macroblocks must be intra coded which leads to more constant bitrate
+ * than inserting a key frame. This key is recommended for video streaming applications
+ * as it provides low-delay and good error-resilience. This key is ignored if the
+ * video encoder does not support the intra refresh feature. Use the output format to
+ * verify that this feature was enabled.
+ * The associated value is an integer.
+ */
+ public static readonly KEY_INTRA_REFRESH_PERIOD: string = 'intra-refresh-period';
+
+ /**
+ * An optional key describing whether encoders prepend headers to sync frames (e.g.
+ * SPS and PPS to IDR frames for H.264). This is an optional parameter that applies only
+ * to video encoders. A video encoder may not support this feature; the component will fail
+ * to configure in that case. For other components, this key is ignored.
+ *
+ * The value is an integer, with 1 indicating to prepend headers to every sync frames,
+ * or 0 otherwise. The default value is 0.
+ */
+ public static readonly KEY_PREPEND_HEADER_TO_SYNC_FRAMES: string = 'prepend-sps-pps-to-idr-frames';
+
+ /**
+ * A key describing the temporal layering schema. This is an optional parameter
+ * that applies only to video encoders. Use {@link MediaCodec#getOutputFormat}
+ * after {@link MediaCodec#configure configure} to query if the encoder supports
+ * the desired schema. Supported values are {@code webrtc.vp8.N-layer},
+ * {@code android.generic.N}, {@code android.generic.N+M} and {@code none}, where
+ * {@code N} denotes the total number of non-bidirectional layers (which must be at least 1)
+ * and {@code M} denotes the total number of bidirectional layers (which must be non-negative).
+ *
{@code android.generic.*} schemas have been added in {@link
+ * android.os.Build.VERSION_CODES#N_MR1}.
+ *
+ * The encoder may support fewer temporal layers, in which case the output format
+ * will contain the configured schema. If the encoder does not support temporal
+ * layering, the output format will not have an entry with this key.
+ * The associated value is a string.
+ */
+ public static readonly KEY_TEMPORAL_LAYERING: string = 'ts-schema';
+
+ /**
+ * A key describing the stride of the video bytebuffer layout.
+ * Stride (or row increment) is the difference between the index of a pixel
+ * and that of the pixel directly underneath. For YUV 420 formats, the
+ * stride corresponds to the Y plane; the stride of the U and V planes can
+ * be calculated based on the color format, though it is generally undefined
+ * and depends on the device and release.
+ * The associated value is an integer, representing number of bytes.
+ */
+ public static readonly KEY_STRIDE: string = 'stride';
+
+ /**
+ * A key describing the plane height of a multi-planar (YUV) video bytebuffer layout.
+ * Slice height (or plane height/vertical stride) is the number of rows that must be skipped
+ * to get from the top of the Y plane to the top of the U plane in the bytebuffer. In essence
+ * the offset of the U plane is sliceHeight * stride. The height of the U/V planes
+ * can be calculated based on the color format, though it is generally undefined
+ * and depends on the device and release.
+ * The associated value is an integer, representing number of rows.
+ */
+ public static readonly KEY_SLICE_HEIGHT: string = 'slice-height';
+
+ /**
+ * Applies only when configuring a video encoder in "surface-input" mode.
+ * The associated value is a long and gives the time in microseconds
+ * after which the frame previously submitted to the encoder will be
+ * repeated (once) if no new frame became available since.
+ */
+ public static readonly KEY_REPEAT_PREVIOUS_FRAME_AFTER: string = 'repeat-previous-frame-after';
+
+ /**
+ * Instruct the video encoder in "surface-input" mode to drop excessive
+ * frames from the source, so that the input frame rate to the encoder
+ * does not exceed the specified fps.
+ *
+ * The associated value is a float, representing the max frame rate to
+ * feed the encoder at.
+ *
+ */
+ public static readonly KEY_MAX_FPS_TO_ENCODER: string = 'max-fps-to-encoder';
+
+ /**
+ * Instruct the video encoder in "surface-input" mode to limit the gap of
+ * timestamp between any two adjacent frames fed to the encoder to the
+ * specified amount (in micro-second).
+ *
+ * The associated value is a long int. When positive, it represents the max
+ * timestamp gap between two adjacent frames fed to the encoder. When negative,
+ * the absolute value represents a fixed timestamp gap between any two adjacent
+ * frames fed to the encoder. Note that this will also apply even when the
+ * original timestamp goes backward in time. Under normal conditions, such frames
+ * would be dropped and not sent to the encoder.
+ *
+ * The output timestamp will be restored to the original timestamp and will
+ * not be affected.
+ *
+ * This is used in some special scenarios where input frames arrive sparingly
+ * but it's undesirable to allocate more bits to any single frame, or when it's
+ * important to ensure all frames are captured (rather than captured in the
+ * correct order).
+ *
+ */
+ public static readonly KEY_MAX_PTS_GAP_TO_ENCODER: string = 'max-pts-gap-to-encoder';
+
+ /**
+ * If specified when configuring a video encoder that's in "surface-input"
+ * mode, it will instruct the encoder to put the surface source in suspended
+ * state when it's connected. No video frames will be accepted until a resume
+ * operation (see {@link MediaCodec#PARAMETER_KEY_SUSPEND}), optionally with
+ * timestamp specified via {@link MediaCodec#PARAMETER_KEY_SUSPEND_TIME}, is
+ * received.
+ *
+ * The value is an integer, with 1 indicating to create with the surface
+ * source suspended, or 0 otherwise. The default value is 0.
+ *
+ * If this key is not set or set to 0, the surface source will accept buffers
+ * as soon as it's connected to the encoder (although they may not be encoded
+ * immediately). This key can be used when the client wants to prepare the
+ * encoder session in advance, but do not want to accept buffers immediately.
+ */
+ public static readonly KEY_CREATE_INPUT_SURFACE_SUSPENDED: string = 'create-input-buffers-suspended';
+
+ /**
+ * If specified when configuring a video decoder rendering to a surface,
+ * causes the decoder to output "blank", i.e. black frames to the surface
+ * when stopped to clear out any previously displayed contents.
+ * The associated value is an integer of value 1.
+ */
+ public static readonly KEY_PUSH_BLANK_BUFFERS_ON_STOP: string = 'push-blank-buffers-on-shutdown';
+
+ /**
+ * A key describing the duration (in microseconds) of the content.
+ * The associated value is a long.
+ */
+ public static readonly KEY_DURATION: string = 'durationUs';
+
+ /**
+ * A key mapping to a value of 1 if the content is AAC audio and
+ * audio frames are prefixed with an ADTS header.
+ * The associated value is an integer (0 or 1).
+ * This key is only supported when _decoding_ content, it cannot
+ * be used to configure an encoder to emit ADTS output.
+ */
+ public static readonly KEY_IS_ADTS: string = 'is-adts';
+
+ /**
+ * A key describing the channel composition of audio content. This mask
+ * is composed of bits drawn from channel mask definitions in {@link android.media.AudioFormat}.
+ * The associated value is an integer.
+ */
+ public static readonly KEY_CHANNEL_MASK: string = 'channel-mask';
+
+ /**
+ * A key describing the AAC profile to be used (AAC audio formats only).
+ * Constants are declared in {@link android.media.MediaCodecInfo.CodecProfileLevel}.
+ */
+ public static readonly KEY_AAC_PROFILE: string = 'aac-profile';
+
+ /**
+ * A key describing the AAC SBR mode to be used (AAC audio formats only).
+ * The associated value is an integer and can be set to following values:
+ *
+ * 0 - no SBR should be applied
+ * 1 - single rate SBR
+ * 2 - double rate SBR
+ *
+ * Note: If this key is not defined the default SRB mode for the desired AAC profile will
+ * be used.
+ * This key is only used during encoding.
+ */
+ public static readonly KEY_AAC_SBR_MODE: string = 'aac-sbr-mode';
+
+ /**
+ * A key describing the maximum number of channels that can be output by the AAC decoder.
+ * By default, the decoder will output the same number of channels as present in the encoded
+ * stream, if supported. Set this value to limit the number of output channels, and use
+ * the downmix information in the stream, if available.
+ *
Values larger than the number of channels in the content to decode are ignored.
+ *
This key is only used during decoding.
+ */
+ public static readonly KEY_AAC_MAX_OUTPUT_CHANNEL_COUNT: string = 'aac-max-output-channel_count';
+
+ /**
+ * A key describing a gain to be applied so that the output loudness matches the
+ * Target Reference Level. This is typically used to normalize loudness across program items.
+ * The gain is derived as the difference between the Target Reference Level and the
+ * Program Reference Level. The latter can be given in the bitstream and indicates the actual
+ * loudness value of the program item.
+ *
The Target Reference Level controls loudness normalization for both MPEG-4 DRC and
+ * MPEG-D DRC.
+ *
The value is given as an integer value between
+ * 40 and 127, and is calculated as -4 * Target Reference Level in LKFS.
+ * Therefore, it represents the range of -10 to -31.75 LKFS.
+ *
The default value on mobile devices is 64 (-16 LKFS).
+ *
This key is only used during decoding.
+ */
+ public static readonly KEY_AAC_DRC_TARGET_REFERENCE_LEVEL: string = 'aac-target-ref-level';
+
+ /**
+ * A key describing for selecting the DRC effect type for MPEG-D DRC.
+ * The supported values are defined in ISO/IEC 23003-4:2015 and are described as follows:
+ *
+ * Value Effect
+ * -1 Off
+ * 0 None
+ * 1 Late night
+ * 2 Noisy environment
+ * 3 Limited playback range
+ * 4 Low playback level
+ * 5 Dialog enhancement
+ * 6 General compression
+ *
+ * The value -1 (Off) disables DRC processing, while loudness normalization may still be
+ * active and dependent on KEY_AAC_DRC_TARGET_REFERENCE_LEVEL.
+ * The value 0 (None) automatically enables DRC processing if necessary to prevent signal
+ * clipping
+ * The value 6 (General compression) can be used for enabling MPEG-D DRC without particular
+ * DRC effect type request.
+ * The default DRC effect type is 3 ("Limited playback range") on mobile devices.
+ *
This key is only used during decoding.
+ */
+ public static readonly KEY_AAC_DRC_EFFECT_TYPE: string = 'aac-drc-effect-type';
+
+ /**
+ * A key describing the target reference level that was assumed at the encoder for
+ * calculation of attenuation gains for clipping prevention.
+ *
If it is known, this information can be provided as an integer value between
+ * 0 and 127, which is calculated as -4 * Encoded Target Level in LKFS.
+ * If the Encoded Target Level is unknown, the value can be set to -1.
+ *
The default value is -1 (unknown).
+ *
The value is ignored when heavy compression is used (see
+ * {@link #KEY_AAC_DRC_HEAVY_COMPRESSION}).
+ *
This key is only used during decoding.
+ */
+ public static readonly KEY_AAC_ENCODED_TARGET_LEVEL: string = 'aac-encoded-target-level';
+
+ /**
+ * A key describing the boost factor allowing to adapt the dynamics of the output to the
+ * actual listening requirements. This relies on DRC gain sequences that can be transmitted in
+ * the encoded bitstream to be able to reduce the dynamics of the output signal upon request.
+ * This factor enables the user to select how much of the gains are applied.
+ *
Positive gains (boost) and negative gains (attenuation, see
+ * {@link #KEY_AAC_DRC_ATTENUATION_FACTOR}) can be controlled separately for a better match
+ * to different use-cases.
+ *
Typically, attenuation gains are sent for loud signal segments, and boost gains are sent
+ * for soft signal segments. If the output is listened to in a noisy environment, for example,
+ * the boost factor is used to enable the positive gains, i.e. to amplify soft signal segments
+ * beyond the noise floor. But for listening late at night, the attenuation
+ * factor is used to enable the negative gains, to prevent loud signal from surprising
+ * the listener. In applications which generally need a low dynamic range, both the boost factor
+ * and the attenuation factor are used in order to enable all DRC gains.
+ *
In order to prevent clipping, it is also recommended to apply the attenuation gains
+ * in case of a downmix and/or loudness normalization to high target reference levels.
+ *
Both the boost and the attenuation factor parameters are given as integer values
+ * between 0 and 127, representing the range of the factor of 0 (i.e. don't apply)
+ * to 1 (i.e. fully apply boost/attenuation gains respectively).
+ *
The default value is 127 (fully apply boost DRC gains).
+ *
This key is only used during decoding.
+ */
+ public static readonly KEY_AAC_DRC_BOOST_FACTOR: string = 'aac-drc-boost-level';
+
+ /**
+ * A key describing the attenuation factor allowing to adapt the dynamics of the output to the
+ * actual listening requirements.
+ * See {@link #KEY_AAC_DRC_BOOST_FACTOR} for a description of the role of this attenuation
+ * factor and the value range.
+ *
The default value is 127 (fully apply attenuation DRC gains).
+ *
This key is only used during decoding.
+ */
+ public static readonly KEY_AAC_DRC_ATTENUATION_FACTOR: string = 'aac-drc-cut-level';
+
+ /**
+ * A key describing the selection of the heavy compression profile for DRC.
+ * Two separate DRC gain sequences can be transmitted in one bitstream: MPEG-4 DRC light
+ * compression, and DVB-specific heavy compression. When selecting the application of the heavy
+ * compression, one of the sequences is selected:
+ *
+ * 0 enables light compression,
+ * 1 enables heavy compression instead.
+ *
+ * Note that only light compression offers the features of scaling of DRC gains
+ * (see {@link #KEY_AAC_DRC_BOOST_FACTOR} and {@link #KEY_AAC_DRC_ATTENUATION_FACTOR} for the
+ * boost and attenuation factors, and frequency-selective (multiband) DRC.
+ * Light compression usually contains clipping prevention for stereo downmixing while heavy
+ * compression, if additionally provided in the bitstream, is usually stronger, and contains
+ * clipping prevention for stereo and mono downmixing.
+ * The default is 1 (heavy compression).
+ *
This key is only used during decoding.
+ */
+ public static readonly KEY_AAC_DRC_HEAVY_COMPRESSION: string = 'aac-drc-heavy-compression';
+
+ /**
+ * A key describing the FLAC compression level to be used (FLAC audio format only).
+ * The associated value is an integer ranging from 0 (fastest, least compression)
+ * to 8 (slowest, most compression).
+ */
+ public static readonly KEY_FLAC_COMPRESSION_LEVEL: string = 'flac-compression-level';
+
+ /**
+ * A key describing the encoding complexity.
+ * The associated value is an integer. These values are device and codec specific,
+ * but lower values generally result in faster and/or less power-hungry encoding.
+ *
+ * @see MediaCodecInfo.EncoderCapabilities#getComplexityRange()
+ */
+ public static readonly KEY_COMPLEXITY: string = 'complexity';
+
+ /**
+ * A key describing the desired encoding quality.
+ * The associated value is an integer. This key is only supported for encoders
+ * that are configured in constant-quality mode. These values are device and
+ * codec specific, but lower values generally result in more efficient
+ * (smaller-sized) encoding.
+ *
+ * @see MediaCodecInfo.EncoderCapabilities#getQualityRange()
+ */
+ public static readonly KEY_QUALITY: string = 'quality';
+
+ /**
+ * A key describing the desired codec priority.
+ *
+ * The associated value is an integer. Higher value means lower priority.
+ *
+ * Currently, only two levels are supported:
+ * 0: realtime priority - meaning that the codec shall support the given
+ * performance configuration (e.g. framerate) at realtime. This should
+ * only be used by media playback, capture, and possibly by realtime
+ * communication scenarios if best effort performance is not suitable.
+ * 1: non-realtime priority (best effort).
+ *
+ * This is a hint used at codec configuration and resource planning - to understand
+ * the realtime requirements of the application; however, due to the nature of
+ * media components, performance is not guaranteed.
+ *
+ */
+ public static readonly KEY_PRIORITY: string = 'priority';
+
+ /**
+ * A key describing the desired operating frame rate for video or sample rate for audio
+ * that the codec will need to operate at.
+ *
+ * The associated value is an integer or a float representing frames-per-second or
+ * samples-per-second
+ *
+ * This is used for cases like high-speed/slow-motion video capture, where the video encoder
+ * format contains the target playback rate (e.g. 30fps), but the component must be able to
+ * handle the high operating capture rate (e.g. 240fps).
+ *
+ * This rate will be used by codec for resource planning and setting the operating points.
+ *
+ */
+ public static readonly KEY_OPERATING_RATE: string = 'operating-rate';
+
+ /**
+ * A key describing the desired profile to be used by an encoder.
+ * The associated value is an integer.
+ * Constants are declared in {@link MediaCodecInfo.CodecProfileLevel}.
+ * This key is used as a hint, and is only supported for codecs
+ * that specify a profile. Note: Codecs are free to use all the available
+ * coding tools at the specified profile.
+ *
+ * @see MediaCodecInfo.CodecCapabilities#profileLevels
+ */
+ public static readonly KEY_PROFILE: string = 'profile';
+
+ /**
+ * A key describing the desired profile to be used by an encoder.
+ * The associated value is an integer.
+ * Constants are declared in {@link MediaCodecInfo.CodecProfileLevel}.
+ * This key is used as a further hint when specifying a desired profile,
+ * and is only supported for codecs that specify a level.
+ *
+ * This key is ignored if the {@link #KEY_PROFILE profile} is not specified.
+ *
+ * @see MediaCodecInfo.CodecCapabilities#profileLevels
+ */
+ public static readonly KEY_LEVEL: string = 'level';
+
+ /**
+ * An optional key describing the desired encoder latency in frames. This is an optional
+ * parameter that applies only to video encoders. If encoder supports it, it should ouput
+ * at least one output frame after being queued the specified number of frames. This key
+ * is ignored if the video encoder does not support the latency feature. Use the output
+ * format to verify that this feature was enabled and the actual value used by the encoder.
+ *
+ * If the key is not specified, the default latency will be implenmentation specific.
+ * The associated value is an integer.
+ */
+ public static readonly KEY_LATENCY: string = 'latency';
+
+ /**
+ * An optional key describing the maximum number of non-display-order coded frames.
+ * This is an optional parameter that applies only to video encoders. Application should
+ * check the value for this key in the output format to see if codec will produce
+ * non-display-order coded frames. If encoder supports it, the output frames' order will be
+ * different from the display order and each frame's display order could be retrived from
+ * {@link MediaCodec.BufferInfo#presentationTimeUs}. Before API level 27, application may
+ * receive non-display-order coded frames even though the application did not request it.
+ * Note: Application should not rearrange the frames to display order before feeding them
+ * to {@link MediaMuxer#writeSampleData}.
+ *
+ * The default value is 0.
+ */
+ public static readonly KEY_OUTPUT_REORDER_DEPTH: string = 'output-reorder-depth';
+
+ /**
+ * A key describing the desired clockwise rotation on an output surface.
+ * This key is only used when the codec is configured using an output surface.
+ * The associated value is an integer, representing degrees. Supported values
+ * are 0, 90, 180 or 270. This is an optional field; if not specified, rotation
+ * defaults to 0.
+ *
+ * @see MediaCodecInfo.CodecCapabilities#profileLevels
+ */
+ public static readonly KEY_ROTATION: string = 'rotation-degrees';
+
+ /**
+ * A key describing the desired bitrate mode to be used by an encoder.
+ * Constants are declared in {@link MediaCodecInfo.CodecCapabilities}.
+ *
+ * @see MediaCodecInfo.EncoderCapabilities#isBitrateModeSupported(int)
+ */
+ public static readonly KEY_BITRATE_MODE: string = 'bitrate-mode';
+
+ /**
+ * A key describing the audio session ID of the AudioTrack associated
+ * to a tunneled video codec.
+ * The associated value is an integer.
+ *
+ * @see MediaCodecInfo.CodecCapabilities#FEATURE_TunneledPlayback
+ */
+ public static readonly KEY_AUDIO_SESSION_ID: string = 'audio-session-id';
+
+ /**
+ * A key for boolean AUTOSELECT behavior for the track. Tracks with AUTOSELECT=true
+ * are considered when automatically selecting a track without specific user
+ * choice, based on the current locale.
+ * This is currently only used for subtitle tracks, when the user selected
+ * 'Default' for the captioning locale.
+ * The associated value is an integer, where non-0 means TRUE. This is an optional
+ * field; if not specified, AUTOSELECT defaults to TRUE.
+ */
+ public static readonly KEY_IS_AUTOSELECT: string = 'is-autoselect';
+
+ /**
+ * A key for boolean DEFAULT behavior for the track. The track with DEFAULT=true is
+ * selected in the absence of a specific user choice.
+ * This is currently used in two scenarios:
+ * 1) for subtitle tracks, when the user selected 'Default' for the captioning locale.
+ * 2) for a {@link #MIMETYPE_IMAGE_ANDROID_HEIC} track, indicating the image is the
+ * primary item in the file.
+
+ * The associated value is an integer, where non-0 means TRUE. This is an optional
+ * field; if not specified, DEFAULT is considered to be FALSE.
+ */
+ public static readonly KEY_IS_DEFAULT: string = 'is-default';
+
+ /**
+ * A key for the FORCED field for subtitle tracks. True if it is a
+ * forced subtitle track. Forced subtitle tracks are essential for the
+ * content and are shown even when the user turns off Captions. They
+ * are used for example to translate foreign/alien dialogs or signs.
+ * The associated value is an integer, where non-0 means TRUE. This is an
+ * optional field; if not specified, FORCED defaults to FALSE.
+ */
+ public static readonly KEY_IS_FORCED_SUBTITLE: string = 'is-forced-subtitle';
+
+ /**
+ * A key describing the number of haptic channels in an audio format.
+ * The associated value is an integer.
+ */
+ public static readonly KEY_HAPTIC_CHANNEL_COUNT: string = 'haptic-channel-count';
+
+ /** @hide */
+ public static readonly KEY_IS_TIMED_TEXT: string = 'is-timed-text';
+
+ // The following color aspect values must be in sync with the ones in HardwareAPI.h.
+ /**
+ * An optional key describing the color primaries, white point and
+ * luminance factors for video content.
+ *
+ * The associated value is an integer: 0 if unspecified, or one of the
+ * COLOR_STANDARD_ values.
+ */
+ public static readonly KEY_COLOR_STANDARD: string = 'color-standard';
+
+ /** BT.709 color chromacity coordinates with KR = 0.2126, KB = 0.0722. */
+ public static readonly COLOR_STANDARD_BT709: number = 1;
+
+ /** BT.601 625 color chromacity coordinates with KR = 0.299, KB = 0.114. */
+ public static readonly COLOR_STANDARD_BT601_PAL: number = 2;
+
+ /** BT.601 525 color chromacity coordinates with KR = 0.299, KB = 0.114. */
+ public static readonly COLOR_STANDARD_BT601_NTSC: number = 4;
+
+ /** BT.2020 color chromacity coordinates with KR = 0.2627, KB = 0.0593. */
+ public static readonly COLOR_STANDARD_BT2020: number = 6;
+
+ // /** @hide */
+ // @IntDef({
+ // COLOR_STANDARD_BT709,
+ // COLOR_STANDARD_BT601_PAL,
+ // COLOR_STANDARD_BT601_NTSC,
+ // COLOR_STANDARD_BT2020,
+ // })
+ // @Retention(RetentionPolicy.SOURCE)
+ // public @interface ColorStandard {}
+
+ /**
+ * An optional key describing the opto-electronic transfer function used
+ * for the video content.
+ *
+ * The associated value is an integer: 0 if unspecified, or one of the
+ * COLOR_TRANSFER_ values.
+ */
+ public static readonly KEY_COLOR_TRANSFER: string = 'color-transfer';
+
+ /** Linear transfer characteristic curve. */
+ public static readonly COLOR_TRANSFER_LINEAR: number = 1;
+
+ /** SMPTE 170M transfer characteristic curve used by BT.601/BT.709/BT.2020. This is the curve
+ * used by most non-HDR video content. */
+ public static readonly COLOR_TRANSFER_SDR_VIDEO: number = 3;
+
+ /** SMPTE ST 2084 transfer function. This is used by some HDR video content. */
+ public static readonly COLOR_TRANSFER_ST2084: number = 6;
+
+ /** ARIB STD-B67 hybrid-log-gamma transfer function. This is used by some HDR video content. */
+ public static readonly COLOR_TRANSFER_HLG: number = 7;
+
+ /** @hide */
+ // @IntDef({
+ // COLOR_TRANSFER_LINEAR,
+ // COLOR_TRANSFER_SDR_VIDEO,
+ // COLOR_TRANSFER_ST2084,
+ // COLOR_TRANSFER_HLG,
+ // })
+ // @Retention(RetentionPolicy.SOURCE)
+ // public @interface ColorTransfer {}
+
+ /**
+ * An optional key describing the range of the component values of the video content.
+ *
+ * The associated value is an integer: 0 if unspecified, or one of the
+ * COLOR_RANGE_ values.
+ */
+ public static readonly KEY_COLOR_RANGE: string = 'color-range';
+
+ /** Limited range. Y component values range from 16 to 235 for 8-bit content.
+ * Cr, Cy values range from 16 to 240 for 8-bit content.
+ * This is the default for video content. */
+ public static readonly COLOR_RANGE_LIMITED: number = 2;
+
+ /** Full range. Y, Cr and Cb component values range from 0 to 255 for 8-bit content. */
+ public static readonly COLOR_RANGE_FULL: number = 1;
+
+ // /** @hide */
+ // @IntDef({
+ // COLOR_RANGE_LIMITED,
+ // COLOR_RANGE_FULL,
+ // })
+ // @Retention(RetentionPolicy.SOURCE)
+ // public @interface ColorRange {}
+
+ /**
+ * An optional key describing the static metadata of HDR (high-dynamic-range) video content.
+ *
+ * The associated value is a ByteBuffer. This buffer contains the raw contents of the
+ * Static Metadata Descriptor (including the descriptor ID) of an HDMI Dynamic Range and
+ * Mastering InfoFrame as defined by CTA-861.3. This key must be provided to video decoders
+ * for HDR video content unless this information is contained in the bitstream and the video
+ * decoder supports an HDR-capable profile. This key must be provided to video encoders for
+ * HDR video content.
+ */
+ public static readonly KEY_HDR_STATIC_INFO: string = 'hdr-static-info';
+
+ /**
+ * An optional key describing the HDR10+ metadata of the video content.
+ *
+ * The associated value is a ByteBuffer containing HDR10+ metadata conforming to the
+ * user_data_registered_itu_t_t35() syntax of SEI message for ST 2094-40. This key will
+ * be present on:
+ *
+ * - The formats of output buffers of a decoder configured for HDR10+ profiles (such as
+ * {@link MediaCodecInfo.CodecProfileLevel#VP9Profile2HDR10Plus}, {@link
+ * MediaCodecInfo.CodecProfileLevel#VP9Profile3HDR10Plus} or {@link
+ * MediaCodecInfo.CodecProfileLevel#HEVCProfileMain10HDR10Plus}), or
+ *
+ * - The formats of output buffers of an encoder configured for an HDR10+ profiles that
+ * uses out-of-band metadata (such as {@link
+ * MediaCodecInfo.CodecProfileLevel#VP9Profile2HDR10Plus} or {@link
+ * MediaCodecInfo.CodecProfileLevel#VP9Profile3HDR10Plus}).
+ *
+ * @see MediaCodec#PARAMETER_KEY_HDR10_PLUS_INFO
+ */
+ public static readonly KEY_HDR10_PLUS_INFO: string = 'hdr10-plus-info';
+
+ /**
+ * A key describing a unique ID for the content of a media track.
+ *
+ *
This key is used by {@link MediaExtractor}. Some extractors provide multiple encodings
+ * of the same track (e.g. float audio tracks for FLAC and WAV may be expressed as two
+ * tracks via MediaExtractor: a normal PCM track for backward compatibility, and a float PCM
+ * track for added fidelity. Similarly, Dolby Vision extractor may provide a baseline SDR
+ * version of a DV track.) This key can be used to identify which MediaExtractor tracks refer
+ * to the same underlying content.
+ *
+ *
+ * The associated value is an integer.
+ */
+ public static readonly KEY_TRACK_ID: string = 'track-id';
+
+ /**
+ * A key describing the system id of the conditional access system used to scramble
+ * a media track.
+ *
+ * This key is set by {@link MediaExtractor} if the track is scrambled with a conditional
+ * access system, regardless of the presence of a valid {@link MediaCas} object.
+ *
+ * The associated value is an integer.
+ * @hide
+ */
+ public static readonly KEY_CA_SYSTEM_ID: string = 'ca-system-id';
+
+ /**
+ * A key describing the {@link MediaCas.Session} object associated with a media track.
+ *
+ * This key is set by {@link MediaExtractor} if the track is scrambled with a conditional
+ * access system, after it receives a valid {@link MediaCas} object.
+ *
+ * The associated value is a ByteBuffer.
+ * @hide
+ */
+ public static readonly KEY_CA_SESSION_ID: string = 'ca-session-id';
+
+ /**
+ * A key describing the private data in the CA_descriptor associated with a media track.
+ *
+ * This key is set by {@link MediaExtractor} if the track is scrambled with a conditional
+ * access system, before it receives a valid {@link MediaCas} object.
+ *
+ * The associated value is a ByteBuffer.
+ * @hide
+ */
+ public static readonly KEY_CA_PRIVATE_DATA: string = 'ca-private-data';
+
+ /**
+ * A key describing the maximum number of B frames between I or P frames,
+ * to be used by a video encoder.
+ * The associated value is an integer. The default value is 0, which means
+ * that no B frames are allowed. Note that non-zero value does not guarantee
+ * B frames; it's up to the encoder to decide.
+ */
+ public static readonly KEY_MAX_B_FRAMES: string = 'max-bframes';
+}
diff --git a/src/app/googDevice/client/ConfigureScrcpy.ts b/src/app/googDevice/client/ConfigureScrcpy.ts
new file mode 100644
index 0000000..742ae93
--- /dev/null
+++ b/src/app/googDevice/client/ConfigureScrcpy.ts
@@ -0,0 +1,697 @@
+import '../../../style/dialog.css';
+import GoogDeviceDescriptor from '../../../types/GoogDeviceDescriptor';
+import { DisplayCombinedInfo } from '../../client/StreamReceiver';
+import VideoSettings from '../../VideoSettings';
+import { StreamClientScrcpy } from './StreamClientScrcpy';
+import Size from '../../Size';
+import Util from '../../Util';
+import { DisplayInfo } from '../../DisplayInfo';
+import { ToolBoxButton } from '../../toolbox/ToolBoxButton';
+import SvgImage from '../../ui/SvgImage';
+import { PlayerClass } from '../../player/BasePlayer';
+import { ToolBoxCheckbox } from '../../toolbox/ToolBoxCheckbox';
+import { DeviceTracker } from './DeviceTracker';
+import { Attribute } from '../../Attribute';
+import { StreamReceiverScrcpy } from './StreamReceiverScrcpy';
+import { ParamsStreamScrcpy } from '../../../types/ParamsStreamScrcpy';
+import { BaseClient } from '../../client/BaseClient';
+
+interface ConfigureScrcpyEvents {
+ closed: { dialog: ConfigureScrcpy; result: boolean };
+}
+
+type Range = {
+ max: number;
+ min: number;
+ step: number;
+ formatter?: (value: number) => string;
+};
+
+export class ConfigureScrcpy extends BaseClient {
+ private readonly TAG: string;
+ private readonly udid: string;
+ private readonly escapedUdid: string;
+ private readonly playerStorageKey: string;
+ private deviceName: string;
+ private streamReceiver?: StreamReceiverScrcpy;
+ private playerName?: string;
+ private displayInfo?: DisplayInfo;
+ private background: HTMLElement;
+ private dialogBody?: HTMLElement;
+ private okButton?: HTMLButtonElement;
+ private fitToScreenCheckbox?: HTMLInputElement;
+ private resetSettingsButton?: HTMLButtonElement;
+ private loadSettingsButton?: HTMLButtonElement;
+ private saveSettingsButton?: HTMLButtonElement;
+ private playerSelectElement?: HTMLSelectElement;
+ private displayIdSelectElement?: HTMLSelectElement;
+ private encoderSelectElement?: HTMLSelectElement;
+ private connectionStatusElement?: HTMLElement;
+ private dialogContainer?: HTMLElement;
+ private statusText = '';
+ private connectionCount = 0;
+
+ constructor(private readonly tracker: DeviceTracker, descriptor: GoogDeviceDescriptor, params: ParamsStreamScrcpy) {
+ super(params);
+ this.udid = descriptor.udid;
+ this.escapedUdid = Util.escapeUdid(this.udid);
+ this.playerStorageKey = `configure_stream::${this.escapedUdid}::player`;
+ this.deviceName = descriptor['ro.product.model'];
+ this.TAG = `ConfigureScrcpy[${this.udid}]`;
+ this.createStreamReceiver(params);
+ this.setTitle(`${this.deviceName}. Configure stream`);
+ this.background = this.createUI();
+ }
+
+ public getTracker(): DeviceTracker {
+ return this.tracker;
+ }
+
+ // 创建流接收器
+ private createStreamReceiver(params: ParamsStreamScrcpy): void {
+ // 如果已经存在流接收器,则先解绑事件监听器,并停止流接收器
+ if (this.streamReceiver) {
+ this.detachEventsListeners(this.streamReceiver);
+ this.streamReceiver.stop();
+ }
+ // 创建新的流接收器
+ this.streamReceiver = new StreamReceiverScrcpy(params);
+ // 绑定事件监听器
+ this.attachEventsListeners(this.streamReceiver);
+ }
+
+ // 事件监听器,用于监听StreamReceiverScrcpy对象的事件
+ private attachEventsListeners(streamReceiver: StreamReceiverScrcpy): void {
+ // 监听encoders事件,当有新的编码器连接时触发
+ streamReceiver.on('encoders', this.onEncoders);
+ // 监听displayInfo事件,当显示信息发生变化时触发
+ streamReceiver.on('displayInfo', this.onDisplayInfo);
+ // 监听connected事件,当连接成功时触发
+ streamReceiver.on('connected', this.onConnected);
+ // 监听disconnected事件,当连接断开时触发
+ streamReceiver.on('disconnected', this.onDisconnected);
+ }
+
+ // 移除事件监听器
+ private detachEventsListeners(streamReceiver: StreamReceiverScrcpy): void {
+ // 移除encoders事件监听器
+ streamReceiver.off('encoders', this.onEncoders);
+ // 移除displayInfo事件监听器
+ streamReceiver.off('displayInfo', this.onDisplayInfo);
+ // 移除connected事件监听器
+ streamReceiver.off('connected', this.onConnected);
+ // 移除disconnected事件监听器
+ streamReceiver.off('disconnected', this.onDisconnected);
+ }
+
+ // 更新连接状态
+ private updateStatus(): void {
+ // 如果没有连接状态元素,则返回
+ if (!this.connectionStatusElement) {
+ return;
+ }
+ // 获取连接状态文本
+ let text = this.statusText;
+ // 如果有其他客户端连接,则添加其他客户端连接数
+ if (this.connectionCount) {
+ text = `${text}. Other clients: ${this.connectionCount}.`;
+ }
+ // 将连接状态文本设置到连接状态元素中
+ this.connectionStatusElement.innerText = text;
+ }
+
+ private onEncoders = (encoders: string[]): void => {
+ // console.log(this.TAG, 'Encoders', encoders);
+ const select = this.encoderSelectElement || document.createElement('select');
+ let child;
+ while ((child = select.firstChild)) {
+ select.removeChild(child);
+ }
+ encoders.unshift('');
+ encoders.forEach((value) => {
+ const optionElement = document.createElement('option');
+ optionElement.setAttribute('value', value);
+ optionElement.innerText = value;
+ select.appendChild(optionElement);
+ });
+ this.encoderSelectElement = select;
+ };
+
+ private onDisplayInfo = (infoArray: DisplayCombinedInfo[]): void => {
+ // console.log(this.TAG, 'Received info');
+ this.statusText = 'Ready';
+ this.updateStatus();
+ this.dialogContainer?.classList.add('ready');
+ const select = this.displayIdSelectElement || document.createElement('select');
+ let child;
+ while ((child = select.firstChild)) {
+ select.removeChild(child);
+ }
+ let selectedOptionIdx = -1;
+ infoArray.forEach((value: DisplayCombinedInfo, idx: number) => {
+ const { displayInfo } = value;
+ const { displayId, size } = displayInfo;
+ const optionElement = document.createElement('option');
+ optionElement.setAttribute('value', displayId.toString());
+ optionElement.innerText = `ID: ${displayId}; ${size.width}x${size.height}`;
+ select.appendChild(optionElement);
+ if (
+ (this.displayInfo && this.displayInfo.displayId === displayId) ||
+ (!this.displayInfo && displayId === DisplayInfo.DEFAULT_DISPLAY)
+ ) {
+ selectedOptionIdx = idx;
+ }
+ });
+ if (selectedOptionIdx > -1) {
+ select.selectedIndex = selectedOptionIdx;
+ const { videoSettings, connectionCount, displayInfo } = infoArray[selectedOptionIdx];
+ this.displayInfo = displayInfo;
+ if (connectionCount > 0 && videoSettings) {
+ // console.log(this.TAG, 'Apply other clients settings');
+ this.fillInputsFromVideoSettings(videoSettings, false);
+ } else {
+ // console.log(this.TAG, 'Apply settings for current player');
+ this.updateVideoSettingsForPlayer();
+ }
+ this.connectionCount = connectionCount;
+ this.updateStatus();
+ }
+ this.displayIdSelectElement = select;
+ if (this.dialogBody) {
+ this.dialogBody.classList.remove('hidden');
+ this.dialogBody.classList.add('visible');
+ }
+ };
+
+ private onConnected = (): void => {
+ // console.log(this.TAG, 'Connected');
+ this.statusText = 'Waiting for info...';
+ this.updateStatus();
+ if (this.okButton) {
+ this.okButton.disabled = false;
+ }
+ };
+
+ private onDisconnected = (): void => {
+ // console.log(this.TAG, 'Disconnected');
+ this.statusText = 'Disconnected';
+ this.updateStatus();
+ if (this.okButton) {
+ this.okButton.disabled = true;
+ }
+ if (this.dialogBody) {
+ this.dialogBody.classList.remove('visible');
+ this.dialogBody.classList.add('hidden');
+ }
+ };
+
+ private onPlayerChange = (): void => {
+ this.updateVideoSettingsForPlayer();
+ };
+
+ private onDisplayIdChange = (): void => {
+ const select = this.displayIdSelectElement;
+ if (!select || !this.streamReceiver) {
+ return;
+ }
+ const value = select.options[select.selectedIndex].value;
+ const displayId = parseInt(value, 10);
+ if (!isNaN(displayId)) {
+ this.displayInfo = this.streamReceiver.getDisplayInfo(displayId);
+ }
+ this.updateVideoSettingsForPlayer();
+ };
+
+ // 获取玩家
+ private getPlayer(): PlayerClass | undefined {
+ // 如果没有选择玩家,则返回undefined
+ if (!this.playerSelectElement) {
+ return;
+ }
+ // 获取选择的玩家名称
+ const playerName = this.playerSelectElement.options[this.playerSelectElement.selectedIndex].value;
+ // 在StreamClientScrcpy中查找玩家
+ return StreamClientScrcpy.getPlayers().find((playerClass) => {
+ // 如果玩家名称匹配,则返回该玩家
+ return playerClass.playerFullName === playerName;
+ });
+ }
+
+ // 更新播放器的视频设置
+ private updateVideoSettingsForPlayer(): void {
+ // 获取播放器
+ const player = this.getPlayer();
+ // 如果播放器存在
+ if (player) {
+ // 设置播放器名称
+ this.playerName = player.playerFullName;
+ // 获取存储或首选的视频设置
+ const storedOrPreferred = player.loadVideoSettings(this.udid, this.displayInfo);
+ // 获取屏幕适应状态
+ const fitToScreen = player.getFitToScreenStatus(this.udid, this.displayInfo);
+ // 从视频设置中填充输入
+ this.fillInputsFromVideoSettings(storedOrPreferred, fitToScreen);
+ }
+ }
+
+ // 根据id获取基本的输入元素
+ private getBasicInput(id: string): HTMLInputElement | null {
+ // 根据id和udid获取元素
+ const element = document.getElementById(`${id}_${this.escapedUdid}`);
+ // 如果元素不存在,返回null
+ if (!element) {
+ return null;
+ }
+ // 返回元素
+ return element as HTMLInputElement;
+ }
+
+ // 根据视频设置填充输入框
+ private fillInputsFromVideoSettings(videoSettings: VideoSettings, fitToScreen: boolean): void {
+ // 如果显示信息和视频设置中的显示id不匹配,则输出错误信息
+ if (this.displayInfo && this.displayInfo.displayId !== videoSettings.displayId) {
+ console.error(this.TAG, `Display id from VideoSettings and DisplayInfo don't match`);
+ }
+ // 根据视频设置填充基本输入框
+ this.fillBasicInput({ id: 'bitrate' }, videoSettings);
+ this.fillBasicInput({ id: 'maxFps' }, videoSettings);
+ this.fillBasicInput({ id: 'iFrameInterval' }, videoSettings);
+ // this.fillBasicInput({ id: 'displayId' }, videoSettings);
+ this.fillBasicInput({ id: 'codecOptions' }, videoSettings);
+ // 如果视频设置中有边界,则填充最大宽度和最大高度输入框
+ if (videoSettings.bounds) {
+ const { width, height } = videoSettings.bounds;
+ const widthInput = this.getBasicInput('maxWidth');
+ if (widthInput) {
+ widthInput.value = width.toString(10);
+ }
+ const heightInput = this.getBasicInput('maxHeight');
+ if (heightInput) {
+ heightInput.value = height.toString(10);
+ }
+ }
+ // 如果有编码器选择框,则根据视频设置填充编码器选择框
+ if (this.encoderSelectElement) {
+ const encoderName = videoSettings.encoderName || '';
+ const option = Array.from(this.encoderSelectElement.options).find((element) => {
+ return element.value === encoderName;
+ });
+ if (option) {
+ this.encoderSelectElement.selectedIndex = option.index;
+ }
+ }
+ // 如果有适应屏幕复选框,则根据fitToScreen参数填充复选框,并调用onFitToScreenChanged方法
+ if (this.fitToScreenCheckbox) {
+ this.fitToScreenCheckbox.checked = fitToScreen;
+ this.onFitToScreenChanged(fitToScreen);
+ }
+ }
+
+ // 当fitToScreenCheckbox的值改变时调用
+ private onFitToScreenChanged(checked: boolean) {
+ // 获取maxHeight和maxWidth的输入框
+ const heightInput = this.getBasicInput('maxHeight');
+ const widthInput = this.getBasicInput('maxWidth');
+ // 如果fitToScreenCheckbox、heightInput或widthInput不存在,则返回
+ if (!this.fitToScreenCheckbox || !heightInput || !widthInput) {
+ return;
+ }
+ // 如果checked为true,则禁用heightInput和widthInput
+ heightInput.disabled = widthInput.disabled = checked;
+ // 如果checked为true,则将heightInput和widthInput的值清空,并保存原来的值
+ if (checked) {
+ heightInput.setAttribute(Attribute.VALUE, heightInput.value);
+ heightInput.value = '';
+ widthInput.setAttribute(Attribute.VALUE, widthInput.value);
+ widthInput.value = '';
+ } else {
+ // 如果checked为false,则将heightInput和widthInput的值恢复为原来的值
+ const storedHeight = heightInput.getAttribute(Attribute.VALUE);
+ if (typeof storedHeight === 'string') {
+ heightInput.value = storedHeight;
+ heightInput.removeAttribute(Attribute.VALUE);
+ }
+ const storedWidth = widthInput.getAttribute(Attribute.VALUE);
+ if (typeof storedWidth === 'string') {
+ widthInput.value = storedWidth;
+ widthInput.removeAttribute(Attribute.VALUE);
+ }
+ }
+ }
+
+ // 根据传入的参数,填充视频设置的基本输入框
+ private fillBasicInput(opts: { id: keyof VideoSettings }, videoSettings: VideoSettings): void {
+ // 获取基本输入框
+ const input = this.getBasicInput(opts.id);
+ // 获取视频设置中对应id的值
+ const value = videoSettings[opts.id];
+ // 如果输入框存在
+ if (input) {
+ // 如果值存在且不为'-'、0、null
+ if (typeof value !== 'undefined' && value !== '-' && value !== 0 && value !== null) {
+ // 将值转换为字符串并赋值给输入框
+ input.value = value.toString(10);
+ // 如果输入框的类型为range,触发input事件
+ if (input.getAttribute('type') === 'range') {
+ input.dispatchEvent(new Event('input'));
+ }
+ } else {
+ // 否则将输入框的值置为空
+ input.value = '';
+ }
+ }
+ }
+
+ private appendBasicInput(
+ parent: HTMLElement,
+ opts: { label: string; id: string; range?: Range },
+ ): HTMLInputElement {
+ const label = document.createElement('label');
+ label.classList.add('label');
+ label.innerText = `${opts.label}:`;
+ label.id = `label_${opts.id}_${this.escapedUdid}`;
+ parent.appendChild(label);
+ const input = document.createElement('input');
+ input.classList.add('input');
+ input.id = label.htmlFor = `${opts.id}_${this.escapedUdid}`;
+ const { range } = opts;
+ if (range) {
+ label.setAttribute('title', opts.label);
+ input.oninput = () => {
+ const value = range.formatter ? range.formatter(parseInt(input.value, 10)) : input.value;
+ label.innerText = `${opts.label} (${value}):`;
+ };
+ input.setAttribute('type', 'range');
+ input.setAttribute('max', range.max.toString());
+ input.setAttribute('min', range.min.toString());
+ input.setAttribute('step', range.step.toString());
+ }
+ parent.appendChild(input);
+ return input;
+ }
+
+ private getNumberValueFromInput(name: string): number {
+ const value = (document.getElementById(`${name}_${this.escapedUdid}`) as HTMLInputElement).value;
+ return parseInt(value, 10);
+ }
+
+ private getStringValueFromInput(name: string): string {
+ return (document.getElementById(`${name}_${this.escapedUdid}`) as HTMLInputElement).value;
+ }
+
+ private getValueFromSelect(name: string): string {
+ const select = document.getElementById(`${name}_${this.escapedUdid}`) as HTMLSelectElement;
+ return select.options[select.selectedIndex].value;
+ }
+
+ private buildVideoSettings(): VideoSettings | null {
+ try {
+ const bitrate = this.getNumberValueFromInput('bitrate');
+ const maxFps = this.getNumberValueFromInput('maxFps');
+ const iFrameInterval = this.getNumberValueFromInput('iFrameInterval');
+ const maxWidth = this.getNumberValueFromInput('maxWidth');
+ const maxHeight = this.getNumberValueFromInput('maxHeight');
+ const displayId = this.getNumberValueFromInput('displayId');
+ const codecOptions = this.getStringValueFromInput('codecOptions') || undefined;
+ let bounds: Size | undefined;
+ if (!isNaN(maxWidth) && !isNaN(maxHeight) && maxWidth && maxHeight) {
+ bounds = new Size(maxWidth, maxHeight);
+ }
+ const encoderName = this.getValueFromSelect('encoderName') || undefined;
+ return new VideoSettings({
+ bitrate,
+ bounds,
+ maxFps,
+ iFrameInterval,
+ displayId,
+ codecOptions,
+ encoderName,
+ });
+ } catch (error: any) {
+ console.error(this.TAG, error.message);
+ return null;
+ }
+ }
+
+ private getFitToScreenValue(): boolean {
+ if (!this.fitToScreenCheckbox) {
+ return false;
+ }
+ return this.fitToScreenCheckbox.checked;
+ }
+
+ private getPreviouslyUsedPlayer(): string {
+ if (!window.localStorage) {
+ return '';
+ }
+ const result = window.localStorage.getItem(this.playerStorageKey);
+ if (result) {
+ return result;
+ } else {
+ return '';
+ }
+ }
+
+ private setPreviouslyUsedPlayer(playerName: string): void {
+ if (!window.localStorage) {
+ return;
+ }
+ window.localStorage.setItem(this.playerStorageKey, playerName);
+ }
+
+ private createUI(): HTMLElement {
+ const dialogName = 'configureDialog';
+ const blockClass = 'dialog-block';
+ const background = document.createElement('div');
+ background.classList.add('dialog-background', dialogName);
+ const dialogContainer = (this.dialogContainer = document.createElement('div'));
+ dialogContainer.classList.add('dialog-container', dialogName);
+ const dialogHeader = document.createElement('div');
+ dialogHeader.classList.add('dialog-header', dialogName, 'control-wrapper');
+ const backButton = new ToolBoxButton('Back', SvgImage.Icon.ARROW_BACK);
+
+ backButton.addEventListener('click', () => {
+ this.cancel();
+ });
+ backButton.getAllElements().forEach((el) => {
+ dialogHeader.appendChild(el);
+ });
+
+ const deviceName = document.createElement('span');
+ deviceName.classList.add('dialog-title', 'main-title');
+ deviceName.innerText = this.deviceName;
+ dialogHeader.appendChild(deviceName);
+ const dialogBody = (this.dialogBody = document.createElement('div'));
+ dialogBody.classList.add('dialog-body', blockClass, dialogName, 'hidden');
+ const playerWrapper = document.createElement('div');
+ playerWrapper.classList.add('controls');
+ const playerLabel = document.createElement('label');
+ playerLabel.classList.add('label');
+ playerLabel.innerText = 'Player:';
+ playerWrapper.appendChild(playerLabel);
+ const playerSelect = (this.playerSelectElement = document.createElement('select'));
+ playerSelect.classList.add('input');
+ playerSelect.id = playerLabel.htmlFor = `player_${this.escapedUdid}`;
+ playerWrapper.appendChild(playerSelect);
+ dialogBody.appendChild(playerWrapper);
+ const previouslyUsedPlayer = this.getPreviouslyUsedPlayer();
+ StreamClientScrcpy.getPlayers().forEach((playerClass, index) => {
+ const { playerFullName } = playerClass;
+ const optionElement = document.createElement('option');
+ optionElement.setAttribute('value', playerFullName);
+ optionElement.innerText = playerFullName;
+ playerSelect.appendChild(optionElement);
+ if (playerFullName === previouslyUsedPlayer) {
+ playerSelect.selectedIndex = index;
+ }
+ });
+ playerSelect.onchange = this.onPlayerChange;
+ this.updateVideoSettingsForPlayer();
+
+ const controls = document.createElement('div');
+ controls.classList.add('controls', 'control-wrapper');
+ const displayIdLabel = document.createElement('label');
+ displayIdLabel.classList.add('label');
+ displayIdLabel.innerText = 'Display:';
+ controls.appendChild(displayIdLabel);
+ if (!this.displayIdSelectElement) {
+ this.displayIdSelectElement = document.createElement('select');
+ }
+ controls.appendChild(this.displayIdSelectElement);
+ this.displayIdSelectElement.classList.add('input');
+ this.displayIdSelectElement.id = displayIdLabel.htmlFor = `displayId_${this.escapedUdid}`;
+ this.displayIdSelectElement.onchange = this.onDisplayIdChange;
+
+ this.appendBasicInput(controls, {
+ label: 'Bitrate',
+ id: 'bitrate',
+ range: { min: 524288, max: 8388608, step: 524288, formatter: Util.prettyBytes },
+ });
+ this.appendBasicInput(controls, {
+ label: 'Max FPS',
+ id: 'maxFps',
+ range: { min: 1, max: 60, step: 1 },
+ });
+ this.appendBasicInput(controls, { label: 'I-Frame interval', id: 'iFrameInterval' });
+ const fitLabel = document.createElement('label');
+ fitLabel.innerText = 'Fit to screen';
+ fitLabel.classList.add('label');
+ controls.appendChild(fitLabel);
+ const fitToggle = new ToolBoxCheckbox(
+ 'Fit to screen',
+ { off: SvgImage.Icon.TOGGLE_OFF, on: SvgImage.Icon.TOGGLE_ON },
+ 'fit_to_screen',
+ );
+ fitToggle.getAllElements().forEach((el) => {
+ controls.appendChild(el);
+ if (el instanceof HTMLLabelElement) {
+ fitLabel.htmlFor = el.htmlFor;
+ el.classList.add('input');
+ }
+ if (el instanceof HTMLInputElement) {
+ this.fitToScreenCheckbox = el;
+ }
+ });
+ fitToggle.addEventListener('click', (_, el) => {
+ const element = el.getElement();
+ this.onFitToScreenChanged(element.checked);
+ });
+ this.appendBasicInput(controls, { label: 'Max width', id: 'maxWidth' });
+ this.appendBasicInput(controls, { label: 'Max height', id: 'maxHeight' });
+ this.appendBasicInput(controls, { label: 'Codec options', id: 'codecOptions' });
+
+ const encoderLabel = document.createElement('label');
+ encoderLabel.classList.add('label');
+ encoderLabel.innerText = 'Encoder:';
+ controls.appendChild(encoderLabel);
+ if (!this.encoderSelectElement) {
+ this.encoderSelectElement = document.createElement('select');
+ }
+ controls.appendChild(this.encoderSelectElement);
+ this.encoderSelectElement.classList.add('input');
+ this.encoderSelectElement.id = encoderLabel.htmlFor = `encoderName_${this.escapedUdid}`;
+
+ dialogBody.appendChild(controls);
+
+ const buttonsWrapper = document.createElement('div');
+ buttonsWrapper.classList.add('controls');
+
+ const resetSettingsButton = (this.resetSettingsButton = document.createElement('button'));
+ resetSettingsButton.classList.add('button');
+ resetSettingsButton.innerText = 'Reset settings';
+ resetSettingsButton.addEventListener('click', this.resetSettings);
+ buttonsWrapper.appendChild(resetSettingsButton);
+
+ const loadSettingsButton = (this.loadSettingsButton = document.createElement('button'));
+ loadSettingsButton.classList.add('button');
+ loadSettingsButton.innerText = 'Load settings';
+ loadSettingsButton.addEventListener('click', this.loadSettings);
+ buttonsWrapper.appendChild(loadSettingsButton);
+
+ const saveSettingsButton = (this.saveSettingsButton = document.createElement('button'));
+ saveSettingsButton.classList.add('button');
+ saveSettingsButton.innerText = 'Save settings';
+ saveSettingsButton.addEventListener('click', this.saveSettings);
+ buttonsWrapper.appendChild(saveSettingsButton);
+
+ dialogBody.appendChild(buttonsWrapper);
+
+ const dialogFooter = document.createElement('div');
+ dialogFooter.classList.add('dialog-footer', blockClass, dialogName);
+ const statusElement = document.createElement('span');
+ statusElement.classList.add('subtitle');
+ this.connectionStatusElement = statusElement;
+ dialogFooter.appendChild(statusElement);
+ this.statusText = `Connecting...`;
+ this.updateStatus();
+
+ // const cancelButton = (this.cancelButton = document.createElement('button'));
+ // cancelButton.innerText = 'Cancel';
+ // cancelButton.addEventListener('click', this.cancel);
+ const okButton = (this.okButton = document.createElement('button'));
+ okButton.innerText = 'Open';
+ okButton.disabled = true;
+ okButton.addEventListener('click', this.openStream);
+ dialogFooter.appendChild(okButton);
+ // dialogFooter.appendChild(cancelButton);
+ dialogBody.appendChild(dialogFooter);
+ dialogContainer.appendChild(dialogHeader);
+ dialogContainer.appendChild(dialogBody);
+ dialogContainer.appendChild(dialogFooter);
+ background.appendChild(dialogContainer);
+ background.addEventListener('click', this.onBackgroundClick);
+ document.body.appendChild(background);
+ return background;
+ }
+
+ private removeUI(): void {
+ document.body.removeChild(this.background);
+ this.okButton?.removeEventListener('click', this.openStream);
+ // this.cancelButton?.removeEventListener('click', this.cancel);
+ this.resetSettingsButton?.removeEventListener('click', this.resetSettings);
+ this.loadSettingsButton?.removeEventListener('click', this.loadSettings);
+ this.saveSettingsButton?.removeEventListener('click', this.saveSettings);
+ this.background.removeEventListener('click', this.onBackgroundClick);
+ }
+
+ private onBackgroundClick = (event: MouseEvent): void => {
+ if (event.target !== event.currentTarget) {
+ return;
+ }
+ this.cancel();
+ };
+
+ private cancel = (): void => {
+ if (this.streamReceiver) {
+ this.detachEventsListeners(this.streamReceiver);
+ this.streamReceiver.stop();
+ }
+ this.emit('closed', { dialog: this, result: false });
+ this.removeUI();
+ };
+
+ private resetSettings = (): void => {
+ const player = this.getPlayer();
+ if (player) {
+ this.fillInputsFromVideoSettings(player.getPreferredVideoSetting(), false);
+ }
+ };
+
+ private loadSettings = (): void => {
+ this.updateVideoSettingsForPlayer();
+ };
+
+ private saveSettings = (): void => {
+ const videoSettings = this.buildVideoSettings();
+ const player = this.getPlayer();
+ if (videoSettings && player) {
+ const fitToScreen = this.getFitToScreenValue();
+ player.saveVideoSettings(this.udid, videoSettings, fitToScreen, this.displayInfo);
+ }
+ };
+
+ private openStream = (): void => {
+ const videoSettings = this.buildVideoSettings();
+ if (!videoSettings || !this.streamReceiver || !this.playerName) {
+ return;
+ }
+ const fitToScreen = this.getFitToScreenValue();
+ this.detachEventsListeners(this.streamReceiver);
+ this.emit('closed', { dialog: this, result: true });
+ this.removeUI();
+ const player = StreamClientScrcpy.createPlayer(this.playerName, this.udid, this.displayInfo);
+ if (!player) {
+ return;
+ }
+ this.setPreviouslyUsedPlayer(this.playerName);
+ // return;
+ player.setVideoSettings(videoSettings, fitToScreen, false);
+ const params: ParamsStreamScrcpy = {
+ ...this.params,
+ udid: this.udid,
+ fitToScreen,
+ };
+ StreamClientScrcpy.start(params, this.streamReceiver, player, fitToScreen, videoSettings);
+ this.streamReceiver.triggerInitialInfoEvents();
+ };
+}
diff --git a/src/app/googDevice/client/DeviceTracker.ts b/src/app/googDevice/client/DeviceTracker.ts
new file mode 100644
index 0000000..1b30e72
--- /dev/null
+++ b/src/app/googDevice/client/DeviceTracker.ts
@@ -0,0 +1,364 @@
+import '../../../style/devicelist.css';
+import { BaseDeviceTracker } from '../../client/BaseDeviceTracker';
+import { SERVER_PORT } from '../../../common/Constants';
+import { ACTION } from '../../../common/Action';
+import GoogDeviceDescriptor from '../../../types/GoogDeviceDescriptor';
+import { ControlCenterCommand } from '../../../common/ControlCenterCommand';
+import { StreamClientScrcpy } from './StreamClientScrcpy';
+import SvgImage from '../../ui/SvgImage';
+import { html } from '../../ui/HtmlTag';
+import Util from '../../Util';
+import { Attribute } from '../../Attribute';
+import { DeviceState } from '../../../common/DeviceState';
+import { Message } from '../../../types/Message';
+import { ParamsDeviceTracker } from '../../../types/ParamsDeviceTracker';
+import { HostItem } from '../../../types/Configuration';
+import { ChannelCode } from '../../../common/ChannelCode';
+import { Tool } from '../../client/Tool';
+
+type Field = keyof GoogDeviceDescriptor | ((descriptor: GoogDeviceDescriptor) => string);
+type DescriptionColumn = { title: string; field: Field };
+
+const DESC_COLUMNS: DescriptionColumn[] = [
+ {
+ title: 'Net Interface',
+ field: 'interfaces',
+ },
+ {
+ title: 'Server PID',
+ field: 'pid',
+ },
+];
+
+export class DeviceTracker extends BaseDeviceTracker {
+ public static readonly ACTION = ACTION.GOOG_DEVICE_LIST;
+ public static readonly CREATE_DIRECT_LINKS = true;
+ private static instancesByUrl: Map = new Map();
+ protected static tools: Set = new Set();
+ protected tableId = 'goog_device_list';
+
+ public static start(hostItem: HostItem): DeviceTracker {
+ const url = this.buildUrlForTracker(hostItem).toString();
+ let instance = this.instancesByUrl.get(url);
+ if (!instance) {
+ instance = new DeviceTracker(hostItem, url);
+ }
+ return instance;
+ }
+
+ public static getInstance(hostItem: HostItem): DeviceTracker {
+ return this.start(hostItem);
+ }
+
+ protected constructor(params: HostItem, directUrl: string) {
+ super({ ...params, action: DeviceTracker.ACTION }, directUrl);
+ DeviceTracker.instancesByUrl.set(directUrl, this);
+ this.buildDeviceTable();
+ this.openNewConnection();
+ }
+
+ protected onSocketOpen(): void {
+ // nothing here;
+ }
+
+ protected setIdAndHostName(id: string, hostName: string): void {
+ super.setIdAndHostName(id, hostName);
+ for (const value of DeviceTracker.instancesByUrl.values()) {
+ if (value.id === id && value !== this) {
+ console.warn(
+ `Tracker with url: "${this.url}" has the same id(${this.id}) as tracker with url "${value.url}"`,
+ );
+ console.warn(`This tracker will shut down`);
+ this.destroy();
+ }
+ }
+ }
+
+ onInterfaceSelected = (event: Event): void => {
+ const selectElement = event.currentTarget as HTMLSelectElement;
+ const option = selectElement.selectedOptions[0];
+ const url = decodeURI(option.getAttribute(Attribute.URL) || '');
+ const name = option.getAttribute(Attribute.NAME) || '';
+ const fullName = decodeURIComponent(selectElement.getAttribute(Attribute.FULL_NAME) || '');
+ const udid = selectElement.getAttribute(Attribute.UDID) || '';
+ this.updateLink({ url, name, fullName, udid, store: true });
+ };
+
+ private updateLink(params: { url: string; name: string; fullName: string; udid: string; store: boolean }): void {
+ const { url, name, fullName, udid, store } = params;
+ const playerTds = document.getElementsByName(
+ encodeURIComponent(`${DeviceTracker.AttributePrefixPlayerFor}${fullName}`),
+ );
+ if (typeof udid !== 'string') {
+ return;
+ }
+ if (store) {
+ const localStorageKey = DeviceTracker.getLocalStorageKey(fullName || '');
+ if (localStorage && name) {
+ localStorage.setItem(localStorageKey, name);
+ }
+ }
+ const action = ACTION.STREAM_SCRCPY;
+ playerTds.forEach((item) => {
+ item.innerHTML = '';
+ const playerFullName = item.getAttribute(DeviceTracker.AttributePlayerFullName);
+ const playerCodeName = item.getAttribute(DeviceTracker.AttributePlayerCodeName);
+ if (!playerFullName || !playerCodeName) {
+ return;
+ }
+ const link = DeviceTracker.buildLink(
+ {
+ action,
+ udid,
+ player: decodeURIComponent(playerCodeName),
+ ws: url,
+ },
+ decodeURIComponent(playerFullName),
+ this.params,
+ );
+ item.appendChild(link);
+ });
+ }
+
+ onActionButtonClick = (event: MouseEvent): void => {
+ const button = event.currentTarget as HTMLButtonElement;
+ const udid = button.getAttribute(Attribute.UDID);
+ const pidString = button.getAttribute(Attribute.PID) || '';
+ const command = button.getAttribute(Attribute.COMMAND) as string;
+ const pid = parseInt(pidString, 10);
+ const data: Message = {
+ id: this.getNextId(),
+ type: command,
+ data: {
+ udid: typeof udid === 'string' ? udid : undefined,
+ pid: isNaN(pid) ? undefined : pid,
+ },
+ };
+
+ if (this.ws && this.ws.readyState === this.ws.OPEN) {
+ console.log('发送的参数3');
+
+ this.ws.send(JSON.stringify(data));
+ }
+ };
+
+ private static getLocalStorageKey(udid: string): string {
+ return `device_list::${udid}::interface`;
+ }
+
+ protected static createUrl(params: ParamsDeviceTracker, udid = ''): URL {
+ const secure = !!params.secure;
+ const hostname = params.hostname || location.hostname;
+ const port = typeof params.port === 'number' ? params.port : secure ? 443 : 80;
+ const pathname = params.pathname || location.pathname;
+ const urlObject = this.buildUrl({ ...params, secure, hostname, port, pathname });
+ if (udid) {
+ urlObject.searchParams.set('action', ACTION.PROXY_ADB);
+ urlObject.searchParams.set('remote', `tcp:${SERVER_PORT.toString(10)}`);
+ urlObject.searchParams.set('udid', udid);
+ }
+ return urlObject;
+ }
+
+ protected static createInterfaceOption(name: string, url: string): HTMLOptionElement {
+ const optionElement = document.createElement('option');
+ optionElement.setAttribute(Attribute.URL, url);
+ optionElement.setAttribute(Attribute.NAME, name);
+ optionElement.innerText = `proxy over adb`;
+ return optionElement;
+ }
+
+ private static titleToClassName(title: string): string {
+ return title.toLowerCase().replace(/\s/g, '_');
+ }
+
+ protected buildDeviceRow(tbody: Element, device: GoogDeviceDescriptor): void {
+ let selectedInterfaceUrl = '';
+ let selectedInterfaceName = '';
+ const blockClass = 'desc-block';
+ const fullName = `${this.id}_${Util.escapeUdid(device.udid)}`;
+ const isActive = device.state === DeviceState.DEVICE;
+ let hasPid = false;
+ const servicesId = `device_services_${fullName}`;
+ const row = html``.content;
+ const services = row.getElementById(servicesId);
+ if (!services) {
+ return;
+ }
+
+ DeviceTracker.tools.forEach((tool) => {
+ const entry = tool.createEntryForDeviceList(device, blockClass, this.params);
+ if (entry) {
+ if (Array.isArray(entry)) {
+ entry.forEach((item) => {
+ item && services.appendChild(item);
+ });
+ } else {
+ services.appendChild(entry);
+ }
+ }
+ });
+
+ const streamEntry = StreamClientScrcpy.createEntryForDeviceList(device, blockClass, fullName, this.params);
+ streamEntry && services.appendChild(streamEntry);
+
+ DESC_COLUMNS.forEach((item) => {
+ const { title } = item;
+ const fieldName = item.field;
+ let value: string;
+ if (typeof item.field === 'string') {
+ value = '' + device[item.field];
+ } else {
+ value = item.field(device);
+ }
+ const td = document.createElement('div');
+ td.classList.add(DeviceTracker.titleToClassName(title), blockClass);
+ services.appendChild(td);
+ if (fieldName === 'pid') {
+ hasPid = value !== '-1';
+ const actionButton = document.createElement('button');
+ actionButton.className = 'action-button kill-server-button';
+ actionButton.setAttribute(Attribute.UDID, device.udid);
+ actionButton.setAttribute(Attribute.PID, value);
+ let command: string;
+ if (isActive) {
+ actionButton.classList.add('active');
+ actionButton.onclick = this.onActionButtonClick;
+ if (hasPid) {
+ command = ControlCenterCommand.KILL_SERVER;
+ actionButton.title = 'Kill server';
+ actionButton.appendChild(SvgImage.create(SvgImage.Icon.CANCEL));
+ } else {
+ command = ControlCenterCommand.START_SERVER;
+ actionButton.title = 'Start server';
+ actionButton.appendChild(SvgImage.create(SvgImage.Icon.REFRESH));
+ }
+ actionButton.setAttribute(Attribute.COMMAND, command);
+ } else {
+ const timestamp = device['last.update.timestamp'];
+ if (timestamp) {
+ const date = new Date(timestamp);
+ actionButton.title = `Last update on ${date.toLocaleDateString()} at ${date.toLocaleTimeString()}`;
+ } else {
+ actionButton.title = `Not active`;
+ }
+ actionButton.appendChild(SvgImage.create(SvgImage.Icon.OFFLINE));
+ }
+ const span = document.createElement('span');
+ span.innerText = value;
+ actionButton.appendChild(span);
+ td.appendChild(actionButton);
+ } else if (fieldName === 'interfaces') {
+ const proxyInterfaceUrl = DeviceTracker.createUrl(this.params, device.udid).toString();
+ const proxyInterfaceName = 'proxy';
+ const localStorageKey = DeviceTracker.getLocalStorageKey(fullName);
+ const lastSelected = localStorage && localStorage.getItem(localStorageKey);
+ const selectElement = document.createElement('select');
+ selectElement.setAttribute(Attribute.UDID, device.udid);
+ selectElement.setAttribute(Attribute.FULL_NAME, fullName);
+ selectElement.setAttribute(
+ 'name',
+ encodeURIComponent(`${DeviceTracker.AttributePrefixInterfaceSelectFor}${fullName}`),
+ );
+ /// #if SCRCPY_LISTENS_ON_ALL_INTERFACES
+ device.interfaces.forEach((value) => {
+ const params = {
+ ...this.params,
+ secure: false,
+ hostname: value.ipv4,
+ port: SERVER_PORT,
+ };
+ const url = DeviceTracker.createUrl(params).toString();
+ const optionElement = DeviceTracker.createInterfaceOption(value.name, url);
+ optionElement.innerText = `${value.name}: ${value.ipv4}`;
+ selectElement.appendChild(optionElement);
+ if (lastSelected) {
+ if (lastSelected === value.name || !selectedInterfaceName) {
+ optionElement.selected = true;
+ selectedInterfaceUrl = url;
+ selectedInterfaceName = value.name;
+ }
+ } else if (device['wifi.interface'] === value.name) {
+ optionElement.selected = true;
+ }
+ });
+ /// #else
+ selectedInterfaceUrl = proxyInterfaceUrl;
+ selectedInterfaceName = proxyInterfaceName;
+ td.classList.add('hidden');
+ /// #endif
+ if (isActive) {
+ const adbProxyOption = DeviceTracker.createInterfaceOption(proxyInterfaceName, proxyInterfaceUrl);
+ if (lastSelected === proxyInterfaceName || !selectedInterfaceName) {
+ adbProxyOption.selected = true;
+ selectedInterfaceUrl = proxyInterfaceUrl;
+ selectedInterfaceName = proxyInterfaceName;
+ }
+ selectElement.appendChild(adbProxyOption);
+ const actionButton = document.createElement('button');
+ actionButton.className = 'action-button update-interfaces-button active';
+ actionButton.title = `Update information`;
+ actionButton.appendChild(SvgImage.create(SvgImage.Icon.REFRESH));
+ actionButton.setAttribute(Attribute.UDID, device.udid);
+ actionButton.setAttribute(Attribute.COMMAND, ControlCenterCommand.UPDATE_INTERFACES);
+ actionButton.onclick = this.onActionButtonClick;
+ td.appendChild(actionButton);
+ }
+ selectElement.onchange = this.onInterfaceSelected;
+ td.appendChild(selectElement);
+ } else {
+ td.innerText = value;
+ }
+ });
+
+ if (DeviceTracker.CREATE_DIRECT_LINKS) {
+ const name = `${DeviceTracker.AttributePrefixPlayerFor}${fullName}`;
+ StreamClientScrcpy.getPlayers().forEach((playerClass) => {
+ const { playerCodeName, playerFullName } = playerClass;
+ const playerTd = document.createElement('div');
+ playerTd.classList.add(blockClass);
+ playerTd.setAttribute('name', encodeURIComponent(name));
+ playerTd.setAttribute(DeviceTracker.AttributePlayerFullName, encodeURIComponent(playerFullName));
+ playerTd.setAttribute(DeviceTracker.AttributePlayerCodeName, encodeURIComponent(playerCodeName));
+ services.appendChild(playerTd);
+ });
+ }
+
+ tbody.appendChild(row);
+ if (DeviceTracker.CREATE_DIRECT_LINKS && hasPid && selectedInterfaceUrl) {
+ this.updateLink({
+ url: selectedInterfaceUrl,
+ name: selectedInterfaceName,
+ fullName,
+ udid: device.udid,
+ store: false,
+ });
+ }
+ }
+
+ protected getChannelCode(): string {
+ return ChannelCode.GTRC;
+ }
+
+ public destroy(): void {
+ super.destroy();
+ DeviceTracker.instancesByUrl.delete(this.url.toString());
+ if (!DeviceTracker.instancesByUrl.size) {
+ const holder = document.getElementById(BaseDeviceTracker.HOLDER_ELEMENT_ID);
+ if (holder && holder.parentElement) {
+ holder.parentElement.removeChild(holder);
+ }
+ }
+ }
+}
diff --git a/src/app/googDevice/client/DevtoolsClient.ts b/src/app/googDevice/client/DevtoolsClient.ts
new file mode 100644
index 0000000..d886da0
--- /dev/null
+++ b/src/app/googDevice/client/DevtoolsClient.ts
@@ -0,0 +1,381 @@
+import '../../../style/devtools.css';
+import { ManagerClient } from '../../client/ManagerClient';
+import { ACTION } from '../../../common/Action';
+import { ParamsDevtools } from '../../../types/ParamsDevtools';
+import { RemoteDevtoolsCommand } from '../../../types/RemoteDevtoolsCommand';
+import { Message } from '../../../types/Message';
+import { DevtoolsInfo, RemoteBrowserInfo, RemoteTarget, TargetDescription } from '../../../types/RemoteDevtools';
+import GoogDeviceDescriptor from '../../../types/GoogDeviceDescriptor';
+import { BaseDeviceTracker } from '../../client/BaseDeviceTracker';
+import { ParamsDeviceTracker } from '../../../types/ParamsDeviceTracker';
+import Util from '../../Util';
+
+const FRONTEND_RE = /^https?:\/\/chrome-devtools-frontend\.appspot\.com\/serve_rev\/(@.*)/;
+
+const TAG = '[DevtoolsClient]';
+
+export class DevtoolsClient extends ManagerClient {
+ public static readonly ACTION = ACTION.DEVTOOLS;
+ public static readonly TIMEOUT = 1000;
+
+ public static start(params: ParamsDevtools): DevtoolsClient {
+ return new DevtoolsClient(params);
+ }
+
+ private timeout?: number;
+ private readonly hiddenInput: HTMLInputElement;
+ private readonly tooltip: HTMLSpanElement;
+ private hideTimeout?: number;
+ private readonly udid: string;
+ constructor(params: ParamsDevtools) {
+ super(params);
+ this.udid = this.params.udid;
+ this.openNewConnection();
+ this.setTitle(`Devtools ${this.udid}`);
+ this.setBodyClass('devtools');
+ this.hiddenInput = document.createElement('input');
+ this.hiddenInput.className = 'hidden';
+ this.hiddenInput.setAttribute('hidden', 'hidden');
+ document.body.appendChild(this.hiddenInput);
+ this.tooltip = document.createElement('span');
+ this.tooltip.innerText = 'Copied!';
+ this.tooltip.className = 'tooltip';
+ this.tooltip.style.display = 'none';
+ document.body.appendChild(this.tooltip);
+ }
+
+ public static parseParameters(params: URLSearchParams): ParamsDevtools {
+ const typedParams = super.parseParameters(params);
+ const { action } = typedParams;
+ if (action !== ACTION.DEVTOOLS) {
+ throw Error('Incorrect action');
+ }
+ return { ...typedParams, action, udid: Util.parseString(params, 'udid', true) };
+ }
+
+ private static compareBrowsers = (a: RemoteBrowserInfo, b: RemoteBrowserInfo): number => {
+ const aBrowser = a.version.Browser;
+ const bBrowser = b.version.Browser;
+ if (aBrowser > bBrowser) {
+ return 1;
+ } else if (aBrowser < bBrowser) {
+ return -1;
+ }
+ return 0;
+ };
+
+ protected buildDirectWebSocketUrl(): URL {
+ const localUrl = super.buildDirectWebSocketUrl();
+ if (typeof this.params.udid === 'string') {
+ localUrl.searchParams.set('udid', this.params.udid);
+ }
+ return localUrl;
+ }
+
+ protected onSocketClose(event: CloseEvent): void {
+ console.error(TAG, `Socket closed. Code: ${event.code}.${event.reason ? ' Reason: ' + event.reason : ''}`);
+ setTimeout(() => {
+ this.openNewConnection();
+ }, 2000);
+ }
+
+ protected onSocketMessage(event: MessageEvent): void {
+ console.log("接收到的参数234", event.data)
+
+ let message: Message;
+ try {
+ message = JSON.parse(event.data);
+ } catch (error: any) {
+ console.error(TAG, error.message);
+ console.log(TAG, error.data);
+ return;
+ }
+ if (message.type !== DevtoolsClient.ACTION) {
+ console.log(TAG, `Unknown message type: ${message.type}`);
+ return;
+ }
+ const list = message.data as DevtoolsInfo;
+ this.buildList(list);
+ if (!this.timeout) {
+ this.timeout = window.setTimeout(this.requestListUpdate, DevtoolsClient.TIMEOUT);
+ }
+ }
+
+ protected onSocketOpen(): void {
+ this.requestListUpdate();
+ }
+
+ private requestListUpdate = (): void => {
+ this.timeout = undefined;
+ if (!this.ws || this.ws.readyState !== this.ws.OPEN) {
+ return;
+ }
+ console.log('发送的参数4');
+
+ this.ws.send(
+ JSON.stringify({
+ command: RemoteDevtoolsCommand.LIST_DEVTOOLS,
+ }),
+ );
+ };
+
+ private createDeviceBlock(info: DevtoolsInfo): HTMLDivElement {
+ const d = document.createElement('div');
+ d.className = 'device';
+ d.id = `device:${info.deviceSerial}`;
+ return d;
+ }
+ private createDeviceHeader(info: DevtoolsInfo): HTMLDivElement {
+ const h = document.createElement('div');
+ h.className = 'device-header';
+ const n = document.createElement('div');
+ n.className = 'device-name';
+ n.innerText = info.deviceName;
+ const s = document.createElement('div');
+ s.className = 'device-serial';
+ s.innerText = `#${info.deviceSerial.toUpperCase()}`;
+ const p = document.createElement('div');
+ p.className = 'device-ports';
+ h.appendChild(n);
+ h.appendChild(s);
+ h.appendChild(p);
+ return h;
+ }
+
+ private createBrowsersBlock(info: DevtoolsInfo): HTMLDivElement {
+ const { deviceSerial } = info;
+ const bs = document.createElement('div');
+ bs.className = 'browsers';
+ info.browsers.sort(DevtoolsClient.compareBrowsers).forEach((browser) => {
+ const b = this.createBrowserBlock(deviceSerial, browser);
+ bs.appendChild(b);
+ });
+ return bs;
+ }
+
+ private createBrowserBlock(serial: string, info: RemoteBrowserInfo): HTMLDivElement {
+ const { socket } = info;
+ const b = document.createElement('div');
+ b.id = `${serial}:${socket}`;
+ b.className = 'browser';
+ const h = document.createElement('div');
+ h.className = 'browser-header';
+ b.appendChild(h);
+ const n = document.createElement('div');
+ n.className = 'browser-name';
+
+ h.appendChild(n);
+ const pkg = info.version['Android-Package'];
+ const browser = info.version.Browser;
+ let version: string;
+ const temp = browser.split('/');
+ if (temp.length > 1) {
+ version = temp[1];
+ } else {
+ version = browser;
+ }
+ const prefix = socket.indexOf('webview') === 0 ? 'WebView in ' : '';
+ n.innerText = `${prefix}${pkg}(${version})`;
+ const s = document.createElement('span');
+ s.setAttribute('tabIndex', '1');
+ s.className = 'action';
+ s.innerText = 'trace';
+ s.setAttribute('hidden', 'hidden');
+ h.appendChild(s);
+ const pages = document.createElement('div');
+ pages.className = 'list pages';
+ info.targets.forEach((page) => {
+ pages.appendChild(this.createPageBlock(page, version));
+ });
+ b.appendChild(pages);
+ return b;
+ }
+
+ private createPageBlock(page: RemoteTarget, version?: string): HTMLDivElement {
+ const row = document.createElement('div');
+ row.className = 'row';
+ const props = document.createElement('div');
+ props.className = 'properties-box';
+ row.appendChild(props);
+ if (page.faviconUrl) {
+ const img = document.createElement('img');
+ img.src = page.faviconUrl;
+ props.appendChild(img);
+ }
+ const subrow = document.createElement('div');
+ subrow.className = 'subrow-box';
+ props.appendChild(subrow);
+ const sub1 = document.createElement('div');
+ sub1.className = 'subrow';
+ subrow.appendChild(sub1);
+ const n = document.createElement('div');
+ n.className = 'name';
+ if (page.title) {
+ n.innerText = page.title;
+ }
+ sub1.appendChild(n);
+ const u = document.createElement('div');
+ u.className = 'url';
+ if (page.url) {
+ u.innerText = page.url;
+ }
+ sub1.appendChild(u);
+ const sub2 = document.createElement('div');
+ sub2.className = 'subrow webview';
+ subrow.appendChild(sub2);
+ if (page.description) {
+ try {
+ const desc = JSON.parse(page.description) as TargetDescription;
+ const position = document.createElement('div');
+ position.className = 'position';
+ position.innerText = `at (${desc.screenX}, ${desc.screenY})`;
+ sub2.appendChild(position);
+ const size = document.createElement('div');
+ size.className = 'size';
+ size.innerText = `size ${desc.width} × ${desc.height}`;
+ sub2.appendChild(size);
+ } catch (error: any) { }
+ }
+ const absoluteAddress = page.devtoolsFrontendUrl && page.devtoolsFrontendUrl.startsWith('http');
+
+ const actions = document.createElement('div');
+ actions.className = 'actions';
+ subrow.appendChild(actions);
+ const inspect = document.createElement('a');
+ inspect.setAttribute('tabIndex', '1');
+ inspect.className = 'action';
+ inspect.innerText = 'inspect';
+ actions.appendChild(inspect);
+
+ if (page.devtoolsFrontendUrl) {
+ inspect.setAttribute('href', page.devtoolsFrontendUrl);
+ inspect.setAttribute('rel', 'noopener noreferrer');
+ inspect.setAttribute('target', '_blank');
+ } else {
+ inspect.classList.add('disabled');
+ }
+
+ if (!absoluteAddress) {
+ inspect.classList.add('disabled');
+ }
+
+ if (page.webSocketDebuggerUrl) {
+ const bundled = document.createElement('a');
+ bundled.setAttribute('tabIndex', '1');
+ bundled.className = 'action copy';
+ bundled.innerText = 'bundled';
+ bundled.title = 'Copy link and open manually';
+ actions.appendChild(bundled);
+
+ const base = 'devtools://devtools/bundled/inspector.html?experiments=true&ws=';
+ bundled.setAttribute('href', `${base}${page.webSocketDebuggerUrl}`);
+ bundled.setAttribute('rel', 'noopener noreferrer');
+ bundled.setAttribute('target', '_blank');
+ bundled.onclick = this.onDevtoolsLinkClick;
+ }
+
+ if (page.devtoolsFrontendUrl && page.webSocketDebuggerUrl && absoluteAddress) {
+ const ur = new URL(page.devtoolsFrontendUrl);
+ ur.searchParams.delete('ws');
+ const urStr = ur.toString();
+ const match = urStr.match(FRONTEND_RE);
+ if (match) {
+ const str = match[1];
+ const temp = str.split('/');
+ const revision = temp.shift();
+ const rest = temp.join('/');
+ const remoteVersion = version ? `remoteVersion=${version}&` : '';
+ const opts = `remoteFrontend=true&dockSide=undocked&`;
+ const ws = `ws=${page.webSocketDebuggerUrl}`;
+ const url = `devtools://devtools/remote/serve_rev/${revision}/${rest}?${remoteVersion}${opts}${ws}`;
+
+ const remote = document.createElement('a');
+ remote.setAttribute('tabIndex', '1');
+ remote.className = 'action copy';
+ remote.innerText = 'remote';
+ remote.title = 'Copy link and open manually';
+ actions.appendChild(remote);
+
+ remote.setAttribute('href', url);
+ remote.setAttribute('rel', 'noopener noreferrer');
+ remote.setAttribute('target', '_blank');
+ remote.onclick = this.onDevtoolsLinkClick;
+ }
+ }
+
+ const pause = document.createElement('span');
+ pause.setAttribute('hidden', 'hidden');
+ pause.setAttribute('tabIndex', '1');
+ pause.className = 'action';
+ pause.innerText = 'pause';
+ actions.appendChild(pause);
+ return row;
+ }
+
+ private onDevtoolsLinkClick = (event: MouseEvent): void => {
+ const a = event.target as HTMLAnchorElement;
+ const url = a.getAttribute('href');
+ if (!url) {
+ return;
+ }
+ this.hiddenInput.value = url;
+ this.hiddenInput.removeAttribute('hidden');
+ this.hiddenInput.select();
+ this.hiddenInput.setSelectionRange(0, url.length);
+ document.execCommand('copy');
+ this.hiddenInput.setAttribute('hidden', 'hidden');
+ this.tooltip.style.left = `${event.clientX}px`;
+ this.tooltip.style.top = `${event.clientY}px`;
+ this.tooltip.style.display = 'block';
+ this.hideTooltip();
+ event.preventDefault();
+ };
+
+ private hideTooltip() {
+ if (this.hideTimeout) {
+ clearTimeout(this.hideTimeout);
+ }
+ this.hideTimeout = window.setTimeout(() => {
+ this.hideTimeout = undefined;
+ this.tooltip.style.display = 'none';
+ }, 1000);
+ }
+
+ public buildList(info: DevtoolsInfo): void {
+ // console.log(info);
+ const block = this.createDeviceBlock(info);
+ const header = this.createDeviceHeader(info);
+ const browsers = this.createBrowsersBlock(info);
+ block.appendChild(header);
+ block.appendChild(browsers);
+ const old = document.getElementById(block.id);
+ if (old) {
+ old.parentElement?.replaceChild(block, old);
+ } else {
+ document.body.appendChild(block);
+ }
+ }
+
+ public static createEntryForDeviceList(
+ descriptor: GoogDeviceDescriptor,
+ blockClass: string,
+ params: ParamsDeviceTracker,
+ ): HTMLElement | DocumentFragment | undefined {
+ if (descriptor.state !== 'device') {
+ return;
+ }
+ const entry = document.createElement('div');
+ entry.classList.add('devtools', blockClass);
+ entry.appendChild(
+ BaseDeviceTracker.buildLink(
+ {
+ action: ACTION.DEVTOOLS,
+ udid: descriptor.udid,
+ },
+ 'devtools',
+ params,
+ ),
+ );
+ return entry;
+ }
+}
diff --git a/src/app/googDevice/client/FileListingClient.ts b/src/app/googDevice/client/FileListingClient.ts
new file mode 100644
index 0000000..accf6f4
--- /dev/null
+++ b/src/app/googDevice/client/FileListingClient.ts
@@ -0,0 +1,596 @@
+import '../../../style/filelisting.css';
+import { ParamsFileListing } from '../../../types/ParamsFileListing';
+import { ManagerClient } from '../../client/ManagerClient';
+import GoogDeviceDescriptor from '../../../types/GoogDeviceDescriptor';
+import { BaseDeviceTracker } from '../../client/BaseDeviceTracker';
+import { ACTION } from '../../../common/Action';
+import { ParamsDeviceTracker } from '../../../types/ParamsDeviceTracker';
+import Util from '../../Util';
+import Protocol from '@dead50f7/adbkit/lib/adb/protocol';
+import { Entry } from '../Entry';
+import { html } from '../../ui/HtmlTag';
+import * as path from 'path';
+import { ChannelCode } from '../../../common/ChannelCode';
+import { Multiplexer } from '../../../packages/multiplexer/Multiplexer';
+import FilePushHandler, { DragAndPushListener, PushUpdateParams } from '../filePush/FilePushHandler';
+import { AdbkitFilePushStream } from '../filePush/AdbkitFilePushStream';
+
+const TAG = '[FileListing]';
+
+const parentDirLinkBox = 'parentDirLinkBox';
+const rootDirLinkBox = 'rootDirLinkBox';
+const tempDirLinkBox = 'tempDirLinkBox';
+const storageDirLinkBox = 'storageDirLinkBox';
+
+const rootPath = '/';
+const tempPath = '/data/local/tmp';
+const storagePath = '/storage';
+
+type Download = {
+ receivedBytes: number;
+ entry?: Entry;
+ progressEl?: HTMLElement;
+ anchor?: HTMLElement;
+ chunks: Uint8Array[];
+ path: string;
+ pathToLoadAfter: string;
+};
+type Upload = { row: HTMLElement; progressEl: HTMLElement; anchor: HTMLElement; timeout: number | null };
+
+enum Foreground {
+ Drop = 'drop-target',
+ Connect = 'connect',
+}
+
+const Message: Record = {
+ [Foreground.Drop]: 'Drop files here',
+ [Foreground.Connect]: 'Connection lost',
+};
+
+export class FileListingClient extends ManagerClient implements DragAndPushListener {
+ public static readonly ACTION = ACTION.FILE_LISTING;
+ public static readonly PARENT_DIR = '..';
+ public static readonly PROPERTY_NAME = 'data-name';
+ public static readonly PROPERTY_ENTRY_ID = 'data-entry-id';
+ public static REMOVE_ROW_TIMEOUT = 2000;
+
+ public static start(params: ParamsFileListing): FileListingClient {
+ return new FileListingClient(params);
+ }
+
+ public static createEntryForDeviceList(
+ descriptor: GoogDeviceDescriptor,
+ blockClass: string,
+ params: ParamsDeviceTracker,
+ ): HTMLElement | DocumentFragment | undefined {
+ if (descriptor.state !== 'device') {
+ return;
+ }
+ const entry = document.createElement('div');
+ entry.classList.add('file-listing', blockClass);
+ entry.appendChild(
+ BaseDeviceTracker.buildLink(
+ {
+ action: ACTION.FILE_LISTING,
+ udid: descriptor.udid,
+ path: `${tempPath}/`,
+ },
+ 'list files',
+ params,
+ ),
+ );
+ return entry;
+ }
+
+ private readonly serial: string;
+ private readonly name: string;
+ private readonly tableBodyId: string;
+ private readonly wrapperId: string;
+ private readonly filePushHandler?: FilePushHandler;
+ private readonly parent: HTMLElement;
+ private enterCount = 0;
+ private entries: Entry[] = [];
+ private path: string;
+ private requireClean = false;
+ private requestedPath = '';
+ private downloads: Map = new Map();
+ private uploads: Map = new Map();
+ private tableBody: HTMLElement;
+ private channels: Set = new Set();
+ constructor(params: ParamsFileListing) {
+ super(params);
+ this.parent = document.body;
+ this.serial = this.params.udid;
+ this.path = this.params.path;
+ this.openNewConnection();
+ this.setTitle(`Listing ${this.serial}`);
+ this.setBodyClass('file-listing');
+ this.name = `${TAG} [${this.serial}]`;
+ this.tableBodyId = `${Util.escapeUdid(this.serial)}_list`;
+ this.wrapperId = `wrapper_${this.tableBodyId}`;
+ const fragment = html`
+
+
+
+
+
+
+
+
+ Name
+ Size
+ MTime
+
+
+
+
+
`.content;
+ this.tableBody = fragment.getElementById(this.tableBodyId) as HTMLElement;
+ const wrapper = fragment.getElementById(this.wrapperId);
+ if (wrapper) {
+ wrapper.addEventListener('click', (e) => {
+ if (!e.target || !(e.target instanceof HTMLElement)) {
+ return;
+ }
+ const name = e.target.getAttribute(FileListingClient.PROPERTY_NAME);
+ if (!name) {
+ return;
+ }
+ e.preventDefault();
+ e.cancelBubble = true;
+ const newPath = path.resolve(this.path, name);
+ if (newPath !== this.path) {
+ const entryIdString = e.target.getAttribute(FileListingClient.PROPERTY_ENTRY_ID);
+ let entry: Entry | undefined;
+ let anchor: HTMLElement | undefined;
+ if (entryIdString) {
+ const entryId = parseInt(entryIdString, 10);
+ if (!isNaN(entryId) && this.entries[entryId]) {
+ entry = this.entries[entryId];
+ anchor = e.target;
+ }
+ }
+ this.loadContent(newPath, entry, anchor);
+ }
+ });
+
+ if (this.ws instanceof Multiplexer) {
+ this.filePushHandler = new FilePushHandler(this.parent, new AdbkitFilePushStream(this.ws, this));
+ this.filePushHandler.addEventListener(this);
+ }
+ }
+ this.parent.appendChild(fragment);
+ }
+
+ public onDragEnter(): boolean {
+ if (this.enterCount === 0) {
+ this.addForeground(Foreground.Drop);
+ }
+ this.enterCount++;
+ return true;
+ }
+
+ public onDragLeave(): boolean {
+ this.enterCount--;
+ if (this.enterCount < 0) {
+ this.enterCount = 0;
+ }
+ if (this.enterCount === 0) {
+ this.removeForeground(Foreground.Drop);
+ }
+ return true;
+ }
+
+ public onDrop(): boolean {
+ this.enterCount = 0;
+ this.removeForeground(Foreground.Drop);
+ return true;
+ }
+
+ private findOrCreateEntryRow(fileName: string): HTMLElement {
+ const row = document.getElementById(`entry-${fileName}`);
+ if (row) {
+ return row;
+ }
+ return this.addRow(true, fileName, 'file');
+ }
+
+ public onFilePushUpdate(data: PushUpdateParams): void {
+ const { fileName, progress, error, message, finished } = data;
+ let upload = this.uploads.get(fileName);
+ if (!upload || document.getElementById(upload.anchor.id) !== upload.anchor) {
+ const row = this.findOrCreateEntryRow(fileName);
+ const anchor = row.getElementsByTagName('a')[0];
+ if (!anchor.id) {
+ anchor.id = `upload_${fileName}`;
+ }
+ const progressEl = this.appendProgressElement(anchor);
+ upload = { row, progressEl, anchor, timeout: null };
+ this.uploads.set(fileName, upload);
+ }
+ const { row, progressEl, anchor } = upload;
+ if (error) {
+ this.uploads.delete(fileName);
+ progressEl.style.width = `100%`;
+ progressEl.classList.add('error');
+ if (!anchor.classList.contains('error')) {
+ anchor.classList.add('error');
+ anchor.innerText = `${fileName}. ${message}`;
+ }
+ if (!upload.timeout) {
+ upload.timeout = window.setTimeout(() => {
+ const parent = row.parentElement;
+ if (parent) {
+ parent.removeChild(row);
+ this.reload();
+ }
+ }, FileListingClient.REMOVE_ROW_TIMEOUT);
+ }
+ } else {
+ anchor.innerText = `${fileName}. ${message}`;
+ progressEl.style.width = `${progress}%`;
+ }
+ if (finished && !error) {
+ this.uploads.delete(fileName);
+ this.reload();
+ }
+ }
+ public onError(error: string | Error): void {
+ console.error(this.name, 'FIXME: implement', error);
+ }
+
+ private addForeground(type: Foreground): void {
+ const fragment = html``.content;
+ this.parent.appendChild(fragment);
+ }
+
+ private removeForeground(type: Foreground): void {
+ const els = this.parent.getElementsByClassName(type);
+ Array.from(els).forEach((el) => {
+ this.parent.removeChild(el);
+ });
+ }
+
+ public static parseParameters(params: URLSearchParams): ParamsFileListing {
+ const typedParams = super.parseParameters(params);
+ const { action } = typedParams;
+ if (action !== ACTION.FILE_LISTING) {
+ throw Error('Incorrect action');
+ }
+ const pathParam = params.get('path');
+ const path = pathParam || '/data/local/tmp';
+ return { ...typedParams, action, udid: Util.parseString(params, 'udid', true), path };
+ }
+
+ protected buildDirectWebSocketUrl(): URL {
+ const localUrl = super.buildDirectWebSocketUrl();
+ localUrl.searchParams.set('action', ACTION.MULTIPLEX);
+ return localUrl;
+ }
+
+ protected onSocketClose(event: CloseEvent): void {
+ if (this.filePushHandler) {
+ this.filePushHandler.release();
+ }
+ console.error(this.name, 'socket closed', event.reason);
+ this.addForeground(Foreground.Connect);
+ }
+
+ protected onSocketMessage(_e: MessageEvent): void {
+ // We create separate channel for each request
+ // Don't expect any messages on this level
+ console.log("接收到的参数234", _e.data)
+
+ }
+
+ protected onSocketOpen(): void {
+ this.loadContent(this.path);
+ }
+
+ protected loadContent(path: string, entry?: Entry, anchor?: HTMLElement, pathToLoadAfter = ''): void {
+ if (!this.ws || this.ws.readyState !== this.ws.OPEN || !(this.ws instanceof Multiplexer)) {
+ return;
+ }
+ if (!entry && (this.channels.size || this.uploads.size)) {
+ return;
+ }
+ this.requireClean = true;
+ this.requestedPath = path;
+ let cmd: string;
+ if (!entry) {
+ cmd = Protocol.STAT;
+ } else if (entry.isFile()) {
+ cmd = Protocol.RECV;
+ } else {
+ cmd = Protocol.LIST;
+ }
+ const len = Buffer.byteLength(path, 'utf-8');
+ const payload = Buffer.alloc(cmd.length + 4 + len);
+ let pos = payload.write(cmd, 0);
+ pos = payload.writeUInt32LE(len, pos);
+ payload.write(path, pos);
+ const channel = this.ws.createChannel(payload);
+ this.channels.add(channel);
+ const download: Download = {
+ receivedBytes: 0,
+ path,
+ entry,
+ anchor,
+ chunks: [],
+ pathToLoadAfter,
+ };
+ this.downloads.set(channel, download);
+ const onMessage = (event: MessageEvent): void => {
+ this.handleReply(channel, event);
+ };
+ const onClose = (): void => {
+ this.channels.delete(channel);
+ this.downloads.delete(channel);
+ channel.removeEventListener('message', onMessage);
+ channel.removeEventListener('close', onClose);
+ };
+ channel.addEventListener('message', onMessage);
+ channel.addEventListener('close', onClose);
+ }
+
+ protected clean(): void {
+ this.tableBody.innerHTML = '';
+ const header = document.getElementById('header');
+ if (header) {
+ header.innerText = `Content ${this.path}`;
+ }
+ this.toggleQuickLinks(this.path);
+
+ // FIXME: should do over way around: load content on hash change
+ const hash = location.hash.replace(/#!/, '');
+ const params = new URLSearchParams(hash);
+ if (params.get('action') === ACTION.FILE_LISTING) {
+ params.set('path', this.path);
+ location.hash = `#!${params.toString()}`;
+ }
+ }
+
+ protected toggleQuickLinks(path: string): void {
+ const isRoot = path === rootPath;
+ const parentEl = document.getElementById(parentDirLinkBox);
+ if (parentEl) {
+ parentEl.classList.toggle('hidden', isRoot);
+ }
+ const rootEl = document.getElementById(rootDirLinkBox);
+ if (rootEl) {
+ rootEl.classList.toggle('hidden', isRoot);
+ }
+ const isTemp = path === tempPath;
+ const tempEl = document.getElementById(tempDirLinkBox);
+ if (tempEl) {
+ tempEl.classList.toggle('hidden', isTemp);
+ }
+ const isStorage = path === storagePath;
+ const storageEl = document.getElementById(storageDirLinkBox);
+ if (storageEl) {
+ storageEl.classList.toggle('hidden', isStorage);
+ }
+ }
+
+ protected handleReply(channel: Multiplexer, e: MessageEvent): void {
+ const data = Buffer.from(e.data);
+ const reply = data.slice(0, 4).toString('ascii');
+ switch (reply) {
+ case Protocol.DENT:
+ const stat = data.slice(4);
+ const mode = stat.readUInt32LE(0);
+ const size = stat.readUInt32LE(4);
+ const mtime = stat.readUInt32LE(8);
+ const namelen = stat.readUInt32LE(12);
+ const name = Util.utf8ByteArrayToString(stat.slice(16, 16 + namelen));
+ this.addEntry(new Entry(name, mode, size, mtime));
+ return;
+ case Protocol.DONE:
+ this.finishDownload(channel);
+ return;
+ case Protocol.STAT: {
+ const download = this.downloads.get(channel);
+ if (!download) {
+ return;
+ }
+ const stat = data.slice(4);
+ const mode = stat.readUInt32LE(0);
+ const size = stat.readUInt32LE(4);
+ const mtime = stat.readUInt32LE(8);
+ const nameString = path.basename(download.path);
+ if (mode === 0) {
+ console.error('FIXME: show error in UI');
+ console.error(`Error: no entity "${download.path}"`);
+ this.channels.delete(channel);
+ this.loadContent(tempPath);
+ return;
+ }
+ const entry = new Entry(nameString, mode, size, mtime);
+ let anchor: HTMLElement | undefined;
+ let nextPath = '';
+ if (!entry.isDirectory()) {
+ nextPath = this.requestedPath = path.dirname(download.path);
+ const row = this.addEntry(entry);
+ anchor = row ? row.getElementsByTagName('a')[0] : undefined;
+ }
+ this.loadContent(download.path, entry, anchor, nextPath);
+ break;
+ }
+ case Protocol.FAIL:
+ const length = data.readUInt32LE(4);
+ const message = Util.utf8ByteArrayToString(data.slice(8, 8 + length));
+ console.error(TAG, `FAIL: ${message}`);
+ return;
+ case Protocol.DATA:
+ const download = this.downloads.get(channel);
+ if (!download) {
+ return;
+ }
+ download.chunks.push(data.slice(4));
+ download.receivedBytes += data.length - 4;
+ if (download.anchor) {
+ let progressElement = download.progressEl;
+ if (!progressElement) {
+ progressElement = this.appendProgressElement(download.anchor);
+ download.progressEl = progressElement;
+ }
+ if (download.entry) {
+ const { size } = download.entry;
+ const percent = (download.receivedBytes * 100) / size;
+ progressElement.style.width = `${percent}%`;
+ }
+ }
+ return;
+ default:
+ console.error(`Unexpected "${reply}"`);
+ }
+ }
+
+ protected appendProgressElement(anchor: HTMLElement): HTMLElement {
+ const progressElement = document.createElement('span');
+ progressElement.className = 'background-progress';
+ const parent = anchor.parentElement;
+ if (parent) {
+ parent.appendChild(progressElement);
+ }
+ return progressElement;
+ }
+
+ protected addEntry(entry: Entry): HTMLElement | undefined {
+ if (this.requireClean) {
+ this.path = this.requestedPath;
+ this.requestedPath = '';
+ this.clean();
+ this.requireClean = false;
+ this.entries.length = 0;
+ }
+ this.entries.push(entry);
+ const entryId = (this.entries.length - 1).toString();
+ if (entry.name === '.') {
+ return;
+ }
+ if (entry.name === FileListingClient.PARENT_DIR) {
+ const el = document.getElementById(parentDirLinkBox);
+ if (el) {
+ const a = el.children[0];
+ if (a) {
+ a.setAttribute(FileListingClient.PROPERTY_ENTRY_ID, entryId);
+ }
+ }
+ return;
+ }
+ const type = entry.isDirectory() ? 'dir' : entry.isSymbolicLink() ? 'link' : entry.isFile() ? 'file' : 'else';
+ const date = entry.mtime.toLocaleString();
+ return this.addRow(false, entry.name, type, entry.size.toString(), date, entryId);
+ }
+
+ protected addRow(push: boolean, name: string, typeClass: string, size = '', date = '', entryId = ''): HTMLElement {
+ const row = document.createElement('tr');
+ row.id = `entry-${name}`;
+ row.classList.add('entry-row');
+ const nameTd = document.createElement('td');
+ nameTd.classList.add('entry-name');
+ const link = document.createElement('a');
+ link.classList.add('icon', typeClass);
+ link.setAttribute(FileListingClient.PROPERTY_NAME, name);
+ if (entryId) {
+ link.setAttribute(FileListingClient.PROPERTY_ENTRY_ID, entryId);
+ }
+ link.innerText = name;
+ nameTd.appendChild(link);
+ row.appendChild(nameTd);
+ if (push) {
+ nameTd.colSpan = 3;
+ link.classList.add('push');
+ } else {
+ const href = new URL(location.href);
+ const hash = new URLSearchParams(href.hash.replace(/^#!/, ''));
+ hash.set('path', path.join(this.path, name));
+ href.hash = `#!${hash.toString()}`;
+ link.href = href.toString();
+ const sizeTd = document.createElement('td');
+ sizeTd.classList.add('entry-size');
+ sizeTd.innerText = size;
+ row.appendChild(sizeTd);
+ const mtimeTd = document.createElement('td');
+ mtimeTd.classList.add('entry-time');
+ mtimeTd.innerText = date;
+ row.appendChild(mtimeTd);
+ }
+ if (push || !this.tableBody.children.length) {
+ this.tableBody.insertBefore(row, this.tableBody.firstChild);
+ } else {
+ this.tableBody.appendChild(row);
+ }
+ return row;
+ }
+
+ protected finishDownload(channel: Multiplexer): void {
+ const download = this.downloads.get(channel);
+ if (!download) {
+ return;
+ }
+ this.downloads.delete(channel);
+ const el = download.progressEl;
+ if (el) {
+ this.cleanProgress(el);
+ }
+ let name: string;
+ if (download.entry && download.entry.isFile()) {
+ name = download.entry.name;
+ } else {
+ // we always should have `download.entry` and never be here
+ name = path.basename(this.path);
+ }
+ if (download.pathToLoadAfter) {
+ this.channels.delete(channel);
+ this.loadContent(download.pathToLoadAfter);
+ }
+ const file = new File(download.chunks, name, { type: 'application/octet-stream' });
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(file);
+ a.download = `${name}`;
+ a.click();
+ }
+
+ protected cleanProgress(el: HTMLElement): void {
+ el.classList.add('finished');
+ setTimeout(() => {
+ const parent = el.parentElement;
+ if (parent) {
+ parent.removeChild(el);
+ }
+ });
+ }
+
+ public getPath(): string {
+ return this.path;
+ }
+
+ public reload(): void {
+ this.loadContent(this.path);
+ }
+
+ protected supportMultiplexing(): boolean {
+ return true;
+ }
+
+ protected getChannelInitData(): Buffer {
+ const serial = Util.stringToUtf8ByteArray(this.serial);
+ const buffer = Buffer.alloc(4 + 4 + serial.byteLength);
+ buffer.write(ChannelCode.FSLS, 'ascii');
+ buffer.writeUInt32LE(serial.length, 4);
+ buffer.set(serial, 8);
+ return buffer;
+ }
+}
diff --git a/src/app/googDevice/client/ShellClient.ts b/src/app/googDevice/client/ShellClient.ts
new file mode 100644
index 0000000..e78538b
--- /dev/null
+++ b/src/app/googDevice/client/ShellClient.ts
@@ -0,0 +1,147 @@
+import 'xterm/css/xterm.css';
+import { ManagerClient } from '../../client/ManagerClient';
+import { Terminal } from 'xterm';
+import { AttachAddon } from 'xterm-addon-attach';
+import { FitAddon } from 'xterm-addon-fit';
+import { MessageXtermClient } from '../../../types/MessageXtermClient';
+import { ACTION } from '../../../common/Action';
+import { ParamsShell } from '../../../types/ParamsShell';
+import GoogDeviceDescriptor from '../../../types/GoogDeviceDescriptor';
+import { BaseDeviceTracker } from '../../client/BaseDeviceTracker';
+import Util from '../../Util';
+import { ParamsDeviceTracker } from '../../../types/ParamsDeviceTracker';
+import { ChannelCode } from '../../../common/ChannelCode';
+
+const TAG = '[ShellClient]';
+
+export class ShellClient extends ManagerClient {
+ public static ACTION = ACTION.SHELL;
+ public static start(params: ParamsShell): ShellClient {
+ return new ShellClient(params);
+ }
+
+ private readonly term: Terminal;
+ private readonly fitAddon: FitAddon;
+ private readonly escapedUdid: string;
+ private readonly udid: string;
+
+ constructor(params: ParamsShell) {
+ super(params);
+ this.udid = params.udid;
+ this.openNewConnection();
+ this.setTitle(`Shell ${this.udid}`);
+ this.setBodyClass('shell');
+ if (!this.ws) {
+ throw Error('No WebSocket');
+ }
+ this.term = new Terminal();
+ this.term.loadAddon(new AttachAddon(this.ws));
+ this.fitAddon = new FitAddon();
+ this.term.loadAddon(this.fitAddon);
+ this.escapedUdid = Util.escapeUdid(this.udid);
+ this.term.open(ShellClient.getOrCreateContainer(this.escapedUdid));
+ this.updateTerminalSize();
+ this.term.focus();
+ }
+
+ protected supportMultiplexing(): boolean {
+ return true;
+ }
+
+ public static parseParameters(params: URLSearchParams): ParamsShell {
+ const typedParams = super.parseParameters(params);
+ const { action } = typedParams;
+ if (action !== ACTION.SHELL) {
+ throw Error('Incorrect action');
+ }
+ return { ...typedParams, action, udid: Util.parseString(params, 'udid', true) };
+ }
+
+ protected onSocketOpen = (): void => {
+ this.startShell(this.udid);
+ };
+
+ protected onSocketClose(event: CloseEvent): void {
+ console.log(TAG, `Connection closed: ${event.reason}`);
+ this.term.dispose();
+ }
+
+ protected onSocketMessage(): void {
+ // messages are processed by Attach Addon
+ }
+
+ public startShell(udid: string): void {
+ if (!udid || !this.ws || this.ws.readyState !== this.ws.OPEN) {
+ return;
+ }
+ const { rows, cols } = this.fitAddon.proposeDimensions();
+ const message: MessageXtermClient = {
+ id: 1,
+ type: 'shell',
+ data: {
+ type: 'start',
+ rows,
+ cols,
+ udid,
+ },
+ };
+ console.log('发送的参数5');
+
+ this.ws.send(JSON.stringify(message));
+ }
+
+ private static getOrCreateContainer(udid: string): HTMLElement {
+ let container = document.getElementById(udid);
+ if (!container) {
+ container = document.createElement('div');
+ container.className = 'terminal-container';
+ container.id = udid;
+ document.body.appendChild(container);
+ }
+ return container;
+ }
+
+ private updateTerminalSize(): void {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const term: any = this.term;
+ const terminalContainer: HTMLElement = ShellClient.getOrCreateContainer(this.escapedUdid);
+ const { rows, cols } = this.fitAddon.proposeDimensions();
+ const width =
+ (cols * term._core._renderService.dimensions.actualCellWidth + term._core.viewport.scrollBarWidth).toFixed(
+ 2,
+ ) + 'px';
+ const height = (rows * term._core._renderService.dimensions.actualCellHeight).toFixed(2) + 'px';
+ terminalContainer.style.width = width;
+ terminalContainer.style.height = height;
+ this.fitAddon.fit();
+ }
+
+ public static createEntryForDeviceList(
+ descriptor: GoogDeviceDescriptor,
+ blockClass: string,
+ params: ParamsDeviceTracker,
+ ): HTMLElement | DocumentFragment | undefined {
+ if (descriptor.state !== 'device') {
+ return;
+ }
+ const entry = document.createElement('div');
+ entry.classList.add('shell', blockClass);
+ entry.appendChild(
+ BaseDeviceTracker.buildLink(
+ {
+ action: ACTION.SHELL,
+ udid: descriptor.udid,
+ },
+ 'shell',
+ params,
+ ),
+ );
+ return entry;
+ }
+
+ protected getChannelInitData(): Buffer {
+ const buffer = Buffer.alloc(4);
+ buffer.write(ChannelCode.SHEL, 'ascii');
+ return buffer;
+ }
+}
diff --git a/src/app/googDevice/client/StreamClientScrcpy.ts b/src/app/googDevice/client/StreamClientScrcpy.ts
new file mode 100644
index 0000000..6a22e14
--- /dev/null
+++ b/src/app/googDevice/client/StreamClientScrcpy.ts
@@ -0,0 +1,506 @@
+import { BaseClient } from '../../client/BaseClient';
+import { ParamsStreamScrcpy } from '../../../types/ParamsStreamScrcpy';
+import { GoogMoreBox } from '../toolbox/GoogMoreBox';
+import { GoogToolBox } from '../toolbox/GoogToolBox';
+import VideoSettings from '../../VideoSettings';
+import Size from '../../Size';
+import { ControlMessage } from '../../controlMessage/ControlMessage';
+import { ClientsStats, DisplayCombinedInfo } from '../../client/StreamReceiver';
+import { CommandControlMessage } from '../../controlMessage/CommandControlMessage';
+import Util from '../../Util';
+import FilePushHandler from '../filePush/FilePushHandler';
+import DragAndPushLogger from '../DragAndPushLogger';
+import { KeyEventListener, KeyInputHandler } from '../KeyInputHandler';
+import { KeyCodeControlMessage } from '../../controlMessage/KeyCodeControlMessage';
+import { BasePlayer, PlayerClass } from '../../player/BasePlayer';
+import GoogDeviceDescriptor from '../../../types/GoogDeviceDescriptor';
+import { ConfigureScrcpy } from './ConfigureScrcpy';
+import { DeviceTracker } from './DeviceTracker';
+import { ControlCenterCommand } from '../../../common/ControlCenterCommand';
+import { html } from '../../ui/HtmlTag';
+import {
+ FeaturedInteractionHandler,
+ InteractionHandlerListener,
+} from '../../interactionHandler/FeaturedInteractionHandler';
+import DeviceMessage from '../DeviceMessage';
+import { DisplayInfo } from '../../DisplayInfo';
+import { Attribute } from '../../Attribute';
+import { HostTracker } from '../../client/HostTracker';
+import { ACTION } from '../../../common/Action';
+import { StreamReceiverScrcpy } from './StreamReceiverScrcpy';
+import { ParamsDeviceTracker } from '../../../types/ParamsDeviceTracker';
+import { ScrcpyFilePushStream } from '../filePush/ScrcpyFilePushStream';
+
+type StartParams = {
+ udid: string;
+ playerName?: string;
+ player?: BasePlayer;
+ fitToScreen?: boolean;
+ videoSettings?: VideoSettings;
+};
+
+const TAG = '[StreamClientScrcpy]';
+
+export class StreamClientScrcpy
+ extends BaseClient
+ implements KeyEventListener, InteractionHandlerListener {
+ public static ACTION = 'stream';
+ private static players: Map = new Map();
+
+ private controlButtons?: HTMLElement;
+ private deviceName = '';
+ private clientId = -1;
+ private clientsCount = -1;
+ private joinedStream = false;
+ private requestedVideoSettings?: VideoSettings;
+ private touchHandler?: FeaturedInteractionHandler;
+ private moreBox?: GoogMoreBox;
+ private player?: BasePlayer;
+ private filePushHandler?: FilePushHandler;
+ private fitToScreen?: boolean;
+ private readonly streamReceiver: StreamReceiverScrcpy;
+
+ public static registerPlayer(playerClass: PlayerClass): void {
+ if (playerClass.isSupported()) {
+ this.players.set(playerClass.playerFullName, playerClass);
+ }
+ }
+
+ public static getPlayers(): PlayerClass[] {
+ return Array.from(this.players.values());
+ }
+
+ private static getPlayerClass(playerName: string): PlayerClass | undefined {
+ let playerClass: PlayerClass | undefined;
+ for (const value of StreamClientScrcpy.players.values()) {
+ if (value.playerFullName === playerName || value.playerCodeName === playerName) {
+ playerClass = value;
+ }
+ }
+ return playerClass;
+ }
+
+ public static createPlayer(playerName: string, udid: string, displayInfo?: DisplayInfo): BasePlayer | undefined {
+ const playerClass = this.getPlayerClass(playerName);
+ if (!playerClass) {
+ return;
+ }
+ return new playerClass(udid, displayInfo);
+ }
+
+ public static getFitToScreen(playerName: string, udid: string, displayInfo?: DisplayInfo): boolean {
+ const playerClass = this.getPlayerClass(playerName);
+ if (!playerClass) {
+ return false;
+ }
+ return playerClass.getFitToScreenStatus(udid, displayInfo);
+ }
+
+ public static start(
+ query: URLSearchParams | ParamsStreamScrcpy,
+ streamReceiver?: StreamReceiverScrcpy,
+ player?: BasePlayer,
+ fitToScreen?: boolean,
+ videoSettings?: VideoSettings,
+ ): StreamClientScrcpy {
+ if (query instanceof URLSearchParams) {
+ const params = StreamClientScrcpy.parseParameters(query);
+ return new StreamClientScrcpy(params, streamReceiver, player, fitToScreen, videoSettings);
+ } else {
+ return new StreamClientScrcpy(query, streamReceiver, player, fitToScreen, videoSettings);
+ }
+ }
+
+ private static createVideoSettingsWithBounds(old: VideoSettings, newBounds: Size): VideoSettings {
+ return new VideoSettings({
+ crop: old.crop,
+ bitrate: old.bitrate,
+ bounds: newBounds,
+ maxFps: old.maxFps,
+ iFrameInterval: old.iFrameInterval,
+ sendFrameMeta: old.sendFrameMeta,
+ lockedVideoOrientation: old.lockedVideoOrientation,
+ displayId: old.displayId,
+ codecOptions: old.codecOptions,
+ encoderName: old.encoderName,
+ });
+ }
+
+ protected constructor(
+ params: ParamsStreamScrcpy,
+ streamReceiver?: StreamReceiverScrcpy,
+ player?: BasePlayer,
+ fitToScreen?: boolean,
+ videoSettings?: VideoSettings,
+ ) {
+ super(params);
+ if (streamReceiver) {
+ this.streamReceiver = streamReceiver;
+ } else {
+ this.streamReceiver = new StreamReceiverScrcpy(this.params);
+ }
+
+ const { udid, player: playerName } = this.params;
+ this.startStream({ udid, player, playerName, fitToScreen, videoSettings });
+ this.setBodyClass('stream');
+ }
+
+ public static parseParameters(params: URLSearchParams): ParamsStreamScrcpy {
+ const typedParams = super.parseParameters(params);
+ const { action } = typedParams;
+ if (action !== ACTION.STREAM_SCRCPY) {
+ throw Error('Incorrect action');
+ }
+ return {
+ ...typedParams,
+ action,
+ player: Util.parseString(params, 'player', true),
+ udid: Util.parseString(params, 'udid', true),
+ ws: Util.parseString(params, 'ws', true),
+ };
+ }
+
+ public OnDeviceMessage = (message: DeviceMessage): void => {
+ if (this.moreBox) {
+ console.log('message', message)
+ this.moreBox.OnDeviceMessage(message);
+ }
+ };
+
+ public onVideo = (data: ArrayBuffer): void => {
+
+ if (!this.player) {
+ return;
+ }
+ const STATE = BasePlayer.STATE;
+ if (this.player.getState() === STATE.PAUSED) {
+ this.player.play();
+ }
+ if (this.player.getState() === STATE.PLAYING) {
+ this.player.pushFrame(new Uint8Array(data));
+ }
+ };
+
+ public onClientsStats = (stats: ClientsStats): void => {
+ this.deviceName = stats.deviceName;
+ this.clientId = stats.clientId;
+ this.setTitle(`Stream ${this.deviceName}`);
+ };
+
+ public onDisplayInfo = (infoArray: DisplayCombinedInfo[]): void => {
+ if (!this.player) {
+ return;
+ }
+ let currentSettings = this.player.getVideoSettings();
+ const displayId = currentSettings.displayId;
+ const info = infoArray.find((value) => {
+ return value.displayInfo.displayId === displayId;
+ });
+ if (!info) {
+ return;
+ }
+ if (this.player.getState() === BasePlayer.STATE.PAUSED) {
+ this.player.play();
+ }
+ const { videoSettings, screenInfo } = info;
+ this.player.setDisplayInfo(info.displayInfo);
+ if (typeof this.fitToScreen !== 'boolean') {
+ this.fitToScreen = this.player.getFitToScreenStatus();
+ }
+ if (this.fitToScreen) {
+ const newBounds = this.getMaxSize();
+ if (newBounds) {
+ currentSettings = StreamClientScrcpy.createVideoSettingsWithBounds(currentSettings, newBounds);
+ this.player.setVideoSettings(currentSettings, this.fitToScreen, false);
+ }
+ }
+ if (!videoSettings || !screenInfo) {
+ this.joinedStream = true;
+ this.sendMessage(CommandControlMessage.createSetVideoSettingsCommand(currentSettings));
+ return;
+ }
+
+ this.clientsCount = info.connectionCount;
+ let min = VideoSettings.copy(videoSettings);
+ const oldInfo = this.player.getScreenInfo();
+ if (!screenInfo.equals(oldInfo)) {
+ this.player.setScreenInfo(screenInfo);
+ }
+
+ if (!videoSettings.equals(currentSettings)) {
+ this.applyNewVideoSettings(videoSettings, videoSettings.equals(this.requestedVideoSettings));
+ }
+ if (!oldInfo) {
+ const bounds = currentSettings.bounds;
+ const videoSize: Size = screenInfo.videoSize;
+ const onlyOneClient = this.clientsCount === 0;
+ const smallerThenCurrent = bounds && (bounds.width < videoSize.width || bounds.height < videoSize.height);
+ if (onlyOneClient || smallerThenCurrent) {
+ min = currentSettings;
+ }
+ const minBounds = currentSettings.bounds?.intersect(min.bounds);
+ if (minBounds && !minBounds.equals(min.bounds)) {
+ min = StreamClientScrcpy.createVideoSettingsWithBounds(min, minBounds);
+ }
+ }
+ if (!min.equals(videoSettings) || !this.joinedStream) {
+ this.joinedStream = true;
+ this.sendMessage(CommandControlMessage.createSetVideoSettingsCommand(min));
+ }
+ };
+
+ public onDisconnected = (): void => {
+ this.streamReceiver.off('deviceMessage', this.OnDeviceMessage);
+ this.streamReceiver.off('video', this.onVideo);
+ this.streamReceiver.off('clientsStats', this.onClientsStats);
+ this.streamReceiver.off('displayInfo', this.onDisplayInfo);
+ this.streamReceiver.off('disconnected', this.onDisconnected);
+
+ this.filePushHandler?.release();
+ this.filePushHandler = undefined;
+ this.touchHandler?.release();
+ this.touchHandler = undefined;
+ };
+
+ public startStream({ udid, player, playerName, videoSettings, fitToScreen }: StartParams): void {
+ if (!udid) {
+ throw Error(`Invalid udid value: "${udid}"`);
+ }
+
+ this.fitToScreen = fitToScreen;
+ if (!player) {
+ if (typeof playerName !== 'string') {
+ throw Error('Must provide BasePlayer instance or playerName');
+ }
+ let displayInfo: DisplayInfo | undefined;
+ if (this.streamReceiver && videoSettings) {
+ displayInfo = this.streamReceiver.getDisplayInfo(videoSettings.displayId);
+ }
+ const p = StreamClientScrcpy.createPlayer(playerName, udid, displayInfo);
+ if (!p) {
+ throw Error(`Unsupported player: "${playerName}"`);
+ }
+ if (typeof fitToScreen !== 'boolean') {
+ fitToScreen = StreamClientScrcpy.getFitToScreen(playerName, udid, displayInfo);
+ }
+ player = p;
+ }
+ this.player = player;
+ this.setTouchListeners(player);
+
+ if (!videoSettings) {
+ videoSettings = player.getVideoSettings();
+ }
+
+ const deviceView = document.createElement('div');
+ deviceView.className = 'device-view';
+ const stop = (ev?: string | Event) => {
+ if (ev && ev instanceof Event && ev.type === 'error') {
+ console.error(TAG, ev);
+ }
+ let parent;
+ parent = deviceView.parentElement;
+ if (parent) {
+ parent.removeChild(deviceView);
+ }
+ parent = moreBox.parentElement;
+ if (parent) {
+ parent.removeChild(moreBox);
+ }
+ this.streamReceiver.stop();
+ if (this.player) {
+ this.player.stop();
+ }
+ };
+
+ const googMoreBox = (this.moreBox = new GoogMoreBox(udid, player, this));
+ const moreBox = googMoreBox.getHolderElement();
+ googMoreBox.setOnStop(stop);
+ const googToolBox = GoogToolBox.createToolBox(udid, player, this, moreBox);
+ this.controlButtons = googToolBox.getHolderElement();
+ deviceView.appendChild(this.controlButtons);
+ const video = document.createElement('div');
+ video.className = 'video';
+ deviceView.appendChild(video);
+ deviceView.appendChild(moreBox);
+ player.setParent(video);
+ player.pause();
+
+ document.body.appendChild(deviceView);
+ if (fitToScreen) {
+ const newBounds = this.getMaxSize();
+ if (newBounds) {
+ videoSettings = StreamClientScrcpy.createVideoSettingsWithBounds(videoSettings, newBounds);
+ }
+ }
+ this.applyNewVideoSettings(videoSettings, false);
+ const element = player.getTouchableElement();
+ const logger = new DragAndPushLogger(element);
+ this.filePushHandler = new FilePushHandler(element, new ScrcpyFilePushStream(this.streamReceiver));
+ this.filePushHandler.addEventListener(logger);
+
+ const streamReceiver = this.streamReceiver;
+ streamReceiver.on('deviceMessage', this.OnDeviceMessage);
+ streamReceiver.on('video', this.onVideo);
+ streamReceiver.on('clientsStats', this.onClientsStats);
+ streamReceiver.on('displayInfo', this.onDisplayInfo);
+ streamReceiver.on('disconnected', this.onDisconnected);
+ console.log(TAG, player.getName(), udid);
+ }
+
+ public sendMessage(message: ControlMessage): void {
+ console.log("发送消息", message)
+ this.streamReceiver.sendEvent(message);
+ }
+
+ public getDeviceName(): string {
+ return this.deviceName;
+ }
+
+ public setHandleKeyboardEvents(enabled: boolean): void {
+ if (enabled) {
+ KeyInputHandler.addEventListener(this);
+ } else {
+ KeyInputHandler.removeEventListener(this);
+ }
+ }
+
+ public onKeyEvent(event: KeyCodeControlMessage): void {
+ this.sendMessage(event);
+ }
+
+ public sendNewVideoSetting(videoSettings: VideoSettings): void {
+ this.requestedVideoSettings = videoSettings;
+ this.sendMessage(CommandControlMessage.createSetVideoSettingsCommand(videoSettings));
+ }
+
+ public getClientId(): number {
+ return this.clientId;
+ }
+
+ public getClientsCount(): number {
+ return this.clientsCount;
+ }
+
+ public getMaxSize(): Size | undefined {
+ if (!this.controlButtons) {
+ return;
+ }
+ const body = document.body;
+ const width = (body.clientWidth - this.controlButtons.clientWidth) & ~15;
+ const height = body.clientHeight & ~15;
+ return new Size(width, height);
+ }
+
+ private setTouchListeners(player: BasePlayer): void {
+ if (this.touchHandler) {
+ return;
+ }
+ this.touchHandler = new FeaturedInteractionHandler(player, this);
+ }
+
+ private applyNewVideoSettings(videoSettings: VideoSettings, saveToStorage: boolean): void {
+ let fitToScreen = false;
+
+ // TODO: create control (switch/checkbox) instead
+ if (videoSettings.bounds && videoSettings.bounds.equals(this.getMaxSize())) {
+ fitToScreen = true;
+ }
+ if (this.player) {
+ this.player.setVideoSettings(videoSettings, fitToScreen, saveToStorage);
+ }
+ }
+
+ public static createEntryForDeviceList(
+ descriptor: GoogDeviceDescriptor,
+ blockClass: string,
+ fullName: string,
+ params: ParamsDeviceTracker,
+ ): HTMLElement | DocumentFragment | undefined {
+ const hasPid = descriptor.pid !== -1;
+ if (hasPid) {
+ const configureButtonId = `configure_${Util.escapeUdid(descriptor.udid)}`;
+ const e = html`
+
+ Configure stream
+
+
`;
+ const a = e.content.getElementById(configureButtonId);
+ a && (a.onclick = this.onConfigureStreamClick);
+ return e.content;
+ }
+ return;
+ }
+
+ private static onConfigureStreamClick = (event: MouseEvent): void => {
+ const button = event.currentTarget as HTMLAnchorElement;
+ const udid = Util.parseStringEnv(button.getAttribute(Attribute.UDID) || '');
+ const fullName = button.getAttribute(Attribute.FULL_NAME);
+ const secure = Util.parseBooleanEnv(button.getAttribute(Attribute.SECURE) || undefined) || false;
+ const hostname = Util.parseStringEnv(button.getAttribute(Attribute.HOSTNAME) || undefined) || '';
+ const port = Util.parseIntEnv(button.getAttribute(Attribute.PORT) || undefined);
+ const pathname = Util.parseStringEnv(button.getAttribute(Attribute.PATHNAME) || undefined) || '';
+ const useProxy = Util.parseBooleanEnv(button.getAttribute(Attribute.USE_PROXY) || undefined);
+ if (!udid) {
+ throw Error(`Invalid udid value: "${udid}"`);
+ }
+ if (typeof port !== 'number') {
+ throw Error(`Invalid port type: ${typeof port}`);
+ }
+ const tracker = DeviceTracker.getInstance({
+ type: 'android',
+ secure,
+ hostname,
+ port,
+ pathname,
+ useProxy,
+ });
+ const descriptor = tracker.getDescriptorByUdid(udid);
+ if (!descriptor) {
+ return;
+ }
+ event.preventDefault();
+ const elements = document.getElementsByName(`${DeviceTracker.AttributePrefixInterfaceSelectFor}${fullName}`);
+ if (!elements || !elements.length) {
+ return;
+ }
+ const select = elements[0] as HTMLSelectElement;
+ const optionElement = select.options[select.selectedIndex];
+ const ws = optionElement.getAttribute(Attribute.URL);
+ const name = optionElement.getAttribute(Attribute.NAME);
+ if (!ws || !name) {
+ return;
+ }
+ const options: ParamsStreamScrcpy = {
+ udid,
+ ws,
+ player: '',
+ action: ACTION.STREAM_SCRCPY,
+ secure,
+ hostname,
+ port,
+ pathname,
+ useProxy,
+ };
+ const dialog = new ConfigureScrcpy(tracker, descriptor, options);
+ dialog.on('closed', StreamClientScrcpy.onConfigureDialogClosed);
+ };
+
+ private static onConfigureDialogClosed = (event: { dialog: ConfigureScrcpy; result: boolean }): void => {
+ event.dialog.off('closed', StreamClientScrcpy.onConfigureDialogClosed);
+ if (event.result) {
+ HostTracker.getInstance().destroy();
+ }
+ };
+}
diff --git a/src/app/googDevice/client/StreamReceiverScrcpy.ts b/src/app/googDevice/client/StreamReceiverScrcpy.ts
new file mode 100644
index 0000000..f0a114f
--- /dev/null
+++ b/src/app/googDevice/client/StreamReceiverScrcpy.ts
@@ -0,0 +1,24 @@
+import { StreamReceiver } from '../../client/StreamReceiver';
+import { ParamsStreamScrcpy } from '../../../types/ParamsStreamScrcpy';
+import { ACTION } from '../../../common/Action';
+import Util from '../../Util';
+
+export class StreamReceiverScrcpy extends StreamReceiver {
+ public static parseParameters(params: URLSearchParams): ParamsStreamScrcpy {
+ const typedParams = super.parseParameters(params);
+ const { action } = typedParams;
+ if (action !== ACTION.STREAM_SCRCPY) {
+ throw Error('Incorrect action');
+ }
+ return {
+ ...typedParams,
+ action,
+ udid: Util.parseString(params, 'udid', true),
+ ws: Util.parseString(params, 'ws', true),
+ player: Util.parseString(params, 'player', true),
+ };
+ }
+ protected buildDirectWebSocketUrl(): URL {
+ return new URL((this.params as ParamsStreamScrcpy).ws);
+ }
+}
diff --git a/src/app/googDevice/filePush/AdbkitFilePushStream.ts b/src/app/googDevice/filePush/AdbkitFilePushStream.ts
new file mode 100644
index 0000000..f5fa211
--- /dev/null
+++ b/src/app/googDevice/filePush/AdbkitFilePushStream.ts
@@ -0,0 +1,110 @@
+import { FilePushStream } from './FilePushStream';
+import { CommandControlMessage, FilePushState } from '../../controlMessage/CommandControlMessage';
+import { Multiplexer } from '../../../packages/multiplexer/Multiplexer';
+import { FilePushResponseStatus } from './FilePushResponseStatus';
+import Protocol from '@dead50f7/adbkit/lib/adb/protocol';
+import { FileListingClient } from '../client/FileListingClient';
+import * as path from 'path';
+import FilePushHandler from './FilePushHandler';
+
+export class AdbkitFilePushStream extends FilePushStream {
+ private channels: Map = new Map();
+ constructor(private readonly socket: Multiplexer, private readonly fileListingClient: FileListingClient) {
+ super();
+ }
+ public hasConnection(): boolean {
+ return this.socket.readyState == this.socket.OPEN;
+ }
+
+ public isAllowedFile(): boolean {
+ return true;
+ }
+
+ public getChannel(id: number): Multiplexer | undefined {
+ const channel = this.channels.get(id);
+ let code: FilePushResponseStatus = FilePushResponseStatus.NO_ERROR;
+ if (!channel) {
+ code = FilePushResponseStatus.ERROR_UNKNOWN_ID;
+ }
+ if (code) {
+ this.emit('response', { id, code });
+ return;
+ }
+ return channel;
+ }
+
+ public sendEventAppend({ id, chunk }: { id: number; chunk: Uint8Array }): void {
+ const appendParams = { id, chunk, state: FilePushState.APPEND };
+ const channel = this.getChannel(id);
+ if (!channel) {
+ return;
+ }
+ console.log('发送的参数6');
+
+ channel.send(CommandControlMessage.createPushFileCommand(appendParams).toBuffer());
+ }
+
+ public sendEventFinish({ id }: { id: number }): void {
+ const finishParams = { id, state: FilePushState.FINISH };
+ const channel = this.getChannel(id);
+ if (!channel) {
+ return;
+ }
+ console.log('发送的参数7');
+
+ channel.send(CommandControlMessage.createPushFileCommand(finishParams).toBuffer());
+ }
+
+ public sendEventNew({ id }: { id: number }): void {
+ let pushId = id;
+ const newParams = { id, state: FilePushState.NEW };
+ const channel = this.socket.createChannel(Buffer.from(Protocol.SEND));
+ const onMessage = (event: MessageEvent): void => {
+ let offset = 0;
+ const buffer = Buffer.from(event.data);
+ const id = buffer.readInt16BE(offset);
+ offset += 2;
+ const code = buffer.readInt8(offset);
+ if (code === FilePushResponseStatus.NEW_PUSH_ID) {
+ this.channels.set(id, channel);
+ pushId = id;
+ }
+ this.emit('response', { id, code });
+ };
+ const onClose = (event: CloseEvent): void => {
+ if (!event.wasClean) {
+ const code = 4000 - event.code;
+ // this.emit('response', { id: pushId, code });
+ this.emit('error', {
+ id: pushId,
+ error: new Error(FilePushHandler.getErrorMessage(code, event.reason)),
+ });
+ }
+ channel.removeEventListener('message', onMessage);
+ channel.removeEventListener('close', onClose);
+ };
+ channel.addEventListener('message', onMessage);
+ channel.addEventListener('close', onClose);
+ console.log('发送的参数7');
+
+ channel.send(CommandControlMessage.createPushFileCommand(newParams).toBuffer());
+ }
+
+ public sendEventStart({ id, fileName, fileSize }: { id: number; fileName: string; fileSize: number }): void {
+ const filePath = path.join(this.fileListingClient.getPath(), fileName);
+ const startParams = { id, fileName: filePath, fileSize, state: FilePushState.START };
+ const channel = this.getChannel(id);
+ if (!channel) {
+ return;
+ }
+ console.log('发送的参数8');
+
+ channel.send(CommandControlMessage.createPushFileCommand(startParams).toBuffer());
+ }
+
+ public release(): void {
+ this.channels.forEach((channel) => {
+ channel.close();
+ });
+ }
+}
diff --git a/src/app/googDevice/filePush/FilePushHandler.ts b/src/app/googDevice/filePush/FilePushHandler.ts
new file mode 100644
index 0000000..45a508c
--- /dev/null
+++ b/src/app/googDevice/filePush/FilePushHandler.ts
@@ -0,0 +1,256 @@
+import { DragAndDropHandler, DragEventListener } from '../DragAndDropHandler';
+import { FilePushStream, PushResponse } from './FilePushStream';
+import { FilePushResponseStatus } from './FilePushResponseStatus';
+
+type Resolve = (response: PushResponse) => void;
+
+export type PushUpdateParams = {
+ pushId: number;
+ fileName: string;
+ message: string;
+ progress: number;
+ error: boolean;
+ finished: boolean;
+};
+
+export interface DragAndPushListener {
+ onDragEnter: () => boolean;
+ onDragLeave: () => boolean;
+ onDrop: () => boolean;
+ onFilePushUpdate: (data: PushUpdateParams) => void;
+ onError: (error: Error | string) => void;
+}
+
+const TAG = '[FilePushHandler]';
+
+export default class FilePushHandler implements DragEventListener {
+ public static readonly REQUEST_NEW_PUSH_ID = 0; // ignored on server, when state is `NEW_PUSH_ID`
+
+ private responseWaiter: Map = new Map();
+ private listeners: Set = new Set();
+ private pushIdFileNameMap: Map = new Map();
+
+ constructor(private readonly element: HTMLElement, private readonly filePushStream: FilePushStream) {
+ DragAndDropHandler.addEventListener(this);
+ filePushStream.on('response', this.onStreamResponse);
+ filePushStream.on('error', this.onStreamError);
+ }
+
+ private sendUpdate(params: PushUpdateParams): void {
+ if (params.error) {
+ this.pushIdFileNameMap.delete(params.pushId);
+ }
+ this.listeners.forEach((listener) => {
+ listener.onFilePushUpdate(params);
+ });
+ }
+
+ private logError(pushId: number, fileName: string, code: number): void {
+ const msg = RESPONSE_CODES.get(code) || `Unknown error (${code})`;
+ this.sendUpdate({ pushId, fileName, message: `error: "${msg}"`, progress: -1, error: true, finished: true });
+ }
+
+ private static async getStreamReader(file: File): Promise<{
+ reader: ReadableStreamDefaultReader;
+ result: ReadableStreamReadResult;
+ }> {
+ const blob = await new Response(file).blob();
+ const reader = blob.stream().getReader() as ReadableStreamDefaultReader;
+ const result = await reader.read();
+ return { reader, result };
+ }
+
+ private async pushFile(file: File): Promise {
+ const start = Date.now();
+ const { name: fileName, size: fileSize } = file;
+ if (!this.filePushStream.hasConnection()) {
+ this.listeners.forEach((listener) => {
+ listener.onError('WebSocket is not ready');
+ });
+ return;
+ }
+ const id = FilePushHandler.REQUEST_NEW_PUSH_ID;
+ this.sendUpdate({ pushId: id, fileName, message: 'begins...', progress: 0, error: false, finished: false });
+ this.filePushStream.sendEventNew({ id });
+ const { code: pushId } = await this.waitForResponse(id);
+ if (pushId <= 0) {
+ return this.logError(pushId, fileName, pushId);
+ }
+
+ this.pushIdFileNameMap.set(pushId, fileName);
+ const waitPromise = this.waitForResponse(pushId);
+ this.filePushStream.sendEventStart({ id: pushId, fileName, fileSize });
+ const [{ code: startResponseCode }, { reader, result }] = await Promise.all([
+ waitPromise,
+ FilePushHandler.getStreamReader(file),
+ ]);
+ if (startResponseCode !== FilePushResponseStatus.NO_ERROR) {
+ this.logError(pushId, fileName, startResponseCode);
+ return;
+ }
+ let receivedBytes = 0;
+
+ const processData = async ({ done, value }: { done: boolean; value?: Uint8Array }): Promise => {
+ if (done || !value) {
+ this.filePushStream.sendEventFinish({ id: pushId });
+ const { code: finishResponseCode } = await this.waitForResponse(pushId);
+ if (finishResponseCode !== 0) {
+ this.logError(pushId, fileName, finishResponseCode);
+ } else {
+ this.sendUpdate({
+ pushId,
+ fileName,
+ message: 'success!',
+ progress: 100,
+ error: false,
+ finished: true,
+ });
+ }
+ console.log(TAG, `File "${fileName}" uploaded in ${Date.now() - start}ms`);
+ return;
+ }
+
+ receivedBytes += value.length;
+ this.filePushStream.sendEventAppend({ id: pushId, chunk: value });
+
+ const [{ code: appendResponseCode }, result] = await Promise.all([
+ this.waitForResponse(pushId),
+ reader.read(),
+ ]);
+ if (appendResponseCode !== 0) {
+ this.logError(pushId, fileName, appendResponseCode);
+ return;
+ }
+ const progress = (receivedBytes * 100) / fileSize;
+ const message = `${progress.toFixed(2)}%`;
+ this.sendUpdate({ pushId, fileName, message, progress, error: false, finished: false });
+ return processData(result);
+ };
+ return processData(result);
+ }
+
+ private waitForResponse(pushId: number): Promise {
+ return new Promise((resolve) => {
+ const stored = this.responseWaiter.get(pushId);
+ if (Array.isArray(stored)) {
+ stored.push(resolve);
+ } else if (stored) {
+ const arr: Resolve[] = [stored];
+ arr.push(resolve);
+ this.responseWaiter.set(pushId, arr);
+ } else {
+ this.responseWaiter.set(pushId, resolve);
+ }
+ });
+ }
+
+ onStreamError = ({ id: pushId, error }: { id: number; error: Error }): void => {
+ const fileName = this.pushIdFileNameMap.get(pushId) || 'Unknown file';
+ this.sendUpdate({ pushId, fileName, message: error.message, progress: -1, error: true, finished: true });
+ };
+
+ onStreamResponse = (response: PushResponse): void => {
+ let func: Resolve;
+ let value: PushResponse;
+ const { code, id: idInResponse } = response;
+ const id = code === FilePushResponseStatus.NEW_PUSH_ID ? FilePushHandler.REQUEST_NEW_PUSH_ID : response.id;
+ const resolve = this.responseWaiter.get(id);
+ if (!resolve) {
+ console.warn(TAG, `Unexpected push id: "${id}", ${JSON.stringify(response)}`);
+ return;
+ }
+ if (Array.isArray(resolve)) {
+ func = resolve.shift() as Resolve;
+ if (!resolve.length) {
+ this.responseWaiter.delete(id);
+ }
+ } else {
+ func = resolve;
+ this.responseWaiter.delete(id);
+ }
+ if (code === FilePushResponseStatus.NEW_PUSH_ID) {
+ value = { id, code: idInResponse };
+ } else {
+ value = { id, code: code };
+ }
+ func(value);
+ };
+ public onFilesDrop(files: File[]): boolean {
+ this.listeners.forEach((listener) => {
+ listener.onDrop();
+ });
+ files.forEach((file: File) => {
+ const { type, name } = file;
+ if (this.filePushStream.isAllowedFile(file)) {
+ this.pushFile(file);
+ } else {
+ const errorParams: PushUpdateParams = {
+ pushId: FilePushHandler.REQUEST_NEW_PUSH_ID,
+ fileName: name,
+ message: `Unsupported type "${type}"`,
+ progress: -1,
+ error: true,
+ finished: true,
+ };
+ this.sendUpdate(errorParams);
+ }
+ });
+ return true;
+ }
+
+ public static getErrorMessage(code: number, message?: string): string {
+ return message || RESPONSE_CODES.get(code) || 'Unknown error';
+ }
+
+ public onDragEnter(): boolean {
+ let handled = false;
+ this.listeners.forEach((listener) => {
+ handled = handled || listener.onDragEnter();
+ });
+ return handled;
+ }
+
+ public onDragLeave(): boolean {
+ let handled = false;
+ this.listeners.forEach((listener) => {
+ handled = handled || listener.onDragLeave();
+ });
+ return handled;
+ }
+
+ public getElement(): HTMLElement {
+ return this.element;
+ }
+
+ public release(): void {
+ this.filePushStream.off('response', this.onStreamResponse);
+ this.filePushStream.off('error', this.onStreamError);
+ this.filePushStream.release();
+ DragAndDropHandler.removeEventListener(this);
+ this.listeners.clear();
+ }
+
+ public addEventListener(listener: DragAndPushListener): void {
+ this.listeners.add(listener);
+ }
+ public removeEventListener(listener: DragAndPushListener): void {
+ this.listeners.delete(listener);
+ }
+}
+
+const RESPONSE_CODES = new Map([
+ [FilePushResponseStatus.NEW_PUSH_ID, 'New push id'],
+ [FilePushResponseStatus.NO_ERROR, 'No error'],
+
+ [FilePushResponseStatus.ERROR_INVALID_NAME, 'Invalid name'],
+ [FilePushResponseStatus.ERROR_NO_SPACE, 'No space'],
+ [FilePushResponseStatus.ERROR_FAILED_TO_DELETE, 'Failed to delete existing'],
+ [FilePushResponseStatus.ERROR_FAILED_TO_CREATE, 'Failed to create new file'],
+ [FilePushResponseStatus.ERROR_FILE_NOT_FOUND, 'File not found'],
+ [FilePushResponseStatus.ERROR_FAILED_TO_WRITE, 'Failed to write to file'],
+ [FilePushResponseStatus.ERROR_FILE_IS_BUSY, 'File is busy'],
+ [FilePushResponseStatus.ERROR_INVALID_STATE, 'Invalid state'],
+ [FilePushResponseStatus.ERROR_UNKNOWN_ID, 'Unknown id'],
+ [FilePushResponseStatus.ERROR_NO_FREE_ID, 'No free id'],
+ [FilePushResponseStatus.ERROR_INCORRECT_SIZE, 'Incorrect size'],
+]);
diff --git a/src/app/googDevice/filePush/FilePushResponseStatus.ts b/src/app/googDevice/filePush/FilePushResponseStatus.ts
new file mode 100644
index 0000000..02e655e
--- /dev/null
+++ b/src/app/googDevice/filePush/FilePushResponseStatus.ts
@@ -0,0 +1,16 @@
+export enum FilePushResponseStatus {
+ NEW_PUSH_ID = 1,
+ NO_ERROR = 0,
+ ERROR_INVALID_NAME = -1,
+ ERROR_NO_SPACE = -2,
+ ERROR_FAILED_TO_DELETE = -3,
+ ERROR_FAILED_TO_CREATE = -4,
+ ERROR_FILE_NOT_FOUND = -5,
+ ERROR_FAILED_TO_WRITE = -6,
+ ERROR_FILE_IS_BUSY = -7,
+ ERROR_INVALID_STATE = -8,
+ ERROR_UNKNOWN_ID = -9,
+ ERROR_NO_FREE_ID = -10,
+ ERROR_INCORRECT_SIZE = -11,
+ ERROR_OTHER = -12,
+}
diff --git a/src/app/googDevice/filePush/FilePushStream.ts b/src/app/googDevice/filePush/FilePushStream.ts
new file mode 100644
index 0000000..62d7dd4
--- /dev/null
+++ b/src/app/googDevice/filePush/FilePushStream.ts
@@ -0,0 +1,18 @@
+import { TypedEmitter } from '../../../common/TypedEmitter';
+
+export type PushResponse = { id: number; code: number };
+
+interface FilePushStreamEvents {
+ response: PushResponse;
+ error: { id: number; error: Error };
+}
+
+export abstract class FilePushStream extends TypedEmitter {
+ public abstract hasConnection(): boolean;
+ public abstract isAllowedFile(file: File): boolean;
+ public abstract sendEventNew(params: { id: number }): void;
+ public abstract sendEventStart(params: { id: number; fileName: string; fileSize: number }): void;
+ public abstract sendEventFinish(params: { id: number }): void;
+ public abstract sendEventAppend(params: { id: number; chunk: Uint8Array }): void;
+ public abstract release(): void;
+}
diff --git a/src/app/googDevice/filePush/ScrcpyFilePushStream.ts b/src/app/googDevice/filePush/ScrcpyFilePushStream.ts
new file mode 100644
index 0000000..2f3fdc5
--- /dev/null
+++ b/src/app/googDevice/filePush/ScrcpyFilePushStream.ts
@@ -0,0 +1,54 @@
+import { FilePushStream } from './FilePushStream';
+import { StreamReceiverScrcpy } from '../client/StreamReceiverScrcpy';
+import DeviceMessage from '../DeviceMessage';
+import { CommandControlMessage, FilePushState } from '../../controlMessage/CommandControlMessage';
+
+const ALLOWED_TYPES = ['application/vnd.android.package-archive'];
+const ALLOWED_NAME_RE = /\.apk$/i;
+
+export class ScrcpyFilePushStream extends FilePushStream {
+ constructor(private readonly streamReceiver: StreamReceiverScrcpy) {
+ super();
+ streamReceiver.on('deviceMessage', this.onDeviceMessage);
+ }
+ public hasConnection(): boolean {
+ return this.streamReceiver.hasConnection();
+ }
+
+ public isAllowedFile(file: File): boolean {
+ const { type, name } = file;
+ return (type && ALLOWED_TYPES.includes(type)) || (!type && ALLOWED_NAME_RE.test(name));
+ }
+
+ public sendEventAppend({ id, chunk }: { id: number; chunk: Uint8Array }): void {
+ const appendParams = { id, chunk, state: FilePushState.APPEND };
+ this.streamReceiver.sendEvent(CommandControlMessage.createPushFileCommand(appendParams));
+ }
+
+ public sendEventFinish({ id }: { id: number }): void {
+ const finishParams = { id, state: FilePushState.FINISH };
+ this.streamReceiver.sendEvent(CommandControlMessage.createPushFileCommand(finishParams));
+ }
+
+ public sendEventNew({ id }: { id: number }): void {
+ const newParams = { id, state: FilePushState.NEW };
+ this.streamReceiver.sendEvent(CommandControlMessage.createPushFileCommand(newParams));
+ }
+
+ public sendEventStart({ id, fileName, fileSize }: { id: number; fileName: string; fileSize: number }): void {
+ const startParams = { id, fileName, fileSize, state: FilePushState.START };
+ this.streamReceiver.sendEvent(CommandControlMessage.createPushFileCommand(startParams));
+ }
+
+ public release(): void {
+ this.streamReceiver.off('deviceMessage', this.onDeviceMessage);
+ }
+
+ onDeviceMessage = (ev: DeviceMessage): void => {
+ if (ev.type !== DeviceMessage.TYPE_PUSH_RESPONSE) {
+ return;
+ }
+ const stats = ev.getPushStats();
+ this.emit('response', stats);
+ };
+}
diff --git a/src/app/googDevice/toolbox/GoogMoreBox.ts b/src/app/googDevice/toolbox/GoogMoreBox.ts
new file mode 100644
index 0000000..ccc520c
--- /dev/null
+++ b/src/app/googDevice/toolbox/GoogMoreBox.ts
@@ -0,0 +1,319 @@
+import '../../../style/morebox.css';
+import { BasePlayer } from '../../player/BasePlayer';
+import { TextControlMessage } from '../../controlMessage/TextControlMessage';
+import { CommandControlMessage } from '../../controlMessage/CommandControlMessage';
+import { ControlMessage } from '../../controlMessage/ControlMessage';
+import Size from '../../Size';
+import DeviceMessage from '../DeviceMessage';
+import VideoSettings from '../../VideoSettings';
+import { StreamClientScrcpy } from '../client/StreamClientScrcpy';
+
+const TAG = '[GoogMoreBox]';
+
+export class GoogMoreBox {
+ private static defaultSize = new Size(480, 480);
+ private onStop?: () => void;
+ private readonly holder: HTMLElement;
+ private readonly input: HTMLTextAreaElement;
+ private readonly bitrateInput?: HTMLInputElement;
+ private readonly maxFpsInput?: HTMLInputElement;
+ private readonly iFrameIntervalInput?: HTMLInputElement;
+ private readonly maxWidthInput?: HTMLInputElement;
+ private readonly maxHeightInput?: HTMLInputElement;
+
+ constructor(udid: string, private player: BasePlayer, private client: StreamClientScrcpy) {
+ const playerName = player.getName();
+ const videoSettings = player.getVideoSettings();
+ const { displayId } = videoSettings;
+ const preferredSettings = player.getPreferredVideoSetting();
+ const moreBox = document.createElement('div');
+ moreBox.className = 'more-box';
+ const nameBox = document.createElement('p');
+ nameBox.innerText = `${udid} (${playerName})`;
+ nameBox.className = 'text-with-shadow';
+ moreBox.appendChild(nameBox);
+ const input = (this.input = document.createElement('textarea'));
+ input.classList.add('text-area');
+ const sendButton = document.createElement('button');
+ sendButton.innerText = 'Send as keys';
+
+ const inputWrapper = GoogMoreBox.wrap('p', [input, sendButton], moreBox);
+ sendButton.onclick = () => {
+ if (input.value) {
+ client.sendMessage(new TextControlMessage(input.value));
+ }
+ };
+
+ const commands: HTMLElement[] = [];
+ const codes = CommandControlMessage.Commands;
+ for (const [action, command] of codes.entries()) {
+ const btn = document.createElement('button');
+ let bitrateInput: HTMLInputElement;
+ let maxFpsInput: HTMLInputElement;
+ let iFrameIntervalInput: HTMLInputElement;
+ let maxWidthInput: HTMLInputElement;
+ let maxHeightInput: HTMLInputElement;
+ if (action === ControlMessage.TYPE_CHANGE_STREAM_PARAMETERS) {
+ const spoiler = document.createElement('div');
+ const spoilerLabel = document.createElement('label');
+ const spoilerCheck = document.createElement('input');
+
+ const innerDiv = document.createElement('div');
+ const id = `spoiler_video_${udid}_${playerName}_${displayId}_${action}`;
+
+ spoiler.className = 'spoiler';
+ spoilerCheck.type = 'checkbox';
+ spoilerCheck.id = id;
+ spoilerLabel.htmlFor = id;
+ spoilerLabel.innerText = command;
+ innerDiv.className = 'box';
+ spoiler.appendChild(spoilerCheck);
+ spoiler.appendChild(spoilerLabel);
+ spoiler.appendChild(innerDiv);
+
+ const bitrateLabel = document.createElement('label');
+ bitrateLabel.innerText = 'Bitrate:';
+ bitrateInput = document.createElement('input');
+ bitrateInput.placeholder = `${preferredSettings.bitrate} bps`;
+ bitrateInput.value = videoSettings.bitrate.toString();
+ GoogMoreBox.wrap('div', [bitrateLabel, bitrateInput], innerDiv);
+ this.bitrateInput = bitrateInput;
+
+ const maxFpsLabel = document.createElement('label');
+ maxFpsLabel.innerText = 'Max fps:';
+ maxFpsInput = document.createElement('input');
+ maxFpsInput.placeholder = `${preferredSettings.maxFps} fps`;
+ maxFpsInput.value = videoSettings.maxFps.toString();
+ GoogMoreBox.wrap('div', [maxFpsLabel, maxFpsInput], innerDiv);
+ this.maxFpsInput = maxFpsInput;
+
+ const iFrameIntervalLabel = document.createElement('label');
+ iFrameIntervalLabel.innerText = 'I-Frame Interval:';
+ iFrameIntervalInput = document.createElement('input');
+ iFrameIntervalInput.placeholder = `${preferredSettings.iFrameInterval} seconds`;
+ iFrameIntervalInput.value = videoSettings.iFrameInterval.toString();
+ GoogMoreBox.wrap('div', [iFrameIntervalLabel, iFrameIntervalInput], innerDiv);
+ this.iFrameIntervalInput = iFrameIntervalInput;
+
+ const { width, height } = videoSettings.bounds || client.getMaxSize() || GoogMoreBox.defaultSize;
+ const pWidth = preferredSettings.bounds?.width || width;
+ const pHeight = preferredSettings.bounds?.height || height;
+
+ const maxWidthLabel = document.createElement('label');
+ maxWidthLabel.innerText = 'Max width:';
+ maxWidthInput = document.createElement('input');
+ maxWidthInput.placeholder = `${pWidth} px`;
+ maxWidthInput.value = width.toString();
+ GoogMoreBox.wrap('div', [maxWidthLabel, maxWidthInput], innerDiv);
+ this.maxWidthInput = maxWidthInput;
+
+ const maxHeightLabel = document.createElement('label');
+ maxHeightLabel.innerText = 'Max height:';
+ maxHeightInput = document.createElement('input');
+ maxHeightInput.placeholder = `${pHeight} px`;
+ maxHeightInput.value = height.toString();
+ GoogMoreBox.wrap('div', [maxHeightLabel, maxHeightInput], innerDiv);
+ this.maxHeightInput = maxHeightInput;
+
+ innerDiv.appendChild(btn);
+ const fitButton = document.createElement('button');
+ fitButton.innerText = 'Fit';
+ fitButton.onclick = this.fit;
+ innerDiv.insertBefore(fitButton, innerDiv.firstChild);
+ const resetButton = document.createElement('button');
+ resetButton.innerText = 'Reset';
+ resetButton.onclick = this.reset;
+ innerDiv.insertBefore(resetButton, innerDiv.firstChild);
+ commands.push(spoiler);
+ } else {
+ if (
+ action === CommandControlMessage.TYPE_SET_CLIPBOARD ||
+ action === CommandControlMessage.TYPE_GET_CLIPBOARD
+ ) {
+ inputWrapper.appendChild(btn);
+ } else {
+ commands.push(btn);
+ }
+ }
+ btn.innerText = command;
+ if (action === ControlMessage.TYPE_CHANGE_STREAM_PARAMETERS) {
+ btn.onclick = () => {
+ const bitrate = parseInt(bitrateInput.value, 10);
+ const maxFps = parseInt(maxFpsInput.value, 10);
+ const iFrameInterval = parseInt(iFrameIntervalInput.value, 10);
+ if (isNaN(bitrate) || isNaN(maxFps)) {
+ return;
+ }
+ const width = parseInt(maxWidthInput.value, 10) & ~15;
+ const height = parseInt(maxHeightInput.value, 10) & ~15;
+ const bounds = new Size(width, height);
+ const current = player.getVideoSettings();
+ const { lockedVideoOrientation, sendFrameMeta, displayId, codecOptions, encoderName } = current;
+ const videoSettings = new VideoSettings({
+ bounds,
+ bitrate,
+ maxFps,
+ iFrameInterval,
+ lockedVideoOrientation,
+ sendFrameMeta,
+ displayId,
+ codecOptions,
+ encoderName,
+ });
+ client.sendNewVideoSetting(videoSettings);
+ };
+ } else if (action === CommandControlMessage.TYPE_SET_CLIPBOARD) {
+ btn.onclick = () => {
+ const text = input.value;
+ if (text) {
+ console.log(`粘贴的内容${text}`);
+ client.sendMessage(CommandControlMessage.createSetClipboardCommand(text));
+ }
+ };
+ } else {
+ btn.onclick = () => {
+ client.sendMessage(new CommandControlMessage(action));
+ };
+ }
+ }
+ GoogMoreBox.wrap('p', commands, moreBox);
+
+ const screenPowerModeId = `screen_power_mode_${udid}_${playerName}_${displayId}`;
+ const screenPowerModeLabel = document.createElement('label');
+ screenPowerModeLabel.style.display = 'none';
+ const labelTextPrefix = 'Mode';
+ const buttonTextPrefix = 'Set screen power mode';
+ const screenPowerModeCheck = document.createElement('input');
+ screenPowerModeCheck.type = 'checkbox';
+ let mode = (screenPowerModeCheck.checked = false) ? 'ON' : 'OFF';
+ screenPowerModeCheck.id = screenPowerModeLabel.htmlFor = screenPowerModeId;
+ screenPowerModeLabel.innerText = `${labelTextPrefix} ${mode}`;
+ screenPowerModeCheck.onchange = () => {
+ mode = screenPowerModeCheck.checked ? 'ON' : 'OFF';
+ screenPowerModeLabel.innerText = `${labelTextPrefix} ${mode}`;
+ sendScreenPowerModeButton.innerText = `${buttonTextPrefix} ${mode}`;
+ };
+ const sendScreenPowerModeButton = document.createElement('button');
+ sendScreenPowerModeButton.innerText = `${buttonTextPrefix} ${mode}`;
+ sendScreenPowerModeButton.onclick = () => {
+ const message = CommandControlMessage.createSetScreenPowerModeCommand(screenPowerModeCheck.checked);
+ client.sendMessage(message);
+ };
+ GoogMoreBox.wrap('p', [screenPowerModeCheck, screenPowerModeLabel, sendScreenPowerModeButton], moreBox, [
+ 'flex-center',
+ ]);
+
+ const qualityId = `show_video_quality_${udid}_${playerName}_${displayId}`;
+ const qualityLabel = document.createElement('label');
+ const qualityCheck = document.createElement('input');
+ qualityCheck.type = 'checkbox';
+ qualityCheck.checked = BasePlayer.DEFAULT_SHOW_QUALITY_STATS;
+ qualityCheck.id = qualityId;
+ qualityLabel.htmlFor = qualityId;
+ qualityLabel.innerText = 'Show quality stats';
+ GoogMoreBox.wrap('p', [qualityCheck, qualityLabel], moreBox, ['flex-center']);
+ qualityCheck.onchange = () => {
+ player.setShowQualityStats(qualityCheck.checked);
+ };
+
+ const stop = (ev?: string | Event) => {
+ if (ev && ev instanceof Event && ev.type === 'error') {
+ console.error(TAG, ev);
+ }
+ const parent = moreBox.parentElement;
+ if (parent) {
+ parent.removeChild(moreBox);
+ }
+ player.off('video-view-resize', this.onViewVideoResize);
+ if (this.onStop) {
+ this.onStop();
+ delete this.onStop;
+ }
+ };
+
+ const stopBtn = document.createElement('button') as HTMLButtonElement;
+ stopBtn.innerText = `Disconnect`;
+ stopBtn.onclick = stop;
+
+ GoogMoreBox.wrap('p', [stopBtn], moreBox);
+ player.on('video-view-resize', this.onViewVideoResize);
+ player.on('video-settings', this.onVideoSettings);
+ this.holder = moreBox;
+ }
+
+ private onViewVideoResize = (size: Size): void => {
+ // padding: 10px
+ this.holder.style.width = `${size.width - 2 * 10}px`;
+ };
+
+ private onVideoSettings = (videoSettings: VideoSettings): void => {
+ if (this.bitrateInput) {
+ this.bitrateInput.value = videoSettings.bitrate.toString();
+ }
+ if (this.maxFpsInput) {
+ this.maxFpsInput.value = videoSettings.maxFps.toString();
+ }
+ if (this.iFrameIntervalInput) {
+ this.iFrameIntervalInput.value = videoSettings.iFrameInterval.toString();
+ }
+ if (videoSettings.bounds) {
+ const { width, height } = videoSettings.bounds;
+ if (this.maxWidthInput) {
+ this.maxWidthInput.value = width.toString();
+ }
+ if (this.maxHeightInput) {
+ this.maxHeightInput.value = height.toString();
+ }
+ }
+ };
+
+ private fit = (): void => {
+ const { width, height } = this.client.getMaxSize() || GoogMoreBox.defaultSize;
+ if (this.maxWidthInput) {
+ this.maxWidthInput.value = width.toString();
+ }
+ if (this.maxHeightInput) {
+ this.maxHeightInput.value = height.toString();
+ }
+ };
+
+ private reset = (): void => {
+ const preferredSettings = this.player.getPreferredVideoSetting();
+ this.onVideoSettings(preferredSettings);
+ };
+
+ public OnDeviceMessage(ev: DeviceMessage): void {
+ if (ev.type !== DeviceMessage.TYPE_CLIPBOARD) {
+ return;
+ }
+ this.input.value = ev.getText();
+ console.log("this.input.value", this.input.value)
+ this.input.select();
+ document.execCommand('copy');
+ }
+
+ private static wrap(
+ tagName: string,
+ elements: HTMLElement[],
+ parent: HTMLElement,
+ opt_classes?: string[],
+ ): HTMLElement {
+ const wrap = document.createElement(tagName);
+ if (opt_classes) {
+ wrap.classList.add(...opt_classes);
+ }
+ elements.forEach((e) => {
+ wrap.appendChild(e);
+ });
+ parent.appendChild(wrap);
+ return wrap;
+ }
+
+ public getHolderElement(): HTMLElement {
+ return this.holder;
+ }
+
+ public setOnStop(listener: () => void): void {
+ this.onStop = listener;
+ }
+}
diff --git a/src/app/googDevice/toolbox/GoogToolBox.ts b/src/app/googDevice/toolbox/GoogToolBox.ts
new file mode 100644
index 0000000..e27c6a4
--- /dev/null
+++ b/src/app/googDevice/toolbox/GoogToolBox.ts
@@ -0,0 +1,122 @@
+import { ToolBox } from '../../toolbox/ToolBox';
+import KeyEvent from '../android/KeyEvent';
+import SvgImage from '../../ui/SvgImage';
+import { KeyCodeControlMessage } from '../../controlMessage/KeyCodeControlMessage';
+import { ToolBoxButton } from '../../toolbox/ToolBoxButton';
+import { ToolBoxElement } from '../../toolbox/ToolBoxElement';
+import { ToolBoxCheckbox } from '../../toolbox/ToolBoxCheckbox';
+import { StreamClientScrcpy } from '../client/StreamClientScrcpy';
+import { BasePlayer } from '../../player/BasePlayer';
+
+const BUTTONS = [
+ {
+ title: 'Power',
+ code: KeyEvent.KEYCODE_POWER,
+ icon: SvgImage.Icon.POWER,
+ },
+ {
+ title: 'Volume up',
+ code: KeyEvent.KEYCODE_VOLUME_UP,
+ icon: SvgImage.Icon.VOLUME_UP,
+ },
+ {
+ title: 'Volume down',
+ code: KeyEvent.KEYCODE_VOLUME_DOWN,
+ icon: SvgImage.Icon.VOLUME_DOWN,
+ },
+ {
+ title: 'Back',
+ code: KeyEvent.KEYCODE_BACK,
+ icon: SvgImage.Icon.BACK,
+ },
+ {
+ title: 'Home',
+ code: KeyEvent.KEYCODE_HOME,
+ icon: SvgImage.Icon.HOME,
+ },
+ {
+ title: 'Overview',
+ code: KeyEvent.KEYCODE_APP_SWITCH,
+ icon: SvgImage.Icon.OVERVIEW,
+ },
+ {
+ title: '打开抖音',
+ code: -1, // 用特殊值标记,后续处理
+ icon: SvgImage.Icon.OVERVIEW,
+ action: 'launch_app', // 自定义字段,标记特殊动作
+ packageName: 'com.ss.android.ugc.aweme', // 抖音包名
+ },
+];
+
+export class GoogToolBox extends ToolBox {
+ protected constructor(list: ToolBoxElement[]) {
+ super(list);
+ }
+
+ public static createToolBox(
+ udid: string,
+ player: BasePlayer,
+ client: StreamClientScrcpy,
+ moreBox?: HTMLElement,
+ ): GoogToolBox {
+ const playerName = player.getName();
+ const list = BUTTONS.slice();
+ console.log("list", list)
+ const handler = (
+ type: K,
+ element: ToolBoxElement,
+ ) => {
+ const { code } = element.optional || {};
+
+ // 处理普通按键事件
+ if (code && code !== -1) {
+ const actionType = type === 'mousedown' ? KeyEvent.ACTION_DOWN : KeyEvent.ACTION_UP;
+ const event = new KeyCodeControlMessage(actionType, code, 0, 0);
+ client.sendMessage(event);
+ // 处理启动抖音应用
+ } else if (code == -1) {
+ console.log("启动抖音")
+
+ }
+
+ };
+ const elements: ToolBoxElement[] = list.map((item) => {
+ const button = new ToolBoxButton(item.title, item.icon, {
+ code: item.code,
+ });
+ button.addEventListener('mousedown', handler);
+ button.addEventListener('mouseup', handler);
+ return button;
+ });
+ if (player.supportsScreenshot) {
+ const screenshot = new ToolBoxButton('Take screenshot', SvgImage.Icon.CAMERA);
+ screenshot.addEventListener('click', () => {
+ player.createScreenshot(client.getDeviceName());
+ });
+ elements.push(screenshot);
+ }
+
+ const keyboard = new ToolBoxCheckbox(
+ 'Capture keyboard',
+ SvgImage.Icon.KEYBOARD,
+ `capture_keyboard_${udid}_${playerName}`,
+ );
+ keyboard.addEventListener('click', (_, el) => {
+ const element = el.getElement();
+ client.setHandleKeyboardEvents(element.checked);
+ });
+ elements.push(keyboard);
+
+ if (moreBox) {
+ const displayId = player.getVideoSettings().displayId;
+ const id = `show_more_${udid}_${playerName}_${displayId}`;
+ const more = new ToolBoxCheckbox('More', SvgImage.Icon.MORE, id);
+ more.addEventListener('click', (_, el) => {
+ const element = el.getElement();
+ moreBox.style.display = element.checked ? 'block' : 'none';
+ });
+ elements.unshift(more);
+ }
+ return new GoogToolBox(elements);
+ }
+}
diff --git a/src/app/index.ts b/src/app/index.ts
new file mode 100644
index 0000000..cbc7a28
--- /dev/null
+++ b/src/app/index.ts
@@ -0,0 +1,112 @@
+import '../style/app.css';
+import { StreamClientScrcpy } from './googDevice/client/StreamClientScrcpy';
+import { HostTracker } from './client/HostTracker';
+import { Tool } from './client/Tool';
+
+window.onload = async function (): Promise {
+ const hash = location.hash.replace(/^#!/, '');
+ const parsedQuery = new URLSearchParams(hash);
+ const action = parsedQuery.get('action');
+
+ /// #if USE_BROADWAY
+ const { BroadwayPlayer } = await import('./player/BroadwayPlayer');
+ StreamClientScrcpy.registerPlayer(BroadwayPlayer);
+ /// #endif
+
+ /// #if USE_H264_CONVERTER
+ const { MsePlayer } = await import('./player/MsePlayer');
+ StreamClientScrcpy.registerPlayer(MsePlayer);
+ /// #endif
+
+ /// #if USE_TINY_H264
+ const { TinyH264Player } = await import('./player/TinyH264Player');
+ StreamClientScrcpy.registerPlayer(TinyH264Player);
+ /// #endif
+
+ /// #if USE_WEBCODECS
+ const { WebCodecsPlayer } = await import('./player/WebCodecsPlayer');
+ StreamClientScrcpy.registerPlayer(WebCodecsPlayer);
+ /// #endif
+
+ if (action === StreamClientScrcpy.ACTION && typeof parsedQuery.get('udid') === 'string') {
+ StreamClientScrcpy.start(parsedQuery);
+ return;
+ }
+
+ /// #if INCLUDE_APPL
+ {
+ const { DeviceTracker } = await import('./applDevice/client/DeviceTracker');
+
+ /// #if USE_QVH_SERVER
+ const { StreamClientQVHack } = await import('./applDevice/client/StreamClientQVHack');
+
+ DeviceTracker.registerTool(StreamClientQVHack);
+
+ /// #if USE_WEBCODECS
+ const { WebCodecsPlayer } = await import('./player/WebCodecsPlayer');
+ StreamClientQVHack.registerPlayer(WebCodecsPlayer);
+ /// #endif
+
+ /// #if USE_H264_CONVERTER
+ const { MsePlayerForQVHack } = await import('./player/MsePlayerForQVHack');
+ StreamClientQVHack.registerPlayer(MsePlayerForQVHack);
+ /// #endif
+
+ if (action === StreamClientQVHack.ACTION && typeof parsedQuery.get('udid') === 'string') {
+ StreamClientQVHack.start(StreamClientQVHack.parseParameters(parsedQuery));
+ return;
+ }
+ /// #endif
+
+ /// #if USE_WDA_MJPEG_SERVER
+ const { StreamClientMJPEG } = await import('./applDevice/client/StreamClientMJPEG');
+ DeviceTracker.registerTool(StreamClientMJPEG);
+
+ const { MjpegPlayer } = await import('./player/MjpegPlayer');
+ StreamClientMJPEG.registerPlayer(MjpegPlayer);
+
+ if (action === StreamClientMJPEG.ACTION && typeof parsedQuery.get('udid') === 'string') {
+ StreamClientMJPEG.start(StreamClientMJPEG.parseParameters(parsedQuery));
+ return;
+ }
+ /// #endif
+ }
+ /// #endif
+
+ const tools: Tool[] = [];
+
+ /// #if INCLUDE_ADB_SHELL
+ const { ShellClient } = await import('./googDevice/client/ShellClient');
+ if (action === ShellClient.ACTION && typeof parsedQuery.get('udid') === 'string') {
+ ShellClient.start(ShellClient.parseParameters(parsedQuery));
+ return;
+ }
+ tools.push(ShellClient);
+ /// #endif
+
+ /// #if INCLUDE_DEV_TOOLS
+ const { DevtoolsClient } = await import('./googDevice/client/DevtoolsClient');
+ if (action === DevtoolsClient.ACTION) {
+ DevtoolsClient.start(DevtoolsClient.parseParameters(parsedQuery));
+ return;
+ }
+ tools.push(DevtoolsClient);
+ /// #endif
+
+ /// #if INCLUDE_FILE_LISTING
+ const { FileListingClient } = await import('./googDevice/client/FileListingClient');
+ if (action === FileListingClient.ACTION) {
+ FileListingClient.start(FileListingClient.parseParameters(parsedQuery));
+ return;
+ }
+ tools.push(FileListingClient);
+ /// #endif
+
+ if (tools.length) {
+ const { DeviceTracker } = await import('./googDevice/client/DeviceTracker');
+ tools.forEach((tool) => {
+ DeviceTracker.registerTool(tool);
+ });
+ }
+ HostTracker.start();
+};
diff --git a/src/app/interactionHandler/FeaturedInteractionHandler.ts b/src/app/interactionHandler/FeaturedInteractionHandler.ts
new file mode 100644
index 0000000..d4b1a84
--- /dev/null
+++ b/src/app/interactionHandler/FeaturedInteractionHandler.ts
@@ -0,0 +1,132 @@
+import { InteractionEvents, KeyEventNames, InteractionHandler } from './InteractionHandler';
+import { BasePlayer } from '../player/BasePlayer';
+import { ControlMessage } from '../controlMessage/ControlMessage';
+import { TouchControlMessage } from '../controlMessage/TouchControlMessage';
+import MotionEvent from '../MotionEvent';
+import ScreenInfo from '../ScreenInfo';
+import { ScrollControlMessage } from '../controlMessage/ScrollControlMessage';
+
+const TAG = '[FeaturedTouchHandler]';
+
+export interface InteractionHandlerListener {
+ sendMessage: (message: ControlMessage) => void;
+}
+
+export class FeaturedInteractionHandler extends InteractionHandler {
+ private static readonly touchEventsNames: InteractionEvents[] = [
+ 'touchstart',
+ 'touchend',
+ 'touchmove',
+ 'touchcancel',
+ 'mousedown',
+ 'mouseup',
+ 'mousemove',
+ 'wheel',
+ ];
+ private static readonly keyEventsNames: KeyEventNames[] = ['keydown', 'keyup'];
+ public static SCROLL_EVENT_THROTTLING_TIME = 30; // one event per 50ms
+ private readonly storedFromMouseEvent = new Map();
+ private readonly storedFromTouchEvent = new Map();
+ private lastScrollEvent?: { time: number; hScroll: number; vScroll: number };
+
+ constructor(player: BasePlayer, public readonly listener: InteractionHandlerListener) {
+ super(player, FeaturedInteractionHandler.touchEventsNames, FeaturedInteractionHandler.keyEventsNames);
+ this.tag.addEventListener('mouseleave', this.onMouseLeave);
+ this.tag.addEventListener('mouseenter', this.onMouseEnter);
+ }
+
+ public buildScrollEvent(event: WheelEvent, screenInfo: ScreenInfo): ScrollControlMessage[] {
+ const messages: ScrollControlMessage[] = [];
+ const touchOnClient = InteractionHandler.buildTouchOnClient(event, screenInfo);
+ if (touchOnClient) {
+ const hScroll = event.deltaX > 0 ? -1 : event.deltaX < -0 ? 1 : 0;
+ const vScroll = event.deltaY > 0 ? -1 : event.deltaY < -0 ? 1 : 0;
+ const time = Date.now();
+ if (
+ !this.lastScrollEvent ||
+ time - this.lastScrollEvent.time > FeaturedInteractionHandler.SCROLL_EVENT_THROTTLING_TIME ||
+ this.lastScrollEvent.vScroll !== vScroll ||
+ this.lastScrollEvent.hScroll !== hScroll
+ ) {
+ this.lastScrollEvent = { time, hScroll, vScroll };
+ messages.push(new ScrollControlMessage(touchOnClient.touch.position, hScroll, vScroll));
+ }
+ }
+ return messages;
+ }
+
+ protected onInteraction(event: MouseEvent | TouchEvent): void {
+ const screenInfo = this.player.getScreenInfo();
+ if (!screenInfo) {
+ return;
+ }
+ let messages: ControlMessage[];
+ let storage: Map;
+ if (event instanceof MouseEvent) {
+ if (event.target !== this.tag) {
+ return;
+ }
+ if (window['WheelEvent'] && event instanceof WheelEvent) {
+ messages = this.buildScrollEvent(event, screenInfo);
+ } else {
+ storage = this.storedFromMouseEvent;
+ messages = this.buildTouchEvent(event, screenInfo, storage);
+ }
+ if (this.over) {
+ this.lastPosition = event;
+ }
+ } else if (window['TouchEvent'] && event instanceof TouchEvent) {
+ // TODO: Research drag from out of the target inside it
+ if (event.target !== this.tag) {
+ return;
+ }
+ storage = this.storedFromTouchEvent;
+ messages = this.formatTouchEvent(event, screenInfo, storage);
+ } else {
+ console.error(TAG, 'Unsupported event', event);
+ return;
+ }
+ if (event.cancelable) {
+ event.preventDefault();
+ }
+ event.stopPropagation();
+ messages.forEach((message) => {
+ this.listener.sendMessage(message);
+ });
+ }
+
+ protected onKey(event: KeyboardEvent): void {
+ if (!this.lastPosition) {
+ return;
+ }
+ const screenInfo = this.player.getScreenInfo();
+ if (!screenInfo) {
+ return;
+ }
+ const { ctrlKey, shiftKey } = event;
+ const { target, button, buttons, clientY, clientX } = this.lastPosition;
+ const type = InteractionHandler.SIMULATE_MULTI_TOUCH;
+ const props = { ctrlKey, shiftKey, type, target, button, buttons, clientX, clientY };
+ this.buildTouchEvent(props, screenInfo, new Map());
+ }
+
+ private onMouseEnter = (): void => {
+ this.over = true;
+ };
+ private onMouseLeave = (): void => {
+ this.lastPosition = undefined;
+ this.over = false;
+ this.storedFromMouseEvent.forEach((message) => {
+ this.listener.sendMessage(InteractionHandler.createEmulatedMessage(MotionEvent.ACTION_UP, message));
+ });
+ this.storedFromMouseEvent.clear();
+ this.clearCanvas();
+ };
+
+ public release(): void {
+ super.release();
+ this.tag.removeEventListener('mouseleave', this.onMouseLeave);
+ this.tag.removeEventListener('mouseenter', this.onMouseEnter);
+ this.storedFromMouseEvent.clear();
+ }
+}
diff --git a/src/app/interactionHandler/InteractionHandler.ts b/src/app/interactionHandler/InteractionHandler.ts
new file mode 100644
index 0000000..8a739b2
--- /dev/null
+++ b/src/app/interactionHandler/InteractionHandler.ts
@@ -0,0 +1,562 @@
+import MotionEvent from '../MotionEvent';
+import ScreenInfo from '../ScreenInfo';
+import { TouchControlMessage } from '../controlMessage/TouchControlMessage';
+import Size from '../Size';
+import Point from '../Point';
+import Position from '../Position';
+import TouchPointPNG from '../../public/images/multitouch/touch_point.png';
+import CenterPointPNG from '../../public/images/multitouch/center_point.png';
+import Util from '../Util';
+import { BasePlayer } from '../player/BasePlayer';
+
+interface Touch {
+ action: number;
+ position: Position;
+ buttons: number;
+ invalid: boolean;
+}
+
+interface TouchOnClient {
+ client: {
+ width: number;
+ height: number;
+ };
+ touch: Touch;
+}
+
+interface CommonTouchAndMouse {
+ clientX: number;
+ clientY: number;
+ type: string;
+ target: EventTarget | null;
+ buttons: number;
+}
+
+interface MiniMouseEvent extends CommonTouchAndMouse {
+ ctrlKey: boolean;
+ shiftKey: boolean;
+ buttons: number;
+}
+
+const TAG = '[TouchHandler]';
+
+export type TouchEventNames =
+ | 'touchstart'
+ | 'touchend'
+ | 'touchmove'
+ | 'touchcancel'
+ | 'mousedown'
+ | 'mouseup'
+ | 'mousemove';
+export type WheelEventNames = 'wheel';
+export type InteractionEvents = TouchEventNames | WheelEventNames;
+export type KeyEventNames = 'keydown' | 'keyup';
+
+export abstract class InteractionHandler {
+ protected static readonly SIMULATE_MULTI_TOUCH = 'SIMULATE_MULTI_TOUCH';
+ protected static readonly STROKE_STYLE: string = '#00BEA4';
+ protected static EVENT_ACTION_MAP: Record = {
+ touchstart: MotionEvent.ACTION_DOWN,
+ touchend: MotionEvent.ACTION_UP,
+ touchmove: MotionEvent.ACTION_MOVE,
+ touchcancel: MotionEvent.ACTION_UP,
+ mousedown: MotionEvent.ACTION_DOWN,
+ mousemove: MotionEvent.ACTION_MOVE,
+ mouseup: MotionEvent.ACTION_UP,
+ [InteractionHandler.SIMULATE_MULTI_TOUCH]: -1,
+ };
+ private static options = Util.supportsPassive() ? { passive: false } : false;
+ private static idToPointerMap: Map = new Map();
+ private static pointerToIdMap: Map = new Map();
+ private static touchPointRadius = 10;
+ private static centerPointRadius = 5;
+ private static touchPointImage?: HTMLImageElement;
+ private static centerPointImage?: HTMLImageElement;
+ private static pointImagesLoaded = false;
+ private static eventListeners: Map> = new Map();
+ private multiTouchActive = false;
+ private multiTouchCenter?: Point;
+ private multiTouchShift = false;
+ private dirtyPlace: Point[] = [];
+ protected readonly ctx: CanvasRenderingContext2D | null;
+ protected readonly tag: HTMLCanvasElement;
+ protected over = false;
+ protected lastPosition?: MouseEvent;
+
+ protected constructor(
+ public readonly player: BasePlayer,
+ public readonly touchEventsNames: InteractionEvents[],
+ public readonly keyEventsNames: KeyEventNames[],
+ ) {
+ this.tag = player.getTouchableElement();
+ this.ctx = this.tag.getContext('2d');
+ InteractionHandler.loadImages();
+ InteractionHandler.bindGlobalListeners(this);
+ }
+
+ protected abstract onInteraction(event: MouseEvent | TouchEvent): void;
+ protected abstract onKey(event: KeyboardEvent): void;
+
+ protected static bindGlobalListeners(interactionHandler: InteractionHandler): void {
+ interactionHandler.touchEventsNames.forEach((eventName) => {
+ let set: Set | undefined = InteractionHandler.eventListeners.get(eventName);
+ if (!set) {
+ set = new Set();
+ document.body.addEventListener(eventName, this.onInteractionEvent, InteractionHandler.options);
+ this.eventListeners.set(eventName, set);
+ }
+ set.add(interactionHandler);
+ });
+ interactionHandler.keyEventsNames.forEach((eventName) => {
+ let set = InteractionHandler.eventListeners.get(eventName);
+ if (!set) {
+ set = new Set();
+ document.body.addEventListener(eventName, this.onKeyEvent);
+ this.eventListeners.set(eventName, set);
+ }
+ set.add(interactionHandler);
+ });
+ }
+
+ protected static unbindListeners(touchHandler: InteractionHandler): void {
+ touchHandler.touchEventsNames.forEach((eventName) => {
+ const set = InteractionHandler.eventListeners.get(eventName);
+ if (!set) {
+ return;
+ }
+ set.delete(touchHandler);
+ if (set.size <= 0) {
+ this.eventListeners.delete(eventName);
+ document.body.removeEventListener(eventName, this.onInteractionEvent);
+ }
+ });
+ touchHandler.keyEventsNames.forEach((eventName) => {
+ const set = InteractionHandler.eventListeners.get(eventName);
+ if (!set) {
+ return;
+ }
+ set.delete(touchHandler);
+ if (set.size <= 0) {
+ this.eventListeners.delete(eventName);
+ document.body.removeEventListener(eventName, this.onKeyEvent);
+ }
+ });
+ }
+
+ protected static onInteractionEvent = (event: MouseEvent | TouchEvent): void => {
+ const set = InteractionHandler.eventListeners.get(event.type as TouchEventNames);
+ if (!set) {
+ return;
+ }
+ set.forEach((instance) => {
+ instance.onInteraction(event);
+ });
+ };
+
+ protected static onKeyEvent = (event: KeyboardEvent): void => {
+ const set = InteractionHandler.eventListeners.get(event.type as KeyEventNames);
+ if (!set) {
+ return;
+ }
+ set.forEach((instance) => {
+ instance.onKey(event);
+ });
+ };
+
+ protected static loadImages(): void {
+ if (this.pointImagesLoaded) {
+ return;
+ }
+ const total = 2;
+ let current = 0;
+
+ const onload = (event: Event) => {
+ if (++current === total) {
+ this.pointImagesLoaded = true;
+ }
+ if (event.target === this.touchPointImage) {
+ this.touchPointRadius = this.touchPointImage.width / 2;
+ } else if (event.target === this.centerPointImage) {
+ this.centerPointRadius = this.centerPointImage.width / 2;
+ }
+ };
+ const touch = (this.touchPointImage = new Image());
+ touch.src = TouchPointPNG;
+ touch.onload = onload;
+ const center = (this.centerPointImage = new Image());
+ center.src = CenterPointPNG;
+ center.onload = onload;
+ }
+
+ protected static getPointerId(type: string, identifier: number): number {
+ if (this.idToPointerMap.has(identifier)) {
+ const pointerId = this.idToPointerMap.get(identifier) as number;
+ if (type === 'touchend' || type === 'touchcancel') {
+ this.idToPointerMap.delete(identifier);
+ this.pointerToIdMap.delete(pointerId);
+ }
+ return pointerId;
+ }
+ let pointerId = 0;
+ while (this.pointerToIdMap.has(pointerId)) {
+ pointerId++;
+ }
+ this.idToPointerMap.set(identifier, pointerId);
+ this.pointerToIdMap.set(pointerId, identifier);
+ return pointerId;
+ }
+
+ protected static buildTouchOnClient(event: CommonTouchAndMouse, screenInfo: ScreenInfo): TouchOnClient | null {
+ const action = this.mapTypeToAction(event.type);
+ const { width, height } = screenInfo.videoSize;
+ const target: HTMLElement = event.target as HTMLElement;
+ const rect = target.getBoundingClientRect();
+ let { clientWidth, clientHeight } = target;
+ let touchX = event.clientX - rect.left;
+ let touchY = event.clientY - rect.top;
+ let invalid = false;
+ if (touchX < 0 || touchX > clientWidth || touchY < 0 || touchY > clientHeight) {
+ invalid = true;
+ }
+ const eps = 1e5;
+ const ratio = width / height;
+ const shouldBe = Math.round(eps * ratio);
+ const haveNow = Math.round((eps * clientWidth) / clientHeight);
+ if (shouldBe > haveNow) {
+ const realHeight = Math.ceil(clientWidth / ratio);
+ const top = (clientHeight - realHeight) / 2;
+ if (touchY < top || touchY > top + realHeight) {
+ invalid = true;
+ }
+ touchY -= top;
+ clientHeight = realHeight;
+ } else if (shouldBe < haveNow) {
+ const realWidth = Math.ceil(clientHeight * ratio);
+ const left = (clientWidth - realWidth) / 2;
+ if (touchX < left || touchX > left + realWidth) {
+ invalid = true;
+ }
+ touchX -= left;
+ clientWidth = realWidth;
+ }
+ const x = (touchX * width) / clientWidth;
+ const y = (touchY * height) / clientHeight;
+ const size = new Size(width, height);
+ const point = new Point(x, y);
+ const position = new Position(point, size);
+ if (x < 0 || y < 0 || x > width || y > height) {
+ invalid = true;
+ }
+ return {
+ client: {
+ width: clientWidth,
+ height: clientHeight,
+ },
+ touch: {
+ invalid,
+ action,
+ position,
+ buttons: event.buttons,
+ },
+ };
+ }
+
+ private static validateMessage(
+ originalEvent: MiniMouseEvent | TouchEvent,
+ message: TouchControlMessage,
+ storage: Map,
+ logPrefix: string,
+ ): TouchControlMessage[] {
+ const messages: TouchControlMessage[] = [];
+ const { action, pointerId } = message;
+ const previous = storage.get(pointerId);
+ if (action === MotionEvent.ACTION_UP) {
+ if (!previous) {
+ console.warn(logPrefix, 'Received ACTION_UP while there are no DOWN stored');
+ } else {
+ storage.delete(pointerId);
+ messages.push(message);
+ }
+ } else if (action === MotionEvent.ACTION_DOWN) {
+ if (previous) {
+ console.warn(logPrefix, 'Received ACTION_DOWN while already has one stored');
+ } else {
+ storage.set(pointerId, message);
+ messages.push(message);
+ }
+ } else if (action === MotionEvent.ACTION_MOVE) {
+ if (!previous) {
+ if (
+ (originalEvent instanceof MouseEvent && originalEvent.buttons) ||
+ (window['TouchEvent'] && originalEvent instanceof TouchEvent)
+ ) {
+ console.warn(logPrefix, 'Received ACTION_MOVE while there are no DOWN stored');
+ const emulated = InteractionHandler.createEmulatedMessage(MotionEvent.ACTION_DOWN, message);
+ messages.push(emulated);
+ storage.set(pointerId, emulated);
+ }
+ } else {
+ messages.push(message);
+ storage.set(pointerId, message);
+ }
+ }
+ return messages;
+ }
+
+ protected static createEmulatedMessage(action: number, event: TouchControlMessage): TouchControlMessage {
+ const { pointerId, position, buttons } = event;
+ let pressure = event.pressure;
+ if (action === MotionEvent.ACTION_UP) {
+ pressure = 0;
+ }
+ return new TouchControlMessage(action, pointerId, position, pressure, buttons);
+ }
+
+ public static mapTypeToAction(type: string): number {
+ return this.EVENT_ACTION_MAP[type];
+ }
+
+ protected getTouch(
+ e: CommonTouchAndMouse,
+ screenInfo: ScreenInfo,
+ ctrlKey: boolean,
+ shiftKey: boolean,
+ ): Touch[] | null {
+ const touchOnClient = InteractionHandler.buildTouchOnClient(e, screenInfo);
+ if (!touchOnClient) {
+ return null;
+ }
+ const { client, touch } = touchOnClient;
+ const result: Touch[] = [touch];
+ if (!ctrlKey) {
+ this.multiTouchActive = false;
+ this.multiTouchCenter = undefined;
+ this.multiTouchShift = false;
+ this.clearCanvas();
+ return result;
+ }
+ const { position, action, buttons } = touch;
+ const { point, screenSize } = position;
+ const { width, height } = screenSize;
+ const { x, y } = point;
+ if (!this.multiTouchActive) {
+ if (shiftKey) {
+ this.multiTouchCenter = point;
+ this.multiTouchShift = true;
+ } else {
+ this.multiTouchCenter = new Point(client.width / 2, client.height / 2);
+ }
+ }
+ this.multiTouchActive = true;
+ let opposite: Point | undefined;
+ let invalid = false;
+ if (this.multiTouchShift && this.multiTouchCenter) {
+ const oppoX = 2 * this.multiTouchCenter.x - x;
+ const oppoY = 2 * this.multiTouchCenter.y - y;
+ opposite = new Point(oppoX, oppoY);
+ if (!(oppoX <= width && oppoX >= 0 && oppoY <= height && oppoY >= 0)) {
+ invalid = true;
+ }
+ } else {
+ opposite = new Point(client.width - x, client.height - y);
+ invalid = touch.invalid;
+ }
+ if (opposite) {
+ result.push({
+ invalid,
+ action,
+ buttons,
+ position: new Position(opposite, screenSize),
+ });
+ }
+ return result;
+ }
+
+ protected drawCircle(ctx: CanvasRenderingContext2D, point: Point, radius: number): void {
+ ctx.beginPath();
+ ctx.arc(point.x, point.y, radius, 0, Math.PI * 2, true);
+ ctx.stroke();
+ }
+
+ public drawLine(point1: Point, point2: Point): void {
+ if (!this.ctx) {
+ return;
+ }
+ this.ctx.save();
+ this.ctx.strokeStyle = InteractionHandler.STROKE_STYLE;
+ this.ctx.beginPath();
+ this.ctx.moveTo(point1.x, point1.y);
+ this.ctx.lineTo(point2.x, point2.y);
+ this.ctx.stroke();
+ this.ctx.restore();
+ }
+
+ protected drawPoint(point: Point, radius: number, image?: HTMLImageElement): void {
+ if (!this.ctx) {
+ return;
+ }
+ let { lineWidth } = this.ctx;
+ if (InteractionHandler.pointImagesLoaded && image) {
+ radius = image.width / 2;
+ lineWidth = 0;
+ this.ctx.drawImage(image, point.x - radius, point.y - radius);
+ } else {
+ this.drawCircle(this.ctx, point, radius);
+ }
+
+ const topLeft = new Point(point.x - radius - lineWidth, point.y - radius - lineWidth);
+ const bottomRight = new Point(point.x + radius + lineWidth, point.y + radius + lineWidth);
+ this.updateDirty(topLeft, bottomRight);
+ }
+
+ public drawPointer(point: Point): void {
+ this.drawPoint(point, InteractionHandler.touchPointRadius, InteractionHandler.touchPointImage);
+ if (this.multiTouchCenter) {
+ this.drawLine(this.multiTouchCenter, point);
+ }
+ }
+
+ public drawCenter(point: Point): void {
+ this.drawPoint(point, InteractionHandler.centerPointRadius, InteractionHandler.centerPointImage);
+ }
+
+ protected updateDirty(topLeft: Point, bottomRight: Point): void {
+ if (!this.dirtyPlace.length) {
+ this.dirtyPlace.push(topLeft, bottomRight);
+ return;
+ }
+ const currentTopLeft = this.dirtyPlace[0];
+ const currentBottomRight = this.dirtyPlace[1];
+ const newTopLeft = new Point(Math.min(currentTopLeft.x, topLeft.x), Math.min(currentTopLeft.y, topLeft.y));
+ const newBottomRight = new Point(
+ Math.max(currentBottomRight.x, bottomRight.x),
+ Math.max(currentBottomRight.y, bottomRight.y),
+ );
+ this.dirtyPlace.length = 0;
+ this.dirtyPlace.push(newTopLeft, newBottomRight);
+ }
+
+ public clearCanvas(): void {
+ const { clientWidth, clientHeight } = this.tag;
+ const ctx = this.ctx;
+ if (ctx && this.dirtyPlace.length) {
+ const topLeft = this.dirtyPlace[0];
+ const bottomRight = this.dirtyPlace[1];
+ this.dirtyPlace.length = 0;
+ const x = Math.max(topLeft.x, 0);
+ const y = Math.max(topLeft.y, 0);
+ const w = Math.min(clientWidth, bottomRight.x - x);
+ const h = Math.min(clientHeight, bottomRight.y - y);
+ ctx.clearRect(x, y, w, h);
+ ctx.strokeStyle = InteractionHandler.STROKE_STYLE;
+ }
+ }
+
+ public formatTouchEvent(
+ e: TouchEvent,
+ screenInfo: ScreenInfo,
+ storage: Map,
+ ): TouchControlMessage[] {
+ const logPrefix = `${TAG}[formatTouchEvent]`;
+ const messages: TouchControlMessage[] = [];
+ const touches = e.changedTouches;
+ if (touches && touches.length) {
+ for (let i = 0, l = touches.length; i < l; i++) {
+ const touch = touches[i];
+ const pointerId = InteractionHandler.getPointerId(e.type, touch.identifier);
+ if (touch.target !== this.tag) {
+ continue;
+ }
+ const previous = storage.get(pointerId);
+ const item: CommonTouchAndMouse = {
+ clientX: touch.clientX,
+ clientY: touch.clientY,
+ type: e.type,
+ buttons: MotionEvent.BUTTON_PRIMARY,
+ target: e.target,
+ };
+ const event = InteractionHandler.buildTouchOnClient(item, screenInfo);
+ if (event) {
+ const { action, buttons, position, invalid } = event.touch;
+ let pressure = 1;
+ if (action === MotionEvent.ACTION_UP) {
+ pressure = 0;
+ } else if (typeof touch.force === 'number') {
+ pressure = touch.force;
+ }
+ if (!invalid) {
+ const message = new TouchControlMessage(action, pointerId, position, pressure, buttons);
+ messages.push(
+ ...InteractionHandler.validateMessage(e, message, storage, `${logPrefix}[validate]`),
+ );
+ } else {
+ if (previous) {
+ messages.push(InteractionHandler.createEmulatedMessage(MotionEvent.ACTION_UP, previous));
+ storage.delete(pointerId);
+ }
+ }
+ } else {
+ console.error(logPrefix, `Failed to format touch`, touch);
+ }
+ }
+ } else {
+ console.error(logPrefix, 'No "touches"', e);
+ }
+ return messages;
+ }
+
+ public buildTouchEvent(
+ e: MiniMouseEvent,
+ screenInfo: ScreenInfo,
+ storage: Map,
+ ): TouchControlMessage[] {
+ const logPrefix = `${TAG}[buildTouchEvent]`;
+ const touches = this.getTouch(e, screenInfo, e.ctrlKey, e.shiftKey);
+ if (!touches) {
+ return [];
+ }
+ const messages: TouchControlMessage[] = [];
+ const points: Point[] = [];
+ this.clearCanvas();
+ touches.forEach((touch: Touch, pointerId: number) => {
+ const { action, buttons, position } = touch;
+ const previous = storage.get(pointerId);
+ if (!touch.invalid) {
+ let pressure = 1.0;
+ if (action === MotionEvent.ACTION_UP) {
+ pressure = 0;
+ }
+ const message = new TouchControlMessage(action, pointerId, position, pressure, buttons);
+ messages.push(...InteractionHandler.validateMessage(e, message, storage, `${logPrefix}[validate]`));
+ points.push(touch.position.point);
+ } else {
+ if (previous) {
+ points.push(previous.position.point);
+ }
+ }
+ });
+ if (this.multiTouchActive) {
+ if (this.multiTouchCenter) {
+ this.drawCenter(this.multiTouchCenter);
+ }
+ points.forEach((point) => {
+ this.drawPointer(point);
+ });
+ }
+ const hasActionUp = messages.find((message) => {
+ return message.action === MotionEvent.ACTION_UP;
+ });
+ if (hasActionUp && storage.size) {
+ console.warn(logPrefix, 'Looks like one of Multi-touch pointers was not raised up');
+ storage.forEach((message) => {
+ messages.push(InteractionHandler.createEmulatedMessage(MotionEvent.ACTION_UP, message));
+ });
+ storage.clear();
+ }
+ return messages;
+ }
+
+ public release(): void {
+ InteractionHandler.unbindListeners(this);
+ }
+}
diff --git a/src/app/interactionHandler/SimpleInteractionHandler.ts b/src/app/interactionHandler/SimpleInteractionHandler.ts
new file mode 100644
index 0000000..7065061
--- /dev/null
+++ b/src/app/interactionHandler/SimpleInteractionHandler.ts
@@ -0,0 +1,86 @@
+import { InteractionEvents, InteractionHandler } from './InteractionHandler';
+import { BasePlayer } from '../player/BasePlayer';
+import ScreenInfo from '../ScreenInfo';
+import Position from '../Position';
+
+export interface TouchHandlerListener {
+ performClick: (position: Position) => void;
+ performScroll: (from: Position, to: Position) => void;
+}
+
+const TAG = '[SimpleTouchHandler]';
+
+export class SimpleInteractionHandler extends InteractionHandler {
+ private startPosition?: Position;
+ private endPosition?: Position;
+ private static readonly touchEventsNames: InteractionEvents[] = ['mousedown', 'mouseup', 'mousemove'];
+ private storage = new Map();
+
+ constructor(player: BasePlayer, private readonly listener: TouchHandlerListener) {
+ super(player, SimpleInteractionHandler.touchEventsNames, []);
+ }
+
+ protected onInteraction(event: MouseEvent | TouchEvent): void {
+ let handled = false;
+ if (!(event instanceof MouseEvent)) {
+ return;
+ }
+ if (event.target === this.tag) {
+ const screenInfo: ScreenInfo = this.player.getScreenInfo() as ScreenInfo;
+ if (!screenInfo) {
+ return;
+ }
+ const events = this.buildTouchEvent(event, screenInfo, this.storage);
+ if (events.length > 1) {
+ console.warn(TAG, 'Too many events', events);
+ return;
+ }
+ const downEventName = 'mousedown';
+ if (events.length === 1) {
+ handled = true;
+ if (event.type === downEventName) {
+ this.startPosition = events[0].position;
+ } else {
+ if (this.startPosition) {
+ this.endPosition = events[0].position;
+ } else {
+ console.warn(TAG, `Received "${event.type}" before "${downEventName}"`);
+ }
+ }
+ if (this.startPosition) {
+ this.drawPointer(this.startPosition.point);
+ }
+ if (this.endPosition) {
+ this.drawPointer(this.endPosition.point);
+ if (this.startPosition) {
+ this.drawLine(this.startPosition.point, this.endPosition.point);
+ }
+ }
+ if (event.type === 'mouseup') {
+ if (this.startPosition && this.endPosition) {
+ this.clearCanvas();
+ if (this.startPosition.point.distance(this.endPosition.point) < 10) {
+ this.listener.performClick(this.endPosition);
+ } else {
+ this.listener.performScroll(this.startPosition, this.endPosition);
+ }
+ }
+ }
+ }
+ if (handled) {
+ if (event.cancelable) {
+ event.preventDefault();
+ }
+ event.stopPropagation();
+ }
+ }
+ if (event.type === 'mouseup') {
+ this.startPosition = undefined;
+ this.endPosition = undefined;
+ }
+ }
+
+ protected onKey(): void {
+ throw Error(`${TAG} Unsupported`);
+ }
+}
diff --git a/src/app/player/BaseCanvasBasedPlayer.ts b/src/app/player/BaseCanvasBasedPlayer.ts
new file mode 100644
index 0000000..613ada3
--- /dev/null
+++ b/src/app/player/BaseCanvasBasedPlayer.ts
@@ -0,0 +1,236 @@
+import { BasePlayer, PlaybackQuality } from './BasePlayer';
+import ScreenInfo from '../ScreenInfo';
+import VideoSettings from '../VideoSettings';
+import { DisplayInfo } from '../DisplayInfo';
+
+type DecodedFrame = {
+ width: number;
+ height: number;
+ frame: any;
+};
+
+interface CanvasDecoder {
+ decode(buffer: Uint8Array, width: number, height: number): void;
+}
+
+export abstract class BaseCanvasBasedPlayer extends BasePlayer {
+ protected framesList: Uint8Array[] = [];
+ protected decodedFrames: DecodedFrame[] = [];
+ protected videoStats: PlaybackQuality[] = [];
+ protected animationFrameId?: number;
+ protected canvas?: CanvasDecoder;
+
+ public static hasWebGLSupport(): boolean {
+ // For some reason if I use here `this.tag` image on canvas will be flattened
+ const testCanvas: HTMLCanvasElement = document.createElement('canvas');
+ const validContextNames = ['webgl', 'experimental-webgl', 'moz-webgl', 'webkit-3d'];
+ let index = 0;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let gl: any = null;
+ while (!gl && index++ < validContextNames.length) {
+ try {
+ gl = testCanvas.getContext(validContextNames[index]);
+ } catch (error: any) {
+ gl = null;
+ }
+ }
+ return !!gl;
+ }
+
+ public static createElement(id?: string): HTMLCanvasElement {
+ const tag = document.createElement('canvas') as HTMLCanvasElement;
+ if (typeof id === 'string') {
+ tag.id = id;
+ }
+ tag.className = 'video-layer';
+ return tag;
+ }
+
+ constructor(
+ udid: string,
+ displayInfo?: DisplayInfo,
+ name = 'Canvas',
+ storageKeyPrefix = 'DummyCanvas',
+ protected tag: HTMLCanvasElement = BaseCanvasBasedPlayer.createElement(),
+ ) {
+ super(udid, displayInfo, name, storageKeyPrefix, tag);
+ }
+
+ protected abstract decode(data: Uint8Array): void;
+ public abstract getPreferredVideoSetting(): VideoSettings;
+
+ protected drawDecoded = (): void => {
+ if (!this.canvas) {
+ return;
+ }
+ if (this.receivedFirstFrame) {
+ const data = this.decodedFrames.shift();
+ if (data) {
+ const { frame, width, height } = data;
+ this.canvas.decode(frame, width, height);
+ }
+ }
+ if (this.decodedFrames.length) {
+ this.animationFrameId = requestAnimationFrame(this.drawDecoded);
+ } else {
+ this.animationFrameId = undefined;
+ }
+ };
+
+ protected onFrameDecoded(width: number, height: number, frame: any): void {
+ if (!this.receivedFirstFrame) {
+ // decoded frame with previous video settings
+ return;
+ }
+ let dropped = 0;
+ const maxStored = this.videoSettings.maxFps / 10; // for 100ms
+
+ while (this.decodedFrames.length > maxStored) {
+ const data = this.decodedFrames.shift();
+ if (data) {
+ this.dropFrame(data.frame);
+ dropped++;
+ }
+ }
+ this.decodedFrames.push({ width, height, frame });
+ this.videoStats.push({
+ decodedFrames: 1,
+ droppedFrames: dropped,
+ inputBytes: 0,
+ inputFrames: 0,
+ timestamp: Date.now(),
+ });
+ if (!this.animationFrameId) {
+ this.animationFrameId = requestAnimationFrame(this.drawDecoded);
+ }
+ }
+
+ protected dropFrame(_frame: any): void {
+ // dispose frame if required
+ }
+
+ private shiftFrame(): void {
+ if (this.getState() !== BasePlayer.STATE.PLAYING) {
+ return;
+ }
+ const first = this.framesList.shift();
+ if (first) {
+ this.decode(first);
+ }
+ }
+
+ protected calculateMomentumStats(): void {
+ const timestamp = Date.now();
+ const oneSecondBefore = timestamp - 1000;
+
+ while (this.videoStats.length && this.videoStats[0].timestamp < oneSecondBefore) {
+ this.videoStats.shift();
+ }
+ while (this.inputBytes.length && this.inputBytes[0].timestamp < oneSecondBefore) {
+ this.inputBytes.shift();
+ }
+ let decodedFrames = 0;
+ let droppedFrames = 0;
+ let inputBytes = 0;
+ this.videoStats.forEach((item) => {
+ decodedFrames += item.decodedFrames;
+ droppedFrames += item.droppedFrames;
+ });
+ this.inputBytes.forEach((item) => {
+ inputBytes += item.bytes;
+ });
+ this.momentumQualityStats = {
+ decodedFrames,
+ droppedFrames,
+ inputFrames: this.inputBytes.length,
+ inputBytes,
+ timestamp,
+ };
+ }
+
+ protected resetStats(): void {
+ super.resetStats();
+ this.videoStats = [];
+ }
+
+ public getImageDataURL(): string {
+ return this.tag.toDataURL();
+ }
+
+ protected initCanvas(width: number, height: number): void {
+ if (this.canvas) {
+ const parent = this.tag.parentNode;
+ if (parent) {
+ const tag = BaseCanvasBasedPlayer.createElement(this.tag.id);
+ tag.className = this.tag.className;
+ parent.replaceChild(tag, this.tag);
+ parent.appendChild(this.touchableCanvas);
+ this.tag = tag;
+ }
+ }
+ this.tag.onerror = (event: Event | string): void => {
+ console.error(`[${this.name}]`, event);
+ };
+ this.tag.oncontextmenu = (event: MouseEvent): void => {
+ event.preventDefault();
+ };
+ this.tag.width = Math.round(width);
+ this.tag.height = Math.round(height);
+ }
+
+ public play(): void {
+ super.play();
+ if (this.getState() !== BasePlayer.STATE.PLAYING || !this.screenInfo) {
+ return;
+ }
+ if (!this.canvas) {
+ const { width, height } = this.screenInfo.videoSize;
+ this.initCanvas(width, height);
+ this.resetStats();
+ }
+ this.shiftFrame();
+ }
+
+ public stop(): void {
+ super.stop();
+ this.clearState();
+ }
+
+ public setScreenInfo(screenInfo: ScreenInfo): void {
+ super.setScreenInfo(screenInfo);
+ this.clearState();
+ const { width, height } = screenInfo.videoSize;
+ this.initCanvas(width, height);
+ this.framesList = [];
+ if (this.animationFrameId) {
+ cancelAnimationFrame(this.animationFrameId);
+ this.animationFrameId = undefined;
+ }
+ }
+
+ public pushFrame(frame: Uint8Array): void {
+ super.pushFrame(frame);
+ if (BasePlayer.isIFrame(frame)) {
+ if (this.videoSettings) {
+ const { maxFps } = this.videoSettings;
+ if (this.framesList.length > maxFps / 2) {
+ const dropped = this.framesList.length;
+ this.framesList = [];
+ this.videoStats.push({
+ decodedFrames: 0,
+ droppedFrames: dropped,
+ inputBytes: 0,
+ inputFrames: 0,
+ timestamp: Date.now(),
+ });
+ }
+ }
+ }
+ this.framesList.push(frame);
+ this.shiftFrame();
+ }
+
+ protected clearState(): void {
+ this.framesList = [];
+ }
+}
diff --git a/src/app/player/BasePlayer.ts b/src/app/player/BasePlayer.ts
new file mode 100644
index 0000000..fb1768f
--- /dev/null
+++ b/src/app/player/BasePlayer.ts
@@ -0,0 +1,577 @@
+import VideoSettings from '../VideoSettings';
+import ScreenInfo from '../ScreenInfo';
+import Rect from '../Rect';
+import Size from '../Size';
+import Util from '../Util';
+import { TypedEmitter } from '../../common/TypedEmitter';
+import { DisplayInfo } from '../DisplayInfo';
+
+interface BitrateStat {
+ timestamp: number;
+ bytes: number;
+}
+
+interface FramesPerSecondStats {
+ avgInput: number;
+ avgDecoded: number;
+ avgDropped: number;
+ avgSize: number;
+}
+
+export interface PlaybackQuality {
+ decodedFrames: number;
+ droppedFrames: number;
+ inputFrames: number;
+ inputBytes: number;
+ timestamp: number;
+}
+
+export interface PlayerEvents {
+ 'video-view-resize': Size;
+ 'input-video-resize': ScreenInfo;
+ 'video-settings': VideoSettings;
+}
+
+export interface PlayerClass {
+ playerFullName: string;
+ playerCodeName: string;
+ storageKeyPrefix: string;
+ isSupported(): boolean;
+ getPreferredVideoSetting(): VideoSettings;
+ getFitToScreenStatus(deviceName: string, displayInfo?: DisplayInfo): boolean;
+ loadVideoSettings(deviceName: string, displayInfo?: DisplayInfo): VideoSettings;
+ saveVideoSettings(
+ deviceName: string,
+ videoSettings: VideoSettings,
+ fitToScreen: boolean,
+ displayInfo?: DisplayInfo,
+ ): void;
+ new(udid: string, displayInfo?: DisplayInfo): BasePlayer;
+}
+
+export abstract class BasePlayer extends TypedEmitter {
+ private static readonly STAT_BACKGROUND: string = 'rgba(0, 0, 0, 0.5)';
+ private static readonly STAT_TEXT_COLOR: string = 'hsl(24, 85%, 50%)';
+ public static readonly DEFAULT_SHOW_QUALITY_STATS = false;
+ public static STATE: Record = {
+ PLAYING: 1,
+ PAUSED: 2,
+ STOPPED: 3,
+ };
+ private static STATS_HEIGHT = 12;
+ protected screenInfo?: ScreenInfo;
+ protected videoSettings: VideoSettings;
+ protected parentElement?: HTMLElement;
+ protected touchableCanvas: HTMLCanvasElement;
+ protected inputBytes: BitrateStat[] = [];
+ protected perSecondQualityStats?: FramesPerSecondStats;
+ protected momentumQualityStats?: PlaybackQuality;
+ protected bounds: Size | null = null;
+ private totalStats: PlaybackQuality = {
+ decodedFrames: 0,
+ droppedFrames: 0,
+ inputFrames: 0,
+ inputBytes: 0,
+ timestamp: 0,
+ };
+ private totalStatsCounter = 0;
+ private dirtyStatsWidth = 0;
+ private state: number = BasePlayer.STATE.STOPPED;
+ private qualityAnimationId?: number;
+ private showQualityStats = BasePlayer.DEFAULT_SHOW_QUALITY_STATS;
+ protected receivedFirstFrame = false;
+ private statLines: string[] = [];
+ public readonly supportsScreenshot: boolean = false;
+ public readonly resizeVideoToBounds: boolean = false;
+ protected videoHeight = -1;
+ protected videoWidth = -1;
+
+ public static storageKeyPrefix = 'BaseDecoder';
+ public static playerFullName = 'BasePlayer';
+ public static playerCodeName = 'baseplayer';
+ public static preferredVideoSettings: VideoSettings = new VideoSettings({
+ lockedVideoOrientation: -1,
+ bitrate: 524288,
+ maxFps: 24,
+ iFrameInterval: 5,
+ bounds: new Size(480, 480),
+ sendFrameMeta: false,
+ });
+
+ public static isSupported(): boolean {
+ // Implement the check in a child class
+ return false;
+ }
+
+ constructor(
+ public readonly udid: string,
+ protected displayInfo?: DisplayInfo,
+ protected name: string = 'BasePlayer',
+ protected storageKeyPrefix: string = 'Dummy',
+ protected tag: HTMLElement = document.createElement('div'),
+ ) {
+ super();
+ this.touchableCanvas = document.createElement('canvas');
+ this.touchableCanvas.className = 'touch-layer';
+ this.touchableCanvas.oncontextmenu = function (event: MouseEvent): void {
+ event.preventDefault();
+ };
+ const preferred = this.getPreferredVideoSetting();
+ this.videoSettings = BasePlayer.getVideoSettingFromStorage(preferred, this.storageKeyPrefix, udid, displayInfo);
+ }
+
+ protected calculateScreenInfoForBounds(videoWidth: number, videoHeight: number): void {
+ this.videoWidth = videoWidth;
+ this.videoHeight = videoHeight;
+ if (this.resizeVideoToBounds) {
+ let w = videoWidth;
+ let h = videoHeight;
+ if (this.bounds) {
+ let { w: boundsWidth, h: boundsHeight } = this.bounds;
+ if (w > boundsWidth || h > boundsHeight) {
+ let scaledHeight;
+ let scaledWidth;
+ if (boundsWidth > w) {
+ scaledHeight = h;
+ } else {
+ scaledHeight = (boundsWidth * h) / w;
+ }
+ if (boundsHeight > scaledHeight) {
+ boundsHeight = scaledHeight;
+ }
+ if (boundsHeight == h) {
+ scaledWidth = w;
+ } else {
+ scaledWidth = (boundsHeight * w) / h;
+ }
+ if (boundsWidth > scaledWidth) {
+ boundsWidth = scaledWidth;
+ }
+ w = boundsWidth | 0;
+ h = boundsHeight | 0;
+ this.tag.style.maxWidth = `${w}px`;
+ this.tag.style.maxHeight = `${h}px`;
+ }
+ }
+ const realScreen = new ScreenInfo(new Rect(0, 0, videoWidth, videoHeight), new Size(w, h), 0);
+ this.emit('input-video-resize', realScreen);
+ this.setScreenInfo(new ScreenInfo(new Rect(0, 0, w, h), new Size(w, h), 0));
+ }
+ }
+
+ protected static isIFrame(frame: Uint8Array): boolean {
+ // last 5 bits === 5: Coded slice of an IDR picture
+
+ // https://www.ietf.org/rfc/rfc3984.txt
+ // 1.3. Network Abstraction Layer Unit Types
+ // https://www.itu.int/rec/T-REC-H.264-201906-I/en
+ // Table 7-1 – NAL unit type codes, syntax element categories, and NAL unit type classes
+ return frame && frame.length > 4 && (frame[4] & 31) === 5;
+ }
+
+ private static getStorageKey(storageKeyPrefix: string, udid: string): string {
+ const { innerHeight, innerWidth } = window;
+ return `${storageKeyPrefix}:${udid}:${innerWidth}x${innerHeight}`;
+ }
+
+ private static getFullStorageKey(storageKeyPrefix: string, udid: string, displayInfo?: DisplayInfo): string {
+ const { innerHeight, innerWidth } = window;
+ let base = `${storageKeyPrefix}:${udid}:${innerWidth}x${innerHeight}`;
+ if (displayInfo) {
+ const { displayId, size } = displayInfo;
+ base = `${base}:${displayId}:${size.width}x${size.height}`;
+ }
+ return base;
+ }
+
+ public static getFromStorageCompat(prefix: string, udid: string, displayInfo?: DisplayInfo): string | null {
+ const shortKey = this.getStorageKey(prefix, udid);
+ const savedInShort = window.localStorage.getItem(shortKey);
+ if (!displayInfo) {
+ return savedInShort;
+ }
+ const isDefaultDisplay = displayInfo.displayId === DisplayInfo.DEFAULT_DISPLAY;
+ const fullKey = this.getFullStorageKey(prefix, udid, displayInfo);
+ const savedInFull = window.localStorage.getItem(fullKey);
+ if (savedInFull) {
+ if (savedInShort && isDefaultDisplay) {
+ window.localStorage.removeItem(shortKey);
+ }
+ return savedInFull;
+ }
+ if (isDefaultDisplay) {
+ return savedInShort;
+ }
+ return null;
+ }
+
+ public static getFitToScreenFromStorage(
+ storageKeyPrefix: string,
+ udid: string,
+ displayInfo?: DisplayInfo,
+ ): boolean {
+ if (!window.localStorage) {
+ return false;
+ }
+ let parsedValue = false;
+ const key = `${this.getFullStorageKey(storageKeyPrefix, udid, displayInfo)}:fit`;
+ const saved = window.localStorage.getItem(key);
+ if (!saved) {
+ return false;
+ }
+ try {
+ parsedValue = JSON.parse(saved);
+ } catch (error: any) {
+ console.error(`[${this.name}]`, 'Failed to parse', saved);
+ }
+ return parsedValue;
+ }
+
+ public static getVideoSettingFromStorage(
+ preferred: VideoSettings,
+ storageKeyPrefix: string,
+ udid: string,
+ displayInfo?: DisplayInfo,
+ ): VideoSettings {
+ if (!window.localStorage) {
+ return preferred;
+ }
+ const saved = this.getFromStorageCompat(storageKeyPrefix, udid, displayInfo);
+ if (!saved) {
+ return preferred;
+ }
+ const parsed = JSON.parse(saved);
+ const {
+ displayId,
+ crop,
+ bitrate,
+ iFrameInterval,
+ sendFrameMeta,
+ lockedVideoOrientation,
+ codecOptions,
+ encoderName,
+ } = parsed;
+
+ // REMOVE `frameRate`
+ const maxFps = isNaN(parsed.maxFps) ? parsed.frameRate : parsed.maxFps;
+ // REMOVE `maxSize`
+ let bounds: Size | null = null;
+ if (typeof parsed.bounds !== 'object' || isNaN(parsed.bounds.width) || isNaN(parsed.bounds.height)) {
+ if (!isNaN(parsed.maxSize)) {
+ bounds = new Size(parsed.maxSize, parsed.maxSize);
+ }
+ } else {
+ bounds = new Size(parsed.bounds.width, parsed.bounds.height);
+ }
+ return new VideoSettings({
+ displayId: typeof displayId === 'number' ? displayId : 0,
+ crop: crop ? new Rect(crop.left, crop.top, crop.right, crop.bottom) : preferred.crop,
+ bitrate: !isNaN(bitrate) ? bitrate : preferred.bitrate,
+ bounds: bounds !== null ? bounds : preferred.bounds,
+ maxFps: !isNaN(maxFps) ? maxFps : preferred.maxFps,
+ iFrameInterval: !isNaN(iFrameInterval) ? iFrameInterval : preferred.iFrameInterval,
+ sendFrameMeta: typeof sendFrameMeta === 'boolean' ? sendFrameMeta : preferred.sendFrameMeta,
+ lockedVideoOrientation: !isNaN(lockedVideoOrientation)
+ ? lockedVideoOrientation
+ : preferred.lockedVideoOrientation,
+ codecOptions,
+ encoderName,
+ });
+ }
+
+ protected static putVideoSettingsToStorage(
+ storageKeyPrefix: string,
+ udid: string,
+ videoSettings: VideoSettings,
+ fitToScreen: boolean,
+ displayInfo?: DisplayInfo,
+ ): void {
+ if (!window.localStorage) {
+ return;
+ }
+ const key = this.getFullStorageKey(storageKeyPrefix, udid, displayInfo);
+ window.localStorage.setItem(key, JSON.stringify(videoSettings));
+ const fitKey = `${key}:fit`;
+ window.localStorage.setItem(fitKey, JSON.stringify(fitToScreen));
+ }
+
+ public abstract getImageDataURL(): string;
+
+ public createScreenshot(deviceName: string): void {
+ const a = document.createElement('a');
+ a.href = this.getImageDataURL();
+ a.download = `${deviceName} ${new Date().toLocaleString()}.png`;
+ a.click();
+ }
+
+ public play(): void {
+ if (this.needScreenInfoBeforePlay() && !this.screenInfo) {
+ return;
+ }
+ this.state = BasePlayer.STATE.PLAYING;
+ }
+
+ public pause(): void {
+ this.state = BasePlayer.STATE.PAUSED;
+ }
+
+ public stop(): void {
+ this.state = BasePlayer.STATE.STOPPED;
+ }
+
+ public getState(): number {
+ return this.state;
+ }
+
+ public pushFrame(frame: Uint8Array): void {
+ if (!this.receivedFirstFrame) {
+ this.receivedFirstFrame = true;
+ if (typeof this.qualityAnimationId !== 'number') {
+ this.qualityAnimationId = requestAnimationFrame(this.updateQualityStats);
+ }
+ }
+ this.inputBytes.push({
+ timestamp: Date.now(),
+ bytes: frame.byteLength,
+ });
+ }
+
+ public abstract getPreferredVideoSetting(): VideoSettings;
+ protected abstract calculateMomentumStats(): void;
+
+ public getTouchableElement(): HTMLCanvasElement {
+ return this.touchableCanvas;
+ }
+
+ public setParent(parent: HTMLElement): void {
+ this.parentElement = parent;
+ parent.appendChild(this.tag);
+ parent.appendChild(this.touchableCanvas);
+ }
+
+ protected needScreenInfoBeforePlay(): boolean {
+ return true;
+ }
+
+ public getVideoSettings(): VideoSettings {
+ return this.videoSettings;
+ }
+
+ public setVideoSettings(videoSettings: VideoSettings, fitToScreen: boolean, saveToStorage: boolean): void {
+ this.videoSettings = videoSettings;
+ if (saveToStorage) {
+ BasePlayer.putVideoSettingsToStorage(
+ this.storageKeyPrefix,
+ this.udid,
+ videoSettings,
+ fitToScreen,
+ this.displayInfo,
+ );
+ }
+ this.resetStats();
+ this.emit('video-settings', VideoSettings.copy(videoSettings));
+ }
+
+ public getScreenInfo(): ScreenInfo | undefined {
+ return this.screenInfo;
+ }
+
+ public setScreenInfo(screenInfo: ScreenInfo): void {
+ if (this.needScreenInfoBeforePlay()) {
+ this.pause();
+ }
+ this.receivedFirstFrame = false;
+ this.screenInfo = screenInfo;
+ const { width, height } = screenInfo.videoSize;
+ this.touchableCanvas.width = width;
+ this.touchableCanvas.height = height;
+ if (this.parentElement) {
+ this.parentElement.style.height = `${height}px`;
+ this.parentElement.style.width = `${width}px`;
+ }
+ const size = new Size(width, height);
+ this.emit('video-view-resize', size);
+ }
+
+ public getName(): string {
+ return this.name;
+ }
+
+ protected resetStats(): void {
+ this.receivedFirstFrame = false;
+ this.totalStatsCounter = 0;
+ this.totalStats = {
+ droppedFrames: 0,
+ decodedFrames: 0,
+ inputFrames: 0,
+ inputBytes: 0,
+ timestamp: 0,
+ };
+ this.perSecondQualityStats = {
+ avgDecoded: 0,
+ avgDropped: 0,
+ avgInput: 0,
+ avgSize: 0,
+ };
+ }
+
+ private updateQualityStats = (): void => {
+ const now = Date.now();
+ const oneSecondBefore = now - 1000;
+ this.calculateMomentumStats();
+ if (!this.momentumQualityStats) {
+ return;
+ }
+ if (this.totalStats.timestamp < oneSecondBefore) {
+ this.totalStats = {
+ timestamp: now,
+ decodedFrames: this.totalStats.decodedFrames + this.momentumQualityStats.decodedFrames,
+ droppedFrames: this.totalStats.droppedFrames + this.momentumQualityStats.droppedFrames,
+ inputFrames: this.totalStats.inputFrames + this.momentumQualityStats.inputFrames,
+ inputBytes: this.totalStats.inputBytes + this.momentumQualityStats.inputBytes,
+ };
+
+ if (this.totalStatsCounter !== 0) {
+ this.perSecondQualityStats = {
+ avgDecoded: this.totalStats.decodedFrames / this.totalStatsCounter,
+ avgDropped: this.totalStats.droppedFrames / this.totalStatsCounter,
+ avgInput: this.totalStats.inputFrames / this.totalStatsCounter,
+ avgSize: this.totalStats.inputBytes / this.totalStatsCounter,
+ };
+ }
+ this.totalStatsCounter++;
+ }
+ this.drawStats();
+ if (this.state !== BasePlayer.STATE.STOPPED) {
+ this.qualityAnimationId = requestAnimationFrame(this.updateQualityStats);
+ }
+ };
+
+ private drawStats(): void {
+ if (!this.showQualityStats) {
+ return;
+ }
+ const ctx = this.touchableCanvas.getContext('2d');
+ if (!ctx) {
+ return;
+ }
+ const newStats = [];
+ if (this.perSecondQualityStats && this.momentumQualityStats) {
+ const { decodedFrames, droppedFrames, inputBytes, inputFrames } = this.momentumQualityStats;
+ const { avgDecoded, avgDropped, avgSize, avgInput } = this.perSecondQualityStats;
+ const padInput = inputFrames.toString().padStart(3, ' ');
+ const padDecoded = decodedFrames.toString().padStart(3, ' ');
+ const padDropped = droppedFrames.toString().padStart(3, ' ');
+ const padAvgDecoded = avgDecoded.toFixed(1).padStart(5, ' ');
+ const padAvgDropped = avgDropped.toFixed(1).padStart(5, ' ');
+ const padAvgInput = avgInput.toFixed(1).padStart(5, ' ');
+ const prettyBytes = Util.prettyBytes(inputBytes).padStart(8, ' ');
+ const prettyAvgBytes = Util.prettyBytes(avgSize).padStart(8, ' ');
+
+ newStats.push(`Input bytes: ${prettyBytes} (avg: ${prettyAvgBytes}/s)`);
+ newStats.push(`Input FPS: ${padInput} (avg: ${padAvgInput})`);
+ newStats.push(`Dropped FPS: ${padDropped} (avg: ${padAvgDropped})`);
+ newStats.push(`Decoded FPS: ${padDecoded} (avg: ${padAvgDecoded})`);
+ } else {
+ newStats.push(`Not supported`);
+ }
+ let changed = this.statLines.length !== newStats.length;
+ let i = 0;
+ while (!changed && i++ < newStats.length) {
+ if (newStats[i] !== this.statLines[i]) {
+ changed = true;
+ }
+ }
+
+ if (changed) {
+ this.statLines = newStats;
+ this.updateCanvas(false);
+ }
+ }
+
+ private updateCanvas(onlyClear: boolean): void {
+ const ctx = this.touchableCanvas.getContext('2d');
+ if (!ctx) {
+ return;
+ }
+ console.log("123")
+ const y = this.touchableCanvas.height;
+ const height = BasePlayer.STATS_HEIGHT;
+ const lines = this.statLines.length;
+ const spaces = lines + 1;
+ const p = height / 2;
+ const d = p * 2;
+ const totalHeight = height * lines + p * spaces;
+
+ ctx.clearRect(0, y - totalHeight, this.dirtyStatsWidth + d, totalHeight);
+ this.dirtyStatsWidth = 0;
+
+ if (onlyClear) {
+ return;
+ }
+ ctx.save();
+ ctx.font = `${height}px monospace`;
+ this.statLines.forEach((text) => {
+ const textMetrics = ctx.measureText(text);
+ const dirty = Math.abs(textMetrics.actualBoundingBoxLeft) + Math.abs(textMetrics.actualBoundingBoxRight);
+ this.dirtyStatsWidth = Math.max(dirty, this.dirtyStatsWidth);
+ });
+ ctx.fillStyle = BasePlayer.STAT_BACKGROUND;
+ ctx.fillRect(0, y - totalHeight, this.dirtyStatsWidth + d, totalHeight);
+ ctx.fillStyle = BasePlayer.STAT_TEXT_COLOR;
+ this.statLines.forEach((text, line) => {
+ ctx.fillText(text, p, y - p - line * (height + p));
+ });
+ ctx.restore();
+ }
+
+ public setShowQualityStats(value: boolean): void {
+ this.showQualityStats = value;
+ if (!value) {
+ this.updateCanvas(true);
+ } else {
+ this.drawStats();
+ }
+ }
+
+ public getShowQualityStats(): boolean {
+ return this.showQualityStats;
+ }
+
+ public setBounds(bounds: Size): void {
+ this.bounds = Size.copy(bounds);
+ }
+
+ public getDisplayInfo(): DisplayInfo | undefined {
+ return this.displayInfo;
+ }
+
+ public setDisplayInfo(displayInfo: DisplayInfo): void {
+ this.displayInfo = displayInfo;
+ }
+
+ public abstract getFitToScreenStatus(): boolean;
+
+ public abstract loadVideoSettings(): VideoSettings;
+
+ public static loadVideoSettings(udid: string, displayInfo?: DisplayInfo): VideoSettings {
+ return this.getVideoSettingFromStorage(this.preferredVideoSettings, this.storageKeyPrefix, udid, displayInfo);
+ }
+
+ public static getFitToScreenStatus(udid: string, displayInfo?: DisplayInfo): boolean {
+ return this.getFitToScreenFromStorage(this.storageKeyPrefix, udid, displayInfo);
+ }
+
+ public static getPreferredVideoSetting(): VideoSettings {
+ return this.preferredVideoSettings;
+ }
+
+ public static saveVideoSettings(
+ udid: string,
+ videoSettings: VideoSettings,
+ fitToScreen: boolean,
+ displayInfo?: DisplayInfo,
+ ): void {
+ this.putVideoSettingsToStorage(this.storageKeyPrefix, udid, videoSettings, fitToScreen, displayInfo);
+ }
+}
diff --git a/src/app/player/BroadwayPlayer.ts b/src/app/player/BroadwayPlayer.ts
new file mode 100644
index 0000000..abe24d6
--- /dev/null
+++ b/src/app/player/BroadwayPlayer.ts
@@ -0,0 +1,69 @@
+import '../../../vendor/Broadway/avc.wasm.asset';
+import { BaseCanvasBasedPlayer } from './BaseCanvasBasedPlayer';
+import Size from '../Size';
+import YUVCanvas from '../../../vendor/h264-live-player/YUVCanvas';
+import YUVWebGLCanvas from '../../../vendor/h264-live-player/YUVWebGLCanvas';
+import Avc from '../../../vendor/Broadway/Decoder';
+import VideoSettings from '../VideoSettings';
+import Canvas from '../../../vendor/h264-live-player/Canvas';
+import { DisplayInfo } from '../DisplayInfo';
+
+export class BroadwayPlayer extends BaseCanvasBasedPlayer {
+ public static readonly storageKeyPrefix = 'BroadwayDecoder';
+ public static readonly playerFullName = 'Broadway.js';
+ public static readonly playerCodeName = 'broadway';
+ public static readonly preferredVideoSettings: VideoSettings = new VideoSettings({
+ lockedVideoOrientation: -1,
+ bitrate: 524288,
+ maxFps: 24,
+ iFrameInterval: 5,
+ bounds: new Size(480, 480),
+ sendFrameMeta: false,
+ });
+
+ protected canvas?: Canvas;
+ private avc?: Avc;
+ public readonly supportsScreenshot: boolean = true;
+
+ public static isSupported(): boolean {
+ return typeof WebAssembly === 'object' && typeof WebAssembly.instantiate === 'function';
+ }
+
+ constructor(udid: string, displayInfo?: DisplayInfo, name = BroadwayPlayer.playerFullName) {
+ super(udid, displayInfo, name, BroadwayPlayer.storageKeyPrefix);
+ }
+
+ protected initCanvas(width: number, height: number): void {
+ super.initCanvas(width, height);
+ if (BaseCanvasBasedPlayer.hasWebGLSupport()) {
+ this.canvas = new YUVWebGLCanvas(this.tag, new Size(width, height));
+ } else {
+ this.canvas = new YUVCanvas(this.tag, new Size(width, height));
+ }
+ if (!this.avc) {
+ this.avc = new Avc();
+ }
+ this.avc.onPictureDecoded = (buffer: Uint8Array, width: number, height: number) => {
+ this.onFrameDecoded(width, height, buffer);
+ };
+ }
+
+ protected decode(data: Uint8Array): void {
+ if (!this.avc) {
+ return;
+ }
+ this.avc.decode(data);
+ }
+
+ public getPreferredVideoSetting(): VideoSettings {
+ return BroadwayPlayer.preferredVideoSettings;
+ }
+
+ public getFitToScreenStatus(): boolean {
+ return BroadwayPlayer.getFitToScreenStatus(this.udid, this.displayInfo);
+ }
+
+ public loadVideoSettings(): VideoSettings {
+ return BroadwayPlayer.loadVideoSettings(this.udid, this.displayInfo);
+ }
+}
diff --git a/src/app/player/MjpegPlayer.ts b/src/app/player/MjpegPlayer.ts
new file mode 100644
index 0000000..ae8d7cf
--- /dev/null
+++ b/src/app/player/MjpegPlayer.ts
@@ -0,0 +1,93 @@
+import { BasePlayer } from './BasePlayer';
+import VideoSettings from '../VideoSettings';
+import { DisplayInfo } from '../DisplayInfo';
+
+export class MjpegPlayer extends BasePlayer {
+ private static dummyVideoSettings = new VideoSettings();
+ public static storageKeyPrefix = 'MjpegDecoder';
+ public static playerFullName = 'Mjpeg Http Player';
+ public static playerCodeName = 'mjpeghttp';
+
+ public static createElement(id?: string): HTMLImageElement {
+ const tag = document.createElement('img') as HTMLImageElement;
+ if (typeof id === 'string') {
+ tag.id = id;
+ }
+ tag.className = 'video-layer';
+ return tag;
+ }
+
+ public static isSupported(): boolean {
+ // I guess everything supports MJPEG?
+ return true;
+ }
+
+ public readonly supportsScreenshot = true;
+ public readonly resizeVideoToBounds: boolean = true;
+
+ constructor(
+ udid: string,
+ displayInfo?: DisplayInfo,
+ name = 'MJPEG_Player',
+ storageKeyPrefix = 'MJPEG',
+ protected tag: HTMLImageElement = MjpegPlayer.createElement(),
+ ) {
+ super(udid, displayInfo, name, storageKeyPrefix, tag);
+ this.tag.onload = () => {
+ this.checkVideoResize();
+ };
+ }
+
+ public play(): void {
+ super.play();
+ this.tag.setAttribute('src', `${location.protocol}//${location.host}/mjpeg/${this.udid}`);
+ }
+
+ public pause(): void {
+ super.pause();
+ this.tag.removeAttribute('src');
+ }
+
+ public stop(): void {
+ super.stop();
+ this.tag.removeAttribute('src');
+ }
+
+ protected needScreenInfoBeforePlay(): boolean {
+ return false;
+ }
+
+ protected calculateMomentumStats(): void {
+ // not implemented
+ }
+
+ getFitToScreenStatus(): boolean {
+ return false;
+ }
+
+ getImageDataURL(): string {
+ const canvas = document.createElement('canvas');
+ canvas.width = this.videoWidth;
+ canvas.height = this.videoHeight;
+ canvas.getContext('2d')?.drawImage(this.tag, 0, 0);
+ return canvas.toDataURL('image/png');
+ }
+
+ getPreferredVideoSetting(): VideoSettings {
+ return MjpegPlayer.dummyVideoSettings;
+ }
+
+ loadVideoSettings(): VideoSettings {
+ return MjpegPlayer.dummyVideoSettings;
+ }
+
+ checkVideoResize = (): void => {
+ if (!this.tag) {
+ return;
+ }
+ const { height, width } = this.tag;
+ if (this.videoHeight !== height || this.videoWidth !== width) {
+ this.calculateScreenInfoForBounds(width, height);
+ }
+ };
+}
diff --git a/src/app/player/MsePlayer.ts b/src/app/player/MsePlayer.ts
new file mode 100644
index 0000000..3f645ed
--- /dev/null
+++ b/src/app/player/MsePlayer.ts
@@ -0,0 +1,450 @@
+import { BasePlayer } from './BasePlayer';
+import VideoConverter, { setLogger, mimeType } from 'h264-converter';
+import VideoSettings from '../VideoSettings';
+import Size from '../Size';
+import { DisplayInfo } from '../DisplayInfo';
+
+interface QualityStats {
+ timestamp: number;
+ decodedFrames: number;
+ droppedFrames: number;
+}
+
+type Block = {
+ start: number;
+ end: number;
+};
+
+export class MsePlayer extends BasePlayer {
+ public static readonly storageKeyPrefix = 'MseDecoder';
+ public static readonly playerFullName = 'H264 Converter';
+ public static readonly playerCodeName = 'mse';
+ public static readonly preferredVideoSettings: VideoSettings = new VideoSettings({
+ lockedVideoOrientation: -1,
+ bitrate: 7340032,
+ maxFps: 60,
+ iFrameInterval: 10,
+ bounds: new Size(720, 720),
+ sendFrameMeta: false,
+ });
+ private static DEFAULT_FRAMES_PER_FRAGMENT = 1;
+ private static DEFAULT_FRAMES_PER_SECOND = 60;
+
+ public static createElement(id?: string): HTMLVideoElement {
+ const tag = document.createElement('video') as HTMLVideoElement;
+ tag.muted = true;
+ tag.autoplay = true;
+ tag.setAttribute('muted', 'muted');
+ tag.setAttribute('autoplay', 'autoplay');
+ if (typeof id === 'string') {
+ tag.id = id;
+ }
+ tag.className = 'video-layer';
+ return tag;
+ }
+
+ private converter?: VideoConverter;
+ private videoStats: QualityStats[] = [];
+ private noDecodedFramesSince = -1;
+ private currentTimeNotChangedSince = -1;
+ private bigBufferSince = -1;
+ private aheadOfBufferSince = -1;
+ public fpf: number = MsePlayer.DEFAULT_FRAMES_PER_FRAGMENT;
+ public readonly supportsScreenshot = true;
+ private sourceBuffer?: SourceBuffer;
+ private waitUntilSegmentRemoved = false;
+ private blocks: Block[] = [];
+ private frames: Uint8Array[] = [];
+ private jumpEnd = -1;
+ private lastTime = -1;
+ protected canPlay = false;
+ private seekingSince = -1;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ protected readonly isSafari = !!(window as unknown as any)['safari'];
+ protected readonly isChrome = navigator.userAgent.includes('Chrome');
+ protected readonly isMac = navigator.platform.startsWith('Mac');
+ private MAX_TIME_TO_RECOVER = 200; // ms
+ private MAX_BUFFER = this.isSafari ? 2 : this.isChrome && this.isMac ? 0.9 : 0.2;
+ private MAX_AHEAD = -0.2;
+
+ public static isSupported(): boolean {
+ return typeof MediaSource !== 'undefined' && MediaSource.isTypeSupported(mimeType);
+ }
+
+ constructor(
+ udid: string,
+ displayInfo?: DisplayInfo,
+ name = MsePlayer.playerFullName,
+ protected tag: HTMLVideoElement = MsePlayer.createElement(),
+ ) {
+ super(udid, displayInfo, name, MsePlayer.storageKeyPrefix, tag);
+ tag.oncontextmenu = function (event: MouseEvent): boolean {
+ event.preventDefault();
+ return false;
+ };
+ tag.addEventListener('error', this.onVideoError);
+ tag.addEventListener('canplay', this.onVideoCanPlay);
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ setLogger(() => {}, console.error);
+ }
+
+ onVideoError = (event: Event): void => {
+ console.error(`[${this.name}]`, event);
+ };
+
+ onVideoCanPlay = (): void => {
+ this.onCanPlayHandler();
+ };
+
+ private static createConverter(
+ tag: HTMLVideoElement,
+ fps: number = MsePlayer.DEFAULT_FRAMES_PER_SECOND,
+ fpf: number = MsePlayer.DEFAULT_FRAMES_PER_FRAGMENT,
+ ): VideoConverter {
+ return new VideoConverter(tag, fps, fpf);
+ }
+
+ private getVideoPlaybackQuality(): QualityStats | null {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const video = this.tag as any;
+ if (typeof video.mozDecodedFrames !== 'undefined') {
+ return null;
+ }
+ const now = Date.now();
+ if (typeof this.tag.getVideoPlaybackQuality == 'function') {
+ const temp = this.tag.getVideoPlaybackQuality();
+ return {
+ timestamp: now,
+ decodedFrames: temp.totalVideoFrames,
+ droppedFrames: temp.droppedVideoFrames,
+ };
+ }
+
+ // Webkit-specific properties
+ if (typeof video.webkitDecodedFrameCount !== 'undefined') {
+ return {
+ timestamp: now,
+ decodedFrames: video.webkitDecodedFrameCount,
+ droppedFrames: video.webkitDroppedFrameCount,
+ };
+ }
+ return null;
+ }
+
+ protected onCanPlayHandler(): void {
+ this.canPlay = true;
+ this.tag.play();
+ this.tag.removeEventListener('canplay', this.onVideoCanPlay);
+ this.checkVideoResize();
+ }
+
+ protected calculateMomentumStats(): void {
+ const stat = this.getVideoPlaybackQuality();
+ if (!stat) {
+ return;
+ }
+
+ const timestamp = Date.now();
+ const oneSecondBefore = timestamp - 1000;
+ this.videoStats.push(stat);
+
+ while (this.videoStats.length && this.videoStats[0].timestamp < oneSecondBefore) {
+ this.videoStats.shift();
+ }
+ while (this.inputBytes.length && this.inputBytes[0].timestamp < oneSecondBefore) {
+ this.inputBytes.shift();
+ }
+ let inputBytes = 0;
+ this.inputBytes.forEach((item) => {
+ inputBytes += item.bytes;
+ });
+ const inputFrames = this.inputBytes.length;
+ if (this.videoStats.length) {
+ const oldest = this.videoStats[0];
+ const decodedFrames = stat.decodedFrames - oldest.decodedFrames;
+ const droppedFrames = stat.droppedFrames - oldest.droppedFrames;
+ // const droppedFrames = inputFrames - decodedFrames;
+ this.momentumQualityStats = {
+ decodedFrames,
+ droppedFrames,
+ inputBytes,
+ inputFrames,
+ timestamp,
+ };
+ }
+ }
+
+ protected resetStats(): void {
+ super.resetStats();
+ this.videoStats = [];
+ }
+
+ public getImageDataURL(): string {
+ const canvas = document.createElement('canvas');
+ canvas.width = this.tag.clientWidth;
+ canvas.height = this.tag.clientHeight;
+ const ctx = canvas.getContext('2d');
+ if (ctx) {
+ ctx.drawImage(this.tag, 0, 0, canvas.width, canvas.height);
+ }
+
+ return canvas.toDataURL();
+ }
+
+ public play(): void {
+ super.play();
+ if (this.getState() !== BasePlayer.STATE.PLAYING) {
+ return;
+ }
+ if (!this.converter) {
+ let fps = MsePlayer.DEFAULT_FRAMES_PER_SECOND;
+ if (this.videoSettings) {
+ fps = this.videoSettings.maxFps;
+ }
+ this.converter = MsePlayer.createConverter(this.tag, fps, this.fpf);
+ this.canPlay = false;
+ this.resetStats();
+ }
+ this.converter.play();
+ }
+
+ public pause(): void {
+ super.pause();
+ this.stopConverter();
+ }
+
+ public stop(): void {
+ super.stop();
+ this.stopConverter();
+ }
+
+ public setVideoSettings(videoSettings: VideoSettings, fitToScreen: boolean, saveToStorage: boolean): void {
+ if (this.videoSettings && this.videoSettings.maxFps !== videoSettings.maxFps) {
+ const state = this.getState();
+ if (this.converter) {
+ this.stop();
+ this.converter = MsePlayer.createConverter(this.tag, videoSettings.maxFps, this.fpf);
+ this.canPlay = false;
+ }
+ if (state === BasePlayer.STATE.PLAYING) {
+ this.play();
+ }
+ }
+ super.setVideoSettings(videoSettings, fitToScreen, saveToStorage);
+ }
+
+ public getPreferredVideoSetting(): VideoSettings {
+ return MsePlayer.preferredVideoSettings;
+ }
+
+ checkVideoResize = (): void => {
+ if (!this.tag) {
+ return;
+ }
+ const { videoHeight, videoWidth } = this.tag;
+ if (this.videoHeight !== videoHeight || this.videoWidth !== videoWidth) {
+ this.calculateScreenInfoForBounds(videoWidth, videoHeight);
+ }
+ };
+ cleanSourceBuffer = (): void => {
+ if (!this.sourceBuffer) {
+ return;
+ }
+ if (this.sourceBuffer.updating) {
+ return;
+ }
+ if (this.blocks.length < 10) {
+ return;
+ }
+ try {
+ this.sourceBuffer.removeEventListener('updateend', this.cleanSourceBuffer);
+ this.waitUntilSegmentRemoved = false;
+ const removeStart = this.blocks[0].start;
+ const removeEnd = this.blocks[4].end;
+ this.blocks = this.blocks.slice(5);
+ this.sourceBuffer.remove(removeStart, removeEnd);
+ let frame = this.frames.shift();
+ while (frame) {
+ if (!this.checkForIFrame(frame)) {
+ this.frames.unshift(frame);
+ break;
+ }
+ frame = this.frames.shift();
+ }
+ } catch (error: any) {
+ console.error(`[${this.name}]`, 'Failed to clean source buffer');
+ }
+ };
+
+ jumpToEnd = (): void => {
+ if (!this.sourceBuffer) {
+ return;
+ }
+ if (this.sourceBuffer.updating) {
+ return;
+ }
+ if (!this.tag.buffered.length) {
+ return;
+ }
+ const end = this.tag.buffered.end(this.tag.seekable.length - 1);
+ console.log(`[${this.name}]`, `Jumping to the end (${this.jumpEnd}, ${end - this.jumpEnd}).`);
+ this.tag.currentTime = end;
+ this.jumpEnd = -1;
+ this.sourceBuffer.removeEventListener('updateend', this.jumpToEnd);
+ };
+
+ public pushFrame(frame: Uint8Array): void {
+ super.pushFrame(frame);
+ if (!this.checkForIFrame(frame)) {
+ this.frames.push(frame);
+ } else {
+ this.checkForBadState();
+ }
+ }
+
+ protected checkForBadState(): void {
+ // Workaround for stalled playback (`stalled` event is not fired, but the image freezes)
+ const { currentTime } = this.tag;
+ const now = Date.now();
+ // let reasonToJump = '';
+ let hasReasonToJump = false;
+ if (this.momentumQualityStats) {
+ if (this.momentumQualityStats.decodedFrames === 0 && this.momentumQualityStats.inputFrames > 0) {
+ if (this.noDecodedFramesSince === -1) {
+ this.noDecodedFramesSince = now;
+ } else {
+ const time = now - this.noDecodedFramesSince;
+ if (time > this.MAX_TIME_TO_RECOVER) {
+ // reasonToJump = `No frames decoded for ${time} ms.`;
+ hasReasonToJump = true;
+ }
+ }
+ } else {
+ this.noDecodedFramesSince = -1;
+ }
+ }
+ if (currentTime === this.lastTime && this.currentTimeNotChangedSince === -1) {
+ this.currentTimeNotChangedSince = now;
+ } else {
+ this.currentTimeNotChangedSince = -1;
+ }
+ this.lastTime = currentTime;
+ if (this.tag.buffered.length) {
+ const end = this.tag.buffered.end(0);
+ const buffered = end - currentTime;
+
+ if ((end | 0) - currentTime > this.MAX_BUFFER) {
+ if (this.bigBufferSince === -1) {
+ this.bigBufferSince = now;
+ } else {
+ const time = now - this.bigBufferSince;
+ if (time > this.MAX_TIME_TO_RECOVER) {
+ // reasonToJump = `Buffer is bigger then ${this.MAX_BUFFER} (${buffered.toFixed(
+ // 3,
+ // )}) for ${time} ms.`;
+ hasReasonToJump = true;
+ }
+ }
+ } else {
+ this.bigBufferSince = -1;
+ }
+ if (buffered < this.MAX_AHEAD) {
+ if (this.aheadOfBufferSince === -1) {
+ this.aheadOfBufferSince = now;
+ } else {
+ const time = now - this.aheadOfBufferSince;
+ if (time > this.MAX_TIME_TO_RECOVER) {
+ // reasonToJump = `Current time is ahead of end (${buffered}) for ${time} ms.`;
+ hasReasonToJump = true;
+ }
+ }
+ } else {
+ this.aheadOfBufferSince = -1;
+ }
+ if (this.currentTimeNotChangedSince !== -1) {
+ const time = now - this.currentTimeNotChangedSince;
+ if (time > this.MAX_TIME_TO_RECOVER) {
+ // reasonToJump = `Current time not changed for ${time} ms.`;
+ hasReasonToJump = true;
+ }
+ }
+ if (!hasReasonToJump) {
+ return;
+ }
+ let waitingForSeekEnd = 0;
+ if (this.seekingSince !== -1) {
+ waitingForSeekEnd = now - this.seekingSince;
+ if (waitingForSeekEnd < 1500) {
+ return;
+ }
+ }
+ // console.info(`${reasonToJump} Jumping to the end. ${waitingForSeekEnd}`);
+
+ const onSeekEnd = () => {
+ this.seekingSince = -1;
+ this.tag.removeEventListener('seeked', onSeekEnd);
+ this.tag.play();
+ };
+ if (this.seekingSince !== -1) {
+ console.warn(`[${this.name}]`, `Attempt to seek while already seeking! ${waitingForSeekEnd}`);
+ }
+ this.seekingSince = now;
+ this.tag.addEventListener('seeked', onSeekEnd);
+ this.tag.currentTime = this.tag.buffered.end(0);
+ }
+ }
+
+ protected checkForIFrame(frame: Uint8Array): boolean {
+ if (!this.converter) {
+ return false;
+ }
+ this.sourceBuffer = this.converter.sourceBuffer;
+ if (BasePlayer.isIFrame(frame)) {
+ let start = 0;
+ let end = 0;
+ if (this.tag.buffered && this.tag.buffered.length) {
+ start = this.tag.buffered.start(0);
+ end = this.tag.buffered.end(0);
+ }
+ if (end !== 0 && start < end) {
+ const block: Block = {
+ start,
+ end,
+ };
+ this.blocks.push(block);
+ if (this.blocks.length > 10) {
+ this.waitUntilSegmentRemoved = true;
+
+ this.sourceBuffer.addEventListener('updateend', this.cleanSourceBuffer);
+ this.converter.appendRawData(frame);
+ return true;
+ }
+ }
+ if (this.sourceBuffer) {
+ this.sourceBuffer.onupdateend = this.checkVideoResize;
+ }
+ }
+ if (this.waitUntilSegmentRemoved) {
+ return false;
+ }
+
+ this.converter.appendRawData(frame);
+ return true;
+ }
+
+ private stopConverter(): void {
+ if (this.converter) {
+ this.converter.appendRawData(new Uint8Array([]));
+ this.converter.pause();
+ delete this.converter;
+ }
+ }
+
+ public getFitToScreenStatus(): boolean {
+ return MsePlayer.getFitToScreenStatus(this.udid, this.displayInfo);
+ }
+
+ public loadVideoSettings(): VideoSettings {
+ return MsePlayer.loadVideoSettings(this.udid, this.displayInfo);
+ }
+}
diff --git a/src/app/player/MsePlayerForQVHack.ts b/src/app/player/MsePlayerForQVHack.ts
new file mode 100644
index 0000000..9bfd9e0
--- /dev/null
+++ b/src/app/player/MsePlayerForQVHack.ts
@@ -0,0 +1,37 @@
+import { MsePlayer } from './MsePlayer';
+import Size from '../Size';
+import VideoSettings from '../VideoSettings';
+import { DisplayInfo } from '../DisplayInfo';
+
+export class MsePlayerForQVHack extends MsePlayer {
+ public static readonly preferredVideoSettings: VideoSettings = new VideoSettings({
+ lockedVideoOrientation: -1,
+ bitrate: 8000000,
+ maxFps: 30,
+ iFrameInterval: 10,
+ bounds: new Size(720, 720),
+ sendFrameMeta: false,
+ });
+
+ public readonly resizeVideoToBounds: boolean = true;
+ constructor(
+ udid: string,
+ displayInfo?: DisplayInfo,
+ name = 'MSE_Player_For_QVHack',
+ tag = MsePlayerForQVHack.createElement(),
+ ) {
+ super(udid, displayInfo, name, tag);
+ }
+
+ protected needScreenInfoBeforePlay(): boolean {
+ return false;
+ }
+
+ public getPreferredVideoSetting(): VideoSettings {
+ return MsePlayerForQVHack.preferredVideoSettings;
+ }
+
+ public setVideoSettings(): void {
+ return;
+ }
+}
diff --git a/src/app/player/TinyH264Player.ts b/src/app/player/TinyH264Player.ts
new file mode 100644
index 0000000..79fab94
--- /dev/null
+++ b/src/app/player/TinyH264Player.ts
@@ -0,0 +1,126 @@
+import { BaseCanvasBasedPlayer } from './BaseCanvasBasedPlayer';
+import TinyH264Worker from 'worker-loader!./../../../vendor/tinyh264/H264NALDecoder.worker';
+import VideoSettings from '../VideoSettings';
+import YUVWebGLCanvas from '../../../vendor/tinyh264/YUVWebGLCanvas';
+import YUVCanvas from '../../../vendor/tinyh264/YUVCanvas';
+import Size from '../Size';
+import { DisplayInfo } from '../DisplayInfo';
+
+type WorkerMessage = {
+ type: string;
+ width: number;
+ height: number;
+ data: ArrayBuffer;
+ renderStateId: number;
+};
+
+export class TinyH264Player extends BaseCanvasBasedPlayer {
+ public static readonly storageKeyPrefix = 'Tinyh264Decoder';
+ public static readonly playerFullName = 'Tiny H264';
+ public static readonly playerCodeName = 'tinyh264';
+ private static videoStreamId = 1;
+ public static readonly preferredVideoSettings: VideoSettings = new VideoSettings({
+ lockedVideoOrientation: -1,
+ bitrate: 524288,
+ maxFps: 24,
+ iFrameInterval: 5,
+ bounds: new Size(480, 480),
+ sendFrameMeta: false,
+ });
+
+ private worker?: TinyH264Worker;
+ private isDecoderReady = false;
+ protected canvas?: YUVWebGLCanvas | YUVCanvas;
+ public readonly supportsScreenshot: boolean = true;
+
+ public static isSupported(): boolean {
+ return typeof WebAssembly === 'object' && typeof WebAssembly.instantiate === 'function';
+ }
+
+ constructor(udid: string, displayInfo?: DisplayInfo, name = TinyH264Player.playerFullName) {
+ super(udid, displayInfo, name, TinyH264Player.storageKeyPrefix);
+ }
+
+ private onWorkerMessage = (event: MessageEvent): void => {
+ const message: WorkerMessage = event.data;
+ switch (message.type) {
+ case 'pictureReady':
+ const { width, height, data } = message;
+ this.onFrameDecoded(width, height, new Uint8Array(data));
+ break;
+ case 'decoderReady':
+ this.isDecoderReady = true;
+ break;
+ default:
+ console.error(`[${this.name}]`, Error(`Wrong message type "${message.type}"`));
+ }
+ };
+
+ private initWorker(): void {
+ this.worker = new TinyH264Worker();
+ this.worker.addEventListener('message', this.onWorkerMessage);
+ }
+
+ protected initCanvas(width: number, height: number): void {
+ super.initCanvas(width, height);
+
+ if (BaseCanvasBasedPlayer.hasWebGLSupport()) {
+ this.canvas = new YUVWebGLCanvas(this.tag);
+ } else {
+ this.canvas = new YUVCanvas(this.tag);
+ }
+ }
+
+ protected decode(data: Uint8Array): void {
+ if (!this.worker || !this.isDecoderReady) {
+ return;
+ }
+
+ this.worker.postMessage(
+ {
+ type: 'decode',
+ data: data.buffer,
+ offset: data.byteOffset,
+ length: data.byteLength,
+ renderStateId: TinyH264Player.videoStreamId,
+ },
+ [data.buffer],
+ );
+ }
+
+ public play(): void {
+ super.play();
+ if (!this.worker) {
+ this.initWorker();
+ }
+ }
+
+ public stop(): void {
+ super.stop();
+ if (this.worker) {
+ this.worker.removeEventListener('message', this.onWorkerMessage);
+ this.worker.postMessage({ type: 'release', renderStateId: TinyH264Player.videoStreamId });
+ delete this.worker;
+ }
+ }
+
+ public getPreferredVideoSetting(): VideoSettings {
+ return TinyH264Player.preferredVideoSettings;
+ }
+
+ protected clearState(): void {
+ super.clearState();
+ if (this.worker) {
+ this.worker.postMessage({ type: 'release', renderStateId: TinyH264Player.videoStreamId });
+ TinyH264Player.videoStreamId++;
+ }
+ }
+
+ public getFitToScreenStatus(): boolean {
+ return TinyH264Player.getFitToScreenStatus(this.udid, this.displayInfo);
+ }
+
+ public loadVideoSettings(): VideoSettings {
+ return TinyH264Player.loadVideoSettings(this.udid, this.displayInfo);
+ }
+}
diff --git a/src/app/player/WebCodecsPlayer.ts b/src/app/player/WebCodecsPlayer.ts
new file mode 100644
index 0000000..6e942cf
--- /dev/null
+++ b/src/app/player/WebCodecsPlayer.ts
@@ -0,0 +1,223 @@
+import { BaseCanvasBasedPlayer } from './BaseCanvasBasedPlayer';
+import VideoSettings from '../VideoSettings';
+import Size from '../Size';
+import { DisplayInfo } from '../DisplayInfo';
+import H264Parser from 'h264-converter/dist/h264-parser';
+import NALU from 'h264-converter/dist/util/NALU';
+import ScreenInfo from '../ScreenInfo';
+import Rect from '../Rect';
+
+type ParametersSubSet = {
+ codec: string;
+ width: number;
+ height: number;
+};
+
+function toHex(value: number) {
+ return value.toString(16).padStart(2, '0').toUpperCase();
+}
+
+export class WebCodecsPlayer extends BaseCanvasBasedPlayer {
+ public static readonly storageKeyPrefix = 'WebCodecsPlayer';
+ public static readonly playerFullName = 'WebCodecs';
+ public static readonly playerCodeName = 'webcodecs';
+
+ public static readonly preferredVideoSettings: VideoSettings = new VideoSettings({
+ lockedVideoOrientation: -1,
+ bitrate: 524288,
+ maxFps: 24,
+ iFrameInterval: 5,
+ bounds: new Size(480, 480),
+ sendFrameMeta: false,
+ });
+
+ public static isSupported(): boolean {
+ if (typeof VideoDecoder !== 'function' || typeof VideoDecoder.isConfigSupported !== 'function') {
+ return false;
+ }
+
+ // FIXME: verify support
+ // const result = await VideoDecoder.isConfigSupported();
+ return true;
+ }
+
+ private static parseSPS(data: Uint8Array): ParametersSubSet {
+ const {
+ profile_idc,
+ constraint_set_flags,
+ level_idc,
+ pic_width_in_mbs_minus1,
+ frame_crop_left_offset,
+ frame_crop_right_offset,
+ frame_mbs_only_flag,
+ pic_height_in_map_units_minus1,
+ frame_crop_top_offset,
+ frame_crop_bottom_offset,
+ sar,
+ } = H264Parser.parseSPS(data);
+
+ const sarScale = sar[0] / sar[1];
+ const codec = `avc1.${[profile_idc, constraint_set_flags, level_idc].map(toHex).join('')}`;
+ const width = Math.ceil(
+ ((pic_width_in_mbs_minus1 + 1) * 16 - frame_crop_left_offset * 2 - frame_crop_right_offset * 2) * sarScale,
+ );
+ const height =
+ (2 - frame_mbs_only_flag) * (pic_height_in_map_units_minus1 + 1) * 16 -
+ (frame_mbs_only_flag ? 2 : 4) * (frame_crop_top_offset + frame_crop_bottom_offset);
+ return { codec, width, height };
+ }
+
+ public readonly supportsScreenshot = true;
+ private context: CanvasRenderingContext2D;
+ private decoder: VideoDecoder;
+ private buffer: ArrayBuffer | undefined;
+ private hadIDR = false;
+ private bufferedSPS = false;
+ private bufferedPPS = false;
+
+ constructor(udid: string, displayInfo?: DisplayInfo, name = WebCodecsPlayer.playerFullName) {
+ super(udid, displayInfo, name, WebCodecsPlayer.storageKeyPrefix);
+ const context = this.tag.getContext('2d');
+ if (!context) {
+ throw Error('Failed to get 2d context from canvas');
+ }
+ this.context = context;
+ this.decoder = this.createDecoder();
+ }
+
+ private createDecoder(): VideoDecoder {
+ return new VideoDecoder({
+ output: (frame) => {
+ this.onFrameDecoded(0, 0, frame);
+ },
+ error: (error: DOMException) => {
+ console.error(error, `code: ${error.code}`);
+ this.stop();
+ },
+ });
+ }
+
+ protected addToBuffer(data: Uint8Array): Uint8Array {
+ let array: Uint8Array;
+ if (this.buffer) {
+ array = new Uint8Array(this.buffer.byteLength + data.byteLength);
+ array.set(new Uint8Array(this.buffer));
+ array.set(new Uint8Array(data), this.buffer.byteLength);
+ } else {
+ array = data;
+ }
+ this.buffer = array.buffer;
+ return array;
+ }
+
+ protected scaleCanvas(width: number, height: number): void {
+ const videoSize = new Size(width, height);
+ let scale = 1;
+ if (this.bounds && !this.bounds.intersect(videoSize).equals(videoSize)) {
+ scale = Math.min(this.bounds.w / width, this.bounds.h / height);
+ }
+ const w = width * scale;
+ const h = height * scale;
+ const screenInfo = new ScreenInfo(new Rect(0, 0, width, height), new Size(w, h), 0);
+ this.emit('input-video-resize', screenInfo);
+ this.setScreenInfo(screenInfo);
+
+ // FIXME: canvas was initialized from `.setScreenInfo()` call above, but with wrong values
+ this.initCanvas(width, height);
+ if (scale !== 1) {
+ this.tag.style.transform = `scale(${scale.toFixed(4)})`;
+ } else {
+ this.tag.style.transform = ``;
+ }
+ this.tag.style.transformOrigin = 'top left';
+ }
+
+ protected decode(data: Uint8Array): void {
+ if (!data || data.length < 4) {
+ return;
+ }
+ const type = data[4] & 31;
+ const isIDR = type === NALU.IDR;
+
+ if (type === NALU.SPS) {
+ const { codec, width, height } = WebCodecsPlayer.parseSPS(data.subarray(4));
+ this.scaleCanvas(width, height);
+ const config: VideoDecoderConfig = {
+ codec,
+ optimizeForLatency: true,
+ } as VideoDecoderConfig;
+ this.decoder.configure(config);
+ this.bufferedSPS = true;
+ this.addToBuffer(data);
+ this.hadIDR = false;
+ return;
+ } else if (type === NALU.PPS) {
+ this.bufferedPPS = true;
+ this.addToBuffer(data);
+ return;
+ } else if (type === NALU.SEI) {
+ // Workaround for lonely SEI from ws-qvh
+ if (!this.bufferedSPS || !this.bufferedPPS) {
+ return;
+ }
+ }
+ const array = this.addToBuffer(data);
+ this.hadIDR = this.hadIDR || isIDR;
+ if (array && this.decoder.state === 'configured' && this.hadIDR) {
+ this.buffer = undefined;
+ this.bufferedPPS = false;
+ this.bufferedSPS = false;
+ this.decoder.decode(
+ new EncodedVideoChunk({
+ type: 'key',
+ timestamp: 0,
+ data: array.buffer,
+ }),
+ );
+ return;
+ }
+ }
+
+ protected drawDecoded = (): void => {
+ if (this.receivedFirstFrame) {
+ const data = this.decodedFrames.shift();
+ if (data) {
+ const frame: VideoFrame = data.frame;
+ this.context.drawImage(frame, 0, 0);
+ frame.close();
+ }
+ }
+ if (this.decodedFrames.length) {
+ this.animationFrameId = requestAnimationFrame(this.drawDecoded);
+ } else {
+ this.animationFrameId = undefined;
+ }
+ };
+
+ protected dropFrame(frame: VideoFrame): void {
+ frame.close();
+ }
+
+ public getFitToScreenStatus(): boolean {
+ return false;
+ }
+
+ public getPreferredVideoSetting(): VideoSettings {
+ return WebCodecsPlayer.preferredVideoSettings;
+ }
+
+ public loadVideoSettings(): VideoSettings {
+ return WebCodecsPlayer.loadVideoSettings(this.udid, this.displayInfo);
+ }
+
+ protected needScreenInfoBeforePlay(): boolean {
+ return false;
+ }
+
+ public stop(): void {
+ super.stop();
+ if (this.decoder.state === 'configured') {
+ this.decoder.close();
+ }
+ }
+}
diff --git a/src/app/toolbox/ToolBox.ts b/src/app/toolbox/ToolBox.ts
new file mode 100644
index 0000000..1749832
--- /dev/null
+++ b/src/app/toolbox/ToolBox.ts
@@ -0,0 +1,19 @@
+import { ToolBoxElement } from './ToolBoxElement';
+
+export class ToolBox {
+ private readonly holder: HTMLElement;
+
+ constructor(list: ToolBoxElement[]) {
+ this.holder = document.createElement('div');
+ this.holder.classList.add('control-buttons-list', 'control-wrapper');
+ list.forEach((item) => {
+ item.getAllElements().forEach((el) => {
+ this.holder.appendChild(el);
+ });
+ });
+ }
+
+ public getHolderElement(): HTMLElement {
+ return this.holder;
+ }
+}
diff --git a/src/app/toolbox/ToolBoxButton.ts b/src/app/toolbox/ToolBoxButton.ts
new file mode 100644
index 0000000..f8b6cd4
--- /dev/null
+++ b/src/app/toolbox/ToolBoxButton.ts
@@ -0,0 +1,21 @@
+import { Optional, ToolBoxElement } from './ToolBoxElement';
+import SvgImage, { Icon } from '../ui/SvgImage';
+
+export class ToolBoxButton extends ToolBoxElement {
+ private readonly btn: HTMLButtonElement;
+ constructor(title: string, icon: Icon, optional?: Optional) {
+ super(title, optional);
+ const btn = document.createElement('button');
+ btn.classList.add('control-button');
+ btn.title = title;
+ btn.appendChild(SvgImage.create(icon));
+ this.btn = btn;
+ }
+
+ public getElement(): HTMLButtonElement {
+ return this.btn;
+ }
+ public getAllElements(): HTMLElement[] {
+ return [this.btn];
+ }
+}
diff --git a/src/app/toolbox/ToolBoxCheckbox.ts b/src/app/toolbox/ToolBoxCheckbox.ts
new file mode 100644
index 0000000..59acd2e
--- /dev/null
+++ b/src/app/toolbox/ToolBoxCheckbox.ts
@@ -0,0 +1,51 @@
+import { Optional, ToolBoxElement } from './ToolBoxElement';
+import SvgImage, { Icon } from '../ui/SvgImage';
+
+type Icons = {
+ on?: Icon;
+ off: Icon;
+};
+
+export class ToolBoxCheckbox extends ToolBoxElement {
+ private readonly input: HTMLInputElement;
+ private readonly label: HTMLLabelElement;
+ private readonly imageOn?: Element;
+ private readonly imageOff: Element;
+ constructor(title: string, icons: Icons | Icon, opt_id?: string, optional?: Optional) {
+ super(title, optional);
+ const input = document.createElement('input');
+ input.type = 'checkbox';
+ const label = document.createElement('label');
+ label.title = title;
+ label.classList.add('control-button');
+ let iconOff: Icon;
+ let iconOn: Icon | undefined;
+ if (typeof icons !== 'number') {
+ iconOff = icons.off;
+ iconOn = icons.on;
+ } else {
+ iconOff = icons;
+ }
+ this.imageOff = SvgImage.create(iconOff);
+ this.imageOff.classList.add('image', 'image-off');
+ label.appendChild(this.imageOff);
+ if (iconOn) {
+ this.imageOn = SvgImage.create(iconOn);
+ this.imageOn.classList.add('image', 'image-on');
+ label.appendChild(this.imageOn);
+ input.classList.add('two-images');
+ }
+ const id = opt_id || title.toLowerCase().replace(' ', '_');
+ label.htmlFor = input.id = `input_${id}`;
+ this.input = input;
+ this.label = label;
+ }
+
+ public getElement(): HTMLInputElement {
+ return this.input;
+ }
+
+ public getAllElements(): HTMLElement[] {
+ return [this.input, this.label];
+ }
+}
diff --git a/src/app/toolbox/ToolBoxElement.ts b/src/app/toolbox/ToolBoxElement.ts
new file mode 100644
index 0000000..30104dd
--- /dev/null
+++ b/src/app/toolbox/ToolBoxElement.ts
@@ -0,0 +1,53 @@
+export type Optional = {
+ [index: string]: any;
+};
+
+// type Listener = (type: K, el: ToolBoxElement) => any;
+
+export abstract class ToolBoxElement {
+ private listeners: Map(type: K, el: ToolBoxElement) => any>> =
+ new Map();
+ protected constructor(public readonly title: string, public readonly optional?: Optional) {}
+
+ public abstract getElement(): T;
+ public abstract getAllElements(): HTMLElement[];
+
+ public addEventListener(
+ type: K,
+ listener: (type: K, el: ToolBoxElement) => any,
+ options?: boolean | AddEventListenerOptions,
+ ): void {
+ const set = this.listeners.get(type) || new Set();
+ if (!set.size) {
+ const element = this.getElement();
+ element.addEventListener(type, this.onEvent, options);
+ }
+ set.add(listener);
+ this.listeners.set(type, set);
+ }
+ public removeEventListener(
+ type: K,
+ listener: (type: K, el: ToolBoxElement) => any,
+ ): void {
+ const set = this.listeners.get(type);
+ if (!set) {
+ return;
+ }
+ set.delete(listener);
+ if (!set.size) {
+ this.listeners.delete(type);
+ const element = this.getElement();
+ element.removeEventListener(type, this.onEvent);
+ }
+ }
+ onEvent = (ev: HTMLElementEventMap[K]): void => {
+ const set = this.listeners.get(ev.type);
+ if (!set) {
+ return;
+ }
+ const type = ev.type as K;
+ set.forEach((listener) => {
+ listener(type, this);
+ });
+ };
+}
diff --git a/src/app/ui/HtmlTag.ts b/src/app/ui/HtmlTag.ts
new file mode 100644
index 0000000..ca2ff36
--- /dev/null
+++ b/src/app/ui/HtmlTag.ts
@@ -0,0 +1,23 @@
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type Value = any;
+
+function htmlValue(value: Value): string {
+ if (value instanceof HTMLTemplateElement) {
+ return value.innerHTML;
+ }
+ if (typeof value === 'undefined') {
+ return 'undefined';
+ }
+ if (value === null) {
+ return 'null';
+ }
+ const e = document.createElement('dummy');
+ e.innerText = value.toString();
+ return e.innerHTML;
+}
+
+export const html = function html(strings: TemplateStringsArray, ...values: ReadonlyArray): HTMLTemplateElement {
+ const template = document.createElement('template') as HTMLTemplateElement;
+ template.innerHTML = values.reduce((acc, v, idx) => acc + htmlValue(v) + strings[idx + 1], strings[0]).toString();
+ return template;
+};
diff --git a/src/app/ui/SvgImage.ts b/src/app/ui/SvgImage.ts
new file mode 100644
index 0000000..392470e
--- /dev/null
+++ b/src/app/ui/SvgImage.ts
@@ -0,0 +1,91 @@
+import KeyboardSVG from '../../public/images/skin-light/ic_keyboard_678_48dp.svg';
+import MoreSVG from '../../public/images/skin-light/ic_more_horiz_678_48dp.svg';
+import CameraSVG from '../../public/images/skin-light/ic_photo_camera_678_48dp.svg';
+import PowerSVG from '../../public/images/skin-light/ic_power_settings_new_678_48px.svg';
+import VolumeDownSVG from '../../public/images/skin-light/ic_volume_down_678_48px.svg';
+import VolumeUpSVG from '../../public/images/skin-light/ic_volume_up_678_48px.svg';
+import BackSVG from '../../public/images/skin-light/System_Back_678.svg';
+import HomeSVG from '../../public/images/skin-light/System_Home_678.svg';
+import OverviewSVG from '../../public/images/skin-light/System_Overview_678.svg';
+import CancelSVG from '../../public/images/buttons/cancel.svg';
+import OfflineSVG from '../../public/images/buttons/offline.svg';
+import RefreshSVG from '../../public/images/buttons/refresh.svg';
+import SettingsSVG from '../../public/images/buttons/settings.svg';
+import MenuSVG from '../../public/images/buttons/menu.svg';
+import ArrowBackSVG from '../../public/images/buttons/arrow_back.svg';
+import ToggleOnSVG from '../../public/images/buttons/toggle_on.svg';
+import ToggleOffSVG from '../../public/images/buttons/toggle_off.svg';
+
+export enum Icon {
+ BACK,
+ HOME,
+ OVERVIEW,
+ POWER,
+ VOLUME_UP,
+ VOLUME_DOWN,
+ MORE,
+ CAMERA,
+ KEYBOARD,
+ CANCEL,
+ OFFLINE,
+ REFRESH,
+ SETTINGS,
+ MENU,
+ ARROW_BACK,
+ TOGGLE_ON,
+ TOGGLE_OFF,
+}
+
+export default class SvgImage {
+ static Icon = Icon;
+ private static getSvgString(type: Icon): string {
+ switch (type) {
+ case Icon.KEYBOARD:
+ return KeyboardSVG;
+ case Icon.MORE:
+ return MoreSVG;
+ case Icon.CAMERA:
+ return CameraSVG;
+ case Icon.POWER:
+ return PowerSVG;
+ case Icon.VOLUME_DOWN:
+ return VolumeDownSVG;
+ case Icon.VOLUME_UP:
+ return VolumeUpSVG;
+ case Icon.BACK:
+ return BackSVG;
+ case Icon.HOME:
+ return HomeSVG;
+ case Icon.OVERVIEW:
+ return OverviewSVG;
+ case Icon.CANCEL:
+ return CancelSVG;
+ case Icon.OFFLINE:
+ return OfflineSVG;
+ case Icon.REFRESH:
+ return RefreshSVG;
+ case Icon.SETTINGS:
+ return SettingsSVG;
+ case Icon.MENU:
+ return MenuSVG;
+ case Icon.ARROW_BACK:
+ return ArrowBackSVG;
+ case Icon.TOGGLE_ON:
+ return ToggleOnSVG;
+ case Icon.TOGGLE_OFF:
+ return ToggleOffSVG;
+ default:
+ return '';
+ }
+ }
+ public static create(type: Icon): Element {
+ const dummy = document.createElement('div');
+ dummy.innerHTML = this.getSvgString(type);
+ const svg = dummy.children[0];
+ const titles = svg.getElementsByTagName('title');
+ for (let i = 0, l = titles.length; i < l; i++) {
+ svg.removeChild(titles[i]);
+ }
+ return svg;
+ }
+}
diff --git a/src/common/Action.ts b/src/common/Action.ts
new file mode 100644
index 0000000..150091a
--- /dev/null
+++ b/src/common/Action.ts
@@ -0,0 +1,15 @@
+export enum ACTION {
+ LIST_HOSTS = 'list-hosts',
+ APPL_DEVICE_LIST = 'appl-device-list',
+ GOOG_DEVICE_LIST = 'goog-device-list',
+ MULTIPLEX = 'multiplex',
+ SHELL = 'shell',
+ PROXY_WS = 'proxy-ws',
+ PROXY_ADB = 'proxy-adb',
+ DEVTOOLS = 'devtools',
+ STREAM_SCRCPY = 'stream',
+ STREAM_WS_QVH = 'stream-qvh',
+ STREAM_MJPEG = 'stream-mjpeg',
+ PROXY_WDA = 'proxy-wda',
+ FILE_LISTING = 'list-files',
+}
diff --git a/src/common/ChannelCode.ts b/src/common/ChannelCode.ts
new file mode 100644
index 0000000..3d60f01
--- /dev/null
+++ b/src/common/ChannelCode.ts
@@ -0,0 +1,9 @@
+export enum ChannelCode {
+ FSLS = 'FSLS', // File System LiSt
+ HSTS = 'HSTS', // HoSTS List
+ SHEL = 'SHEL', // SHELl
+ GTRC = 'GTRC', // Goog device TRaCer
+ ATRC = 'ATRC', // Appl device TRaCer
+ WDAP = 'WDAP', // WebDriverAgent Proxy
+ QVHS = 'QVHS', // Quicktime_Video_Hack Stream
+}
diff --git a/src/common/Constants.ts b/src/common/Constants.ts
new file mode 100644
index 0000000..d804b3f
--- /dev/null
+++ b/src/common/Constants.ts
@@ -0,0 +1,20 @@
+export const SERVER_PACKAGE = 'com.genymobile.scrcpy.Server';
+export const SERVER_PORT = 8886;
+export const SERVER_VERSION = '1.19-ws6';
+
+export const SERVER_TYPE = 'web';
+
+export const LOG_LEVEL = 'ERROR';
+
+let SCRCPY_LISTENS_ON_ALL_INTERFACES;
+/// #if SCRCPY_LISTENS_ON_ALL_INTERFACES
+SCRCPY_LISTENS_ON_ALL_INTERFACES = true;
+/// #else
+SCRCPY_LISTENS_ON_ALL_INTERFACES = false;
+/// #endif
+
+const ARGUMENTS = [SERVER_VERSION, SERVER_TYPE, LOG_LEVEL, SERVER_PORT, SCRCPY_LISTENS_ON_ALL_INTERFACES];
+
+export const SERVER_PROCESS_NAME = 'app_process';
+
+export const ARGS_STRING = `/ ${SERVER_PACKAGE} ${ARGUMENTS.join(' ')} 2>&1 > /dev/null`;
diff --git a/src/common/ControlCenterCommand.ts b/src/common/ControlCenterCommand.ts
new file mode 100644
index 0000000..8fbf7a5
--- /dev/null
+++ b/src/common/ControlCenterCommand.ts
@@ -0,0 +1,77 @@
+import { WDAMethod } from './WDAMethod';
+
+export class ControlCenterCommand {
+ public static KILL_SERVER = 'kill_server';
+ public static START_SERVER = 'start_server';
+ public static UPDATE_INTERFACES = 'update_interfaces';
+ public static CONFIGURE_STREAM = 'configure_stream';
+ public static RUN_WDA = 'run-wda';
+ public static REQUEST_WDA = 'request-wda';
+
+ private id = -1;
+ private type = '';
+ private pid = 0;
+ private udid = '';
+ private method = '';
+ private args?: any;
+ private data?: any;
+
+ public static fromJSON(json: string): ControlCenterCommand {
+ const body = JSON.parse(json);
+ if (!body) {
+ throw new Error('Invalid input');
+ }
+ const command = new ControlCenterCommand();
+ const data = (command.data = body.data);
+ command.id = body.id;
+ command.type = body.type;
+
+ if (typeof data.udid === 'string') {
+ command.udid = data.udid;
+ }
+ switch (body.type) {
+ case this.KILL_SERVER:
+ if (typeof data.pid !== 'number' && data.pid <= 0) {
+ throw new Error('Invalid "pid" value');
+ }
+ command.pid = data.pid;
+ return command;
+ case this.REQUEST_WDA:
+ if (typeof data.method !== 'string') {
+ throw new Error('Invalid "method" value');
+ }
+ command.method = data.method;
+ command.args = data.args;
+ return command;
+ case this.START_SERVER:
+ case this.UPDATE_INTERFACES:
+ case this.CONFIGURE_STREAM:
+ case this.RUN_WDA:
+ return command;
+ default:
+ throw new Error(`Unknown command "${body.command}"`);
+ }
+ }
+
+ public getType(): string {
+ return this.type;
+ }
+ public getPid(): number {
+ return this.pid;
+ }
+ public getUdid(): string {
+ return this.udid;
+ }
+ public getId(): number {
+ return this.id;
+ }
+ public getMethod(): WDAMethod | string {
+ return this.method;
+ }
+ public getData(): any {
+ return this.data;
+ }
+ public getArgs(): any {
+ return this.args;
+ }
+}
diff --git a/src/common/DeviceState.ts b/src/common/DeviceState.ts
new file mode 100644
index 0000000..f501b44
--- /dev/null
+++ b/src/common/DeviceState.ts
@@ -0,0 +1,6 @@
+export enum DeviceState {
+ DEVICE = 'device',
+ DISCONNECTED = 'disconnected',
+
+ CONNECTED = 'Connected',
+}
diff --git a/src/common/HostTrackerMessage.ts b/src/common/HostTrackerMessage.ts
new file mode 100644
index 0000000..dde6ccf
--- /dev/null
+++ b/src/common/HostTrackerMessage.ts
@@ -0,0 +1,20 @@
+import { Message } from '../types/Message';
+import { HostItem } from '../types/Configuration';
+
+export enum MessageType {
+ HOSTS = 'hosts',
+ ERROR = 'error',
+}
+
+export interface MessageHosts extends Message {
+ type: 'hosts';
+ data: {
+ local?: { type: string }[];
+ remote?: HostItem[];
+ };
+}
+
+export interface MessageError extends Message {
+ type: 'error';
+ data: string;
+}
diff --git a/src/common/ProductType.ts b/src/common/ProductType.ts
new file mode 100644
index 0000000..ab46888
--- /dev/null
+++ b/src/common/ProductType.ts
@@ -0,0 +1,147 @@
+export class ProductType {
+ // from https://gist.github.com/adamawolf/3048717
+ private static type: Record = {
+ i386: 'iPhone Simulator',
+ x86_64: 'iPhone Simulator',
+ arm64: 'iPhone Simulator',
+ 'iPhone1,1': 'iPhone',
+ 'iPhone1,2': 'iPhone 3G',
+ 'iPhone2,1': 'iPhone 3GS',
+ 'iPhone3,1': 'iPhone 4',
+ 'iPhone3,2': 'iPhone 4 GSM Rev A',
+ 'iPhone3,3': 'iPhone 4 CDMA',
+ 'iPhone4,1': 'iPhone 4S',
+ 'iPhone5,1': 'iPhone 5 (GSM)',
+ 'iPhone5,2': 'iPhone 5 (GSM+CDMA)',
+ 'iPhone5,3': 'iPhone 5C (GSM)',
+ 'iPhone5,4': 'iPhone 5C (Global)',
+ 'iPhone6,1': 'iPhone 5S (GSM)',
+ 'iPhone6,2': 'iPhone 5S (Global)',
+ 'iPhone7,1': 'iPhone 6 Plus',
+ 'iPhone7,2': 'iPhone 6',
+ 'iPhone8,1': 'iPhone 6s',
+ 'iPhone8,2': 'iPhone 6s Plus',
+ 'iPhone8,4': 'iPhone SE (GSM)',
+ 'iPhone9,1': 'iPhone 7',
+ 'iPhone9,2': 'iPhone 7 Plus',
+ 'iPhone9,3': 'iPhone 7',
+ 'iPhone9,4': 'iPhone 7 Plus',
+ 'iPhone10,1': 'iPhone 8',
+ 'iPhone10,2': 'iPhone 8 Plus',
+ 'iPhone10,3': 'iPhone X Global',
+ 'iPhone10,4': 'iPhone 8',
+ 'iPhone10,5': 'iPhone 8 Plus',
+ 'iPhone10,6': 'iPhone X GSM',
+ 'iPhone11,2': 'iPhone XS',
+ 'iPhone11,4': 'iPhone XS Max',
+ 'iPhone11,6': 'iPhone XS Max Global',
+ 'iPhone11,8': 'iPhone XR',
+ 'iPhone12,1': 'iPhone 11',
+ 'iPhone12,3': 'iPhone 11 Pro',
+ 'iPhone12,5': 'iPhone 11 Pro Max',
+ 'iPhone12,8': 'iPhone SE 2nd Gen',
+ 'iPhone13,1': 'iPhone 12 Mini',
+ 'iPhone13,2': 'iPhone 12',
+ 'iPhone13,3': 'iPhone 12 Pro',
+ 'iPhone13,4': 'iPhone 12 Pro Max',
+ 'iPod1,1': '1st Gen iPod',
+ 'iPod2,1': '2nd Gen iPod',
+ 'iPod3,1': '3rd Gen iPod',
+ 'iPod4,1': '4th Gen iPod',
+ 'iPod5,1': '5th Gen iPod',
+ 'iPod7,1': '6th Gen iPod',
+ 'iPod9,1': '7th Gen iPod',
+ 'iPad1,1': 'iPad',
+ 'iPad1,2': 'iPad 3G',
+ 'iPad2,1': '2nd Gen iPad',
+ 'iPad2,2': '2nd Gen iPad GSM',
+ 'iPad2,3': '2nd Gen iPad CDMA',
+ 'iPad2,4': '2nd Gen iPad New Revision',
+ 'iPad3,1': '3rd Gen iPad',
+ 'iPad3,2': '3rd Gen iPad CDMA',
+ 'iPad3,3': '3rd Gen iPad GSM',
+ 'iPad2,5': 'iPad mini',
+ 'iPad2,6': 'iPad mini GSM+LTE',
+ 'iPad2,7': 'iPad mini CDMA+LTE',
+ 'iPad3,4': '4th Gen iPad',
+ 'iPad3,5': '4th Gen iPad GSM+LTE',
+ 'iPad3,6': '4th Gen iPad CDMA+LTE',
+ 'iPad4,1': 'iPad Air (WiFi)',
+ 'iPad4,2': 'iPad Air (GSM+CDMA)',
+ 'iPad4,3': '1st Gen iPad Air (China)',
+ 'iPad4,4': 'iPad mini Retina (WiFi)',
+ 'iPad4,5': 'iPad mini Retina (GSM+CDMA)',
+ 'iPad4,6': 'iPad mini Retina (China)',
+ 'iPad4,7': 'iPad mini 3 (WiFi)',
+ 'iPad4,8': 'iPad mini 3 (GSM+CDMA)',
+ 'iPad4,9': 'iPad Mini 3 (China)',
+ 'iPad5,1': 'iPad mini 4 (WiFi)',
+ 'iPad5,2': '4th Gen iPad mini (WiFi+Cellular)',
+ 'iPad5,3': 'iPad Air 2 (WiFi)',
+ 'iPad5,4': 'iPad Air 2 (Cellular)',
+ 'iPad6,3': 'iPad Pro (9.7 inch, WiFi)',
+ 'iPad6,4': 'iPad Pro (9.7 inch, WiFi+LTE)',
+ 'iPad6,7': 'iPad Pro (12.9 inch, WiFi)',
+ 'iPad6,8': 'iPad Pro (12.9 inch, WiFi+LTE)',
+ 'iPad6,11': 'iPad (2017)',
+ 'iPad6,12': 'iPad (2017)',
+ 'iPad7,1': 'iPad Pro 2nd Gen (WiFi)',
+ 'iPad7,2': 'iPad Pro 2nd Gen (WiFi+Cellular)',
+ 'iPad7,3': 'iPad Pro 10.5-inch',
+ 'iPad7,4': 'iPad Pro 10.5-inch',
+ 'iPad7,5': 'iPad 6th Gen (WiFi)',
+ 'iPad7,6': 'iPad 6th Gen (WiFi+Cellular)',
+ 'iPad7,11': 'iPad 7th Gen 10.2-inch (WiFi)',
+ 'iPad7,12': 'iPad 7th Gen 10.2-inch (WiFi+Cellular)',
+ 'iPad8,1': 'iPad Pro 11 inch 3rd Gen (WiFi)',
+ 'iPad8,2': 'iPad Pro 11 inch 3rd Gen (1TB, WiFi)',
+ 'iPad8,3': 'iPad Pro 11 inch 3rd Gen (WiFi+Cellular)',
+ 'iPad8,4': 'iPad Pro 11 inch 3rd Gen (1TB, WiFi+Cellular)',
+ 'iPad8,5': 'iPad Pro 12.9 inch 3rd Gen (WiFi)',
+ 'iPad8,6': 'iPad Pro 12.9 inch 3rd Gen (1TB, WiFi)',
+ 'iPad8,7': 'iPad Pro 12.9 inch 3rd Gen (WiFi+Cellular)',
+ 'iPad8,8': 'iPad Pro 12.9 inch 3rd Gen (1TB, WiFi+Cellular)',
+ 'iPad8,9': 'iPad Pro 11 inch 4th Gen (WiFi)',
+ 'iPad8,10': 'iPad Pro 11 inch 4th Gen (WiFi+Cellular)',
+ 'iPad8,11': 'iPad Pro 12.9 inch 4th Gen (WiFi)',
+ 'iPad8,12': 'iPad Pro 12.9 inch 4th Gen (WiFi+Cellular)',
+ 'iPad11,1': 'iPad mini 5th Gen (WiFi)',
+ 'iPad11,2': 'iPad mini 5th Gen',
+ 'iPad11,3': 'iPad Air 3rd Gen (WiFi)',
+ 'iPad11,4': 'iPad Air 3rd Gen',
+ 'iPad11,6': 'iPad 8th Gen (WiFi)',
+ 'iPad11,7': 'iPad 8th Gen (WiFi+Cellular)',
+ 'iPad13,1': 'iPad air 4th Gen (WiFi)',
+ 'iPad13,2': 'iPad air 4th Gen (WiFi+Cellular)',
+ 'Watch1,1': 'Apple Watch 38mm case',
+ 'Watch1,2': 'Apple Watch 42mm case',
+ 'Watch2,6': 'Apple Watch Series 1 38mm case',
+ 'Watch2,7': 'Apple Watch Series 1 42mm case',
+ 'Watch2,3': 'Apple Watch Series 2 38mm case',
+ 'Watch2,4': 'Apple Watch Series 2 42mm case',
+ 'Watch3,1': 'Apple Watch Series 3 38mm case (GPS+Cellular)',
+ 'Watch3,2': 'Apple Watch Series 3 42mm case (GPS+Cellular)',
+ 'Watch3,3': 'Apple Watch Series 3 38mm case (GPS)',
+ 'Watch3,4': 'Apple Watch Series 3 42mm case (GPS)',
+ 'Watch4,1': 'Apple Watch Series 4 40mm case (GPS)',
+ 'Watch4,2': 'Apple Watch Series 4 44mm case (GPS)',
+ 'Watch4,3': 'Apple Watch Series 4 40mm case (GPS+Cellular)',
+ 'Watch4,4': 'Apple Watch Series 4 44mm case (GPS+Cellular)',
+ 'Watch5,1': 'Apple Watch Series 5 40mm case (GPS)',
+ 'Watch5,2': 'Apple Watch Series 5 44mm case (GPS)',
+ 'Watch5,3': 'Apple Watch Series 5 40mm case (GPS+Cellular)',
+ 'Watch5,4': 'Apple Watch Series 5 44mm case (GPS+Cellular)',
+ 'Watch5,9': 'Apple Watch SE 40mm case (GPS)',
+ 'Watch5,10': 'Apple Watch SE 44mm case (GPS)',
+ 'Watch5,11': 'Apple Watch SE 40mm case (GPS+Cellular)',
+ 'Watch5,12': 'Apple Watch SE 44mm case (GPS+Cellular)',
+ 'Watch6,1': 'Apple Watch Series 6 40mm case (GPS)',
+ 'Watch6,2': 'Apple Watch Series 6 44mm case (GPS)',
+ 'Watch6,3': 'Apple Watch Series 6 40mm case (GPS+Cellular)',
+ 'Watch6,4': 'Apple Watch Series 6 44mm case (GPS+Cellular)',
+ };
+
+ public static getModel(productType: string): string {
+ return this.type[productType] || productType;
+ }
+}
diff --git a/src/common/TypedEmitter.ts b/src/common/TypedEmitter.ts
new file mode 100644
index 0000000..3895fd7
--- /dev/null
+++ b/src/common/TypedEmitter.ts
@@ -0,0 +1,43 @@
+import { EventEmitter } from 'events';
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type EventMap = Record;
+export type EventKey = string & keyof T;
+export type EventReceiver = (params: T) => void;
+
+interface Emitter {
+ on>(eventName: K, fn: EventReceiver): void;
+ off>(eventName: K, fn: EventReceiver): void;
+ emit>(eventName: K, params: T[K]): void;
+}
+
+export class TypedEmitter implements Emitter {
+ private emitter = new EventEmitter();
+ addEventListener>(eventName: K, fn: EventReceiver): void {
+ this.emitter.on(eventName, fn);
+ }
+
+ removeEventListener>(eventName: K, fn: EventReceiver): void {
+ this.emitter.off(eventName, fn);
+ }
+
+ dispatchEvent(event: Event): boolean {
+ return this.emitter.emit(event.type, event);
+ }
+
+ on>(eventName: K, fn: EventReceiver): void {
+ this.emitter.on(eventName, fn);
+ }
+
+ once>(eventName: K, fn: EventReceiver): void {
+ this.emitter.once(eventName, fn);
+ }
+
+ off>(eventName: K, fn: EventReceiver): void {
+ this.emitter.off(eventName, fn);
+ }
+
+ emit>(eventName: K, params: T[K]): boolean {
+ return this.emitter.emit(eventName, params);
+ }
+}
diff --git a/src/common/WDAMethod.ts b/src/common/WDAMethod.ts
new file mode 100644
index 0000000..caf2bd5
--- /dev/null
+++ b/src/common/WDAMethod.ts
@@ -0,0 +1,8 @@
+export enum WDAMethod {
+ CLICK = 'CLICK',
+ SCROLL = 'SCROLL',
+ PRESS_BUTTON = 'PRESS_BUTTON',
+ GET_SCREEN_WIDTH = 'GET_SCREEN_WIDTH',
+ APPIUM_SETTINGS = 'APPIUM_SETTINGS',
+ SEND_KEYS = 'SEND_KEYS',
+}
diff --git a/src/common/WdaStatus.ts b/src/common/WdaStatus.ts
new file mode 100644
index 0000000..3089b9e
--- /dev/null
+++ b/src/common/WdaStatus.ts
@@ -0,0 +1,5 @@
+export enum WdaStatus {
+ STARTING = 'STARTING',
+ STARTED = 'STARTED',
+ STOPPED = 'STOPPED',
+}
diff --git a/src/packages/multiplexer/CloseEventClass.ts b/src/packages/multiplexer/CloseEventClass.ts
new file mode 100644
index 0000000..8932977
--- /dev/null
+++ b/src/packages/multiplexer/CloseEventClass.ts
@@ -0,0 +1,15 @@
+import { Event2 } from './Event';
+
+export class CloseEvent2 extends Event2 implements CloseEvent {
+ readonly code: number;
+ readonly reason: string;
+ readonly wasClean: boolean;
+ constructor(type: string, { code, reason }: CloseEventInit = {}) {
+ super(type);
+ this.code = code || 0;
+ this.reason = reason || '';
+ this.wasClean = this.code === 0;
+ }
+}
+
+export const CloseEventClass = typeof CloseEvent !== 'undefined' ? CloseEvent : CloseEvent2;
diff --git a/src/packages/multiplexer/ErrorEventClass.ts b/src/packages/multiplexer/ErrorEventClass.ts
new file mode 100644
index 0000000..fd364b2
--- /dev/null
+++ b/src/packages/multiplexer/ErrorEventClass.ts
@@ -0,0 +1,20 @@
+import { Event2 } from './Event';
+
+export class ErrorEvent2 extends Event2 implements ErrorEvent {
+ readonly colno: number;
+ readonly error: any;
+ readonly filename: string;
+ readonly lineno: number;
+ readonly message: string;
+
+ constructor(type: string, { colno, error, filename, lineno, message }: ErrorEventInit = {}) {
+ super(type);
+ this.error = error;
+ this.colno = colno || 0;
+ this.filename = filename || '';
+ this.lineno = lineno || 0;
+ this.message = message || '';
+ }
+}
+
+export const ErrorEventClass = typeof ErrorEvent !== 'undefined' ? ErrorEvent : ErrorEvent2;
diff --git a/src/packages/multiplexer/Event.ts b/src/packages/multiplexer/Event.ts
new file mode 100644
index 0000000..63eda41
--- /dev/null
+++ b/src/packages/multiplexer/Event.ts
@@ -0,0 +1,78 @@
+export class Event2 {
+ static NONE = 0;
+ static CAPTURING_PHASE = 1;
+ static AT_TARGET = 2;
+ static BUBBLING_PHASE = 3;
+
+ public cancelable: boolean;
+ public bubbles: boolean;
+ public composed: boolean;
+ public type: string;
+ public defaultPrevented: boolean;
+ public timeStamp: number;
+ public target: any;
+ public readonly isTrusted: boolean = true;
+ readonly AT_TARGET: number = 0;
+ readonly BUBBLING_PHASE: number = 0;
+ readonly CAPTURING_PHASE: number = 0;
+ readonly NONE: number = 0;
+
+ constructor(type: string, options = { cancelable: true, bubbles: true, composed: false }) {
+ const { cancelable, bubbles, composed } = { ...options };
+ this.cancelable = !!cancelable;
+ this.bubbles = !!bubbles;
+ this.composed = !!composed;
+ this.type = `${type}`;
+ this.defaultPrevented = false;
+ this.timeStamp = Date.now();
+ this.target = null;
+ }
+
+ stopImmediatePropagation() {
+ // this[kStop] = true;
+ }
+
+ preventDefault() {
+ this.defaultPrevented = true;
+ }
+
+ get currentTarget() {
+ return this.target;
+ }
+ get srcElement() {
+ return this.target;
+ }
+
+ composedPath() {
+ return this.target ? [this.target] : [];
+ }
+ get returnValue() {
+ return !this.defaultPrevented;
+ }
+ get eventPhase() {
+ return this.target ? Event.AT_TARGET : Event.NONE;
+ }
+ get cancelBubble() {
+ return false;
+ // return this.propagationStopped;
+ }
+ set cancelBubble(value: any) {
+ if (value) {
+ this.stopPropagation();
+ }
+ }
+ stopPropagation() {
+ // this.propagationStopped = true;
+ }
+ initEvent(type: string, bubbles?: boolean, cancelable?: boolean): void {
+ this.type = type;
+ if (arguments.length > 1) {
+ this.bubbles = !!bubbles;
+ }
+ if (arguments.length > 2) {
+ this.cancelable = !!cancelable;
+ }
+ }
+}
+
+export const EventClass = typeof Event !== 'undefined' ? Event : Event2;
diff --git a/src/packages/multiplexer/Message.ts b/src/packages/multiplexer/Message.ts
new file mode 100644
index 0000000..8f5b818
--- /dev/null
+++ b/src/packages/multiplexer/Message.ts
@@ -0,0 +1,64 @@
+import { MessageType } from './MessageType';
+import Util from '../../app/Util';
+import { CloseEventClass } from './CloseEventClass';
+
+export class Message {
+ public static parse(buffer: ArrayBuffer): Message {
+ const view = Buffer.from(buffer);
+
+ const type: MessageType = view.readUInt8(0);
+ const channelId = view.readUInt32LE(1);
+ const data: ArrayBuffer = buffer.slice(5);
+
+ return new Message(type, channelId, data);
+ }
+
+ public static fromCloseEvent(id: number, code: number, reason?: string): Message {
+ const reasonBuffer = reason ? Util.stringToUtf8ByteArray(reason) : Buffer.alloc(0);
+ const buffer = Buffer.alloc(2 + 4 + reasonBuffer.byteLength);
+ buffer.writeUInt16LE(code, 0);
+ if (reasonBuffer.byteLength) {
+ buffer.writeUInt32LE(reasonBuffer.byteLength, 2);
+ buffer.set(reasonBuffer, 6);
+ }
+ return new Message(MessageType.CloseChannel, id, buffer);
+ }
+
+ public static createBuffer(type: MessageType, channelId: number, data?: ArrayBuffer): Buffer {
+ const result = Buffer.alloc(5 + (data ? data.byteLength : 0));
+ result.writeUInt8(type, 0);
+ result.writeUInt32LE(channelId, 1);
+ if (data?.byteLength) {
+ result.set(Buffer.from(data), 5);
+ }
+ return result;
+ }
+
+ public constructor(
+ public readonly type: MessageType,
+ public readonly channelId: number,
+ public readonly data: ArrayBuffer,
+ ) {}
+
+ public toCloseEvent(): CloseEvent {
+ let code: number | undefined;
+ let reason: string | undefined;
+ if (this.data && this.data.byteLength) {
+ const buffer = Buffer.from(this.data);
+ code = buffer.readUInt16LE(0);
+ if (buffer.byteLength > 6) {
+ const length = buffer.readUInt32LE(2);
+ reason = Util.utf8ByteArrayToString(buffer.slice(6, 6 + length));
+ }
+ }
+ return new CloseEventClass('close', {
+ code,
+ reason,
+ wasClean: code === 1000,
+ });
+ }
+
+ public toBuffer(): ArrayBuffer {
+ return Message.createBuffer(this.type, this.channelId, this.data);
+ }
+}
diff --git a/src/packages/multiplexer/MessageEventClass.ts b/src/packages/multiplexer/MessageEventClass.ts
new file mode 100644
index 0000000..9838106
--- /dev/null
+++ b/src/packages/multiplexer/MessageEventClass.ts
@@ -0,0 +1,26 @@
+import { Event2 } from './Event';
+
+export class MessageEvent2 extends Event2 implements MessageEvent {
+ public readonly data: any;
+ public readonly origin: string;
+ public readonly lastEventId: string;
+ public readonly source: any;
+ public readonly ports: ReadonlyArray;
+ constructor(
+ type: string,
+ { data = null, origin = '', lastEventId = '', source = null, ports = [] }: MessageEventInit = {},
+ ) {
+ super(type);
+ this.data = data;
+ this.origin = `${origin}`;
+ this.lastEventId = `${lastEventId}`;
+ this.source = source;
+ this.ports = [...ports];
+ }
+
+ initMessageEvent(): void {
+ throw Error('Deprecated method');
+ }
+}
+
+export const MessageEventClass = typeof MessageEvent !== 'undefined' ? MessageEvent : MessageEvent2;
diff --git a/src/packages/multiplexer/MessageType.ts b/src/packages/multiplexer/MessageType.ts
new file mode 100644
index 0000000..6f7f721
--- /dev/null
+++ b/src/packages/multiplexer/MessageType.ts
@@ -0,0 +1,7 @@
+export enum MessageType {
+ CreateChannel = 4,
+ CloseChannel = 8,
+ RawBinaryData = 16,
+ RawStringData = 32,
+ Data = 64,
+}
diff --git a/src/packages/multiplexer/Multiplexer.ts b/src/packages/multiplexer/Multiplexer.ts
new file mode 100644
index 0000000..ee7b0fd
--- /dev/null
+++ b/src/packages/multiplexer/Multiplexer.ts
@@ -0,0 +1,366 @@
+import { TypedEmitter } from '../../common/TypedEmitter';
+import { Message } from './Message';
+import { MessageType } from './MessageType';
+import { EventClass } from './Event';
+import { CloseEventClass } from './CloseEventClass';
+import { ErrorEventClass } from './ErrorEventClass';
+import { MessageEventClass } from './MessageEventClass';
+import Util from '../../app/Util';
+
+interface MultiplexerEvents extends WebSocketEventMap {
+ empty: Multiplexer;
+ channel: { channel: Multiplexer; data: ArrayBuffer };
+ open: Event;
+ close: CloseEvent;
+ message: MessageEvent;
+}
+
+export interface WebsocketEventEmitter {
+ dispatchEvent(event: Event): boolean;
+ addEventListener(
+ type: K,
+ listener: (this: WebSocket, ev: WebSocketEventMap[K]) => any,
+ options?: boolean | AddEventListenerOptions,
+ ): void;
+ removeEventListener(
+ type: K,
+ listener: (this: WebSocket, ev: WebSocketEventMap[K]) => any,
+ options?: boolean | EventListenerOptions,
+ ): void;
+}
+
+export class Multiplexer extends TypedEmitter implements WebSocket {
+ readonly CONNECTING = 0;
+ readonly OPEN = 1;
+ readonly CLOSING = 2;
+ readonly CLOSED = 3;
+ public binaryType: BinaryType = 'blob';
+ public readyState: number;
+ private channels: Map = new Map();
+ private nextId = 0;
+ private maxId = 4294967296;
+ private storage: Array = [];
+ private readonly messageEmitter: WebsocketEventEmitter;
+ private emptyTimerScheduled = false;
+
+ public onclose: ((this: WebSocket, ev: CloseEvent) => any) | null = null;
+ public onerror: ((this: WebSocket, ev: Event) => any) | null = null;
+ public onmessage: ((this: WebSocket, ev: MessageEvent) => any) | null = null;
+ public onopen: ((this: WebSocket, ev: Event) => any) | null = null;
+ public url = '';
+
+ public static wrap(ws: WebSocket): Multiplexer {
+ return new Multiplexer(ws);
+ }
+
+ protected constructor(public readonly ws: WebSocket, private _id = 0, emitter?: WebsocketEventEmitter) {
+ super();
+ this.readyState = this.CONNECTING;
+ if (this._id === 0) {
+ ws.binaryType = 'arraybuffer';
+ this.readyState = this.ws.readyState;
+ }
+ this.messageEmitter = emitter || ws;
+
+ const onOpenHandler = (event: Event) => {
+ this.readyState = this.ws.readyState;
+ this.dispatchEvent(event);
+ };
+
+ const onCloseHandler = (event: CloseEvent) => {
+ this.readyState = this.ws.readyState;
+ this.dispatchEvent(event);
+ this.channels.clear();
+ };
+
+ const onErrorHandler = (event: Event) => {
+ this.readyState = this.ws.readyState;
+ this.dispatchEvent(event);
+ this.channels.clear();
+ };
+
+ const onMessageHandler = (event: MessageEvent) => {
+ const { data } = event;
+ // console.log('收到的对象数据', event)
+ const message = Message.parse(data);
+ // console.log('解析对象数据', message)
+
+ switch (message.type) {
+ case MessageType.CreateChannel: {
+ const { channelId, data } = message;
+ if (this.nextId < channelId) {
+ this.nextId = channelId;
+ }
+ const channel = this._createChannel(channelId, false);
+ this.emit('channel', { channel, data });
+ break;
+ }
+ case MessageType.RawStringData: {
+ const data = this.channels.get(message.channelId);
+ if (data) {
+ const { channel } = data;
+ const msg = new MessageEventClass('message', {
+ data: Util.utf8ByteArrayToString(Buffer.from(message.data)),
+ lastEventId: event.lastEventId,
+ origin: event.origin,
+ source: event.source,
+ });
+ channel.dispatchEvent(msg);
+ } else {
+ console.error(`Channel with id (${message.channelId}) not found`);
+ }
+ break;
+ }
+ case MessageType.RawBinaryData: {
+ const data = this.channels.get(message.channelId);
+ if (data) {
+ const { channel } = data;
+ const msg = new MessageEventClass('message', {
+ data: message.data,
+ lastEventId: event.lastEventId,
+ origin: event.origin,
+ source: event.source,
+ });
+ channel.dispatchEvent(msg);
+ } else {
+ console.error(`Channel with id (${message.channelId}) not found`);
+ }
+ break;
+ }
+ case MessageType.Data: {
+ const data = this.channels.get(message.channelId);
+ if (data) {
+ const { emitter } = data;
+ const msg = new MessageEventClass('message', {
+ data: message.data,
+ lastEventId: event.lastEventId,
+ origin: event.origin,
+ source: event.source,
+ });
+ emitter.dispatchEvent(msg);
+ } else {
+ console.error(`Channel with id (${message.channelId}) not found`);
+ }
+ break;
+ }
+ case MessageType.CloseChannel: {
+ const data = this.channels.get(message.channelId);
+ if (data) {
+ const { channel } = data;
+ channel.readyState = channel.CLOSING;
+ try {
+ channel.dispatchEvent(message.toCloseEvent());
+ } finally {
+ channel.readyState = channel.CLOSED;
+ }
+ } else {
+ console.error(`Channel with id (${message.channelId}) not found`);
+ }
+ break;
+ }
+ default:
+ const error = new Error(`Unsupported message type: ${message.type}`);
+ this.dispatchEvent(new ErrorEventClass('error', { error }));
+ }
+ };
+
+ const onThisOpenHandler = () => {
+ if (!this.storage.length) {
+ return;
+ }
+ const ws = this.ws;
+ if (ws instanceof Multiplexer) {
+ this.storage.forEach((data) => ws.sendData(data));
+ } else {
+
+ this.storage.forEach((data) => {
+ console.log('发送的参数9', data);
+
+ ws.send(data)
+ });
+ }
+ this.storage.length = 0;
+ };
+
+ const onThisCloseHandler = () => {
+ ws.removeEventListener('open', onOpenHandler);
+ ws.removeEventListener('error', onErrorHandler);
+ ws.removeEventListener('close', onCloseHandler);
+ this.messageEmitter.removeEventListener('message', onMessageHandler);
+ this.off('close', onThisCloseHandler);
+ this.off('open', onThisOpenHandler);
+ };
+
+ ws.addEventListener('open', onOpenHandler);
+ ws.addEventListener('error', onErrorHandler);
+ ws.addEventListener('close', onCloseHandler);
+ this.messageEmitter.addEventListener('message', onMessageHandler);
+
+ this.on('close', onThisCloseHandler);
+ this.on('open', onThisOpenHandler);
+ this.scheduleEmptyEvent();
+ }
+
+ public get bufferedAmount(): number {
+ return 0;
+ }
+
+ public get extensions(): string {
+ return '';
+ }
+
+ public get protocol(): string {
+ return '';
+ }
+
+ public get id(): number {
+ return this._id;
+ }
+
+ private scheduleEmptyEvent(): void {
+ if (this.emptyTimerScheduled) {
+ return;
+ }
+ this.emptyTimerScheduled = true;
+ Promise.resolve().then(() => {
+ if (this.emptyTimerScheduled) {
+ this.emptyTimerScheduled = false;
+ this.emit('empty', this);
+ }
+ });
+ }
+
+ private clearEmptyEvent(): void {
+ if (this.emptyTimerScheduled) {
+ this.emptyTimerScheduled = false;
+ }
+ }
+
+ public close(code = 1000, reason?: string): void {
+ if (this.readyState === this.CLOSED || this.readyState === this.CLOSING) {
+ return;
+ }
+ if (this._id) {
+ this.readyState = this.CLOSING;
+
+ try {
+ const message = Message.fromCloseEvent(this._id, code, reason).toBuffer();
+ if (this.ws instanceof Multiplexer) {
+ this.ws.sendData(message);
+ } else {
+ console.log('发送的参数10');
+
+ this.ws.send(message);
+ }
+ this.emit('close', new CloseEventClass('close', { code, reason }));
+ } finally {
+ this.readyState = this.CLOSED;
+ }
+ } else {
+ this.ws.close(code, reason);
+ }
+ }
+
+ public send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
+ if (this.ws instanceof Multiplexer) {
+ if (typeof data === 'string') {
+ data = Message.createBuffer(MessageType.RawStringData, this._id, Buffer.from(data));
+ } else {
+ data = Message.createBuffer(MessageType.RawBinaryData, this._id, Buffer.from(data));
+ }
+ }
+ // console.log('发送的参数13send', data);
+
+ this._send(data);
+ }
+
+ public sendData(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
+ if (this.ws instanceof Multiplexer) {
+ console.log('发送的参数13sendData', data);
+ data = Message.createBuffer(MessageType.Data, this._id, Buffer.from(data));
+ }
+
+ this._send(data);
+ }
+
+ private _send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
+ const { readyState } = this;
+ if (readyState === this.OPEN) {
+ if (this.ws instanceof Multiplexer) {
+ this.ws.sendData(data);
+ } else {
+ // console.log('发送的参数13', data);
+ this.ws.send(data);
+ }
+ } else if (readyState === this.ws.CONNECTING) {
+ this.storage.push(data);
+ } else {
+ throw Error(`Socket is already in CLOSING or CLOSED state.`);
+ }
+ }
+
+ private _createChannel(id: number, sendOpenEvent: boolean): Multiplexer {
+ const emitter = new TypedEmitter();
+ const channel = new Multiplexer(this, id, emitter);
+ this.channels.set(id, { channel, emitter });
+ if (sendOpenEvent) {
+ if (this.readyState === this.OPEN) {
+ Util.setImmediate(() => {
+ channel.readyState = this.OPEN;
+ channel.dispatchEvent(new EventClass('open'));
+ });
+ }
+ } else {
+ channel.readyState = this.readyState;
+ }
+ channel.addEventListener('close', () => {
+ this.channels.delete(id);
+ if (!this.channels.size) {
+ this.scheduleEmptyEvent();
+ }
+ });
+ this.clearEmptyEvent();
+ return channel;
+ }
+
+ public createChannel(data: Buffer): Multiplexer {
+ if (this.readyState === this.CLOSING || this.readyState === this.CLOSED) {
+ throw Error('Incorrect socket state');
+ }
+ const id = this.getNextId();
+ const channel = this._createChannel(id, true);
+ console.log('发送的参数原始', data)
+ this.sendData(Message.createBuffer(MessageType.CreateChannel, id, data));
+ return channel;
+ }
+
+ private getNextId(): number {
+ let hitTop = false;
+ while (this.channels.has(++this.nextId)) {
+ if (this.nextId === this.maxId) {
+ if (hitTop) {
+ throw Error('No available id');
+ }
+ this.nextId = 0;
+ hitTop = true;
+ }
+ }
+ return this.nextId;
+ }
+
+ public dispatchEvent(event: Event): boolean {
+ if (event.type === 'close' && typeof this.onclose === 'function') {
+ Reflect.apply(this.onclose, this, [event]);
+ }
+ if (event.type === 'open' && typeof this.onopen === 'function') {
+ Reflect.apply(this.onopen, this, [event]);
+ }
+ if (event.type === 'message' && typeof this.onmessage === 'function') {
+ Reflect.apply(this.onmessage, this, [event]);
+ }
+ if (event.type === 'error' && typeof this.onerror === 'function') {
+ Reflect.apply(this.onerror, this, [event]);
+ }
+ return super.dispatchEvent(event);
+ }
+}
diff --git a/src/public/images/buttons/arrow_back.svg b/src/public/images/buttons/arrow_back.svg
new file mode 100644
index 0000000..e464d6d
--- /dev/null
+++ b/src/public/images/buttons/arrow_back.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/public/images/buttons/cancel.svg b/src/public/images/buttons/cancel.svg
new file mode 100644
index 0000000..c976ce9
--- /dev/null
+++ b/src/public/images/buttons/cancel.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/public/images/buttons/menu.svg b/src/public/images/buttons/menu.svg
new file mode 100644
index 0000000..5cfd647
--- /dev/null
+++ b/src/public/images/buttons/menu.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/public/images/buttons/offline.svg b/src/public/images/buttons/offline.svg
new file mode 100644
index 0000000..468e1d1
--- /dev/null
+++ b/src/public/images/buttons/offline.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/public/images/buttons/refresh.svg b/src/public/images/buttons/refresh.svg
new file mode 100644
index 0000000..7b94e48
--- /dev/null
+++ b/src/public/images/buttons/refresh.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/public/images/buttons/settings.svg b/src/public/images/buttons/settings.svg
new file mode 100644
index 0000000..de9f45a
--- /dev/null
+++ b/src/public/images/buttons/settings.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/public/images/buttons/toggle_off.svg b/src/public/images/buttons/toggle_off.svg
new file mode 100644
index 0000000..e60aa40
--- /dev/null
+++ b/src/public/images/buttons/toggle_off.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/public/images/buttons/toggle_on.svg b/src/public/images/buttons/toggle_on.svg
new file mode 100644
index 0000000..40c0bb8
--- /dev/null
+++ b/src/public/images/buttons/toggle_on.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/public/images/multitouch/SOURCE b/src/public/images/multitouch/SOURCE
new file mode 100644
index 0000000..8a9bdf5
--- /dev/null
+++ b/src/public/images/multitouch/SOURCE
@@ -0,0 +1 @@
+https://android.googlesource.com/platform/external/qemu/+/emu-2.0-release/android/skin/qt/images/multitouch/
diff --git a/src/public/images/multitouch/center_point.png b/src/public/images/multitouch/center_point.png
new file mode 100644
index 0000000..bbbae58
Binary files /dev/null and b/src/public/images/multitouch/center_point.png differ
diff --git a/src/public/images/multitouch/center_point_2x.png b/src/public/images/multitouch/center_point_2x.png
new file mode 100644
index 0000000..22bf59e
Binary files /dev/null and b/src/public/images/multitouch/center_point_2x.png differ
diff --git a/src/public/images/multitouch/touch_point.png b/src/public/images/multitouch/touch_point.png
new file mode 100644
index 0000000..3388089
Binary files /dev/null and b/src/public/images/multitouch/touch_point.png differ
diff --git a/src/public/images/multitouch/touch_point_2x.png b/src/public/images/multitouch/touch_point_2x.png
new file mode 100644
index 0000000..c6d4c54
Binary files /dev/null and b/src/public/images/multitouch/touch_point_2x.png differ
diff --git a/src/public/images/skin-light/SOURCE b/src/public/images/skin-light/SOURCE
new file mode 100644
index 0000000..9f97be4
--- /dev/null
+++ b/src/public/images/skin-light/SOURCE
@@ -0,0 +1 @@
+https://android.googlesource.com/platform/external/qemu/+/emu-2.0-release/android/skin/qt/images/light/
diff --git a/src/public/images/skin-light/System_Back_678.svg b/src/public/images/skin-light/System_Back_678.svg
new file mode 100644
index 0000000..4637e6b
--- /dev/null
+++ b/src/public/images/skin-light/System_Back_678.svg
@@ -0,0 +1,21 @@
+
+
+
+ Artboard 1
+ Created with Sketch.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/public/images/skin-light/System_Home_678.svg b/src/public/images/skin-light/System_Home_678.svg
new file mode 100644
index 0000000..5d6d0a2
--- /dev/null
+++ b/src/public/images/skin-light/System_Home_678.svg
@@ -0,0 +1,15 @@
+
+
+
+ System_Home
+ Created with Sketch.
+
+
+
+
+
+
+
+
+
+
diff --git a/src/public/images/skin-light/System_Overview_678.svg b/src/public/images/skin-light/System_Overview_678.svg
new file mode 100644
index 0000000..2b19b98
--- /dev/null
+++ b/src/public/images/skin-light/System_Overview_678.svg
@@ -0,0 +1,15 @@
+
+
+
+ System_Overview
+ Created with Sketch.
+
+
+
+
+
+
+
+
+
+
diff --git a/src/public/images/skin-light/ic_keyboard_678_48dp.svg b/src/public/images/skin-light/ic_keyboard_678_48dp.svg
new file mode 100644
index 0000000..ae09f96
--- /dev/null
+++ b/src/public/images/skin-light/ic_keyboard_678_48dp.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/public/images/skin-light/ic_more_horiz_678_48dp.svg b/src/public/images/skin-light/ic_more_horiz_678_48dp.svg
new file mode 100644
index 0000000..22cf9fe
--- /dev/null
+++ b/src/public/images/skin-light/ic_more_horiz_678_48dp.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/public/images/skin-light/ic_photo_camera_678_48dp.svg b/src/public/images/skin-light/ic_photo_camera_678_48dp.svg
new file mode 100644
index 0000000..e58a0f3
--- /dev/null
+++ b/src/public/images/skin-light/ic_photo_camera_678_48dp.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/src/public/images/skin-light/ic_power_settings_new_678_48px.svg b/src/public/images/skin-light/ic_power_settings_new_678_48px.svg
new file mode 100644
index 0000000..c0e4ade
--- /dev/null
+++ b/src/public/images/skin-light/ic_power_settings_new_678_48px.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/public/images/skin-light/ic_volume_down_678_48px.svg b/src/public/images/skin-light/ic_volume_down_678_48px.svg
new file mode 100644
index 0000000..f4e6282
--- /dev/null
+++ b/src/public/images/skin-light/ic_volume_down_678_48px.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/public/images/skin-light/ic_volume_up_678_48px.svg b/src/public/images/skin-light/ic_volume_up_678_48px.svg
new file mode 100644
index 0000000..dcfaa3a
--- /dev/null
+++ b/src/public/images/skin-light/ic_volume_up_678_48px.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/public/index.html b/src/public/index.html
new file mode 100644
index 0000000..394398c
--- /dev/null
+++ b/src/public/index.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+ WS scrcpy
+
+
+
+
diff --git a/src/server/Config.ts b/src/server/Config.ts
new file mode 100644
index 0000000..37d962f
--- /dev/null
+++ b/src/server/Config.ts
@@ -0,0 +1,156 @@
+import * as process from 'process';
+import * as fs from 'fs';
+import * as path from 'path';
+import { Configuration, HostItem, ServerItem } from '../types/Configuration';
+import { EnvName } from './EnvName';
+import YAML from 'yaml';
+
+const DEFAULT_PORT = 8000;
+
+const YAML_RE = /^.+\.(yaml|yml)$/i;
+const JSON_RE = /^.+\.(json|js)$/i;
+
+export class Config {
+ private static instance?: Config;
+ private static initConfig(userConfig: Configuration = {}): Required {
+ let runGoogTracker = false;
+ let announceGoogTracker = false;
+ /// #if INCLUDE_GOOG
+ runGoogTracker = true;
+ announceGoogTracker = true;
+ /// #endif
+
+ let runApplTracker = false;
+ let announceApplTracker = false;
+ /// #if INCLUDE_APPL
+ runApplTracker = true;
+ announceApplTracker = true;
+ /// #endif
+ const server: ServerItem[] = [
+ {
+ secure: false,
+ port: DEFAULT_PORT,
+ },
+ ];
+ const defaultConfig: Required = {
+ runGoogTracker,
+ runApplTracker,
+ announceGoogTracker,
+ announceApplTracker,
+ server,
+ remoteHostList: [],
+ };
+ const merged = Object.assign({}, defaultConfig, userConfig);
+ merged.server = merged.server.map((item) => this.parseServerItem(item));
+ return merged;
+ }
+ private static parseServerItem(config: Partial = {}): ServerItem {
+ const secure = config.secure || false;
+ const port = config.port || (secure ? 443 : 80);
+ const options = config.options;
+ const redirectToSecure = config.redirectToSecure || false;
+ if (secure && !options) {
+ throw Error('Must provide "options" for secure server configuration');
+ }
+ if (options?.certPath) {
+ if (options.cert) {
+ throw Error(`Can't use "cert" and "certPath" together`);
+ }
+ options.cert = this.readFile(options.certPath);
+ }
+ if (options?.keyPath) {
+ if (options.key) {
+ throw Error(`Can't use "key" and "keyPath" together`);
+ }
+ options.key = this.readFile(options.keyPath);
+ }
+ const serverItem: ServerItem = {
+ secure,
+ port,
+ redirectToSecure,
+ };
+ if (typeof options !== 'undefined') {
+ serverItem.options = options;
+ }
+ if (typeof redirectToSecure === 'boolean') {
+ serverItem.redirectToSecure = redirectToSecure;
+ }
+ return serverItem;
+ }
+ public static getInstance(): Config {
+ if (!this.instance) {
+ const configPath = process.env[EnvName.CONFIG_PATH];
+ let userConfig: Configuration;
+ if (!configPath) {
+ userConfig = {};
+ } else {
+ if (configPath.match(YAML_RE)) {
+ userConfig = YAML.parse(this.readFile(configPath));
+ } else if (configPath.match(JSON_RE)) {
+ userConfig = JSON.parse(this.readFile(configPath));
+ } else {
+ throw Error(`Unknown file type: ${configPath}`);
+ }
+ }
+ const fullConfig = this.initConfig(userConfig);
+ this.instance = new Config(fullConfig);
+ }
+ return this.instance;
+ }
+
+ public static readFile(pathString: string): string {
+ const isAbsolute = pathString.startsWith('/');
+ const absolutePath = isAbsolute ? pathString : path.resolve(process.cwd(), pathString);
+ if (!fs.existsSync(absolutePath)) {
+ throw Error(`Can't find file "${absolutePath}"`);
+ }
+ return fs.readFileSync(absolutePath).toString();
+ }
+
+ constructor(private fullConfig: Required) {}
+
+ public getHostList(): HostItem[] {
+ if (!this.fullConfig.remoteHostList || !this.fullConfig.remoteHostList.length) {
+ return [];
+ }
+ const hostList: HostItem[] = [];
+ this.fullConfig.remoteHostList.forEach((item) => {
+ const { hostname, port, pathname, secure, useProxy } = item;
+ if (Array.isArray(item.type)) {
+ item.type.forEach((type) => {
+ hostList.push({
+ hostname,
+ port,
+ pathname,
+ secure,
+ useProxy,
+ type,
+ });
+ });
+ } else {
+ hostList.push({ hostname, port, pathname, secure, useProxy, type: item.type });
+ }
+ });
+ return hostList;
+ }
+
+ public get runLocalGoogTracker(): boolean {
+ return this.fullConfig.runGoogTracker;
+ }
+
+ public get announceLocalGoogTracker(): boolean {
+ return this.fullConfig.runGoogTracker;
+ }
+
+ public get runLocalApplTracker(): boolean {
+ return this.fullConfig.runApplTracker;
+ }
+
+ public get announceLocalApplTracker(): boolean {
+ return this.fullConfig.runApplTracker;
+ }
+
+ public get servers(): ServerItem[] {
+ return this.fullConfig.server;
+ }
+}
diff --git a/src/server/EnvName.ts b/src/server/EnvName.ts
new file mode 100644
index 0000000..f37ea6a
--- /dev/null
+++ b/src/server/EnvName.ts
@@ -0,0 +1,4 @@
+export enum EnvName {
+ CONFIG_PATH = 'WS_SCRCPY_CONFIG',
+ WS_SCRCPY_PATHNAME = 'WS_SCRCPY_PATHNAME',
+}
diff --git a/src/server/Utils.ts b/src/server/Utils.ts
new file mode 100644
index 0000000..8cdc03b
--- /dev/null
+++ b/src/server/Utils.ts
@@ -0,0 +1,47 @@
+import * as os from 'os';
+
+export class Utils {
+ public static printListeningMsg(proto: string, port: number, pathname: string): void {
+ const ipv4List: string[] = [];
+ const ipv6List: string[] = [];
+ const formatAddress = (ip: string, scopeid: number | undefined): void => {
+ if (typeof scopeid === 'undefined') {
+ ipv4List.push(`${proto}://${ip}:${port}${pathname}`);
+ return;
+ }
+ if (scopeid === 0) {
+ ipv6List.push(`${proto}://[${ip}]:${port}${pathname}`);
+ } else {
+ return;
+ // skip
+ // ipv6List.push(`${proto}://[${ip}%${scopeid}]:${port}`);
+ }
+ };
+ Object.keys(os.networkInterfaces())
+ .map((key) => os.networkInterfaces()[key])
+ .forEach((info) => {
+ info.forEach((iface) => {
+ let scopeid: number | undefined;
+ if (iface.family === 'IPv6') {
+ scopeid = iface.scopeid;
+ } else if (iface.family === 'IPv4') {
+ scopeid = undefined;
+ } else {
+ return;
+ }
+ formatAddress(iface.address, scopeid);
+ });
+ });
+ const nameList = [
+ encodeURI(`${proto}://${os.hostname()}:${port}${pathname}`),
+ encodeURI(`${proto}://localhost:${port}${pathname}`),
+ ];
+ console.log('Listening on:\n\t' + nameList.join(' '));
+ if (ipv4List.length) {
+ console.log('\t' + ipv4List.join(' '));
+ }
+ if (ipv6List.length) {
+ console.log('\t' + ipv6List.join(' '));
+ }
+ }
+}
diff --git a/src/server/appl-device/mw/DeviceTracker.ts b/src/server/appl-device/mw/DeviceTracker.ts
new file mode 100644
index 0000000..ee972ec
--- /dev/null
+++ b/src/server/appl-device/mw/DeviceTracker.ts
@@ -0,0 +1,91 @@
+import WS from 'ws';
+import { Mw, RequestParameters } from '../../mw/Mw';
+import { ControlCenterCommand } from '../../../common/ControlCenterCommand';
+import { ACTION } from '../../../common/Action';
+import { DeviceTrackerEvent } from '../../../types/DeviceTrackerEvent';
+import { DeviceTrackerEventList } from '../../../types/DeviceTrackerEventList';
+import { ControlCenter } from '../services/ControlCenter';
+import ApplDeviceDescriptor from '../../../types/ApplDeviceDescriptor';
+import { Multiplexer } from '../../../packages/multiplexer/Multiplexer';
+import { ChannelCode } from '../../../common/ChannelCode';
+
+export class DeviceTracker extends Mw {
+ public static readonly TAG = 'IosDeviceTracker';
+ public static readonly type = 'ios';
+ private icc: ControlCenter = ControlCenter.getInstance();
+ private readonly id: string;
+
+ public static processChannel(ws: Multiplexer, code: string): Mw | undefined {
+ if (code !== ChannelCode.ATRC) {
+ return;
+ }
+ return new DeviceTracker(ws);
+ }
+
+ public static processRequest(ws: WS, params: RequestParameters): DeviceTracker | undefined {
+ if (params.action !== ACTION.APPL_DEVICE_LIST) {
+ return;
+ }
+ return new DeviceTracker(ws);
+ }
+
+ constructor(ws: WS | Multiplexer) {
+ super(ws);
+
+ this.id = this.icc.getId();
+ this.icc
+ .init()
+ .then(() => {
+ this.icc.on('device', this.sendDeviceMessage);
+ this.buildAndSendMessage(this.icc.getDevices());
+ })
+ .catch((error: Error) => {
+ console.error(`[${DeviceTracker.TAG}] Error: ${error.message}`);
+ });
+ }
+
+ private sendDeviceMessage = (device: ApplDeviceDescriptor): void => {
+ const data: DeviceTrackerEvent = {
+ device,
+ id: this.id,
+ name: this.icc.getName(),
+ };
+ this.sendMessage({
+ id: -1,
+ type: 'device',
+ data,
+ });
+ };
+
+ private buildAndSendMessage = (list: ApplDeviceDescriptor[]): void => {
+ const data: DeviceTrackerEventList = {
+ list,
+ id: this.id,
+ name: this.icc.getName(),
+ };
+ this.sendMessage({
+ id: -1,
+ type: 'devicelist',
+ data,
+ });
+ };
+
+ protected onSocketMessage(event: WS.MessageEvent): void {
+ console.log("接收到的参数2", event.data)
+ let command: ControlCenterCommand;
+ try {
+ command = ControlCenterCommand.fromJSON(event.data.toString());
+ } catch (error: any) {
+ console.error(`[${DeviceTracker.TAG}], Received message: ${event.data}. Error: ${error.message}`);
+ return;
+ }
+ this.icc.runCommand(command).catch((error) => {
+ console.error(`[${DeviceTracker.TAG}], Received message: ${event.data}. Error: ${error.message}`);
+ });
+ }
+
+ public release(): void {
+ super.release();
+ this.icc.off('device', this.sendDeviceMessage);
+ }
+}
diff --git a/src/server/appl-device/mw/QVHStreamProxy.ts b/src/server/appl-device/mw/QVHStreamProxy.ts
new file mode 100644
index 0000000..b3d63ff
--- /dev/null
+++ b/src/server/appl-device/mw/QVHStreamProxy.ts
@@ -0,0 +1,81 @@
+import WS from 'ws';
+import { Mw } from '../../mw/Mw';
+import { ControlCenterCommand } from '../../../common/ControlCenterCommand';
+import { QvhackRunner } from '../services/QvhackRunner';
+import { WebsocketProxy } from '../../mw/WebsocketProxy';
+import { Multiplexer } from '../../../packages/multiplexer/Multiplexer';
+import { ChannelCode } from '../../../common/ChannelCode';
+import Util from '../../../app/Util';
+
+export class QVHStreamProxy extends Mw {
+ public static readonly TAG = 'QVHStreamProxy';
+
+ public static processChannel(ws: Multiplexer, code: string, data: ArrayBuffer): Mw | undefined {
+ if (code !== ChannelCode.QVHS) {
+ return;
+ }
+ if (!data || data.byteLength < 4) {
+ return;
+ }
+ const buffer = Buffer.from(data);
+ const length = buffer.readInt32LE(0);
+ const udid = Util.utf8ByteArrayToString(buffer.slice(4, 4 + length));
+ return new QVHStreamProxy(ws, udid);
+ }
+
+ private qvhProcess: QvhackRunner;
+ private wsProxy?: WebsocketProxy;
+ protected name: string;
+ constructor(protected ws: Multiplexer, private readonly udid: string) {
+ super(ws);
+ this.name = `[${QVHStreamProxy.TAG}][udid:${this.udid}]`;
+ this.qvhProcess = QvhackRunner.getInstance(udid);
+ this.attachEventListeners();
+ }
+
+ private onStarted = (): void => {
+ const remote = this.qvhProcess.getWebSocketAddress();
+ this.wsProxy = WebsocketProxy.createProxy(this.ws, remote);
+ this.ws.addEventListener('close', this.onSocketClose.bind(this));
+ };
+
+ private attachEventListeners(): void {
+ if (this.qvhProcess.isStarted()) {
+ this.onStarted();
+ } else {
+ this.qvhProcess.once('started', this.onStarted);
+ }
+ }
+
+ protected onSocketMessage(event: WS.MessageEvent): void {
+ console.log("接收到的参数13", event.data)
+
+ let command: ControlCenterCommand;
+ try {
+ command = ControlCenterCommand.fromJSON(event.data.toString());
+ } catch (error: any) {
+ console.error(`${this.name}, Received message: ${event.data}. Error: ${error.message}`);
+ return;
+ }
+ console.log(`${this.name}, Received message: type:"${command.getType()}", data:${command.getData()}.`);
+ }
+
+ protected onSocketClose(): void {
+ if (this.wsProxy) {
+ this.wsProxy.release();
+ delete this.wsProxy;
+ }
+ this.release();
+ }
+
+ public release(): void {
+ super.release();
+ if (this.qvhProcess) {
+ this.qvhProcess.release();
+ }
+ if (this.wsProxy) {
+ this.wsProxy.release();
+ delete this.wsProxy;
+ }
+ }
+}
diff --git a/src/server/appl-device/mw/WebDriverAgentProxy.ts b/src/server/appl-device/mw/WebDriverAgentProxy.ts
new file mode 100644
index 0000000..2ac5408
--- /dev/null
+++ b/src/server/appl-device/mw/WebDriverAgentProxy.ts
@@ -0,0 +1,136 @@
+import WS from 'ws';
+import { Mw } from '../../mw/Mw';
+import { ControlCenterCommand } from '../../../common/ControlCenterCommand';
+import { WdaRunner } from '../services/WDARunner';
+import { MessageRunWdaResponse } from '../../../types/MessageRunWdaResponse';
+import { Multiplexer } from '../../../packages/multiplexer/Multiplexer';
+import { ChannelCode } from '../../../common/ChannelCode';
+import Util from '../../../app/Util';
+import { WdaStatus } from '../../../common/WdaStatus';
+
+export class WebDriverAgentProxy extends Mw {
+ public static readonly TAG = 'WebDriverAgentProxy';
+ protected name: string;
+ private wda?: WdaRunner;
+
+ public static processChannel(ws: Multiplexer, code: string, data: ArrayBuffer): Mw | undefined {
+ if (code !== ChannelCode.WDAP) {
+ return;
+ }
+ if (!data || data.byteLength < 4) {
+ return;
+ }
+ const buffer = Buffer.from(data);
+ const length = buffer.readInt32LE(0);
+ const udid = Util.utf8ByteArrayToString(buffer.slice(4, 4 + length));
+ return new WebDriverAgentProxy(ws, udid);
+ }
+
+ constructor(protected ws: Multiplexer, private readonly udid: string) {
+ super(ws);
+ this.name = `[${WebDriverAgentProxy.TAG}][udid: ${this.udid}]`;
+ }
+
+ private runWda(command: ControlCenterCommand): void {
+ const udid = command.getUdid();
+ const id = command.getId();
+ if (this.wda) {
+ const message: MessageRunWdaResponse = {
+ id,
+ type: 'run-wda',
+ data: {
+ udid: udid,
+ status: WdaStatus.STARTED,
+ code: -1,
+ text: 'WDA already started',
+ },
+ };
+ this.sendMessage(message);
+ return;
+ }
+ this.wda = WdaRunner.getInstance(udid);
+ this.wda.on('status-change', ({ status, code, text }) => {
+ this.onStatusChange(command, status, code, text);
+ });
+ if (this.wda.isStarted()) {
+ this.onStatusChange(command, WdaStatus.STARTED);
+ } else {
+ this.wda.start();
+ }
+ }
+
+ private onStatusChange = (command: ControlCenterCommand, status: WdaStatus, code?: number, text?: string): void => {
+ const id = command.getId();
+ const udid = command.getUdid();
+ const type = 'run-wda';
+ const message: MessageRunWdaResponse = {
+ id,
+ type,
+ data: {
+ udid,
+ status,
+ code,
+ text,
+ },
+ };
+ this.sendMessage(message);
+ };
+
+ private requestWda(command: ControlCenterCommand): void {
+ if (!this.wda) {
+ return;
+ }
+ this.wda
+ .request(command)
+ .then((response) => {
+ this.sendMessage({
+ id: command.getId(),
+ type: command.getType(),
+ data: {
+ success: true,
+ response,
+ },
+ });
+ })
+ .catch((e) => {
+ this.sendMessage({
+ id: command.getId(),
+ type: command.getType(),
+ data: {
+ success: false,
+ error: e.message,
+ },
+ });
+ });
+ }
+
+ protected onSocketMessage(event: WS.MessageEvent): void {
+ console.log("接收到的参数44", event.data)
+
+ let command: ControlCenterCommand;
+ try {
+ command = ControlCenterCommand.fromJSON(event.data.toString());
+ } catch (error: any) {
+ console.error(`[${WebDriverAgentProxy.TAG}], Received message: ${event.data}. Error: ${error.message}`);
+ return;
+ }
+ const type = command.getType();
+ switch (type) {
+ case ControlCenterCommand.RUN_WDA:
+ this.runWda(command);
+ break;
+ case ControlCenterCommand.REQUEST_WDA:
+ this.requestWda(command);
+ break;
+ default:
+ throw new Error(`Unsupported command: "${type}"`);
+ }
+ }
+
+ public release(): void {
+ super.release();
+ if (this.wda) {
+ this.wda.release();
+ }
+ }
+}
diff --git a/src/server/appl-device/services/ControlCenter.ts b/src/server/appl-device/services/ControlCenter.ts
new file mode 100644
index 0000000..970e95c
--- /dev/null
+++ b/src/server/appl-device/services/ControlCenter.ts
@@ -0,0 +1,126 @@
+import { Service } from '../../services/Service';
+import { BaseControlCenter } from '../../services/BaseControlCenter';
+import { ControlCenterCommand } from '../../../common/ControlCenterCommand';
+import * as os from 'os';
+import * as crypto from 'crypto';
+import ApplDeviceDescriptor from '../../../types/ApplDeviceDescriptor';
+import { IOSDeviceLib } from 'ios-device-lib';
+import { DeviceState } from '../../../common/DeviceState';
+import { ProductType } from '../../../common/ProductType';
+
+export class ControlCenter extends BaseControlCenter implements Service {
+ private static instance?: ControlCenter;
+
+ private initialized = false;
+ private tracker?: IOSDeviceLib.IOSDeviceLib;
+ private descriptors: Map = new Map();
+ private readonly id: string;
+
+ protected constructor() {
+ super();
+ const idString = `appl|${os.hostname()}|${os.uptime()}`;
+ this.id = crypto.createHash('md5').update(idString).digest('hex');
+ }
+
+ public static getInstance(): ControlCenter {
+ if (!this.instance) {
+ this.instance = new ControlCenter();
+ }
+ return this.instance;
+ }
+
+ public static hasInstance(): boolean {
+ return !!ControlCenter.instance;
+ }
+
+ private onDeviceUpdate = (device: IOSDeviceLib.IDeviceActionInfo): void => {
+ const udid = device.deviceId;
+ const state = device.status || '';
+ const name = device.deviceName || '';
+ const productType = device.productType || '';
+ const version = device.productVersion || '';
+ const model = ProductType.getModel(productType);
+ const descriptor = {
+ udid,
+ name,
+ model,
+ version,
+ state,
+ 'last.update.timestamp': Date.now(),
+ };
+ this.descriptors.set(udid, descriptor);
+ this.emit('device', descriptor);
+ };
+
+ private onDeviceLost = (device: IOSDeviceLib.IDeviceActionInfo): void => {
+ const udid = device.deviceId;
+ const descriptor = this.descriptors.get(udid);
+ if (!descriptor) {
+ console.warn(`Received "lost" event for unknown device "${udid}"`);
+ return;
+ }
+ descriptor.state = DeviceState.DISCONNECTED;
+ this.emit('device', descriptor);
+ };
+
+ public async init(): Promise {
+ if (this.initialized) {
+ return;
+ }
+ this.tracker = await this.startTracker();
+ this.initialized = true;
+ }
+
+ private async startTracker(): Promise {
+ if (this.tracker) {
+ return this.tracker;
+ }
+ this.tracker = new IOSDeviceLib(this.onDeviceUpdate, this.onDeviceUpdate, this.onDeviceLost);
+ return this.tracker;
+ }
+
+ private stopTracker(): void {
+ if (this.tracker) {
+ this.tracker.dispose();
+ this.tracker = undefined;
+ }
+ this.tracker = undefined;
+ this.initialized = false;
+ }
+
+ public getDevices(): ApplDeviceDescriptor[] {
+ return Array.from(this.descriptors.values());
+ }
+
+ public getId(): string {
+ return this.id;
+ }
+
+ public getName(): string {
+ return `iDevice Tracker [${os.hostname()}]`;
+ }
+
+ public start(): Promise {
+ return this.init().catch((e) => {
+ console.error(`Error: Failed to init "${this.getName()}". ${e.message}`);
+ });
+ }
+
+ public release(): void {
+ this.stopTracker();
+ }
+
+ public async runCommand(command: ControlCenterCommand): Promise {
+ const udid = command.getUdid();
+ const device = this.descriptors.get(udid);
+ if (!device) {
+ console.error(`Device with udid:"${udid}" not found`);
+ return;
+ }
+ const type = command.getType();
+ switch (type) {
+ default:
+ throw new Error(`Unsupported command: "${type}"`);
+ }
+ }
+}
diff --git a/src/server/appl-device/services/QvhackRunner.ts b/src/server/appl-device/services/QvhackRunner.ts
new file mode 100644
index 0000000..e6b93f1
--- /dev/null
+++ b/src/server/appl-device/services/QvhackRunner.ts
@@ -0,0 +1,80 @@
+import * as portfinder from 'portfinder';
+import { ProcessRunner, ProcessRunnerEvents } from '../../services/ProcessRunner';
+
+export class QvhackRunner extends ProcessRunner {
+ private static instances: Map = new Map();
+ public static SHUTDOWN_TIMEOUT = 15000;
+ public static getInstance(udid: string): QvhackRunner {
+ let instance = this.instances.get(udid);
+ if (!instance) {
+ instance = new QvhackRunner(udid);
+ this.instances.set(udid, instance);
+ instance.start();
+ }
+ instance.lock();
+ return instance;
+ }
+ protected TAG = '[QvhackRunner]';
+ protected name: string;
+ protected cmd = 'ws-qvh';
+ protected releaseTimeoutId?: NodeJS.Timeout;
+ protected address = '';
+ protected started = false;
+ private holders = 0;
+
+ constructor(private readonly udid: string) {
+ super();
+ this.name = `${this.TAG}[udid: ${this.udid}]`;
+ }
+
+ public getWebSocketAddress(): string {
+ return this.address;
+ }
+
+ protected lock(): void {
+ if (this.releaseTimeoutId) {
+ clearTimeout(this.releaseTimeoutId);
+ }
+ this.holders++;
+ }
+
+ protected unlock(): void {
+ this.holders--;
+ if (this.holders > 0) {
+ return;
+ }
+ this.releaseTimeoutId = setTimeout(() => {
+ super.release();
+ QvhackRunner.instances.delete(this.udid);
+ }, QvhackRunner.SHUTDOWN_TIMEOUT);
+ }
+
+ protected async getArgs(): Promise {
+ const port = await portfinder.getPortPromise();
+ const host = `127.0.0.1:${port}`;
+ this.address = `ws://${host}/ws?stream=${encodeURIComponent(this.udid)}`;
+ return [host];
+ }
+
+ public async start(): Promise {
+ return this.runProcess()
+ .then(() => {
+ // Wait for server to start listen on a port
+ this.once('stderr', () => {
+ this.started = true;
+ this.emit('started', true);
+ });
+ })
+ .catch((e) => {
+ console.error(this.name, e.message);
+ });
+ }
+
+ public isStarted(): boolean {
+ return this.started;
+ }
+
+ public release(): void {
+ this.unlock();
+ }
+}
diff --git a/src/server/appl-device/services/WDARunner.ts b/src/server/appl-device/services/WDARunner.ts
new file mode 100644
index 0000000..5f0a865
--- /dev/null
+++ b/src/server/appl-device/services/WDARunner.ts
@@ -0,0 +1,209 @@
+import { ControlCenterCommand } from '../../../common/ControlCenterCommand';
+import { TypedEmitter } from '../../../common/TypedEmitter';
+import * as portfinder from 'portfinder';
+import { Server, XCUITestDriver } from '../../../types/WdaServer';
+import * as XCUITest from 'appium-xcuitest-driver';
+import { WDAMethod } from '../../../common/WDAMethod';
+import { timing } from 'appium-support';
+import { WdaStatus } from '../../../common/WdaStatus';
+
+const MJPEG_SERVER_PORT = 9100;
+
+export interface WdaRunnerEvents {
+ 'status-change': { status: WdaStatus; text?: string; code?: number };
+ error: Error;
+}
+
+export class WdaRunner extends TypedEmitter {
+ protected static TAG = 'WDARunner';
+ private static instances: Map = new Map();
+ public static SHUTDOWN_TIMEOUT = 15000;
+ private static servers: Map = new Map();
+ private static cachedScreenWidth: Map = new Map();
+ public static getInstance(udid: string): WdaRunner {
+ let instance = this.instances.get(udid);
+ if (!instance) {
+ instance = new WdaRunner(udid);
+ this.instances.set(udid, instance);
+ }
+ instance.lock();
+ return instance;
+ }
+ public static async getServer(udid: string): Promise {
+ let server = this.servers.get(udid);
+ if (!server) {
+ const port = await portfinder.getPortPromise();
+ server = await XCUITest.startServer(port, '127.0.0.1');
+ server.on('error', (...args: any[]) => {
+ console.error('Server Error:', args);
+ });
+ server.on('close', (...args: any[]) => {
+ console.error('Server Close:', args);
+ });
+ this.servers.set(udid, server);
+ }
+ return server;
+ }
+
+ public static async getScreenWidth(udid: string, driver: XCUITestDriver): Promise {
+ const cached = this.cachedScreenWidth.get(udid);
+ if (cached) {
+ return cached;
+ }
+ const info = await driver.getScreenInfo();
+ if (info && info.statusBarSize.width > 0) {
+ const screenWidth = info.statusBarSize.width;
+ this.cachedScreenWidth.set(udid, screenWidth);
+ return screenWidth;
+ }
+ const el = await driver.findElement('xpath', '//XCUIElementTypeApplication');
+ const size = await driver.getSize(el);
+ if (size) {
+ const screenWidth = size.width;
+ this.cachedScreenWidth.set(udid, screenWidth);
+ return screenWidth;
+ }
+ return 0;
+ }
+
+ protected name: string;
+ protected started = false;
+ protected starting = false;
+ private server?: Server;
+ private mjpegServerPort = 0;
+ private wdaLocalPort = 0;
+ private holders = 0;
+ protected releaseTimeoutId?: NodeJS.Timeout;
+
+ constructor(private readonly udid: string) {
+ super();
+ this.name = `[${WdaRunner.TAG}][udid: ${this.udid}]`;
+ }
+
+ protected lock(): void {
+ if (this.releaseTimeoutId) {
+ clearTimeout(this.releaseTimeoutId);
+ }
+ this.holders++;
+ }
+
+ protected unlock(): void {
+ this.holders--;
+ if (this.holders > 0) {
+ return;
+ }
+ this.releaseTimeoutId = setTimeout(async () => {
+ WdaRunner.servers.delete(this.udid);
+ WdaRunner.instances.delete(this.udid);
+ if (this.server) {
+ if (this.server.driver) {
+ await this.server.driver.deleteSession();
+ }
+ this.server.close();
+ delete this.server;
+ }
+ }, WdaRunner.SHUTDOWN_TIMEOUT);
+ }
+
+ public get mjpegPort(): number {
+ return this.mjpegServerPort;
+ }
+
+ public async request(command: ControlCenterCommand): Promise {
+ const driver = this.server?.driver;
+ if (!driver) {
+ return;
+ }
+
+ const method = command.getMethod();
+ const args = command.getArgs();
+ switch (method) {
+ case WDAMethod.GET_SCREEN_WIDTH:
+ return WdaRunner.getScreenWidth(this.udid, driver);
+ case WDAMethod.CLICK:
+ return driver.performTouch([{ action: 'tap', options: { x: args.x, y: args.y } }]);
+ case WDAMethod.PRESS_BUTTON:
+ return driver.mobilePressButton({ name: args.name });
+ case WDAMethod.SCROLL:
+ const { from, to } = args;
+ return driver.performTouch([
+ { action: 'press', options: { x: from.x, y: from.y } },
+ { action: 'wait', options: { ms: 500 } },
+ { action: 'moveTo', options: { x: to.x, y: to.y } },
+ { action: 'release', options: {} },
+ ]);
+ case WDAMethod.APPIUM_SETTINGS:
+ return driver.updateSettings(args.options);
+ case WDAMethod.SEND_KEYS:
+ return driver.keys(args.keys);
+ default:
+ return `Unknown command: ${method}`;
+ }
+ }
+
+ public async start(): Promise {
+ if (this.started || this.starting) {
+ return;
+ }
+ this.emit('status-change', { status: WdaStatus.STARTING });
+ this.starting = true;
+ const server = await WdaRunner.getServer(this.udid);
+ try {
+ const remoteMjpegServerPort = MJPEG_SERVER_PORT;
+ const ports = await Promise.all([portfinder.getPortPromise(), portfinder.getPortPromise()]);
+ this.wdaLocalPort = ports[0];
+ this.mjpegServerPort = ports[1];
+ await server.driver.createSession({
+ platformName: 'iOS',
+ deviceName: 'my iphone',
+ udid: this.udid,
+ wdaLocalPort: this.wdaLocalPort,
+ usePrebuiltWDA: true,
+ mjpegServerPort: remoteMjpegServerPort,
+ });
+ await server.driver.wda.xcodebuild.waitForStart(new timing.Timer().start());
+ if (server.driver?.wda?.xcodebuild?.xcodebuild) {
+ server.driver.wda.xcodebuild.xcodebuild.on('exit', (code: number) => {
+ this.started = false;
+ this.starting = false;
+ server.driver.deleteSession();
+ delete this.server;
+ this.emit('status-change', { status: WdaStatus.STOPPED, code });
+ if (this.holders > 0) {
+ this.start();
+ }
+ });
+ } else {
+ this.started = false;
+ this.starting = false;
+ delete this.server;
+ throw new Error('xcodebuild process not found');
+ }
+ /// #if USE_WDA_MJPEG_SERVER
+ const { DEVICE_CONNECTIONS_FACTORY } = await import(
+ 'appium-xcuitest-driver/build/lib/device-connections-factory'
+ );
+
+ await DEVICE_CONNECTIONS_FACTORY.requestConnection(this.udid, this.mjpegServerPort, {
+ usePortForwarding: true,
+ devicePort: remoteMjpegServerPort,
+ });
+ /// #endif
+ this.started = true;
+ this.emit('status-change', { status: WdaStatus.STARTED });
+ } catch (error: any) {
+ this.started = false;
+ this.starting = false;
+ this.emit('error', error);
+ }
+ this.server = server;
+ }
+
+ public isStarted(): boolean {
+ return this.started;
+ }
+
+ public release(): void {
+ this.unlock();
+ }
+}
diff --git a/src/server/goog-device/AdbUtils.ts b/src/server/goog-device/AdbUtils.ts
new file mode 100644
index 0000000..e0a0093
--- /dev/null
+++ b/src/server/goog-device/AdbUtils.ts
@@ -0,0 +1,372 @@
+import * as portfinder from 'portfinder';
+import * as http from 'http';
+import * as path from 'path';
+import { ACTION } from '../../common/Action';
+import { AdbExtended } from './adb';
+import { DevtoolsInfo, RemoteBrowserInfo, RemoteTarget, VersionMetadata } from '../../types/RemoteDevtools';
+import { URL } from 'url';
+import { Forward } from '@dead50f7/adbkit/lib/Forward';
+import Entry from '@dead50f7/adbkit/lib/adb/sync/entry';
+import Stats from '@dead50f7/adbkit/lib/adb/sync/stats';
+import PullTransfer from '@dead50f7/adbkit/lib/adb/sync/pulltransfer';
+import { FileStats } from '../../types/FileStats';
+import Protocol from '@dead50f7/adbkit/lib/adb/protocol';
+import { Multiplexer } from '../../packages/multiplexer/Multiplexer';
+import { ReadStream } from 'fs';
+import PushTransfer from '@dead50f7/adbkit/lib/adb/sync/pushtransfer';
+
+type IncomingMessage = {
+ statusCode?: number;
+ contentType?: string;
+ body: string;
+};
+
+const proto = 'http://';
+const fakeHost = '127.0.0.1:6666';
+const fakeHostRe = /127\.0\.0\.1:6666/;
+
+export class AdbUtils {
+ private static async formatStatsMin(entry: Entry): Promise {
+ return {
+ name: entry.name,
+ isDir: entry.isDirectory() ? 1 : 0,
+ size: entry.size,
+ dateModified: entry.mtimeMs ? entry.mtimeMs : entry.mtime.getTime(),
+ };
+ }
+
+ public static async push(serial: string, stream: ReadStream, pathString: string): Promise {
+ const client = AdbExtended.createClient();
+ const transfer = await client.push(serial, stream, pathString);
+ client.on('error', (error: Error) => {
+ transfer.emit('error', error);
+ });
+ return transfer;
+ }
+
+ public static async stats(serial: string, pathString: string, stats?: Stats, deep = 0): Promise {
+ if (!stats || (stats.isSymbolicLink() && pathString.endsWith('/'))) {
+ const client = AdbExtended.createClient();
+ stats = await client.stat(serial, pathString);
+ }
+ if (stats.isSymbolicLink()) {
+ if (deep === 5) {
+ throw Error('Too deep');
+ }
+ if (!pathString.endsWith('/')) {
+ pathString += '/';
+ }
+ try {
+ stats = await this.stats(serial, pathString, stats, deep++);
+ } catch (error: any) {
+ if (error.message === 'Too deep') {
+ if (deep === 0) {
+ console.error(`Symlink is too deep: ${pathString}`);
+ return stats;
+ }
+ throw error;
+ }
+ if (error.code !== 'ENOENT') {
+ console.error(error.message);
+ }
+ }
+ return stats;
+ }
+ return stats;
+ }
+
+ public static async readdir(serial: string, pathString: string): Promise {
+ const client = AdbExtended.createClient();
+ const list = await client.readdir(serial, pathString);
+ const all = list.map(async (entry) => {
+ if (entry.isSymbolicLink()) {
+ const stat = await this.stats(serial, path.join(pathString, entry.name));
+ const mtime = stat.mtimeMs ? stat.mtimeMs : stat.mtime.getTime();
+ entry = new Entry(entry.name, stat.mode, stat.size, (mtime / 1000) | 0);
+ }
+ return AdbUtils.formatStatsMin(entry);
+ });
+ return Promise.all(all);
+ }
+
+ public static async pipePullFile(serial: string, pathString: string): Promise {
+ const client = AdbExtended.createClient();
+ const transfer = await client.pull(serial, pathString);
+
+ transfer.on('progress', function (stats) {
+ console.log('[%s] [%s] Pulled %d bytes so far', serial, pathString, stats.bytesTransferred);
+ });
+ transfer.on('end', function () {
+ console.log('[%s] [%s] Pull complete', serial, pathString);
+ });
+ return new Promise((resolve, reject) => {
+ transfer.on('readable', () => {
+ resolve(transfer);
+ });
+ transfer.on('error', (e) => {
+ reject(e);
+ });
+ });
+ }
+
+ public static async pipeStatToStream(serial: string, pathString: string, stream: Multiplexer): Promise {
+ const client = AdbExtended.createClient();
+ return client.pipeStat(serial, pathString, stream);
+ }
+
+ public static async pipeReadDirToStream(serial: string, pathString: string, stream: Multiplexer): Promise {
+ const client = AdbExtended.createClient();
+ return client.pipeReadDir(serial, pathString, stream);
+ }
+
+ public static async pipePullFileToStream(serial: string, pathString: string, stream: Multiplexer): Promise {
+ const client = AdbExtended.createClient();
+ const transfer = await client.pull(serial, pathString);
+ transfer.on('data', (data) => {
+ console.log('发送的参数14');
+
+ stream.send(Buffer.concat([Buffer.from(Protocol.DATA, 'ascii'), data]));
+ });
+ return new Promise((resolve, reject) => {
+ transfer.on('end', function () {
+ console.log('发送的参数15');
+
+ stream.send(Buffer.from(Protocol.DONE, 'ascii'));
+ stream.close();
+ resolve();
+ });
+ transfer.on('error', (e) => {
+ reject(e);
+ });
+ });
+ }
+
+ public static async forward(serial: string, remote: string): Promise {
+ const client = AdbExtended.createClient();
+ const forwards = await client.listForwards(serial);
+ const forward = forwards.find((item: Forward) => {
+ return item.remote === remote && item.local.startsWith('tcp:') && item.serial === serial;
+ });
+ if (forward) {
+ const { local } = forward;
+ return parseInt(local.split('tcp:')[1], 10);
+ }
+ const port = await portfinder.getPortPromise();
+ const local = `tcp:${port}`;
+ await client.forward(serial, local, remote);
+ return port;
+ }
+
+ public static async getDevtoolsRemoteList(serial: string): Promise {
+ const client = AdbExtended.createClient();
+ const stream = await client.shell(serial, 'cat /proc/net/unix');
+ const buffer = await AdbExtended.util.readAll(stream);
+ const lines = buffer
+ .toString()
+ .split('\n')
+ .filter((s: string) => {
+ if (!s) {
+ return false;
+ }
+ return s.includes('devtools_remote');
+ });
+ const names: string[] = [];
+ lines.forEach((line: string) => {
+ const temp = line.split(' ');
+ if (temp.length !== 8) {
+ return;
+ }
+ if (temp[5] === '01') {
+ const name = temp[7].substr(1);
+ names.push(name);
+ }
+ });
+ return names;
+ }
+
+ private static async createHttpRequest(
+ serial: string,
+ unixSocketName: string,
+ url: string,
+ ): Promise {
+ const client = AdbExtended.createClient();
+ const socket = await client.openLocal(serial, `localabstract:${unixSocketName}`);
+ const request = new (http.ClientRequest as any)(url, {
+ createConnection: () => {
+ return socket;
+ },
+ });
+ const message: http.IncomingMessage = await new Promise((resolve, reject) => {
+ request.on('response', (r: http.IncomingMessage) => {
+ resolve(r);
+ });
+ request.on('socket', () => {
+ request.end();
+ });
+ request.on('error', (error: Error) => {
+ reject(error);
+ });
+ });
+ let data = '';
+ return new Promise((resolve, reject) => {
+ message.on('data', (d) => {
+ data += d;
+ });
+ message.on('end', () => {
+ const { statusCode } = message;
+ resolve({
+ statusCode,
+ contentType: message.headers['content-type'],
+ body: data,
+ });
+ });
+ message.on('error', (e) => {
+ reject(e);
+ });
+ });
+ }
+
+ private static parseResponse(message: IncomingMessage): T {
+ if (!message) {
+ throw Error('empty response');
+ }
+ const { contentType, statusCode } = message;
+ if (typeof statusCode !== 'number' || statusCode !== 200) {
+ throw Error(`wrong status code: ${statusCode}`);
+ }
+ if (!contentType?.startsWith('application/json')) {
+ throw Error(`wrong content type: ${contentType}`);
+ }
+ const json = JSON.parse(message.body);
+ return json as T;
+ }
+
+ private static patchWebSocketDebuggerUrl(host: string, serial: string, socket: string, url: string): string {
+ if (url) {
+ const remote = `localabstract:${socket}`;
+ const path = url.replace(/ws:\/\//, '').replace(fakeHostRe, '');
+ return `${host}/${ACTION.PROXY_ADB}/${serial}/${remote}/${path}`;
+ }
+ return url;
+ }
+
+ public static async getRemoteDevtoolsVersion(
+ host: string,
+ serial: string,
+ socket: string,
+ ): Promise {
+ const data = await this.createHttpRequest(serial, socket, `${proto}${fakeHost}/json/version`);
+ if (!data) {
+ throw Error('Empty response');
+ }
+ const metadata = this.parseResponse(data);
+ if (metadata.webSocketDebuggerUrl) {
+ metadata.webSocketDebuggerUrl = this.patchWebSocketDebuggerUrl(
+ host,
+ serial,
+ socket,
+ metadata.webSocketDebuggerUrl,
+ );
+ }
+ return metadata;
+ }
+
+ public static async getRemoteDevtoolsTargets(
+ host: string,
+ serial: string,
+ socket: string,
+ ): Promise {
+ const data = await this.createHttpRequest(serial, socket, `${proto}${fakeHost}/json`);
+ const list = this.parseResponse(data);
+ if (!list || !list.length) {
+ return [];
+ }
+ return list.map((target) => {
+ const { devtoolsFrontendUrl, webSocketDebuggerUrl } = target;
+ if (devtoolsFrontendUrl) {
+ let temp = devtoolsFrontendUrl;
+ let bundledOnDevice = false;
+ const ws = this.patchWebSocketDebuggerUrl(host, serial, socket, webSocketDebuggerUrl);
+
+ if (!temp.startsWith('http')) {
+ bundledOnDevice = true;
+ temp = `${proto}${fakeHost}${temp}`;
+ }
+ const url = new URL(temp);
+ // don't use `url.searchParams.set` here, argument will be url-encoded
+ // chrome-devtools.fronted will now work with url-encoded value
+ url.searchParams.delete('ws');
+ let urlString = url.toString();
+ if (urlString.includes('?')) {
+ urlString += '&';
+ } else {
+ urlString += '?';
+ }
+ urlString += `ws=${ws}`;
+
+ if (bundledOnDevice) {
+ urlString = urlString.substr(`${proto}${fakeHost}`.length);
+ }
+ target.devtoolsFrontendUrl = urlString;
+ target.webSocketDebuggerUrl = ws;
+ }
+ return target;
+ });
+ }
+
+ public static async getRemoteDevtoolsInfo(host: string, serial: string): Promise {
+ const list = await this.getDevtoolsRemoteList(serial);
+ if (!list || !list.length) {
+ const deviceName = await this.getDeviceName(serial);
+ return {
+ deviceName,
+ deviceSerial: serial,
+ browsers: [],
+ };
+ }
+
+ const all: Promise[] = [];
+ list.forEach((socket) => {
+ const v = this.getRemoteDevtoolsVersion(host, serial, socket).catch((error: Error) => {
+ console.error('getRemoteDevtoolsVersion failed:', error.message);
+ return {
+ 'Android-Package': 'string',
+ Browser: 'string',
+ 'Protocol-Version': 'string',
+ 'User-Agent': 'string',
+ 'V8-Version': 'string',
+ 'WebKit-Version': 'string',
+ webSocketDebuggerUrl: 'string',
+ };
+ });
+ const t = this.getRemoteDevtoolsTargets(host, serial, socket).catch((error: Error) => {
+ console.error('getRemoteDevtoolsTargets failed:', error.message);
+ return [];
+ });
+ const p = Promise.all([v, t]).then((result) => {
+ const [version, targets] = result;
+ return {
+ socket,
+ version,
+ targets,
+ };
+ });
+ all.push(p);
+ });
+ all.unshift(this.getDeviceName(serial));
+ const result = await Promise.all(all);
+ const deviceName: string = result.shift() as string;
+ const browsers: RemoteBrowserInfo[] = result as RemoteBrowserInfo[];
+ return {
+ deviceName,
+ deviceSerial: serial,
+ browsers,
+ };
+ }
+
+ public static async getDeviceName(serial: string): Promise {
+ const client = AdbExtended.createClient();
+ const props = await client.getProperties(serial);
+ return props['ro.product.model'] || 'Unknown device';
+ }
+}
diff --git a/src/server/goog-device/Device.ts b/src/server/goog-device/Device.ts
new file mode 100644
index 0000000..d3d0c2b
--- /dev/null
+++ b/src/server/goog-device/Device.ts
@@ -0,0 +1,465 @@
+import { AdbExtended } from './adb';
+import AdbKitClient from '@dead50f7/adbkit/lib/adb/client';
+import PushTransfer from '@dead50f7/adbkit/lib/adb/sync/pushtransfer';
+import { spawn } from 'child_process';
+import { NetInterface } from '../../types/NetInterface';
+import { TypedEmitter } from '../../common/TypedEmitter';
+import GoogDeviceDescriptor from '../../types/GoogDeviceDescriptor';
+import { ScrcpyServer } from './ScrcpyServer';
+import { Properties } from './Properties';
+import Timeout = NodeJS.Timeout;
+
+enum PID_DETECTION {
+ UNKNOWN,
+ PIDOF,
+ GREP_PS,
+ GREP_PS_A,
+ LS_PROC,
+}
+
+export interface DeviceEvents {
+ update: Device;
+}
+
+export class Device extends TypedEmitter {
+ private static readonly INITIAL_UPDATE_TIMEOUT = 1500;
+ private static readonly MAX_UPDATES_COUNT = 7;
+ private connected = true;
+ private pidDetectionVariant: PID_DETECTION = PID_DETECTION.UNKNOWN;
+ private client: AdbKitClient;
+ private properties?: Record;
+ private spawnServer = true;
+ private updateTimeoutId?: Timeout;
+ private updateTimeout = Device.INITIAL_UPDATE_TIMEOUT;
+ private updateCount = 0;
+ private throttleTimeoutId?: Timeout;
+ private lastEmit = 0;
+ public readonly TAG: string;
+ public readonly descriptor: GoogDeviceDescriptor;
+
+ constructor(public readonly udid: string, state: string) {
+ super();
+ this.TAG = `[${udid}]`;
+ this.descriptor = {
+ udid,
+ state,
+ interfaces: [],
+ pid: -1,
+ 'wifi.interface': '',
+ 'ro.build.version.release': '',
+ 'ro.build.version.sdk': '',
+ 'ro.product.manufacturer': '',
+ 'ro.product.model': '',
+ 'ro.product.cpu.abi': '',
+ 'last.update.timestamp': 0,
+ };
+ this.client = AdbExtended.createClient();
+ this.setState(state);
+ }
+
+ public setState(state: string): void {
+ if (state === 'device') {
+ this.connected = true;
+ this.properties = undefined;
+ } else {
+ this.connected = false;
+ }
+ this.descriptor.state = state;
+ this.emitUpdate();
+ this.fetchDeviceInfo();
+ }
+
+ public isConnected(): boolean {
+ return this.connected;
+ }
+
+ public async getPidOf(processName: string): Promise {
+ if (!this.connected) {
+ return;
+ }
+ if (this.pidDetectionVariant === PID_DETECTION.UNKNOWN) {
+ this.pidDetectionVariant = await this.findDetectionVariant();
+ }
+ switch (this.pidDetectionVariant) {
+ case PID_DETECTION.PIDOF:
+ return this.pidOf(processName);
+ case PID_DETECTION.GREP_PS:
+ return this.grepPs(processName);
+ case PID_DETECTION.GREP_PS_A:
+ return this.grepPs_A(processName);
+ default:
+ return this.listProc(processName);
+ }
+ }
+
+ public killProcess(pid: number): Promise {
+ const command = `kill ${pid}`;
+ return this.runShellCommandAdbKit(command);
+ }
+
+ public async runShellCommandAdb(command: string): Promise {
+ return new Promise((resolve, reject) => {
+ const cmd = 'adb';
+ const args = ['-s', `${this.udid}`, 'shell', command];
+ const adb = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
+ let output = '';
+ console.log('执行ADB命令')
+ adb.stdout.on('data', (data) => {
+ output += data.toString();
+ console.log(this.TAG, `stdout: ${data.toString().replace(/\n$/, '')}`);
+ });
+
+ adb.stderr.on('data', (data) => {
+ console.error(this.TAG, `stderr: ${data}`);
+ });
+
+ adb.on('error', (error: Error) => {
+ console.error(this.TAG, `failed to spawn adb process.\n${error.stack}`);
+ reject(error);
+ });
+
+ adb.on('close', (code) => {
+ console.log(this.TAG, `adb process (${args.join(' ')}) exited with code ${code}`);
+ resolve(output);
+ });
+ });
+ }
+
+ public async runShellCommandAdbKit(command: string): Promise {
+ return this.client
+ .shell(this.udid, command)
+ .then(AdbExtended.util.readAll)
+ .then((output: Buffer) => output.toString().trim());
+ }
+
+ public async push(contents: string, path: string): Promise {
+ return this.client.push(this.udid, contents, path);
+ }
+
+ public async getProperties(): Promise | undefined> {
+ if (this.properties) {
+ return this.properties;
+ }
+ if (!this.connected) {
+ return;
+ }
+ this.properties = await this.client.getProperties(this.udid);
+ return this.properties;
+ }
+
+ private interfacesSort = (a: NetInterface, b: NetInterface): number => {
+ if (a.name > b.name) {
+ return 1;
+ }
+ if (a.name < b.name) {
+ return -1;
+ }
+ return 0;
+ };
+
+ public async getNetInterfaces(): Promise {
+ if (!this.connected) {
+ return [];
+ }
+ const list: NetInterface[] = [];
+ const output = await this.runShellCommandAdbKit(`ip -4 -f inet -o a | grep 'scope global'`);
+ const lines = output.split('\n').filter((i: string) => !!i);
+ lines.forEach((value: string) => {
+ const temp = value.split(' ').filter((i: string) => !!i);
+ const name = temp[1];
+ const ipAndMask = temp[3];
+ const ipv4 = ipAndMask.split('/')[0];
+ list.push({ name, ipv4 });
+ });
+ return list.sort(this.interfacesSort);
+ }
+
+ private async pidOf(processName: string): Promise {
+ return this.runShellCommandAdbKit(`pidof ${processName}`)
+ .then((output) => {
+ return output
+ .split(' ')
+ .map((pid) => parseInt(pid, 10))
+ .filter((num) => !isNaN(num));
+ })
+ .catch(() => {
+ return [];
+ });
+ }
+
+ private filterPsOutput(processName: string, output: string): number[] {
+ const list: number[] = [];
+ const processes = output.split('\n');
+ processes.map((line) => {
+ const cols = line
+ .trim()
+ .split(' ')
+ .filter((item) => item.length);
+ if (cols[cols.length - 1] === processName) {
+ const pid = parseInt(cols[1], 10);
+ if (!isNaN(pid)) {
+ list.push(pid);
+ }
+ }
+ });
+ return list;
+ }
+
+ private async grepPs_A(processName: string): Promise {
+ return this.runShellCommandAdbKit(`ps -A | grep ${processName}`)
+ .then((output) => {
+ return this.filterPsOutput(processName, output);
+ })
+ .catch(() => {
+ return [];
+ });
+ }
+
+ private async grepPs(processName: string): Promise {
+ return this.runShellCommandAdbKit(`ps | grep ${processName}`)
+ .then((output) => {
+ return this.filterPsOutput(processName, output);
+ })
+ .catch(() => {
+ return [];
+ });
+ }
+
+ private async listProc(processName: string): Promise {
+ const find = `find /proc -maxdepth 2 -name cmdline 2>/dev/null`;
+ const lines = await this.runShellCommandAdbKit(
+ `for L in \`${find}\`; do grep -sae '^${processName}' $L 2>&1 >/dev/null && echo $L; done`,
+ );
+ const re = /\/proc\/([0-9]+)\/cmdline/;
+ const list: number[] = [];
+ lines.split('\n').map((line) => {
+ const trim = line.trim();
+ const m = trim.match(re);
+ if (m) {
+ list.push(parseInt(m[1], 10));
+ }
+ });
+ return list;
+ }
+
+ private async executedWithoutError(command: string): Promise {
+ return this.runShellCommandAdbKit(command)
+ .then((output) => {
+ const err = parseInt(output, 10);
+ return err === 0;
+ })
+ .catch(() => {
+ return false;
+ });
+ }
+
+ private async hasPs(): Promise {
+ return this.executedWithoutError('ps | grep init 2>&1 >/dev/null; echo $?');
+ }
+
+ private async hasPs_A(): Promise {
+ return this.executedWithoutError('ps -A | grep init 2>&1 >/dev/null; echo $?');
+ }
+
+ private async hasPidOf(): Promise {
+ const ok = await this.executedWithoutError('which pidof 2>&1 >/dev/null && echo $?');
+ if (!ok) {
+ return false;
+ }
+ return this.runShellCommandAdbKit('echo $PPID; pidof init')
+ .then((output) => {
+ const pids = output.split('\n').filter((a) => a.length);
+ if (pids.length < 2) {
+ return false;
+ }
+ const parentPid = pids[0].replace('\r', '');
+ const list = pids[1].split(' ');
+ if (list.includes(parentPid)) {
+ return false;
+ }
+ return list.includes('1');
+ })
+ .catch(() => {
+ return false;
+ });
+ }
+
+ private async findDetectionVariant(): Promise {
+ if (await this.hasPidOf()) {
+ return PID_DETECTION.PIDOF;
+ }
+ if (await this.hasPs_A()) {
+ return PID_DETECTION.GREP_PS_A;
+ }
+ if (await this.hasPs()) {
+ return PID_DETECTION.GREP_PS;
+ }
+ return PID_DETECTION.LS_PROC;
+ }
+
+ private scheduleInfoUpdate(): void {
+ if (this.updateTimeoutId) {
+ return;
+ }
+ if (++this.updateCount > Device.MAX_UPDATES_COUNT) {
+ console.error(this.TAG, 'The maximum number of attempts to fetch device info has been reached.');
+ return;
+ }
+ this.updateTimeoutId = setTimeout(this.fetchDeviceInfo, this.updateTimeout);
+ this.updateTimeout *= 2;
+ }
+
+ private fetchDeviceInfo = (): void => {
+ if (this.connected) {
+ const propsPromise = this.getProperties().then((props) => {
+ if (!props) {
+ return false;
+ }
+ let changed = false;
+ Properties.forEach((propName: keyof GoogDeviceDescriptor) => {
+ if (props[propName] !== this.descriptor[propName]) {
+ changed = true;
+ (this.descriptor[propName] as any) = props[propName];
+ }
+ });
+ if (changed) {
+ this.emitUpdate();
+ }
+ return true;
+ });
+ const netIntPromise = this.updateInterfaces().then((interfaces) => {
+ return !!interfaces.length;
+ });
+ let pidPromise: Promise;
+ if (this.spawnServer) {
+ pidPromise = this.startServer();
+ } else {
+ pidPromise = this.getServerPid();
+ }
+ const serverPromise = pidPromise.then(() => {
+ return !(this.descriptor.pid === -1 && this.spawnServer);
+ });
+ Promise.all([propsPromise, netIntPromise, serverPromise])
+ .then((results) => {
+ this.updateTimeoutId = undefined;
+ const failedCount = results.filter((result) => !result).length;
+ if (!failedCount) {
+ this.updateCount = 0;
+ this.updateTimeout = Device.INITIAL_UPDATE_TIMEOUT;
+ } else {
+ this.scheduleInfoUpdate();
+ }
+ })
+ .catch(() => {
+ this.updateTimeoutId = undefined;
+ this.scheduleInfoUpdate();
+ });
+ } else {
+ this.updateCount = 0;
+ this.updateTimeout = Device.INITIAL_UPDATE_TIMEOUT;
+ this.updateTimeoutId = undefined;
+ this.emitUpdate();
+ }
+ return;
+ };
+
+ private emitUpdate(setUpdateTime = true): void {
+ const THROTTLE = 300;
+ const now = Date.now();
+ const time = now - this.lastEmit;
+ if (setUpdateTime) {
+ this.descriptor['last.update.timestamp'] = now;
+ }
+ if (time > THROTTLE) {
+ this.lastEmit = now;
+ this.emit('update', this);
+ return;
+ }
+ if (!this.throttleTimeoutId) {
+ this.throttleTimeoutId = setTimeout(() => {
+ delete this.throttleTimeoutId;
+ this.emitUpdate(false);
+ }, THROTTLE - time);
+ }
+ }
+
+ private async getServerPid(): Promise {
+ const pids = await ScrcpyServer.getServerPid(this);
+ let pid;
+ if (!Array.isArray(pids) || !pids.length) {
+ pid = -1;
+ } else {
+ pid = pids[0];
+ }
+ if (this.descriptor.pid !== pid) {
+ this.descriptor.pid = pid;
+ this.emitUpdate();
+ }
+ if (pid !== -1) {
+ return pid;
+ } else {
+ return;
+ }
+ }
+
+ public async updateInterfaces(): Promise {
+ return this.getNetInterfaces().then((interfaces) => {
+ let changed = false;
+ const old = this.descriptor.interfaces;
+ if (old.length !== interfaces.length) {
+ changed = true;
+ } else {
+ old.forEach((value, idx) => {
+ if (value.name !== interfaces[idx].name || value.ipv4 !== interfaces[idx].ipv4) {
+ changed = true;
+ }
+ });
+ }
+ if (changed) {
+ this.descriptor.interfaces = interfaces;
+ this.emitUpdate();
+ }
+ return this.descriptor.interfaces;
+ });
+ }
+
+ public async killServer(pid: number): Promise {
+ this.spawnServer = false;
+ const realPid = await this.getServerPid();
+ if (typeof realPid !== 'number') {
+ return;
+ }
+ if (realPid !== pid) {
+ console.error(this.TAG, `Requested to kill server with PID ${pid}. Real server PID is ${realPid}.`);
+ }
+ try {
+ const output = await this.killProcess(realPid);
+ if (output) {
+ console.log(this.TAG, `kill server: "${output}"`);
+ }
+ this.descriptor.pid = -1;
+ this.emitUpdate();
+ } catch (error: any) {
+ console.error(this.TAG, `Error: ${error.message}`);
+ throw error;
+ }
+ }
+
+ public async startServer(): Promise {
+ this.spawnServer = true;
+ const pid = await this.getServerPid();
+ if (typeof pid === 'number') {
+ return pid;
+ }
+ try {
+ const output = await ScrcpyServer.run(this);
+ if (output) {
+ console.log(this.TAG, `start server: "${output}"`);
+ }
+ return this.getServerPid();
+ } catch (error: any) {
+ console.error(this.TAG, `Error: ${error.message}`);
+ throw error;
+ }
+ }
+}
diff --git a/src/server/goog-device/Properties.ts b/src/server/goog-device/Properties.ts
new file mode 100644
index 0000000..1929d2b
--- /dev/null
+++ b/src/server/goog-device/Properties.ts
@@ -0,0 +1,10 @@
+import GoogDeviceDescriptor from '../../types/GoogDeviceDescriptor';
+
+export const Properties: ReadonlyArray = [
+ 'ro.product.cpu.abi',
+ 'ro.product.manufacturer',
+ 'ro.product.model',
+ 'ro.build.version.release',
+ 'ro.build.version.sdk',
+ 'wifi.interface',
+];
diff --git a/src/server/goog-device/ScrcpyServer.ts b/src/server/goog-device/ScrcpyServer.ts
new file mode 100644
index 0000000..e7a500d
--- /dev/null
+++ b/src/server/goog-device/ScrcpyServer.ts
@@ -0,0 +1,141 @@
+import '../../../vendor/Genymobile/scrcpy/scrcpy-server.jar';
+import '../../../vendor/Genymobile/scrcpy/LICENSE';
+
+import { Device } from './Device';
+import { ARGS_STRING, SERVER_PACKAGE, SERVER_PROCESS_NAME, SERVER_VERSION } from '../../common/Constants';
+import path from 'path';
+import PushTransfer from '@dead50f7/adbkit/lib/adb/sync/pushtransfer';
+import { ServerVersion } from './ServerVersion';
+
+const TEMP_PATH = '/data/local/tmp/';
+const FILE_DIR = path.join(__dirname, 'vendor/Genymobile/scrcpy');
+const FILE_NAME = 'scrcpy-server.jar';
+const RUN_COMMAND = `CLASSPATH=${TEMP_PATH}${FILE_NAME} nohup app_process ${ARGS_STRING}`;
+
+type WaitForPidParams = { tryCounter: number; processExited: boolean; lookPidFile: boolean };
+
+export class ScrcpyServer {
+ private static PID_FILE_PATH = '/data/local/tmp/ws_scrcpy.pid';
+ private static async copyServer(device: Device): Promise {
+ const src = path.join(FILE_DIR, FILE_NAME);
+ const dst = TEMP_PATH + FILE_NAME; // don't use path.join(): will not work on win host
+ return device.push(src, dst);
+ }
+
+ // Important to notice that we first try to read PID from file.
+ // Checking with `.getServerPid()` will return process id, but process may stop.
+ // PID file only created after WebSocket server has been successfully started.
+ private static async waitForServerPid(device: Device, params: WaitForPidParams): Promise {
+ const { tryCounter, processExited, lookPidFile } = params;
+ if (processExited) {
+ return;
+ }
+ const timeout = 500 + 100 * tryCounter;
+ if (lookPidFile) {
+ const fileName = ScrcpyServer.PID_FILE_PATH;
+ const content = await device.runShellCommandAdbKit(`test -f ${fileName} && cat ${fileName}`);
+ if (content.trim()) {
+ const pid = parseInt(content, 10);
+ if (pid && !isNaN(pid)) {
+ const realPid = await this.getServerPid(device);
+ if (realPid?.includes(pid)) {
+ return realPid;
+ } else {
+ params.lookPidFile = false;
+ }
+ }
+ }
+ } else {
+ const list = await this.getServerPid(device);
+ if (Array.isArray(list) && list.length) {
+ return list;
+ }
+ }
+ if (++params.tryCounter > 5) {
+ throw new Error('Failed to start server');
+ }
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ resolve(this.waitForServerPid(device, params));
+ }, timeout);
+ });
+ }
+
+ public static async getServerPid(device: Device): Promise {
+ if (!device.isConnected()) {
+ return;
+ }
+ const list = await device.getPidOf(SERVER_PROCESS_NAME);
+ if (!Array.isArray(list) || !list.length) {
+ return;
+ }
+ const serverPid: number[] = [];
+ const promises = list.map((pid) => {
+ return device.runShellCommandAdbKit(`cat /proc/${pid}/cmdline`).then((output) => {
+ const args = output.split('\0');
+ if (!args.length || args[0] !== SERVER_PROCESS_NAME) {
+ return;
+ }
+ let first = args[0];
+ while (args.length && first !== SERVER_PACKAGE) {
+ args.shift();
+ first = args[0];
+ }
+ if (args.length < 3) {
+ return;
+ }
+ const versionString = args[1];
+ if (versionString === SERVER_VERSION) {
+ serverPid.push(pid);
+ } else {
+ const currentVersion = new ServerVersion(versionString);
+ if (currentVersion.isCompatible()) {
+ const desired = new ServerVersion(SERVER_VERSION);
+ if (desired.gt(currentVersion)) {
+ console.log(
+ device.TAG,
+ `Found old server version running (PID: ${pid}, Version: ${versionString})`,
+ );
+ console.log(device.TAG, 'Perform kill now');
+ device.killProcess(pid);
+ }
+ }
+ }
+ return;
+ });
+ });
+ await Promise.all(promises);
+ return serverPid;
+ }
+
+ public static async run(device: Device): Promise {
+ if (!device.isConnected()) {
+ return;
+ }
+ let list: number[] | string | undefined = await this.getServerPid(device);
+ if (Array.isArray(list) && list.length) {
+ return list;
+ }
+ await this.copyServer(device);
+
+ const params: WaitForPidParams = { tryCounter: 0, processExited: false, lookPidFile: true };
+ const runPromise = device.runShellCommandAdb(RUN_COMMAND);
+ runPromise
+ .then((out) => {
+ if (device.isConnected()) {
+ console.log(device.TAG, 'Server exited:', out);
+ }
+ })
+ .catch((e) => {
+ console.log(device.TAG, 'Error:', e.message);
+ })
+ .finally(() => {
+ params.processExited = true;
+ });
+ list = await Promise.race([runPromise, this.waitForServerPid(device, params)]);
+ if (Array.isArray(list) && list.length) {
+ return list;
+ }
+ return;
+ }
+}
diff --git a/src/server/goog-device/ServerVersion.ts b/src/server/goog-device/ServerVersion.ts
new file mode 100644
index 0000000..15bc2aa
--- /dev/null
+++ b/src/server/goog-device/ServerVersion.ts
@@ -0,0 +1,43 @@
+export class ServerVersion {
+ protected parts: string[] = [];
+ protected suffix: string;
+ protected readonly compatible: boolean;
+
+ constructor(public readonly versionString: string) {
+ const temp = versionString.split('-');
+ const main = temp.shift();
+ this.suffix = temp.join('-');
+ if (main) {
+ this.parts = main.split('.');
+ }
+ this.compatible = this.suffix.startsWith('ws') && this.parts.length >= 2;
+ }
+ public equals(a: ServerVersion | string): boolean {
+ const versionString = typeof a === 'string' ? a : a.versionString;
+ return this.versionString === versionString;
+ }
+ public gt(a: ServerVersion | string): boolean {
+ if (this.equals(a)) {
+ return false;
+ }
+ if (typeof a === 'string') {
+ a = new ServerVersion(a);
+ }
+ const minLength = Math.min(this.parts.length, a.parts.length);
+ for (let i = 0; i < minLength; i++) {
+ if (this.parts[i] > a.parts[i]) {
+ return true;
+ }
+ }
+ if (this.parts.length > a.parts.length) {
+ return true;
+ }
+ if (this.parts.length < a.parts.length) {
+ return false;
+ }
+ return this.suffix > a.suffix;
+ }
+ public isCompatible(): boolean {
+ return this.compatible;
+ }
+}
diff --git a/src/server/goog-device/adb/ExtendedClient.ts b/src/server/goog-device/adb/ExtendedClient.ts
new file mode 100644
index 0000000..28be748
--- /dev/null
+++ b/src/server/goog-device/adb/ExtendedClient.ts
@@ -0,0 +1,32 @@
+import Client from '@dead50f7/adbkit/lib/adb/client';
+import { ExtendedSync } from './ExtendedSync';
+import { SyncCommand } from './command/host-transport/sync';
+import { Multiplexer } from '../../../packages/multiplexer/Multiplexer';
+
+export class ExtendedClient extends Client {
+ public async pipeSyncService(serial: string): Promise {
+ const transport = await this.transport(serial);
+ return new SyncCommand(transport).execute();
+ }
+
+ public async pipeReadDir(serial: string, pathString: string, stream: Multiplexer): Promise {
+ const sync = await this.pipeSyncService(serial);
+ return sync.pipeReadDir(pathString, stream).then(() => {
+ sync.end();
+ });
+ }
+
+ public async pipePull(serial: string, path: string, stream: Multiplexer): Promise {
+ const sync = await this.pipeSyncService(serial);
+ return sync.pipePull(path, stream).then(() => {
+ sync.end();
+ });
+ }
+
+ public async pipeStat(serial: string, path: string, stream: Multiplexer): Promise {
+ const sync = await this.pipeSyncService(serial);
+ return sync.pipeStat(path, stream).then(() => {
+ sync.end();
+ });
+ }
+}
diff --git a/src/server/goog-device/adb/ExtendedSync.ts b/src/server/goog-device/adb/ExtendedSync.ts
new file mode 100644
index 0000000..975c32c
--- /dev/null
+++ b/src/server/goog-device/adb/ExtendedSync.ts
@@ -0,0 +1,112 @@
+import Connection from '@dead50f7/adbkit/lib/adb/connection';
+import Parser from '@dead50f7/adbkit/lib/adb/parser';
+import Protocol from '@dead50f7/adbkit/lib/adb/protocol';
+import { Multiplexer } from '../../../packages/multiplexer/Multiplexer';
+
+export class ExtendedSync {
+ private parser: Parser;
+
+ constructor(private connection: Connection) {
+ this.connection = connection;
+ this.parser = this.connection.parser;
+ }
+
+ public async pipeReadDir(path: string, stream: Multiplexer): Promise {
+ const readNext = async (): Promise => {
+ const reply = await this.parser.readAscii(4);
+ switch (reply) {
+ case Protocol.DENT:
+ const stat = await this.parser.readBytes(16);
+ const namelen = stat.readUInt32LE(12);
+ const name = await this.parser.readBytes(namelen);
+ console.log('发送的参数16');
+
+ stream.send(Buffer.concat([Buffer.from(reply), stat, name]));
+ return readNext();
+ case Protocol.DONE:
+ await this.parser.readBytes(16);
+ stream.close(0);
+ return;
+ case Protocol.FAIL:
+ return this._readError(stream);
+ default:
+ return this.parser.unexpected(reply, 'DENT, DONE or FAIL');
+ }
+ };
+ this._sendCommandWithArg(Protocol.LIST, path);
+ return readNext();
+ }
+
+ public pipePull(path: string, stream: Multiplexer): Promise {
+ this._sendCommandWithArg(Protocol.RECV, `${path}`);
+ return this._readData(stream);
+ }
+
+ public async pipeStat(path: string, stream: Multiplexer): Promise {
+ this._sendCommandWithArg(Protocol.STAT, `${path}`);
+ const reply = await this.parser.readAscii(4);
+ switch (reply) {
+ case Protocol.STAT:
+ const stat = await this.parser.readBytes(12);
+ console.log('发送的参数17');
+
+ stream.send(Buffer.concat([Buffer.from(reply), stat]));
+ stream.close(1000);
+ break;
+ case Protocol.FAIL:
+ return this._readError(stream);
+ default:
+ return this.parser.unexpected(reply, 'STAT or FAIL');
+ }
+ }
+
+ private _readData(stream: Multiplexer): Promise {
+ const readNext = async (): Promise => {
+ const reply = await this.parser.readAscii(4);
+ switch (reply) {
+ case Protocol.DATA:
+ const lengthData = await this.parser.readBytes(4);
+ const length = lengthData.readUInt32LE(0);
+ const data = await this.parser.readBytes(length);
+ console.log('发送的参数18');
+
+ stream.send(Buffer.concat([Buffer.from(reply), data]));
+ return readNext();
+ case Protocol.DONE:
+ await this.parser.readBytes(4);
+ stream.close(1000);
+ return;
+ case Protocol.FAIL:
+ return this._readError(stream);
+ default:
+ return this.parser.unexpected(reply, 'DATA, DONE or FAIL');
+ }
+ };
+ return readNext();
+ }
+
+ private _sendCommandWithArg(cmd: string, arg: string): Connection {
+ const arglen = Buffer.byteLength(arg, 'utf-8');
+ const payload = Buffer.alloc(cmd.length + 4 + arglen);
+ let pos = 0;
+ payload.write(cmd, pos, cmd.length);
+ pos += cmd.length;
+ payload.writeUInt32LE(arglen, pos);
+ pos += 4;
+ payload.write(arg, pos);
+ return this.connection.write(payload);
+ }
+
+ private async _readError(stream: Multiplexer): Promise {
+ const length = await this.parser.readBytes(4);
+ const message = await this.parser.readAscii(length.readUInt32LE(0));
+ stream.close(4000, message);
+ await this.parser.end();
+ return;
+ }
+
+ public end(): ExtendedSync {
+ this.connection.end();
+ return this;
+ }
+}
diff --git a/src/server/goog-device/adb/command/host-transport/sync.ts b/src/server/goog-device/adb/command/host-transport/sync.ts
new file mode 100644
index 0000000..9932b71
--- /dev/null
+++ b/src/server/goog-device/adb/command/host-transport/sync.ts
@@ -0,0 +1,20 @@
+import Protocol from '@dead50f7/adbkit/lib/adb/protocol';
+import Command from '@dead50f7/adbkit/lib/adb/command';
+import { ExtendedSync } from '../../ExtendedSync';
+import Bluebird from 'bluebird';
+
+export class SyncCommand extends Command {
+ execute(): Bluebird