diff --git a/lib/cli/device/index.js b/lib/cli/device/index.js index f8d1069ffb..49eddf59f5 100644 --- a/lib/cli/device/index.js +++ b/lib/cli/device/index.js @@ -168,6 +168,11 @@ export const builder = function(yargs) { type: 'boolean', default: false }) + .option('need-vnc', { + describe: 'Need a running VNC server', + type: 'boolean', + default: false + }) .option('device-name', { describe: 'Device name', type: 'string', @@ -240,6 +245,7 @@ export const handler = function(argv) { urlWithoutAdbPort: argv.urlWithoutAdbPort, deviceCode: argv.deviceCode, secret: argv.secret, - disableLogsOverWire: argv.disableLogsOverWire + disableLogsOverWire: argv.disableLogsOverWire, + needVnc: argv.needVnc }) } diff --git a/lib/cli/provider/index.js b/lib/cli/provider/index.js index ff19d4e07c..4c5121a10f 100644 --- a/lib/cli/provider/index.js +++ b/lib/cli/provider/index.js @@ -172,6 +172,11 @@ export const builder = function(yargs) { type: 'boolean', default: false }) + .option('need-vnc', { + describe: 'Need a running VNC server', + type: 'boolean', + default: false + }) .option('vnc-port', { describe: 'Port allocated to VNC connections.', type: 'number', @@ -240,7 +245,7 @@ export const handler = function(argv) { }, allowRemote: argv.allowRemote, fork: function(device, ports) { - var args = [ + const args = [ 'device', '--serial', device.serial, '--device-name', argv.deviceName, @@ -268,7 +273,8 @@ export const handler = function(argv) { '--url-without-adb-port', argv.urlWithoutAdbPort, '--device-code', argv.deviceCode, '--secret', argv.secret, - '--disable-logs-over-wire', argv.disableLogsOverWire + '--disable-logs-over-wire', argv.disableLogsOverWire, + '--need-vnc', argv.needVnc ] .concat(argv.connectSub.reduce(function(all, val) { return all.concat(['--connect-sub', val]) diff --git a/lib/units/base-device/support/connector.js b/lib/units/base-device/support/connector.js index 4e48ca0bd6..5010d0dc1d 100755 --- a/lib/units/base-device/support/connector.js +++ b/lib/units/base-device/support/connector.js @@ -44,7 +44,7 @@ export default syrup.serial() /** @param {ConnectorInitOptions} opt*/ init({handlers, serial, storageUrl, deviceType, urlWithoutAdbPort}) { - this.handlers = handlers + this.handlers = {start: handlers.start, stop: handlers.stop} this.serial = serial this.reply = wireutil.reply(serial) this.storageUrl = storageUrl diff --git a/lib/units/device/index.ts b/lib/units/device/index.ts index 449935d910..01c0916bfc 100644 --- a/lib/units/device/index.ts +++ b/lib/units/device/index.ts @@ -28,12 +28,13 @@ import sd from './plugins/sd.js' import filesystem from './plugins/filesystem.js' import mobileService from './plugins/mobile-service.js' import remotedebug from './plugins/remotedebug.js' +import vnc from './plugins/vnc/index.js' import {trackModuleReadyness} from './readyness.js' -import wireutil from '../../wire/util.js' -import wire from '../../wire/index.js' +import wireutil, {DEVICE_STATUS_MAP} from '../../wire/util.js' import push from '../base-device/support/push.js' import adb from './support/adb.js' import router from '../base-device/support/router.js' +import {DeviceIntroductionMessage, DeviceRegisteredMessage, ProviderMessage} from "../../wire/wire.js"; export default (function(options: any) { return syrup.serial() @@ -51,7 +52,7 @@ export default (function(options: any) { let listener: ((...args: any[]) => void) | null = null const waitRegister = Promise.race([ new Promise(resolve => - router.on(wire.DeviceRegisteredMessage, listener = (...args: any[]) => resolve(args)) + router.on(DeviceRegisteredMessage, listener = (...args: any[]) => resolve(args)) ), new Promise(r => setTimeout(r, 15000)) ]) @@ -59,11 +60,20 @@ export default (function(options: any) { const type = await adb.getDevice(options.serial).getState() push?.send([ wireutil.global, - wireutil.envelope(new wire.DeviceIntroductionMessage(options.serial, wireutil.toDeviceStatus(type), new wire.ProviderMessage(solo.channel, `standalone-${options.serial}`))) + wireutil.pack(DeviceIntroductionMessage, { + serial: options.serial, + + // TODO: Verify that @u4/adbkit statuses are correct + status: wireutil.toDeviceStatus(type as keyof typeof DEVICE_STATUS_MAP), + provider: ProviderMessage.create({ + channel: solo.channel, + name: `standalone-${options.serial}` + }) + }) ]) await waitRegister - router.removeListener(wire.DeviceRegisteredMessage, listener!) + router.removeListener(DeviceRegisteredMessage, listener!) listener = null } @@ -72,17 +82,18 @@ export default (function(options: any) { .dependency(trackModuleReadyness('stream', stream)) .dependency(trackModuleReadyness('capture', capture)) .dependency(trackModuleReadyness('service', service)) + .dependency(trackModuleReadyness('shell', shell)) + .dependency(trackModuleReadyness('touch', touch)) + .dependency(trackModuleReadyness('group', group)) + .dependency(trackModuleReadyness('vnc', vnc)) .dependency(trackModuleReadyness('browser', browser)) .dependency(trackModuleReadyness('store', store)) .dependency(trackModuleReadyness('airplane', airplane)) .dependency(trackModuleReadyness('clipboard', clipboard)) .dependency(trackModuleReadyness('logcat', logcat)) .dependency(trackModuleReadyness('mute', mute)) - .dependency(trackModuleReadyness('shell', shell)) - .dependency(trackModuleReadyness('touch', touch)) .dependency(trackModuleReadyness('install', install)) .dependency(trackModuleReadyness('forward', forward)) - .dependency(trackModuleReadyness('group', group)) .dependency(trackModuleReadyness('cleanup', cleanup)) .dependency(trackModuleReadyness('reboot', reboot)) .dependency(trackModuleReadyness('connect', connect)) @@ -94,7 +105,11 @@ export default (function(options: any) { .dependency(trackModuleReadyness('filesystem', filesystem)) .dependency(trackModuleReadyness('mobileService', mobileService)) .dependency(trackModuleReadyness('remotedebug', remotedebug)) - .define((options, heartbeat) => { + .define((options, heartbeat, stream, capture, service, shell, touch, group, vnc) => { + if (options.needVnc) { + vnc.start() + } + if (process.send) { // Only if we have a parent process process.send('ready') diff --git a/lib/units/device/plugins/cleanup.js b/lib/units/device/plugins/cleanup.js deleted file mode 100644 index a80c7f1dc2..0000000000 --- a/lib/units/device/plugins/cleanup.js +++ /dev/null @@ -1,69 +0,0 @@ -import syrup from '@devicefarmer/stf-syrup' -import Promise from 'bluebird' -import _ from 'lodash' -import logger from '../../../util/logger.js' -import adb from '../support/adb.js' -import service from '../resources/service.js' -import group from './group.js' -import service$0 from './service.js' -export default syrup.serial() - .dependency(adb) - .dependency(service) - .dependency(group) - .dependency(service$0) - .define(function(options, adb, stfservice, group, service) { - var log = logger.createLogger('device:plugins:cleanup') - var plugin = Object.create(null) - if (!options.cleanup) { - return plugin - } - function listPackages() { - return Promise.resolve().then(async() => await adb.getDevice(options.serial).getPackages()) - } - function uninstallPackage(pkg) { - log.info('Cleaning up package "%s"', pkg) - return adb.getDevice(options.serial).uninstall(pkg) - .catch(function(err) { - log.warn('Unable to clean up package "%s"', pkg, err) - return true - }) - } - return listPackages() - .then(function(initialPackages) { - initialPackages.push(stfservice.pkg) - plugin.removePackages = function() { - return listPackages() - .then(function(currentPackages) { - var remove = _.difference(currentPackages, initialPackages) - return Promise.map(remove, uninstallPackage) - }) - } - plugin.disableBluetooth = function() { - if (!options.cleanupDisableBluetooth) { - return - } - return service.getBluetoothStatus() - .then(function(enabled) { - if (enabled) { - log.info('Disabling Bluetooth') - return service.setBluetoothEnabled(false) - } - }) - } - plugin.cleanBluetoothBonds = function() { - if (!options.cleanupBluetoothBonds) { - return - } - log.info('Cleanup Bluetooth bonds') - return service.cleanBluetoothBonds() - } - group.on('leave', function() { - Promise.all([ - plugin.removePackages(), - plugin.cleanBluetoothBonds(), - plugin.disableBluetooth() - ]) - }) - }) - .return(plugin) - }) diff --git a/lib/units/device/plugins/cleanup.ts b/lib/units/device/plugins/cleanup.ts new file mode 100644 index 0000000000..429f8a1356 --- /dev/null +++ b/lib/units/device/plugins/cleanup.ts @@ -0,0 +1,78 @@ +import syrup from '@devicefarmer/stf-syrup' +import logger from '../../../util/logger.js' +import adb from '../support/adb.js' +import group from './group.js' +import service$0 from './service.js' + +export default syrup.serial() + .dependency(adb) + .dependency(group) + .dependency(service$0) + .define(async(options, adb, group, service) => { + if (!options.cleanup) { + return + } + + const log = logger.createLogger('device:plugins:cleanup') + + const getInstalledApps = async() => ( + await adb.getDevice(options.serial).execOut('pm list packages -3') || '' + ) + .toString() + .trim() + .split('\n') + .map(pkg => pkg.trim().substring(8)) + + const checkpoint = await getInstalledApps() + log.info('Saved checkpoint of installed apps: %s', checkpoint.join(', ')) + + const uninstall = (pkg: string) => { + try { + log.info('Cleaning up package "%s"', pkg) + return adb.getDevice(options.serial).uninstall(pkg) + } catch (err: any) { + log.warn('Unable to clean up package "%s": %s', pkg, err?.message) + } + } + + const removePackages = async() => { + const apps = await getInstalledApps() + const newApps = apps.filter(app => !checkpoint.includes(app)) + + if (!newApps.length) return + log.info('Cleaning: %s', newApps.join(', ')) + + for (const pkg of newApps) { + await uninstall(pkg) + } + + log.info('Cleaning completed') + } + + const disableBluetooth = async() => { + if (!options.cleanupDisableBluetooth) { + return + } + + const enabled = await service.getBluetoothStatus() + if (enabled) { + log.info('Disabling Bluetooth') + return service.setBluetoothEnabled(false) + } + } + + const cleanBluetoothBonds = () => { + if (!options.cleanupBluetoothBonds) { + return + } + + log.info('Cleanup Bluetooth bonds') + return service.cleanBluetoothBonds() + } + + group.on('leave', async() => { + await removePackages() + await cleanBluetoothBonds() + await disableBluetooth() + }) + }) diff --git a/lib/units/device/plugins/group.ts b/lib/units/device/plugins/group.ts index c628918805..361d19024b 100644 --- a/lib/units/device/plugins/group.ts +++ b/lib/units/device/plugins/group.ts @@ -59,11 +59,6 @@ export default syrup.serial() service.sendCommand('pm clear com.android.chrome'), service.sendCommand('pm clear com.chrome.beta'), service.sendCommand('pm clear com.sec.android.app.sbrowser'), - service.sendCommand('pm uninstall com.vkontakte.android'), - service.sendCommand('pm uninstall com.vk.im'), - service.sendCommand('pm uninstall com.vk.clips'), - service.sendCommand('pm uninstall com.vk.calls'), - service.sendCommand('pm uninstall com.vk.admin'), service.sendCommand('pm clear com.mi.globalbrowser'), service.sendCommand('pm clear com.microsoft.emmx'), service.sendCommand('pm clear com.huawei.browser'), diff --git a/lib/units/device/plugins/screen/stream.js b/lib/units/device/plugins/screen/stream.js deleted file mode 100644 index 5305c28868..0000000000 --- a/lib/units/device/plugins/screen/stream.js +++ /dev/null @@ -1,618 +0,0 @@ -import util from 'util' -import Promise from 'bluebird' -import syrup from '@devicefarmer/stf-syrup' -import WebSocket from 'ws' -import {v4 as uuidv4} from 'uuid' -import EventEmitter from 'eventemitter3' -import split from 'split' -import {Adb} from '@u4/adbkit' -import logger from '../../../../util/logger.js' -import lifecycle from '../../../../util/lifecycle.js' -import * as bannerutil from './util/banner.js' -import FrameParser from './util/frameparser.js' -import FrameConfig from './util/frameconfig.js' -import BroadcastSet from './util/broadcastset.js' -import StateQueue from '../../../../util/statequeue.js' -import RiskyStream from '../../../../util/riskystream.js' -import FailCounter from '../../../../util/failcounter.js' -import wire from '../../../../wire/index.js' -import adb from '../../support/adb.js' -import router from '../../../base-device/support/router.js' -import minicap from '../../resources/minicap.js' -import scrcpy from '../../resources/scrcpy.js' -import display from '../util/display.js' -import options from './options.js' -import group from '../group.js' -import * as jwtutil from '../../../../util/jwtutil.js' -import {NoGroupError} from '../../../../util/grouputil.js' -import {ChangeQualityMessage} from '../../../../wire/wire.js' -export default syrup.serial() - .dependency(adb) - .dependency(router) - .dependency(minicap) - .dependency(scrcpy) - .dependency(display) - .dependency(options) - .dependency(group) - .define(function(options, adb, router, minicap, scrcpy, display, screenOptions, group) { - let log = logger.createLogger('device:plugins:screen:stream') - log.info('ScreenGrabber option set to %s', options.screenGrabber) - log.info('ScreenFrameRate option set to %d', options.screenFrameRate) - const scrcpyClient = new scrcpy.Scrcpy() - function FrameProducer(config, grabber) { - EventEmitter.call(this) - this.actionQueue = [] - this.runningState = FrameProducer.STATE_STOPPED - this.desiredState = new StateQueue() - this.output = null - this.socket = null - this.pid = -1 - this.banner = null - this.parser = null - this.frameConfig = config - this.grabber = options.screenGrabber - this.readable = false - this.needsReadable = false - this.failCounter = new FailCounter(3, 10000) - this.failCounter.on('exceedLimit', this._failLimitExceeded.bind(this)) - this.failed = false - this.readableListener = this._readableListener.bind(this) - } - util.inherits(FrameProducer, EventEmitter) - FrameProducer.STATE_STOPPED = 1 - FrameProducer.STATE_STARTING = 2 - FrameProducer.STATE_STARTED = 3 - FrameProducer.STATE_STOPPING = 4 - FrameProducer.prototype._ensureState = function() { - if (this.desiredState.empty()) { - return - } - if (this.failed) { - log.warn('Will not apply desired state due to too many failures') - return - } - switch (this.runningState) { - case FrameProducer.STATE_STARTING: - case FrameProducer.STATE_STOPPING: - // Just wait. - break - case FrameProducer.STATE_STOPPED: - if (this.desiredState.next() === FrameProducer.STATE_STARTED) { - this.runningState = FrameProducer.STATE_STARTING - if (options.needScrcpy) { - scrcpyClient.start() - .then((function() { - this.runningState = FrameProducer.STATE_STARTED - this.emit('start') - }).bind(this)) - } - else { - this._startService().bind(this) - .then(function(out) { - this.output = new RiskyStream(out) - .on('unexpectedEnd', this._outputEnded.bind(this)) - return this._readOutput(this.output.stream) - }) - .then(function() { - return this._waitForPid() - }) - .then(function() { - return this._connectService() - }) - .then(function(socket) { - this.parser = new FrameParser() - this.socket = new RiskyStream(socket) - .on('unexpectedEnd', this._socketEnded.bind(this)) - return this._readBanner(this.socket.stream) - }) - .then(function(banner) { - this.banner = banner - return this._readFrames(this.socket.stream) - }) - .then(function() { - this.runningState = FrameProducer.STATE_STARTED - this.emit('start') - }) - .catch(Promise.CancellationError, function() { - return this._stop() - }) - .catch(function(err) { - return this._stop().finally(function() { - this.failCounter.inc() - this.grabber = 'minicap-apk' - }) - }) - .finally(function() { - this._ensureState() - }) - } - } - else { - setImmediate(this._ensureState.bind(this)) - } - break - case FrameProducer.STATE_STARTED: - if (this.desiredState.next() === FrameProducer.STATE_STOPPED) { - this.runningState = FrameProducer.STATE_STOPPING - this._stop().finally(function() { - this._ensureState() - }) - } - else { - setImmediate(this._ensureState.bind(this)) - } - break - } - } - FrameProducer.prototype.start = function() { - log.info('Requesting frame producer to start') - this.desiredState.push(FrameProducer.STATE_STARTED) - this._ensureState() - } - FrameProducer.prototype.stop = function() { - log.info('Requesting frame producer to stop') - this.desiredState.push(FrameProducer.STATE_STOPPED) - this._ensureState() - } - FrameProducer.prototype.restart = function() { - switch (this.runningState) { - case FrameProducer.STATE_STARTED: - case FrameProducer.STATE_STARTING: - this.desiredState.push(FrameProducer.STATE_STOPPED) - this.desiredState.push(FrameProducer.STATE_STARTED) - this._ensureState() - break - } - } - FrameProducer.prototype.updateRotation = function(rotation) { - if (this.frameConfig.rotation === rotation) { - log.info('Keeping %d as current frame producer rotation', rotation) - return - } - log.info('Setting frame producer rotation to %d', rotation) - this.frameConfig.rotation = rotation - this._configChanged() - } - FrameProducer.prototype.changeQuality = function(newQuality) { - log.info('Setting frame producer quality to %d', newQuality) - this.frameConfig.quality = newQuality - this._configChanged() - } - FrameProducer.prototype.updateProjection = function(width, height) { - if (this.frameConfig.virtualWidth === width && - this.frameConfig.virtualHeight === height) { - log.info('Keeping %dx%d as current frame producer projection', width, height) - return - } - log.info('Setting frame producer projection to %dx%d', width, height) - this.frameConfig.virtualWidth = width - this.frameConfig.virtualHeight = height - this._configChanged() - } - FrameProducer.prototype.nextFrame = function() { - var frame = null - var chunk - if (this.parser) { - while ((frame = this.parser.nextFrame()) === null) { - chunk = this.socket.stream.read() - if (chunk) { - this.parser.push(chunk) - } - else { - this.readable = false - break - } - } - } - return frame - } - FrameProducer.prototype.needFrame = function() { - this.needsReadable = true - this._maybeEmitReadable() - } - FrameProducer.prototype._configChanged = function() { - this.restart() - } - FrameProducer.prototype._socketEnded = function() { - log.warn('Connection to minicap ended unexpectedly') - this.failCounter.inc() - this.restart() - } - FrameProducer.prototype._outputEnded = function() { - log.warn('Shell keeping minicap running ended unexpectedly') - this.failCounter.inc() - this.restart() - } - FrameProducer.prototype._failLimitExceeded = function(limit, time) { - this._stop() - this.failed = true - this.emit('error', new Error(util.format('Failed more than %d times in %dms', limit, time))) - } - FrameProducer.prototype._startService = function() { - log.info('Launching screen service %s', this.grabber) - if (options.screenFrameRate <= 0.0) { - return minicap.run(this.grabber, util.format('-S -Q %d -P %s', this.frameConfig.quality, this.frameConfig.toString())) - .timeout(10000) - } - else { - return minicap.run(this.grabber, util.format('-S -r %d -Q %d -P %s', options.screenFrameRate, this.frameConfig.quality, this.frameConfig.toString())) - .timeout(10000) - } - } - FrameProducer.prototype._readOutput = function(out) { - out.pipe(split()).on('data', function(line) { - var trimmed = line.toString().trim() - if (trimmed === '') { - return - } - if (/ERROR/.test(line)) { - log.fatal('minicap error: "%s"', line) - return lifecycle.fatal() - } - var match = /^PID: (\d+)$/.exec(line) - if (match) { - this.pid = Number(match[1]) - this.emit('pid', this.pid) - } - log.info('minicap says: "%s"', line) - }.bind(this)) - } - FrameProducer.prototype._waitForPid = function() { - if (this.pid > 0) { - return Promise.resolve(this.pid) - } - var pidListener - return new Promise(function(resolve) { - this.on('pid', pidListener = resolve) - }.bind(this)).bind(this) - .timeout(5000) - .finally(function() { - this.removeListener('pid', pidListener) - }) - } - FrameProducer.prototype._connectService = function() { - function tryConnect(times, delay) { - return adb.getDevice(options.serial).openLocal('localabstract:minicap') - .then(function(out) { - return out - }) - .catch(function(err) { - if (/closed/.test(err.message) && times > 1) { - return Promise.delay(delay) - .then(function() { - log.info('Retrying connect to minicap service') - return tryConnect(times - 1, delay + 100) // non exp, if need exponential use - delay * 2 - }) - } - return Promise.reject(err) - }) - } - log.info('Connecting to minicap service') - return tryConnect(10, 100) - } - FrameProducer.prototype._stop = function() { - return this._disconnectService(this.socket).bind(this) - .timeout(2000) - .then(function() { - return this._stopService(this.output).timeout(10000) - }) - .then(function() { - this.runningState = FrameProducer.STATE_STOPPED - this.emit('stop') - }) - .catch(function(err) { - // In practice we _should_ never get here due to _stopService() - // being quite aggressive. But if we do, well... assume it - // stopped anyway for now. - this.runningState = FrameProducer.STATE_STOPPED - this.emit('error', err) - this.emit('stop') - }) - .finally(function() { - this.output = null - this.socket = null - this.pid = -1 - this.banner = null - this.parser = null - }) - } - FrameProducer.prototype._disconnectService = function(socket) { - log.info('Disconnecting from minicap service') - if (!socket || socket.ended) { - return Promise.resolve(true) - } - socket.stream.removeListener('readable', this.readableListener) - var endListener - return new Promise(function(resolve) { - socket.on('end', endListener = function() { - resolve(true) - }) - socket.stream.resume() - socket.end() - }) - .finally(function() { - socket.removeListener('end', endListener) - }) - } - FrameProducer.prototype._stopService = function(output) { - log.info('Stopping minicap service') - if (!output || output.ended) { - return Promise.resolve(true) - } - var pid = this.pid - function kill(signal) { - if (pid <= 0) { - return Promise.reject(new Error('Minicap service pid is unknown')) - } - var signum = { - SIGTERM: -15, - SIGKILL: -9 - }[signal] - log.info('Sending %s to minicap', signal) - return Promise.all([ - output.waitForEnd(), - adb.getDevice(options.serial).shell(['kill', signum, pid]) - .then(Adb.util.readAll) - ]) - .timeout(2000) - } - function kindKill() { - return kill('SIGTERM') - } - function forceKill() { - return kill('SIGKILL') - } - function forceEnd() { - log.info('Ending minicap I/O as a last resort') - output.end() - return Promise.resolve(true) - } - return kindKill() - .catch(Promise.TimeoutError, forceKill) - .catch(forceEnd) - } - FrameProducer.prototype._readBanner = function(socket) { - log.info('Reading minicap banner') - return bannerutil.read(socket).timeout(2000) - } - FrameProducer.prototype._readFrames = function(socket) { - this.needsReadable = true - socket.on('readable', this.readableListener) - // We may already have data pending. Let the user know they should - // at least attempt to read frames now. - this.readableListener() - } - FrameProducer.prototype._maybeEmitReadable = function() { - if (this.readable && this.needsReadable) { - this.needsReadable = false - this.emit('readable') - } - } - FrameProducer.prototype._readableListener = function() { - this.readable = true - this._maybeEmitReadable() - } - function createServer() { - log.info('Starting WebSocket server on port %d', screenOptions.publicPort) - var wss = new WebSocket.Server({ - port: screenOptions.publicPort, - perMessageDeflate: false - }) - var listeningListener, errorListener - return new Promise(function(resolve, reject) { - listeningListener = function() { - return resolve(wss) - } - errorListener = function(err) { - return reject(err) - } - wss.on('listening', listeningListener) - wss.on('error', errorListener) - }) - .finally(function() { - wss.removeListener('listening', listeningListener) - wss.removeListener('error', errorListener) - }) - } - return createServer() - .then(function(wss) { - log.info('creating FrameProducer: %s', options.screenGrabber) - var frameProducer = new FrameProducer(new FrameConfig(display.properties, display.properties, options.screenJpegQuality)) - var broadcastSet = frameProducer.broadcastSet = new BroadcastSet() - broadcastSet.on('nonempty', function() { - frameProducer.start() - }) - broadcastSet.on('empty', function() { - frameProducer.stop() - }) - broadcastSet.on('insert', function(id) { - // If two clients join a session in the middle, one of them - // may not release the initial size because the projection - // doesn't necessarily change, and the producer doesn't Getting - // restarted. Therefore we have to call onStart() manually - // if the producer is already up and running. - switch (frameProducer.runningState) { - case FrameProducer.STATE_STARTED: - broadcastSet.get(id).onStart(frameProducer) - break - } - }) - display.on('rotationChange', function(newRotation) { - frameProducer.updateRotation(newRotation) - }) - router.on(ChangeQualityMessage, function(channel, message) { - frameProducer.changeQuality(message.quality) - }) - frameProducer.on('start', function() { - broadcastSet.keys().map(function(id) { - return broadcastSet.get(id).onStart(frameProducer) - }) - }) - frameProducer.on('readable', function next() { - var frame = frameProducer.nextFrame() - if (frame) { - Promise.settle([broadcastSet.keys().map(function(id) { - return broadcastSet.get(id).onFrame(frame) - })]).then(next) - } - else { - frameProducer.needFrame() - } - }) - frameProducer.on('error', function(err) { - log.fatal('Frame producer had an error', err.stack) - lifecycle.fatal() - }) - wss.on('connection', async function(ws, req) { - let id = uuidv4() - let pingTimer - - // Extract token from WebSocket subprotocols - const token = ws.protocol.substring('access_token.'.length) - const user = !!token && jwtutil.decode(token, options.secret) - - if (!token || !user) { - log.warn('WebSocket connection attempt without token from %s', req.socket.remoteAddress) - ws.send(JSON.stringify({ - type: 'auth_error', - message: 'Authentication token required' - })) - ws.close(1008, 'Authentication token required') - return - } - - const tryCheckDeviceGroup = async(fail = false) => { - try { - await new Promise(r => setTimeout(r, 200)) - - const deviceGroup = await group.get() - if (deviceGroup.email !== user?.email) { - const err = 'Device used by another user' - log.warn('WebSocket authentication failed for device %s: $s', options.serial, err) - ws.send(JSON.stringify({ - type: 'auth_error', - message: err - })) - ws.close(1008, 'Authentication failed') - return - } - - log.info('WebSocket authenticated for device %s', options.serial) - - // Send success message - ws.send(JSON.stringify({ - type: 'auth_success', - message: 'Authentication successful' - })) - - // Sending a ping message every now and then makes sure that - // reverse proxies like nginx don't time out the connection [1]. - // - // [1] http://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_read_timeout - pingTimer = setInterval(wsPingNotifier, 10 * 60000) // options.screenPingInterval - } - catch (/** @type {any} */err) { - if (!fail && err instanceof NoGroupError) { - await new Promise(r => setTimeout(r, 1000)) - return tryCheckDeviceGroup(true) - } - - log.error('WebSocket authentication error for device %s: %s', options.serial, err.message) - ws.send(JSON.stringify({ - type: 'auth_error', - message: 'Authentication error' - }), () => {}) - ws.close(1008, 'Authentication error') - } - } - - await tryCheckDeviceGroup() - - function send(message, options) { - return new Promise(function(resolve, reject) { - switch (ws.readyState) { - case WebSocket.OPENING: - // This should never happen. - log.warn('Unable to send to OPENING client "%s"', id) - break - case WebSocket.OPEN: - // This is what SHOULD happen. - ws.send(message, options, function(err) { - return err ? reject(err) : resolve() - }) - break - case WebSocket.CLOSING: - // Ok, a 'close' event should remove the client from the set - // soon. - break - case WebSocket.CLOSED: - // This should never happen. - log.warn('Unable to send to CLOSED client "%s"', id) - clearInterval(pingTimer) - broadcastSet.remove(id) - break - } - }) - } - function wsStartNotifier() { - return send(util.format('start %s', JSON.stringify(frameProducer.banner))) - } - function wsPingNotifier() { - return send('ping') - } - function wsFrameNotifier(frame) { - return send(frame, { - binary: true - }) - } - ws.on('message', function(data) { - var match = /^(on|off|(size) ([0-9]+)x([0-9]+))$/.exec(data) - if (match) { - switch (match[2] || match[1]) { - case 'on': - broadcastSet.insert(id, { - onStart: wsStartNotifier, - onFrame: wsFrameNotifier - }) - break - case 'off': - broadcastSet.remove(id) - // Keep pinging even when the screen is off. - break - case 'size': - frameProducer.updateProjection(Number(match[3]), Number(match[4])) - break - } - } - }) - ws.on('close', function() { - if (pingTimer) { - clearInterval(pingTimer) - } - broadcastSet.remove(id) - log.info('WebSocket closed for device %s', options.serial) - }) - if (options.needScrcpy) { - log.info(`Scrcpy client has gotten for device s/n ${options.serial}`) - scrcpyClient.on('rawData', (data) => { - console.log(`Data: ${data}`) - send(data, {binary: true}) - }) - } - ws.on('error', function(e) { - if (pingTimer) { - clearInterval(pingTimer) - } - broadcastSet.remove(id) - log.error('WebSocket error for device %s: %s', options.serial, e.message) - }) - }) - lifecycle.observe(function() { - wss.close() - }) - lifecycle.observe(function() { - frameProducer.stop() - }) - return frameProducer - }) - }) diff --git a/lib/units/device/plugins/screen/stream.ts b/lib/units/device/plugins/screen/stream.ts new file mode 100644 index 0000000000..800fe5ede2 --- /dev/null +++ b/lib/units/device/plugins/screen/stream.ts @@ -0,0 +1,757 @@ +import util from 'util' +import syrup from '@devicefarmer/stf-syrup' +import WebSocket from 'ws' +import {v4 as uuidv4} from 'uuid' +import EventEmitter from 'eventemitter3' +import split from 'split' +import {Adb} from '@u4/adbkit' +import {Readable} from 'stream' +import logger from '../../../../util/logger.js' +import lifecycle from '../../../../util/lifecycle.js' +import * as bannerutil from './util/banner.js' +import {Banner} from './util/banner.js' +import FrameParser from './util/frameparser.js' +import FrameConfig from './util/frameconfig.js' +import BroadcastSet from './util/broadcastset.js' +import StateQueue from '../../../../util/statequeue.js' +import RiskyStream from '../../../../util/riskystream.js' +import FailCounter from '../../../../util/failcounter.js' +import adb from '../../support/adb.js' +import router from '../../../base-device/support/router.js' +import minicap from '../../resources/minicap.js' +import scrcpy from '../../resources/scrcpy.js' +import display from '../util/display.js' +import options from './options.js' +import group from '../group.js' +import * as jwtutil from '../../../../util/jwtutil.js' +import {NoGroupError} from '../../../../util/grouputil.js' +import {ChangeQualityMessage} from '../../../../wire/wire.js' + +// Utility functions to replace Bluebird +class TimeoutError extends Error { + constructor(message: string = 'Operation timed out') { + super(message) + this.name = 'TimeoutError' + } +} + +class CancellationError extends Error { + constructor(message: string = 'Promise was cancelled') { + super(message) + this.name = 'CancellationError' + } +} + +function withTimeout(promise: Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new TimeoutError(`Operation timed out after ${ms}ms`)) + }, ms) + + promise + .then((value) => { + clearTimeout(timer) + resolve(value) + }) + .catch((error) => { + clearTimeout(timer) + reject(error) + }) + }) +} + +function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +type FrameProducerState = 1 | 2 | 3 | 4 + +interface ScreenOptions { + publicPort: number + screenPingInterval?: number +} + +interface DeviceOptions { + serial: string + screenGrabber: string + screenFrameRate: number + screenJpegQuality: number + needScrcpy: boolean + secret: string +} + +interface DisplayProperties { + width: number + height: number + rotation: number +} + +export default syrup.serial() + .dependency(adb) + .dependency(router) + .dependency(minicap) + .dependency(scrcpy) + .dependency(display) + .dependency(options) + .dependency(group) + .define((options: DeviceOptions, adb: any, router: any, minicap: any, scrcpy: any, display: any, screenOptions: ScreenOptions, group: any) => { + const log = logger.createLogger('device:plugins:screen:stream') + log.info('ScreenGrabber option set to %s', options.screenGrabber) + log.info('ScreenFrameRate option set to %s', options.screenFrameRate) + const scrcpyClient = new scrcpy.Scrcpy() + + class FrameProducer extends EventEmitter { + static readonly STATE_STOPPED: FrameProducerState = 1 + static readonly STATE_STARTING: FrameProducerState = 2 + static readonly STATE_STARTED: FrameProducerState = 3 + static readonly STATE_STOPPING: FrameProducerState = 4 + + public actionQueue: any[] + public runningState: FrameProducerState + public desiredState: StateQueue + public output: RiskyStream | null + public socket: RiskyStream | null + public pid: number + public banner: Banner | null + public parser: FrameParser | null + public frameConfig: FrameConfig + public grabber: string + public readable: boolean + public needsReadable: boolean + public failCounter: FailCounter + public failed: boolean + public broadcastSet?: BroadcastSet + private readableListener: () => void + + constructor(config: FrameConfig, grabber: string) { + super() + this.actionQueue = [] + this.runningState = FrameProducer.STATE_STOPPED + this.desiredState = new StateQueue() + this.output = null + this.socket = null + this.pid = -1 + this.banner = null + this.parser = null + this.frameConfig = config + this.grabber = options.screenGrabber + this.readable = false + this.needsReadable = false + this.failCounter = new FailCounter(3, 10000) + this.failCounter.on('exceedLimit', this._failLimitExceeded.bind(this)) + this.failed = false + this.readableListener = this._readableListener.bind(this) + } + + private async _ensureState(): Promise { + if (this.desiredState.empty()) { + return + } + + if (this.failed) { + log.warn('Will not apply desired state due to too many failures') + return + } + + switch (this.runningState) { + case FrameProducer.STATE_STARTING: + case FrameProducer.STATE_STOPPING: + // Just wait. + break + case FrameProducer.STATE_STOPPED: + if (this.desiredState.next() === FrameProducer.STATE_STARTED) { + this.runningState = FrameProducer.STATE_STARTING + if (options.needScrcpy) { + try { + await scrcpyClient.start() + this.runningState = FrameProducer.STATE_STARTED + this.emit('start') + } catch (err: any) { + log.error('Scrcpy start failed: %s', err?.message || err) + } + } + else { + try { + const out = await this._startService() + this.output = new RiskyStream(out) + .on('unexpectedEnd', this._outputEnded.bind(this)) + await this._readOutput(this.output.stream) + await this._waitForPid() + const socket = await this._connectService() + this.parser = new FrameParser() + this.socket = new RiskyStream(socket) + .on('unexpectedEnd', this._socketEnded.bind(this)) + const banner = await this._readBanner(this.socket.stream) + this.banner = banner + await this._readFrames(this.socket.stream) + this.runningState = FrameProducer.STATE_STARTED + this.emit('start') + } catch (err) { + if (err instanceof CancellationError) { + await this._stop() + } else { + await this._stop() + this.failCounter.inc() + this.grabber = 'minicap-apk' + } + } finally { + await this._ensureState() + } + } + } + else { + setImmediate(() => this._ensureState()) + } + break + case FrameProducer.STATE_STARTED: + if (this.desiredState.next() === FrameProducer.STATE_STOPPED) { + this.runningState = FrameProducer.STATE_STOPPING + try { + await this._stop() + } finally { + await this._ensureState() + } + } + else { + setImmediate(() => this._ensureState()) + } + break + } + } + + public start(): void { + log.info('Requesting frame producer to start') + this.desiredState.push(FrameProducer.STATE_STARTED) + this._ensureState() + } + + public stop(): void { + log.info('Requesting frame producer to stop') + this.desiredState.push(FrameProducer.STATE_STOPPED) + this._ensureState() + } + + public restart(): void { + switch (this.runningState) { + case FrameProducer.STATE_STARTED: + case FrameProducer.STATE_STARTING: + this.desiredState.push(FrameProducer.STATE_STOPPED) + this.desiredState.push(FrameProducer.STATE_STARTED) + this._ensureState() + break + } + } + + public updateRotation(rotation: number): void { + if (this.frameConfig.rotation === rotation) { + log.info('Keeping %s as current frame producer rotation', rotation) + return + } + log.info('Setting frame producer rotation to %s', rotation) + this.frameConfig.rotation = rotation + this._configChanged() + } + + public changeQuality(newQuality: number): void { + log.info('Setting frame producer quality to %s', newQuality) + this.frameConfig.quality = newQuality + this._configChanged() + } + + public updateProjection(width: number, height: number): void { + if (this.frameConfig.virtualWidth === width && + this.frameConfig.virtualHeight === height) { + log.info('Keeping %sx%s as current frame producer projection', width, height) + return + } + log.info('Setting frame producer projection to %sx%s', width, height) + this.frameConfig.virtualWidth = width + this.frameConfig.virtualHeight = height + this._configChanged() + } + + public nextFrame(): Buffer | null { + let frame: Buffer | null = null + let chunk: Buffer | null + + if (this.parser && this.socket) { + while ((frame = this.parser.nextFrame()) === null) { + chunk = this.socket.stream.read() + if (chunk) { + this.parser.push(chunk) + } + else { + this.readable = false + break + } + } + } + return frame + } + + public needFrame(): void { + this.needsReadable = true + this._maybeEmitReadable() + } + + private _configChanged(): void { + this.restart() + } + + private _socketEnded(): void { + log.warn('Connection to minicap ended unexpectedly') + this.failCounter.inc() + this.restart() + } + + private _outputEnded(): void { + log.warn('Shell keeping minicap running ended unexpectedly') + this.failCounter.inc() + this.restart() + } + + private _failLimitExceeded(limit: number, time: number): void { + this._stop() + this.failed = true + this.emit('error', new Error(util.format('Failed more than %s times in %sms', limit, time))) + } + + private async _startService(): Promise { + log.info('Launching screen service %s', this.grabber) + const args = options.screenFrameRate <= 0.0 + ? util.format('-S -Q %s -P %s', this.frameConfig.quality, this.frameConfig.toString()) + : util.format('-S -r %s -Q %s -P %s', options.screenFrameRate, this.frameConfig.quality, this.frameConfig.toString()) + + return withTimeout(minicap.run(this.grabber, args), 10000) + } + + private _readOutput(out: Readable): Promise { + return new Promise((resolve) => { + out.pipe(split()).on('data', (line: string) => { + const trimmed = line.toString().trim() + if (trimmed === '') { + return + } + if (/ERROR/.test(line)) { + log.fatal('minicap error: "%s"', line) + lifecycle.fatal() + return + } + const match = /^PID: (\d+)$/.exec(line) + if (match) { + this.pid = Number(match[1]) + this.emit('pid', this.pid) + } + log.info('minicap says: "%s"', line) + }) + // Resolve immediately as we're just setting up the pipe + resolve() + }) + } + + private async _waitForPid(): Promise { + if (this.pid > 0) { + return this.pid + } + + return withTimeout( + new Promise((resolve) => { + const pidListener = (pid: number) => { + this.removeListener('pid', pidListener) + resolve(pid) + } + this.on('pid', pidListener) + }), + 5000 + ) + } + + private async _connectService(): Promise { + const tryConnect = async (times: number, delayMs: number): Promise => { + try { + const device = adb.getDevice(options.serial) + return await device.openLocal('localabstract:minicap') + } catch (err: any) { + if (/closed/.test(err.message) && times > 1) { + await delay(delayMs) + log.info('Retrying connect to minicap service') + return tryConnect(times - 1, delayMs + 100) + } + throw err + } + } + + log.info('Connecting to minicap service') + return tryConnect(10, 100) + } + + private async _stop(): Promise { + try { + await withTimeout(this._disconnectService(this.socket), 2000) + await withTimeout(this._stopService(this.output), 10000) + this.runningState = FrameProducer.STATE_STOPPED + this.emit('stop') + } catch (err) { + // In practice we _should_ never get here due to _stopService() + // being quite aggressive. But if we do, well... assume it + // stopped anyway for now. + this.runningState = FrameProducer.STATE_STOPPED + this.emit('error', err) + this.emit('stop') + } finally { + this.output = null + this.socket = null + this.pid = -1 + this.banner = null + this.parser = null + } + } + + private async _disconnectService(socket: RiskyStream | null): Promise { + log.info('Disconnecting from minicap service') + if (!socket || socket.ended) { + return true + } + + socket.stream.removeListener('readable', this.readableListener) + + return new Promise((resolve) => { + const endListener = () => { + socket.removeListener('end', endListener) + resolve(true) + } + socket.on('end', endListener) + socket.stream.resume() + socket.end() + }) + } + + private async _stopService(output: RiskyStream | null): Promise { + log.info('Stopping minicap service') + if (!output || output.ended) { + return true + } + + const pid = this.pid + + const kill = async (signal: 'SIGTERM' | 'SIGKILL'): Promise => { + if (pid <= 0) { + throw new Error('Minicap service pid is unknown') + } + const signum = { + SIGTERM: -15, + SIGKILL: -9 + }[signal] + + log.info('Sending %s to minicap', signal) + const device = adb.getDevice(options.serial) + return withTimeout( + Promise.all([ + output.waitForEnd(), + device.shell(['kill', signum, pid]).then(Adb.util.readAll) + ]), + 2000 + ) + } + + const kindKill = () => kill('SIGTERM') + const forceKill = () => kill('SIGKILL') + const forceEnd = () => { + log.info('Ending minicap I/O as a last resort') + output.end() + return true + } + + try { + await kindKill() + return true + } catch (err) { + if (err instanceof TimeoutError) { + try { + await forceKill() + return true + } catch { + return forceEnd() + } + } + return forceEnd() + } + } + + private async _readBanner(socket: Readable): Promise { + log.info('Reading minicap banner') + return withTimeout(bannerutil.read(socket), 4000) + } + + private async _readFrames(socket: Readable): Promise { + this.needsReadable = true + socket.on('readable', this.readableListener) + // We may already have data pending. Let the user know they should + // at least attempt to read frames now. + this.readableListener() + } + + private _maybeEmitReadable(): void { + if (this.readable && this.needsReadable) { + this.needsReadable = false + this.emit('readable') + } + } + + private _readableListener(): void { + this.readable = true + this._maybeEmitReadable() + } + } + + async function createServer(): Promise { + log.info('Starting WebSocket server on port %s', screenOptions.publicPort) + const wss = new WebSocket.Server({ + port: screenOptions.publicPort, + perMessageDeflate: false + }) + + return new Promise((resolve, reject) => { + const listeningListener = () => { + wss.removeListener('listening', listeningListener) + wss.removeListener('error', errorListener) + resolve(wss) + } + const errorListener = (err: Error) => { + wss.removeListener('listening', listeningListener) + wss.removeListener('error', errorListener) + reject(err) + } + wss.on('listening', listeningListener) + wss.on('error', errorListener) + }) + } + + return createServer() + .then((wss) => { + log.info('creating FrameProducer: %s', options.screenGrabber) + const frameProducer = new FrameProducer( + new FrameConfig( + display.properties as DisplayProperties, + display.properties as DisplayProperties, + options.screenJpegQuality + ), + options.screenGrabber + ) + const broadcastSet = frameProducer.broadcastSet = new BroadcastSet() + + broadcastSet.on('nonempty', () => { + frameProducer.start() + }) + + broadcastSet.on('empty', () => { + frameProducer.stop() + }) + + broadcastSet.on('insert', (id: string) => { + // If two clients join a session in the middle, one of them + // may not release the initial size because the projection + // doesn't necessarily change, and the producer doesn't Getting + // restarted. Therefore we have to call onStart() manually + // if the producer is already up and running. + switch (frameProducer.runningState) { + case FrameProducer.STATE_STARTED: + broadcastSet.get(id).onStart(frameProducer) + break + } + }) + + display.on('rotationChange', (newRotation: number) => { + frameProducer.updateRotation(newRotation) + }) + + router.on(ChangeQualityMessage, (_channel: any, message: any) => { + frameProducer.changeQuality(message.quality) + }) + + frameProducer.on('start', () => { + broadcastSet.keys().map((id: string) => { + return broadcastSet.get(id).onStart(frameProducer) + }) + }) + + frameProducer.on('readable', function next() { + const frame = frameProducer.nextFrame() + if (frame) { + Promise.allSettled( + broadcastSet.keys().map((id: string) => { + return broadcastSet.get(id).onFrame(frame) + }) + ).then(next) + } + else { + frameProducer.needFrame() + } + }) + + frameProducer.on('error', (err: Error) => { + log.fatal('Frame producer had an error: %s', err.stack) + lifecycle.fatal() + }) + + wss.on('connection', async (ws: WebSocket, req) => { + const id = uuidv4() + let pingTimer: NodeJS.Timeout | undefined + + // Extract token from WebSocket subprotocols + const token = ws.protocol.substring('access_token.'.length) + const user = !!token && jwtutil.decode(token, options.secret) + + if (!token || !user) { + log.warn('WebSocket connection attempt without token from %s', req.socket.remoteAddress) + ws.send(JSON.stringify({ + type: 'auth_error', + message: 'Authentication token required' + })) + ws.close(1008, 'Authentication token required') + return + } + + const tryCheckDeviceGroup = async(fail = false) => { + try { + await delay(200) + + const deviceGroup = await group.get() + if (deviceGroup.email !== user?.email) { + const err = 'Device used by another user' + log.warn('WebSocket authentication failed for device %s: %s', options.serial, err) + ws.send(JSON.stringify({ + type: 'auth_error', + message: err + })) + ws.close(1008, 'Authentication failed') + return + } + + log.info('WebSocket authenticated for device %s', options.serial) + + // Send success message + ws.send(JSON.stringify({ + type: 'auth_success', + message: 'Authentication successful' + })) + + // Sending a ping message every now and then makes sure that + // reverse proxies like nginx don't time out the connection [1]. + // + // [1] http://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_read_timeout + pingTimer = setInterval(wsPingNotifier, 10 * 60000) // options.screenPingInterval + } + catch (err: any) { + if (!fail && err instanceof NoGroupError) { + await delay(1000) + return tryCheckDeviceGroup(true) + } + + log.error('WebSocket authentication error for device %s: %s', options.serial, err.message) + ws.send(JSON.stringify({ + type: 'auth_error', + message: 'Authentication error' + }), () => {}) + ws.close(1008, 'Authentication error') + } + } + + await tryCheckDeviceGroup() + + function send(message: string | Buffer, sendOptions?: {binary?: boolean}): Promise { + return new Promise((resolve, reject) => { + if (ws.readyState === WebSocket.OPEN) { + const onErr = (err?: Error) => + err ? reject(err) : resolve() + + // @ts-ignore + ws.send(...(sendOptions ? [message, sendOptions, onErr] : [message, onErr])) + return + } + + if (ws.readyState === WebSocket.CLOSED) { + log.warn('Unable to send to CLOSED client "%s"', id) + if (pingTimer) { + clearInterval(pingTimer) + } + broadcastSet.remove(id) + return + } + + log.warn('Unable to send to %s client "%s"', ws.readyState, id) + }) + } + + function wsStartNotifier(): Promise { + return send(util.format('start %s', JSON.stringify(frameProducer.banner))) + } + + function wsPingNotifier(): Promise { + return send('ping') + } + + function wsFrameNotifier(frame: Buffer): Promise { + return send(frame, { + binary: true + }) + } + + ws.on('message', (data: WebSocket.Data) => { + const match = /^(on|off|(size) ([0-9]+)x([0-9]+))$/.exec(data.toString()) + if (match) { + switch (match[2] || match[1]) { + case 'on': + broadcastSet.insert(id, { + onStart: wsStartNotifier, + onFrame: wsFrameNotifier + }) + break + case 'off': + broadcastSet.remove(id) + // Keep pinging even when the screen is off. + break + case 'size': + frameProducer.updateProjection(Number(match[3]), Number(match[4])) + break + } + } + }) + + ws.on('close', () => { + if (pingTimer) { + clearInterval(pingTimer) + } + broadcastSet.remove(id) + log.info('WebSocket closed for device %s', options.serial) + }) + + if (options.needScrcpy) { + log.info(`Scrcpy client has gotten for device s/n ${options.serial}`) + scrcpyClient.on('rawData', (data: Buffer) => { + console.log(`Data: ${data}`) + send(data, {binary: true}) + }) + } + + ws.on('error', (e: Error) => { + if (pingTimer) { + clearInterval(pingTimer) + } + broadcastSet.remove(id) + log.error('WebSocket error for device %s: %s', options.serial, e.message) + }) + }) + + lifecycle.observe(() => { + wss.close() + }) + + lifecycle.observe(() => { + frameProducer.stop() + }) + + return frameProducer + }) + }) + diff --git a/lib/units/device/plugins/screen/util/banner.js b/lib/units/device/plugins/screen/util/banner.js deleted file mode 100644 index b89729bb57..0000000000 --- a/lib/units/device/plugins/screen/util/banner.js +++ /dev/null @@ -1,104 +0,0 @@ -import Promise from 'bluebird' -export const read = function parseBanner(out) { - var tryRead - return new Promise(function(resolve, reject) { - var readBannerBytes = 0 - var needBannerBytes = 2 - var banner = out.banner = { - version: 0, - length: 0, - pid: 0, - realWidth: 0, - realHeight: 0, - virtualWidth: 0, - virtualHeight: 0, - orientation: 0, - quirks: { - dumb: false, - alwaysUpright: false, - tear: false - } - } - tryRead = function() { - for (var chunk; (chunk = out.read(needBannerBytes - readBannerBytes));) { - for (var cursor = 0, len = chunk.length; cursor < len;) { - if (readBannerBytes < needBannerBytes) { - switch (readBannerBytes) { - case 0: - // version - banner.version = chunk[cursor] - break - case 1: - // length - banner.length = needBannerBytes = chunk[cursor] - break - case 2: - case 3: - case 4: - case 5: - // pid - banner.pid += - (chunk[cursor] << ((readBannerBytes - 2) * 8)) >>> 0 - break - case 6: - case 7: - case 8: - case 9: - // real width - banner.realWidth += - (chunk[cursor] << ((readBannerBytes - 6) * 8)) >>> 0 - break - case 10: - case 11: - case 12: - case 13: - // real height - banner.realHeight += - (chunk[cursor] << ((readBannerBytes - 10) * 8)) >>> 0 - break - case 14: - case 15: - case 16: - case 17: - // virtual width - banner.virtualWidth += - (chunk[cursor] << ((readBannerBytes - 14) * 8)) >>> 0 - break - case 18: - case 19: - case 20: - case 21: - // virtual height - banner.virtualHeight += - (chunk[cursor] << ((readBannerBytes - 18) * 8)) >>> 0 - break - case 22: - // orientation - banner.orientation += chunk[cursor] * 90 - break - case 23: - // quirks - banner.quirks.dumb = (chunk[cursor] & 1) === 1 - banner.quirks.alwaysUpright = (chunk[cursor] & 2) === 2 - banner.quirks.tear = (chunk[cursor] & 4) === 4 - break - } - cursor += 1 - readBannerBytes += 1 - if (readBannerBytes === needBannerBytes) { - return resolve(banner) - } - } - else { - reject(new Error('Supposedly impossible error parsing banner')) - } - } - } - } - tryRead() - out.on('readable', tryRead) - }) - .finally(function() { - out.removeListener('readable', tryRead) - }) -} diff --git a/lib/units/device/plugins/screen/util/banner.ts b/lib/units/device/plugins/screen/util/banner.ts new file mode 100644 index 0000000000..cf5d857251 --- /dev/null +++ b/lib/units/device/plugins/screen/util/banner.ts @@ -0,0 +1,106 @@ +import { Readable } from 'stream' + +export interface BannerQuirks { + dumb: boolean + alwaysUpright: boolean + tear: boolean +} + +export interface Banner { + version: number + length: number + pid: number + realWidth: number + realHeight: number + virtualWidth: number + virtualHeight: number + orientation: number + quirks: BannerQuirks +} + +interface BannerStream extends Readable { + banner?: Banner +} + +export const read = (out: BannerStream) => + new Promise((resolve, reject) => { + let readBannerBytes = 0 + let needBannerBytes = 2 + + const banner: Banner = out.banner = { + version: 0, + length: 0, + pid: 0, + realWidth: 0, + realHeight: 0, + virtualWidth: 0, + virtualHeight: 0, + orientation: 0, + quirks: { + dumb: false, + alwaysUpright: false, + tear: false + } + } + + const tryRead = function() { + for (let chunk: Buffer | null; (chunk = out.read(needBannerBytes - readBannerBytes));) { + for (let cursor = 0, len = chunk.length; cursor < len;) { + if (readBannerBytes >= needBannerBytes) { + out.removeListener('readable', tryRead) + reject(new Error('Supposedly impossible error parsing banner')) + return + } + + if (readBannerBytes === 0) { + banner.version = chunk[cursor] + } + + else if (readBannerBytes === 1) { + banner.length = needBannerBytes = chunk[cursor] + } + + else if (readBannerBytes <= 5) { + banner.pid += (chunk[cursor] << ((readBannerBytes - 2) * 8)) >>> 0 + } + + else if (readBannerBytes <= 9) { + banner.realWidth += (chunk[cursor] << ((readBannerBytes - 6) * 8)) >>> 0 + } + + else if (readBannerBytes <= 13) { + banner.realHeight += (chunk[cursor] << ((readBannerBytes - 10) * 8)) >>> 0 + } + + else if (readBannerBytes <= 17) { + banner.virtualWidth += (chunk[cursor] << ((readBannerBytes - 14) * 8)) >>> 0 + } + + else if (readBannerBytes <= 21) { + banner.virtualHeight += (chunk[cursor] << ((readBannerBytes - 18) * 8)) >>> 0 + } + + else if (readBannerBytes === 22) { + banner.orientation += chunk[cursor] * 90 + } + + else if (readBannerBytes === 23) { + banner.quirks.dumb = (chunk[cursor] & 1) === 1 + banner.quirks.alwaysUpright = (chunk[cursor] & 2) === 2 + banner.quirks.tear = (chunk[cursor] & 4) === 4 + } + + cursor += 1 + readBannerBytes += 1 + + if (readBannerBytes === needBannerBytes) { + out.removeListener('readable', tryRead) + return resolve(banner) + } + } + } + } + + tryRead() + out.on('readable', tryRead) + }) diff --git a/lib/units/device/plugins/screen/util/broadcastset.js b/lib/units/device/plugins/screen/util/broadcastset.js deleted file mode 100644 index 24bba44dea..0000000000 --- a/lib/units/device/plugins/screen/util/broadcastset.js +++ /dev/null @@ -1,39 +0,0 @@ -import util from 'util' -import EventEmitter from 'eventemitter3' -function BroadcastSet() { - this.set = Object.create(null) - this.count = 0 -} -util.inherits(BroadcastSet, EventEmitter) -BroadcastSet.prototype.insert = function(id, ws) { - if (!(id in this.set)) { - this.set[id] = ws - this.count += 1 - this.emit('insert', id) - if (this.count === 1) { - this.emit('nonempty') - } - } -} -BroadcastSet.prototype.remove = function(id) { - if (id in this.set) { - delete this.set[id] - this.count -= 1 - this.emit('remove', id) - if (this.count === 0) { - this.emit('empty') - } - } -} -BroadcastSet.prototype.values = function() { - return Object.keys(this.set).map(function(id) { - return this.set[id] - }, this) -} -BroadcastSet.prototype.keys = function() { - return Object.keys(this.set) -} -BroadcastSet.prototype.get = function(id) { - return this.set[id] -} -export default BroadcastSet diff --git a/lib/units/device/plugins/screen/util/broadcastset.ts b/lib/units/device/plugins/screen/util/broadcastset.ts new file mode 100644 index 0000000000..6765082a67 --- /dev/null +++ b/lib/units/device/plugins/screen/util/broadcastset.ts @@ -0,0 +1,81 @@ +import EventEmitter from 'events' + +interface BroadcastSetEvents { + insert: (id: string) => void + remove: (id: string) => void + nonempty: () => void + empty: () => void +} + +export default class BroadcastSet extends EventEmitter { + private set: Record + public count: number + + constructor() { + super() + this.set = Object.create(null) + this.count = 0 + } + + on(event: K, listener: BroadcastSetEvents[K]): this { + return super.on(event, listener) + } + + once(event: K, listener: BroadcastSetEvents[K]): this { + return super.once(event, listener) + } + + emit( + event: K, + ...args: Parameters + ): boolean { + return super.emit(event, ...args) + } + + off(event: K, listener: BroadcastSetEvents[K]): this { + return super.off(event, listener) + } + + removeListener( + event: K, + listener: BroadcastSetEvents[K] + ): this { + return super.removeListener(event, listener) + } + + insert(id: string, ws: T): void { + if (!(id in this.set)) { + this.set[id] = ws + this.count += 1 + this.emit('insert', id) + if (this.count === 1) { + this.emit('nonempty') + } + } + } + + remove(id: string): void { + if (id in this.set) { + delete this.set[id] + this.count -= 1 + this.emit('remove', id) + if (this.count === 0) { + this.emit('empty') + } + } + } + + values(): T[] { + return Object.keys(this.set).map((id) => { + return this.set[id] + }) + } + + keys(): string[] { + return Object.keys(this.set) + } + + get(id: string): T | undefined { + return this.set[id] + } +} diff --git a/lib/units/device/plugins/screen/util/frameconfig.js b/lib/units/device/plugins/screen/util/frameconfig.js deleted file mode 100644 index 7e6b62af85..0000000000 --- a/lib/units/device/plugins/screen/util/frameconfig.js +++ /dev/null @@ -1,13 +0,0 @@ -import util from 'util' -function FrameConfig(real, virtual, quality) { - this.realWidth = real.width - this.realHeight = real.height - this.virtualWidth = virtual.width - this.virtualHeight = virtual.height - this.rotation = virtual.rotation - this.quality = quality -} -FrameConfig.prototype.toString = function() { - return util.format('%dx%d@%dx%d/%d', this.realWidth, this.realHeight, this.virtualWidth, this.virtualHeight, this.rotation) -} -export default FrameConfig diff --git a/lib/units/device/plugins/screen/util/frameconfig.ts b/lib/units/device/plugins/screen/util/frameconfig.ts new file mode 100644 index 0000000000..8a674a07ea --- /dev/null +++ b/lib/units/device/plugins/screen/util/frameconfig.ts @@ -0,0 +1,33 @@ +interface RealDimensions { + width: number + height: number +} + +interface VirtualDimensions { + width: number + height: number + rotation: number +} + +export default class FrameConfig { + public realWidth: number + public realHeight: number + public virtualWidth: number + public virtualHeight: number + public rotation: number + public quality: number + + constructor(real: RealDimensions, virtual: VirtualDimensions, quality: number) { + this.realWidth = real.width + this.realHeight = real.height + this.virtualWidth = virtual.width + this.virtualHeight = virtual.height + this.rotation = virtual.rotation + this.quality = quality + } + + toString(): string { + return `${this.realWidth}x${this.realHeight}@${this.virtualWidth}x${this.virtualHeight}/${this.rotation}` + } +} + diff --git a/lib/units/device/plugins/screen/util/frameparser.js b/lib/units/device/plugins/screen/util/frameparser.js deleted file mode 100644 index bb9094c0b9..0000000000 --- a/lib/units/device/plugins/screen/util/frameparser.js +++ /dev/null @@ -1,63 +0,0 @@ -function FrameParser() { - this.readFrameBytes = 0 - this.frameBodyLength = 0 - this.frameBody = null - this.cursor = 0 - this.chunk = null -} -FrameParser.prototype.push = function(chunk) { - if (this.chunk) { - throw new Error('Must consume pending frames before pushing more chunks') - } - this.chunk = chunk -} -FrameParser.prototype.nextFrame = function() { - if (!this.chunk) { - return null - } - for (var len = this.chunk.length; this.cursor < len;) { - if (this.readFrameBytes < 4) { - this.frameBodyLength += - (this.chunk[this.cursor] << (this.readFrameBytes * 8)) >>> 0 - this.cursor += 1 - this.readFrameBytes += 1 - } - else { - var bytesLeft = len - this.cursor - if (bytesLeft >= this.frameBodyLength) { - var completeBody - if (this.frameBody) { - completeBody = Buffer.concat([ - this.frameBody, - this.chunk.slice(this.cursor, this.cursor + this.frameBodyLength) - ]) - } - else { - completeBody = this.chunk.slice(this.cursor, this.cursor + this.frameBodyLength) - } - this.cursor += this.frameBodyLength - this.frameBodyLength = this.readFrameBytes = 0 - this.frameBody = null - return completeBody - } - else { - // @todo Consider/benchmark continuation frames to prevent - // potential Buffer thrashing. - if (this.frameBody) { - this.frameBody = - Buffer.concat([this.frameBody, this.chunk.slice(this.cursor, len)]) - } - else { - this.frameBody = this.chunk.slice(this.cursor, len) - } - this.frameBodyLength -= bytesLeft - this.readFrameBytes += bytesLeft - this.cursor = len - } - } - } - this.cursor = 0 - this.chunk = null - return null -} -export default FrameParser diff --git a/lib/units/device/plugins/screen/util/frameparser.ts b/lib/units/device/plugins/screen/util/frameparser.ts new file mode 100644 index 0000000000..e6d26db7c7 --- /dev/null +++ b/lib/units/device/plugins/screen/util/frameparser.ts @@ -0,0 +1,74 @@ +export default class FrameParser { + private readFrameBytes: number + private frameBodyLength: number + private frameBody: Buffer | null + private cursor: number + private chunk: Buffer | null + + constructor() { + this.readFrameBytes = 0 + this.frameBodyLength = 0 + this.frameBody = null + this.cursor = 0 + this.chunk = null + } + + push(chunk: Buffer): void { + if (this.chunk) { + throw new Error('Must consume pending frames before pushing more chunks') + } + this.chunk = chunk + } + + nextFrame(): Buffer | null { + if (!this.chunk) { + return null + } + + const len = this.chunk.length + while (this.cursor < len) { + if (this.readFrameBytes < 4) { + this.frameBodyLength += + (this.chunk[this.cursor] << (this.readFrameBytes * 8)) >>> 0 + this.cursor += 1 + this.readFrameBytes += 1 + } else { + const bytesLeft = len - this.cursor + if (bytesLeft >= this.frameBodyLength) { + let completeBody: Buffer + if (this.frameBody) { + completeBody = Buffer.concat([ + this.frameBody, + this.chunk.slice(this.cursor, this.cursor + this.frameBodyLength), + ]) + } else { + completeBody = this.chunk.slice(this.cursor, this.cursor + this.frameBodyLength) + } + this.cursor += this.frameBodyLength + this.frameBodyLength = this.readFrameBytes = 0 + this.frameBody = null + return completeBody + } else { + // @todo Consider/benchmark continuation frames to prevent + // potential Buffer thrashing. + if (this.frameBody) { + this.frameBody = Buffer.concat([ + this.frameBody, + this.chunk.slice(this.cursor, len), + ]) + } else { + this.frameBody = this.chunk.slice(this.cursor, len) + } + this.frameBodyLength -= bytesLeft + this.readFrameBytes += bytesLeft + this.cursor = len + } + } + } + + this.cursor = 0 + this.chunk = null + return null + } +} + diff --git a/lib/units/device/plugins/touch/index.ts b/lib/units/device/plugins/touch/index.ts index 13e961efa3..0af578f14a 100644 --- a/lib/units/device/plugins/touch/index.ts +++ b/lib/units/device/plugins/touch/index.ts @@ -113,7 +113,7 @@ class TouchConsumer extends EventEmitter { this._queueWrite(() => { const x = Math.ceil(this.touchConfig.origin.x(point) * this.banner!.maxX) const y = Math.ceil(this.touchConfig.origin.y(point) * this.banner!.maxY) - const p = Math.ceil((point.pressure || 0.5) * this.banner!.maxPressure) + const p = Math.max(1, Math.ceil((point.pressure || 0.5) * this.banner!.maxPressure)) return this._write(`d ${point.contact} ${x} ${y} ${p}\n`) }) } @@ -122,7 +122,7 @@ class TouchConsumer extends EventEmitter { this._queueWrite(() => { const x = Math.ceil(this.touchConfig.origin.x(point) * this.banner!.maxX) const y = Math.ceil(this.touchConfig.origin.y(point) * this.banner!.maxY) - const p = Math.ceil((point.pressure || 0.5) * this.banner!.maxPressure) + const p = Math.max(1, Math.ceil((point.pressure || 0.5) * this.banner!.maxPressure)) return this._write(`m ${point.contact} ${x} ${y} ${p}\n`) }) } @@ -151,7 +151,7 @@ class TouchConsumer extends EventEmitter { this.touchUp(point) this.touchCommit() } - + private async startState(): Promise { if (this.desiredState.next() !== STATE_STARTED) { this.ensureStateLock = false @@ -164,19 +164,19 @@ class TouchConsumer extends EventEmitter { const out = await this._startService() this.output = new RiskyStream(out) .on('unexpectedEnd', this._outputEnded.bind(this)) - + this._readOutput(this.output.stream) - + const socket = await this._connectService() this.socket = new RiskyStream(socket) .on('unexpectedEnd', this._socketEnded.bind(this)) - + const banner = await this._readBanner(this.socket.stream) this.banner = banner - + this._readUnexpected(this.socket.stream) this._processWriteQueue() - + this.runningState = STATE_STARTED this.emit('start') } catch (err: any) { @@ -217,7 +217,7 @@ class TouchConsumer extends EventEmitter { log.warn('Will not apply desired state due to too many failures') return } - + // Prevent concurrent execution if (this.ensureStateLock) { return @@ -295,7 +295,7 @@ class TouchConsumer extends EventEmitter { this.splitStream.removeAllListeners('data') this.splitStream.destroy() } - + this.splitStream = out.pipe(split()).on('data', (line: any) => { const trimmed = line.toString().trim() if (trimmed === '') { @@ -352,7 +352,7 @@ class TouchConsumer extends EventEmitter { this.splitStream.destroy() this.splitStream = null } - + this.output = null this.socket = null this.banner = null @@ -361,13 +361,13 @@ class TouchConsumer extends EventEmitter { private async _disconnectService(socket: RiskyStream | null): Promise { log.info('Disconnecting from minitouch service') - + if (!socket || socket.ended) { return true } socket.stream.removeListener('readable', this.readableListener) - + return new Promise((resolve) => { const endListener = () => { socket.removeListener('end', endListener) @@ -376,7 +376,7 @@ class TouchConsumer extends EventEmitter { socket.on('end', endListener) socket.stream.resume() socket.end() - + // Add timeout setTimeout(() => { socket.removeListener('end', endListener) @@ -387,7 +387,7 @@ class TouchConsumer extends EventEmitter { private async _stopService(output: RiskyStream | null): Promise { log.info('Stopping minitouch service') - + if (!output || output.ended) { return true } @@ -402,9 +402,9 @@ class TouchConsumer extends EventEmitter { SIGTERM: -15, SIGKILL: -9 }[signal] - + log.info('Sending %s to minitouch', signal) - + await Promise.race([ Promise.all([ output.waitForEnd(), @@ -413,7 +413,7 @@ class TouchConsumer extends EventEmitter { ]), new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 2000)) ]) - + return true } @@ -441,7 +441,7 @@ class TouchConsumer extends EventEmitter { private async _readBanner(socket: any): Promise { log.info('Reading minitouch banner') - + const parser = new Parser(socket) const banner: Banner = { pid: -1, @@ -521,14 +521,14 @@ class TouchConsumer extends EventEmitter { if (!this.socket?.stream) { return } - + // Handle backpressure const canWrite = this.socket.stream.write(chunk) if (!canWrite) { log.warn('Socket buffer is full, experiencing backpressure') } } - + destroy(): void { // Clean up all resources if (this.splitStream) { @@ -536,16 +536,16 @@ class TouchConsumer extends EventEmitter { this.splitStream.destroy() this.splitStream = null } - + if (this.socket) { this.socket.stream.removeListener('readable', this.readableListener) this.socket.removeAllListeners() } - + if (this.output) { this.output.removeAllListeners() } - + this.failCounter.removeAllListeners() this.removeAllListeners() this.writeQueue = [] @@ -583,7 +583,7 @@ export default syrup.serial() // Use Promise.race with once() for cleaner event handling touchConsumer.start() - + return Promise.race([ new Promise((resolve) => { touchConsumer.once('start', () => resolve(touchConsumer)) @@ -595,7 +595,9 @@ export default syrup.serial() } const touchConsumer = await startConsumer() - const queue = new SeqQueue(100, 4) + + // TODO: refactoring SeqQueue + // const queue = new SeqQueue(100, 4) touchConsumer.on('error', (err: Error) => { log.fatal('Touch consumer had an error %s: %s', err?.message, err?.stack) @@ -604,37 +606,25 @@ export default syrup.serial() router .on(GestureStartMessage, (channel: any, message: any) => { - queue.start(message.seq) + touchConsumer.start() }) .on(GestureStopMessage, (channel: any, message: any) => { - queue.push(message.seq, () => { - queue.stop() - }) + touchConsumer.stop() }) .on(TouchDownMessage, (channel: any, message: any) => { - queue.push(message.seq, () => { - touchConsumer.touchDown(message) - }) + touchConsumer.touchDown(message) }) .on(TouchMoveMessage, (channel: any, message: any) => { - queue.push(message.seq, () => { - touchConsumer.touchMove(message) - }) + touchConsumer.touchMove(message) }) .on(TouchUpMessage, (channel: any, message: any) => { - queue.push(message.seq, () => { - touchConsumer.touchUp(message) - }) + touchConsumer.touchUp(message) }) .on(TouchCommitMessage, (channel: any, message: any) => { - queue.push(message.seq, () => { - touchConsumer.touchCommit() - }) + touchConsumer.touchCommit() }) .on(TouchResetMessage, (channel: any, message: any) => { - queue.push(message.seq, () => { - touchConsumer.touchReset() - }) + touchConsumer.touchReset() }) return touchConsumer diff --git a/lib/units/device/plugins/vnc/index.js b/lib/units/device/plugins/vnc/index.js deleted file mode 100644 index f980a369df..0000000000 --- a/lib/units/device/plugins/vnc/index.js +++ /dev/null @@ -1,242 +0,0 @@ -import net from 'net' -import util from 'util' -import os from 'os' -import syrup from '@devicefarmer/stf-syrup' -import Promise from 'bluebird' -import {v4 as uuidv4} from 'uuid' -import * as jpeg from '@julusian/jpeg-turbo' -import logger from '../../../../util/logger.js' -import * as grouputil from '../../../../util/grouputil.js' -import wire from '../../../../wire/index.js' -import wireutil from '../../../../wire/util.js' -import lifecycle from '../../../../util/lifecycle.js' -import VncServer from './util/server.js' -import VncConnection from './util/connection.js' -import PointerTranslator from './util/pointertranslator.js' -import router from '../../../base-device/support/router.js' -import push from '../../../base-device/support/push.js' -import stream from '../screen/stream.js' -import touch from '../touch/index.js' -import group from '../group.js' -import solo from '../solo.js' -import {VncAuthResponsesUpdatedMessage} from '../../../../wire/wire.js' -export default syrup.serial() - .dependency(router) - .dependency(push) - .dependency(stream) - .dependency(touch) - .dependency(group) - .dependency(solo) - .define(function(options, router, push, screenStream, touch, group, solo) { - var log = logger.createLogger('device:plugins:vnc') - function vncAuthHandler(data) { - log.info('VNC authentication attempt using "%s"', data.response.toString('hex')) - var resolver = Promise.defer() - function notify() { - group.get() - .then(function(currentGroup) { - push.send([ - solo.channel, - wireutil.envelope(new wire.JoinGroupByVncAuthResponseMessage(options.serial, data.response.toString('hex'), currentGroup.group)) - ]) - }) - .catch(grouputil.NoGroupError, function() { - push.send([ - solo.channel, - wireutil.envelope(new wire.JoinGroupByVncAuthResponseMessage(options.serial, data.response.toString('hex'))) - ]) - }) - } - function joinListener(newGroup, identifier) { - if (!data.response.equals(Buffer.from(identifier || '', 'hex'))) { - resolver.reject(new Error('Someone else took the device')) - } - } - function autojoinListener(identifier, joined) { - if (data.response.equals(Buffer.from(identifier, 'hex'))) { - if (joined) { - resolver.resolve() - } - else { - resolver.reject(new Error('Device is already in use')) - } - } - } - group.on('join', joinListener) - group.on('autojoin', autojoinListener) - router.on(VncAuthResponsesUpdatedMessage, notify) - notify() - return resolver.promise - .timeout(5000) - .finally(function() { - group.removeListener('join', joinListener) - group.removeListener('autojoin', autojoinListener) - router.removeListener(wire.VncAuthResponsesUpdatedMessage, notify) - }) - } - function createServer() { - log.info('Starting VNC server on port %d', options.vncPort) - var opts = { - name: options.serial, - width: options.vncInitialSize[0], - height: options.vncInitialSize[1], - security: [{ - type: VncConnection.SECURITY_VNC, - challenge: Buffer.alloc(16).fill(0), - auth: vncAuthHandler - }] - } - var vnc = new VncServer(net.createServer({ - allowHalfOpen: true - }), opts) - var listeningListener, errorListener - return new Promise(function(resolve, reject) { - listeningListener = function() { - return resolve(vnc) - } - errorListener = function(err) { - return reject(err) - } - vnc.on('listening', listeningListener) - vnc.on('error', errorListener) - vnc.listen(options.vncPort) - }) - .finally(function() { - vnc.removeListener('listening', listeningListener) - vnc.removeListener('error', errorListener) - }) - } - return createServer() - .then(function(vnc) { - vnc.on('connection', function(conn) { - log.info('New VNC connection from %s', conn.conn.remoteAddress) - var id = util.format('vnc-%s', uuidv4()) - var connState = { - lastFrame: null, - lastFrameTime: null, - frameWidth: 0, - frameHeight: 0, - sentFrameTime: null, - updateRequests: 0, - frameConfig: { - format: jpeg.FORMAT_RGB - } - } - var pointerTranslator = new PointerTranslator() - pointerTranslator.on('touchdown', function(event) { - touch.touchDown(event) - }) - pointerTranslator.on('touchmove', function(event) { - touch.touchMove(event) - }) - pointerTranslator.on('touchup', function(event) { - touch.touchUp(event) - }) - pointerTranslator.on('touchcommit', function() { - touch.touchCommit() - }) - function maybeSendFrame() { - if (!connState.updateRequests) { - return - } - if (!connState.lastFrame) { - return - } - if (connState.lastFrameTime === connState.sentFrameTime) { - return - } - var decoded = jpeg.decompressSync(connState.lastFrame, connState.frameConfig) - conn.writeFramebufferUpdate([{ - xPosition: 0, - yPosition: 0, - width: decoded.width, - height: decoded.height, - encodingType: VncConnection.ENCODING_RAW, - data: decoded.data - }, - { - xPosition: 0, - yPosition: 0, - width: decoded.width, - height: decoded.height, - encodingType: VncConnection.ENCODING_DESKTOPSIZE - } - ]) - connState.updateRequests = 0 - connState.sentFrameTime = connState.lastFrameTime - } - function vncStartListener(frameProducer) { - return new Promise(function(resolve) { - connState.frameWidth = frameProducer.banner.virtualWidth - connState.frameHeight = frameProducer.banner.virtualHeight - resolve() - }) - } - function vncFrameListener(frame) { - return new Promise(function(resolve) { - connState.lastFrame = frame - connState.lastFrameTime = Date.now() - maybeSendFrame() - resolve() - }) - } - function groupLeaveListener() { - conn.end() - } - conn.on('authenticated', function() { - screenStream.updateProjection(options.vncInitialSize[0], options.vncInitialSize[1]) - screenStream.broadcastSet.insert(id, { - onStart: vncStartListener, - onFrame: vncFrameListener - }) - }) - conn.on('fbupdaterequest', function() { - connState.updateRequests += 1 - maybeSendFrame() - }) - conn.on('formatchange', function(format) { - var same = os.endianness() === 'BE' === - Boolean(format.bigEndianFlag) - var formatOrder = (format.redShift > format.blueShift) === same - switch (format.bitsPerPixel) { - case 8: - connState.frameConfig = { - format: jpeg.FORMAT_GRAY - } - break - case 24: - connState.frameConfig = { - format: formatOrder ? jpeg.FORMAT_BGR : jpeg.FORMAT_RGB - } - break - case 32: - var f - if (formatOrder) { - f = format.blueShift === 0 ? jpeg.FORMAT_BGRX : jpeg.FORMAT_XBGR - } - else { - f = format.redShift === 0 ? jpeg.FORMAT_RGBX : jpeg.FORMAT_XRGB - } - connState.frameConfig = { - format: f - } - break - } - }) - conn.on('pointer', function(event) { - pointerTranslator.push(event) - }) - conn.on('close', function() { - screenStream.broadcastSet.remove(id) - group.removeListener('leave', groupLeaveListener) - }) - conn.on('userActivity', function() { - group.keepalive() - }) - group.on('leave', groupLeaveListener) - }) - lifecycle.observe(function() { - vnc.close() - }) - }) - }) diff --git a/lib/units/device/plugins/vnc/index.ts b/lib/units/device/plugins/vnc/index.ts new file mode 100644 index 0000000000..da58e9f7d8 --- /dev/null +++ b/lib/units/device/plugins/vnc/index.ts @@ -0,0 +1,561 @@ +import util from 'util' +import os from 'os' +import syrup from '@devicefarmer/stf-syrup' +import {v4 as uuidv4} from 'uuid' +import jpeg from '@julusian/jpeg-turbo' +import webp from '@cwasm/webp' +import logger from '../../../../util/logger.js' +import lifecycle from '../../../../util/lifecycle.js' +import VncServer from './util/server.js' +import VncConnection from './util/connection.js' +import PointerTranslator from './util/pointertranslator.js' +import router from '../../../base-device/support/router.js' +import push from '../../../base-device/support/push.js' +import stream from '../screen/stream.js' +import touch from '../touch/index.js' +import group from '../group.js' +import solo from '../solo.js' + +interface VNCConnectionState { + lastFrame: Buffer | null + lastFrameTime: number | null + lastSentFrame: Buffer | null + frameWidth: number + frameHeight: number + lastSentTimestamp: number + sentFrameWidth: number + sentFrameHeight: number + updateRequests: number + lastRequestIncremental: boolean + pendingResponse: boolean + frameConfig: { + format: number + } + // Frame cache to avoid re-decoding identical frames + cachedRawFrame: Buffer | null + cachedDecodedFrame: {width: number, height: number, data: Buffer} | null + // Frame type detection (cached after first frame) + frameType: 'jpeg' | 'webp' | null + // FPS tracking + framesSent: number + framesDecoded: number + framesCached: number + framesThrottled: number + framesSkipped: number + lastStatsTime: number +} + +export default syrup.serial() + .dependency(router) + .dependency(push) + .dependency(stream) + .dependency(touch) + .dependency(group) + .dependency(solo) + .define((options, router, push, screenStream, touch, group) => { + const log = logger.createLogger('device:plugins:vnc') + + const MAX_FPS = 60 // Maximum fps for changing content + const STATIC_FPS = 2 // Fps for static content + const MAX_FRAME_INTERVAL = 1000 / MAX_FPS + const STATIC_FRAME_INTERVAL = 1000 / STATIC_FPS + + // const vncAuthHandler = (data: any) => new Promise((resolve, reject) => { + // log.info('VNC authentication attempt using "%s"', data.response.toString('hex')) + // const cleanup = () => { + // group.removeListener('join', joinListener) + // group.removeListener('autojoin', autojoinListener) + // router.removeListener(VncAuthResponsesUpdatedMessage, notify) + // } + // + // const notify = async() => { + // try { + // const currentGroup = await group.get() + // push.send([ + // solo.channel, + // wireutil.pack(JoinGroupByVncAuthResponseMessage, { + // serial: options.serial, + // response: data.response.toString('hex'), + // currentGroup: currentGroup?.group + // }) + // ]) + // } catch (e) { + // push.send([ + // solo.channel, + // wireutil.pack(JoinGroupByVncAuthResponseMessage, { + // serial: options.serial, + // response: data.response.toString('hex') + // }) + // ]) + // } + // } + // + // const joinListener = (newGroup: any, identifier: any) => { + // if (!data.response.equals(Buffer.from(identifier || '', 'hex'))) { + // cleanup() + // reject(new Error('Someone else took the device')) + // } + // } + // + // const autojoinListener = (identifier: any, joined: any) => { + // if (data.response.equals(Buffer.from(identifier, 'hex'))) { + // cleanup() + // if (joined) { + // resolve() + // } + // else { + // reject(new Error('Device is already in use')) + // } + // } + // } + // + // group.on('join', joinListener) + // group.on('autojoin', autojoinListener) + // router.on(VncAuthResponsesUpdatedMessage, notify) + // notify() + // }) + + log.info('Starting VNC server on port %s', options.vncPort) + + const vnc = new VncServer({ + name: options.serial, + width: options.vncInitialSize[0], + height: options.vncInitialSize[1], + // security: [{ + // type: VncConnection.SECURITY_VNC, + // challenge: Buffer.alloc(16).fill(0), + // auth: vncAuthHandler + // }] + }) + + vnc.on('error', (err: any) => { + log.error('VNC error: %s', err?.message || err) + }) + + vnc.on('connection', (conn: any) => { + log.info('New VNC connection from %s', conn.conn.remoteAddress) + + const id = util.format('vnc-%s', uuidv4()) + const connState: VNCConnectionState = { + lastFrame: null, + lastFrameTime: null, + lastSentFrame: null, + frameWidth: 0, + frameHeight: 0, + lastSentTimestamp: 0, + sentFrameWidth: 0, + sentFrameHeight: 0, + updateRequests: 0, + lastRequestIncremental: false, + pendingResponse: false, + frameConfig: { + format: jpeg.FORMAT_RGB + }, + cachedRawFrame: null, + cachedDecodedFrame: null, + frameType: null, + framesSent: 0, + framesDecoded: 0, + framesCached: 0, + framesThrottled: 0, + framesSkipped: 0, + lastStatsTime: Date.now() + } + + // Stats logging every 10 seconds + const statsInterval = setInterval(() => { + const now = Date.now() + const elapsed = (now - connState.lastStatsTime) / 1000 + const fps = (connState.framesSent / elapsed).toFixed(1) + const cacheHitRate = connState.framesSent > 0 + ? ((connState.framesCached / connState.framesSent) * 100).toFixed(1) + : '0.0' + + // Always log stats, even if no activity (shows 0 fps for static screens) + if (connState.framesSent > 0 || connState.framesThrottled > 0) { + log.info(`VNC Stats: ${fps} fps | sent: ${connState.framesSent}, decoded: ${connState.framesDecoded}, cached: ${cacheHitRate}%, throttled: ${connState.framesThrottled}`) + } + + // Reset counters + connState.framesSent = 0 + connState.framesDecoded = 0 + connState.framesCached = 0 + connState.framesThrottled = 0 + connState.framesSkipped = 0 + connState.lastStatsTime = now + }, 10000) + + const pointerTranslator = new PointerTranslator() + .on('touchdown', (event: any) => { + try { + // log.debug(`VNC pointer event: contact=${event.contact}, x=${event.x?.toFixed(3)}, y=${event.y?.toFixed(3)}`) + touch.touchDown(event) + } catch (err: any) { + log.error(`Error calling touch.touchDown(): ${err?.message} ${err?.stack}`) + } + }) + .on('touchmove', (event: any) => { + try { + // log.debug(`VNC touchmove: contact=${event.contact}, x=${event.x?.toFixed(3)}, y=${event.y?.toFixed(3)}`) + touch.touchMove(event) + } catch (err: any) { + log.error(`Error calling touch.touchMove(): ${err?.message}`) + } + }) + .on('touchup', (event: any) => { + try { + // log.debug(`VNC touchup: contact=${event.contact}`) + touch.touchUp(event) + } catch (err: any) { + log.error(`Error calling touch.touchUp(): ${err?.message}`) + } + }) + .on('touchcommit', () => { + try { + log.debug(`VNC touchcommit`) + touch.touchCommit() + } catch (err: any) { + log.error(`Error calling touch.touchCommit(): ${err?.message}`) + } + }) + .on('touchstart', () => { + try { + // log.debug(`VNC touchstart`) + touch.start() + } catch (err: any) { + log.error(`Error calling touch.touchStart(): ${err?.message}`) + } + }) + .on('touchstop', () => { + try { + // log.debug(`VNC touchstop`) + touch.stop() + } catch (err: any) { + log.error(`Error calling touch.touchStop(): ${err?.message}`) + } + }) + + const maybeSendFrame = () => { + // Must have a frame and pending update request + if (!connState.lastFrame || !connState.updateRequests) { + return + } + + const now = Date.now() + const isFirstFrame = !connState.lastSentFrame + + // Fast frame change detection: compare size first, then first/last bytes + // This is much faster than Buffer.equals() on large frames (20KB+) + let frameContentChanged = !connState.lastSentFrame + if (!frameContentChanged && connState.lastSentFrame) { + const current = connState.lastFrame + const last = connState.lastSentFrame + // Quick checks: size, first 4 bytes, last 4 bytes + frameContentChanged = + current.length !== last.length || + current[0] !== last[0] || current[1] !== last[1] || current[2] !== last[2] || current[3] !== last[3] || + current[current.length - 1] !== last[last.length - 1] || + current[current.length - 2] !== last[last.length - 2] + } + + const timeSinceLastSend = now - connState.lastSentTimestamp + + // For changing content: send as fast as possible (up to MAX_FPS) + // For static content: rate limit to STATIC_FPS to save bandwidth/CPU + if (frameContentChanged) { + // Content is changing - allow up to MAX_FPS + if (!isFirstFrame && timeSinceLastSend < MAX_FRAME_INTERVAL) { + // Too soon, but schedule immediate retry when interval passes + if (!connState.pendingResponse) { + connState.pendingResponse = true + connState.framesThrottled++ + setTimeout(() => { + connState.pendingResponse = false + maybeSendFrame() + }, MAX_FRAME_INTERVAL - timeSinceLastSend) + } + return + } + } else { + // Content is static - rate limit to STATIC_FPS + if (!isFirstFrame && connState.lastRequestIncremental && timeSinceLastSend < STATIC_FRAME_INTERVAL) { + // For static + incremental, use lower fps + if (!connState.pendingResponse) { + connState.pendingResponse = true + setTimeout(() => { + connState.pendingResponse = false + maybeSendFrame() + }, STATIC_FRAME_INTERVAL - timeSinceLastSend) + } + return + } + } + + try { + const frame = connState.lastFrame! + + let decoded: {width: number, height: number, data: Buffer} + + // Check if we can reuse cached decoded frame + // Use fast comparison: same object reference means same frame + const canUseCache = connState.cachedRawFrame === frame + + if (canUseCache && connState.cachedDecodedFrame) { + // Frame hasn't changed, reuse cached decode + decoded = connState.cachedDecodedFrame + connState.framesCached++ + } else { + // New frame, need to decode + // Detect frame type on first frame only + if (!connState.frameType) { + const isJpeg = frame[0] === 0xFF && frame[1] === 0xD8 + const isWebP = frame[0] === 0x52 && frame[1] === 0x49 && frame[2] === 0x46 && frame[3] === 0x46 // "RIFF" + + if (isWebP) { + connState.frameType = 'webp' + log.info('VNC: Detected WebP frame format') + } else if (isJpeg) { + connState.frameType = 'jpeg' + log.info('VNC: Detected JPEG frame format') + } else { + log.error(`Unknown frame format, first 4 bytes: ${frame.slice(0, 4).toString('hex')}`) + return + } + } + + // Decode based on detected frame type + if (connState.frameType === 'webp') { + const webpDecoded = webp.decode(frame) + decoded = { + width: webpDecoded.width, + height: webpDecoded.height, + data: Buffer.from(webpDecoded.data) + } + // WebP decoder returns RGBA, convert to client's expected format + decoded.data = convertRGBAToFormat(decoded.data, decoded.width, decoded.height, connState.frameConfig.format) + } else { + // JPEG + decoded = jpeg.decompressSync(frame, connState.frameConfig) + } + + connState.framesDecoded++ + + // Cache the decoded frame + connState.cachedRawFrame = frame + connState.cachedDecodedFrame = decoded + } + + // Build framebuffer update + const rectangles: any[] = [{ + xPosition: 0, + yPosition: 0, + width: decoded.width, + height: decoded.height, + encodingType: VncConnection.ENCODING_RAW, + data: decoded.data + }] + + // Send DESKTOPSIZE on first frame or when dimensions change + const isFirstFrame = connState.sentFrameWidth === 0 && connState.sentFrameHeight === 0 + const dimensionsChanged = decoded.width !== connState.sentFrameWidth || decoded.height !== connState.sentFrameHeight + + if (isFirstFrame || dimensionsChanged) { + if (isFirstFrame) { + log.info(`VNC: First frame, sending DESKTOPSIZE ${decoded.width}x${decoded.height}`) + } else { + log.info(`VNC: Dimensions changed from ${connState.sentFrameWidth}x${connState.sentFrameHeight} to ${decoded.width}x${decoded.height}`) + } + rectangles.push({ + xPosition: 0, + yPosition: 0, + width: decoded.width, + height: decoded.height, + encodingType: VncConnection.ENCODING_DESKTOPSIZE + }) + connState.sentFrameWidth = decoded.width + connState.sentFrameHeight = decoded.height + } + + try { + conn.writeFramebufferUpdate(rectangles) + connState.framesSent++ + connState.lastSentTimestamp = Date.now() + connState.lastSentFrame = connState.lastFrame + connState.updateRequests = 0 + } catch (writeErr: any) { + // Client likely disconnected, ignore EPIPE errors + if (writeErr.code === 'EPIPE' || writeErr.code === 'ECONNRESET') { + log.warn('VNC client disconnected while sending frame') + } else { + throw writeErr + } + } + } catch (err: any) { + log.error(`Error in maybeSendFrame: ${err?.message || 'Unknown error'}`) + log.error(err.stack) + // Don't let errors stop the broadcast + } + } + + // Convert RGBA (from WebP) to the format expected by VNC client + const convertRGBAToFormat = (rgba: Buffer, width: number, height: number, targetFormat: number): Buffer => { + const pixelCount = width * height + + // Most common case: client wants BGRX (format 3) or similar 32-bit format + if (targetFormat === jpeg.FORMAT_BGRX) { + // RGBA -> BGRX + const result = Buffer.alloc(pixelCount * 4) + for (let i = 0; i < pixelCount; i++) { + const src = i * 4 + const dst = i * 4 + result[dst] = rgba[src + 2] // B + result[dst + 1] = rgba[src + 1] // G + result[dst + 2] = rgba[src] // R + result[dst + 3] = 0 // X (padding) + } + return result + } else if (targetFormat === jpeg.FORMAT_RGBX || targetFormat === jpeg.FORMAT_RGB) { + // RGBA -> RGBX or RGB + const bytesPerPixel = targetFormat === jpeg.FORMAT_RGB ? 3 : 4 + const result = Buffer.alloc(pixelCount * bytesPerPixel) + for (let i = 0; i < pixelCount; i++) { + const src = i * 4 + const dst = i * bytesPerPixel + result[dst] = rgba[src] // R + result[dst + 1] = rgba[src + 1] // G + result[dst + 2] = rgba[src + 2] // B + if (bytesPerPixel === 4) { + result[dst + 3] = 0 // X + } + } + return result + } else { + // For other formats, just strip the alpha channel for now (RGB) + log.warn(`Unsupported target format ${targetFormat}, converting to RGB`) + const result = Buffer.alloc(pixelCount * 3) + for (let i = 0; i < pixelCount; i++) { + const src = i * 4 + const dst = i * 3 + result[dst] = rgba[src] // R + result[dst + 1] = rgba[src + 1] // G + result[dst + 2] = rgba[src + 2] // B + } + return result + } + } + + const vncStartListener = (frameProducer: any) => + new Promise((resolve) => { + const width = frameProducer.banner?.virtualWidth || 0 + const height = frameProducer.banner?.virtualHeight || 0 + log.info(`VNC: Frame producer started, updating dimensions to ${width}x${height}`) + connState.frameWidth = width + connState.frameHeight = height + conn.updateDimensions(width, height) + log.info(`VNC: Dimensions updated, pointer events will now use ${width}x${height} for normalization`) + resolve() + }) + + const vncFrameListener = (frame: any) => + new Promise((resolve) => { + connState.lastFrame = frame + connState.lastFrameTime = Date.now() + maybeSendFrame() + resolve() + }) + + const groupLeaveListener = () => conn.end() + + conn.on('authenticated', () => { + // Don't force projection update on VNC auth - use existing projection + // This prevents unnecessary minicap restarts that can fail on certain Android versions + // The VNC client can still request a size change later if needed + log.info('VNC authenticated, inserting into broadcastSet with id: %s', id) + + // If frame producer is already running, update dimensions before ServerInit is sent + if (screenStream.banner) { + const width = screenStream.banner.virtualWidth + const height = screenStream.banner.virtualHeight + log.info(`VNC: Setting dimensions to ${width}x${height} from running frame producer`) + conn.updateDimensions(width, height) + } + + screenStream.broadcastSet?.insert(id, { + onStart: vncStartListener, + onFrame: vncFrameListener + }) + }) + + conn.on('fbupdaterequest', (request: any) => { + connState.updateRequests += 1 + connState.lastRequestIncremental = request.incremental === 1 + maybeSendFrame() + }) + + conn.on('formatchange', (format: any) => { + const same = os.endianness() === 'BE' === Boolean(format.bigEndianFlag) + const formatOrder = (format.redShift > format.blueShift) === same + + let selectedFormat + switch (format.bitsPerPixel) { + case 8: + selectedFormat = jpeg.FORMAT_GRAY + connState.frameConfig = {format: selectedFormat} + break + case 24: + selectedFormat = formatOrder ? jpeg.FORMAT_BGR : jpeg.FORMAT_RGB + connState.frameConfig = {format: selectedFormat} + break + case 32: + selectedFormat = formatOrder + ? format.blueShift === 0 ? jpeg.FORMAT_BGRX : jpeg.FORMAT_XBGR + : format.redShift === 0 ? jpeg.FORMAT_RGBX : jpeg.FORMAT_XRGB + connState.frameConfig = {format: selectedFormat} + break + default: + log.warn(`Unsupported bitsPerPixel: ${format.bitsPerPixel}, using RGB`) + selectedFormat = jpeg.FORMAT_RGB + connState.frameConfig = {format: selectedFormat} + } + log.info(`VNC pixel format set: ${format.bitsPerPixel}bpp, format=${selectedFormat}`) + + // Clear cache when format changes since we need to re-convert + connState.cachedRawFrame = null + connState.cachedDecodedFrame = null + }) + + conn.on('pointer', (event: any) => { + // log.info(`VNC pointer event: button=${event.buttonMask}, x=${event.xPosition.toFixed(3)}, y=${event.yPosition.toFixed(3)}`) + pointerTranslator.push(event) + }) + + conn.on('close', () => { + log.info('VNC connection closed for device %s', options.serial) + clearInterval(statsInterval) + screenStream.broadcastSet?.remove(id) + group.removeListener('leave', groupLeaveListener) + }) + + conn.on('error', (err: Error) => { + log.warn('VNC connection error for device %s: %s', options.serial, err.message) + clearInterval(statsInterval) + // Clean up will happen in 'close' event + }) + + conn.on('userActivity', () => { + group.keepalive() + }) + + group.on('leave', groupLeaveListener) + clearInterval(statsInterval) + }) + + lifecycle.observe(() => { + vnc.close() + }) + + return { + start: () => vnc.listen(options.vncPort), + stop: () => vnc.close() + } + }) diff --git a/lib/units/device/plugins/vnc/util/connection.js b/lib/units/device/plugins/vnc/util/connection.js deleted file mode 100644 index 29a4e9bf7b..0000000000 --- a/lib/units/device/plugins/vnc/util/connection.js +++ /dev/null @@ -1,465 +0,0 @@ -import util from 'util' -import os from 'os' -import crypto from 'crypto' -import EventEmitter from 'eventemitter3' -import * as debug$0 from 'debug' -import Promise from 'bluebird' -import PixelFormat from './pixelformat.js' -var debug = debug$0('vnc:connection') -function VncConnection(conn, options) { - this.options = options - this._bound = { - _errorListener: this._errorListener.bind(this), - _readableListener: this._readableListener.bind(this), - _endListener: this._endListener.bind(this), - _closeListener: this._closeListener.bind(this) - } - this._buffer = null - this._state = 0 - this._changeState(VncConnection.STATE_NEED_CLIENT_VERSION) - this._serverVersion = VncConnection.V3_008 - this._serverSupportedSecurity = this.options.security - this._serverSupportedSecurityByType = - this.options.security.reduce(function(map, method) { - map[method.type] = method - return map - }, Object.create(null)) - this._serverWidth = this.options.width - this._serverHeight = this.options.height - this._serverPixelFormat = new PixelFormat({ - bitsPerPixel: 32, - depth: 24, - bigEndianFlag: os.endianness() === 'BE' ? 1 : 0, - trueColorFlag: 1, - redMax: 255, - greenMax: 255, - blueMax: 255, - redShift: 16, - greenShift: 8, - blueShift: 0 - }) - this._serverName = this.options.name - this._clientVersion = null - this._clientShare = false - this._clientPixelFormat = this._serverPixelFormat - this._clientEncodingCount = 0 - this._clientEncodings = [] - this._clientCutTextLength = 0 - this._authChallenge = this.options.challenge || crypto.randomBytes(16) - this.conn = conn - .on('error', this._bound._errorListener) - .on('readable', this._bound._readableListener) - .on('end', this._bound._endListener) - .on('close', this._bound._closeListener) - this._blockingOps = [] - this._writeServerVersion() - this._read() -} -util.inherits(VncConnection, EventEmitter) -VncConnection.V3_003 = 3003 -VncConnection.V3_007 = 3007 -VncConnection.V3_008 = 3008 -VncConnection.SECURITY_NONE = 1 -VncConnection.SECURITY_VNC = 2 -VncConnection.SECURITYRESULT_OK = 0 -VncConnection.SECURITYRESULT_FAIL = 1 -VncConnection.CLIENT_MESSAGE_SETPIXELFORMAT = 0 -VncConnection.CLIENT_MESSAGE_SETENCODINGS = 2 -VncConnection.CLIENT_MESSAGE_FBUPDATEREQUEST = 3 -VncConnection.CLIENT_MESSAGE_KEYEVENT = 4 -VncConnection.CLIENT_MESSAGE_POINTEREVENT = 5 -VncConnection.CLIENT_MESSAGE_CLIENTCUTTEXT = 6 -VncConnection.SERVER_MESSAGE_FBUPDATE = 0 -var StateReverse = Object.create(null) -var State = { - STATE_NEED_CLIENT_VERSION: 10, - STATE_NEED_CLIENT_SECURITY: 20, - STATE_NEED_CLIENT_INIT: 30, - STATE_NEED_CLIENT_VNC_AUTH: 31, - STATE_NEED_CLIENT_MESSAGE: 40, - STATE_NEED_CLIENT_MESSAGE_SETPIXELFORMAT: 50, - STATE_NEED_CLIENT_MESSAGE_SETENCODINGS: 60, - STATE_NEED_CLIENT_MESSAGE_SETENCODINGS_VALUE: 61, - STATE_NEED_CLIENT_MESSAGE_FBUPDATEREQUEST: 70, - STATE_NEED_CLIENT_MESSAGE_KEYEVENT: 80, - STATE_NEED_CLIENT_MESSAGE_POINTEREVENT: 90, - STATE_NEED_CLIENT_MESSAGE_CLIENTCUTTEXT: 100, - STATE_NEED_CLIENT_MESSAGE_CLIENTCUTTEXT_VALUE: 101 -} -VncConnection.ENCODING_RAW = 0 -VncConnection.ENCODING_DESKTOPSIZE = -223 -Object.keys(State).map(function(name) { - VncConnection[name] = State[name] - StateReverse[State[name]] = name -}) -VncConnection.prototype.end = function() { - this.conn.end() -} -VncConnection.prototype.writeFramebufferUpdate = function(rectangles) { - var chunk = Buffer.alloc(4) - chunk[0] = VncConnection.SERVER_MESSAGE_FBUPDATE - chunk[1] = 0 - chunk.writeUInt16BE(rectangles.length, 2) - this._write(chunk) - rectangles.forEach(function(rect) { - var rchunk = Buffer.alloc(12) - rchunk.writeUInt16BE(rect.xPosition, 0) - rchunk.writeUInt16BE(rect.yPosition, 2) - rchunk.writeUInt16BE(rect.width, 4) - rchunk.writeUInt16BE(rect.height, 6) - rchunk.writeInt32BE(rect.encodingType, 8) - this._write(rchunk) - switch (rect.encodingType) { - case VncConnection.ENCODING_RAW: - this._write(rect.data) - break - case VncConnection.ENCODING_DESKTOPSIZE: - this._serverWidth = rect.width - this._serverHeight = rect.height - break - default: - throw new Error(util.format('Unsupported encoding type', rect.encodingType)) - } - }, this) -} -VncConnection.prototype._error = function(err) { - this.emit('error', err) - this.end() -} -VncConnection.prototype._errorListener = function(err) { - this._error(err) -} -VncConnection.prototype._endListener = function() { - this.emit('end') -} -VncConnection.prototype._closeListener = function() { - this.emit('close') -} -VncConnection.prototype._writeServerVersion = function() { - // Yes, we could just format the string instead. Didn't feel like it. - switch (this._serverVersion) { - case VncConnection.V3_003: - this._write(Buffer.from('RFB 003.003\n')) - break - case VncConnection.V3_007: - this._write(Buffer.from('RFB 003.007\n')) - break - case VncConnection.V3_008: - this._write(Buffer.from('RFB 003.008\n')) - break - } -} -VncConnection.prototype._writeSupportedSecurity = function() { - var chunk = Buffer.alloc(1 + this._serverSupportedSecurity.length) - chunk[0] = this._serverSupportedSecurity.length - this._serverSupportedSecurity.forEach(function(security, i) { - chunk[1 + i] = security.type - }) - this._write(chunk) -} -VncConnection.prototype._writeSecurityResult = function(result, reason) { - var chunk - switch (result) { - case VncConnection.SECURITYRESULT_OK: - chunk = Buffer.alloc(4) - chunk.writeUInt32BE(result, 0) - this._write(chunk) - break - case VncConnection.SECURITYRESULT_FAIL: - chunk = Buffer.alloc(4 + 4 + reason.length) - chunk.writeUInt32BE(result, 0) - chunk.writeUInt32BE(reason.length, 4) - chunk.write(reason, 8, reason.length) - this._write(chunk) - break - } -} -VncConnection.prototype._writeServerInit = function() { - debug('server pixel format', this._serverPixelFormat) - var chunk = Buffer.alloc(2 + 2 + 16 + 4 + this._serverName.length) - chunk.writeUInt16BE(this._serverWidth, 0) - chunk.writeUInt16BE(this._serverHeight, 2) - chunk[4] = this._serverPixelFormat.bitsPerPixel - chunk[5] = this._serverPixelFormat.depth - chunk[6] = this._serverPixelFormat.bigEndianFlag - chunk[7] = this._serverPixelFormat.trueColorFlag - chunk.writeUInt16BE(this._serverPixelFormat.redMax, 8) - chunk.writeUInt16BE(this._serverPixelFormat.greenMax, 10) - chunk.writeUInt16BE(this._serverPixelFormat.blueMax, 12) - chunk[14] = this._serverPixelFormat.redShift - chunk[15] = this._serverPixelFormat.greenShift - chunk[16] = this._serverPixelFormat.blueShift - chunk[17] = 0 // padding - chunk[18] = 0 // padding - chunk[19] = 0 // padding - chunk.writeUInt32BE(this._serverName.length, 20) - chunk.write(this._serverName, 24, this._serverName.length) - this._write(chunk) -} -VncConnection.prototype._writeVncAuthChallenge = function() { - var vncSec = this._serverSupportedSecurityByType[VncConnection.SECURITY_VNC] - debug('vnc auth challenge', vncSec.challenge) - this._write(vncSec.challenge) -} -VncConnection.prototype._readableListener = function() { - this._read() -} -VncConnection.prototype._read = function() { - Promise.all(this._blockingOps).bind(this) - .then(this._unguardedRead) -} -VncConnection.prototype._auth = function(type, data) { - var security = this._serverSupportedSecurityByType[type] - this._blockingOps.push(security.auth(data).bind(this) - .then(function() { - this._changeState(VncConnection.STATE_NEED_CLIENT_INIT) - this._writeSecurityResult(VncConnection.SECURITYRESULT_OK) - this.emit('authenticated') - this._read() - }) - .catch(function() { - this._writeSecurityResult(VncConnection.SECURITYRESULT_FAIL, 'Authentication failure') - this.end() - })) -} -VncConnection.prototype._unguardedRead = function() { - var chunk, lo, hi - while (this._append(this.conn.read())) { - do { - debug('state', StateReverse[this._state]) - chunk = null - switch (this._state) { - case VncConnection.STATE_NEED_CLIENT_VERSION: - if ((chunk = this._consume(12))) { - if ((this._clientVersion = this._parseVersion(chunk)) === null) { - this.end() - return - } - debug('client version', this._clientVersion) - this._writeSupportedSecurity() - this._changeState(VncConnection.STATE_NEED_CLIENT_SECURITY) - } - break - case VncConnection.STATE_NEED_CLIENT_SECURITY: - if ((chunk = this._consume(1))) { - if ((this._clientSecurity = this._parseSecurity(chunk)) === null) { - this._writeSecurityResult(VncConnection.SECURITYRESULT_FAIL, 'Unimplemented security type') - this.end() - return - } - debug('client security', this._clientSecurity) - if (!(this._clientSecurity in this._serverSupportedSecurityByType)) { - this._writeSecurityResult(VncConnection.SECURITYRESULT_FAIL, 'Unsupported security type') - this.end() - return - } - switch (this._clientSecurity) { - case VncConnection.SECURITY_NONE: - // TODO: investigate more elegant way of passing security challenge - // his._auth(VncConnection.SECURITY_NONE) - this._changeState(VncConnection.STATE_NEED_CLIENT_INIT) - this._writeSecurityResult(VncConnection.SECURITYRESULT_OK) - this.emit('authenticated') - return - case VncConnection.SECURITY_VNC: - this._writeVncAuthChallenge() - this._changeState(VncConnection.STATE_NEED_CLIENT_VNC_AUTH) - break - } - } - break - case VncConnection.STATE_NEED_CLIENT_VNC_AUTH: - if ((chunk = this._consume(16))) { - this._auth(VncConnection.SECURITY_VNC, { - response: chunk - }) - return - } - break - case VncConnection.STATE_NEED_CLIENT_INIT: - if ((chunk = this._consume(1))) { - this._clientShare = chunk[0] - debug('client shareFlag', this._clientShare) - this._writeServerInit() - this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE) - } - break - case VncConnection.STATE_NEED_CLIENT_MESSAGE: - if ((chunk = this._consume(1))) { - switch (chunk[0]) { - case VncConnection.CLIENT_MESSAGE_SETPIXELFORMAT: - this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE_SETPIXELFORMAT) - break - case VncConnection.CLIENT_MESSAGE_SETENCODINGS: - this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE_SETENCODINGS) - break - case VncConnection.CLIENT_MESSAGE_FBUPDATEREQUEST: - this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE_FBUPDATEREQUEST) - break - case VncConnection.CLIENT_MESSAGE_KEYEVENT: - this.emit('userActivity') - this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE_KEYEVENT) - break - case VncConnection.CLIENT_MESSAGE_POINTEREVENT: - this.emit('userActivity') - this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE_POINTEREVENT) - break - case VncConnection.CLIENT_MESSAGE_CLIENTCUTTEXT: - this.emit('userActivity') - this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE_CLIENTCUTTEXT) - break - default: - this._error(new Error(util.format('Unsupported message type %d', chunk[0]))) - return - } - } - break - case VncConnection.STATE_NEED_CLIENT_MESSAGE_SETPIXELFORMAT: - if ((chunk = this._consume(19))) { - // [0b, 3b) padding - this._clientPixelFormat = new PixelFormat({ - bitsPerPixel: chunk[3], - depth: chunk[4], - bigEndianFlag: chunk[5], - trueColorFlag: chunk[6], - redMax: chunk.readUInt16BE(7, true), - greenMax: chunk.readUInt16BE(9, true), - blueMax: chunk.readUInt16BE(11, true), - redShift: chunk[13], - greenShift: chunk[14], - blueShift: chunk[15] - }) - // [16b, 19b) padding - debug('client pixel format', this._clientPixelFormat) - this.emit('formatchange', this._clientPixelFormat) - this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE) - } - break - case VncConnection.STATE_NEED_CLIENT_MESSAGE_SETENCODINGS: - if ((chunk = this._consume(3))) { - // [0b, 1b) padding - this._clientEncodingCount = chunk.readUInt16BE(1, true) - this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE_SETENCODINGS_VALUE) - } - break - case VncConnection.STATE_NEED_CLIENT_MESSAGE_SETENCODINGS_VALUE: - lo = 0 - hi = 4 * this._clientEncodingCount - if ((chunk = this._consume(hi))) { - this._clientEncodings = [] - while (lo < hi) { - this._clientEncodings.push(chunk.readInt32BE(lo, true)) - lo += 4 - } - debug('client encodings', this._clientEncodings) - this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE) - } - break - case VncConnection.STATE_NEED_CLIENT_MESSAGE_FBUPDATEREQUEST: - if ((chunk = this._consume(9))) { - this.emit('fbupdaterequest', { - incremental: chunk[0], - xPosition: chunk.readUInt16BE(1, true), - yPosition: chunk.readUInt16BE(3, true), - width: chunk.readUInt16BE(5, true), - height: chunk.readUInt16BE(7, true) - }) - this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE) - } - break - case VncConnection.STATE_NEED_CLIENT_MESSAGE_KEYEVENT: - if ((chunk = this._consume(7))) { - // downFlag = chunk[0] - // [1b, 3b) padding - // key = chunk.readUInt32BE(3, true) - this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE) - } - break - case VncConnection.STATE_NEED_CLIENT_MESSAGE_POINTEREVENT: - if ((chunk = this._consume(5))) { - this.emit('pointer', { - buttonMask: chunk[0], - xPosition: chunk.readUInt16BE(1, true) / this._serverWidth, - yPosition: chunk.readUInt16BE(3, true) / this._serverHeight - }) - this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE) - } - break - case VncConnection.STATE_NEED_CLIENT_MESSAGE_CLIENTCUTTEXT: - if ((chunk = this._consume(7))) { - // [0b, 3b) padding - this._clientCutTextLength = chunk.readUInt32BE(3) - this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE_CLIENTCUTTEXT_VALUE) - } - break - case VncConnection.STATE_NEED_CLIENT_MESSAGE_CLIENTCUTTEXT_VALUE: - if ((chunk = this._consume(this._clientCutTextLength))) { - // value = chunk - this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE) - } - break - default: - throw new Error(util.format('Impossible state %d', this._state)) - } - } while (chunk) - } -} -VncConnection.prototype._parseVersion = function(chunk) { - if (chunk.equals(Buffer.from('RFB 003.008\n'))) { - return VncConnection.V3_008 - } - if (chunk.equals(Buffer.from('RFB 003.007\n'))) { - return VncConnection.V3_007 - } - if (chunk.equals(Buffer.from('RFB 003.003\n'))) { - return VncConnection.V3_003 - } - return null -} -VncConnection.prototype._parseSecurity = function(chunk) { - switch (chunk[0]) { - case VncConnection.SECURITY_NONE: - case VncConnection.SECURITY_VNC: - return chunk[0] - default: - return null - } -} -VncConnection.prototype._changeState = function(state) { - this._state = state -} -VncConnection.prototype._append = function(chunk) { - if (!chunk) { - return false - } - debug('in', chunk) - if (this._buffer) { - this._buffer = Buffer.concat([this._buffer, chunk], this._buffer.length + chunk.length) - } - else { - this._buffer = chunk - } - return true -} -VncConnection.prototype._consume = function(n) { - var chunk - if (!this._buffer) { - return null - } - if (n < this._buffer.length) { - chunk = this._buffer.slice(0, n) - this._buffer = this._buffer.slice(n) - return chunk - } - if (n === this._buffer.length) { - chunk = this._buffer - this._buffer = null - return chunk - } - return null -} -VncConnection.prototype._write = function(chunk) { - debug('out', chunk) - this.conn.write(chunk) -} -export default VncConnection diff --git a/lib/units/device/plugins/vnc/util/connection.ts b/lib/units/device/plugins/vnc/util/connection.ts new file mode 100644 index 0000000000..05c84eba20 --- /dev/null +++ b/lib/units/device/plugins/vnc/util/connection.ts @@ -0,0 +1,753 @@ +import util from 'util' +import os from 'os' +import crypto from 'crypto' +import EventEmitter from 'events' +import { Socket } from 'net' +import PixelFormat from './pixelformat.js' +import logger from '../../../../../util/logger.js' + +const _logger = logger.createLogger('vnc:connection') +const debug = _logger.debug.bind(_logger) + +interface SecurityMethod { + type: number + challenge?: Buffer + auth: (data: AuthData) => Promise +} + +interface VncConnectionOptions { + width: number + height: number + name: string + challenge?: Buffer + security?: SecurityMethod[] +} + +interface AuthData { + response: Buffer +} + +interface Rectangle { + xPosition: number + yPosition: number + width: number + height: number + encodingType: number + data?: Buffer +} + +interface FbUpdateRequest { + incremental: number + xPosition: number + yPosition: number + width: number + height: number +} + +interface PointerEvent { + buttonMask: number + xPosition: number + yPosition: number +} + +class VncConnection extends EventEmitter { + // Version constants + static readonly V3_003 = 3003 + static readonly V3_007 = 3007 + static readonly V3_008 = 3008 + + // Security constants + static readonly SECURITY_NONE = 1 + static readonly SECURITY_VNC = 2 + + // Security result constants + static readonly SECURITYRESULT_OK = 0 + static readonly SECURITYRESULT_FAIL = 1 + + // Client message constants + static readonly CLIENT_MESSAGE_SETPIXELFORMAT = 0 + static readonly CLIENT_MESSAGE_SETENCODINGS = 2 + static readonly CLIENT_MESSAGE_FBUPDATEREQUEST = 3 + static readonly CLIENT_MESSAGE_KEYEVENT = 4 + static readonly CLIENT_MESSAGE_POINTEREVENT = 5 + static readonly CLIENT_MESSAGE_CLIENTCUTTEXT = 6 + + // Server message constants + static readonly SERVER_MESSAGE_FBUPDATE = 0 + + // Encoding constants + static readonly ENCODING_RAW = 0 + static readonly ENCODING_TIGHT = 7 + static readonly ENCODING_DESKTOPSIZE = -223 + + // State constants + static readonly STATE_NEED_CLIENT_VERSION = 10 + static readonly STATE_NEED_CLIENT_SECURITY = 20 + static readonly STATE_NEED_CLIENT_INIT = 30 + static readonly STATE_NEED_CLIENT_VNC_AUTH = 31 + static readonly STATE_NEED_CLIENT_MESSAGE = 40 + static readonly STATE_NEED_CLIENT_MESSAGE_SETPIXELFORMAT = 50 + static readonly STATE_NEED_CLIENT_MESSAGE_SETENCODINGS = 60 + static readonly STATE_NEED_CLIENT_MESSAGE_SETENCODINGS_VALUE = 61 + static readonly STATE_NEED_CLIENT_MESSAGE_FBUPDATEREQUEST = 70 + static readonly STATE_NEED_CLIENT_MESSAGE_KEYEVENT = 80 + static readonly STATE_NEED_CLIENT_MESSAGE_POINTEREVENT = 90 + static readonly STATE_NEED_CLIENT_MESSAGE_CLIENTCUTTEXT = 100 + static readonly STATE_NEED_CLIENT_MESSAGE_CLIENTCUTTEXT_VALUE = 101 + + options: VncConnectionOptions + conn: Socket + private _buffer: Buffer | null + private _state: number + private _serverVersion: number + private _serverSupportedSecurity: SecurityMethod[] + private _serverSupportedSecurityByType: Record + private _serverWidth: number + private _serverHeight: number + private _serverPixelFormat: PixelFormat + private _serverName: string + private _clientVersion: number | null + private _clientSecurity?: number + private _clientShare: number + private _clientPixelFormat: PixelFormat + private _clientEncodingCount: number + private _clientEncodings: number[] + private _clientCutTextLength: number + private _authChallenge: Buffer + private _blockingOps: Array> + + constructor(conn: Socket, options: VncConnectionOptions) { + super() + this.options = options + this._buffer = null + this._state = 0 + this._changeState(VncConnection.STATE_NEED_CLIENT_VERSION) + this._serverVersion = VncConnection.V3_008 + + // If no security is provided, default to accepting both SECURITY_VNC and SECURITY_NONE + // with an auth handler that always succeeds (accepts any password) + // IMPORTANT: SECURITY_VNC must be FIRST because RFB 3.003 clients (like macOS Screen Sharing) + // only use the first security type in the array (no negotiation), and macOS requires VNC auth + if (!this.options.security || this.options.security.length === 0) { + this._serverSupportedSecurity = [ + { + type: VncConnection.SECURITY_VNC, + challenge: crypto.randomBytes(16), + auth: async () => { + /* Accept any password - no verification */ + debug('VNC auth: accepting any password (no security configured)') + } + }, + { + type: VncConnection.SECURITY_NONE, + auth: async () => { /* No authentication needed */ } + } + ] + } else { + this._serverSupportedSecurity = this.options.security + } + + this._serverSupportedSecurityByType = this._serverSupportedSecurity.reduce( + (map: Record, method: SecurityMethod) => { + map[method.type] = method + return map + }, + Object.create(null) + ) + this._serverWidth = this.options.width + this._serverHeight = this.options.height + this._serverPixelFormat = new PixelFormat({ + bitsPerPixel: 32, + depth: 24, + bigEndianFlag: os.endianness() === 'BE' ? 1 : 0, + trueColorFlag: 1, + redMax: 255, + greenMax: 255, + blueMax: 255, + redShift: 16, + greenShift: 8, + blueShift: 0 + }) + this._serverName = this.options.name + this._clientVersion = null + this._clientShare = 0 + this._clientPixelFormat = this._serverPixelFormat + this._clientEncodingCount = 0 + this._clientEncodings = [] + this._clientCutTextLength = 0 + this._authChallenge = this.options.challenge || crypto.randomBytes(16) + this.conn = conn + .on('error', this._errorListener) + .on('readable', this._readableListener) + .on('end', this._endListener) + .on('close', this._closeListener) + this._blockingOps = [] + this._writeServerVersion() + this._read() + } + + end(): void { + if (!this.conn.destroyed) { + this.conn.end() + } + } + + updateDimensions(width: number, height: number): void { + debug(`Updating server dimensions from ${this._serverWidth}x${this._serverHeight} to ${width}x${height}`) + this._serverWidth = width + this._serverHeight = height + } + + writeFramebufferUpdate(rectangles: Rectangle[]): void { + const chunk = Buffer.alloc(4) + chunk[0] = VncConnection.SERVER_MESSAGE_FBUPDATE + chunk[1] = 0 + chunk.writeUInt16BE(rectangles.length, 2) + this._write(chunk) + rectangles.forEach((rect: any) => { + const rchunk = Buffer.alloc(12) + rchunk.writeUInt16BE(rect.xPosition, 0) + rchunk.writeUInt16BE(rect.yPosition, 2) + rchunk.writeUInt16BE(rect.width, 4) + rchunk.writeUInt16BE(rect.height, 6) + rchunk.writeInt32BE(rect.encodingType, 8) + this._write(rchunk) + switch (rect.encodingType) { + case VncConnection.ENCODING_RAW: + if (rect.data) { + this._write(rect.data) + } + break + case VncConnection.ENCODING_TIGHT: + // Tight encoding with JPEG compression + if (rect.jpegData) { + // Compression control byte (0x90 = JPEG compression) + const compressionControl = Buffer.alloc(1) + compressionControl[0] = rect.compressionControl || 0x90 + this._write(compressionControl) + + // Write JPEG data length in compact representation + const jpegLength = rect.jpegData.length + const lengthBuf = this._encodeCompactLength(jpegLength) + this._write(lengthBuf) + + // Write JPEG data + this._write(rect.jpegData) + } + break + case VncConnection.ENCODING_DESKTOPSIZE: + this._serverWidth = rect.width + this._serverHeight = rect.height + break + default: + throw new Error(util.format('Unsupported encoding type', rect.encodingType)) + } + }) + } + + private _encodeCompactLength(length: number): Buffer { + // Tight encoding uses compact length representation + // 0-127: 1 byte + // 128-16383: 2 bytes + // 16384+: 3 bytes + if (length < 128) { + const buf = Buffer.alloc(1) + buf[0] = length + return buf + } else if (length < 16384) { + const buf = Buffer.alloc(2) + buf[0] = (length & 0x7F) | 0x80 + buf[1] = (length >> 7) & 0xFF + return buf + } else { + const buf = Buffer.alloc(3) + buf[0] = (length & 0x7F) | 0x80 + buf[1] = ((length >> 7) & 0x7F) | 0x80 + buf[2] = (length >> 14) & 0xFF + return buf + } + } + + private _error(err: Error): void { + this.emit('error', err) + if (!this.conn.destroyed) { + this.end() + } + } + + private _errorListener = (err: Error): void => { + this._error(err) + } + + private _endListener = (): void => { + this.emit('end') + } + + private _closeListener = (): void => { + this.emit('close') + } + + private _writeServerVersion(): void { + switch (this._serverVersion) { + case VncConnection.V3_003: + this._write(Buffer.from('RFB 003.003\n')) + break + case VncConnection.V3_007: + this._write(Buffer.from('RFB 003.007\n')) + break + case VncConnection.V3_008: + this._write(Buffer.from('RFB 003.008\n')) + break + } + } + + private _writeSupportedSecurity(): void { + const chunk = Buffer.alloc(1 + this._serverSupportedSecurity.length) + chunk[0] = this._serverSupportedSecurity.length + this._serverSupportedSecurity.forEach((security, i) => { + chunk[1 + i] = security.type + }) + this._write(chunk) + } + + private _writeSupportedSecurityV3_003(): void { + // RFB 3.003: Server sends 4-byte UINT32 with security type (no negotiation) + const chunk = Buffer.alloc(4) + const secType = this._serverSupportedSecurity.length > 0 + ? this._serverSupportedSecurity[0].type + : VncConnection.SECURITY_NONE + chunk.writeUInt32BE(secType, 0) + this._write(chunk) + } + + private _writeSecurityResult(result: number, reason?: string): void { + let chunk: Buffer + switch (result) { + case VncConnection.SECURITYRESULT_OK: + chunk = Buffer.alloc(4) + chunk.writeUInt32BE(result, 0) + this._write(chunk) + break + case VncConnection.SECURITYRESULT_FAIL: + if (!reason) { + reason = 'Unknown error' + } + chunk = Buffer.alloc(4 + 4 + reason.length) + chunk.writeUInt32BE(result, 0) + chunk.writeUInt32BE(reason.length, 4) + chunk.write(reason, 8, reason.length) + this._write(chunk) + break + } + } + + private _writeServerInit(): void { + debug('server pixel format %s', this._serverPixelFormat) + debug(`ServerInit: ${this._serverWidth}x${this._serverHeight}, name=${this._serverName}`) + const chunk = Buffer.alloc(2 + 2 + 16 + 4 + this._serverName.length) + chunk.writeUInt16BE(this._serverWidth, 0) + chunk.writeUInt16BE(this._serverHeight, 2) + chunk[4] = this._serverPixelFormat.bitsPerPixel + chunk[5] = this._serverPixelFormat.depth + chunk[6] = this._serverPixelFormat.bigEndianFlag + chunk[7] = this._serverPixelFormat.trueColorFlag + chunk.writeUInt16BE(this._serverPixelFormat.redMax, 8) + chunk.writeUInt16BE(this._serverPixelFormat.greenMax, 10) + chunk.writeUInt16BE(this._serverPixelFormat.blueMax, 12) + chunk[14] = this._serverPixelFormat.redShift + chunk[15] = this._serverPixelFormat.greenShift + chunk[16] = this._serverPixelFormat.blueShift + chunk[17] = 0 // padding + chunk[18] = 0 // padding + chunk[19] = 0 // padding + chunk.writeUInt32BE(this._serverName.length, 20) + chunk.write(this._serverName, 24, this._serverName.length) + this._write(chunk) + } + + private _writeVncAuthChallenge(): void { + const vncSec = this._serverSupportedSecurityByType[VncConnection.SECURITY_VNC] + debug('vnc auth challenge %s', vncSec.challenge) + if (vncSec.challenge) { + this._write(vncSec.challenge) + } + } + + private _readableListener = (): void => { + this._read() + } + + private _read(): void { + Promise.all(this._blockingOps) + .then(() => this._unguardedRead()) + .catch((err) => { + debug('_read() promise rejected: %s', err?.message || err) + }) + } + + private _auth(type: number, data: AuthData): void { + const security = this._serverSupportedSecurityByType[type] + const success = () => { + this._changeState(VncConnection.STATE_NEED_CLIENT_INIT) + // RFB 3.003 spec says not to send security result, but many clients (especially macOS Screen Sharing) + // expect it anyway. Send it for compatibility. + this._writeSecurityResult(VncConnection.SECURITYRESULT_OK) + debug('VNC authenticated successfully') + this.emit('authenticated') + // Don't call _read() here - let the promise completion trigger it + } + + if (!security) { + success() + // Manually trigger read after synchronous success + this._read() + return + } + + const authPromise = security + .auth(data) + .then(() => { + success() + }) + .catch((err) => { + debug('auth failed: %s', err?.message || err) + // Send security result for compatibility (see success case) + this._writeSecurityResult( + VncConnection.SECURITYRESULT_FAIL, + 'Authentication failure' + ) + this.end() + }) + .finally(() => { + // Remove this promise from blocking ops after completion + const index = this._blockingOps.indexOf(authPromise) + if (index > -1) { + this._blockingOps.splice(index, 1) + } + // Trigger next read cycle after auth completes + // Use setImmediate to break out of the promise context + setImmediate(() => { + this._read() + }) + }) + + this._blockingOps.push(authPromise) + } + + private _unguardedRead(): void { + let chunk: Buffer | null + let lo: number + let hi: number + while (this._append(this.conn.read())) { + do { + chunk = null + switch (this._state) { + case VncConnection.STATE_NEED_CLIENT_VERSION: + if ((chunk = this._consume(12))) { + const parsedVersion = this._parseVersion(chunk) + if (parsedVersion === null) { + debug('ERROR: Invalid client version string: %s', chunk.toString()) + this.end() + return + } + this._clientVersion = parsedVersion + const versionString = parsedVersion === VncConnection.V3_003 ? '3.003' : + parsedVersion === VncConnection.V3_007 ? '3.007' : '3.008' + debug(`Client version: RFB ${versionString} (${parsedVersion})`) + + // RFB 3.003 uses a different security handshake than 3.007/3.008 + if (this._clientVersion === VncConnection.V3_003) { + this._writeSupportedSecurityV3_003() + // For 3.003, no client selection - move based on security type + if (this._serverSupportedSecurity.length === 0) { + this._error(new Error('No security methods available')) + return + } + const secType = this._serverSupportedSecurity[0].type + this._clientSecurity = secType + + if (secType === VncConnection.SECURITY_NONE) { + this._changeState(VncConnection.STATE_NEED_CLIENT_INIT) + this.emit('authenticated') + } else if (secType === VncConnection.SECURITY_VNC) { + this._writeVncAuthChallenge() + this._changeState(VncConnection.STATE_NEED_CLIENT_VNC_AUTH) + } else { + this._error(new Error('Unsupported security type for RFB 3.003')) + return + } + } else { + // RFB 3.007 and 3.008 + this._writeSupportedSecurity() + this._changeState(VncConnection.STATE_NEED_CLIENT_SECURITY) + } + } + break + case VncConnection.STATE_NEED_CLIENT_SECURITY: + if ((chunk = this._consume(1))) { + const parsedSecurity = this._parseSecurity(chunk) + if (parsedSecurity === null) { + this._writeSecurityResult( + VncConnection.SECURITYRESULT_FAIL, + 'Unimplemented security type' + ) + this.end() + return + } + this._clientSecurity = parsedSecurity + debug('client security %s', this._clientSecurity) + if (!(this._clientSecurity in this._serverSupportedSecurityByType)) { + this._writeSecurityResult( + VncConnection.SECURITYRESULT_FAIL, + 'Unsupported security type' + ) + this.end() + return + } + switch (this._clientSecurity) { + case VncConnection.SECURITY_NONE: + this._changeState(VncConnection.STATE_NEED_CLIENT_INIT) + // RFB 3.007/3.008 send security result, 3.003 doesn't (but won't reach here) + if (this._clientVersion !== VncConnection.V3_003) { + this._writeSecurityResult(VncConnection.SECURITYRESULT_OK) + } + this.emit('authenticated') + return + case VncConnection.SECURITY_VNC: + this._writeVncAuthChallenge() + this._changeState(VncConnection.STATE_NEED_CLIENT_VNC_AUTH) + break + } + } + break + case VncConnection.STATE_NEED_CLIENT_VNC_AUTH: + if ((chunk = this._consume(16))) { + this._auth(VncConnection.SECURITY_VNC, { + response: chunk + }) + return + } + break + case VncConnection.STATE_NEED_CLIENT_INIT: + if ((chunk = this._consume(1))) { + this._clientShare = chunk[0] + debug('client shareFlag %s', this._clientShare) + this._writeServerInit() + this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE) + } + break + case VncConnection.STATE_NEED_CLIENT_MESSAGE: + if ((chunk = this._consume(1))) { + const messageType = chunk[0] + // debug(`Client message type: ${messageType}`) + switch (messageType) { + case VncConnection.CLIENT_MESSAGE_SETPIXELFORMAT: + this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE_SETPIXELFORMAT) + break + case VncConnection.CLIENT_MESSAGE_SETENCODINGS: + this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE_SETENCODINGS) + break + case VncConnection.CLIENT_MESSAGE_FBUPDATEREQUEST: + this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE_FBUPDATEREQUEST) + break + case VncConnection.CLIENT_MESSAGE_KEYEVENT: + this.emit('userActivity') + this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE_KEYEVENT) + break + case VncConnection.CLIENT_MESSAGE_POINTEREVENT: + this.emit('userActivity') + this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE_POINTEREVENT) + break + case VncConnection.CLIENT_MESSAGE_CLIENTCUTTEXT: + this.emit('userActivity') + this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE_CLIENTCUTTEXT) + break + default: + this._error(new Error(util.format('Unsupported message type %d', messageType))) + return + } + } + break + case VncConnection.STATE_NEED_CLIENT_MESSAGE_SETPIXELFORMAT: + if ((chunk = this._consume(19))) { + // [0b, 3b) padding + this._clientPixelFormat = new PixelFormat({ + bitsPerPixel: chunk[3], + depth: chunk[4], + bigEndianFlag: chunk[5], + trueColorFlag: chunk[6], + redMax: chunk.readUInt16BE(7), + greenMax: chunk.readUInt16BE(9), + blueMax: chunk.readUInt16BE(11), + redShift: chunk[13], + greenShift: chunk[14], + blueShift: chunk[15] + }) + // [16b, 19b) padding + debug('client pixel format %s', this._clientPixelFormat) + this.emit('formatchange', this._clientPixelFormat) + this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE) + } + break + case VncConnection.STATE_NEED_CLIENT_MESSAGE_SETENCODINGS: + if ((chunk = this._consume(3))) { + // [0b, 1b) padding + this._clientEncodingCount = chunk.readUInt16BE(1) + this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE_SETENCODINGS_VALUE) + } + break + case VncConnection.STATE_NEED_CLIENT_MESSAGE_SETENCODINGS_VALUE: + lo = 0 + hi = 4 * this._clientEncodingCount + if ((chunk = this._consume(hi))) { + this._clientEncodings = [] + while (lo < hi) { + this._clientEncodings.push(chunk.readInt32BE(lo)) + lo += 4 + } + debug('client encodings %s', this._clientEncodings) + this.emit('encodings', this._clientEncodings) + this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE) + } + break + case VncConnection.STATE_NEED_CLIENT_MESSAGE_FBUPDATEREQUEST: + if ((chunk = this._consume(9))) { + this.emit('fbupdaterequest', { + incremental: chunk[0], + xPosition: chunk.readUInt16BE(1), + yPosition: chunk.readUInt16BE(3), + width: chunk.readUInt16BE(5), + height: chunk.readUInt16BE(7) + } as FbUpdateRequest) + this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE) + } + break + case VncConnection.STATE_NEED_CLIENT_MESSAGE_KEYEVENT: + if ((chunk = this._consume(7))) { + // downFlag = chunk[0] + // [1b, 3b) padding + // key = chunk.readUInt32BE(3) + this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE) + } + break + case VncConnection.STATE_NEED_CLIENT_MESSAGE_POINTEREVENT: + if ((chunk = this._consume(5))) { + const buttonMask = chunk[0] + const xPixel = chunk.readUInt16BE(1) + const yPixel = chunk.readUInt16BE(3) + + // Prevent division by zero - if dimensions are not set, use pixel coordinates as normalized (assuming 0-1 range) + if (this._serverWidth === 0 || this._serverHeight === 0) { + debug(`WARNING: Server dimensions not set (${this._serverWidth}x${this._serverHeight}), cannot normalize pointer coordinates`) + this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE) + break + } + + const xNorm = xPixel / this._serverWidth + const yNorm = yPixel / this._serverHeight + + debug(`Pointer event received: button=${buttonMask}, x=${xPixel}/${this._serverWidth}=${xNorm.toFixed(3)}, y=${yPixel}/${this._serverHeight}=${yNorm.toFixed(3)}`) + + this.emit('pointer', { + buttonMask: buttonMask, + xPosition: xNorm, + yPosition: yNorm + } as PointerEvent) + this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE) + } + break + case VncConnection.STATE_NEED_CLIENT_MESSAGE_CLIENTCUTTEXT: + if ((chunk = this._consume(7))) { + // [0b, 3b) padding + this._clientCutTextLength = chunk.readUInt32BE(3) + this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE_CLIENTCUTTEXT_VALUE) + } + break + case VncConnection.STATE_NEED_CLIENT_MESSAGE_CLIENTCUTTEXT_VALUE: + if ((chunk = this._consume(this._clientCutTextLength))) { + // value = chunk + this._changeState(VncConnection.STATE_NEED_CLIENT_MESSAGE) + } + break + default: + throw new Error(util.format('Impossible state %d', this._state)) + } + } while (chunk) + } + } + + private _parseVersion(chunk: Buffer): number | null { + if (chunk.equals(Buffer.from('RFB 003.008\n'))) { + return VncConnection.V3_008 + } + if (chunk.equals(Buffer.from('RFB 003.007\n'))) { + return VncConnection.V3_007 + } + if (chunk.equals(Buffer.from('RFB 003.003\n'))) { + return VncConnection.V3_003 + } + return null + } + + private _parseSecurity(chunk: Buffer): number | null { + switch (chunk[0]) { + case VncConnection.SECURITY_NONE: + case VncConnection.SECURITY_VNC: + return chunk[0] + default: + return null + } + } + + private _changeState(state: number): void { + this._state = state + } + + private _append(chunk: Buffer | null): boolean { + if (!chunk) { + return false + } + // debug('in %s', chunk) + if (this._buffer) { + this._buffer = Buffer.concat([this._buffer, chunk], this._buffer.length + chunk.length) + } else { + this._buffer = chunk + } + return true + } + + private _consume(n: number): Buffer | null { + let chunk: Buffer + if (!this._buffer) { + return null + } + if (n < this._buffer.length) { + chunk = this._buffer.slice(0, n) + this._buffer = this._buffer.slice(n) + return chunk + } + if (n === this._buffer.length) { + chunk = this._buffer + this._buffer = null + return chunk + } + return null + } + + private _write(chunk: Buffer): void { + try { + if (!this.conn.destroyed && this.conn.writable) { + this.conn.write(chunk) + } + } catch (err: any) { + // Ignore EPIPE and ECONNRESET errors - client disconnected + if (err.code !== 'EPIPE' && err.code !== 'ECONNRESET') { + debug(`Write error: ${err?.message || err}`) + throw err + } + } + } +} + +export default VncConnection + diff --git a/lib/units/device/plugins/vnc/util/pixelformat.js b/lib/units/device/plugins/vnc/util/pixelformat.js deleted file mode 100644 index 0827192d48..0000000000 --- a/lib/units/device/plugins/vnc/util/pixelformat.js +++ /dev/null @@ -1,13 +0,0 @@ -function PixelFormat(values) { - this.bitsPerPixel = values.bitsPerPixel - this.depth = values.depth - this.bigEndianFlag = values.bigEndianFlag - this.trueColorFlag = values.trueColorFlag - this.redMax = values.redMax - this.greenMax = values.greenMax - this.blueMax = values.blueMax - this.redShift = values.redShift - this.greenShift = values.greenShift - this.blueShift = values.blueShift -} -export default PixelFormat diff --git a/lib/units/device/plugins/vnc/util/pixelformat.ts b/lib/units/device/plugins/vnc/util/pixelformat.ts new file mode 100644 index 0000000000..549f472b4e --- /dev/null +++ b/lib/units/device/plugins/vnc/util/pixelformat.ts @@ -0,0 +1,41 @@ +interface PixelFormatValues { + bitsPerPixel: number + depth: number + bigEndianFlag: number + trueColorFlag: number + redMax: number + greenMax: number + blueMax: number + redShift: number + greenShift: number + blueShift: number +} + +class PixelFormat { + bitsPerPixel: number + depth: number + bigEndianFlag: number + trueColorFlag: number + redMax: number + greenMax: number + blueMax: number + redShift: number + greenShift: number + blueShift: number + + constructor(values: PixelFormatValues) { + this.bitsPerPixel = values.bitsPerPixel + this.depth = values.depth + this.bigEndianFlag = values.bigEndianFlag + this.trueColorFlag = values.trueColorFlag + this.redMax = values.redMax + this.greenMax = values.greenMax + this.blueMax = values.blueMax + this.redShift = values.redShift + this.greenShift = values.greenShift + this.blueShift = values.blueShift + } +} + +export default PixelFormat + diff --git a/lib/units/device/plugins/vnc/util/pointertranslator.js b/lib/units/device/plugins/vnc/util/pointertranslator.js deleted file mode 100644 index 511c8df1bc..0000000000 --- a/lib/units/device/plugins/vnc/util/pointertranslator.js +++ /dev/null @@ -1,58 +0,0 @@ -import util from 'util' -import EventEmitter from 'eventemitter3' -function PointerTranslator() { - this.previousEvent = null -} -util.inherits(PointerTranslator, EventEmitter) -PointerTranslator.prototype.push = function(event) { - if (event.buttonMask & 0xFE) { - // Non-primary buttons included, ignore. - return - } - if (this.previousEvent) { - var buttonChanges = event.buttonMask ^ this.previousEvent.buttonMask - // If the primary button changed, we have an up/down event. - if (buttonChanges & 1) { - // If it's pressed now, that's a down event. - if (event.buttonMask & 1) { - this.emit('touchdown', { - contact: 1, - x: event.xPosition, - y: event.yPosition - }) - this.emit('touchcommit') - } - // It's not pressed, so we have an up event. - else { - this.emit('touchup', { - contact: 1 - }) - this.emit('touchcommit') - } - } - // Otherwise, if we're still holding the primary button down, - // that's a move event. - else if (event.buttonMask & 1) { - this.emit('touchmove', { - contact: 1, - x: event.xPosition, - y: event.yPosition - }) - this.emit('touchcommit') - } - } - else { - // If it's the first event we get and the primary button's pressed, - // it's a down event. - if (event.buttonMask & 1) { - this.emit('touchdown', { - contact: 1, - x: event.xPosition, - y: event.yPosition - }) - this.emit('touchcommit') - } - } - this.previousEvent = event -} -export default PointerTranslator diff --git a/lib/units/device/plugins/vnc/util/pointertranslator.ts b/lib/units/device/plugins/vnc/util/pointertranslator.ts new file mode 100644 index 0000000000..12a15c4fb6 --- /dev/null +++ b/lib/units/device/plugins/vnc/util/pointertranslator.ts @@ -0,0 +1,79 @@ +import EventEmitter from 'events' + +interface PointerEvent { + buttonMask: number + xPosition: number + yPosition: number +} + +interface TouchEvent { + seq: number + contact: number + x?: number + y?: number + pressure?: number +} + +class PointerTranslator extends EventEmitter { + private gestureStarted = false + private seq = 0 + + push(event: PointerEvent): void { + if (!this.gestureStarted) { + if (!event.buttonMask) { + return + } + + this.emit('touchstart', { + seq: this.seq++ + }) + + this.emit('touchdown', { + seq: this.seq++, + contact: 0, + x: event.xPosition, + y: event.yPosition, + pressure: 0.5 + } as TouchEvent) + + this.emit('touchcommit', { + seq: this.seq++ + }) + + this.gestureStarted = true + return + } + + if (event.buttonMask) { + this.emit('touchmove', { + seq: this.seq++, + contact: 0, + x: event.xPosition, + y: event.yPosition, + pressure: 0.5 + } as TouchEvent) + + this.emit('touchcommit', { + seq: this.seq++ + }) + } else { + this.emit('touchup', { + seq: this.seq++, + contact: 0 + } as TouchEvent) + + this.emit('touchcommit', { + seq: this.seq++ + }) + + this.emit('touchstop', { + seq: this.seq++ + }) + + this.gestureStarted = false + } + } +} + +export default PointerTranslator + diff --git a/lib/units/device/plugins/vnc/util/server.js b/lib/units/device/plugins/vnc/util/server.js deleted file mode 100644 index dc2cb7df00..0000000000 --- a/lib/units/device/plugins/vnc/util/server.js +++ /dev/null @@ -1,40 +0,0 @@ -import util from 'util' -import EventEmitter from 'eventemitter3' -import * as debug$0 from 'debug' -import VncConnection from './connection.js' -var debug = debug$0('vnc:server') -function VncServer(server, options) { - this.options = options - this._bound = { - _listeningListener: this._listeningListener.bind(this), - _connectionListener: this._connectionListener.bind(this), - _closeListener: this._closeListener.bind(this), - _errorListener: this._errorListener.bind(this) - } - this.server = server - .on('listening', this._bound._listeningListener) - .on('connection', this._bound._connectionListener) - .on('close', this._bound._closeListener) - .on('error', this._bound._errorListener) -} -util.inherits(VncServer, EventEmitter) -VncServer.prototype.close = function() { - this.server.close() -} -VncServer.prototype.listen = function() { - this.server.listen.apply(this.server, arguments) -} -VncServer.prototype._listeningListener = function() { - this.emit('listening') -} -VncServer.prototype._connectionListener = function(conn) { - debug('connection', conn.remoteAddress, conn.remotePort) - this.emit('connection', new VncConnection(conn, this.options)) -} -VncServer.prototype._closeListener = function() { - this.emit('close') -} -VncServer.prototype._errorListener = function(err) { - this.emit('error', err) -} -export default VncServer diff --git a/lib/units/device/plugins/vnc/util/server.ts b/lib/units/device/plugins/vnc/util/server.ts new file mode 100644 index 0000000000..5ee4124107 --- /dev/null +++ b/lib/units/device/plugins/vnc/util/server.ts @@ -0,0 +1,66 @@ +import EventEmitter from 'eventemitter3' +import net, { Server, Socket } from 'net' +import VncConnection from './connection.js' +import logger from '../../../../../util/logger.js' + +const _logger = logger.createLogger('vnc:server') +const debug = _logger.debug.bind(_logger) + +interface SecurityMethod { + type: number + challenge?: Buffer + auth: (data: any) => Promise +} + +interface VncServerOptions { + width: number + height: number + name: string + challenge?: Buffer + security?: SecurityMethod[] +} + +class VncServer extends EventEmitter { + options: VncServerOptions + server: Server + + constructor(options: VncServerOptions) { + super() + this.options = options + this.server = net.createServer({ + allowHalfOpen: true + }) + .on('listening', this._listeningListener) + .on('connection', this._connectionListener) + .on('close', this._closeListener) + .on('error', this._errorListener) + } + + close(): void { + this.server.close() + } + + listen(...args: any[]): void { + this.server.listen(...args) + } + + private _listeningListener = (): void => { + this.emit('listening') + } + + private _connectionListener = (conn: Socket): void => { + debug('connection %s %s', conn.remoteAddress, conn.remotePort) + this.emit('connection', new VncConnection(conn, this.options)) + } + + private _closeListener = (): void => { + this.emit('close') + } + + private _errorListener = (err: Error): void => { + this.emit('error', err) + } +} + +export default VncServer + diff --git a/lib/units/device/resources/scrcpy.js b/lib/units/device/resources/scrcpy.js index 886057490e..8e215e943e 100644 --- a/lib/units/device/resources/scrcpy.js +++ b/lib/units/device/resources/scrcpy.js @@ -38,7 +38,7 @@ export default syrup.serial() */ async start() { // Transfer server... - await this.adbClient.getDevice(options.serial).push(path.join(__dirname, 'scrcpy-server.jar'), '/data/local/tmp/scrcpy-server.jar') + await this.adbClient.getDevice(options.serial).push(path.join(import.meta.dirname, 'scrcpy-server.jar'), '/data/local/tmp/scrcpy-server.jar') .then(transfer => new Promise(((resolve, reject) => { transfer.on('progress', (stats) => { console.log('[%s] Pushed %d bytes so far', options.serial, stats.bytesTransferred) diff --git a/lib/wire/util.ts b/lib/wire/util.ts index 748c4b04d7..a772dfc219 100644 --- a/lib/wire/util.ts +++ b/lib/wire/util.ts @@ -6,7 +6,7 @@ import { createLogger } from '../util/logger.ts'; import {MessageType} from "@protobuf-ts/runtime"; import { ADBDeviceType } from '../units/provider/ADBObserver.js'; -const DEVICE_STATUS_MAP = { +export const DEVICE_STATUS_MAP = { device: 'ONLINE', // emulator: 'ONLINE', unauthorized: 'UNAUTHORIZED', diff --git a/package-lock.json b/package-lock.json index 6139152d62..f0dc00aedb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.772.0", "@aws-sdk/credential-providers": "^3.772.0", + "@cwasm/webp": "^0.1.5", "@devicefarmer/adbkit-apkreader": "3.2.4", "@devicefarmer/adbkit-monkey": "1.2.1", "@devicefarmer/minicap-prebuilt": "file:minicap-prebuilt", @@ -50,7 +51,7 @@ "express-validator": "4.3.0", "fast-printf": "^1.6.10", "file-saver": "1.3.3", - "follow-redirects": "1.15.5", + "follow-redirects": "1.15.6", "formidable": "1.2.6", "gm": "1.25.1", "http-proxy": "1.18.1", @@ -117,7 +118,6 @@ "devDependencies": { "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.33.0", - "@playwright/test": "^1.52.0", "@types/bluebird": "^3.5.42", "@types/chalk": "^0.4.31", "@types/eventemitter3": "^1.2.0", @@ -1198,6 +1198,21 @@ "node": ">=14.17" } }, + "node_modules/@canvas/image-data": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@canvas/image-data/-/image-data-1.1.0.tgz", + "integrity": "sha512-QdObRRjRbcXGmM1tmJ+MrHcaz1MftF2+W7YI+MsphnsCrmtyfS0d5qJbk0MeSbUeyM/jCb0hmnkXPsy026L7dA==", + "license": "MIT" + }, + "node_modules/@cwasm/webp": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@cwasm/webp/-/webp-0.1.5.tgz", + "integrity": "sha512-ceIZQkyxK+s7mmItNcWqqHdOBiJAxYxTnrnPNgUNjldB1M9j+Bp/3eVIVwC8rUFyN/zoFwuT0331pyY3ackaNA==", + "license": "MIT", + "dependencies": { + "@canvas/image-data": "^1.0.0" + } + }, "node_modules/@devicefarmer/adbkit-apkreader": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@devicefarmer/adbkit-apkreader/-/adbkit-apkreader-3.2.4.tgz", @@ -2966,22 +2981,6 @@ "node": ">=14" } }, - "node_modules/@playwright/test": { - "version": "1.54.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.1.tgz", - "integrity": "sha512-FS8hQ12acieG2dYSksmLOF7BNxnVf2afRJdCuM1eMSxj6QTSE6G4InGF7oApGgDb65MX7AwMVlIkpru0yZA4Xw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.54.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@postman/form-data": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@postman/form-data/-/form-data-3.1.1.tgz", @@ -8372,9 +8371,9 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", - "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==", + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", "funding": [ { "type": "individual", @@ -8545,21 +8544,6 @@ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "license": "ISC" }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/ftp-response-parser": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/ftp-response-parser/-/ftp-response-parser-1.0.1.tgz", @@ -11384,38 +11368,6 @@ "node": ">= 14.15.0" } }, - "node_modules/playwright": { - "version": "1.54.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.1.tgz", - "integrity": "sha512-peWpSwIBmSLi6aW2auvrUtf2DqY16YYcCMO8rTVx486jKmDTJg7UAhyrraP98GB8BoPURZP8+nxO7TSd4cPr5g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.54.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.54.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.1.tgz", - "integrity": "sha512-Nbjs2zjj0htNhzgiy5wu+3w09YetDx5pkrpI/kZotDlDUaYk0HVA5xrBVPdow4SAUIlhgKcJeJg4GRKW6xHusA==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/please-upgrade-node": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz", diff --git a/package.json b/package.json index f31f526540..6c98aa815f 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.772.0", "@aws-sdk/credential-providers": "^3.772.0", + "@cwasm/webp": "^0.1.5", "@devicefarmer/adbkit-apkreader": "3.2.4", "@devicefarmer/adbkit-monkey": "1.2.1", "@devicefarmer/minicap-prebuilt": "file:minicap-prebuilt", @@ -73,7 +74,7 @@ "express-validator": "4.3.0", "fast-printf": "^1.6.10", "file-saver": "1.3.3", - "follow-redirects": "1.15.5", + "follow-redirects": "1.15.6", "formidable": "1.2.6", "gm": "1.25.1", "http-proxy": "1.18.1", @@ -135,7 +136,6 @@ "devDependencies": { "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.33.0", - "@playwright/test": "^1.52.0", "@types/bluebird": "^3.5.42", "@types/chalk": "^0.4.31", "@types/eventemitter3": "^1.2.0", diff --git a/test/e2e/Dockerfile b/test/e2e/Dockerfile index 3e915f2153..b9107285c0 100644 --- a/test/e2e/Dockerfile +++ b/test/e2e/Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/playwright:v1.52.0-noble +FROM mcr.microsoft.com/playwright:v1.55.1-noble WORKDIR /tests diff --git a/test/e2e/package-lock.json b/test/e2e/package-lock.json index 34a0217df7..70fd01ac88 100644 --- a/test/e2e/package-lock.json +++ b/test/e2e/package-lock.json @@ -8,7 +8,7 @@ "name": "vk-devicehub-e2e-tests", "version": "0.0.1", "dependencies": { - "@playwright/test": "^1.52.0", + "@playwright/test": "^1.55.1", "typescript": "^5.5.3" }, "devDependencies": { @@ -230,12 +230,12 @@ } }, "node_modules/@playwright/test": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz", - "integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", + "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", "license": "Apache-2.0", "dependencies": { - "playwright": "1.52.0" + "playwright": "1.56.1" }, "bin": { "playwright": "cli.js" @@ -2324,12 +2324,12 @@ } }, "node_modules/playwright": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz", - "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", + "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.52.0" + "playwright-core": "1.56.1" }, "bin": { "playwright": "cli.js" @@ -2342,9 +2342,9 @@ } }, "node_modules/playwright-core": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz", - "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==", + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", + "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" diff --git a/test/e2e/package.json b/test/e2e/package.json index df6949660c..f81cd67ae7 100644 --- a/test/e2e/package.json +++ b/test/e2e/package.json @@ -9,7 +9,7 @@ "scripts": { }, "dependencies": { - "@playwright/test": "^1.52.0", + "@playwright/test": "^1.55.1", "typescript": "^5.5.3" }, "devDependencies": { diff --git a/ui/package-lock.json b/ui/package-lock.json index 05631b6170..ad744371f5 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -16,7 +16,7 @@ "@tanstack/react-virtual": "^3.10.8", "@vkontakte/icons": "^2.148.0", "@vkontakte/vkui": "^7.1.2", - "axios": "^1.11.0", + "axios": "^1.12.0", "classnames": "^2.5.1", "console-feed": "^3.8.0", "date-fns": "^4.1.0", @@ -55,8 +55,8 @@ "@types/react-slider": "^1.3.6", "@types/wicg-file-system-access": "^2023.10.5", "@vitejs/plugin-react-swc": "^3.5.0", - "@vitest/coverage-v8": "^3.0.9", - "@vitest/ui": "^3.0.9", + "@vitest/coverage-v8": "^3.2.4", + "@vitest/ui": "^3.2.4", "eslint": "^9.11.1", "eslint-import-resolver-typescript": "^3.6.3", "eslint-plugin-fp": "^2.3.0", @@ -83,7 +83,7 @@ "typescript": "^5.5.3", "typescript-eslint": "^8.16.0", "typescript-plugin-css-modules": "^5.1.0", - "vite": "^6.2.2", + "vite": "^6.4.1", "vite-plugin-svgr": "^4.2.0", "vite-tsconfig-paths": "^5.0.1", "vitest": "^3.0.9" @@ -2887,9 +2887,9 @@ "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -4021,9 +4021,9 @@ } }, "node_modules/@polka/url": { - "version": "1.0.0-next.28", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.28.tgz", - "integrity": "sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==", + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", "dev": true, "license": "MIT" }, @@ -5588,6 +5588,17 @@ "license": "MIT", "peer": true }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", @@ -5595,6 +5606,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/es-aggregate-error": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/es-aggregate-error/-/es-aggregate-error-1.0.6.tgz", @@ -5968,22 +5986,23 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.9.tgz", - "integrity": "sha512-15OACZcBtQ34keIEn19JYTVuMFTlFrClclwWjHo/IRPg/8ELpkgNTl0o7WLP9WO9XGH6+tip9CPYtEOrIDJvBA==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", + "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^1.0.2", - "debug": "^4.4.0", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.1.7", "magic-string": "^0.30.17", "magicast": "^0.3.5", - "std-env": "^3.8.0", + "std-env": "^3.9.0", "test-exclude": "^7.0.1", "tinyrainbow": "^2.0.0" }, @@ -5991,8 +6010,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "3.0.9", - "vitest": "3.0.9" + "@vitest/browser": "3.2.4", + "vitest": "3.2.4" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -6001,14 +6020,15 @@ } }, "node_modules/@vitest/expect": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.9.tgz", - "integrity": "sha512-5eCqRItYgIML7NNVgJj6TVCmdzE7ZVgJhruW0ziSQV4V7PvLkDL1bBkBdcTs/VuIz0IxPb5da1IDSqc1TR9eig==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.0.9", - "@vitest/utils": "3.0.9", + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, @@ -6017,13 +6037,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.9.tgz", - "integrity": "sha512-ryERPIBOnvevAkTq+L1lD+DTFBRcjueL9lOUfXsLfwP92h4e+Heb+PjiqS3/OURWPtywfafK0kj++yDFjWUmrA==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.0.9", + "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -6032,7 +6052,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0" + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "peerDependenciesMeta": { "msw": { @@ -6054,9 +6074,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.9.tgz", - "integrity": "sha512-OW9F8t2J3AwFEwENg3yMyKWweF7oRJlMyHOMIhO5F3n0+cgQAJZBjNgrF8dLwFTEXl5jUqBLXd9QyyKv8zEcmA==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", "dev": true, "license": "MIT", "dependencies": { @@ -6067,27 +6087,28 @@ } }, "node_modules/@vitest/runner": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.9.tgz", - "integrity": "sha512-NX9oUXgF9HPfJSwl8tUZCMP1oGx2+Sf+ru6d05QjzQz4OwWg0psEzwY6VexP2tTHWdOkhKHUIZH+fS6nA7jfOw==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.0.9", - "pathe": "^2.0.3" + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.9.tgz", - "integrity": "sha512-AiLUiuZ0FuA+/8i19mTYd+re5jqjEc2jZbgJ2up0VY0Ddyyxg/uUtBDpIFAy4uzKaQxOW8gMgBdAJJ2ydhu39A==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.0.9", + "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" }, @@ -6096,49 +6117,49 @@ } }, "node_modules/@vitest/spy": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.9.tgz", - "integrity": "sha512-/CcK2UDl0aQ2wtkp3YVWldrpLRNCfVcIOFGlVGKO4R5eajsH393Z1yiXLVQ7vWsj26JOEjeZI0x5sm5P4OGUNQ==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", "dev": true, "license": "MIT", "dependencies": { - "tinyspy": "^3.0.2" + "tinyspy": "^4.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/ui": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-3.0.9.tgz", - "integrity": "sha512-FpZD4aIv/qNpwkV3XbLV6xldWFHMgoNWAJEgg5GmpObmAOLAErpYjew9dDwXdYdKOS3iZRKdwI+P3JOJcYeUBg==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-3.2.4.tgz", + "integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.0.9", + "@vitest/utils": "3.2.4", "fflate": "^0.8.2", "flatted": "^3.3.3", "pathe": "^2.0.3", "sirv": "^3.0.1", - "tinyglobby": "^0.2.12", + "tinyglobby": "^0.2.14", "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "3.0.9" + "vitest": "3.2.4" } }, "node_modules/@vitest/utils": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.9.tgz", - "integrity": "sha512-ilHM5fHhZ89MCp5aAaM9uhfl1c2JdxVxl3McqsdVyVNN6JffnEen8UMCdRTzOhGXNQGo5GNL9QugHrz727Wnng==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.0.9", - "loupe": "^3.1.3", + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" }, "funding": { @@ -6618,6 +6639,35 @@ "dev": true, "license": "MIT" }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.8.tgz", + "integrity": "sha512-szgSZqUxI5T8mLKvS7WTjF9is+MVbOeLADU73IseOcrqhxr/VAvy6wfoVE39KnKzA7JRhjF5eUagNlHwvZPlKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^9.0.1" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -6719,9 +6769,9 @@ } }, "node_modules/axios": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", - "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.0.tgz", + "integrity": "sha512-zt40Pz4zcRXra9CVV31KeyofwiNvAbJ5B6YPz9pMJ+yOSLikvPT4Yi5LjfgjRa9CawVYBaD1JQzIVcIvBejKeA==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -7098,9 +7148,9 @@ } }, "node_modules/chai": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", - "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", "dev": true, "license": "MIT", "dependencies": { @@ -7111,7 +7161,7 @@ "pathval": "^2.0.0" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/chalk": { @@ -7753,9 +7803,9 @@ } }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -8392,9 +8442,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz", - "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true, "license": "MIT" }, @@ -9176,9 +9226,9 @@ } }, "node_modules/expect-type": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.0.tgz", - "integrity": "sha512-80F22aiJ3GLyVnS/B3HzgR6RelZVumzj9jkL0Rhz4h0xYbNW9PjlQz5h3J/SShErbXBc295vseR4/MIbVmUbeA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", + "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -11834,9 +11884,9 @@ } }, "node_modules/loupe": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz", - "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", "dev": true, "license": "MIT" }, @@ -13313,9 +13363,9 @@ "license": "MIT" }, "node_modules/pathval": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", - "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", "dev": true, "license": "MIT", "engines": { @@ -15371,9 +15421,9 @@ } }, "node_modules/sirv": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.1.tgz", - "integrity": "sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", "dev": true, "license": "MIT", "dependencies": { @@ -15537,9 +15587,9 @@ } }, "node_modules/std-env": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.8.1.tgz", - "integrity": "sha512-vj5lIj3Mwf9D79hBkltk5qmkFI+biIKWS2IBxEyEU3AX1tUf7AoL8nSazCOiiqQsGKIq01SClsKEzweu34uwvA==", + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "dev": true, "license": "MIT" }, @@ -15814,6 +15864,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stylelint": { "version": "16.15.0", "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.15.0.tgz", @@ -16388,9 +16458,9 @@ } }, "node_modules/tinypool": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz", - "integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", "dev": true, "license": "MIT", "engines": { @@ -16408,9 +16478,9 @@ } }, "node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", "dev": true, "license": "MIT", "engines": { @@ -16945,9 +17015,9 @@ } }, "node_modules/vite": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", - "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", "dependencies": { @@ -17020,17 +17090,17 @@ } }, "node_modules/vite-node": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.9.tgz", - "integrity": "sha512-w3Gdx7jDcuT9cNn9jExXgOyKmf5UOTb6WMHz8LGAm54eS1Elf5OuBhCxl6zJxGhEeIkgsE1WbHuoL0mj/UXqXg==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", "dev": true, "license": "MIT", "dependencies": { "cac": "^6.7.14", - "debug": "^4.4.0", - "es-module-lexer": "^1.6.0", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0" + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" @@ -17099,31 +17169,34 @@ } }, "node_modules/vitest": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.9.tgz", - "integrity": "sha512-BbcFDqNyBlfSpATmTtXOAOj71RNKDDvjBM/uPfnxxVGrG+FSH2RQIwgeEngTaTkuU/h0ScFvf+tRcKfYXzBybQ==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "3.0.9", - "@vitest/mocker": "3.0.9", - "@vitest/pretty-format": "^3.0.9", - "@vitest/runner": "3.0.9", - "@vitest/snapshot": "3.0.9", - "@vitest/spy": "3.0.9", - "@vitest/utils": "3.0.9", + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", "chai": "^5.2.0", - "debug": "^4.4.0", - "expect-type": "^1.1.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", - "std-env": "^3.8.0", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", - "tinypool": "^1.0.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0", - "vite-node": "3.0.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "bin": { @@ -17139,8 +17212,8 @@ "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.0.9", - "@vitest/ui": "3.0.9", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, diff --git a/ui/package.json b/ui/package.json index de59eb82dc..ac22e5449a 100644 --- a/ui/package.json +++ b/ui/package.json @@ -36,7 +36,7 @@ "@tanstack/react-virtual": "^3.10.8", "@vkontakte/icons": "^2.148.0", "@vkontakte/vkui": "^7.1.2", - "axios": "^1.11.0", + "axios": "^1.12.0", "classnames": "^2.5.1", "console-feed": "^3.8.0", "date-fns": "^4.1.0", @@ -75,8 +75,8 @@ "@types/react-slider": "^1.3.6", "@types/wicg-file-system-access": "^2023.10.5", "@vitejs/plugin-react-swc": "^3.5.0", - "@vitest/coverage-v8": "^3.0.9", - "@vitest/ui": "^3.0.9", + "@vitest/coverage-v8": "^3.2.4", + "@vitest/ui": "^3.2.4", "eslint": "^9.11.1", "eslint-import-resolver-typescript": "^3.6.3", "eslint-plugin-fp": "^2.3.0", @@ -103,7 +103,7 @@ "typescript": "^5.5.3", "typescript-eslint": "^8.16.0", "typescript-plugin-css-modules": "^5.1.0", - "vite": "^6.2.2", + "vite": "^6.4.1", "vite-plugin-svgr": "^4.2.0", "vite-tsconfig-paths": "^5.0.1", "vitest": "^3.0.9"