diff --git a/.eslintignore b/.eslintignore index fecff4a6..9bf54751 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,4 +1,4 @@ src/public/**/*.js -src/vendor/**/*.js -src/Util.ts -src/h264-live-player/**/*.ts +vendor/**/*.js +src/app/Util.ts +src/app/h264-live-player/**/*.ts diff --git a/LICENSE b/LICENSE index 0e1bbc74..dae34211 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (C) 2019 by Netris, CJSC. +Copyright (C) 2020 by Netris, JSC. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 93c003b0..4d6f1ae8 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ Web client prototype for [scrcpy](https://github.com/Genymobile/scrcpy). You'll need a web browser that supports the following technologies: * WebSockets -* Media Source Extensions and h264 decoding ([MseDecoder](/src/decoder/MseDecoder.ts)) -* WebWorkers ([h264bsd](/src/decoder/H264bsdDecoder.ts), [tinyh264](/src/decoder/Tinyh264Decoder.ts)) -* WebAssembly ([Broadway.js](/src/decoder/BroadwayDecoder.ts) and [h264bsd](/src/decoder/H264bsdDecoder.ts), [tinyh264](/src/decoder/Tinyh264Decoder.ts)) +* Media Source Extensions and h264 decoding ([MseDecoder](/src/app/decoder/MseDecoder.ts)) +* WebWorkers [tinyh264](/src/app/decoder/Tinyh264Decoder.ts)) +* WebAssembly ([Broadway.js](/src/app/decoder/BroadwayDecoder.ts) and [tinyh264](/src/app/decoder/Tinyh264Decoder.ts)) ## Build and Start @@ -39,7 +39,7 @@ Drag & drop an APK file to push it to the `/data/local/tmp` directory. You can i * New versions are most likely not incompatible with previous ones. If you do upgrade, then manually stop `app_process` or just reboot the device. * The server on the Android Emulator listens on the internal interface and not available from the outside (as workaround you can do `adb forward tcp:8886 tcp:8886` and use `127.0.0.1` instead of emulator IP address) -* H264bsdDecoder and Tinyh264Decoder may fail to start, try to reload the page. +* Tinyh264Decoder may fail to start, try to reload the page. * MseDecoder reports too many dropped frames in quality statistics: needs further investigation. ## Security warning @@ -50,11 +50,18 @@ Be advised and keep in mind: * The modified version of scrcpy with integrated WebSocket server is listening for connections on all network interfaces. * The modified version of scrcpy will keep running after the last client disconnected. +## WS QVH +This project also contains frontend for [NetrisTV/ws-qvh](https://github.com/NetrisTV/ws-qvh). Run this to build it: + +```shell script +npm install +npm run dist:qvhack:frontend +``` + ## Related projects * [Genymobile/scrcpy](https://github.com/Genymobile/scrcpy) * [xevokk/h264-converter](https://github.com/xevokk/h264-converter) * [131/h264-live-player](https://github.com/131/h264-live-player) -* [oneam/h264bsd](https://github.com/oneam/h264bsd) * [mbebenita/Broadway](https://github.com/mbebenita/Broadway) * [openstf/adbkit](https://github.com/openstf/adbkit) * [xtermjs/xterm.js](https://github.com/xtermjs/xterm.js) @@ -63,5 +70,5 @@ Be advised and keep in mind: ## scrcpy websocket fork Currently, support of WebSocket protocol added to v1.16 of scrcpy -* [Prebuilt package](/src/public/scrcpy-server.jar) +* [Prebuilt package](/vendor/Genymobile/scrcpy/scrcpy-server.jar) * [Source code](https://github.com/NetrisTV/scrcpy/tree/feature/websocket-v1.16.x) diff --git a/package.json b/package.json index d113cedd..aa0a2ce3 100644 --- a/package.json +++ b/package.json @@ -1,22 +1,15 @@ { "name": "ws-scrcpy", - "version": "0.4.1", + "version": "0.5.0", "description": "ws client for scrcpy", "scripts": { - "build": "npm run compile && npm run copy:vendor && npm run build:webpack", - "clean": "npm run clean:build && npm run clean:dist", - "clean:build": "npx rimraf build", - "clean:dist": "npx rimraf dist", - "build:webpack": "webpack --config webpack.config.js", - "compile": "npx tsc -p .", - "copy:bundle": "node -e \"fs.copyFile('build/bundle.js','dist/public/bundle.js',function(e){if(e)process.exit(1);process.exit(0);})\"", - "copy:public": "node -e \"require('recursive-copy')('src/public','dist/public', {overwrite: true, debug: true} ,function(e){if(e)process.exit(1);process.exit(0);})\"", - "copy:server": "node -e \"require('recursive-copy')('build/server','dist/server', {overwrite: true, debug: true} ,function(e){if(e)process.exit(1);process.exit(0);})\"", - "copy:vendor": "node -e \"require('recursive-copy')('src/vendor','build', {overwrite: true, debug: true} ,function(e){if(e)process.exit(1);process.exit(0);})\"", - "copy:xterm.css": "node -e \"require('recursive-copy')('node_modules/xterm/css','dist/public', {overwrite: true, debug: true} ,function(e){if(e)process.exit(1);process.exit(0);})\"", - "dist": "npm run build && npm run mkdirs && npm run copy:package.json && npm run copy:public && npm run copy:xterm.css && npm run copy:server", + "clean": "npx rimraf dist", + "build:webpack": "webpack --config webpack.ws-scrcpy.config.js", + "copy:license": "node -e \"require('fs').copyFileSync('LICENSE', 'dist/LICENSE')\"", + "dist": "npm run build:webpack && npm run copy:package.json && npm run copy:license", + "build:qvhack:frontend": "webpack --config webpack.qvhack.config.js", + "dist:qvhack:frontend": "npm run build:qvhack:frontend && npm run copy:license", "copy:package.json": "node -e \"const j=require('./package.json');const {name,version,description,author,license,dependencies,scripts}=j; const p={name, version, description,author,license,dependencies}; p.scripts={start: scripts['dist:start']};fs.writeFileSync('./dist/package.json', JSON.stringify(p, null, ' '))\"", - "mkdirs": "npx mkdirp dist/public", "start": "npm run dist && npm run start:dist", "start:dist": "cd dist && npm start", "dist:start": "cd server && node index.js", @@ -38,12 +31,15 @@ "@typescript-eslint/eslint-plugin": "^3.8.0", "@typescript-eslint/parser": "^3.8.0", "buffer": "^5.2.1", + "css-loader": "^4.3.0", "eslint": "^7.6.0", "eslint-config-prettier": "^6.11.0", "eslint-plugin-prettier": "^3.1.4", "eslint-plugin-progress": "0.0.1", "file-loader": "^6.0.0", "h264-converter": "^0.1.2", + "html-webpack-plugin": "^4.5.0", + "mini-css-extract-plugin": "^0.11.2", "mkdirp": "^0.5.1", "prettier": "^2.0.5", "recursive-copy": "^2.0.10", @@ -51,9 +47,11 @@ "svg-inline-loader": "^0.8.2", "sylvester.js": "^0.1.1", "tinyh264": "0.0.6", + "ts-loader": "^8.0.4", "typescript": "^3.4.5", "webpack": "^4.43.0", "webpack-cli": "^3.3.12", + "webpack-node-externals": "^2.5.2", "worker-loader": "^2.0.0", "xterm": "^4.5.0", "xterm-addon-attach": "^0.5.0", diff --git a/src/DeviceController.ts b/src/DeviceController.ts deleted file mode 100644 index 73145628..00000000 --- a/src/DeviceController.ts +++ /dev/null @@ -1,345 +0,0 @@ -import Decoder, { VideoResizeListener } from './decoder/Decoder'; -import { DeviceConnection, DeviceMessageListener } from './DeviceConnection'; -import VideoSettings from './VideoSettings'; -import ErrorHandler from './ErrorHandler'; -import KeyCodeControlEvent from './controlEvent/KeyCodeControlEvent'; -import KeyEvent from './android/KeyEvent'; -import CommandControlEvent from './controlEvent/CommandControlEvent'; -import ControlEvent from './controlEvent/ControlEvent'; -import TextControlEvent from './controlEvent/TextControlEvent'; -import DeviceMessage from './DeviceMessage'; -import SvgImage from './ui/SvgImage'; -import Size from './Size'; - -export interface DeviceControllerParams { - url: string; - udid: string; - decoder: Decoder; -} - -export class DeviceController implements DeviceMessageListener, VideoResizeListener { - public readonly decoder: Decoder; - public readonly deviceView: HTMLDivElement; - public readonly input: HTMLInputElement; - private readonly moreBox: HTMLDivElement; - private readonly controlButtons: HTMLElement; - private readonly deviceConnection: DeviceConnection; - - constructor(params: DeviceControllerParams) { - const decoder = (this.decoder = params.decoder); - const udid = params.udid; - const decoderName = this.decoder.getName(); - const deviceView = (this.deviceView = document.createElement('div')); - deviceView.className = 'device-view'; - const connection = (this.deviceConnection = DeviceConnection.getInstance(udid, params.url)); - const videoSettings = decoder.getVideoSettings(); - connection.addEventListener(this); - const moreBox = (this.moreBox = document.createElement('div')); - moreBox.className = 'more-box'; - const nameBox = document.createElement('p'); - nameBox.innerText = `${udid} (${decoderName})`; - nameBox.className = 'text-with-shadow'; - moreBox.appendChild(nameBox); - const input = (this.input = document.createElement('input')); - const sendButton = document.createElement('button'); - sendButton.innerText = 'Send as keys'; - - this.wrap('p', [input, sendButton], moreBox); - sendButton.onclick = () => { - if (input.value) { - connection.sendEvent(new TextControlEvent(input.value)); - } - }; - - this.controlButtons = document.createElement('div'); - this.controlButtons.className = 'control-buttons-list'; - const commands: HTMLElement[] = []; - const codes = CommandControlEvent.CommandCodes; - for (const command in codes) { - if (codes.hasOwnProperty(command)) { - const action: number = codes[command]; - const btn = document.createElement('button'); - let bitrateInput: HTMLInputElement; - let maxFpsInput: HTMLInputElement; - let iFrameIntervalInput: HTMLInputElement; - let maxWidthInput: HTMLInputElement; - let maxHeightInput: HTMLInputElement; - if (action === ControlEvent.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}_${decoderName}_${action}`; - - spoiler.className = 'spoiler'; - spoilerCheck.type = 'checkbox'; - spoilerCheck.id = id; - spoilerLabel.htmlFor = id; - spoilerLabel.innerText = CommandControlEvent.CommandNames[action]; - 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 = `bitrate (${videoSettings.bitrate})`; - bitrateInput.value = videoSettings.bitrate.toString(); - this.wrap('div', [bitrateLabel, bitrateInput], innerDiv); - - const maxFpsLabel = document.createElement('label'); - maxFpsLabel.innerText = 'Max fps:'; - maxFpsInput = document.createElement('input'); - maxFpsInput.placeholder = `max fps (${videoSettings.maxFps})`; - maxFpsInput.value = videoSettings.maxFps.toString(); - this.wrap('div', [maxFpsLabel, maxFpsInput], innerDiv); - - const iFrameIntervalLabel = document.createElement('label'); - iFrameIntervalLabel.innerText = 'I-Frame Interval:'; - iFrameIntervalInput = document.createElement('input'); - iFrameIntervalInput.placeholder = `I-frame interval (${videoSettings.iFrameInterval})`; - iFrameIntervalInput.value = videoSettings.iFrameInterval.toString(); - this.wrap('div', [iFrameIntervalLabel, iFrameIntervalInput], innerDiv); - - const { width, height } = videoSettings.bounds || this.getMaxSize(); - - const maxWidthLabel = document.createElement('label'); - maxWidthLabel.innerText = 'Max width:'; - maxWidthInput = document.createElement('input'); - maxWidthInput.placeholder = `max width (${width})`; - maxWidthInput.value = width.toString(); - this.wrap('div', [maxWidthLabel, maxWidthInput], innerDiv); - - const maxHeightLabel = document.createElement('label'); - maxHeightLabel.innerText = 'Max height:'; - maxHeightInput = document.createElement('input'); - maxHeightInput.placeholder = `max height (${height})`; - maxHeightInput.value = height.toString(); - this.wrap('div', [maxHeightLabel, maxHeightInput], innerDiv); - - innerDiv.appendChild(btn); - commands.push(spoiler); - } else { - commands.push(btn); - } - btn.innerText = CommandControlEvent.CommandNames[action]; - btn.onclick = () => { - let event: CommandControlEvent | undefined; - if (action === ControlEvent.TYPE_CHANGE_STREAM_PARAMETERS) { - 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 videoSettings = new VideoSettings({ - bounds, - bitrate, - maxFps, - iFrameInterval, - lockedVideoOrientation: -1, - sendFrameMeta: false, - }); - connection.sendNewVideoSetting(videoSettings); - } else if (action === CommandControlEvent.TYPE_SET_CLIPBOARD) { - const text = input.value; - if (text) { - event = CommandControlEvent.createSetClipboardCommand(text); - } - } else { - event = new CommandControlEvent(action); - } - if (event) { - connection.sendEvent(event); - } - }; - } - } - const list = [ - { - 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, - }, - ]; - list.forEach((item) => { - const { code, icon, title } = item; - const btn = document.createElement('button'); - btn.classList.add('control-button'); - btn.title = title; - btn.appendChild(SvgImage.create(icon)); - btn.onmousedown = () => { - const event = new KeyCodeControlEvent(KeyEvent.ACTION_DOWN, code, 0, 0); - connection.sendEvent(event); - }; - btn.onmouseup = () => { - const event = new KeyCodeControlEvent(KeyEvent.ACTION_UP, code, 0, 0); - connection.sendEvent(event); - }; - this.controlButtons.appendChild(btn); - }); - if (decoder.supportsScreenshot) { - const screenshotButton = document.createElement('button'); - screenshotButton.classList.add('control-button'); - screenshotButton.title = 'Take screenshot'; - screenshotButton.appendChild(SvgImage.create(SvgImage.Icon.CAMERA)); - screenshotButton.onclick = () => { - decoder.createScreenshot(connection.getDeviceName()); - }; - this.controlButtons.appendChild(screenshotButton); - } - const captureKeyboardInput = document.createElement('input'); - captureKeyboardInput.type = 'checkbox'; - const captureKeyboardLabel = document.createElement('label'); - captureKeyboardLabel.title = 'Capture keyboard'; - captureKeyboardLabel.classList.add('control-button'); - captureKeyboardLabel.appendChild(SvgImage.create(SvgImage.Icon.KEYBOARD)); - captureKeyboardLabel.htmlFor = captureKeyboardInput.id = `capture_keyboard_${udid}_${decoderName}`; - captureKeyboardInput.onclick = (e: MouseEvent) => { - const checkbox = e.target as HTMLInputElement; - connection.setHandleKeyboardEvents(checkbox.checked); - }; - this.controlButtons.appendChild(captureKeyboardInput); - this.controlButtons.appendChild(captureKeyboardLabel); - this.wrap('p', commands, moreBox); - const showMoreInput = document.createElement('input'); - showMoreInput.type = 'checkbox'; - const showMoreLabel = document.createElement('label'); - showMoreLabel.title = 'More'; - showMoreLabel.classList.add('control-button'); - showMoreLabel.appendChild(SvgImage.create(SvgImage.Icon.MORE)); - showMoreLabel.htmlFor = showMoreInput.id = `show_more_${udid}_${decoderName}`; - showMoreInput.onclick = (e: MouseEvent) => { - const checkbox = e.target as HTMLInputElement; - moreBox.style.display = checkbox.checked ? 'block' : 'none'; - }; - const firstChild = this.controlButtons.firstChild as ChildNode; - this.controlButtons.insertBefore(showMoreInput, firstChild); - this.controlButtons.insertBefore(showMoreLabel, firstChild); - - const stop = (ev?: string | Event) => { - if (ev && ev instanceof Event && ev.type === 'error') { - console.error(ev); - } - connection.removeDecoder(decoder); - let parent; - parent = deviceView.parentElement; - if (parent) { - parent.removeChild(deviceView); - } - parent = moreBox.parentElement; - if (parent) { - parent.removeChild(moreBox); - } - decoder.removeResizeListener(this); - }; - const qualityId = `show_video_quality_${udid}_${decoderName}`; - const qualityLabel = document.createElement('label'); - const qualityCheck = document.createElement('input'); - qualityCheck.type = 'checkbox'; - qualityCheck.checked = Decoder.DEFAULT_SHOW_QUALITY_STATS; - qualityCheck.id = qualityId; - qualityLabel.htmlFor = qualityId; - qualityLabel.innerText = 'Show quality stats'; - this.wrap('p', [qualityCheck, qualityLabel], moreBox); - qualityCheck.onchange = () => { - decoder.setShowQualityStats(qualityCheck.checked); - }; - const stopBtn = document.createElement('button') as HTMLButtonElement; - stopBtn.innerText = `Disconnect`; - stopBtn.onclick = stop; - this.wrap('p', [stopBtn], moreBox); - deviceView.appendChild(this.controlButtons); - const video = document.createElement('div'); - video.className = 'video'; - deviceView.appendChild(video); - deviceView.appendChild(moreBox); - this.decoder.setParent(video); - this.decoder.addResizeListener(this); - connection.setErrorListener(new ErrorHandler(stop)); - } - - private wrap(tagName: string, elements: HTMLElement[], parent: HTMLElement): void { - const wrap = document.createElement(tagName); - elements.forEach((e) => { - wrap.appendChild(e); - }); - parent.appendChild(wrap); - } - - private getMaxSize(): Size { - const body = document.body; - const width = (body.clientWidth - this.controlButtons.clientWidth) & ~15; - const height = body.clientHeight & ~15; - return new Size(width, height); - } - - public start(): void { - document.body.appendChild(this.deviceView); - const decoder = this.decoder; - if (decoder.getPreferredVideoSetting().equals(decoder.getVideoSettings())) { - const bounds = this.getMaxSize(); - const { - bitrate, - maxFps, - iFrameInterval, - lockedVideoOrientation, - sendFrameMeta, - } = decoder.getVideoSettings(); - const newVideoSettings = new VideoSettings({ - bounds, - bitrate, - maxFps, - iFrameInterval, - lockedVideoOrientation, - sendFrameMeta, - }); - decoder.setVideoSettings(newVideoSettings, false); - } - this.deviceConnection.addDecoder(decoder); - } - - public OnDeviceMessage(ev: DeviceMessage): void { - if (ev.type !== DeviceMessage.TYPE_CLIPBOARD) { - return; - } - this.input.value = ev.getText(); - this.input.select(); - document.execCommand('copy'); - } - - public onVideoResize(size: Size): void { - // padding: 10px - this.moreBox.style.width = `${size.width - 2 * 10}px`; - } -} diff --git a/src/Point.ts b/src/Point.ts deleted file mode 100644 index acc10b0a..00000000 --- a/src/Point.ts +++ /dev/null @@ -1,20 +0,0 @@ -export default class Point { - constructor(readonly x: number, readonly y: number) { - this.x = Math.round(x); - this.y = Math.round(y); - } - - public equals(o: Point): boolean { - if (this === o) { - return true; - } - if (o === null) { - return false; - } - return this.x === o.x && this.y === o.y; - } - - public toString(): string { - return `Point{x=${this.x}, y=${this.y}}`; - } -} diff --git a/src/Position.ts b/src/Position.ts deleted file mode 100644 index bb062f7a..00000000 --- a/src/Position.ts +++ /dev/null @@ -1,24 +0,0 @@ -import Point from './Point'; -import Size from './Size'; - -export default class Position { - public constructor(readonly point: Point, readonly screenSize: Size) { - this.point = point; - this.screenSize = screenSize; - } - - public equals(o: Position): boolean { - if (this === o) { - return true; - } - if (o === null) { - return false; - } - - return this.point.equals(o.point) && this.screenSize.equals(o.screenSize); - } - - public toString(): string { - return `Position{point=${this.point}, screenSize=${this.screenSize}}`; - } -} diff --git a/src/DeviceConnection.ts b/src/app/DeviceConnection.ts similarity index 92% rename from src/DeviceConnection.ts rename to src/app/DeviceConnection.ts index 4fcf5115..6d2f5a4f 100644 --- a/src/DeviceConnection.ts +++ b/src/app/DeviceConnection.ts @@ -1,17 +1,17 @@ import VideoSettings from './VideoSettings'; -import ControlEvent from './controlEvent/ControlEvent'; +import { ControlMessage } from './controlMessage/ControlMessage'; import Size from './Size'; import Decoder from './decoder/Decoder'; import Util from './Util'; -import TouchControlEvent from './controlEvent/TouchControlEvent'; -import CommandControlEvent from './controlEvent/CommandControlEvent'; +import { TouchControlMessage } from './controlMessage/TouchControlMessage'; +import { CommandControlMessage } from './controlMessage/CommandControlMessage'; import ScreenInfo from './ScreenInfo'; import DeviceMessage from './DeviceMessage'; import TouchHandler from './TouchHandler'; import { KeyEventListener, KeyInputHandler } from './KeyInputHandler'; -import KeyCodeControlEvent from './controlEvent/KeyCodeControlEvent'; +import { KeyCodeControlMessage } from './controlMessage/KeyCodeControlMessage'; import FilePushHandler from './FilePushHandler'; -import DragAndPushLogger from './DragAndPushLogger'; +// import DragAndPushLogger from './DragAndPushLogger'; const DEVICE_NAME_FIELD_LENGTH = 64; const MAGIC = 'scrcpy'; @@ -37,7 +37,7 @@ export class DeviceConnection implements KeyEventListener { private static hasTouchListeners = false; private static instances: Record = {}; public readonly ws: WebSocket; - private events: ControlEvent[] = []; + private events: ControlMessage[] = []; private decoders: Set = new Set(); private filePushHandlers: Map = new Map(); private errorListener?: ErrorListener; @@ -77,7 +77,7 @@ export class DeviceConnection implements KeyEventListener { if (!screenInfo) { return; } - let events: TouchControlEvent[] | null = null; + let events: TouchControlMessage[] | null = null; let condition = true; if (e instanceof MouseEvent) { condition = down > 0; @@ -182,13 +182,13 @@ export class DeviceConnection implements KeyEventListener { decoder.pause(); } this.decoders.add(decoder); - if (!this.filePushHandlers.has(decoder)) { - const element = decoder.getTouchableElement(); - const handler = new FilePushHandler(element, this); - const logger = new DragAndPushLogger(element); - handler.addEventListener(logger); - this.filePushHandlers.set(decoder, handler); - } + // if (!this.filePushHandlers.has(decoder)) { + // const element = decoder.getTouchableElement(); + // const handler = new FilePushHandler(element, this); + // const logger = new DragAndPushLogger(element); + // handler.addEventListener(logger); + // this.filePushHandlers.set(decoder, handler); + // } DeviceConnection.setTouchListeners(); } @@ -213,7 +213,7 @@ export class DeviceConnection implements KeyEventListener { this.events.length = 0; } - public sendEvent(event: ControlEvent): void { + public sendEvent(event: ControlMessage): void { if (this.hasConnection()) { this.ws.send(event.toBuffer()); } else { @@ -223,7 +223,7 @@ export class DeviceConnection implements KeyEventListener { public sendNewVideoSetting(videoSettings: VideoSettings): void { this.requestedVideoSettings = videoSettings; - this.sendEvent(CommandControlEvent.createSetVideoSettingsCommand(videoSettings)); + this.sendEvent(CommandControlMessage.createSetVideoSettingsCommand(videoSettings)); } public setErrorListener(listener: ErrorListener): void { @@ -258,7 +258,7 @@ export class DeviceConnection implements KeyEventListener { } } - public onKeyEvent(event: KeyCodeControlEvent): void { + public onKeyEvent(event: KeyCodeControlMessage): void { this.sendEvent(event); } diff --git a/src/DeviceMessage.ts b/src/app/DeviceMessage.ts similarity index 100% rename from src/DeviceMessage.ts rename to src/app/DeviceMessage.ts diff --git a/src/DragAndDropHandler.ts b/src/app/DragAndDropHandler.ts similarity index 100% rename from src/DragAndDropHandler.ts rename to src/app/DragAndDropHandler.ts diff --git a/src/DragAndPushLogger.ts b/src/app/DragAndPushLogger.ts similarity index 100% rename from src/DragAndPushLogger.ts rename to src/app/DragAndPushLogger.ts diff --git a/src/ErrorHandler.ts b/src/app/ErrorHandler.ts similarity index 100% rename from src/ErrorHandler.ts rename to src/app/ErrorHandler.ts diff --git a/src/FilePushHandler.ts b/src/app/FilePushHandler.ts similarity index 91% rename from src/FilePushHandler.ts rename to src/app/FilePushHandler.ts index c753a21b..53db9570 100644 --- a/src/FilePushHandler.ts +++ b/src/app/FilePushHandler.ts @@ -1,7 +1,7 @@ import { DragAndDropHandler, DragEventListener } from './DragAndDropHandler'; -import { DeviceConnection, DeviceMessageListener } from './DeviceConnection'; import DeviceMessage from './DeviceMessage'; -import CommandControlEvent, { FilePushState } from './controlEvent/CommandControlEvent'; +import { CommandControlMessage, FilePushState } from './controlMessage/CommandControlMessage'; +import { StreamReceiver } from './client/StreamReceiver'; const ALLOWED_TYPES = ['application/vnd.android.package-archive']; @@ -17,7 +17,7 @@ export interface DragAndPushListener { onError: (error: Error | string) => void; } -export default class FilePushHandler implements DragEventListener, DeviceMessageListener { +export default class FilePushHandler implements DragEventListener { public static readonly REQUEST_NEW_PUSH_ID = 0; // ignored on server, when state is `NEW_PUSH_ID` public static readonly NEW_PUSH_ID: number = 1; public static readonly NO_ERROR: number = 0; @@ -36,9 +36,9 @@ export default class FilePushHandler implements DragEventListener, DeviceMessage private responseWaiter: Map = new Map(); private listeners: Set = new Set(); - constructor(private readonly element: HTMLElement, private readonly connection: DeviceConnection) { + constructor(private readonly element: HTMLElement, private readonly streamReceiver: StreamReceiver) { DragAndDropHandler.addEventListener(this); - connection.addEventListener(this); + streamReceiver.on('deviceMessage', this.onDeviceMessage); } private sendUpdate(params: PushUpdateParams): void { @@ -55,7 +55,7 @@ export default class FilePushHandler implements DragEventListener, DeviceMessage private async pushFile(file: File): Promise { const start = Date.now(); const { name: fileName, size: fileSize } = file; - if (!this.connection.hasConnection()) { + if (!this.streamReceiver.hasConnection()) { this.listeners.forEach((listener) => { listener.onError('WebSocket is not ready'); }); @@ -64,14 +64,14 @@ export default class FilePushHandler implements DragEventListener, DeviceMessage const id = FilePushHandler.REQUEST_NEW_PUSH_ID; this.sendUpdate({ pushId: id, fileName, logString: 'begin...', error: false }); const newParams = { id, state: FilePushState.NEW }; - this.connection.sendEvent(CommandControlEvent.createPushFileCommand(newParams)); + this.streamReceiver.sendEvent(CommandControlMessage.createPushFileCommand(newParams)); const pushId: number = await this.waitForResponse(id); if (pushId <= 0) { this.logError(pushId, fileName, pushId); } const startParams = { id: pushId, fileName, fileSize, state: FilePushState.START }; - this.connection.sendEvent(CommandControlEvent.createPushFileCommand(startParams)); + this.streamReceiver.sendEvent(CommandControlMessage.createPushFileCommand(startParams)); const stream = file.stream(); const reader = stream.getReader(); const [startResponseCode, result] = await Promise.all([this.waitForResponse(pushId), reader.read()]); @@ -85,7 +85,7 @@ export default class FilePushHandler implements DragEventListener, DeviceMessage const processData = async ({ done, value }: { done: boolean; value?: any }): Promise => { if (done) { const finishParams = { id: pushId, state: FilePushState.FINISH }; - this.connection.sendEvent(CommandControlEvent.createPushFileCommand(finishParams)); + this.streamReceiver.sendEvent(CommandControlMessage.createPushFileCommand(finishParams)); const finishResponseCode = await this.waitForResponse(pushId); if (finishResponseCode !== 0) { this.logError(pushId, fileName, finishResponseCode); @@ -98,7 +98,7 @@ export default class FilePushHandler implements DragEventListener, DeviceMessage receivedBytes += value.length; const appendParams = { id: pushId, chunk: value, state: FilePushState.APPEND }; - this.connection.sendEvent(CommandControlEvent.createPushFileCommand(appendParams)); + this.streamReceiver.sendEvent(CommandControlMessage.createPushFileCommand(appendParams)); const [appendResponseCode, result] = await Promise.all([this.waitForResponse(pushId), reader.read()]); if (appendResponseCode !== 0) { @@ -127,7 +127,7 @@ export default class FilePushHandler implements DragEventListener, DeviceMessage }); } - public OnDeviceMessage(ev: DeviceMessage): void { + onDeviceMessage = (ev: DeviceMessage): void => { if (ev.type !== DeviceMessage.TYPE_PUSH_RESPONSE) { return; } @@ -157,7 +157,7 @@ export default class FilePushHandler implements DragEventListener, DeviceMessage value = result; } func(value); - } + }; public onFilesDrop(files: File[]): void { this.listeners.forEach((listener) => { listener.onDrop(); diff --git a/src/KeyInputHandler.ts b/src/app/KeyInputHandler.ts similarity index 88% rename from src/KeyInputHandler.ts rename to src/app/KeyInputHandler.ts index 7537e743..03ea2058 100644 --- a/src/KeyInputHandler.ts +++ b/src/app/KeyInputHandler.ts @@ -1,9 +1,9 @@ -import KeyCodeControlEvent from './controlEvent/KeyCodeControlEvent'; +import { KeyCodeControlMessage } from './controlMessage/KeyCodeControlMessage'; import KeyEvent from './android/KeyEvent'; import { KeyToCodeMap } from './KeyToCodeMap'; export interface KeyEventListener { - onKeyEvent: (event: KeyCodeControlEvent) => void; + onKeyEvent: (event: KeyCodeControlMessage) => void; } export class KeyInputHandler { @@ -44,9 +44,14 @@ export class KeyInputHandler { (event.getModifierState('ScrollLock') ? KeyEvent.META_SCROLL_LOCK_ON : 0) | (event.getModifierState('NumLock') ? KeyEvent.META_NUM_LOCK_ON : 0); - const controlEvent: KeyCodeControlEvent = new KeyCodeControlEvent(action, keyCode, repeatCount, metaState); + const controlMessage: KeyCodeControlMessage = new KeyCodeControlMessage( + action, + keyCode, + repeatCount, + metaState, + ); KeyInputHandler.listeners.forEach((listener) => { - listener.onKeyEvent(controlEvent); + listener.onKeyEvent(controlMessage); }); e.preventDefault(); }; diff --git a/src/KeyToCodeMap.ts b/src/app/KeyToCodeMap.ts similarity index 100% rename from src/KeyToCodeMap.ts rename to src/app/KeyToCodeMap.ts diff --git a/src/app/MainQVHackOnly.ts b/src/app/MainQVHackOnly.ts new file mode 100644 index 00000000..4a3e1f72 --- /dev/null +++ b/src/app/MainQVHackOnly.ts @@ -0,0 +1,16 @@ +import '../style/app.css'; +import * as querystring from 'querystring'; +import { QVHackClientDeviceTracker } from './client/QVHackClientDeviceTracker'; +import { QVHackStreamClient } from './client/QVHackStreamClient'; +import { QVHackStreamParams } from '../common/QVHackStreamParams'; + +window.onload = function (): void { + const hash = location.hash.replace(/^#!/, ''); + const parsedQuery = querystring.parse(hash); + const action = parsedQuery.action; + if (action === QVHackStreamClient.ACTION && typeof parsedQuery.udid === 'string') { + new QVHackStreamClient(parsedQuery as QVHackStreamParams); + } else { + QVHackClientDeviceTracker.start(); + } +}; diff --git a/src/MotionEvent.ts b/src/app/MotionEvent.ts similarity index 100% rename from src/MotionEvent.ts rename to src/app/MotionEvent.ts diff --git a/src/app/Point.ts b/src/app/Point.ts new file mode 100644 index 00000000..983b1500 --- /dev/null +++ b/src/app/Point.ts @@ -0,0 +1,40 @@ +export interface PointInterface { + x: number; + y: number; +} + +export default class Point { + readonly x: number; + readonly y: number; + constructor(x: number, y: number) { + this.x = Math.round(x); + this.y = Math.round(y); + } + + public equals(o: Point): boolean { + if (this === o) { + return true; + } + if (o === null) { + return false; + } + return this.x === o.x && this.y === o.y; + } + + public distance(to: Point): number { + const x = (this.x - to.x); + const y = (this.y - to.y); + return Math.sqrt(x * x + y * y); + } + + public toString(): string { + return `Point{x=${this.x}, y=${this.y}}`; + } + + public toJSON(): PointInterface { + return { + x: this.x, + y: this.y, + }; + } +} diff --git a/src/app/Position.ts b/src/app/Position.ts new file mode 100644 index 00000000..38459cf1 --- /dev/null +++ b/src/app/Position.ts @@ -0,0 +1,55 @@ +import Point, { PointInterface } from './Point'; +import Size, { SizeInterface } from './Size'; + +export interface PositionInterface { + point: PointInterface; + screenSize: SizeInterface; +} + +export default class Position { + public constructor(readonly point: Point, readonly screenSize: Size) {} + + public equals(o: Position): boolean { + if (this === o) { + return true; + } + if (o === null) { + return false; + } + + return this.point.equals(o.point) && this.screenSize.equals(o.screenSize); + } + + public rotate(rotation: number): Position { + switch (rotation) { + case 1: + return new Position( + new Point(this.screenSize.height - this.point.y, this.point.x), + this.screenSize.rotate(), + ); + case 2: + return new Position( + new Point(this.screenSize.width - this.point.x, this.screenSize.height - this.point.y), + this.screenSize, + ); + case 3: + return new Position( + new Point(this.point.y, this.screenSize.width - this.point.x), + this.screenSize.rotate(), + ); + default: + return this; + } + } + + public toString(): string { + return `Position{point=${this.point}, screenSize=${this.screenSize}}`; + } + + public toJSON(): PositionInterface { + return { + point: this.point.toJSON(), + screenSize: this.screenSize.toJSON(), + }; + } +} diff --git a/src/Rect.ts b/src/app/Rect.ts similarity index 89% rename from src/Rect.ts rename to src/app/Rect.ts index 585f939e..9bcbd21d 100644 --- a/src/Rect.ts +++ b/src/app/Rect.ts @@ -33,6 +33,15 @@ export default class Rect { } return this.left === o.left && this.top === o.top && this.right === o.right && this.bottom === o.bottom; } + + public getWidth(): number { + return this.right - this.left; + } + + public getHeight(): number { + return this.bottom - this.top; + } + public toString(): string { // prettier-ignore return `Rect{left=${ diff --git a/src/ScreenInfo.ts b/src/app/ScreenInfo.ts similarity index 70% rename from src/ScreenInfo.ts rename to src/app/ScreenInfo.ts index c9257f85..e6ae393a 100644 --- a/src/ScreenInfo.ts +++ b/src/app/ScreenInfo.ts @@ -3,7 +3,7 @@ import Size from './Size'; export default class ScreenInfo { public static readonly BUFFER_LENGTH: number = 13; - constructor(readonly contentRect: Rect, readonly videoSize: Size, readonly rotated: boolean) {} + constructor(readonly contentRect: Rect, readonly videoSize: Size, readonly deviceRotation: number) {} public static fromBuffer(buffer: Buffer): ScreenInfo { const left = buffer.readUInt16BE(0); @@ -12,8 +12,8 @@ export default class ScreenInfo { const bottom = buffer.readUInt16BE(6); const width = buffer.readUInt16BE(8); const height = buffer.readUInt16BE(10); - const rotated = !!buffer.readUInt8(12); - return new ScreenInfo(new Rect(left, top, right, bottom), new Size(width, height), rotated); + const deviceRotation = buffer.readUInt8(12); + return new ScreenInfo(new Rect(left, top, right, bottom), new Size(width, height), deviceRotation); } public equals(o?: ScreenInfo | null): boolean { @@ -21,11 +21,13 @@ export default class ScreenInfo { return false; } return ( - this.contentRect.equals(o.contentRect) && this.videoSize.equals(o.videoSize) && this.rotated === o.rotated + this.contentRect.equals(o.contentRect) && + this.videoSize.equals(o.videoSize) && + this.deviceRotation === o.deviceRotation ); } public toString(): string { - return `ScreenInfo{contentRect=${this.contentRect}, videoSize=${this.videoSize}, rotated=${this.rotated}}`; + return `ScreenInfo{contentRect=${this.contentRect}, videoSize=${this.videoSize}, deviceRotation=${this.deviceRotation}}`; } } diff --git a/src/Size.ts b/src/app/Size.ts similarity index 97% rename from src/Size.ts rename to src/app/Size.ts index df26453e..36a8f103 100644 --- a/src/Size.ts +++ b/src/app/Size.ts @@ -1,4 +1,4 @@ -interface SizeInterface { +export interface SizeInterface { width: number; height: number; } diff --git a/src/TouchHandler.ts b/src/app/TouchHandler.ts similarity index 90% rename from src/TouchHandler.ts rename to src/app/TouchHandler.ts index 832f0d12..e6011401 100644 --- a/src/TouchHandler.ts +++ b/src/app/TouchHandler.ts @@ -1,11 +1,11 @@ import MotionEvent from './MotionEvent'; import ScreenInfo from './ScreenInfo'; -import TouchControlEvent from './controlEvent/TouchControlEvent'; +import { TouchControlMessage } from './controlMessage/TouchControlMessage'; import Size from './Size'; import Point from './Point'; import Position from './Position'; -import TouchPointPNG from '../images/multitouch/touch_point.png'; -import CenterPointPNG from '../images/multitouch/center_point.png'; +import TouchPointPNG from '../public/images/multitouch/touch_point.png'; +import CenterPointPNG from '../public/images/multitouch/center_point.png'; interface Touch { action: number; @@ -211,11 +211,14 @@ export default class TouchHandler { ctx.stroke(); } - private static drawLine(ctx: CanvasRenderingContext2D, point1: Point, point2: Point): void { + public static drawLine(ctx: CanvasRenderingContext2D, point1: Point, point2: Point): void { + ctx.save(); + ctx.strokeStyle = this.STROKE_STYLE; ctx.beginPath(); ctx.moveTo(point1.x, point1.y); ctx.lineTo(point2.x, point2.y); ctx.stroke(); + ctx.restore(); } private static drawPoint( @@ -238,6 +241,14 @@ export default class TouchHandler { this.updateDirty(topLeft, bottomRight); } + public static drawPointer(ctx: CanvasRenderingContext2D, point: Point) { + this.drawPoint(ctx, point, this.touchPointRadius, this.touchPointImage); + } + + public static drawCenter(ctx: CanvasRenderingContext2D, point: Point) { + this.drawPoint(ctx, point, this.centerPointRadius, this.centerPointImage); + } + private static updateDirty(topLeft: Point, bottomRight: Point): void { if (!this.dirtyPlace.length) { this.dirtyPlace.push(topLeft, bottomRight); @@ -254,7 +265,7 @@ export default class TouchHandler { this.dirtyPlace.push(newTopLeft, newBottomRight); } - private static clearCanvas(target: HTMLCanvasElement): void { + public static clearCanvas(target: HTMLCanvasElement): void { const { clientWidth, clientHeight } = target; const ctx = target.getContext('2d'); if (ctx && this.dirtyPlace.length) { @@ -273,8 +284,8 @@ export default class TouchHandler { e: TouchEvent, screenInfo: ScreenInfo, tag: HTMLElement, - ): TouchControlEvent[] | null { - const events: TouchControlEvent[] = []; + ): TouchControlMessage[] | null { + const events: TouchControlMessage[] = []; const touches = e.changedTouches; if (touches && touches.length) { for (let i = 0, l = touches.length; i < l; i++) { @@ -294,7 +305,7 @@ export default class TouchHandler { if (event) { const { action, buttons, position } = event.touch; const pressure = touch.force * 255; - events.push(new TouchControlEvent(action, pointerId, position, pressure, buttons)); + events.push(new TouchControlMessage(action, pointerId, position, pressure, buttons)); } else { console.error(`Failed to format touch`, touch); } @@ -308,7 +319,7 @@ export default class TouchHandler { return null; } - public static buildTouchEvent(e: MouseEvent, screenInfo: ScreenInfo): TouchControlEvent[] | null { + public static buildTouchEvent(e: MouseEvent, screenInfo: ScreenInfo): TouchControlMessage[] | null { const touches = this.getTouch(e, screenInfo); if (!touches) { return null; @@ -321,19 +332,19 @@ export default class TouchHandler { ctx.strokeStyle = TouchHandler.STROKE_STYLE; touches.forEach((touch) => { const { point } = touch.position; - this.drawPoint(ctx, point, this.touchPointRadius, this.touchPointImage); + this.drawPointer(ctx, point); if (this.multiTouchCenter) { this.drawLine(ctx, this.multiTouchCenter, point); } }); if (this.multiTouchCenter) { - this.drawPoint(ctx, this.multiTouchCenter, this.centerPointRadius, this.centerPointImage); + this.drawCenter(ctx, this.multiTouchCenter); } } } return touches.map((touch: Touch, pointerId: number) => { const { action, buttons, position } = touch; - return new TouchControlEvent(action, pointerId, position, 255, buttons); + return new TouchControlMessage(action, pointerId, position, 255, buttons); }); } } diff --git a/src/app/TypedEmitter.ts b/src/app/TypedEmitter.ts new file mode 100644 index 00000000..e4fd5d4c --- /dev/null +++ b/src/app/TypedEmitter.ts @@ -0,0 +1,30 @@ +import { EventEmitter } from 'events'; + +type EventMap = Record; +type EventKey = string & keyof T; +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(); + on>(eventName: K, fn: EventReceiver) { + this.emitter.on(eventName, fn); + } + + once>(eventName: K, fn: EventReceiver) { + this.emitter.once(eventName, fn); + } + + off>(eventName: K, fn: EventReceiver) { + this.emitter.off(eventName, fn); + } + + emit>(eventName: K, params: T[K]) { + this.emitter.emit(eventName, params); + } +} diff --git a/src/UIEventsCode.ts b/src/app/UIEventsCode.ts similarity index 100% rename from src/UIEventsCode.ts rename to src/app/UIEventsCode.ts diff --git a/src/Util.ts b/src/app/Util.ts similarity index 100% rename from src/Util.ts rename to src/app/Util.ts diff --git a/src/VideoSettings.ts b/src/app/VideoSettings.ts similarity index 100% rename from src/VideoSettings.ts rename to src/app/VideoSettings.ts diff --git a/src/app/WdaConnection.ts b/src/app/WdaConnection.ts new file mode 100644 index 00000000..018e057e --- /dev/null +++ b/src/app/WdaConnection.ts @@ -0,0 +1,186 @@ +import Point from './Point'; +import Position from './Position'; +import ScreenInfo from './ScreenInfo'; + +interface WdaScreen { + statusBarSize: Point; + scale: number; +} + +export default class WdaConnection { + private screenInfo?: ScreenInfo; + private wdaUrl?: string; + private wdaSessionId?: string; + private wdaScreen?: WdaScreen; + + public setScreenInfo(screenInfo: ScreenInfo): void { + this.screenInfo = screenInfo; + } + + public getScreenInfo(): ScreenInfo | undefined { + return this.screenInfo; + } + + public async wdaPressButton(name: string): Promise { + const sessionId = await this.getOrCreateWdaSessionId(); + if (!sessionId) { + throw Error('No WDA session'); + } + + const response = await fetch(`${this.wdaUrl}/session/${sessionId}/wda/pressButton`, { + method: 'POST', + mode: 'cors', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name }), + }); + const json = await response.json(); + if (json.value && json.value.error === 'invalid session id') { + this.wdaSessionId = ''; + return this.wdaPressButton(name); + } + } + + public async wdaPerformClick(position: Position): Promise { + const sessionId = await this.getOrCreateWdaSessionId(); + if (!sessionId) { + throw Error('No WDA session'); + } + const point = await this.getPhysicalPoint(position); + if (!point) { + return; + } + const response = await fetch(`${this.wdaUrl}/session/${sessionId}/wda/touch/perform`, { + method: 'POST', + mode: 'cors', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ actions: [{ action: 'tap', options: { x: point.x, y: point.y } }] }), + }); + const json = await response.json(); + if (json.value && json.value.error === 'invalid session id') { + this.wdaSessionId = ''; + return this.wdaPerformClick(position); + } + } + + public async wdaPerformScroll(from: Position, to: Position): Promise { + const sessionId = await this.getOrCreateWdaSessionId(); + if (!sessionId) { + throw Error('No WDA session'); + } + const fromPoint = await this.getPhysicalPoint(from); + const toPoint = await this.getPhysicalPoint(to); + if (!fromPoint || !toPoint) { + return; + } + const response = await fetch(`${this.wdaUrl}/session/${sessionId}/wda/touch/perform`, { + method: 'POST', + mode: 'cors', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + actions: [ + { action: 'press', options: { x: fromPoint.x, y: fromPoint.y } }, + { action: 'wait', options: { ms: 500 } }, + { action: 'moveTo', options: { x: toPoint.x, y: toPoint.y } }, + { action: 'release', options: {} }, + ], + }), + }); + const json = await response.json(); + if (json.value && json.value.error === 'invalid session id') { + this.wdaSessionId = ''; + return this.wdaPerformScroll(from, to); + } + } + + private async getWdaScreen(): Promise { + if (this.wdaScreen) { + return this.wdaScreen; + } + const sessionId = await this.getOrCreateWdaSessionId(); + if (!sessionId) { + throw Error('No WDA session'); + } + const response = await fetch(`${this.wdaUrl}/session/${sessionId}/wda/screen`, { + method: 'GET', + mode: 'cors', + }); + const json = await response.json(); + const { value } = json; + const { width, height } = value['statusBarSize']; + this.wdaScreen = { + scale: value['scale'], + statusBarSize: new Point(width, height), + }; + return this.wdaScreen; + } + + private async getOrCreateWdaSessionId(): Promise { + if (this.wdaSessionId) { + return this.wdaSessionId; + } + if (!this.wdaUrl) { + throw Error('No url'); + } + let response = await fetch(`${this.wdaUrl}/status`); + let json = await response.json(); + if (typeof json.sessionId === 'string' && json.sessionId !== 'null') { + this.wdaSessionId = json.sessionId as string; + return this.wdaSessionId; + } + response = await fetch(`${this.wdaUrl}/session`, { + method: 'POST', + mode: 'cors', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ capabilities: { platformName: 'iOS' } }), + }); + json = await response.json(); + if (typeof json.sessionId === 'string' && json.sessionId !== 'null') { + this.wdaSessionId = json.sessionId as string; + return this.wdaSessionId; + } + return ''; + } + + public async getPhysicalPoint(position: Position): Promise { + if (!this.screenInfo) { + return; + } + + let wdaScreen = this.wdaScreen; + if (!wdaScreen) { + wdaScreen = await this.getWdaScreen(); + } + const { scale } = wdaScreen; + // ignore the locked video orientation, the events will apply in coordinates considered in the physical device orientation + const { videoSize, deviceRotation, contentRect } = this.screenInfo; + + // reverse the video rotation to apply the events + const devicePosition = position.rotate(deviceRotation); + + if (!videoSize.equals(devicePosition.screenSize)) { + // The client sends a click relative to a video with wrong dimensions, + // the device may have been rotated since the event was generated, so ignore the event + return; + } + const { point } = devicePosition; + const convertedX = contentRect.left + (point.x * contentRect.getWidth()) / videoSize.width; + const convertedY = contentRect.top + (point.y * contentRect.getHeight()) / videoSize.height; + + const scaledX = Math.round(convertedX / scale); + const scaledY = Math.round(convertedY / scale); + + return new Point(scaledX, scaledY); + } + + public setUrl(url: string) { + this.wdaUrl = url; + } +} diff --git a/src/android/KeyEvent.ts b/src/app/android/KeyEvent.ts similarity index 100% rename from src/android/KeyEvent.ts rename to src/app/android/KeyEvent.ts diff --git a/src/android/MediaFormat.ts b/src/app/android/MediaFormat.ts similarity index 100% rename from src/android/MediaFormat.ts rename to src/app/android/MediaFormat.ts diff --git a/src/client/BaseClient.ts b/src/app/client/BaseClient.ts similarity index 81% rename from src/client/BaseClient.ts rename to src/app/client/BaseClient.ts index 9191384e..aa86217d 100644 --- a/src/client/BaseClient.ts +++ b/src/app/client/BaseClient.ts @@ -1,4 +1,6 @@ -export class BaseClient { +import { TypedEmitter } from '../TypedEmitter'; + +export class BaseClient extends TypedEmitter { public setTitle(text: string): void { let titleTag: HTMLTitleElement | null = document.querySelector('head > title'); if (!titleTag) { diff --git a/src/app/client/DeviceTrackerClient.ts b/src/app/client/DeviceTrackerClient.ts new file mode 100644 index 00000000..8c55fcaf --- /dev/null +++ b/src/app/client/DeviceTrackerClient.ts @@ -0,0 +1,106 @@ +import * as querystring from 'querystring'; +import { ManagerClient } from './ManagerClient'; +import { Message } from '../../common/Message'; +import DroidDeviceDescriptor from '../../common/DroidDeviceDescriptor'; +import { ShellParams } from '../../common/ShellParams'; +import { ScrcpyStreamParams } from '../../common/ScrcpyStreamParams'; +import QVHackDeviceDescriptor from '../../common/QVHackDeviceDescriptor'; +import { QVHackStreamParams } from '../../common/QVHackStreamParams'; + +export type MapItem = { + field?: keyof T; + title: string; +}; + +export abstract class DeviceTrackerClient< + T extends DroidDeviceDescriptor | QVHackDeviceDescriptor, + K +> extends ManagerClient { + public static ACTION = 'devicelist'; + protected tableId = 'droid_device_list'; + + protected constructor(action: string, protected rows: MapItem[]) { + super(action); + this.setBodyClass('list'); + this.setTitle('Device list'); + this.openNewWebSocket(); + } + + protected abstract buildDeviceTable(data: T[]): void; + + protected onSocketClose(e: CloseEvent): void { + console.log(`Connection closed: ${e.reason}`); + setTimeout(() => { + this.openNewWebSocket(); + }, 2000); + } + + protected onSocketMessage(e: MessageEvent): void { + let message: Message; + try { + message = JSON.parse(e.data); + } catch (error) { + console.error(error.message); + console.log(e.data); + return; + } + if (message.type !== DeviceTrackerClient.ACTION) { + console.log(`Unknown message type: ${message.type}`); + return; + } + const list: T[] = message.data as T[]; + this.buildDeviceTable(list); + } + + protected getOrCreateTableHolder(): HTMLElement { + let devices = document.getElementById('devices'); + if (!devices) { + devices = document.createElement('div'); + devices.id = 'devices'; + devices.className = 'table-wrapper'; + document.body.appendChild(devices); + } + return devices; + } + + protected getOrBuildTableBody(parent: HTMLElement): Element { + let tbody = document.querySelector(`#devices table#${this.tableId} tbody`) as Element; + if (!tbody) { + const table = document.createElement('table'); + const thead = document.createElement('thead'); + const headRow = document.createElement('tr'); + this.rows.forEach((item) => { + const { title } = item; + const td = document.createElement('th'); + td.innerText = title; + td.className = title.toLowerCase(); + headRow.appendChild(td); + }); + thead.appendChild(headRow); + table.appendChild(thead); + tbody = document.createElement('tbody'); + table.id = this.tableId; + table.appendChild(tbody); + table.setAttribute('width', '100%'); + parent.appendChild(table); + } else { + while (tbody.children.length) { + tbody.removeChild(tbody.children[0]); + } + } + return tbody; + } + + protected static buildLink( + q: ScrcpyStreamParams | ShellParams | QVHackStreamParams, + text: string, + ): HTMLAnchorElement { + const hash = `#!${querystring.encode(q)}`; + const a = document.createElement('a'); + a.setAttribute('href', `${location.origin}${location.pathname}${hash}`); + a.setAttribute('rel', 'noopener noreferrer'); + a.setAttribute('target', '_blank'); + a.innerText = text; + return a; + } +} diff --git a/src/app/client/DroidDeviceTrackerClient.ts b/src/app/client/DroidDeviceTrackerClient.ts new file mode 100644 index 00000000..177eca17 --- /dev/null +++ b/src/app/client/DroidDeviceTrackerClient.ts @@ -0,0 +1,131 @@ +import { DeviceTrackerClient, MapItem } from './DeviceTrackerClient'; +import { ACTION, SERVER_PORT } from '../../server/Constants'; +import DroidDeviceDescriptor from '../../common/DroidDeviceDescriptor'; + +const FIELDS_MAP: MapItem[] = [ + { + field: 'product.manufacturer', + title: 'Manufacturer', + }, + { + field: 'product.model', + title: 'Model', + }, + { + field: 'build.version.release', + title: 'Release', + }, + { + field: 'build.version.sdk', + title: 'SDK', + }, + { + field: 'udid', + title: 'Serial', + }, + { + field: 'state', + title: 'State', + }, + { + field: 'ip', + title: 'Wi-Fi IP', + }, + { + field: 'pid', + title: 'Pid', + }, + { + title: 'Broadway', + }, + { + title: 'MSE', + }, + { + title: 'tinyh264', + }, + { + title: 'Shell', + }, +]; + +type Decoders = 'broadway' | 'mse' | 'tinyh264'; + +const DECODERS: Decoders[] = ['broadway', 'mse', 'tinyh264']; + +export class DroidDeviceTrackerClient extends DeviceTrackerClient { + public static ACTION = ACTION.DEVICE_LIST; + + public static start(): DroidDeviceTrackerClient { + return new DroidDeviceTrackerClient(this.ACTION); + } + + constructor(action: string) { + super(action, FIELDS_MAP); + } + + protected onSocketOpen(): void { + // if (this.hasConnection()) { + // this.ws.send(JSON.stringify({ command: 'list' })); + // } + } + + protected buildDeviceTable(data: DroidDeviceDescriptor[]): void { + const devices = this.getOrCreateTableHolder(); + const tbody = this.getOrBuildTableBody(devices); + + data.forEach((device) => { + const row = document.createElement('tr'); + let hasPid = false; + let hasIp = false; + this.rows.forEach((item) => { + if (item.field) { + const value = '' + device[item.field]; + const td = document.createElement('td'); + td.innerText = value; + row.appendChild(td); + if (item.field === 'pid') { + hasPid = value !== '-1'; + } else if (item.field === 'ip') { + hasIp = !value.includes('['); + } + } + }); + const isActive = device.state === 'device'; + DECODERS.forEach((decoderName) => { + const decoderTd = document.createElement('td'); + if (isActive) { + if (hasIp && hasPid) { + const link = DeviceTrackerClient.buildLink( + { + action: 'stream', + udid: device.udid, + decoder: decoderName, + ip: device.ip, + port: SERVER_PORT.toString(10), + }, + 'stream', + ); + decoderTd.appendChild(link); + } + } + row.appendChild(decoderTd); + }); + + const shellTd = document.createElement('td'); + if (isActive) { + shellTd.appendChild( + DeviceTrackerClient.buildLink( + { + action: 'shell', + udid: device.udid, + }, + 'shell', + ), + ); + } + row.appendChild(shellTd); + tbody.appendChild(row); + }); + } +} diff --git a/src/client/NodeClient.ts b/src/app/client/ManagerClient.ts similarity index 58% rename from src/client/NodeClient.ts rename to src/app/client/ManagerClient.ts index 92e7d81f..e9568596 100644 --- a/src/client/NodeClient.ts +++ b/src/app/client/ManagerClient.ts @@ -1,6 +1,6 @@ import { BaseClient } from './BaseClient'; -export abstract class NodeClient extends BaseClient { +export abstract class ManagerClient extends BaseClient { public static ACTION = 'unknown'; // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars @@ -8,18 +8,22 @@ export abstract class NodeClient extends BaseClient { throw Error('Not implemented'); } - protected ws: WebSocket; + protected ws?: WebSocket; - protected constructor(protected readonly action: string) { + protected constructor(protected readonly action?: string) { super(); - this.ws = this.openNewWebSocket(); + } + + public hasConnection(): boolean { + return !!(this.ws && this.ws.readyState === this.ws.OPEN); } protected openNewWebSocket(): WebSocket { - if (this.ws && this.ws.readyState === this.ws.OPEN) { - this.ws.close(); + if (this.hasConnection()) { + (this.ws as WebSocket).close(); } this.ws = new WebSocket(this.buildWebSocketUrl()); + this.ws.onopen = this.onSocketOpen.bind(this); this.ws.onmessage = this.onSocketMessage.bind(this); this.ws.onclose = this.onSocketClose.bind(this); return this.ws; @@ -27,9 +31,11 @@ export abstract class NodeClient extends BaseClient { protected buildWebSocketUrl(): string { const proto = location.protocol === 'https:' ? 'wss' : 'ws'; - return `${proto}://${location.host}/?action=${this.action}`; + const query = this.action ? `/?action=${this.action}` : ''; + return `${proto}://${location.host}${query}`; } + protected abstract onSocketOpen(e: Event): void; protected abstract onSocketMessage(e: MessageEvent): void; protected abstract onSocketClose(e: CloseEvent): void; } diff --git a/src/app/client/QVHackClientDeviceTracker.ts b/src/app/client/QVHackClientDeviceTracker.ts new file mode 100644 index 00000000..d9bcd467 --- /dev/null +++ b/src/app/client/QVHackClientDeviceTracker.ts @@ -0,0 +1,107 @@ +import { DeviceTrackerClient, MapItem } from './DeviceTrackerClient'; +import QVHackDeviceDescriptor from '../../common/QVHackDeviceDescriptor'; +import { QVHackStreamClient } from './QVHackStreamClient'; + +const SERVER_PORT = 8080; +const SERVER_HOST = location.hostname; + +const FIELDS_MAP: MapItem[] = [ + { + field: 'ProductName', + title: 'Device Name', + }, + { + field: 'ProductType', + title: 'Type', + }, + { + field: 'ProductVersion', + title: 'Version', + }, + { + field: 'Udid', + title: 'UDID', + }, + { + title: 'Stream', + }, +]; + +export class QVHackClientDeviceTracker extends DeviceTrackerClient { + public static ACTION = 'devicelist'; + protected tableId = 'qvhack_devices_list'; + public static start(): QVHackClientDeviceTracker { + return new QVHackClientDeviceTracker(QVHackClientDeviceTracker.ACTION); + } + + constructor(action: string) { + super(action, FIELDS_MAP); + this.setBodyClass('list'); + this.setTitle('Device list'); + } + + protected onSocketOpen(): void { + if (this.hasConnection()) { + (this.ws as WebSocket).send(JSON.stringify({ command: 'list' })); + } + } + + protected onSocketClose(e: CloseEvent): void { + console.log(`Connection closed: ${e.reason}`); + setTimeout(() => { + this.openNewWebSocket(); + }, 2000); + } + + protected onSocketMessage(e: MessageEvent): void { + new Response(e.data) + .text() + .then((text: string) => { + const list: QVHackDeviceDescriptor[] = JSON.parse(text) as QVHackDeviceDescriptor[]; + this.buildDeviceTable(list); + }) + .catch((error: Error) => { + console.error(error.message); + console.log(e.data); + }); + } + + public buildDeviceTable(data: QVHackDeviceDescriptor[]): void { + const devices = this.getOrCreateTableHolder(); + const tbody = this.getOrBuildTableBody(devices); + + data.forEach((device) => { + const row = document.createElement('tr'); + FIELDS_MAP.forEach((item) => { + if (item.field) { + const value = device[item.field].toString(); + const td = document.createElement('td'); + td.innerText = value; + row.appendChild(td); + } + }); + const decoderTd = document.createElement('td'); + const link = QVHackClientDeviceTracker.buildLink( + { + action: QVHackStreamClient.ACTION, + udid: device.Udid, + ip: SERVER_HOST, + port: SERVER_PORT.toString(10), + }, + 'stream', + ); + decoderTd.appendChild(link); + row.appendChild(decoderTd); + + tbody.appendChild(row); + }); + } + + protected buildWebSocketUrl(): string { + const proto = location.protocol === 'https:' ? 'wss' : 'ws'; + const host = SERVER_HOST; + const port = SERVER_PORT; + const path = '/ws'; + return `${proto}://${host}:${port}${path}`; + } +} diff --git a/src/app/client/QVHackStreamClient.ts b/src/app/client/QVHackStreamClient.ts new file mode 100644 index 00000000..0c6d786a --- /dev/null +++ b/src/app/client/QVHackStreamClient.ts @@ -0,0 +1,221 @@ +import { BaseClient } from './BaseClient'; +import { QVHackStreamParams } from '../../common/QVHackStreamParams'; +import { Mse4QVHackDecoder } from '../decoder/Mse4QVHackDecoder'; +import { QVHackMoreBox } from '../toolbox/QVHackMoreBox'; +import { QVHackToolBox } from '../toolbox/QVHackToolBox'; +import WdaConnection from '../WdaConnection'; +import { WsQVHackClient } from './WsQVHackClient'; +import Decoder, { VideoResizeListener } from '../decoder/Decoder'; +import Size from '../Size'; +import ScreenInfo from '../ScreenInfo'; +import { StreamReceiver } from './StreamReceiver'; +import TouchHandler from '../TouchHandler'; +import Position from '../Position'; + +const ACTION = 'stream-qvh'; +const PORT = 8080; +const WAIT_CLASS = 'wait'; + +export class QVHackStreamClient extends BaseClient implements VideoResizeListener { + public static ACTION: QVHackStreamParams['action'] = ACTION; + private hasTouchListeners = false; + private deviceName = ''; + private managerClient = new WsQVHackClient(); + private wdaConnection = new WdaConnection(); + private readonly udid: string; + private wdaUrl?: string; + private readonly streamReceiver: StreamReceiver; + private videoWrapper?: HTMLElement; + + constructor(params: QVHackStreamParams) { + super(); + let udid = (this.udid = params.udid); + + // Workaround for qvh v0.5-beta + if (udid.indexOf('-') !== -1) { + udid = udid.replace('-', ''); + udid = encodeURIComponent(udid) + '%00'.repeat(16); + } else { + udid = encodeURIComponent(udid); + } + this.streamReceiver = new StreamReceiver(location.hostname, PORT, '/ws', `?stream=${udid}`); + this.startStream(params.udid, `ws://${params.ip}:${params.port}/ws?stream=${udid}`); + this.setBodyClass('stream'); + this.setTitle(`${params.udid} stream`); + } + + async onViewVideoResize(): Promise { + this.runWebDriverAgent(); + } + onInputVideoResize(screenInfo: ScreenInfo): void { + this.wdaConnection.setScreenInfo(screenInfo); + } + + private runWebDriverAgent() { + if (typeof this.wdaUrl === 'string') { + return; + } + + this.wdaUrl = ''; + this.managerClient.runWebDriverAgent(this.udid).then((response) => { + const data = response.data; + if (data.code === 0) { + const url = data.text; + this.wdaUrl = url; + this.wdaConnection.setUrl(url); + } else { + console.error(`Failed to run WebDriverAgent. Reason: ${data.text}, code: ${data.code}`); + } + }).finally(() => { + this.videoWrapper?.classList.remove(WAIT_CLASS); + }) + } + + private startStream(udid: string, url: string) { + const decoder = new Mse4QVHackDecoder(udid, Mse4QVHackDecoder.createElement(`qvh_video`)); + this.setTouchListeners(decoder); + decoder.pause(); + + const deviceView = document.createElement('div'); + deviceView.className = 'device-view'; + const stop = (ev?: string | Event) => { + if (ev && ev instanceof Event && ev.type === 'error') { + console.error(ev); + } + let parent; + parent = deviceView.parentElement; + if (parent) { + parent.removeChild(deviceView); + } + parent = moreBox.parentElement; + if (parent) { + parent.removeChild(moreBox); + } + this.streamReceiver.stop(); + this.managerClient.stop(); + decoder.stop(); + }; + + const qvhackMoreBox = new QVHackMoreBox(udid, decoder); + qvhackMoreBox.setOnStop(stop); + const moreBox = qvhackMoreBox.getHolderElement(); + const qvhackToolBox = QVHackToolBox.createToolBox(udid, decoder, this, this.wdaConnection, moreBox); + const controlButtons = qvhackToolBox.getHolderElement(); + deviceView.appendChild(controlButtons); + const video = document.createElement('div'); + video.className = `video ${WAIT_CLASS}`; + deviceView.appendChild(video); + deviceView.appendChild(moreBox); + decoder.setParent(video); + decoder.addResizeListener(this); + this.videoWrapper = video; + const bounds = QVHackStreamClient.getMaxSize(controlButtons); + if (bounds) { + decoder.setBounds(bounds); + } + + document.body.appendChild(deviceView); + this.streamReceiver.on('video', (data) => { + const STATE = Decoder.STATE; + if (decoder.getState() === STATE.PAUSED) { + decoder.play(); + } + if (decoder.getState() === STATE.PLAYING) { + decoder.pushFrame(new Uint8Array(data)); + } + }); + console.log(decoder.getName(), udid, url); + } + + private static getMaxSize(controlButtons: HTMLElement): Size | undefined { + if (!controlButtons) { + return; + } + const body = document.body; + const width = (body.clientWidth - controlButtons.clientWidth) & ~15; + const height = body.clientHeight & ~15; + return new Size(width, height); + } + + public getDeviceName(): string { + return this.deviceName; + } + + private setTouchListeners(decoder: Decoder): void { + if (!this.hasTouchListeners) { + TouchHandler.init(); + let down = 0; + // const supportsPassive = Util.supportsPassive(); + let startPosition: Position | undefined; + let endPosition: Position | undefined; + const onMouseEvent = (e: MouseEvent) => { + let handled = false; + const tag = decoder.getTouchableElement(); + + if (e.target === tag) { + const screenInfo: ScreenInfo = decoder.getScreenInfo() as ScreenInfo; + if (!screenInfo) { + return; + } + handled = true; + const events = TouchHandler.buildTouchEvent(e, screenInfo); + if (down === 1 && events?.length === 1) { + if (e.type === 'mousedown') { + startPosition = events[0].position; + } else { + endPosition = events[0].position; + } + const target = e.target as HTMLCanvasElement; + const ctx = target.getContext('2d'); + if (ctx) { + if (startPosition) { + TouchHandler.drawPointer(ctx, startPosition.point); + } + if (endPosition) { + TouchHandler.drawPointer(ctx, endPosition.point); + if (startPosition) { + TouchHandler.drawLine(ctx, startPosition.point, endPosition.point); + } + } + } + if (e.type === 'mouseup') { + if (startPosition && endPosition) { + TouchHandler.clearCanvas(target); + if (startPosition.point.distance(endPosition.point) < 10) { + this.wdaConnection.wdaPerformClick(endPosition); + } else { + this.wdaConnection.wdaPerformScroll(startPosition, endPosition); + } + } + } + } + if (handled) { + if (e.cancelable) { + e.preventDefault(); + } + e.stopPropagation(); + } + } + if (e.type === 'mouseup') { + startPosition = undefined; + endPosition = undefined; + } + }; + document.body.addEventListener('click', (e: MouseEvent): void => { + onMouseEvent(e); + }); + document.body.addEventListener('mousedown', (e: MouseEvent): void => { + down++; + onMouseEvent(e); + }); + document.body.addEventListener('mouseup', (e: MouseEvent): void => { + onMouseEvent(e); + down--; + }); + document.body.addEventListener('mousemove', (e: MouseEvent): void => { + onMouseEvent(e); + }); + this.hasTouchListeners = true; + } + } +} diff --git a/src/app/client/ScrcpyClient.ts b/src/app/client/ScrcpyClient.ts new file mode 100644 index 00000000..6372496f --- /dev/null +++ b/src/app/client/ScrcpyClient.ts @@ -0,0 +1,290 @@ +import MseDecoder from '../decoder/MseDecoder'; +import BroadwayDecoder from '../decoder/BroadwayDecoder'; +import { BaseClient } from './BaseClient'; +import Decoder from '../decoder/Decoder'; +import Tinyh264Decoder from '../decoder/Tinyh264Decoder'; +import { Decoders } from '../../common/Decoders'; +import { ScrcpyStreamParams } from '../../common/ScrcpyStreamParams'; +import { DroidMoreBox } from '../toolbox/DroidMoreBox'; +import { DroidToolBox } from '../toolbox/DroidToolBox'; +import VideoSettings from '../VideoSettings'; +import Size from '../Size'; +import { ControlMessage } from '../controlMessage/ControlMessage'; +import { StreamReceiver } from './StreamReceiver'; +import { CommandControlMessage } from '../controlMessage/CommandControlMessage'; +import TouchHandler from '../TouchHandler'; +import Util from '../Util'; +import ScreenInfo from '../ScreenInfo'; +import { TouchControlMessage } from '../controlMessage/TouchControlMessage'; +import FilePushHandler from '../FilePushHandler'; +import DragAndPushLogger from '../DragAndPushLogger'; +import { KeyEventListener, KeyInputHandler } from '../KeyInputHandler'; +import { KeyCodeControlMessage } from '../controlMessage/KeyCodeControlMessage'; + +export class ScrcpyClient extends BaseClient implements KeyEventListener { + public static ACTION = 'stream'; + private hasTouchListeners = false; + + private controlButtons?: HTMLElement; + private deviceName = ''; + private clientId = -1; + private clientsCount = -1; + private requestedVideoSettings?: VideoSettings; + private readonly streamReceiver: StreamReceiver; + + constructor(params: ScrcpyStreamParams) { + super(); + + this.streamReceiver = new StreamReceiver(params.ip, params.port); + this.startStream(params.udid, params.decoder, `ws://${params.ip}:${params.port}`); + this.setBodyClass('stream'); + this.setTitle(`${params.udid} stream`); + } + + public startStream(udid: string, decoderName: Decoders, url: string): void { + if (!url || !udid) { + return; + } + let decoderClass: new (udid: string) => Decoder; + switch (decoderName) { + case 'mse': + decoderClass = MseDecoder; + break; + case 'broadway': + decoderClass = BroadwayDecoder; + break; + case 'tinyh264': + decoderClass = Tinyh264Decoder; + break; + default: + return; + } + const decoder = new decoderClass(udid); + this.setTouchListeners(decoder); + + const deviceView = document.createElement('div'); + deviceView.className = 'device-view'; + const stop = (ev?: string | Event) => { + if (ev && ev instanceof Event && ev.type === 'error') { + console.error(ev); + } + let parent; + parent = deviceView.parentElement; + if (parent) { + parent.removeChild(deviceView); + } + parent = moreBox.parentElement; + if (parent) { + parent.removeChild(moreBox); + } + this.streamReceiver.stop(); + decoder.stop(); + }; + + const droidMoreBox = new DroidMoreBox(udid, decoder, this); + const moreBox = droidMoreBox.getHolderElement(); + droidMoreBox.setOnStop(stop); + const droidToolBox = DroidToolBox.createToolBox(udid, decoder, this, moreBox); + this.controlButtons = droidToolBox.getHolderElement(); + deviceView.appendChild(this.controlButtons); + const video = document.createElement('div'); + video.className = 'video'; + deviceView.appendChild(video); + deviceView.appendChild(moreBox); + decoder.setParent(video); + decoder.pause(); + + document.body.appendChild(deviceView); + const current = decoder.getVideoSettings(); + if (decoder.getPreferredVideoSetting().equals(current)) { + const bounds = this.getMaxSize(); + const { bitrate, maxFps, iFrameInterval, lockedVideoOrientation, sendFrameMeta } = current; + const newVideoSettings = new VideoSettings({ + bounds, + bitrate, + maxFps, + iFrameInterval, + lockedVideoOrientation, + sendFrameMeta, + }); + decoder.setVideoSettings(newVideoSettings, false); + } + const element = decoder.getTouchableElement(); + const handler = new FilePushHandler(element, this.streamReceiver); + const logger = new DragAndPushLogger(element); + handler.addEventListener(logger); + // this.filePushHandlers.set(decoder, handler); + + const streamReceiver = this.streamReceiver; + streamReceiver.on('deviceMessage', (message) => { + droidMoreBox.OnDeviceMessage(message); + }); + streamReceiver.on('video', (data) => { + const STATE = Decoder.STATE; + if (decoder.getState() === STATE.PAUSED) { + decoder.play(); + } + if (decoder.getState() === STATE.PLAYING) { + decoder.pushFrame(new Uint8Array(data)); + } + }); + streamReceiver.on('clientsStats', (stats) => { + this.deviceName = stats.deviceName; + this.clientId = stats.clientId; + this.clientsCount = stats.clientsCount; + }); + streamReceiver.on('videoParameters', ({ screenInfo, videoSettings }) => { + let min: VideoSettings = VideoSettings.copy(videoSettings) as VideoSettings; + let playing = false; + const STATE = Decoder.STATE; + if (decoder.getState() === STATE.PAUSED) { + decoder.play(); + } + if (decoder.getState() === STATE.PLAYING) { + playing = true; + } + const oldInfo = decoder.getScreenInfo(); + if (!screenInfo.equals(oldInfo)) { + decoder.setScreenInfo(screenInfo); + } + + const oldSettings = decoder.getVideoSettings(); + if (!videoSettings.equals(oldSettings)) { + decoder.setVideoSettings(videoSettings, videoSettings.equals(this.requestedVideoSettings)); + } + if (!oldInfo) { + const bounds = oldSettings.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 = oldSettings; + } + } + if (!min.equals(videoSettings) || !playing) { + this.sendNewVideoSetting(min); + } + }); + console.log(decoder.getName(), udid, url); + } + + public sendEvent(e: ControlMessage): void { + this.streamReceiver.sendEvent(e); + } + + 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.sendEvent(event); + } + + public sendNewVideoSetting(videoSettings: VideoSettings): void { + this.requestedVideoSettings = videoSettings; + this.sendEvent(CommandControlMessage.createSetVideoSettingsCommand(videoSettings)); + } + + public getClientId(): number { + return this.clientId; + } + + public getClientsCount(): number { + return this.clientsCount; + } + + private 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(decoder: Decoder): void { + if (!this.hasTouchListeners) { + TouchHandler.init(); + let down = 0; + const supportsPassive = Util.supportsPassive(); + const onMouseEvent = (e: MouseEvent | TouchEvent) => { + const tag = decoder.getTouchableElement(); + if (e.target === tag) { + const screenInfo: ScreenInfo = decoder.getScreenInfo() as ScreenInfo; + if (!screenInfo) { + return; + } + let events: TouchControlMessage[] | null = null; + let condition = true; + if (e instanceof MouseEvent) { + condition = down > 0; + events = TouchHandler.buildTouchEvent(e, screenInfo); + } else if (e instanceof TouchEvent) { + events = TouchHandler.formatTouchEvent(e, screenInfo, tag); + } + if (events && events.length && condition) { + events.forEach((event) => { + this.sendEvent(event); + }); + } + if (e.cancelable) { + e.preventDefault(); + } + e.stopPropagation(); + } + }; + + const options = supportsPassive ? { passive: false } : false; + document.body.addEventListener( + 'touchstart', + (e: TouchEvent): void => { + onMouseEvent(e); + }, + options, + ); + document.body.addEventListener( + 'touchend', + (e: TouchEvent): void => { + onMouseEvent(e); + }, + options, + ); + document.body.addEventListener( + 'touchmove', + (e: TouchEvent): void => { + onMouseEvent(e); + }, + options, + ); + document.body.addEventListener( + 'touchcancel', + (e: TouchEvent): void => { + onMouseEvent(e); + }, + options, + ); + document.body.addEventListener('mousedown', (e: MouseEvent): void => { + down++; + onMouseEvent(e); + }); + document.body.addEventListener('mouseup', (e: MouseEvent): void => { + onMouseEvent(e); + down--; + }); + document.body.addEventListener('mousemove', (e: MouseEvent): void => { + onMouseEvent(e); + }); + this.hasTouchListeners = true; + } + } +} diff --git a/src/client/ClientShell.ts b/src/app/client/ShellClient.ts similarity index 77% rename from src/client/ClientShell.ts rename to src/app/client/ShellClient.ts index 3a468866..fe11c711 100644 --- a/src/client/ClientShell.ts +++ b/src/app/client/ShellClient.ts @@ -1,19 +1,16 @@ -import { NodeClient } from './NodeClient'; +import 'xterm/css/xterm.css'; +import { ManagerClient } from './ManagerClient'; import { Terminal } from 'xterm'; import { AttachAddon } from 'xterm-addon-attach'; import { FitAddon } from 'xterm-addon-fit'; -import { ParsedUrlQueryInput } from 'querystring'; -import { Message } from '../common/Message'; +import { MessageXtermClient } from '../../common/MessageXtermClient'; +import { ACTION } from '../../server/Constants'; +import { ShellParams } from '../../common/ShellParams'; -export interface ShellParams extends ParsedUrlQueryInput { - action: 'shell'; - udid: string; -} - -export class ClientShell extends NodeClient { - public static ACTION = 'shell'; - public static start(params: ShellParams): ClientShell { - return new ClientShell(params.action, params.udid); +export class ShellClient extends ManagerClient { + public static ACTION = ACTION.SHELL; + public static start(params: ShellParams): ShellClient { + return new ShellClient(params.action, params.udid); } private readonly term: Terminal; private readonly fitAddon: FitAddon; @@ -21,15 +18,16 @@ export class ClientShell extends NodeClient { constructor(action: string, private readonly udid: string) { super(action); - this.ws.onopen = this.onSocketOpen.bind(this); + this.openNewWebSocket(); + const ws = this.ws as WebSocket; this.setTitle(`Shell ${udid}`); this.setBodyClass('shell'); this.term = new Terminal(); - this.term.loadAddon(new AttachAddon(this.ws)); + this.term.loadAddon(new AttachAddon(ws)); this.fitAddon = new FitAddon(); this.term.loadAddon(this.fitAddon); this.escapedUdid = this.escapeUdid(udid); - this.term.open(ClientShell.getOrCreateContainer(this.escapedUdid)); + this.term.open(ShellClient.getOrCreateContainer(this.escapedUdid)); this.updateTerminalSize(); } @@ -51,7 +49,7 @@ export class ClientShell extends NodeClient { return; } const { rows, cols } = this.fitAddon.proposeDimensions(); - const message: Message = { + const message: MessageXtermClient = { id: 1, type: 'shell', data: { @@ -83,7 +81,7 @@ export class ClientShell extends NodeClient { private updateTerminalSize(): void { // eslint-disable-next-line @typescript-eslint/no-explicit-any const term: any = this.term; - const terminalContainer: HTMLElement = ClientShell.getOrCreateContainer(this.escapedUdid); + 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( diff --git a/src/app/client/StreamReceiver.ts b/src/app/client/StreamReceiver.ts new file mode 100644 index 00000000..59867ed9 --- /dev/null +++ b/src/app/client/StreamReceiver.ts @@ -0,0 +1,134 @@ +import { ManagerClient } from './ManagerClient'; +import { ControlMessage } from '../controlMessage/ControlMessage'; +import DeviceMessage from '../DeviceMessage'; +import VideoSettings from '../VideoSettings'; +import ScreenInfo from '../ScreenInfo'; +import Util from '../Util'; + +const DEVICE_NAME_FIELD_LENGTH = 64; +const MAGIC = 'scrcpy'; +const MAGIC_BYTES = Util.stringToUtf8ByteArray(MAGIC); +const CLIENT_ID_LENGTH = 2; +const CLIENTS_COUNT_LENGTH = 2; +const DEVICE_INFO_LENGTH = + MAGIC.length + + DEVICE_NAME_FIELD_LENGTH + + ScreenInfo.BUFFER_LENGTH + + VideoSettings.BUFFER_LENGTH + + CLIENT_ID_LENGTH + + CLIENTS_COUNT_LENGTH; + +interface StreamReceiverEvents { + video: ArrayBuffer; + deviceMessage: DeviceMessage; + videoParameters: { + videoSettings: VideoSettings; + screenInfo: ScreenInfo; + }; + clientsStats: { + deviceName: string; + clientId: number; + clientsCount: number; + }; +} + +export class StreamReceiver extends ManagerClient { + private events: ControlMessage[] = []; + + constructor( + private readonly host: string, + private readonly port: number | string, + private readonly path = '/', + private readonly query = '', + ) { + super(); + this.openNewWebSocket(); + (this.ws as WebSocket).binaryType = 'arraybuffer'; + } + + private handleDeviceInfo(data: ArrayBuffer): void { + let offset = MAGIC.length; + let nameBytes = new Uint8Array(data, offset, DEVICE_NAME_FIELD_LENGTH); + nameBytes = Util.filterTrailingZeroes(nameBytes); + const deviceName = Util.utf8ByteArrayToString(nameBytes); + offset += DEVICE_NAME_FIELD_LENGTH; + let temp = new Buffer(new Uint8Array(data, offset, ScreenInfo.BUFFER_LENGTH)); + offset += ScreenInfo.BUFFER_LENGTH; + const screenInfo = ScreenInfo.fromBuffer(temp); + temp = new Buffer(new Uint8Array(data, offset, VideoSettings.BUFFER_LENGTH)); + const videoSettings = VideoSettings.fromBuffer(temp); + this.emit('videoParameters', { videoSettings, screenInfo }); + offset += VideoSettings.BUFFER_LENGTH; + temp = new Buffer(new Uint8Array(data, offset, CLIENT_ID_LENGTH + CLIENTS_COUNT_LENGTH)); + const clientId = temp.readInt16BE(0); + const clientsCount = temp.readInt16BE(CLIENT_ID_LENGTH); + this.emit('clientsStats', { + clientId: clientId, + clientsCount: clientsCount, + deviceName: deviceName, + }); + } + + 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 onSocketClose(): void { + console.log('WS closed'); + } + + protected onSocketMessage(e: MessageEvent): void { + if (e.data instanceof ArrayBuffer) { + const data = new Uint8Array(e.data); + const magicBytes = new Uint8Array(e.data, 0, MAGIC.length); + if (StreamReceiver.EqualArrays(magicBytes, MAGIC_BYTES)) { + if (data.length === DEVICE_INFO_LENGTH) { + this.handleDeviceInfo(e.data); + return; + } else { + const message = DeviceMessage.fromBuffer(e.data); + this.emit('deviceMessage', message); + } + } else { + this.emit('video', data); + } + } + } + + protected onSocketOpen(): void { + let e = this.events.shift(); + while (e) { + this.sendEvent(e); + e = this.events.shift(); + } + } + + public sendEvent(event: ControlMessage): void { + if (this.hasConnection()) { + (this.ws as WebSocket).send(event.toBuffer()); + } else { + this.events.push(event); + } + } + + public stop(): void { + if (this.hasConnection()) { + (this.ws as WebSocket).close(); + } + this.events.length = 0; + } + + protected buildWebSocketUrl(): string { + const proto = location.protocol === 'https:' ? 'wss' : 'ws'; + const query = this.query ? this.query : this.action ? `?action=${this.action}` : ''; + return `${proto}://${this.host}:${this.port}${this.path}${query}`; + } +} diff --git a/src/app/client/WsQVHackClient.ts b/src/app/client/WsQVHackClient.ts new file mode 100644 index 00000000..66a6893a --- /dev/null +++ b/src/app/client/WsQVHackClient.ts @@ -0,0 +1,116 @@ +import { ManagerClient } from './ManagerClient'; +import QVHackDeviceDescriptor from '../../common/QVHackDeviceDescriptor'; +import { MessageRunWda } from '../../common/MessageRunWda'; + +const SERVER_PORT = 8080; +const SERVER_HOST = location.hostname; + +export type WsQVHackClientEvents = { + 'device-list': QVHackDeviceDescriptor[]; + 'run-wda': MessageRunWda; + connected: boolean; +}; + +export class WsQVHackClient extends ManagerClient { + private stopped = false; + private commands: string[] = []; + constructor() { + super(); + this.openNewWebSocket(); + } + protected onSocketClose(e: CloseEvent): void { + this.emit('connected', false); + console.log(`Connection closed: ${e.reason}`); + if (!this.stopped) { + setTimeout(() => { + this.openNewWebSocket(); + }, 2000); + } + } + + protected onSocketMessage(e: MessageEvent): void { + new Response(e.data) + .text() + .then((text: string) => { + const json = JSON.parse(text); + const type = json['type']; + switch (type) { + case 'qvhack-device-list': { + const devices = json['data'] as QVHackDeviceDescriptor[]; + this.emit('device-list', devices); + return; + } + case 'run-wda': { + const response = json as MessageRunWda; + this.emit('run-wda', response); + return; + } + default: { + throw Error('Unsupported message'); + } + } + }) + .catch((error: Error) => { + console.error(error.message); + console.log(e.data); + }); + } + + protected onSocketOpen(): void { + this.emit('connected', true); + while (this.commands.length) { + const str = this.commands.shift(); + if (str) { + this.sendCommand(str); + } + } + } + + protected buildWebSocketUrl(): string { + const proto = location.protocol === 'https:' ? 'wss' : 'ws'; + const host = SERVER_HOST; + const port = SERVER_PORT; + const path = '/ws'; + return `${proto}://${host}:${port}${path}`; + } + + private sendCommand(str: string): void { + if (this.hasConnection()) { + (this.ws as WebSocket).send(str); + } else { + this.commands.push(str); + } + } + + public subscribeToDeviceList(listener: (devices: QVHackDeviceDescriptor[]) => void): void { + this.on('device-list', listener); + const command = 'list'; + const str = JSON.stringify({ command, subscribe: true }); + this.sendCommand(str); + } + + public runWebDriverAgent(udid: string): Promise { + const command = 'run-wda'; + this.sendCommand(JSON.stringify({ command, udid })); + return new Promise((resolve) => { + const onResponse = (response: MessageRunWda) => { + const data = response.data; + if (data.udid === udid) { + this.off(command, onResponse); + resolve(response); + } + }; + this.on(command, onResponse); + }); + } + + public stop(): void { + if (this.stopped) { + return; + } + this.stopped = true; + if (this.hasConnection()) { + (this.ws as WebSocket).close(); + } + } +} diff --git a/src/controlEvent/CommandControlEvent.ts b/src/app/controlMessage/CommandControlMessage.ts similarity index 76% rename from src/controlEvent/CommandControlEvent.ts rename to src/app/controlMessage/CommandControlMessage.ts index 4d7b1b32..29ae2c4e 100644 --- a/src/controlEvent/CommandControlEvent.ts +++ b/src/app/controlMessage/CommandControlMessage.ts @@ -1,4 +1,4 @@ -import ControlEvent from './ControlEvent'; +import { ControlMessage } from './ControlMessage'; import VideoSettings from '../VideoSettings'; import Util from '../Util'; @@ -18,16 +18,16 @@ type FilePushParams = { fileSize?: number; }; -export default class CommandControlEvent extends ControlEvent { +export class CommandControlMessage extends ControlMessage { public static PAYLOAD_LENGTH = 0; public static CommandCodes: Record = { - TYPE_EXPAND_NOTIFICATION_PANEL: ControlEvent.TYPE_EXPAND_NOTIFICATION_PANEL, - TYPE_COLLAPSE_NOTIFICATION_PANEL: ControlEvent.TYPE_COLLAPSE_NOTIFICATION_PANEL, - TYPE_GET_CLIPBOARD: ControlEvent.TYPE_GET_CLIPBOARD, - TYPE_SET_CLIPBOARD: ControlEvent.TYPE_SET_CLIPBOARD, - TYPE_ROTATE_DEVICE: ControlEvent.TYPE_ROTATE_DEVICE, - TYPE_CHANGE_STREAM_PARAMETERS: ControlEvent.TYPE_CHANGE_STREAM_PARAMETERS, + TYPE_EXPAND_NOTIFICATION_PANEL: ControlMessage.TYPE_EXPAND_NOTIFICATION_PANEL, + TYPE_COLLAPSE_NOTIFICATION_PANEL: ControlMessage.TYPE_COLLAPSE_NOTIFICATION_PANEL, + TYPE_GET_CLIPBOARD: ControlMessage.TYPE_GET_CLIPBOARD, + TYPE_SET_CLIPBOARD: ControlMessage.TYPE_SET_CLIPBOARD, + TYPE_ROTATE_DEVICE: ControlMessage.TYPE_ROTATE_DEVICE, + TYPE_CHANGE_STREAM_PARAMETERS: ControlMessage.TYPE_CHANGE_STREAM_PARAMETERS, }; public static CommandNames: Record = { @@ -39,10 +39,10 @@ export default class CommandControlEvent extends ControlEvent { 101: 'Change video settings', }; - public static createSetVideoSettingsCommand(videoSettings: VideoSettings): CommandControlEvent { + public static createSetVideoSettingsCommand(videoSettings: VideoSettings): CommandControlMessage { const temp = videoSettings.toBuffer(); - const event = new CommandControlEvent(ControlEvent.TYPE_CHANGE_STREAM_PARAMETERS); - const offset = CommandControlEvent.PAYLOAD_LENGTH + 1; + const event = new CommandControlMessage(ControlMessage.TYPE_CHANGE_STREAM_PARAMETERS); + const offset = CommandControlMessage.PAYLOAD_LENGTH + 1; const buffer = new Buffer(offset + temp.length); buffer.writeUInt8(event.type, 0); temp.forEach((byte, index) => { @@ -52,8 +52,8 @@ export default class CommandControlEvent extends ControlEvent { return event; } - public static createSetClipboardCommand(text: string, paste = false): CommandControlEvent { - const event = new CommandControlEvent(ControlEvent.TYPE_SET_CLIPBOARD); + public static createSetClipboardCommand(text: string, paste = false): 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; @@ -70,7 +70,7 @@ export default class CommandControlEvent extends ControlEvent { return event; } - public static createPushFileCommand(params: FilePushParams): CommandControlEvent { + public static createPushFileCommand(params: FilePushParams): CommandControlMessage { const { id, fileName, fileSize, chunk, state } = params; if (state === FilePushState.START) { @@ -86,8 +86,8 @@ export default class CommandControlEvent extends ControlEvent { throw TypeError(`Unsupported state: "${state}"`); } - private static createPushFileStartCommand(id: number, fileName: string, fileSize: number): CommandControlEvent { - const event = new CommandControlEvent(ControlEvent.TYPE_PUSH_FILE); + 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; @@ -95,7 +95,7 @@ export default class CommandControlEvent extends ControlEvent { const sizeField = 4; const textLengthField = 2; const textLength = text.length; - let offset = CommandControlEvent.PAYLOAD_LENGTH; + let offset = CommandControlMessage.PAYLOAD_LENGTH; const buffer = new Buffer(offset + typeField + idField + stateField + sizeField + textLengthField + textLength); buffer.writeUInt8(event.type, offset); @@ -115,14 +115,14 @@ export default class CommandControlEvent extends ControlEvent { return event; } - private static createPushFileChunkCommand(id: number, chunk: Uint8Array): CommandControlEvent { - const event = new CommandControlEvent(ControlEvent.TYPE_PUSH_FILE); + 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 = CommandControlEvent.PAYLOAD_LENGTH; + let offset = CommandControlMessage.PAYLOAD_LENGTH; const buffer = new Buffer(offset + typeField + idField + stateField + chunkLengthField + chunkLength); buffer.writeUInt8(event.type, offset); @@ -141,11 +141,11 @@ export default class CommandControlEvent extends ControlEvent { } private static createPushFileOtherCommand(id: number, state: FilePushState) { - const event = new CommandControlEvent(ControlEvent.TYPE_PUSH_FILE); + const event = new CommandControlMessage(ControlMessage.TYPE_PUSH_FILE); const typeField = 1; const idField = 2; const stateField = 1; - let offset = CommandControlEvent.PAYLOAD_LENGTH; + let offset = CommandControlMessage.PAYLOAD_LENGTH; const buffer = new Buffer(offset + typeField + idField + stateField); buffer.writeUInt8(event.type, offset); offset += typeField; @@ -167,7 +167,7 @@ export default class CommandControlEvent extends ControlEvent { */ public toBuffer(): Buffer { if (!this.buffer) { - const buffer = new Buffer(CommandControlEvent.PAYLOAD_LENGTH + 1); + const buffer = new Buffer(CommandControlMessage.PAYLOAD_LENGTH + 1); buffer.writeUInt8(this.type, 0); this.buffer = buffer; } @@ -176,6 +176,6 @@ export default class CommandControlEvent extends ControlEvent { public toString(): string { const buffer = this.buffer ? `, buffer=[${this.buffer.join(',')}]` : ''; - return `CommandControlEvent{action=${this.type}${buffer}}`; + return `CommandControlMessage{action=${this.type}${buffer}}`; } } diff --git a/src/controlEvent/ControlEvent.ts b/src/app/controlMessage/ControlMessage.ts similarity index 75% rename from src/controlEvent/ControlEvent.ts rename to src/app/controlMessage/ControlMessage.ts index 6d91ddf4..1470fb42 100644 --- a/src/controlEvent/ControlEvent.ts +++ b/src/app/controlMessage/ControlMessage.ts @@ -1,4 +1,8 @@ -export default class ControlEvent { +export interface ControlMessageInterface { + type: number; +} + +export class ControlMessage { public static TYPE_KEYCODE = 0; public static TYPE_TEXT = 1; public static TYPE_MOUSE = 2; @@ -20,6 +24,12 @@ export default class ControlEvent { } public toString(): string { - return 'ControlEvent'; + 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 00000000..4cbc51a5 --- /dev/null +++ b/src/app/controlMessage/KeyCodeControlMessage.ts @@ -0,0 +1,50 @@ +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 = new Buffer(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); + 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/MotionControlMessage.ts b/src/app/controlMessage/MotionControlMessage.ts new file mode 100644 index 00000000..cde6649e --- /dev/null +++ b/src/app/controlMessage/MotionControlMessage.ts @@ -0,0 +1,44 @@ +import { ControlMessage, ControlMessageInterface } from './ControlMessage'; +import Position, { PositionInterface } from '../Position'; + +export interface MotionControlMessageInterface extends ControlMessageInterface { + action: number; + buttons: number; + position: PositionInterface; +} + +export class MotionControlMessage extends ControlMessage { + public static PAYLOAD_LENGTH = 17; + + constructor(readonly action: number, readonly buttons: number, readonly position: Position) { + super(ControlMessage.TYPE_MOUSE); + } + + /** + * @override + */ + public toBuffer(): Buffer { + const buffer: Buffer = new Buffer(MotionControlMessage.PAYLOAD_LENGTH + 1); + buffer.writeUInt8(this.type, 0); + buffer.writeUInt8(this.action, 1); + buffer.writeUInt32BE(this.buttons, 2); + buffer.writeUInt32BE(this.position.point.x, 6); + buffer.writeUInt32BE(this.position.point.y, 10); + buffer.writeUInt16BE(this.position.screenSize.width, 14); + buffer.writeUInt16BE(this.position.screenSize.height, 16); + return buffer; + } + + public toString(): string { + return `MotionControlMessage{action=${this.action}, buttons=${this.buttons}, position=${this.position}}`; + } + + public toJSON(): MotionControlMessageInterface { + return { + type: this.type, + action: this.action, + buttons: this.buttons, + position: this.position.toJSON(), + }; + } +} diff --git a/src/app/controlMessage/ScrollControlMessage.ts b/src/app/controlMessage/ScrollControlMessage.ts new file mode 100644 index 00000000..a058fb39 --- /dev/null +++ b/src/app/controlMessage/ScrollControlMessage.ts @@ -0,0 +1,44 @@ +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 = new Buffer(ScrollControlMessage.PAYLOAD_LENGTH + 1); + buffer.writeUInt8(this.type, 0); + buffer.writeUInt32BE(this.position.point.x, 1); + buffer.writeUInt32BE(this.position.point.y, 5); + buffer.writeUInt16BE(this.position.screenSize.width, 9); + buffer.writeUInt16BE(this.position.screenSize.height, 11); + buffer.writeUInt32BE(this.hScroll, 13); + buffer.writeUInt32BE(this.vScroll, 17); + 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 00000000..01383b3c --- /dev/null +++ b/src/app/controlMessage/TextControlMessage.ts @@ -0,0 +1,41 @@ +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 = new Buffer(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); + return buffer; + } + + public toString(): string { + return `TextControlMessage{text=${this.text}}`; + } + + public toJSON(): TextControlMessageInterface { + return { + type: this.type, + text: this.text, + }; + } +} diff --git a/src/controlEvent/TouchControlEvent.ts b/src/app/controlMessage/TouchControlMessage.ts similarity index 51% rename from src/controlEvent/TouchControlEvent.ts rename to src/app/controlMessage/TouchControlMessage.ts index 64858769..1cb83d10 100644 --- a/src/controlEvent/TouchControlEvent.ts +++ b/src/app/controlMessage/TouchControlMessage.ts @@ -1,7 +1,16 @@ -import ControlEvent from './ControlEvent'; -import Position from '../Position'; +import { ControlMessage, ControlMessageInterface } from './ControlMessage'; +import Position, { PositionInterface } from '../Position'; -export default class TouchControlEvent extends ControlEvent { +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; constructor( @@ -11,14 +20,14 @@ export default class TouchControlEvent extends ControlEvent { readonly pressure: number, readonly buttons: number, ) { - super(ControlEvent.TYPE_MOUSE); + super(ControlMessage.TYPE_MOUSE); } /** * @override */ public toBuffer(): Buffer { - const buffer: Buffer = new Buffer(TouchControlEvent.PAYLOAD_LENGTH + 1); + const buffer: Buffer = new Buffer(TouchControlMessage.PAYLOAD_LENGTH + 1); let offset = 0; offset = buffer.writeUInt8(this.type, offset); offset = buffer.writeUInt8(this.action, offset); @@ -34,6 +43,17 @@ export default class TouchControlEvent extends ControlEvent { } public toString(): string { - return `TouchControlEvent{action=${this.action}, pointerId=${this.pointerId}, position=${this.position}, pressure=${this.pressure}, buttons=${this.buttons}}`; + 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/decoder/BroadwayDecoder.ts b/src/app/decoder/BroadwayDecoder.ts similarity index 87% rename from src/decoder/BroadwayDecoder.ts rename to src/app/decoder/BroadwayDecoder.ts index 56488203..713676f8 100644 --- a/src/decoder/BroadwayDecoder.ts +++ b/src/app/decoder/BroadwayDecoder.ts @@ -1,9 +1,8 @@ +import '../../../vendor/Broadway/avc.wasm.asset'; import Size from '../Size'; import YUVCanvas from '../h264-live-player/YUVCanvas'; import YUVWebGLCanvas from '../h264-live-player/YUVWebGLCanvas'; -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore -import Avc from '../Decoder'; +import Avc from '../../../vendor/Broadway/Decoder'; import VideoSettings from '../VideoSettings'; import Canvas from '../h264-live-player/Canvas'; import CanvasCommon from './CanvasCommon'; @@ -24,7 +23,6 @@ export default class BroadwayDecoder extends CanvasCommon { constructor(udid: string) { super(udid, 'BroadwayDecoder'); - this.avc = new Avc(); } protected initCanvas(width: number, height: number): void { @@ -34,7 +32,9 @@ export default class BroadwayDecoder extends CanvasCommon { } else { this.canvas = new YUVCanvas(this.tag, new Size(width, height)); } - this.avc = new Avc(); + if (!this.avc) { + this.avc = new Avc(); + } this.avc.onPictureDecoded = (buffer: Uint8Array, width: number, height: number) => { this.onFrameDecoded(); if (this.canvas) { @@ -44,6 +44,9 @@ export default class BroadwayDecoder extends CanvasCommon { } protected decode(data: Uint8Array): void { + if (!this.avc) { + return; + } this.avc.decode(data); } diff --git a/src/decoder/CanvasCommon.ts b/src/app/decoder/CanvasCommon.ts similarity index 100% rename from src/decoder/CanvasCommon.ts rename to src/app/decoder/CanvasCommon.ts diff --git a/src/decoder/Decoder.ts b/src/app/decoder/Decoder.ts similarity index 92% rename from src/decoder/Decoder.ts rename to src/app/decoder/Decoder.ts index fa980a69..a91e3906 100644 --- a/src/decoder/Decoder.ts +++ b/src/app/decoder/Decoder.ts @@ -25,7 +25,8 @@ export interface PlaybackQuality { } export interface VideoResizeListener { - onVideoResize(size: Size): void; + onViewVideoResize(size: Size): void; + onInputVideoResize(screenInfo: ScreenInfo): void; } export default abstract class Decoder { @@ -45,6 +46,7 @@ export default abstract class Decoder { protected inputBytes: BitrateStat[] = []; protected perSecondQualityStats?: FramesPerSecondStats; protected momentumQualityStats?: PlaybackQuality; + protected bounds: Size | null = null; private totalStats: PlaybackQuality = { decodedFrames: 0, droppedFrames: 0, @@ -55,8 +57,10 @@ export default abstract class Decoder { private totalStatsCounter = 0; private dirtyStatsWidth = 0; private state: number = Decoder.STATE.STOPPED; - private resizeListeners: Set = new Set(); + protected resizeListeners: Set = new Set(); + private qualityAnimationId?: number; private showQualityStats = Decoder.DEFAULT_SHOW_QUALITY_STATS; + private receivedFirstFrame = false; private statLines: string[] = []; public readonly supportsScreenshot: boolean = false; @@ -151,11 +155,10 @@ export default abstract class Decoder { } public play(): void { - if (!this.screenInfo) { + if (this.needScreenInfoBeforePlay() && !this.screenInfo) { return; } this.state = Decoder.STATE.PLAYING; - requestAnimationFrame(this.updateQualityStats); } public pause(): void { @@ -171,6 +174,12 @@ export default abstract class Decoder { } 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, @@ -190,6 +199,10 @@ export default abstract class Decoder { parent.appendChild(this.touchableCanvas); } + protected needScreenInfoBeforePlay(): boolean { + return true; + } + public getVideoSettings(): VideoSettings { return this.videoSettings; } @@ -207,7 +220,9 @@ export default abstract class Decoder { } public setScreenInfo(screenInfo: ScreenInfo): void { - this.pause(); + if (this.needScreenInfoBeforePlay()) { + this.pause(); + } this.screenInfo = screenInfo; const { width, height } = screenInfo.videoSize; this.touchableCanvas.width = width; @@ -218,7 +233,7 @@ export default abstract class Decoder { } const size = new Size(width, height); this.resizeListeners.forEach((listener) => { - listener.onVideoResize(size); + listener.onViewVideoResize(size); }); } @@ -235,6 +250,7 @@ export default abstract class Decoder { } protected resetStats(): void { + this.receivedFirstFrame = false; this.totalStatsCounter = 0; this.totalStats = { droppedFrames: 0, @@ -278,7 +294,9 @@ export default abstract class Decoder { this.totalStatsCounter++; } this.drawStats(); - requestAnimationFrame(this.updateQualityStats); + if (this.state !== Decoder.STATE.STOPPED) { + this.qualityAnimationId = requestAnimationFrame(this.updateQualityStats); + } }; private drawStats(): void { @@ -371,4 +389,8 @@ export default abstract class Decoder { public getShowQualityStats(): boolean { return this.showQualityStats; } + + public setBounds(bounds: Size): void { + this.bounds = Size.copy(bounds); + } } diff --git a/src/app/decoder/Mse4QVHackDecoder.ts b/src/app/decoder/Mse4QVHackDecoder.ts new file mode 100644 index 00000000..4edcef7a --- /dev/null +++ b/src/app/decoder/Mse4QVHackDecoder.ts @@ -0,0 +1,80 @@ +import MseDecoder from './MseDecoder'; +import ScreenInfo from '../ScreenInfo'; +import Rect from '../Rect'; +import Size from '../Size'; +import VideoSettings from '../VideoSettings'; + +export class Mse4QVHackDecoder extends MseDecoder { + public static readonly preferredVideoSettings: VideoSettings = new VideoSettings({ + lockedVideoOrientation: -1, + bitrate: 8000000, + maxFps: 30, + iFrameInterval: 10, + bounds: new Size(720, 720), + sendFrameMeta: false, + }); + + constructor(udid: string, tag: HTMLVideoElement) { + super(udid, tag); + } + + protected onCanPlayHandler() { + super.onCanPlayHandler(); + const tag = this.tag; + const { videoWidth, videoHeight } = tag; + if (!videoWidth && !videoHeight) { + return; + } + 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; + tag.style.maxWidth = `${w}px`; + tag.style.maxHeight = `${h}px`; + } + } + const realScreen = new ScreenInfo(new Rect(0, 0, videoWidth, videoHeight), new Size(w, h), 0); + this.resizeListeners.forEach((listener) => { + listener.onInputVideoResize(realScreen); + }); + this.setScreenInfo(new ScreenInfo(new Rect(0, 0, w, h), new Size(w, h), 0)); + } + + protected needScreenInfoBeforePlay(): boolean { + return false; + } + + public getPreferredVideoSetting(): VideoSettings { + return Mse4QVHackDecoder.preferredVideoSettings; + } + + public setVideoSettings(): void { + return; + } + + public play() { + super.play(); + this.tag.oncanplay = this.onVideoCanPlay; + } +} diff --git a/src/decoder/MseDecoder.ts b/src/app/decoder/MseDecoder.ts similarity index 55% rename from src/decoder/MseDecoder.ts rename to src/app/decoder/MseDecoder.ts index c9238e71..aa5c901d 100644 --- a/src/decoder/MseDecoder.ts +++ b/src/app/decoder/MseDecoder.ts @@ -11,8 +11,8 @@ interface QualityStats { // sourceBuffer is private in h264-converter type ConverterFake = { - sourceBuffer: SourceBuffer -} + sourceBuffer: SourceBuffer; +}; export default class MseDecoder extends Decoder { public static readonly preferredVideoSettings: VideoSettings = new VideoSettings({ @@ -41,27 +41,46 @@ export default class MseDecoder extends Decoder { private converter?: VideoConverter; private videoStats: QualityStats[] = []; - private stalledCounter = 0; - private isSeeking = false; + private noDecodedFramesSince = -1; + private currentTimeNotChangedSince = -1; + private bigBufferSince = -1; + private aheadOfBufferSince = -1; public fpf: number = MseDecoder.DEFAULT_FRAMES_PER_FRAGMENT; public readonly supportsScreenshot: boolean = true; private sourceBuffer?: SourceBuffer; private removeStart = -1; private removeEnd = -1; + private jumpEnd = -1; + private lastTime = -1; + protected canPlay = false; + private seekingSince = -1; + 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; constructor(udid: string, protected tag: HTMLVideoElement = MseDecoder.createElement()) { super(udid, 'MseDecoder', tag); - tag.onerror = function (e: Event | string): void { - console.error(e); - }; tag.oncontextmenu = function (e: MouseEvent): boolean { e.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 = (e: Event): void => { + console.error(e); + }; + + onVideoCanPlay = (): void => { + this.onCanPlayHandler(); + }; + private static createConverter( tag: HTMLVideoElement, fps: number = MseDecoder.DEFAULT_FRAMES_PER_SECOND, @@ -97,6 +116,12 @@ export default class MseDecoder extends Decoder { return null; } + protected onCanPlayHandler(): void { + this.canPlay = true; + this.tag.play(); + this.tag.removeEventListener('canplay', this.onVideoCanPlay); + } + protected calculateMomentumStats(): void { const stat = this.getVideoPlaybackQuality(); if (!stat) { @@ -152,7 +177,7 @@ export default class MseDecoder extends Decoder { public play(): void { super.play(); - if (this.getState() !== Decoder.STATE.PLAYING || !this.screenInfo) { + if (this.getState() !== Decoder.STATE.PLAYING) { return; } if (!this.converter) { @@ -161,6 +186,7 @@ export default class MseDecoder extends Decoder { fps = this.videoSettings.maxFps; } this.converter = MseDecoder.createConverter(this.tag, fps, this.fpf); + this.canPlay = false; this.resetStats(); } this.converter.play(); @@ -182,6 +208,7 @@ export default class MseDecoder extends Decoder { if (this.converter) { this.stop(); this.converter = MseDecoder.createConverter(this.tag, videoSettings.maxFps, this.fpf); + this.canPlay = false; } if (state === Decoder.STATE.PLAYING) { this.play(); @@ -202,6 +229,8 @@ export default class MseDecoder extends Decoder { return; } try { + // console.log(this.name, `sourceBuffer.remove(${this.removeStart}, ${this.removeEnd})`); + // FIXME: will kill playback in Safari this.sourceBuffer.remove(this.removeStart, this.removeEnd); this.sourceBuffer.removeEventListener('updateend', this.cleanSourceBuffer); this.removeStart = this.removeEnd = -1; @@ -210,72 +239,146 @@ export default class MseDecoder extends Decoder { } }; + 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.converter) { - if (Decoder.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) | 0; - } - if (end !== 0 && start < end) { - const sourceBuffer: SourceBuffer = (this.converter as unknown as ConverterFake).sourceBuffer; - if (!sourceBuffer.updating) { - sourceBuffer.remove(start, end); - } else { - this.sourceBuffer = sourceBuffer; - if (this.removeEnd !== -1 || this.removeEnd !== -1) { - this.removeEnd = end; - } else { - this.removeStart = start; - this.removeEnd = end; - } - sourceBuffer.addEventListener('updateend', this.cleanSourceBuffer); - } - } - } this.converter.appendRawData(frame); + this.checkForIFrame(frame); } + this.checkForBadState(); + } + protected checkForBadState(): void { // Workaround for stalled playback (`stalled` event is not fired, but the image freezes) - if (this.momentumQualityStats && !this.isSeeking) { + 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) { - this.stalledCounter++; + 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.stalledCounter = 0; + 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 - this.tag.currentTime; - const MAX_BUFFER = 0.2; - const MAX_AHEAD = 0.2; - let hasReasonToJump = false; - if (this.stalledCounter > 5) { - // `Stalled (for ${this.stalledCounter} frames).`; - hasReasonToJump = true + 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 > MAX_BUFFER) { - // `Buffer is bigger then ${MAX_BUFFER} seconds (=${buffered.toFixed(3)}).`; - hasReasonToJump = true; + 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 (buffered < -MAX_AHEAD) { - // `Current time is more then ${MAX_AHEAD} seconds ahead of end (HOW?!) (=${-buffered.toFixed(3)}).`; - hasReasonToJump = true; + 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; } - if (this.tag.seeking && this.stalledCounter < 10) { - // console.info(`${this.name}. ${reasonToJump} But already seeking. Do nothing.`); - return; + let waitingForSeekEnd = 0; + if (this.seekingSince !== -1) { + waitingForSeekEnd = now - this.seekingSince; + if (waitingForSeekEnd < 1500) { + return; + } } - // console.info(`${this.name}. ${reasonToJump} Jumping to the end. ${this.stalledCounter}`); + // 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); - this.stalledCounter = 0; + } + } + + protected checkForIFrame(frame: Uint8Array): void { + if (this.isSafari) { + return; + } + if (Decoder.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) | 0; + } + if (end !== 0 && start < end) { + const sourceBuffer: SourceBuffer = ((this.converter as unknown) as ConverterFake).sourceBuffer; + this.sourceBuffer = sourceBuffer; + if (this.removeEnd !== -1) { + this.removeEnd = end; + } else { + this.removeStart = start; + this.removeEnd = end; + } + sourceBuffer.addEventListener('updateend', this.cleanSourceBuffer); + } } } diff --git a/src/decoder/Tinyh264Decoder.ts b/src/app/decoder/Tinyh264Decoder.ts similarity index 91% rename from src/decoder/Tinyh264Decoder.ts rename to src/app/decoder/Tinyh264Decoder.ts index 95897923..78140b70 100644 --- a/src/decoder/Tinyh264Decoder.ts +++ b/src/app/decoder/Tinyh264Decoder.ts @@ -1,6 +1,4 @@ -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore -import Worker from '../tinyh264/H264NALDecoder.worker'; +import TinyH264Worker from 'worker-loader!../tinyh264/H264NALDecoder.worker'; import VideoSettings from '../VideoSettings'; import YUVWebGLCanvas from '../tinyh264/YUVWebGLCanvas'; import YUVCanvas from '../tinyh264/YUVCanvas'; @@ -26,7 +24,7 @@ export default class Tinyh264Decoder extends CanvasCommon { sendFrameMeta: false, }); - private worker?: Worker; + private worker?: TinyH264Worker; private isDecoderReady = false; protected canvas?: YUVWebGLCanvas | YUVCanvas; public readonly supportsScreenshot: boolean = true; @@ -54,7 +52,7 @@ export default class Tinyh264Decoder extends CanvasCommon { }; private initWorker(): void { - this.worker = new Worker(); + this.worker = new TinyH264Worker(); this.worker.addEventListener('message', this.onWorkerMessage); } @@ -95,7 +93,7 @@ export default class Tinyh264Decoder extends CanvasCommon { public stop(): void { super.stop(); if (this.worker) { - this.worker.worker.removeEventListener('message', this.onWorkerMessage); + this.worker.removeEventListener('message', this.onWorkerMessage); this.worker.postMessage({ type: 'release', renderStateId: Tinyh264Decoder.videoStreamId }); delete this.worker; } diff --git a/src/h264-live-player/AUTHORS b/src/app/h264-live-player/AUTHORS similarity index 100% rename from src/h264-live-player/AUTHORS rename to src/app/h264-live-player/AUTHORS diff --git a/src/h264-live-player/Canvas.ts b/src/app/h264-live-player/Canvas.ts similarity index 100% rename from src/h264-live-player/Canvas.ts rename to src/app/h264-live-player/Canvas.ts diff --git a/src/h264-live-player/LICENSE b/src/app/h264-live-player/LICENSE similarity index 100% rename from src/h264-live-player/LICENSE rename to src/app/h264-live-player/LICENSE diff --git a/src/h264-live-player/Program.ts b/src/app/h264-live-player/Program.ts similarity index 100% rename from src/h264-live-player/Program.ts rename to src/app/h264-live-player/Program.ts diff --git a/src/h264-live-player/Script.ts b/src/app/h264-live-player/Script.ts similarity index 100% rename from src/h264-live-player/Script.ts rename to src/app/h264-live-player/Script.ts diff --git a/src/h264-live-player/Shader.ts b/src/app/h264-live-player/Shader.ts similarity index 100% rename from src/h264-live-player/Shader.ts rename to src/app/h264-live-player/Shader.ts diff --git a/src/h264-live-player/Texture.ts b/src/app/h264-live-player/Texture.ts similarity index 100% rename from src/h264-live-player/Texture.ts rename to src/app/h264-live-player/Texture.ts diff --git a/src/h264-live-player/WebGLCanvas.ts b/src/app/h264-live-player/WebGLCanvas.ts similarity index 100% rename from src/h264-live-player/WebGLCanvas.ts rename to src/app/h264-live-player/WebGLCanvas.ts diff --git a/src/h264-live-player/YUVCanvas.ts b/src/app/h264-live-player/YUVCanvas.ts similarity index 100% rename from src/h264-live-player/YUVCanvas.ts rename to src/app/h264-live-player/YUVCanvas.ts diff --git a/src/h264-live-player/YUVWebGLCanvas.ts b/src/app/h264-live-player/YUVWebGLCanvas.ts similarity index 100% rename from src/h264-live-player/YUVWebGLCanvas.ts rename to src/app/h264-live-player/YUVWebGLCanvas.ts diff --git a/src/h264-live-player/utils/assert.ts b/src/app/h264-live-player/utils/assert.ts similarity index 100% rename from src/h264-live-player/utils/assert.ts rename to src/app/h264-live-player/utils/assert.ts diff --git a/src/h264-live-player/utils/error.ts b/src/app/h264-live-player/utils/error.ts similarity index 100% rename from src/h264-live-player/utils/error.ts rename to src/app/h264-live-player/utils/error.ts diff --git a/src/h264-live-player/utils/glUtils.ts b/src/app/h264-live-player/utils/glUtils.ts similarity index 100% rename from src/h264-live-player/utils/glUtils.ts rename to src/app/h264-live-player/utils/glUtils.ts diff --git a/src/app/index.ts b/src/app/index.ts new file mode 100644 index 00000000..ea3806c2 --- /dev/null +++ b/src/app/index.ts @@ -0,0 +1,20 @@ +import '../style/app.css'; +import * as querystring from 'querystring'; +import { ScrcpyClient } from './client/ScrcpyClient'; +import { ShellClient } from './client/ShellClient'; +import { DroidDeviceTrackerClient } from './client/DroidDeviceTrackerClient'; +import { ScrcpyStreamParams } from '../common/ScrcpyStreamParams'; +import { ShellParams } from '../common/ShellParams'; + +window.onload = function (): void { + const hash = location.hash.replace(/^#!/, ''); + const parsedQuery = querystring.parse(hash); + const action = parsedQuery.action; + if (action === ScrcpyClient.ACTION && typeof parsedQuery.udid === 'string') { + new ScrcpyClient(parsedQuery as ScrcpyStreamParams); + } else if (action === ShellClient.ACTION && typeof parsedQuery.udid === 'string') { + ShellClient.start(parsedQuery as ShellParams); + } else { + DroidDeviceTrackerClient.start(); + } +}; diff --git a/src/tinyh264/Canvas.ts b/src/app/tinyh264/Canvas.ts similarity index 100% rename from src/tinyh264/Canvas.ts rename to src/app/tinyh264/Canvas.ts diff --git a/src/tinyh264/H264NALDecoder.worker.ts b/src/app/tinyh264/H264NALDecoder.worker.ts similarity index 100% rename from src/tinyh264/H264NALDecoder.worker.ts rename to src/app/tinyh264/H264NALDecoder.worker.ts diff --git a/src/tinyh264/ShaderCompiler.ts b/src/app/tinyh264/ShaderCompiler.ts similarity index 100% rename from src/tinyh264/ShaderCompiler.ts rename to src/app/tinyh264/ShaderCompiler.ts diff --git a/src/tinyh264/ShaderProgram.ts b/src/app/tinyh264/ShaderProgram.ts similarity index 100% rename from src/tinyh264/ShaderProgram.ts rename to src/app/tinyh264/ShaderProgram.ts diff --git a/src/tinyh264/ShaderSources.ts b/src/app/tinyh264/ShaderSources.ts similarity index 100% rename from src/tinyh264/ShaderSources.ts rename to src/app/tinyh264/ShaderSources.ts diff --git a/src/tinyh264/YUVCanvas.ts b/src/app/tinyh264/YUVCanvas.ts similarity index 100% rename from src/tinyh264/YUVCanvas.ts rename to src/app/tinyh264/YUVCanvas.ts diff --git a/src/tinyh264/YUVSurfaceShader.ts b/src/app/tinyh264/YUVSurfaceShader.ts similarity index 100% rename from src/tinyh264/YUVSurfaceShader.ts rename to src/app/tinyh264/YUVSurfaceShader.ts diff --git a/src/tinyh264/YUVWebGLCanvas.ts b/src/app/tinyh264/YUVWebGLCanvas.ts similarity index 100% rename from src/tinyh264/YUVWebGLCanvas.ts rename to src/app/tinyh264/YUVWebGLCanvas.ts diff --git a/src/app/toolbox/DroidMoreBox.ts b/src/app/toolbox/DroidMoreBox.ts new file mode 100644 index 00000000..0f0a8c38 --- /dev/null +++ b/src/app/toolbox/DroidMoreBox.ts @@ -0,0 +1,222 @@ +import Decoder, { VideoResizeListener } from '../decoder/Decoder'; +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 { ScrcpyClient } from '../client/ScrcpyClient'; + +export class DroidMoreBox implements VideoResizeListener { + private onStop?: () => void; + private readonly holder: HTMLElement; + private readonly input: HTMLInputElement; + + constructor(udid: string, decoder: Decoder, client: ScrcpyClient) { + const decoderName = decoder.getName(); + const videoSettings = decoder.getVideoSettings(); + const moreBox = document.createElement('div'); + moreBox.className = 'more-box'; + const nameBox = document.createElement('p'); + nameBox.innerText = `${udid} (${decoderName})`; + nameBox.className = 'text-with-shadow'; + moreBox.appendChild(nameBox); + const input = (this.input = document.createElement('input')); + const sendButton = document.createElement('button'); + sendButton.innerText = 'Send as keys'; + + DroidMoreBox.wrap('p', [input, sendButton], moreBox); + sendButton.onclick = () => { + if (input.value) { + client.sendEvent(new TextControlMessage(input.value)); + } + }; + + const controlButtons = document.createElement('div'); + controlButtons.className = 'control-buttons-list'; + const commands: HTMLElement[] = []; + const codes = CommandControlMessage.CommandCodes; + for (const command in codes) { + if (codes.hasOwnProperty(command)) { + const action: number = codes[command]; + 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}_${decoderName}_${action}`; + + spoiler.className = 'spoiler'; + spoilerCheck.type = 'checkbox'; + spoilerCheck.id = id; + spoilerLabel.htmlFor = id; + spoilerLabel.innerText = CommandControlMessage.CommandNames[action]; + 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 = `bitrate (${videoSettings.bitrate})`; + bitrateInput.value = videoSettings.bitrate.toString(); + DroidMoreBox.wrap('div', [bitrateLabel, bitrateInput], innerDiv); + + const maxFpsLabel = document.createElement('label'); + maxFpsLabel.innerText = 'Max fps:'; + maxFpsInput = document.createElement('input'); + maxFpsInput.placeholder = `max fps (${videoSettings.maxFps})`; + maxFpsInput.value = videoSettings.maxFps.toString(); + DroidMoreBox.wrap('div', [maxFpsLabel, maxFpsInput], innerDiv); + + const iFrameIntervalLabel = document.createElement('label'); + iFrameIntervalLabel.innerText = 'I-Frame Interval:'; + iFrameIntervalInput = document.createElement('input'); + iFrameIntervalInput.placeholder = `I-frame interval (${videoSettings.iFrameInterval})`; + iFrameIntervalInput.value = videoSettings.iFrameInterval.toString(); + DroidMoreBox.wrap('div', [iFrameIntervalLabel, iFrameIntervalInput], innerDiv); + + const { width, height } = videoSettings.bounds || DroidMoreBox.getMaxSize(controlButtons); + + const maxWidthLabel = document.createElement('label'); + maxWidthLabel.innerText = 'Max width:'; + maxWidthInput = document.createElement('input'); + maxWidthInput.placeholder = `max width (${width})`; + maxWidthInput.value = width.toString(); + DroidMoreBox.wrap('div', [maxWidthLabel, maxWidthInput], innerDiv); + + const maxHeightLabel = document.createElement('label'); + maxHeightLabel.innerText = 'Max height:'; + maxHeightInput = document.createElement('input'); + maxHeightInput.placeholder = `max height (${height})`; + maxHeightInput.value = height.toString(); + DroidMoreBox.wrap('div', [maxHeightLabel, maxHeightInput], innerDiv); + + innerDiv.appendChild(btn); + commands.push(spoiler); + } else { + commands.push(btn); + } + btn.innerText = CommandControlMessage.CommandNames[action]; + btn.onclick = () => { + let event: CommandControlMessage | undefined; + if (action === ControlMessage.TYPE_CHANGE_STREAM_PARAMETERS) { + 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 videoSettings = new VideoSettings({ + bounds, + bitrate, + maxFps, + iFrameInterval, + lockedVideoOrientation: -1, + sendFrameMeta: false, + }); + client.sendNewVideoSetting(videoSettings); + } else if (action === CommandControlMessage.TYPE_SET_CLIPBOARD) { + const text = input.value; + if (text) { + event = CommandControlMessage.createSetClipboardCommand(text); + } + } else { + event = new CommandControlMessage(action); + } + if (event) { + client.sendEvent(event); + } + }; + } + } + DroidMoreBox.wrap('p', commands, moreBox); + + const qualityId = `show_video_quality_${udid}_${decoderName}`; + const qualityLabel = document.createElement('label'); + const qualityCheck = document.createElement('input'); + qualityCheck.type = 'checkbox'; + qualityCheck.checked = Decoder.DEFAULT_SHOW_QUALITY_STATS; + qualityCheck.id = qualityId; + qualityLabel.htmlFor = qualityId; + qualityLabel.innerText = 'Show quality stats'; + DroidMoreBox.wrap('p', [qualityCheck, qualityLabel], moreBox); + qualityCheck.onchange = () => { + decoder.setShowQualityStats(qualityCheck.checked); + }; + + const stop = (ev?: string | Event) => { + if (ev && ev instanceof Event && ev.type === 'error') { + console.error(ev); + } + const parent = moreBox.parentElement; + if (parent) { + parent.removeChild(moreBox); + } + decoder.removeResizeListener(this); + if (this.onStop) { + this.onStop(); + delete this.onStop; + } + }; + + const stopBtn = document.createElement('button') as HTMLButtonElement; + stopBtn.innerText = `Disconnect`; + stopBtn.onclick = stop; + + DroidMoreBox.wrap('p', [stopBtn], moreBox); + decoder.addResizeListener(this); + this.holder = moreBox; + } + + public onViewVideoResize(size: Size): void { + // padding: 10px + this.holder.style.width = `${size.width - 2 * 10}px`; + } + public onInputVideoResize(/*screenInfo: ScreenInfo*/): void { + // this.connection.setScreenInfo(screenInfo); + } + + public OnDeviceMessage(ev: DeviceMessage): void { + if (ev.type !== DeviceMessage.TYPE_CLIPBOARD) { + return; + } + this.input.value = ev.getText(); + this.input.select(); + document.execCommand('copy'); + } + + private static wrap(tagName: string, elements: HTMLElement[], parent: HTMLElement): void { + const wrap = document.createElement(tagName); + elements.forEach((e) => { + wrap.appendChild(e); + }); + parent.appendChild(wrap); + } + + private static getMaxSize(controlButtons: HTMLElement): Size { + const body = document.body; + const width = (body.clientWidth - controlButtons.clientWidth) & ~15; + const height = body.clientHeight & ~15; + return new Size(width, height); + } + + public getHolderElement(): HTMLElement { + return this.holder; + } + + public setOnStop(listener: () => void) { + this.onStop = listener; + } +} diff --git a/src/app/toolbox/DroidToolBox.ts b/src/app/toolbox/DroidToolBox.ts new file mode 100644 index 00000000..10d2dc1a --- /dev/null +++ b/src/app/toolbox/DroidToolBox.ts @@ -0,0 +1,101 @@ +import { ToolBox } from './ToolBox'; +import KeyEvent from '../android/KeyEvent'; +import SvgImage from '../ui/SvgImage'; +import Decoder from '../decoder/Decoder'; +import { KeyCodeControlMessage } from '../controlMessage/KeyCodeControlMessage'; +import { ToolBoxButton } from './ToolBoxButton'; +import { ToolBoxElement } from './ToolBoxElement'; +import { ToolBoxCheckbox } from './ToolBoxCheckbox'; +import { ScrcpyClient } from '../client/ScrcpyClient'; + +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, + }, +]; + +export class DroidToolBox extends ToolBox { + protected constructor(list: ToolBoxElement[]) { + super(list); + } + + public static createToolBox(udid: string, decoder: Decoder, client: ScrcpyClient, moreBox?: HTMLElement) { + const decoderName = decoder.getName(); + const list = BUTTONS.slice(); + const handler = ( + type: K, + element: ToolBoxElement, + ) => { + if (!element.optional?.code) { + return; + } + const { code } = element.optional; + const action = type === 'mousedown' ? KeyEvent.ACTION_DOWN : KeyEvent.ACTION_UP; + const event = new KeyCodeControlMessage(action, code, 0, 0); + client.sendEvent(event); + }; + 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 (decoder.supportsScreenshot) { + const screenshot = new ToolBoxButton('Take screenshot', SvgImage.Icon.CAMERA); + screenshot.addEventListener('click', () => { + decoder.createScreenshot(client.getDeviceName()); + }); + elements.push(screenshot); + } + + const keyboard = new ToolBoxCheckbox( + 'Capture keyboard', + SvgImage.Icon.KEYBOARD, + `capture_keyboard_${udid}_${decoderName}`, + ); + keyboard.addEventListener('click', (_, el) => { + const element = el.getElement(); + client.setHandleKeyboardEvents(element.checked); + }); + elements.push(keyboard); + + if (moreBox) { + const more = new ToolBoxCheckbox('More', SvgImage.Icon.MORE, `show_more_${udid}_${decoderName}`); + more.addEventListener('click', (_, el) => { + const element = el.getElement(); + moreBox.style.display = element.checked ? 'block' : 'none'; + }); + elements.unshift(more); + } + return new DroidToolBox(elements); + } +} diff --git a/src/app/toolbox/QVHackMoreBox.ts b/src/app/toolbox/QVHackMoreBox.ts new file mode 100644 index 00000000..1227d32a --- /dev/null +++ b/src/app/toolbox/QVHackMoreBox.ts @@ -0,0 +1,77 @@ +import Decoder, { VideoResizeListener } from '../decoder/Decoder'; +import Size from '../Size'; + +export class QVHackMoreBox implements VideoResizeListener { + private onStop?: () => void; + private readonly holder: HTMLElement; + + constructor(udid: string, decoder: Decoder) { + const decoderName = decoder.getName(); + const moreBox = document.createElement('div'); + moreBox.className = 'more-box'; + const nameBox = document.createElement('p'); + nameBox.innerText = `${udid} (${decoderName})`; + nameBox.className = 'text-with-shadow'; + moreBox.appendChild(nameBox); + + const qualityId = `show_video_quality_${udid}_${decoderName}`; + const qualityLabel = document.createElement('label'); + const qualityCheck = document.createElement('input'); + qualityCheck.type = 'checkbox'; + qualityCheck.checked = Decoder.DEFAULT_SHOW_QUALITY_STATS; + qualityCheck.id = qualityId; + qualityLabel.htmlFor = qualityId; + qualityLabel.innerText = 'Show quality stats'; + QVHackMoreBox.wrap('p', [qualityCheck, qualityLabel], moreBox); + qualityCheck.onchange = () => { + decoder.setShowQualityStats(qualityCheck.checked); + }; + + const stop = (ev?: string | Event) => { + if (ev && ev instanceof Event && ev.type === 'error') { + console.error(ev); + } + const parent = moreBox.parentElement; + if (parent) { + parent.removeChild(moreBox); + } + decoder.removeResizeListener(this); + if (this.onStop) { + this.onStop(); + delete this.onStop; + } + }; + + const stopBtn = document.createElement('button') as HTMLButtonElement; + stopBtn.innerText = `Disconnect`; + stopBtn.onclick = stop; + + QVHackMoreBox.wrap('p', [stopBtn], moreBox); + decoder.addResizeListener(this); + this.holder = moreBox; + } + + public onViewVideoResize(size: Size): void { + // padding: 10px + this.holder.style.width = `${size.width - 2 * 10}px`; + } + public onInputVideoResize(/*screenInfo: ScreenInfo*/): void { + // this.connection.setScreenInfo(screenInfo); + } + + private static wrap(tagName: string, elements: HTMLElement[], parent: HTMLElement): void { + const wrap = document.createElement(tagName); + elements.forEach((e) => { + wrap.appendChild(e); + }); + parent.appendChild(wrap); + } + + public getHolderElement(): HTMLElement { + return this.holder; + } + + public setOnStop(listener: () => void) { + this.onStop = listener; + } +} diff --git a/src/app/toolbox/QVHackToolBox.ts b/src/app/toolbox/QVHackToolBox.ts new file mode 100644 index 00000000..7ba71195 --- /dev/null +++ b/src/app/toolbox/QVHackToolBox.ts @@ -0,0 +1,67 @@ +import { ToolBox } from './ToolBox'; +import SvgImage from '../ui/SvgImage'; +import Decoder from '../decoder/Decoder'; +import { ToolBoxButton } from './ToolBoxButton'; +import { ToolBoxElement } from './ToolBoxElement'; +import { ToolBoxCheckbox } from './ToolBoxCheckbox'; +import WdaConnection from '../WdaConnection'; +import { QVHackStreamClient } from '../client/QVHackStreamClient'; + +const BUTTONS = [ + { + title: 'Home', + name: 'home', + icon: SvgImage.Icon.HOME, + }, +]; + +export class QVHackToolBox extends ToolBox { + protected constructor(list: ToolBoxElement[]) { + super(list); + } + + public static createToolBox( + udid: string, + decoder: Decoder, + client: QVHackStreamClient, + wdaConnection: WdaConnection, + moreBox?: HTMLElement, + ) { + const decoderName = decoder.getName(); + const list = BUTTONS.slice(); + const handler = ( + _: K, + element: ToolBoxElement, + ) => { + if (!element.optional?.name) { + return; + } + const { name } = element.optional; + wdaConnection.wdaPressButton(name); + }; + const elements: ToolBoxElement[] = list.map((item) => { + const button = new ToolBoxButton(item.title, item.icon, { + name: item.name, + }); + button.addEventListener('click', handler); + return button; + }); + if (decoder.supportsScreenshot) { + const screenshot = new ToolBoxButton('Take screenshot', SvgImage.Icon.CAMERA); + screenshot.addEventListener('click', () => { + decoder.createScreenshot(client.getDeviceName()); + }); + elements.push(screenshot); + } + + if (moreBox) { + const more = new ToolBoxCheckbox('More', SvgImage.Icon.MORE, `show_more_${udid}_${decoderName}`); + more.addEventListener('click', (_, el) => { + const element = el.getElement(); + moreBox.style.display = element.checked ? 'block' : 'none'; + }); + elements.unshift(more); + } + return new QVHackToolBox(elements); + } +} diff --git a/src/app/toolbox/ToolBox.ts b/src/app/toolbox/ToolBox.ts new file mode 100644 index 00000000..69506314 --- /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.className = 'control-buttons-list'; + 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 00000000..45cee659 --- /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, icon, 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 00000000..b00642e3 --- /dev/null +++ b/src/app/toolbox/ToolBoxCheckbox.ts @@ -0,0 +1,28 @@ +import { Optional, ToolBoxElement } from './ToolBoxElement'; +import SvgImage, { Icon } from '../ui/SvgImage'; + +export class ToolBoxCheckbox extends ToolBoxElement { + private readonly input: HTMLInputElement; + private readonly label: HTMLLabelElement; + constructor(title: string, icon: Icon, opt_id?: string, optional?: Optional) { + super(title, icon, optional); + const input = document.createElement('input'); + input.type = 'checkbox'; + const label = document.createElement('label'); + label.title = title; + label.classList.add('control-button'); + label.appendChild(SvgImage.create(icon)); + 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 00000000..8aa44b67 --- /dev/null +++ b/src/app/toolbox/ToolBoxElement.ts @@ -0,0 +1,61 @@ +import { Icon } from '../ui/SvgImage'; + +export type Optional = { + [index: string]: any; +}; + +// type Listener = (type: K, el: ToolBoxElement) => any; + +export abstract class ToolBoxElement { + private listeners: Map< + string, + Set<(type: K, el: ToolBoxElement) => any> + > = new Map(); + protected constructor( + public readonly title: string, + public readonly icon: Icon, + 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/ui/SvgImage.ts b/src/app/ui/SvgImage.ts similarity index 62% rename from src/ui/SvgImage.ts rename to src/app/ui/SvgImage.ts index 41a2b097..41db54d3 100644 --- a/src/ui/SvgImage.ts +++ b/src/app/ui/SvgImage.ts @@ -1,14 +1,14 @@ -import KeyboardSVG from '../../images/skin-light/ic_keyboard_678_48dp.svg'; -import MoreSVG from '../../images/skin-light/ic_more_horiz_678_48dp.svg'; -import CameraSVG from '../../images/skin-light/ic_photo_camera_678_48dp.svg'; -import PowerSVG from '../../images/skin-light/ic_power_settings_new_678_48px.svg'; -import VolumeDownSVG from '../../images/skin-light/ic_volume_down_678_48px.svg'; -import VolumeUpSVG from '../../images/skin-light/ic_volume_up_678_48px.svg'; -import BackSVG from '../../images/skin-light/System_Back_678.svg'; -import HomeSVG from '../../images/skin-light/System_Home_678.svg'; -import OverviewSVG from '../../images/skin-light/System_Overview_678.svg'; +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'; -enum Icon { +export enum Icon { BACK, HOME, OVERVIEW, diff --git a/src/client/ClientDeviceTracker.ts b/src/client/ClientDeviceTracker.ts deleted file mode 100644 index 88647159..00000000 --- a/src/client/ClientDeviceTracker.ts +++ /dev/null @@ -1,202 +0,0 @@ -import * as querystring from 'querystring'; -import { NodeClient } from './NodeClient'; -import { Message } from '../common/Message'; -import { StreamParams } from './ScrcpyClient'; -import { SERVER_PORT } from '../server/Constants'; -import { ShellParams } from './ClientShell'; -import DescriptorFields from '../common/DescriptorFields'; - -type MapItem = { - field?: keyof DescriptorFields; - title: string; -}; - -const FIELDS_MAP: MapItem[] = [ - { - field: 'product.manufacturer', - title: 'Manufacturer', - }, - { - field: 'product.model', - title: 'Model', - }, - { - field: 'build.version.release', - title: 'Release', - }, - { - field: 'build.version.sdk', - title: 'SDK', - }, - { - field: 'udid', - title: 'Serial', - }, - { - field: 'state', - title: 'State', - }, - { - field: 'ip', - title: 'Wi-Fi IP', - }, - { - field: 'pid', - title: 'Pid', - }, - { - title: 'Broadway', - }, - { - title: 'MSE', - }, - { - title: 'h264bsd', - }, - { - title: 'tinyh264', - }, - { - title: 'Shell', - }, -]; - -type Decoders = 'broadway' | 'mse' | 'h264bsd' | 'tinyh264'; - -const DECODERS: Decoders[] = ['broadway', 'mse', 'h264bsd', 'tinyh264']; - -export class ClientDeviceTracker extends NodeClient { - public static ACTION = 'devicelist'; - public static start(): ClientDeviceTracker { - return new ClientDeviceTracker(ClientDeviceTracker.ACTION); - } - - constructor(action: string) { - super(action); - this.setBodyClass('list'); - this.setTitle('Device list'); - } - - protected onSocketClose(e: CloseEvent): void { - console.log(`Connection closed: ${e.reason}`); - setTimeout(() => { - this.openNewWebSocket(); - }, 2000); - } - - protected onSocketMessage(e: MessageEvent): void { - let message: Message; - try { - message = JSON.parse(e.data); - } catch (error) { - console.error(error.message); - console.log(e.data); - return; - } - if (message.type !== ClientDeviceTracker.ACTION) { - console.log(`Unknown message type: ${message.type}`); - return; - } - const list: DescriptorFields[] = message.data as DescriptorFields[]; - this.buildDeviceTable(list); - } - - private buildDeviceTable(data: DescriptorFields[]): void { - let devices = document.getElementById('devices'); - if (!devices) { - devices = document.createElement('div'); - devices.id = 'devices'; - devices.className = 'table-wrapper'; - document.body.appendChild(devices); - } - const id = 'devicesList'; - let tbody = document.querySelector(`#devices table#${id} tbody`) as Element; - if (!tbody) { - const table = document.createElement('table'); - const thead = document.createElement('thead'); - const headRow = document.createElement('tr'); - FIELDS_MAP.forEach((item) => { - const { title } = item; - const td = document.createElement('th'); - td.innerText = title; - td.className = title.toLowerCase(); - headRow.appendChild(td); - }); - thead.appendChild(headRow); - table.appendChild(thead); - tbody = document.createElement('tbody'); - table.id = id; - table.appendChild(tbody); - table.setAttribute('width', '100%'); - devices.appendChild(table); - } else { - while (tbody.children.length) { - tbody.removeChild(tbody.children[0]); - } - } - - data.forEach((device) => { - const row = document.createElement('tr'); - let hasPid = false; - let hasIp = false; - FIELDS_MAP.forEach((item) => { - if (item.field) { - const value = device[item.field].toString(); - const td = document.createElement('td'); - td.innerText = value; - row.appendChild(td); - if (item.field === 'pid') { - hasPid = value !== '-1'; - } else if (item.field === 'ip') { - hasIp = !value.includes('['); - } - } - }); - const isActive = device.state === 'device'; - DECODERS.forEach((decoderName) => { - const decoderTd = document.createElement('td'); - if (isActive) { - if (hasIp && hasPid) { - const link = ClientDeviceTracker.buildLink( - { - action: 'stream', - udid: device.udid, - decoder: decoderName, - ip: device.ip, - port: SERVER_PORT.toString(10), - }, - 'stream', - ); - decoderTd.appendChild(link); - } - } - row.appendChild(decoderTd); - }); - - const shellTd = document.createElement('td'); - if (isActive) { - shellTd.appendChild( - ClientDeviceTracker.buildLink( - { - action: 'shell', - udid: device.udid, - }, - 'shell', - ), - ); - } - row.appendChild(shellTd); - tbody.appendChild(row); - }); - } - - private static buildLink(q: StreamParams | ShellParams, text: string): HTMLAnchorElement { - const hash = `#!${querystring.encode(q)}`; - const a = document.createElement('a'); - a.setAttribute('href', `${location.origin}${location.pathname}${hash}`); - a.setAttribute('rel', 'noopener noreferrer'); - a.setAttribute('target', '_blank'); - a.innerText = text; - return a; - } -} diff --git a/src/client/ScrcpyClient.ts b/src/client/ScrcpyClient.ts deleted file mode 100644 index 8e54499d..00000000 --- a/src/client/ScrcpyClient.ts +++ /dev/null @@ -1,72 +0,0 @@ -import MseDecoder from '../decoder/MseDecoder'; -import { DeviceController } from '../DeviceController'; -import BroadwayDecoder from '../decoder/BroadwayDecoder'; -import H264bsdDecoder from '../decoder/H264bsdDecoder'; -import { ParsedUrlQueryInput } from 'querystring'; -import { BaseClient } from './BaseClient'; -import Decoder from '../decoder/Decoder'; -import Tinyh264Decoder from '../decoder/Tinyh264Decoder'; - -export type Decoders = 'broadway' | 'h264bsd' | 'mse' | 'tinyh264'; - -export interface StreamParams extends ParsedUrlQueryInput { - action: 'stream'; - udid: string; - decoder: Decoders; - ip: string; - port: string; -} - -export class ScrcpyClient extends BaseClient { - public static ACTION = 'stream'; - private static instance?: ScrcpyClient; - public static start(params: StreamParams): ScrcpyClient { - const client = this.getInstance(); - client.startStream(params.udid, params.decoder, `ws://${params.ip}:${params.port}`); - client.setBodyClass('stream'); - client.setTitle(`${params.udid} stream`); - - return client; - } - - constructor() { - super(); - ScrcpyClient.instance = this; - } - - public static getInstance(): ScrcpyClient { - return ScrcpyClient.instance || new ScrcpyClient(); - } - - public startStream(udid: string, decoderName: Decoders, url: string): Decoder | undefined { - if (!url || !udid) { - return; - } - let decoderClass: new (udid: string) => Decoder; - switch (decoderName) { - case 'mse': - decoderClass = MseDecoder; - break; - case 'broadway': - decoderClass = BroadwayDecoder; - break; - case 'h264bsd': - decoderClass = H264bsdDecoder; - break; - case 'tinyh264': - decoderClass = Tinyh264Decoder; - break; - default: - return; - } - const decoder = new decoderClass(udid); - const controller = new DeviceController({ - url, - udid, - decoder, - }); - controller.start(); - console.log(decoder.getName(), udid, url); - return decoder; - } -} diff --git a/src/common/Decoders.d.ts b/src/common/Decoders.d.ts new file mode 100644 index 00000000..eeff277a --- /dev/null +++ b/src/common/Decoders.d.ts @@ -0,0 +1 @@ +export type Decoders = 'broadway' | 'mse' | 'tinyh264'; diff --git a/src/common/DescriptorFields.d.ts b/src/common/DroidDeviceDescriptor.d.ts similarity index 84% rename from src/common/DescriptorFields.d.ts rename to src/common/DroidDeviceDescriptor.d.ts index 5a23cd87..91c539d0 100644 --- a/src/common/DescriptorFields.d.ts +++ b/src/common/DroidDeviceDescriptor.d.ts @@ -1,4 +1,4 @@ -export default interface DescriptorFields { +export default interface DroidDeviceDescriptor { 'build.version.release': string; 'build.version.sdk': string; 'ro.product.cpu.abi': string; diff --git a/src/common/Message.d.ts b/src/common/Message.d.ts index a2692505..8c3f54b1 100644 --- a/src/common/Message.d.ts +++ b/src/common/Message.d.ts @@ -1,13 +1,11 @@ -import { XtermClientMessage } from './XtermMessage'; -import DescriptorFields from './DescriptorFields'; - -export enum MessageTypes { - 'devicelist', - 'shell', +export enum MessageType { + 'devicelist' = 'devicelist', + 'shell' = 'shell', + 'run-wda' = 'run-wda', } export interface Message { id: number; - type: keyof typeof MessageTypes; - data: DescriptorFields[] | XtermClientMessage; + type: string; + data: any; } diff --git a/src/common/MessageDroidDeviceList.d.ts b/src/common/MessageDroidDeviceList.d.ts new file mode 100644 index 00000000..dd5f0637 --- /dev/null +++ b/src/common/MessageDroidDeviceList.d.ts @@ -0,0 +1,7 @@ +import { Message } from './Message'; +import DroidDeviceDescriptor from './DroidDeviceDescriptor'; + +export interface MessageDroidDeviceList extends Message { + type: 'devicelist'; + data: DroidDeviceDescriptor[]; +} diff --git a/src/common/MessageQVHackDeviceList.d.ts b/src/common/MessageQVHackDeviceList.d.ts new file mode 100644 index 00000000..97f54ead --- /dev/null +++ b/src/common/MessageQVHackDeviceList.d.ts @@ -0,0 +1,7 @@ +import { Message } from './Message'; +import QVHackDeviceDescriptor from './QVHackDeviceDescriptor'; + +export interface MessageQVHackDeviceList extends Message { + type: 'qvhack-device-list'; + data: QVHackDeviceDescriptor[]; +} diff --git a/src/common/MessageRunWda.ts b/src/common/MessageRunWda.ts new file mode 100644 index 00000000..a71d4923 --- /dev/null +++ b/src/common/MessageRunWda.ts @@ -0,0 +1,10 @@ +import { Message } from './Message'; + +export interface MessageRunWda extends Message { + type: 'run-wda'; + data: { + udid: string; + code: number; + text: string; + }; +} diff --git a/src/common/MessageXtermClient.ts b/src/common/MessageXtermClient.ts new file mode 100644 index 00000000..61b5fa6d --- /dev/null +++ b/src/common/MessageXtermClient.ts @@ -0,0 +1,7 @@ +import { Message } from './Message'; +import { XtermClientMessage } from './XtermMessage'; + +export interface MessageXtermClient extends Message { + type: 'shell'; + data: XtermClientMessage; +} diff --git a/src/common/QVHackDeviceDescriptor.d.ts b/src/common/QVHackDeviceDescriptor.d.ts new file mode 100644 index 00000000..b22c5d49 --- /dev/null +++ b/src/common/QVHackDeviceDescriptor.d.ts @@ -0,0 +1,7 @@ +export default interface QVHackDeviceDescriptor { + state: string; + ProductName: string; + ProductType: string; + Udid: string; + ProductVersion: string; +} diff --git a/src/common/QVHackStreamParams.d.ts b/src/common/QVHackStreamParams.d.ts new file mode 100644 index 00000000..df9b9f64 --- /dev/null +++ b/src/common/QVHackStreamParams.d.ts @@ -0,0 +1,8 @@ +import { ParsedUrlQueryInput } from 'querystring'; + +export interface QVHackStreamParams extends ParsedUrlQueryInput { + action: 'stream-qvh'; + udid: string; + ip: string; + port: string; +} diff --git a/src/common/ScrcpyStreamParams.d.ts b/src/common/ScrcpyStreamParams.d.ts new file mode 100644 index 00000000..15f780e7 --- /dev/null +++ b/src/common/ScrcpyStreamParams.d.ts @@ -0,0 +1,10 @@ +import { ParsedUrlQueryInput } from 'querystring'; +import { Decoders } from './Decoders'; + +export interface ScrcpyStreamParams extends ParsedUrlQueryInput { + action: 'stream'; + udid: string; + decoder: Decoders; + ip: string; + port: string; +} diff --git a/src/common/ShellParams.d.ts b/src/common/ShellParams.d.ts new file mode 100644 index 00000000..81ed2620 --- /dev/null +++ b/src/common/ShellParams.d.ts @@ -0,0 +1,6 @@ +import { ParsedUrlQueryInput } from 'querystring'; + +export interface ShellParams extends ParsedUrlQueryInput { + action: 'shell'; + udid: string; +} diff --git a/src/controlEvent/KeyCodeControlEvent.ts b/src/controlEvent/KeyCodeControlEvent.ts deleted file mode 100644 index 6df247fa..00000000 --- a/src/controlEvent/KeyCodeControlEvent.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Buffer } from 'buffer'; -import ControlEvent from './ControlEvent'; - -export default class KeyCodeControlEvent extends ControlEvent { - public static PAYLOAD_LENGTH = 13; - - constructor( - readonly action: number, - readonly keycode: number, - readonly repeat: number, - readonly metaState: number, - ) { - super(ControlEvent.TYPE_KEYCODE); - } - - /** - * @override - */ - public toBuffer(): Buffer { - const buffer = new Buffer(KeyCodeControlEvent.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); - return buffer; - } - - public toString(): string { - return `KeyCodeControlEvent{action=${this.action}, keycode=${this.keycode}, metaState=${this.metaState}}`; - } -} diff --git a/src/controlEvent/MotionControlEvent.ts b/src/controlEvent/MotionControlEvent.ts deleted file mode 100644 index 73dd4089..00000000 --- a/src/controlEvent/MotionControlEvent.ts +++ /dev/null @@ -1,29 +0,0 @@ -import ControlEvent from './ControlEvent'; -import Position from '../Position'; - -export default class MotionControlEvent extends ControlEvent { - public static PAYLOAD_LENGTH = 17; - - constructor(readonly action: number, readonly buttons: number, readonly position: Position) { - super(ControlEvent.TYPE_MOUSE); - } - - /** - * @override - */ - public toBuffer(): Buffer { - const buffer: Buffer = new Buffer(MotionControlEvent.PAYLOAD_LENGTH + 1); - buffer.writeUInt8(this.type, 0); - buffer.writeUInt8(this.action, 1); - buffer.writeUInt32BE(this.buttons, 2); - buffer.writeUInt32BE(this.position.point.x, 6); - buffer.writeUInt32BE(this.position.point.y, 10); - buffer.writeUInt16BE(this.position.screenSize.width, 14); - buffer.writeUInt16BE(this.position.screenSize.height, 16); - return buffer; - } - - public toString(): string { - return `MotionControlEvent{action=${this.action}, buttons=${this.buttons}, position=${this.position}}`; - } -} diff --git a/src/controlEvent/ScrollControlEvent.ts b/src/controlEvent/ScrollControlEvent.ts deleted file mode 100644 index 58b32428..00000000 --- a/src/controlEvent/ScrollControlEvent.ts +++ /dev/null @@ -1,29 +0,0 @@ -import ControlEvent from './ControlEvent'; -import Position from '../Position'; - -export default class ScrollControlEvent extends ControlEvent { - public static PAYLOAD_LENGTH = 20; - - constructor(readonly position: Position, readonly hScroll: number, readonly vScroll: number) { - super(ControlEvent.TYPE_SCROLL); - } - - /** - * @override - */ - public toBuffer(): Buffer { - const buffer = new Buffer(ScrollControlEvent.PAYLOAD_LENGTH + 1); - buffer.writeUInt8(this.type, 0); - buffer.writeUInt32BE(this.position.point.x, 1); - buffer.writeUInt32BE(this.position.point.y, 5); - buffer.writeUInt16BE(this.position.screenSize.width, 9); - buffer.writeUInt16BE(this.position.screenSize.height, 11); - buffer.writeUInt32BE(this.hScroll, 13); - buffer.writeUInt32BE(this.vScroll, 17); - return buffer; - } - - public toString(): string { - return `ScrollControlEvent{hScroll=${this.hScroll}, vScroll=${this.vScroll}, position=${this.position}}`; - } -} diff --git a/src/controlEvent/TextControlEvent.ts b/src/controlEvent/TextControlEvent.ts deleted file mode 100644 index 0fe7d0c9..00000000 --- a/src/controlEvent/TextControlEvent.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Buffer } from 'buffer'; -import ControlEvent from './ControlEvent'; - -export default class TextControlEvent extends ControlEvent { - private static TEXT_SIZE_FIELD_LENGTH = 4; - constructor(readonly text: string) { - super(ControlEvent.TYPE_TEXT); - } - - public getText(): string { - return this.text; - } - - /** - * @override - */ - public toBuffer(): Buffer { - const length = this.text.length; - const buffer = new Buffer(length + 1 + TextControlEvent.TEXT_SIZE_FIELD_LENGTH); - let offset = 0; - offset = buffer.writeUInt8(this.type, offset); - offset = buffer.writeUInt32BE(length, offset); - buffer.write(this.text, offset); - return buffer; - } - - public toString(): string { - return `TextControlEvent{text=${this.text}}`; - } -} diff --git a/src/decoder/H264bsdDecoder.ts b/src/decoder/H264bsdDecoder.ts deleted file mode 100644 index d3cbed77..00000000 --- a/src/decoder/H264bsdDecoder.ts +++ /dev/null @@ -1,116 +0,0 @@ -import VideoSettings from '../VideoSettings'; -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore -import { H264bsdCanvas } from '../h264bsd_canvas.js'; -import H264bsdWorker from '../h264bsd/H264bsdWorker'; -import CanvasCommon from './CanvasCommon'; -import Size from '../Size'; - -export default class H264bsdDecoder extends CanvasCommon { - public static readonly preferredVideoSettings: VideoSettings = new VideoSettings({ - lockedVideoOrientation: -1, - bitrate: 500000, - maxFps: 24, - iFrameInterval: 5, - bounds: new Size(480, 480), - sendFrameMeta: false, - }); - protected canvas?: H264bsdCanvas; - private worker?: H264bsdWorker; - public readonly supportsScreenshot: boolean = true; - - constructor(udid: string) { - super(udid, 'H264bsdDecoder'); - } - - private onWorkerMessage = (e: MessageEvent): void => { - const message = e.data; - if (!message.hasOwnProperty('type')) { - return; - } - switch (message.type) { - // Posted when onHeadersReady is called on the worker - case 'pictureParams': - const croppingParams = message.croppingParams; - if (croppingParams === null) { - this.tag.width = message.width; - this.tag.height = message.height; - } else { - this.tag.width = croppingParams.width; - this.tag.height = croppingParams.height; - } - break; - - // Posted when onPictureReady is called on the worker - case 'pictureReady': - this.onFrameDecoded(); - this.canvas.drawNextOutputPicture( - message.width, - message.height, - message.croppingParams, - new Uint8Array(message.data), - ); - break; - - // Posted after all of the queued data has been decoded - case 'noInput': - break; - - // Posted after the worker creates and configures a decoder - case 'decoderReady': - // handled in H264bsdWorker - break; - - // Error messages that line up with error codes returned by decode() - case 'decodeError': - case 'paramSetError': - case 'memAllocError': - console.error(e); - break; - default: - throw Error(`Wrong message type "${message.type}"`); - } - }; - - private initWorker(): void { - this.worker = H264bsdWorker.getInstance(); - this.worker.worker.addEventListener('message', this.onWorkerMessage); - } - - protected initCanvas(width: number, height: number): void { - super.initCanvas(width, height); - this.canvas = new H264bsdCanvas(this.tag); - } - - protected decode(data: Uint8Array): void { - if (!this.worker || !this.worker.isDecoderReady()) { - return; - } - this.worker.worker.postMessage( - { - type: 'queueInput', - data: data.buffer, - }, - [data.buffer], - ); - } - - public play(): void { - super.play(); - if (!this.worker) { - this.initWorker(); - } - } - - public stop(): void { - super.stop(); - if (this.worker && this.worker.worker) { - this.worker.worker.removeEventListener('message', this.onWorkerMessage); - delete this.worker; - } - } - - public getPreferredVideoSetting(): VideoSettings { - return H264bsdDecoder.preferredVideoSettings; - } -} diff --git a/src/h264bsd/H264bsdWorker.ts b/src/h264bsd/H264bsdWorker.ts deleted file mode 100644 index d0eceedf..00000000 --- a/src/h264bsd/H264bsdWorker.ts +++ /dev/null @@ -1,28 +0,0 @@ -export default class H264bsdWorker { - private static instance: H264bsdWorker; - public static getInstance(): H264bsdWorker { - if (!this.instance) { - this.instance = new H264bsdWorker(); - } - return this.instance; - } - - private decoderReady = false; - public readonly worker: Worker; - private constructor() { - this.worker = new Worker('h264bsd_worker.js'); - this.worker.addEventListener('message', (e: MessageEvent) => { - const message = e.data; - if (!message.hasOwnProperty('type')) { - return; - } - // Posted after the worker creates and configures a decoder - if (message.type === 'decoderReady') { - this.decoderReady = true; - } - }); - } - public isDecoderReady(): boolean { - return this.decoderReady; - } -} diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 25fd8db8..00000000 --- a/src/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -import * as querystring from 'querystring'; -import { ClientDeviceTracker } from './client/ClientDeviceTracker'; -import { ScrcpyClient, StreamParams } from './client/ScrcpyClient'; -import { ShellParams, ClientShell } from './client/ClientShell'; - -window.onload = function (): void { - const hash = location.hash.replace(/^#!/, ''); - const parsedQuery = querystring.parse(hash); - const action = parsedQuery.action; - if (action === ScrcpyClient.ACTION && typeof parsedQuery.udid === 'string') { - ScrcpyClient.start(parsedQuery as StreamParams); - } else if (action === ClientShell.ACTION && typeof parsedQuery.udid === 'string') { - ClientShell.start(parsedQuery as ShellParams); - } else { - ClientDeviceTracker.start(); - } -}; diff --git a/src/public/h264bsd_decoder.js b/src/public/h264bsd_decoder.js deleted file mode 100644 index fe3c159a..00000000 --- a/src/public/h264bsd_decoder.js +++ /dev/null @@ -1,333 +0,0 @@ -// -// Copyright (c) 2013 Sam Leitch. All rights reserved. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to -// deal in the Software without restriction, including without limitation the -// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -// IN THE SOFTWARE. -// - -/** - * This class wraps the details of the h264bsd library. - * Module object is an Emscripten module provided globally by h264bsd_asm.js - * - * In order to use this class, you first queue encoded data using queueData. - * Each call to decode() will decode a single encoded element. - * When decode() returns H264bsdDecoder.PIC_RDY, a picture is ready in the output buffer. - * You can also use the onPictureReady() function to determine when a picture is ready. - * The output buffer can be accessed by calling getNextOutputPicture() - * An output picture may also be decoded using an H264bsdCanvas. - * When you're done decoding, make sure to call release() to clean up internal buffers. - */ - -function H264bsdDecoder(module) { - this.module = module; - this.released = false; - - this.pInput = 0; - this.inputLength = 0; - this.inputOffset = 0; - - this.onPictureReady = null; - this.onHeadersReady = null; - - this.pBytesRead = module._malloc(4); - this.pPicId = module._malloc(4); - this.pIsIdrPic = module._malloc(4); - this.pNumErrMbs = module._malloc(4); - this.pCroppingFlag = module._malloc(4); - this.pLeftOffset = module._malloc(4); - this.pWidth = module._malloc(4); - this.pTopOffset = module._malloc(4); - this.pHeight = module._malloc(4); - - this.pStorage = module._h264bsdAlloc(); - module._h264bsdInit(this.pStorage, 0); -}; - -H264bsdDecoder.RDY = 0; -H264bsdDecoder.PIC_RDY = 1; -H264bsdDecoder.HDRS_RDY = 2; -H264bsdDecoder.ERROR = 3; -H264bsdDecoder.PARAM_SET_ERROR = 4; -H264bsdDecoder.MEMALLOC_ERROR = 5; -H264bsdDecoder.NO_INPUT = 1024; - -/** - * Clean up memory used by the decoder - */ -H264bsdDecoder.prototype.release = function() { - var module = this.module; - var pStorage = this.pStorage; - var pInput = this.pInput; - var pPicId = this.pPicId; - var pIsIdrPic = this.pIsIdrPic; - var pNumErrMbs = this.pNumErrMbs; - var pBytesRead = this.pBytesRead; - var pCroppingFlag = this.pCroppingFlag; - var pLeftOffset = this.pLeftOffset; - var pWidth = this.pWidth; - var pTopOffset = this.pTopOffset; - var pHeight = this.pHeight; - - if(pStorage != 0) { - module._h264bsdShutdown(pStorage); - module._h264bsdFree(pStorage); - } - - if(pInput != 0) { - module._free(pInput); - } - - module._free(pPicId); - module._free(pIsIdrPic); - module._free(pNumErrMbs); - module._free(pBytesRead); - module._free(pCroppingFlag); - module._free(pLeftOffset); - module._free(pWidth); - module._free(pTopOffset); - module._free(pHeight); - - this.pStorage = 0; - this.pInput = 0; - this.inputLength = 0; - this.inputOffset = 0; - - this.pPicId = 0; - this.pIsIdrPic = 0; - this.pNumErrMbs = 0; - this.pBytesRead = 0; - this.pCroppingFlag = 0; - this.pLeftOffset = 0; - this.pWidth = 0; - this.pTopOffset = 0; - this.pHeight = 0; -}; - -/** - * Queue ArrayBuffer data to be decoded - */ -H264bsdDecoder.prototype.queueInput = function(data) { - var module = this.module - var pInput = this.pInput; - var inputLength = this.inputLength; - var inputOffset = this.inputOffset; - - if (data instanceof ArrayBuffer) { - data = new Uint8Array(data) - } - - if(pInput === 0) { - inputLength = data.byteLength; - pInput = module._malloc(inputLength); - inputOffset = 0; - - module.HEAPU8.set(data, pInput); - } else { - var remainingInputLength = inputLength - inputOffset; - var newInputLength = remainingInputLength + data.byteLength; - var pNewInput = module._malloc(newInputLength); - - module._memcpy(pNewInput, pInput + inputOffset, remainingInputLength); - module.HEAPU8.set(data, pNewInput + remainingInputLength); - - module._free(pInput); - - pInput = pNewInput; - inputLength = newInputLength; - inputOffset = 0; - } - - this.pInput = pInput; - this.inputLength = inputLength; - this.inputOffset = inputOffset; -} - -/** - * Returns the numbre of bytes remaining in the decode queue. - */ -H264bsdDecoder.prototype.inputBytesRemaining = function() { - return this.inputLength - this.inputOffset; -}; - -/** - * Decodes the next NAL unit from the queued data. - * Returns H264bsdDecoder.PIC_RDY when a new picture is ready. - * Pictures can be accessed using nextOutputPicture() or nextOutputPictureRGBA() - * decode() will return H264bsdDecoder.NO_INPUT when there is no more data to be decoded. - */ -H264bsdDecoder.prototype.decode = function() { - var module = this.module; - var pStorage = this.pStorage; - var pInput = this.pInput; - var pBytesRead = this.pBytesRead; - var inputLength = this.inputLength; - var inputOffset = this.inputOffset; - - if(pInput == 0) return H264bsdDecoder.NO_INPUT; - - var bytesRead = 0; - var retCode = module._h264bsdDecode(pStorage, pInput + inputOffset, inputLength - inputOffset, 0, pBytesRead); - - if (retCode == H264bsdDecoder.RDY || - retCode == H264bsdDecoder.PIC_RDY || - retCode == H264bsdDecoder.HDRS_RDY) { - bytesRead = module.getValue(pBytesRead, 'i32'); - } - - inputOffset += bytesRead; - - if(inputOffset >= inputLength) { - module._free(pInput); - pInput = 0; - inputOffset = 0; - inputLength = 0; - } - - this.pInput = pInput; - this.inputLength = inputLength; - this.inputOffset = inputOffset; - - if(retCode == H264bsdDecoder.PIC_RDY && this.onPictureReady instanceof Function) { - this.onPictureReady(); - } - - if(retCode == H264bsdDecoder.HDRS_RDY && this.onHeadersReady instanceof Function) { - this.onHeadersReady(); - } - - return retCode; -}; - -/** - * Returns the next output picture as an I420 encoded image. - */ -H264bsdDecoder.prototype.nextOutputPicture = function() { - var module = this.module; - var pStorage = this.pStorage; - var pPicId = this.pPicId; - var pIsIdrPic = this.pIsIdrPic; - var pNumErrMbs = this.pNumErrMbs; - - var pBytes = module._h264bsdNextOutputPicture(pStorage, pPicId, pIsIdrPic, pNumErrMbs); - - var outputLength = this.outputPictureSizeBytes(); - var outputBytes = new Uint8Array(module.HEAPU8.subarray(pBytes, pBytes + outputLength)); - - return outputBytes; -}; - -/** - * Returns the next output picture as an RGBA encoded image. - * Note: There is extra overhead required to convert the image to RGBA. - * This method should be avoided if possible. - */ -H264bsdDecoder.prototype.nextOutputPictureRGBA = function() { - var module = this.module; - var pStorage = this.pStorage; - var pPicId = this.pPicId; - var pIsIdrPic = this.pIsIdrPic; - var pNumErrMbs = this.pNumErrMbs; - - var pBytes = module._h264bsdNextOutputPictureRGBA(pStorage, pPicId, pIsIdrPic, pNumErrMbs); - - var outputLength = this.outputPictureSizeBytesRGBA(); - var outputBytes = new Uint8Array(module.HEAPU8.subarray(pBytes, pBytes + outputLength)); - - return outputBytes; -}; - -/** - * Returns an object containing the width and height of output pictures in pixels. - * This value is only valid after at least one call to decode() has returned H264bsdDecoder.HDRS_RDY - * You can also use onHeadersReady callback to determine when this value changes. - */ -H264bsdDecoder.prototype.outputPictureWidth = function() { - var module = this.module; - var pStorage = this.pStorage; - - return module._h264bsdPicWidth(pStorage) * 16; -}; - -/** - * Returns an object containing the width and height of output pictures in pixels. - * This value is only valid after at least one call to decode() has returned H264bsdDecoder.HDRS_RDY - * You can also use onHeadersReady callback to determine when this value changes. - */ -H264bsdDecoder.prototype.outputPictureHeight = function() { - var module = this.module; - var pStorage = this.pStorage; - - return module._h264bsdPicHeight(pStorage) * 16; -}; - -/** - * Returns integer byte length of output pictures in bytes. - * This value is only valid after at least one call to decode() has returned H264bsdDecoder.HDRS_RDY - */ -H264bsdDecoder.prototype.outputPictureSizeBytes = function() { - var width = this.outputPictureWidth(); - var height = this.outputPictureHeight(); - - return (width * height) * 3 / 2; -}; - -/** - * Returns integer byte length of RGBA output pictures in bytes. - * This value is only valid after at least one call to decode() has returned H264bsdDecoder.HDRS_RDY - */ -H264bsdDecoder.prototype.outputPictureSizeBytesRGBA = function() { - var width = this.outputPictureWidth(); - var height = this.outputPictureHeight(); - - return (width * height) * 4; -}; - -/** - * Returns the info used to crop output images to there final viewing dimensions. - * If this method returns null no cropping info is provided and the full image should be presented. - */ -H264bsdDecoder.prototype.croppingParams = function() { - var module = this.module; - var pStorage = this.pStorage; - var pCroppingFlag = this.pCroppingFlag; - var pLeftOffset = this.pLeftOffset; - var pWidth = this.pWidth; - var pTopOffset = this.pTopOffset; - var pHeight = this.pHeight; - - module._h264bsdCroppingParams(pStorage, pCroppingFlag, pLeftOffset, pWidth, pTopOffset, pHeight); - - var croppingFlag = module.getValue(pCroppingFlag, 'i32'); - var leftOffset = module.getValue(pLeftOffset, 'i32'); - var width = module.getValue(pWidth, 'i32'); - var topOffset = module.getValue(pTopOffset, 'i32'); - var height = module.getValue(pHeight, 'i32'); - - if(croppingFlag === 0) return null; - - return { - 'width': width, - 'height': height, - 'top': topOffset, - 'left': leftOffset - }; -}; - -if (typeof module !== "undefined") { - module.exports = H264bsdDecoder; -} diff --git a/src/public/h264bsd_wasm.js b/src/public/h264bsd_wasm.js deleted file mode 100644 index bdd8f4aa..00000000 --- a/src/public/h264bsd_wasm.js +++ /dev/null @@ -1,4 +0,0 @@ -var Module=typeof Module!=="undefined"?Module:{};var moduleOverrides={};var key;for(key in Module){if(Module.hasOwnProperty(key)){moduleOverrides[key]=Module[key]}}Module["arguments"]=[];Module["thisProgram"]="./this.program";Module["quit"]=(function(status,toThrow){throw toThrow});Module["preRun"]=[];Module["postRun"]=[];var ENVIRONMENT_IS_WEB=false;var ENVIRONMENT_IS_WORKER=false;var ENVIRONMENT_IS_NODE=false;var ENVIRONMENT_IS_SHELL=false;ENVIRONMENT_IS_WEB=typeof window==="object";ENVIRONMENT_IS_WORKER=typeof importScripts==="function";ENVIRONMENT_IS_NODE=typeof process==="object"&&typeof require==="function"&&!ENVIRONMENT_IS_WEB&&!ENVIRONMENT_IS_WORKER;ENVIRONMENT_IS_SHELL=!ENVIRONMENT_IS_WEB&&!ENVIRONMENT_IS_NODE&&!ENVIRONMENT_IS_WORKER;var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}else{return scriptDirectory+path}}if(ENVIRONMENT_IS_NODE){scriptDirectory=__dirname+"/";var nodeFS;var nodePath;Module["read"]=function shell_read(filename,binary){var ret;if(!nodeFS)nodeFS=require("fs");if(!nodePath)nodePath=require("path");filename=nodePath["normalize"](filename);ret=nodeFS["readFileSync"](filename);return binary?ret:ret.toString()};Module["readBinary"]=function readBinary(filename){var ret=Module["read"](filename,true);if(!ret.buffer){ret=new Uint8Array(ret)}assert(ret.buffer);return ret};if(process["argv"].length>1){Module["thisProgram"]=process["argv"][1].replace(/\\/g,"/")}Module["arguments"]=process["argv"].slice(2);if(typeof module!=="undefined"){module["exports"]=Module}process["on"]("uncaughtException",(function(ex){if(!(ex instanceof ExitStatus)){throw ex}}));process["on"]("unhandledRejection",abort);Module["quit"]=(function(status){process["exit"](status)});Module["inspect"]=(function(){return"[Emscripten Module object]"})}else if(ENVIRONMENT_IS_SHELL){if(typeof read!="undefined"){Module["read"]=function shell_read(f){return read(f)}}Module["readBinary"]=function readBinary(f){var data;if(typeof readbuffer==="function"){return new Uint8Array(readbuffer(f))}data=read(f,"binary");assert(typeof data==="object");return data};if(typeof scriptArgs!="undefined"){Module["arguments"]=scriptArgs}else if(typeof arguments!="undefined"){Module["arguments"]=arguments}if(typeof quit==="function"){Module["quit"]=(function(status){quit(status)})}}else if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){if(ENVIRONMENT_IS_WORKER){scriptDirectory=self.location.href}else if(document.currentScript){scriptDirectory=document.currentScript.src}if(scriptDirectory.indexOf("blob:")!==0){scriptDirectory=scriptDirectory.substr(0,scriptDirectory.lastIndexOf("/")+1)}else{scriptDirectory=""}Module["read"]=function shell_read(url){var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.send(null);return xhr.responseText};if(ENVIRONMENT_IS_WORKER){Module["readBinary"]=function readBinary(url){var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}Module["readAsync"]=function readAsync(url,onload,onerror){var xhr=new XMLHttpRequest;xhr.open("GET",url,true);xhr.responseType="arraybuffer";xhr.onload=function xhr_onload(){if(xhr.status==200||xhr.status==0&&xhr.response){onload(xhr.response);return}onerror()};xhr.onerror=onerror;xhr.send(null)};Module["setWindowTitle"]=(function(title){document.title=title})}else{}var out=Module["print"]||(typeof console!=="undefined"?console.log.bind(console):typeof print!=="undefined"?print:null);var err=Module["printErr"]||(typeof printErr!=="undefined"?printErr:typeof console!=="undefined"&&console.warn.bind(console)||out);for(key in moduleOverrides){if(moduleOverrides.hasOwnProperty(key)){Module[key]=moduleOverrides[key]}}moduleOverrides=undefined;var asm2wasmImports={"f64-rem":(function(x,y){return x%y}),"debugger":(function(){debugger})};var functionPointers=new Array(0);var GLOBAL_BASE=1024;var ABORT=false;var EXITSTATUS=0;function assert(condition,text){if(!condition){abort("Assertion failed: "+text)}}function setValue(ptr,value,type,noSafe){type=type||"i8";if(type.charAt(type.length-1)==="*")type="i32";switch(type){case"i1":HEAP8[ptr>>0]=value;break;case"i8":HEAP8[ptr>>0]=value;break;case"i16":HEAP16[ptr>>1]=value;break;case"i32":HEAP32[ptr>>2]=value;break;case"i64":tempI64=[value>>>0,(tempDouble=value,+Math_abs(tempDouble)>=1?tempDouble>0?(Math_min(+Math_floor(tempDouble/4294967296),4294967295)|0)>>>0:~~+Math_ceil((tempDouble- +(~~tempDouble>>>0))/4294967296)>>>0:0)],HEAP32[ptr>>2]=tempI64[0],HEAP32[ptr+4>>2]=tempI64[1];break;case"float":HEAPF32[ptr>>2]=value;break;case"double":HEAPF64[ptr>>3]=value;break;default:abort("invalid type for setValue: "+type)}}function getValue(ptr,type,noSafe){type=type||"i8";if(type.charAt(type.length-1)==="*")type="i32";switch(type){case"i1":return HEAP8[ptr>>0];case"i8":return HEAP8[ptr>>0];case"i16":return HEAP16[ptr>>1];case"i32":return HEAP32[ptr>>2];case"i64":return HEAP32[ptr>>2];case"float":return HEAPF32[ptr>>2];case"double":return HEAPF64[ptr>>3];default:abort("invalid type for getValue: "+type)}return null}var UTF8Decoder=typeof TextDecoder!=="undefined"?new TextDecoder("utf8"):undefined;function UTF8ArrayToString(u8Array,idx){var endPtr=idx;while(u8Array[endPtr])++endPtr;if(endPtr-idx>16&&u8Array.subarray&&UTF8Decoder){return UTF8Decoder.decode(u8Array.subarray(idx,endPtr))}else{var str="";while(1){var u0=u8Array[idx++];if(!u0)return str;if(!(u0&128)){str+=String.fromCharCode(u0);continue}var u1=u8Array[idx++]&63;if((u0&224)==192){str+=String.fromCharCode((u0&31)<<6|u1);continue}var u2=u8Array[idx++]&63;if((u0&240)==224){u0=(u0&15)<<12|u1<<6|u2}else{u0=(u0&7)<<18|u1<<12|u2<<6|u8Array[idx++]&63}if(u0<65536){str+=String.fromCharCode(u0)}else{var ch=u0-65536;str+=String.fromCharCode(55296|ch>>10,56320|ch&1023)}}}}function UTF8ToString(ptr){return UTF8ArrayToString(HEAPU8,ptr)}var UTF16Decoder=typeof TextDecoder!=="undefined"?new TextDecoder("utf-16le"):undefined;var WASM_PAGE_SIZE=65536;function alignUp(x,multiple){if(x%multiple>0){x+=multiple-x%multiple}return x}var buffer,HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64;function updateGlobalBuffer(buf){Module["buffer"]=buffer=buf}function updateGlobalBufferViews(){Module["HEAP8"]=HEAP8=new Int8Array(buffer);Module["HEAP16"]=HEAP16=new Int16Array(buffer);Module["HEAP32"]=HEAP32=new Int32Array(buffer);Module["HEAPU8"]=HEAPU8=new Uint8Array(buffer);Module["HEAPU16"]=HEAPU16=new Uint16Array(buffer);Module["HEAPU32"]=HEAPU32=new Uint32Array(buffer);Module["HEAPF32"]=HEAPF32=new Float32Array(buffer);Module["HEAPF64"]=HEAPF64=new Float64Array(buffer)}var STATIC_BASE=1024,DYNAMIC_BASE=5256944,DYNAMICTOP_PTR=13808;function abortOnCannotGrowMemory(){abort("Cannot enlarge memory arrays. Either (1) compile with -s TOTAL_MEMORY=X with X higher than the current value "+TOTAL_MEMORY+", (2) compile with -s ALLOW_MEMORY_GROWTH=1 which allows increasing the size at runtime, or (3) if you want malloc to return NULL (0) instead of this abort, compile with -s ABORTING_MALLOC=0 ")}var byteLength;try{byteLength=Function.prototype.call.bind(Object.getOwnPropertyDescriptor(ArrayBuffer.prototype,"byteLength").get);byteLength(new ArrayBuffer(4))}catch(e){byteLength=(function(buffer){return buffer.byteLength})}var TOTAL_STACK=5242880;var TOTAL_MEMORY=Module["TOTAL_MEMORY"]||16777216;if(TOTAL_MEMORY>2]=DYNAMIC_BASE;function callRuntimeCallbacks(callbacks){while(callbacks.length>0){var callback=callbacks.shift();if(typeof callback=="function"){callback();continue}var func=callback.func;if(typeof func==="number"){if(callback.arg===undefined){Module["dynCall_v"](func)}else{Module["dynCall_vi"](func,callback.arg)}}else{func(callback.arg===undefined?null:callback.arg)}}}var __ATPRERUN__=[];var __ATINIT__=[];var __ATMAIN__=[];var __ATPOSTRUN__=[];var runtimeInitialized=false;function preRun(){if(Module["preRun"]){if(typeof Module["preRun"]=="function")Module["preRun"]=[Module["preRun"]];while(Module["preRun"].length){addOnPreRun(Module["preRun"].shift())}}callRuntimeCallbacks(__ATPRERUN__)}function ensureInitRuntime(){if(runtimeInitialized)return;runtimeInitialized=true;callRuntimeCallbacks(__ATINIT__)}function preMain(){callRuntimeCallbacks(__ATMAIN__)}function postRun(){if(Module["postRun"]){if(typeof Module["postRun"]=="function")Module["postRun"]=[Module["postRun"]];while(Module["postRun"].length){addOnPostRun(Module["postRun"].shift())}}callRuntimeCallbacks(__ATPOSTRUN__)}function addOnPreRun(cb){__ATPRERUN__.unshift(cb)}function addOnPostRun(cb){__ATPOSTRUN__.unshift(cb)}var Math_abs=Math.abs;var Math_ceil=Math.ceil;var Math_floor=Math.floor;var Math_min=Math.min;var runDependencies=0;var runDependencyWatcher=null;var dependenciesFulfilled=null;function addRunDependency(id){runDependencies++;if(Module["monitorRunDependencies"]){Module["monitorRunDependencies"](runDependencies)}}function removeRunDependency(id){runDependencies--;if(Module["monitorRunDependencies"]){Module["monitorRunDependencies"](runDependencies)}if(runDependencies==0){if(runDependencyWatcher!==null){clearInterval(runDependencyWatcher);runDependencyWatcher=null}if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}}Module["preloadedImages"]={};Module["preloadedAudios"]={};var dataURIPrefix="data:application/octet-stream;base64,";function isDataURI(filename){return String.prototype.startsWith?filename.startsWith(dataURIPrefix):filename.indexOf(dataURIPrefix)===0}var wasmBinaryFile="h264bsd_wasm.wasm";if(!isDataURI(wasmBinaryFile)){wasmBinaryFile=locateFile(wasmBinaryFile)}function mergeMemory(newBuffer){var oldBuffer=Module["buffer"];if(newBuffer.byteLength>2];return ret}),getStr:(function(){var ret=UTF8ToString(SYSCALLS.get());return ret}),get64:(function(){var low=SYSCALLS.get(),high=SYSCALLS.get();if(low>=0)assert(high===0);else assert(high===-1);return low}),getZero:(function(){assert(SYSCALLS.get()===0)})};function ___syscall140(which,varargs){SYSCALLS.varargs=varargs;try{var stream=SYSCALLS.getStreamFromFD(),offset_high=SYSCALLS.get(),offset_low=SYSCALLS.get(),result=SYSCALLS.get(),whence=SYSCALLS.get();var offset=offset_low;FS.llseek(stream,offset,whence);HEAP32[result>>2]=stream.position;if(stream.getdents&&offset===0&&whence===0)stream.getdents=null;return 0}catch(e){if(typeof FS==="undefined"||!(e instanceof FS.ErrnoError))abort(e);return-e.errno}}function ___syscall146(which,varargs){SYSCALLS.varargs=varargs;try{var stream=SYSCALLS.get(),iov=SYSCALLS.get(),iovcnt=SYSCALLS.get();var ret=0;for(var i=0;i>2];var len=HEAP32[iov+(i*8+4)>>2];for(var j=0;jLIMIT){return false}var MIN_TOTAL_MEMORY=16777216;var newSize=Math.max(oldSize,MIN_TOTAL_MEMORY);while(newSize>2]=requestedSize;return true}function _emscripten_memcpy_big(dest,src,num){HEAPU8.set(HEAPU8.subarray(src,src+num),dest)}function ___setErrNo(value){if(Module["___errno_location"])HEAP32[Module["___errno_location"]()>>2]=value;return value}Module["wasmTableSize"]=6;Module["wasmMaxTableSize"]=6;var asmGlobalArg={};Module.asmLibraryArg={"d":abort,"j":abortOnCannotGrowMemory,"c":___setErrNo,"i":___syscall140,"b":___syscall146,"h":___syscall6,"g":_emscripten_get_heap_size,"f":_emscripten_memcpy_big,"e":_emscripten_resize_heap,"a":DYNAMICTOP_PTR};var asm=Module["asm"](asmGlobalArg,Module.asmLibraryArg,buffer);Module["asm"]=asm;var ___errno_location=Module["___errno_location"]=(function(){return Module["asm"]["k"].apply(null,arguments)});var _free=Module["_free"]=(function(){return Module["asm"]["l"].apply(null,arguments)});var _h264bsdAlloc=Module["_h264bsdAlloc"]=(function(){return Module["asm"]["m"].apply(null,arguments)});var _h264bsdCroppingParams=Module["_h264bsdCroppingParams"]=(function(){return Module["asm"]["n"].apply(null,arguments)});var _h264bsdDecode=Module["_h264bsdDecode"]=(function(){return Module["asm"]["o"].apply(null,arguments)});var _h264bsdFree=Module["_h264bsdFree"]=(function(){return Module["asm"]["p"].apply(null,arguments)});var _h264bsdInit=Module["_h264bsdInit"]=(function(){return Module["asm"]["q"].apply(null,arguments)});var _h264bsdNextOutputPicture=Module["_h264bsdNextOutputPicture"]=(function(){return Module["asm"]["r"].apply(null,arguments)});var _h264bsdNextOutputPictureRGBA=Module["_h264bsdNextOutputPictureRGBA"]=(function(){return Module["asm"]["s"].apply(null,arguments)});var _h264bsdPicHeight=Module["_h264bsdPicHeight"]=(function(){return Module["asm"]["t"].apply(null,arguments)});var _h264bsdPicWidth=Module["_h264bsdPicWidth"]=(function(){return Module["asm"]["u"].apply(null,arguments)});var _h264bsdShutdown=Module["_h264bsdShutdown"]=(function(){return Module["asm"]["v"].apply(null,arguments)});var _malloc=Module["_malloc"]=(function(){return Module["asm"]["w"].apply(null,arguments)});var _memcpy=Module["_memcpy"]=(function(){return Module["asm"]["x"].apply(null,arguments)});Module["asm"]=asm;Module["setValue"]=setValue;Module["getValue"]=getValue;function ExitStatus(status){this.name="ExitStatus";this.message="Program terminated with exit("+status+")";this.status=status}ExitStatus.prototype=new Error;ExitStatus.prototype.constructor=ExitStatus;dependenciesFulfilled=function runCaller(){if(!Module["calledRun"])run();if(!Module["calledRun"])dependenciesFulfilled=runCaller};function run(args){args=args||Module["arguments"];if(runDependencies>0){return}preRun();if(runDependencies>0)return;if(Module["calledRun"])return;function doRun(){if(Module["calledRun"])return;Module["calledRun"]=true;if(ABORT)return;ensureInitRuntime();preMain();if(Module["onRuntimeInitialized"])Module["onRuntimeInitialized"]();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout((function(){setTimeout((function(){Module["setStatus"]("")}),1);doRun()}),1)}else{doRun()}}Module["run"]=run;function abort(what){if(Module["onAbort"]){Module["onAbort"](what)}if(what!==undefined){out(what);err(what);what=JSON.stringify(what)}else{what=""}ABORT=true;EXITSTATUS=1;throw"abort("+what+"). Build with -s ASSERTIONS=1 for more info."}Module["abort"]=abort;if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].pop()()}}Module["noExitRuntime"]=true;run() - - - diff --git a/src/public/h264bsd_wasm.wasm b/src/public/h264bsd_wasm.wasm deleted file mode 100644 index bc0c9d84..00000000 Binary files a/src/public/h264bsd_wasm.wasm and /dev/null differ diff --git a/src/public/h264bsd_worker.js b/src/public/h264bsd_worker.js deleted file mode 100644 index b113d917..00000000 --- a/src/public/h264bsd_worker.js +++ /dev/null @@ -1,77 +0,0 @@ -var noInput = true; -var decoder = null; - -var Module = { - onRuntimeInitialized: function() { - decoder = new H264bsdDecoder(Module); - decoder.onPictureReady = onPictureReady; - decoder.onHeadersReady = onHeadersReady; - postMessage({'type': 'decoderReady'}); - } -}; - -function onMessage(e) { - var message = e.data; - switch(message.type) { - case 'queueInput' : - decoder.queueInput(message.data); - if(noInput) { - noInput = false; - decodeLoop(); - } - break; - } -} - -function onPictureReady() { - var width = decoder.outputPictureWidth(); - var height = decoder.outputPictureHeight(); - var croppingParams = decoder.croppingParams(); - var output = decoder.nextOutputPicture(); - - postMessage({ - 'type' : 'pictureReady', - 'width' : width, - 'height' : height, - 'croppingParams' : croppingParams, - 'data' : output.buffer, - }, [output.buffer]); -} - -function onHeadersReady() { - var width = decoder.outputPictureWidth(); - var height = decoder.outputPictureHeight(); - var croppingParams = decoder.croppingParams(); - - postMessage({ - 'type' : 'pictureParams', - 'width' : width, - 'height' : height, - 'croppingParams' : croppingParams, - }); -} - -function decodeLoop() { - var result = decoder.decode(); - - switch(result) { - case H264bsdDecoder.ERROR: - postMessage({'type': 'decodeError'}); - break; - case H264bsdDecoder.PARAM_SET_ERROR: - postMessage({'type': 'paramSetError'}); - break; - case H264bsdDecoder.MEMALLOC_ERROR: - postMessage({'type': 'memAllocError'}); - break; - case H264bsdDecoder.NO_INPUT: - noInput = true; - postMessage({'type': 'noInput'}); - break; - default: - setTimeout(decodeLoop, 0); - } -} - -addEventListener('message', onMessage); -importScripts('h264bsd_decoder.js', 'h264bsd_wasm.js') diff --git a/images/multitouch/SOURCE b/src/public/images/multitouch/SOURCE similarity index 100% rename from images/multitouch/SOURCE rename to src/public/images/multitouch/SOURCE diff --git a/images/multitouch/center_point.png b/src/public/images/multitouch/center_point.png similarity index 100% rename from images/multitouch/center_point.png rename to src/public/images/multitouch/center_point.png diff --git a/images/multitouch/center_point_2x.png b/src/public/images/multitouch/center_point_2x.png similarity index 100% rename from images/multitouch/center_point_2x.png rename to src/public/images/multitouch/center_point_2x.png diff --git a/images/multitouch/touch_point.png b/src/public/images/multitouch/touch_point.png similarity index 100% rename from images/multitouch/touch_point.png rename to src/public/images/multitouch/touch_point.png diff --git a/images/multitouch/touch_point_2x.png b/src/public/images/multitouch/touch_point_2x.png similarity index 100% rename from images/multitouch/touch_point_2x.png rename to src/public/images/multitouch/touch_point_2x.png diff --git a/images/skin-light/SOURCE b/src/public/images/skin-light/SOURCE similarity index 100% rename from images/skin-light/SOURCE rename to src/public/images/skin-light/SOURCE diff --git a/images/skin-light/System_Back_678.svg b/src/public/images/skin-light/System_Back_678.svg similarity index 100% rename from images/skin-light/System_Back_678.svg rename to src/public/images/skin-light/System_Back_678.svg diff --git a/images/skin-light/System_Home_678.svg b/src/public/images/skin-light/System_Home_678.svg similarity index 100% rename from images/skin-light/System_Home_678.svg rename to src/public/images/skin-light/System_Home_678.svg diff --git a/images/skin-light/System_Overview_678.svg b/src/public/images/skin-light/System_Overview_678.svg similarity index 100% rename from images/skin-light/System_Overview_678.svg rename to src/public/images/skin-light/System_Overview_678.svg diff --git a/images/skin-light/ic_keyboard_678_48dp.svg b/src/public/images/skin-light/ic_keyboard_678_48dp.svg similarity index 100% rename from images/skin-light/ic_keyboard_678_48dp.svg rename to src/public/images/skin-light/ic_keyboard_678_48dp.svg diff --git a/images/skin-light/ic_more_horiz_678_48dp.svg b/src/public/images/skin-light/ic_more_horiz_678_48dp.svg similarity index 100% rename from images/skin-light/ic_more_horiz_678_48dp.svg rename to src/public/images/skin-light/ic_more_horiz_678_48dp.svg diff --git a/images/skin-light/ic_photo_camera_678_48dp.svg b/src/public/images/skin-light/ic_photo_camera_678_48dp.svg similarity index 100% rename from images/skin-light/ic_photo_camera_678_48dp.svg rename to src/public/images/skin-light/ic_photo_camera_678_48dp.svg diff --git a/images/skin-light/ic_power_settings_new_678_48px.svg b/src/public/images/skin-light/ic_power_settings_new_678_48px.svg similarity index 100% rename from images/skin-light/ic_power_settings_new_678_48px.svg rename to src/public/images/skin-light/ic_power_settings_new_678_48px.svg diff --git a/images/skin-light/ic_volume_down_678_48px.svg b/src/public/images/skin-light/ic_volume_down_678_48px.svg similarity index 100% rename from images/skin-light/ic_volume_down_678_48px.svg rename to src/public/images/skin-light/ic_volume_down_678_48px.svg diff --git a/images/skin-light/ic_volume_up_678_48px.svg b/src/public/images/skin-light/ic_volume_up_678_48px.svg similarity index 100% rename from images/skin-light/ic_volume_up_678_48px.svg rename to src/public/images/skin-light/ic_volume_up_678_48px.svg diff --git a/src/public/index.html b/src/public/index.html index 79bb4931..394398c4 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -4,9 +4,6 @@ WS scrcpy - - - diff --git a/src/server/Constants.ts b/src/server/Constants.ts index bd9e880f..2f0f376a 100644 --- a/src/server/Constants.ts +++ b/src/server/Constants.ts @@ -37,3 +37,8 @@ const ARGUMENTS = [ ]; export const ARGS_STRING = `/ ${SERVER_PACKAGE} ${ARGUMENTS.join(' ')} 2>&1 > /dev/null`; + +export enum ACTION { + DEVICE_LIST = 'droid-device-list', + SHELL = 'shell', +} diff --git a/src/server/DeviceDescriptor.ts b/src/server/DeviceDescriptor.ts index c704bd86..31289431 100644 --- a/src/server/DeviceDescriptor.ts +++ b/src/server/DeviceDescriptor.ts @@ -1,4 +1,4 @@ -import DescriptorFields from '../common/DescriptorFields'; +import DroidDeviceDescriptor from '../common/DroidDeviceDescriptor'; export class DeviceDescriptor { public releaseVersion: string; @@ -12,7 +12,7 @@ export class DeviceDescriptor { public ip: string; public pid: number; - constructor(fields: DescriptorFields) { + constructor(fields: DroidDeviceDescriptor) { this.releaseVersion = fields['build.version.release']; this.sdkVersion = fields['build.version.sdk']; this.cpuAbi = fields['ro.product.cpu.abi']; @@ -25,7 +25,7 @@ export class DeviceDescriptor { this.pid = fields['pid']; } - public toJSON(): DescriptorFields { + public toJSON(): DroidDeviceDescriptor { return { 'build.version.release': this.releaseVersion, 'build.version.sdk': this.sdkVersion, @@ -40,7 +40,7 @@ export class DeviceDescriptor { }; } - public equals(fields: DescriptorFields): boolean { + public equals(fields: DroidDeviceDescriptor): boolean { return !( this.udid !== fields['udid'] || this.state !== fields['state'] || diff --git a/src/server/ServerDeviceConnection.ts b/src/server/ServerDeviceConnection.ts index 89036875..d0c68c55 100644 --- a/src/server/ServerDeviceConnection.ts +++ b/src/server/ServerDeviceConnection.ts @@ -1,14 +1,17 @@ +import '../../vendor/Genymobile/scrcpy/scrcpy-server.jar'; +import '../../vendor/Genymobile/scrcpy/LICENSE.txt'; + import ADB, { AdbKitChangesSet, AdbKitClient, AdbKitDevice, AdbKitTracker, PushTransfer } from 'adbkit'; import { EventEmitter } from 'events'; import { spawn } from 'child_process'; import * as path from 'path'; import { DeviceDescriptor } from './DeviceDescriptor'; import { ARGS_STRING, SERVER_PACKAGE, SERVER_VERSION } from './Constants'; -import DescriptorFields from '../common/DescriptorFields'; +import DroidDeviceDescriptor from '../common/DroidDeviceDescriptor'; import Timeout = NodeJS.Timeout; const TEMP_PATH = '/data/local/tmp/'; -const FILE_DIR = path.join(__dirname, '../public'); +const FILE_DIR = path.join(__dirname, 'vendor/Genymobile/scrcpy'); const FILE_NAME = 'scrcpy-server.jar'; const GET_SHELL_PROCESSES = 'for DIR in /proc/*; do [ -d "$DIR" ] && echo $DIR; done'; @@ -26,7 +29,7 @@ export class ServerDeviceConnection extends EventEmitter { public static readonly UPDATE_EVENT: string = 'update'; private static instance: ServerDeviceConnection; private pendingUpdate = false; - private cache: DescriptorFields[] = []; + private cache: DroidDeviceDescriptor[] = []; private deviceDescriptors: Map = new Map(); private clientMap: Map = new Map(); private client: AdbKitClient = ADB.createClient(); @@ -132,7 +135,7 @@ export class ServerDeviceConnection extends EventEmitter { }); } - private updateDescriptor(fields: DescriptorFields): DeviceDescriptor { + private updateDescriptor(fields: DroidDeviceDescriptor): DeviceDescriptor { const { udid } = fields; let descriptor = this.deviceDescriptors.get(udid); if (!descriptor || !descriptor.equals(fields)) { @@ -187,7 +190,7 @@ export class ServerDeviceConnection extends EventEmitter { const props = await client.getProperties(udid); const wifi = props['wifi.interface']; const stored = this.deviceDescriptors.get(udid); - const fields: DescriptorFields = { + const fields: DroidDeviceDescriptor = { pid: stored ? stored.pid : -1, ip: stored ? stored.ip : LABEL.DETECTION, 'ro.product.cpu.abi': props['ro.product.cpu.abi'], @@ -315,7 +318,7 @@ export class ServerDeviceConnection extends EventEmitter { .then(anyway, anyway); } - public getDevices(): DescriptorFields[] { + public getDevices(): DroidDeviceDescriptor[] { this.updateDeviceList(); return this.cache; } diff --git a/src/server/ServiceDeviceTracker.ts b/src/server/ServiceDeviceTracker.ts index e6769e1c..87c11599 100644 --- a/src/server/ServiceDeviceTracker.ts +++ b/src/server/ServiceDeviceTracker.ts @@ -2,7 +2,7 @@ import WebSocket from 'ws'; import { ServerDeviceConnection } from './ServerDeviceConnection'; import { ReleasableService } from './ReleasableService'; import { Message } from '../common/Message'; -import DescriptorFields from '../common/DescriptorFields'; +import DroidDeviceDescriptor from '../common/DroidDeviceDescriptor'; export class ServiceDeviceTracker extends ReleasableService { private sdc: ServerDeviceConnection = ServerDeviceConnection.getInstance(); @@ -21,7 +21,7 @@ export class ServiceDeviceTracker extends ReleasableService { }); } - private buildAndSendMessage = (list: DescriptorFields[]): void => { + private buildAndSendMessage = (list: DroidDeviceDescriptor[]): void => { const msg: Message = { id: -1, type: 'devicelist', diff --git a/src/server/index.ts b/src/server/index.ts index ab4aa103..da9bc5af 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -9,6 +9,7 @@ import * as readline from 'readline'; import { IncomingMessage, ServerResponse, STATUS_CODES } from 'http'; import { ServiceDeviceTracker } from './ServiceDeviceTracker'; import { ServiceShell } from './ServiceShell'; +import { ACTION } from './Constants'; const port = parseInt(process.argv[2], 10) || 8000; const map: Record = { @@ -72,10 +73,10 @@ wss.on('connection', async (ws: WebSocket, req) => { ws.close(4002, `Missing required parameter "action"`); } switch (parsedQuery.action) { - case 'devicelist': + case ACTION.DEVICE_LIST: ServiceDeviceTracker.createService(ws); break; - case 'shell': + case ACTION.SHELL: ServiceShell.createService(ws); break; default: diff --git a/src/public/style.css b/src/style/app.css similarity index 99% rename from src/public/style.css rename to src/style/app.css index 8b72d144..438a3d32 100644 --- a/src/public/style.css +++ b/src/style/app.css @@ -68,6 +68,10 @@ body.stream { outline: none; } +.wait { + cursor: wait; +} + #controls { width: 100%; margin: 20px; diff --git a/src/vendor/h264bsd_canvas.js b/src/vendor/h264bsd_canvas.js deleted file mode 100644 index 56b32463..00000000 --- a/src/vendor/h264bsd_canvas.js +++ /dev/null @@ -1,294 +0,0 @@ -// -// Copyright (c) 2014 Sam Leitch. All rights reserved. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to -// deal in the Software without restriction, including without limitation the -// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -// IN THE SOFTWARE. -// - -/** - * This class can be used to render output pictures from an H264bsdDecoder to a canvas element. - * If available the content is rendered using WebGL. - */ -function H264bsdCanvas(canvas, forceNoGL) { - this.canvasElement = canvas; - - if(!forceNoGL) this.initContextGL(); - - if(this.contextGL) { - this.initProgram(); - this.initBuffers(); - this.initTextures(); - } -} - -/** - * Returns true if the canvas supports WebGL - */ -H264bsdCanvas.prototype.isWebGL = function() { - return this.contextGL; -} - -/** - * Create the GL context from the canvas element - */ -H264bsdCanvas.prototype.initContextGL = function() { - var canvas = this.canvasElement; - var gl = null; - - var validContextNames = ["webgl", "experimental-webgl", "moz-webgl", "webkit-3d"]; - var nameIndex = 0; - - while(!gl && nameIndex < validContextNames.length) { - var contextName = validContextNames[nameIndex]; - - try { - gl = canvas.getContext(contextName, { - preserveDrawingBuffer: true - }); - } catch (e) { - gl = null; - } - - if(!gl || typeof gl.getParameter !== "function") { - gl = null; - } - - ++nameIndex; - } - - this.contextGL = gl; -} - -/** - * Initialize GL shader program - */ -H264bsdCanvas.prototype.initProgram = function() { - var gl = this.contextGL; - - var vertexShaderScript = [ - 'attribute vec4 vertexPos;', - 'attribute vec4 texturePos;', - 'varying vec2 textureCoord;', - - 'void main()', - '{', - 'gl_Position = vertexPos;', - 'textureCoord = texturePos.xy;', - '}' - ].join('\n'); - - var fragmentShaderScript = [ - 'precision highp float;', - 'varying highp vec2 textureCoord;', - 'uniform sampler2D ySampler;', - 'uniform sampler2D uSampler;', - 'uniform sampler2D vSampler;', - 'const mat4 YUV2RGB = mat4', - '(', - '1.1643828125, 0, 1.59602734375, -.87078515625,', - '1.1643828125, -.39176171875, -.81296875, .52959375,', - '1.1643828125, 2.017234375, 0, -1.081390625,', - '0, 0, 0, 1', - ');', - - 'void main(void) {', - 'highp float y = texture2D(ySampler, textureCoord).r;', - 'highp float u = texture2D(uSampler, textureCoord).r;', - 'highp float v = texture2D(vSampler, textureCoord).r;', - 'gl_FragColor = vec4(y, u, v, 1) * YUV2RGB;', - '}' - ].join('\n'); - - var vertexShader = gl.createShader(gl.VERTEX_SHADER); - gl.shaderSource(vertexShader, vertexShaderScript); - gl.compileShader(vertexShader); - if(!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)) { - console.log('Vertex shader failed to compile: ' + gl.getShaderInfoLog(vertexShader)); - } - - var fragmentShader = gl.createShader(gl.FRAGMENT_SHADER); - gl.shaderSource(fragmentShader, fragmentShaderScript); - gl.compileShader(fragmentShader); - if(!gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS)) { - console.log('Fragment shader failed to compile: ' + gl.getShaderInfoLog(fragmentShader)); - } - - var program = gl.createProgram(); - gl.attachShader(program, vertexShader); - gl.attachShader(program, fragmentShader); - gl.linkProgram(program); - if(!gl.getProgramParameter(program, gl.LINK_STATUS)) { - console.log('Program failed to compile: ' + gl.getProgramInfoLog(program)); - } - - gl.useProgram(program); - - this.shaderProgram = program; -} - -/** - * Initialize vertex buffers and attach to shader program - */ -H264bsdCanvas.prototype.initBuffers = function() { - var gl = this.contextGL; - var program = this.shaderProgram; - - var vertexPosBuffer = gl.createBuffer(); - gl.bindBuffer(gl.ARRAY_BUFFER, vertexPosBuffer); - gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([1, 1, -1, 1, 1, -1, -1, -1]), gl.STATIC_DRAW); - - var vertexPosRef = gl.getAttribLocation(program, 'vertexPos'); - gl.enableVertexAttribArray(vertexPosRef); - gl.vertexAttribPointer(vertexPosRef, 2, gl.FLOAT, false, 0, 0); - - var texturePosBuffer = gl.createBuffer(); - gl.bindBuffer(gl.ARRAY_BUFFER, texturePosBuffer); - gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([1, 0, 0, 0, 1, 1, 0, 1]), gl.STATIC_DRAW); - - var texturePosRef = gl.getAttribLocation(program, 'texturePos'); - gl.enableVertexAttribArray(texturePosRef); - gl.vertexAttribPointer(texturePosRef, 2, gl.FLOAT, false, 0, 0); - - this.texturePosBuffer = texturePosBuffer; -} - -/** - * Initialize GL textures and attach to shader program - */ -H264bsdCanvas.prototype.initTextures = function() { - var gl = this.contextGL; - var program = this.shaderProgram; - - var yTextureRef = this.initTexture(); - var ySamplerRef = gl.getUniformLocation(program, 'ySampler'); - gl.uniform1i(ySamplerRef, 0); - this.yTextureRef = yTextureRef; - - var uTextureRef = this.initTexture(); - var uSamplerRef = gl.getUniformLocation(program, 'uSampler'); - gl.uniform1i(uSamplerRef, 1); - this.uTextureRef = uTextureRef; - - var vTextureRef = this.initTexture(); - var vSamplerRef = gl.getUniformLocation(program, 'vSampler'); - gl.uniform1i(vSamplerRef, 2); - this.vTextureRef = vTextureRef; -} - -/** - * Create and configure a single texture - */ -H264bsdCanvas.prototype.initTexture = function() { - var gl = this.contextGL; - - var textureRef = gl.createTexture(); - gl.bindTexture(gl.TEXTURE_2D, textureRef); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); - gl.bindTexture(gl.TEXTURE_2D, null); - - return textureRef; -} - -/** - * Draw picture data to the canvas. - * If this object is using WebGL, the data must be an I420 formatted ArrayBuffer, - * Otherwise, data must be an RGBA formatted ArrayBuffer. - */ -H264bsdCanvas.prototype.drawNextOutputPicture = function(width, height, croppingParams, data) { - var gl = this.contextGL; - - if(gl) { - this.drawNextOuptutPictureGL(width, height, croppingParams, data); - } else { - this.drawNextOuptutPictureRGBA(width, height, croppingParams, data); - } -} - -/** - * Draw the next output picture using WebGL - */ -H264bsdCanvas.prototype.drawNextOuptutPictureGL = function(width, height, croppingParams, data) { - var gl = this.contextGL; - var texturePosBuffer = this.texturePosBuffer; - var yTextureRef = this.yTextureRef; - var uTextureRef = this.uTextureRef; - var vTextureRef = this.vTextureRef; - - if(croppingParams === null) { - gl.viewport(0, 0, width, height); - } else { - gl.viewport(0, 0, croppingParams.width, croppingParams.height); - - var tTop = croppingParams.top / height; - var tLeft = croppingParams.left / width; - var tBottom = croppingParams.height / height; - var tRight = croppingParams.width / width; - var texturePosValues = new Float32Array([tRight, tTop, tLeft, tTop, tRight, tBottom, tLeft, tBottom]); - - gl.bindBuffer(gl.ARRAY_BUFFER, texturePosBuffer); - gl.bufferData(gl.ARRAY_BUFFER, texturePosValues, gl.DYNAMIC_DRAW); - } - - var i420Data = data; - - var yDataLength = width * height; - var yData = i420Data.subarray(0, yDataLength); - gl.activeTexture(gl.TEXTURE0); - gl.bindTexture(gl.TEXTURE_2D, yTextureRef); - gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE, width, height, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, yData); - - var cbDataLength = width/2 * height/2; - var cbData = i420Data.subarray(yDataLength, yDataLength + cbDataLength); - gl.activeTexture(gl.TEXTURE1); - gl.bindTexture(gl.TEXTURE_2D, uTextureRef); - gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE, width/2, height/2, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, cbData); - - var crDataLength = cbDataLength; - var crData = i420Data.subarray(yDataLength + cbDataLength, yDataLength + cbDataLength + crDataLength); - gl.activeTexture(gl.TEXTURE2); - gl.bindTexture(gl.TEXTURE_2D, vTextureRef); - gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE, width/2, height/2, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, crData); - - gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); -} - -/** - * Draw next output picture using ARGB data on a 2d canvas. - */ -H264bsdCanvas.prototype.drawNextOuptutPictureRGBA = function(width, height, croppingParams, data) { - var canvas = this.canvasElement; - - var croppingParams = null; - - var argbData = data; - - var ctx = canvas.getContext('2d'); - var imageData = ctx.getImageData(0, 0, width, height); - imageData.data.set(argbData); - - if(croppingParams === null) { - ctx.putImageData(imageData, 0, 0); - } else { - ctx.putImageData(imageData, -croppingParams.left, -croppingParams.top, 0, 0, croppingParams.width, croppingParams.height); - } -} - -exports.H264bsdCanvas = H264bsdCanvas; diff --git a/tsconfig.json b/tsconfig.json index a321b4a8..2bdbd034 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -46,8 +46,8 @@ /* Source Map Options */ // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */ - // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ - // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ + , "inlineSourceMap": true /* Emit a single file with source maps instead of having a separate file. */ + , "inlineSources": true /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ /* Experimental Options */ // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ diff --git a/typings/worker-loader.d.ts b/typings/worker-loader.d.ts new file mode 100644 index 00000000..f019a00a --- /dev/null +++ b/typings/worker-loader.d.ts @@ -0,0 +1,10 @@ +declare module 'worker-loader!*' { + // You need to change `Worker`, if you specified a different value for the `workerType` option + class WebpackWorker extends Worker { + constructor(); + } + + // Uncomment this if you set the `esModule` option to `false` + // export = WebpackWorker; + export default WebpackWorker; +} diff --git a/src/vendor/AUTHORS b/vendor/Broadway/AUTHORS similarity index 100% rename from src/vendor/AUTHORS rename to vendor/Broadway/AUTHORS diff --git a/vendor/Broadway/Decoder.d.ts b/vendor/Broadway/Decoder.d.ts new file mode 100644 index 00000000..682c1aa5 --- /dev/null +++ b/vendor/Broadway/Decoder.d.ts @@ -0,0 +1,6 @@ +declare class Avc { + public onPictureDecoded: (buffer: Uint8Array, width: number, height: number) => void; + public decode(data: Uint8Array): void; +} + +export = Avc; diff --git a/src/vendor/Decoder.js b/vendor/Broadway/Decoder.js similarity index 100% rename from src/vendor/Decoder.js rename to vendor/Broadway/Decoder.js diff --git a/src/vendor/LICENSE b/vendor/Broadway/LICENSE similarity index 100% rename from src/vendor/LICENSE rename to vendor/Broadway/LICENSE diff --git a/src/public/avc.wasm b/vendor/Broadway/avc.wasm.asset similarity index 100% rename from src/public/avc.wasm rename to vendor/Broadway/avc.wasm.asset diff --git a/vendor/Genymobile/scrcpy/LICENSE.txt b/vendor/Genymobile/scrcpy/LICENSE.txt new file mode 100644 index 00000000..bc4bb77d --- /dev/null +++ b/vendor/Genymobile/scrcpy/LICENSE.txt @@ -0,0 +1,204 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright (C) 2018 Genymobile + Copyright (C) 2018-2020 Romain Vimont + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/src/public/scrcpy-server.jar b/vendor/Genymobile/scrcpy/scrcpy-server.jar similarity index 100% rename from src/public/scrcpy-server.jar rename to vendor/Genymobile/scrcpy/scrcpy-server.jar diff --git a/webpack.config.js b/webpack.config.js deleted file mode 100644 index 88de9231..00000000 --- a/webpack.config.js +++ /dev/null @@ -1,41 +0,0 @@ -const path = require('path') - -module.exports = { - entry: './build/index.js', - output: { - filename: 'bundle.js', - path: path.resolve(__dirname, 'dist/public'), - }, - externals: ['fs'], - module: { - rules: [ - { - test: /\.worker\.js$/, - use: { loader: 'worker-loader' } - }, - { - test: /\.svg$/, - loader: 'svg-inline-loader' - }, - { - test: /\.(png|jpe?g|gif)$/i, - use: [ - { - loader: 'file-loader', - }, - ], - }, - { - test: /\.(asset)$/i, - use: [ - { - loader: 'file-loader', - options: { - name: '[name]', - }, - }, - ], - } - ] - } -} diff --git a/webpack.qvhack.config.js b/webpack.qvhack.config.js new file mode 100644 index 00000000..f9292095 --- /dev/null +++ b/webpack.qvhack.config.js @@ -0,0 +1,64 @@ +const HtmlWebpackPlugin = require('html-webpack-plugin'); +const MiniCssExtractPlugin = require('mini-css-extract-plugin'); +const path = require('path'); + +module.exports = { + entry: './src/app/MainQVHackOnly.ts', + externals: ['fs'], + mode: 'development', + devtool: 'inline-source-map', + module: { + rules: [ + { + test: /\.css$/i, + use: [MiniCssExtractPlugin.loader, 'css-loader'], + }, + { + test: /\.tsx?$/, + use: 'ts-loader', + exclude: /node_modules/, + }, + { + test: /\.worker\.js$/, + use: { loader: 'worker-loader' } + }, + { + test: /\.svg$/, + loader: 'svg-inline-loader' + }, + { + test: /\.(png|jpe?g|gif)$/i, + use: [ + { + loader: 'file-loader', + }, + ], + }, + { + test: /\.(asset)$/i, + use: [ + { + loader: 'file-loader', + options: { + name: '[name]', + }, + }, + ], + } + ] + }, + plugins: [ + new HtmlWebpackPlugin({ + template: __dirname + "/src/public/index.html", + inject: 'head' + }), + new MiniCssExtractPlugin() + ], + resolve: { + extensions: [ '.tsx', '.ts', '.js' ], + }, + output: { + filename: 'bundle.js', + path: path.resolve(__dirname, 'dist'), + }, +}; diff --git a/webpack.ws-scrcpy.config.js b/webpack.ws-scrcpy.config.js new file mode 100644 index 00000000..cc19e210 --- /dev/null +++ b/webpack.ws-scrcpy.config.js @@ -0,0 +1,102 @@ +const HtmlWebpackPlugin = require('html-webpack-plugin'); +const MiniCssExtractPlugin = require('mini-css-extract-plugin'); +const nodeExternals = require('webpack-node-externals'); +const path = require('path'); + +const common = { + mode: 'development', + devtool: 'inline-source-map', + module: { + rules: [ + { + test: /\.css$/i, + use: [MiniCssExtractPlugin.loader, 'css-loader'], + }, + { + test: /\.tsx?$/, + use: 'ts-loader', + exclude: /node_modules/, + }, + { + test: /\.worker\.js$/, + use: { loader: 'worker-loader' } + }, + { + test: /\.svg$/, + loader: 'svg-inline-loader' + }, + { + test: /\.(png|jpe?g|gif)$/i, + use: [ + { + loader: 'file-loader', + }, + ], + }, + { + test: /\.(asset)$/i, + use: [ + { + loader: 'file-loader', + options: { + name: '[name]', + }, + }, + ], + }, + { + test: /vendor\/Genymobile/, + use: [ + { + loader: 'file-loader', + options: { + name: '[path][name].[ext]' + } + } + ] + } + ] + }, + resolve: { + extensions: [ '.tsx', '.ts', '.js' ], + }, + +} + +const frontend = { + entry: './src/app/index.ts', + externals: ['fs'], + plugins: [ + new HtmlWebpackPlugin({ + template: __dirname + "/src/public/index.html", + inject: 'head' + }), + new MiniCssExtractPlugin() + ], + output: { + filename: 'bundle.js', + path: path.resolve(__dirname, 'dist/public'), + }, +}; + +const backend = { + entry: './src/server/index.ts', + externals: [nodeExternals()], + devtool: 'inline-source-map', + mode: 'development', + node: { + global: false, + __filename: false, + __dirname: false, + }, + output: { + filename: 'index.js', + path: path.resolve(__dirname, 'dist/server'), + }, + target: 'node', +} + +module.exports = [ + Object.assign({} , common, frontend), + Object.assign({} , common, backend) +];