From 066d7b43ebf037820dfbd3d645c6896684ba1396 Mon Sep 17 00:00:00 2001 From: "e.khalilov" Date: Wed, 27 May 2026 14:28:10 +0300 Subject: [PATCH] fix screen size error --- lib/units/base-device/support/connector.js | 2 +- lib/units/base-device/support/urlformat.js | 24 - lib/units/base-device/support/urlformat.ts | 43 ++ lib/units/device/plugins/screen/stream.js | 2 +- .../device/plugins/screen/util/banner.js | 104 ---- .../device/plugins/screen/util/banner.ts | 141 +++++ lib/units/device/plugins/service.ts | 2 +- lib/units/device/plugins/util/display.js | 63 --- lib/units/device/plugins/util/display.ts | 168 ++++++ lib/units/device/plugins/util/identity.js | 25 - lib/units/device/plugins/util/identity.ts | 55 ++ lib/units/websocket/index.ts | 23 +- lib/util/devutil.js | 241 -------- lib/util/devutil.ts | 515 ++++++++++++++++++ .../ui/device/device-screen/device-screen.tsx | 43 +- .../device-screen-store.ts | 179 ++++-- 16 files changed, 1103 insertions(+), 527 deletions(-) delete mode 100644 lib/units/base-device/support/urlformat.js create mode 100644 lib/units/base-device/support/urlformat.ts delete mode 100644 lib/units/device/plugins/screen/util/banner.js create mode 100644 lib/units/device/plugins/screen/util/banner.ts delete mode 100644 lib/units/device/plugins/util/display.js create mode 100644 lib/units/device/plugins/util/display.ts delete mode 100644 lib/units/device/plugins/util/identity.js create mode 100644 lib/units/device/plugins/util/identity.ts delete mode 100644 lib/util/devutil.js create mode 100644 lib/util/devutil.ts diff --git a/lib/units/base-device/support/connector.js b/lib/units/base-device/support/connector.js index 4e48ca0bd6..2d13af75bf 100755 --- a/lib/units/base-device/support/connector.js +++ b/lib/units/base-device/support/connector.js @@ -82,7 +82,7 @@ export default syrup.serial() } this.url = await this.handlers.start() - if (this.deviceType === DEVICE_TYPE.ANDROID) { + if (!options.connectUrlPattern && this.deviceType === DEVICE_TYPE.ANDROID) { await db.connect() // TODO: remove db connect const device = await dbapi.loadDeviceBySerial(this.serial) if (device.adbPort && this.storageUrl) { diff --git a/lib/units/base-device/support/urlformat.js b/lib/units/base-device/support/urlformat.js deleted file mode 100644 index 35aa5ed2d9..0000000000 --- a/lib/units/base-device/support/urlformat.js +++ /dev/null @@ -1,24 +0,0 @@ -import syrup from '@devicefarmer/stf-syrup' -import _ from 'lodash' -import * as tr from 'transliteration' - -export default syrup.serial() - .define((options) => { - const createSlug = (model, name) => - (name === '' || model.toLowerCase() === name.toLowerCase()) ? - tr.slugify(model) : - tr.slugify(name + ' ' + model) - - return (template, port, model = null, name = null) => - _.template(template, { - imports: { - slugify: tr.slugify - } - })( - Object.assign({ - model, name, - slug: name || model ? createSlug(model, name) : 'slug', - publicPort: port - }, options) - ) - }) diff --git a/lib/units/base-device/support/urlformat.ts b/lib/units/base-device/support/urlformat.ts new file mode 100644 index 0000000000..2eb13c21bf --- /dev/null +++ b/lib/units/base-device/support/urlformat.ts @@ -0,0 +1,43 @@ +import syrup from '@devicefarmer/stf-syrup' +import _ from 'lodash' +import * as tr from 'transliteration' + +export type UrlFormatter = ( + template: string, + port: number, + model?: string | null, + name?: string | null, +) => string + +interface UrlFormatOptions { + [key: string]: unknown +} + +const createSlug = (model: string, name: string): string => + (name === '' || model.toLowerCase() === name.toLowerCase()) ? + tr.slugify(model) : + tr.slugify(name + ' ' + model) + +export default syrup.serial() + .define((options: UrlFormatOptions): UrlFormatter => + (template, port, model = null, name = null) => { + const safeModel = model ?? '' + const safeName = name ?? '' + const slug = (safeName || safeModel) ? createSlug(safeModel, safeName) : 'slug' + + return _.template(template, { + imports: { + slugify: tr.slugify, + }, + })( + Object.assign( + { + model: safeModel, + name: safeName, + slug, + publicPort: port, + }, + options, + ), + ) + }) diff --git a/lib/units/device/plugins/screen/stream.js b/lib/units/device/plugins/screen/stream.js index 5305c28868..b211c74651 100644 --- a/lib/units/device/plugins/screen/stream.js +++ b/lib/units/device/plugins/screen/stream.js @@ -373,7 +373,7 @@ export default syrup.serial() } FrameProducer.prototype._readBanner = function(socket) { log.info('Reading minicap banner') - return bannerutil.read(socket).timeout(2000) + return bannerutil.read(socket, AbortSignal.timeout(2000)) } FrameProducer.prototype._readFrames = function(socket) { this.needsReadable = true 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..e97cbd401b --- /dev/null +++ b/lib/units/device/plugins/screen/util/banner.ts @@ -0,0 +1,141 @@ +import type {Readable} from 'node:stream' + +/** + * See https://github.com/openstf/minicap + */ +export interface Banner { + version: number + length: number + pid: number + realWidth: number + realHeight: number + virtualWidth: number + virtualHeight: number + orientation: number + quirks: { + dumb: boolean + alwaysUpright: boolean + tear: boolean + } +} + +type BannerStream = Readable & {banner?: Banner; read: (n: number) => Buffer | null} + +export const read = function parseBanner(out: BannerStream, signal?: AbortSignal): Promise { + return new Promise(function(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 cleanup = (): void => { + out.removeListener('readable', tryRead) + if (signal) signal.removeEventListener('abort', onAbort) + } + + const onAbort = (): void => { + cleanup() + reject(signal?.reason ?? new Error('Banner read aborted')) + } + + const tryRead = function(): void { + let chunk: Buffer | null + while ((chunk = out.read(needBannerBytes - readBannerBytes))) { + for (let 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) { + cleanup() + resolve(banner) + return + } + } else { + cleanup() + reject(new Error('Supposedly impossible error parsing banner')) + return + } + } + } + } + + if (signal) { + if (signal.aborted) { + reject(signal.reason ?? new Error('Banner read aborted')) + return + } + signal.addEventListener('abort', onAbort, {once: true}) + } + + tryRead() + out.on('readable', tryRead) + }) +} diff --git a/lib/units/device/plugins/service.ts b/lib/units/device/plugins/service.ts index 4187b9224f..88ad78ebb6 100644 --- a/lib/units/device/plugins/service.ts +++ b/lib/units/device/plugins/service.ts @@ -236,7 +236,7 @@ export default syrup.serial() copy = () => this.getClipboard() - getDisplay = (id: string) => + getDisplay = (id: number) => runServiceCommand(apk.wire.MessageType.GET_DISPLAY, new apk.wire.GetDisplayRequest(id)) .then((data) => { const response = apk.wire.GetDisplayResponse.decode(data) diff --git a/lib/units/device/plugins/util/display.js b/lib/units/device/plugins/util/display.js deleted file mode 100644 index 33a86c21d7..0000000000 --- a/lib/units/device/plugins/util/display.js +++ /dev/null @@ -1,63 +0,0 @@ -import util from 'util' -import syrup from '@devicefarmer/stf-syrup' -import EventEmitter from 'eventemitter3' -import logger from '../../../../util/logger.js' -import * as streamutil from '../../../../util/streamutil.js' -import adb from '../../support/adb.js' -import minicap from '../../resources/minicap.js' -import service from '../service.js' -import options from '../screen/options.js' -export default syrup.serial() - .dependency(adb) - .dependency(minicap) - .dependency(service) - .dependency(options) - .define(function(options, adb, minicap, service, screenOptions) { - var log = logger.createLogger('device:plugins:display') - function Display(id, properties) { - this.id = id - this.properties = properties - } - util.inherits(Display, EventEmitter) - Display.prototype.updateRotation = function(newRotation) { - log.info('Rotation changed to %d', newRotation) - this.properties.rotation = newRotation - this.emit('rotationChange', newRotation) - } - function infoFromMinicap(id) { - return minicap.run(util.format('-d %d -i', id)) - .then(streamutil.readAll) - .then(function(out) { - var match - if ((match = /^ERROR: (.*)$/.exec(out))) { - throw new Error(match[1]) - } - try { - return JSON.parse(out) - } - catch (e) { - throw new Error(out.toString()) - } - }) - } - function infoFromService(id) { - return service.getDisplay(id) - } - function readInfo(id) { - log.info('Reading display info') - return infoFromService(id) - .catch(function() { - return infoFromMinicap(id) - }) - .then(function(properties) { - properties.url = screenOptions.publicUrl - return new Display(id, properties) - }) - } - return readInfo(0).then(function(display) { - service.on('rotationChange', function(data) { - display.updateRotation(data.rotation) - }) - return display - }) - }) diff --git a/lib/units/device/plugins/util/display.ts b/lib/units/device/plugins/util/display.ts new file mode 100644 index 0000000000..76bf479558 --- /dev/null +++ b/lib/units/device/plugins/util/display.ts @@ -0,0 +1,168 @@ +import util from 'util' +import {EventEmitter} from 'node:events' +import {type Duplex} from 'node:stream' +import syrup from '@devicefarmer/stf-syrup' +import {type Client as AdbClient} from '@u4/adbkit' + +import logger from '../../../../util/logger.js' +import * as streamutil from '../../../../util/streamutil.js' +import devutil, {type DisplayInfo, type DevUtil} from '../../../../util/devutil.js' +import adb from '../../support/adb.js' +import minicap from '../../resources/minicap.js' +import service from '../service.js' +import options from '../screen/options.js' + +const RETRY_ATTEMPTS = 5 +const RETRY_DELAY_MS = 500 + +export interface DisplayProperties extends DisplayInfo { + url?: string +} + +interface ScreenOptions { + devicePort: number + publicPort: number + publicUrl: string +} + +/** + * Surface of the `resources/minicap` syrup unit consumed here. + * NOTE: `run(mode, cmd)` is the documented two-arg form. The `mode` argument + * is ignored when SDK >= 23 (minicap is invoked via app_process). The single + * argument form below is preserved verbatim from the original `display.js`; + * it relies on minicap.run() tolerating an undefined `cmd` on legacy SDKs. + */ +interface MinicapResource { + bin: string + lib: string + apk: string + run(mode: string, cmd?: string): Promise +} + +interface ServicePluginSurface extends EventEmitter { + getDisplay: (id: number) => Promise +} + +export class Display extends EventEmitter { + public id: number + public properties: DisplayProperties + + constructor(id: number, properties: DisplayProperties) { + super() + this.id = id + this.properties = properties + } + + updateRotation(newRotation: number): void { + this.properties.rotation = newRotation + this.emit('rotationChange', newRotation) + } +} + +export default syrup.serial() + .dependency(adb) + .dependency(minicap) + .dependency(service) + .dependency(options) + .dependency(devutil) + .define(async function( + _options: {serial: string}, + _adb: AdbClient, + minicap: MinicapResource, + service: ServicePluginSurface, + screenOptions: ScreenOptions, + devutil: DevUtil + ): Promise { + const log = logger.createLogger('device:plugins:display') + + function infoFromMinicap(id: number): Promise { + return minicap.run(util.format('-d %d -i', id)) + .then((stream) => streamutil.readAll(stream)) + .then(function(out: Buffer): DisplayProperties { + const text = out.toString() + const errMatch = /^ERROR: (.*)$/.exec(text) + if (errMatch) { + throw new Error(errMatch[1]) + } + try { + return JSON.parse(text) as DisplayProperties + } + catch { + throw new Error(text) + } + }) + } + + function infoFromService(id: number): Promise { + return service.getDisplay(id) + } + + async function readInfoOnce(id: number): Promise { + try { + const info = await devutil.getDisplayInfo(id) + if (info.width > 0 && info.height > 0) { + return info + } + log.warn(util.format('devutil.getDisplayInfo returned zero dimensions for id=%s, falling back', id)) + } + catch (err: any) { + log.warn(util.format('devutil.getDisplayInfo failed for id=%s: %s', id, err?.message)) + } + + try { + return await infoFromService(id) + } + catch { + return await infoFromMinicap(id) + } + } + + async function readInfo(id: number): Promise { + log.info('Reading display info') + + let lastErr: unknown = null + for (let attempt = 1; attempt <= RETRY_ATTEMPTS; attempt++) { + try { + const properties = await readInfoOnce(id) + if (properties.width > 0 && properties.height > 0) { + properties.url = screenOptions.publicUrl + return new Display(id, properties) + } + log.warn(util.format( + 'Display info attempt %d/%d returned zero dimensions, retrying', + attempt, + RETRY_ATTEMPTS + )) + } catch (err: any) { + lastErr = err + log.warn(util.format( + 'Display info attempt %d/%d failed: %s', + attempt, + RETRY_ATTEMPTS, + err?.message + )) + } + if (attempt < RETRY_ATTEMPTS) { + await new Promise((r) => setTimeout(r, RETRY_DELAY_MS)) + } + } + + // Last resort: return whatever the legacy paths give us (even if zero) so + // downstream code keeps its existing behaviour rather than crashing the unit. + const lastErrMessage = lastErr instanceof Error ? lastErr.message : 'none' + log.fatal(util.format( + 'Unable to obtain positive display dimensions after %d attempts. lastErr=%s', + RETRY_ATTEMPTS, + lastErrMessage + )) + const properties = await infoFromService(id).catch(() => infoFromMinicap(id)) + properties.url = screenOptions.publicUrl + return new Display(id, properties) + } + + const display = await readInfo(0) + service.on('rotationChange', (data: {rotation: number}) => { + display.updateRotation(data.rotation) + }) + return display + }) diff --git a/lib/units/device/plugins/util/identity.js b/lib/units/device/plugins/util/identity.js deleted file mode 100644 index 562777e4fc..0000000000 --- a/lib/units/device/plugins/util/identity.js +++ /dev/null @@ -1,25 +0,0 @@ -import syrup from '@devicefarmer/stf-syrup' -import devutil from '../../../../util/devutil.js' -import logger from '../../../../util/logger.js' -import display from './display.js' -import phone from './phone.js' -export default syrup.serial() - .dependency(display) - .dependency(phone) - .dependency(devutil) - .define(function(options, display, phone, devutil) { - var log = logger.createLogger('device:plugins:identity') - - async function solve() { - log.info('Solving identity') - let identity = await devutil.makeIdentity() - identity.display = display.properties - identity.phone = phone - if (options.deviceName) { - identity.module = options.deviceName - } - return identity - } - - return solve() - }) diff --git a/lib/units/device/plugins/util/identity.ts b/lib/units/device/plugins/util/identity.ts new file mode 100644 index 0000000000..945587fe16 --- /dev/null +++ b/lib/units/device/plugins/util/identity.ts @@ -0,0 +1,55 @@ +import syrup from '@devicefarmer/stf-syrup' + +import devutil, {type DevUtil, type Identity} from '../../../../util/devutil.js' +import logger from '../../../../util/logger.js' +import display, {type Display, type DisplayProperties} from './display.js' +import phone from './phone.js' + +export interface PhoneIdentity { + imei?: string + imsi?: string + phoneNumber?: string + iccid?: string + network?: string + [key: string]: string | undefined +} + +export interface FullIdentity extends Identity { + display: DisplayProperties + phone: PhoneIdentity + module?: string +} + +interface IdentityOptions { + deviceName?: string + [key: string]: unknown +} + +export default syrup.serial() + .dependency(display) + .dependency(phone) + .dependency(devutil) + .define(function( + options: IdentityOptions, + display: Display, + phone: PhoneIdentity, + devutil: DevUtil + ): Promise { + const log = logger.createLogger('device:plugins:identity') + + async function solve(): Promise { + log.info('Solving identity') + const baseIdentity = await devutil.makeIdentity() + const identity: FullIdentity = { + ...baseIdentity, + display: display.properties, + phone, + } + if (options.deviceName) { + identity.module = options.deviceName + } + return identity + } + + return solve() + }) diff --git a/lib/units/websocket/index.ts b/lib/units/websocket/index.ts index 55c072050f..5cfe2702b5 100644 --- a/lib/units/websocket/index.ts +++ b/lib/units/websocket/index.ts @@ -117,7 +117,8 @@ import { StoreOpenMessage, ScreenCaptureMessage, FileSystemGetMessage, - FileSystemListMessage + FileSystemListMessage, + SizeIosDevice } from '../../wire/wire.js' import AllModel from '../../db/models/all/index.js' import UserModel from '../../db/models/user/index.js' @@ -429,6 +430,26 @@ export default (async (options: Options) => { data: message }) }) + .on(SizeIosDevice, (_channel: string, message: { + id: string + width: number + height: number + scale: number + url: string + }) => { + io.emit('device.change', { + important: true, + data: { + serial: message.id, + display: { + width: message.width, + height: message.height, + scale: message.scale, + url: message.url + } + } + }) + }) .on(TransactionProgressMessage, (channel: string, message: any) => { socket.emit('tx.progress', channel.toString(), message) }) diff --git a/lib/util/devutil.js b/lib/util/devutil.js deleted file mode 100644 index 48ba01adf1..0000000000 --- a/lib/util/devutil.js +++ /dev/null @@ -1,241 +0,0 @@ -import util from 'util' -import split from 'split' -import syrup from '@devicefarmer/stf-syrup' -import adb from '../units/device/support/adb.js' -import properties from '../units/device/support/properties.js' -import logger from './logger.js' -export default syrup.serial() - .dependency(properties) - .dependency(adb) - .define(function(options, properties, adb) { - const log = logger.createLogger('util:devutil') - const devutil = Object.create(null) - - devutil.executeShellCommand = function(command) { - return adb.getDevice(options.serial).execOut(command).then(result => { - log.debug(`executing shell command ${command}, %s`, result) - }) - } - devutil.ensureUnusedLocalSocket = function(sock) { - return adb.getDevice().openLocal(sock) - .then(function(conn) { - conn.end() - throw new Error(util.format('Local socket "%s" should be unused', sock)) - }) - .catch((err) => { - if (err.message.indexOf('closed') !== -1) { - return sock - } - }) - } - devutil.waitForLocalSocket = (sock, timeout = 60 * 1000) => new Promise(async(resolve, reject) => { - const signal = AbortSignal.timeout(timeout) - let attempt = 0 - - signal.onabort = reject - - while (!signal.aborted) { - try { - const conn = await adb.getDevice(options.serial).openLocal(sock) - conn.sock = sock - resolve(conn) - return - } - catch (err) { - log.error(`[attempt: ${++attempt}] Error in waitForLocalSocket: ${err?.message}`) - // eslint-disable-next-line no-loop-func - await new Promise(r => setTimeout(r, 500 + attempt * 300)) - } - } - }) - devutil.listPidsByComm = function(comm, bin) { - let serial = options.serial - var users = { - shell: true - } - var findProcess = function(out) { - return new Promise(function(resolve) { - var header = true - var pids = [] - var showTotalPid = false - out.pipe(split()) - .on('data', function(chunk) { - if (header) { - header = false - } - else { - var cols = chunk.toString().split(/\s+/) - if (!showTotalPid && cols[0] === 'root') { - showTotalPid = true - } - // last column of output would be command name containing absolute path like '/data/local/tmp/minicap' - // or just binary name like 'minicap', it depends on device/ROM - var lastCol = cols.pop() - if ((lastCol === comm || lastCol === bin) && users[cols[0]]) { - pids.push(Number(cols[1])) - } - } - }) - .on('end', function() { - resolve({showTotalPid: showTotalPid, pids: pids}) - }) - }) - } - return adb.getDevice(serial).shell('ps 2>/dev/null') - .then(findProcess) - .then(function(res) { - // return pids if process can be found in the output of 'ps' command - // or 'ps' command has already displayed all the processes including processes launched by root user - if (res.showTotalPid || res.pids.length > 0) { - return Promise.resolve(res.pids) - } - // otherwise try to run 'ps -elf' - else { - return adb.getDevice(serial).shell('ps -lef 2>/dev/null') - .then(findProcess) - .then(function(res) { - return Promise.resolve(res.pids) - }) - } - }) - } - devutil.waitForProcsToDie = function(comm, bin) { - return devutil.listPidsByComm(comm, bin) - .then(async(pids) => { - if (pids.length) { - await new Promise(r => setTimeout(r, 100)) - return devutil.waitForProcsToDie(comm, bin) - } - }) - } - devutil.killProcsByComm = function(comm, bin, mode) { - return devutil.listPidsByComm(comm, bin, mode) - .then(function(pids) { - if (!pids.length) { - return Promise.resolve() - } - return adb.getDevice(options.serial).shell(['kill', mode || -15].concat(pids)) - .then(function(out) { - return new Promise(function(resolve) { - out.on('end', resolve) - }) - }) - .then(function() { - return devutil.waitForProcsToDie(comm, bin) - }) - .catch(function() { - return devutil.killProcsByComm(comm, bin, -9) - }) - }) - } - devutil.makeIdentity = async function() { - let serial = options.serial - let model = properties['ro.product.model'] - let brand = properties['ro.product.brand'] - let manufacturer = properties['ro.product.manufacturer'] - let operator = properties['gsm.sim.operator.alpha'] || - properties['gsm.operator.alpha'] - let version = properties['ro.build.version.release'] - let sdk = properties['ro.build.version.sdk'] - let abi = properties['ro.product.cpu.abi'] - let product = properties['ro.product.name'] - let cpuPlatform = properties['ro.board.platform'] - let openGLESVersion = properties['ro.opengles.version'] - let marketName = await devutil.getDeviceMarketName() - if (!marketName) { - console.warn('Can\'t get marketing name for device, will be used: \'default\'.') - marketName = 'default' - } - let customMarketName = properties['debug.stf.product.device'] - let macAddress = properties.mac_address - let ram = properties.ram - openGLESVersion = parseInt(openGLESVersion, 10) - if (isNaN(openGLESVersion)) { - openGLESVersion = '0.0' - } - else { - var openGLESVersionMajor = (openGLESVersion & 0xffff0000) >> 16 - var openGLESVersionMinor = (openGLESVersion & 0xffff) - openGLESVersion = openGLESVersionMajor + '.' + openGLESVersionMinor - } - // Remove brand prefix for consistency. Note that some devices (e.g. TPS650) - // do not expose the brand property. - if (brand && model.substr(0, brand.length) === brand) { - model = model.substr(brand.length) - } - // Remove manufacturer prefix for consistency - if (model.substr(0, manufacturer.length) === manufacturer) { - model = model.substr(manufacturer.length) - } - // update device name for human readable values based on env variables - var deviceUdid = process.env.DEVICE_UDID - var deviceName = process.env.STF_PROVIDER_DEVICE_NAME - console.log('Attacted device udid: ' + deviceUdid + '; name: ' + deviceName) - if (serial === deviceUdid) { - model = deviceName - } - if (customMarketName) { - marketName = customMarketName - } - // Clean up remaining model name - // model = model.replace(/[_ ]/g, '') - return { - serial: serial, - platform: 'Android', - manufacturer: manufacturer.toUpperCase(), - operator: operator || null, - model: model, - version: version, - abi: abi, - sdk: sdk, - product: product, - cpuPlatform: cpuPlatform, - openGLESVersion: openGLESVersion, - marketName: marketName, - macAddress: macAddress, - ram: ram - } - } - - devutil.getDeviceMarketName = function() { - let serial = options.serial - let manufacturer = properties['ro.product.manufacturer'] - return adb.getDevice(serial).execOut('settings get global device_name', 'utf-8').then(function(deviceName) { - if (!deviceName || deviceName === 'null\n') { - return adb.getDevice(serial).execOut('settings get secure bluetooth_name', 'utf-8').then(function(bluetoothName) { - if (!bluetoothName || bluetoothName === 'null\n') { - switch (manufacturer.toUpperCase()) { - case 'ARCHOS': - case 'GOOGLE': - return properties['ro.product.model'] - case 'HMD GLOBAL': - return properties['ro.product.nickname'] - case 'OPPO': - return adb.getDevice(serial).execOut('settings get secure oppo_device_name', 'utf-8').then(function(oppoDeviceName) { - if (!oppoDeviceName || oppoDeviceName === 'null\n') { - return properties['ro.oppo.market.name'] - } - return oppoDeviceName - }) - case 'HUAWEI': - return properties['ro.config.marketing_name'] - case 'XIAOMI': - return properties['ro.config.marketing_name'] - case 'ITEL MOBILE LIMITED': - return properties['transsion.device.name'] - default: - return properties['ro.product.device'] - } - } - return bluetoothName - }) - } - return deviceName - }).catch(function(err) { - log.error('Can\'t get marketing name for device, will be used: \'default\'.\n' + - 'Unexpected error: %s', err) - return 'default' - }) - } - return devutil - }) diff --git a/lib/util/devutil.ts b/lib/util/devutil.ts new file mode 100644 index 0000000000..09f4d83173 --- /dev/null +++ b/lib/util/devutil.ts @@ -0,0 +1,515 @@ +import util from 'util' +import split from 'split' +import syrup from '@devicefarmer/stf-syrup' +import type {Client as AdbClient} from '@u4/adbkit' +import type {Duplex, Readable} from 'node:stream' + +import adb from '../units/device/support/adb.js' +import properties from '../units/device/support/properties.js' +import logger from './logger.js' +import * as streamutil from './streamutil.js' + +export interface DisplayInfo { + id: number + width: number + height: number + xdpi: number + ydpi: number + fps: number + density: number + rotation: number + secure: boolean + size: number +} + +export type TaggedSocket = Duplex & {sock: string} + +export interface Identity { + serial: string + platform: 'Android' + manufacturer: string + operator: string | null + model: string + version: string + abi: string + sdk: string + product: string + cpuPlatform: string + openGLESVersion: string + marketName: string + macAddress: string + ram: string | number +} + +interface DevUtilOptions { + serial: string + deviceCode?: string + [key: string]: unknown +} + +interface PsResult { + showTotalPid: boolean + pids: number[] +} + +export interface DevUtil { + executeShellCommand: (command: string) => Promise + ensureUnusedLocalSocket: (sock: string) => Promise + waitForLocalSocket: (sock: string, timeout?: number) => Promise + listPidsByComm: (comm: string, bin: string) => Promise + waitForProcsToDie: (comm: string, bin: string) => Promise + killProcsByComm: (comm: string, bin: string, mode?: number) => Promise + makeIdentity: () => Promise + getDeviceMarketName: () => Promise + getDisplayInfo: (id?: number) => Promise +} + +const Manufacturers = { + HUAWEI: ['HUAWEI', 'HONOR'], + XIAOMI: ['XIAOMI', 'REDMI', 'POCO'], + SAMSUNG: ['SAMSUNG'], +} + +const isAny = (haystack: string, needles: readonly string[]): boolean => + needles.some((n) => haystack.toUpperCase().includes(n)) + +async function shellToString(adb: AdbClient, serial: string, command: string): Promise { + try { + const stream = await adb.getDevice(serial).shell(command) + const buf: Buffer = await streamutil.readAll(stream) + return buf.toString('utf8') + } + catch { + return '' + } +} + +function parseWmSize(out: string): {physical?: [number, number]; override?: [number, number]} { + const result: {physical?: [number, number]; override?: [number, number]} = {} + const phys = /Physical size:\s*(\d+)x(\d+)/i.exec(out) + const over = /Override size:\s*(\d+)x(\d+)/i.exec(out) + if (phys) result.physical = [parseInt(phys[1], 10), parseInt(phys[2], 10)] + if (over) result.override = [parseInt(over[1], 10), parseInt(over[2], 10)] + return result +} + +function parseWmDensity(out: string): {physical?: number; override?: number} { + const result: {physical?: number; override?: number} = {} + const phys = /Physical density:\s*(\d+)/i.exec(out) + const over = /Override density:\s*(\d+)/i.exec(out) + if (phys) result.physical = parseInt(phys[1], 10) + if (over) result.override = parseInt(over[1], 10) + return result +} + +function parseDumpsysDisplay(out: string): { + width?: number + height?: number + density?: number + rotation?: number + fps?: number + secure?: boolean + xdpi?: number + ydpi?: number +} { + const r: { + width?: number + height?: number + density?: number + rotation?: number + fps?: number + secure?: boolean + xdpi?: number + ydpi?: number + } = {} + + // mBaseDisplayInfo / DisplayInfo: "real 1080 x 2400", or "real 1080x2400", or + // "1080 x 2400, ... density ..." in DisplayDeviceInfo. + const real = /real\s+(\d+)\s*x\s*(\d+)/i.exec(out) + if (real) { + r.width = parseInt(real[1], 10) + r.height = parseInt(real[2], 10) + } else { + const m = /DisplayDeviceInfo\{[^}]*?(\d+)\s*x\s*(\d+)/i.exec(out) + if (m) { + r.width = parseInt(m[1], 10) + r.height = parseInt(m[2], 10) + } + } + + const density = /density\s+(\d+(?:\.\d+)?)/i.exec(out) ?? /density:\s*(\d+(?:\.\d+)?)/i.exec(out) + if (density) r.density = parseFloat(density[1]) + + const rotation = /rotation\s+(\d+)/i.exec(out) + if (rotation) r.rotation = parseInt(rotation[1], 10) * 90 // dumpsys reports 0..3 + + const fps = /(\d+(?:\.\d+)?)\s*fps/i.exec(out) + if (fps) r.fps = parseFloat(fps[1]) + + if (/FLAG_SECURE/.test(out)) r.secure = true + + const xdpi = /xDpi[=:]\s*(\d+(?:\.\d+)?)/i.exec(out) + const ydpi = /yDpi[=:]\s*(\d+(?:\.\d+)?)/i.exec(out) + if (xdpi) r.xdpi = parseFloat(xdpi[1]) + if (ydpi) r.ydpi = parseFloat(ydpi[1]) + + return r +} + +function buildDisplayInfo( + id: number, + width: number, + height: number, + overrides: Partial = {} +): DisplayInfo { + const xdpi = overrides.xdpi ?? overrides.density ?? 240 + const ydpi = overrides.ydpi ?? overrides.density ?? 240 + return { + id, + width, + height, + xdpi, + ydpi, + fps: overrides.fps ?? 60, + density: overrides.density ?? 1, + rotation: overrides.rotation ?? 0, + secure: overrides.secure ?? false, + size: + xdpi > 0 && ydpi > 0 + ? Math.sqrt(Math.pow(width / xdpi, 2) + Math.pow(height / ydpi, 2)) + : 0, + } +} + +export default syrup.serial() + .dependency(properties) + .dependency(adb) + .define(function(options: DevUtilOptions, properties: Record, adb: AdbClient) { + const log = logger.createLogger('util:devutil') + const devutil = Object.create(null) as DevUtil + + devutil.executeShellCommand = function(command: string): Promise { + return adb.getDevice(options.serial).execOut(command).then((result) => { + log.debug(`executing shell command ${command}, %s`, result) + }) + } + + devutil.ensureUnusedLocalSocket = function(sock: string): Promise { + return adb.getDevice(options.serial).openLocal(sock) + .then(function(conn): string | undefined { + conn.end() + throw new Error(util.format('Local socket "%s" should be unused', sock)) + }) + .catch((err: Error): string | undefined => { + if (err.message.indexOf('closed') !== -1) { + return sock + } + return undefined + }) + } + + devutil.waitForLocalSocket = (sock: string, timeout = 60 * 1000): Promise => + new Promise(async(resolve, reject) => { + const signal = AbortSignal.timeout(timeout) + let attempt = 0 + + signal.onabort = (): void => reject(signal.reason) + + while (!signal.aborted) { + try { + const conn = await adb.getDevice(options.serial).openLocal(sock) as TaggedSocket + conn.sock = sock + resolve(conn) + return + } catch (err: any) { + log.error(`[attempt: ${++attempt}] Error in waitForLocalSocket: ${err?.message}`) + await new Promise((r) => setTimeout(r, 500 + attempt * 300)) + } + } + }) + + devutil.listPidsByComm = function(comm: string, bin: string): Promise { + const serial = options.serial + const users: Record = {shell: true} + const findProcess = function(out: Readable): Promise { + return new Promise(function(resolve) { + let header = true + const pids: number[] = [] + let showTotalPid = false + out.pipe(split()) + .on('data', function(chunk: Buffer | string) { + if (header) { + header = false + } else { + const cols = chunk.toString().split(/\s+/) + if (!showTotalPid && cols[0] === 'root') { + showTotalPid = true + } + const lastCol = cols.pop() + if ((lastCol === comm || lastCol === bin) && users[cols[0]]) { + pids.push(Number(cols[1])) + } + } + }) + .on('end', function() { + resolve({showTotalPid, pids}) + }) + }) + } + return adb.getDevice(serial).shell('ps 2>/dev/null') + .then((stream) => findProcess(stream)) + .then(function(res): number[] | Promise { + if (res.showTotalPid || res.pids.length > 0) { + return res.pids + } + return adb.getDevice(serial).shell('ps -lef 2>/dev/null') + .then((stream) => findProcess(stream)) + .then((res2) => res2.pids) + }) + } + + devutil.waitForProcsToDie = function(comm: string, bin: string): Promise { + return devutil.listPidsByComm(comm, bin) + .then(async(pids): Promise => { + if (pids.length) { + await new Promise((r) => setTimeout(r, 100)) + return devutil.waitForProcsToDie(comm, bin) + } + }) + } + + devutil.killProcsByComm = function(comm: string, bin: string, mode?: number): Promise { + return devutil.listPidsByComm(comm, bin) + .then(function(pids): Promise { + if (!pids.length) { + return Promise.resolve() + } + return adb.getDevice(options.serial).shell(['kill', String(mode ?? -15)].concat(pids.map(String))) + .then((out) => new Promise((resolve) => { + out.on('end', () => resolve()) + })) + .then(() => devutil.waitForProcsToDie(comm, bin)) + .catch(() => devutil.killProcsByComm(comm, bin, -9)) + }) + } + + devutil.makeIdentity = async function(): Promise { + const serial = options.serial + let model = properties['ro.product.model'] + const brand = properties['ro.product.brand'] + const manufacturer = properties['ro.product.manufacturer'] + const operator = properties['gsm.sim.operator.alpha'] || + properties['gsm.operator.alpha'] + const version = properties['ro.build.version.release'] + const sdk = properties['ro.build.version.sdk'] + const abi = properties['ro.product.cpu.abi'] + const product = properties['ro.product.name'] + const cpuPlatform = properties['ro.board.platform'] + let openGLESVersionRaw: string | number = properties['ro.opengles.version'] + let marketName = await devutil.getDeviceMarketName() + if (!marketName) { + console.warn('Can\'t get marketing name for device, will be used: \'default\'.') + marketName = 'default' + } + const customMarketName = properties['debug.stf.product.device'] + const macAddress = (properties as Record).mac_address + const ram = (properties as Record).ram + let openGLESVersion: string + const parsedOgl = parseInt(String(openGLESVersionRaw), 10) + if (isNaN(parsedOgl)) { + openGLESVersion = '0.0' + } else { + const openGLESVersionMajor = (parsedOgl & 0xffff0000) >> 16 + const openGLESVersionMinor = parsedOgl & 0xffff + openGLESVersion = openGLESVersionMajor + '.' + openGLESVersionMinor + } + // Remove brand prefix for consistency. Note that some devices (e.g. TPS650) + // do not expose the brand property. + if (brand && model.substr(0, brand.length) === brand) { + model = model.substr(brand.length) + } + // Remove manufacturer prefix for consistency + if (model.substr(0, manufacturer.length) === manufacturer) { + model = model.substr(manufacturer.length) + } + // update device name for human readable values based on env variables + const deviceUdid = process.env.DEVICE_UDID + const deviceName = process.env.STF_PROVIDER_DEVICE_NAME + console.log('Attacted device udid: ' + deviceUdid + '; name: ' + deviceName) + if (serial === deviceUdid && deviceName) { + model = deviceName + } + if (customMarketName) { + marketName = customMarketName + } + return { + serial, + platform: 'Android', + manufacturer: manufacturer.toUpperCase(), + operator: operator || null, + model, + version, + abi, + sdk, + product, + cpuPlatform, + openGLESVersion, + marketName, + macAddress, + ram, + } + } + + devutil.getDeviceMarketName = function(): Promise { + const serial = options.serial + const manufacturer = (properties['ro.product.manufacturer'] || '').toUpperCase() + return adb.getDevice(serial).execOut('settings get global device_name', 'utf-8').then(function(deviceName) { + if (!deviceName || deviceName === 'null\n') { + return adb.getDevice(serial).execOut('settings get secure bluetooth_name', 'utf-8').then(function(bluetoothName) { + if (!bluetoothName || bluetoothName === 'null\n') { + switch (manufacturer) { + case 'ARCHOS': + case 'GOOGLE': + return properties['ro.product.model'] + case 'HMD GLOBAL': + return properties['ro.product.nickname'] + case 'OPPO': + return adb.getDevice(serial).execOut('settings get secure oppo_device_name', 'utf-8').then(function(oppoDeviceName) { + if (!oppoDeviceName || oppoDeviceName === 'null\n') { + return properties['ro.oppo.market.name'] + } + return String(oppoDeviceName) + }) + case 'HUAWEI': + return properties['ro.config.marketing_name'] + case 'XIAOMI': + return properties['ro.config.marketing_name'] + case 'ITEL MOBILE LIMITED': + return properties['transsion.device.name'] + default: + return properties['ro.product.device'] + } + } + return String(bluetoothName) + }) + } + return String(deviceName) + }).catch(function(err: Error): string { + log.error(util.format( + 'Can\'t get marketing name for device, will be used: \'default\'.\nUnexpected error: %s', + err + )) + return 'default' + }) + } + + /** + * Vendor-resilient display detection strategy: + * 1. `wm size` -- Physical / Override resolution + * 2. `wm density` -- Physical / Override density + * 3. `dumpsys display` -- real WxH, density, rotation, fps, secure + * 4. Vendor switch -- pick Physical vs Override per OEM quirks + * 5. Result -- buildDisplayInfo() composes the final object + * + * Vendor notes: + * - HUAWEI / HONOR: EMUI's display-scaling overrides are unreliable; prefer + * `dumpsys display` real dimensions, fall back to Physical size from wm. + * - XIAOMI / REDMI / POCO: MIUI's "display size" setting silently rewrites + * the Override size. Always use Physical size to stay aligned with what + * minicap actually streams. + * - SAMSUNG: behaves like default; DEX-mode devices may expose additional + * internal displays but for id=0 the Override behavior is correct. + * - Default: prefer Override (it's what the user has actively set) and fall + * back to Physical. + * + * Returns zero dimensions only if every source failed. + */ + devutil.getDisplayInfo = async function(id: number = 0): Promise { + const serial = options.serial + const manufacturer = (properties['ro.product.manufacturer'] || '').toUpperCase() + + // Run shell commands in parallel -- they don't depend on each other. + const [wmSizeOut, wmDensityOut, dumpsysDisplayOut] = await Promise.all([ + shellToString(adb, serial, 'wm size'), + shellToString(adb, serial, 'wm density'), + shellToString(adb, serial, 'dumpsys display'), + ]) + + const wmSize = parseWmSize(wmSizeOut) + const wmDensity = parseWmDensity(wmDensityOut) + const dumpsys = parseDumpsysDisplay(dumpsysDisplayOut) + + // Pick width/height based on vendor. + let width = 0 + let height = 0 + + if (isAny(manufacturer, Manufacturers.HUAWEI)) { + // Prefer dumpsys "real WxH"; fall back to Physical size. + if (dumpsys.width && dumpsys.height) { + width = dumpsys.width + height = dumpsys.height + } else if (wmSize.physical) { + [width, height] = wmSize.physical + } + log.debug(util.format('Display probe (HUAWEI/HONOR): chose %dx%d', width, height)) + } else if (isAny(manufacturer, Manufacturers.XIAOMI)) { + // MIUI's Override size lies -- always use Physical, fall back to dumpsys. + if (wmSize.physical) { + [width, height] = wmSize.physical + } else if (dumpsys.width && dumpsys.height) { + width = dumpsys.width + height = dumpsys.height + } + log.debug(util.format('Display probe (XIAOMI/REDMI/POCO): chose %dx%d (Physical only)', width, height)) + } else if (isAny(manufacturer, Manufacturers.SAMSUNG)) { + // Samsung honors Override when present. + if (wmSize.override) { + [width, height] = wmSize.override + } else if (wmSize.physical) { + [width, height] = wmSize.physical + } else if (dumpsys.width && dumpsys.height) { + width = dumpsys.width + height = dumpsys.height + } + log.debug(util.format('Display probe (SAMSUNG): chose %dx%d', width, height)) + } else { + // Generic: prefer Override if user explicitly set one, else Physical, else dumpsys. + if (wmSize.override) { + [width, height] = wmSize.override + } else if (wmSize.physical) { + [width, height] = wmSize.physical + } else if (dumpsys.width && dumpsys.height) { + width = dumpsys.width + height = dumpsys.height + } + log.debug(util.format('Display probe (default): chose %dx%d', width, height)) + } + + // Density: prefer override (matches what apps actually see), then physical, then dumpsys, then ro.sf.lcd_density. + let density: number | undefined + if (isAny(manufacturer, Manufacturers.XIAOMI)) { + density = wmDensity.physical ?? wmDensity.override ?? dumpsys.density + } else { + density = wmDensity.override ?? wmDensity.physical ?? dumpsys.density + } + if (!density) { + const lcd = parseInt(properties['ro.sf.lcd_density'] || '', 10) + if (!isNaN(lcd)) density = lcd + } + + // Convert density (dpi) to a multiplier when buildDisplayInfo expects "scale"-ish. + // Historically `density` in DisplayInfo is the multiplier (e.g. 2.0 for xhdpi). + const densityMultiplier = density ? density / 160 : undefined + + return buildDisplayInfo(id, width, height, { + density: densityMultiplier, + xdpi: density, + ydpi: density, + rotation: dumpsys.rotation, + fps: dumpsys.fps, + secure: dumpsys.secure, + }) + } + + return devutil + }) diff --git a/ui/src/components/ui/device/device-screen/device-screen.tsx b/ui/src/components/ui/device/device-screen/device-screen.tsx index 36f518944c..e116913b05 100644 --- a/ui/src/components/ui/device/device-screen/device-screen.tsx +++ b/ui/src/components/ui/device/device-screen/device-screen.tsx @@ -1,5 +1,6 @@ import { useInjection } from 'inversify-react' -import { useEffect, useRef, useState } from 'react' +import { observer } from 'mobx-react-lite' +import { useRef } from 'react' import { Spinner } from '@vkontakte/vkui' import { ConditionalRender } from '@/components/lib/conditional-render' @@ -17,27 +18,25 @@ enum DeviceType { TIZEN, } -export const DeviceScreen = () => { +const resolveDeviceType = ( + manufacturer: string | undefined, + platform: string | undefined +): DeviceType => { + if (!manufacturer && !platform) return DeviceType.FETCHING + + if (manufacturer === 'Apple') return DeviceType.APPLE + + if (platform === 'Tizen') return DeviceType.TIZEN + + return DeviceType.ANDROID +} + +export const DeviceScreen = observer(() => { const canvasWrapperRef = useRef(null) - const deviceScreenStore = useInjection(CONTAINER_IDS.deviceScreenStore) - const [deviceType, setDeviceType] = useState(DeviceType.FETCHING) - - useEffect(() => { - deviceScreenStore.init().then(() => { - const device = deviceScreenStore.getDevice - - return ( - device && - setDeviceType( - device.manufacturer === 'Apple' - ? DeviceType.APPLE - : device.platform === 'Tizen' - ? DeviceType.TIZEN - : DeviceType.ANDROID - ) - ) - }) - }, []) + const deviceBySerialStore = useInjection(CONTAINER_IDS.deviceBySerialStore) + + const { data: device } = deviceBySerialStore.deviceQueryResult() + const deviceType = resolveDeviceType(device?.manufacturer, device?.platform) return (
@@ -54,4 +53,4 @@ export const DeviceScreen = () => {
) -} +}) diff --git a/ui/src/store/device-screen-store/device-screen-store.ts b/ui/src/store/device-screen-store/device-screen-store.ts index 822dd0f7ef..2c779c9093 100644 --- a/ui/src/store/device-screen-store/device-screen-store.ts +++ b/ui/src/store/device-screen-store/device-screen-store.ts @@ -1,5 +1,5 @@ import { t } from 'i18next' -import { makeAutoObservable, runInAction } from 'mobx' +import { autorun, makeAutoObservable, runInAction } from 'mobx' import { inject, injectable } from 'inversify' import { backOff } from 'exponential-backoff' @@ -10,7 +10,13 @@ import { deviceConnectionRequired } from '@/config/inversify/decorators' import { authStore } from '@/store/auth-store' import type { ElementBoundSize, StartScreenStreamingMessage } from './types' -import type { Device } from '@/generated/types' + +class AuthError extends Error { + constructor() { + super('Unauthorized') + this.name = 'AuthError' + } +} @injectable() @deviceConnectionRequired() @@ -21,20 +27,32 @@ export class DeviceScreenStore { private context: ImageBitmapRenderingContext | null = null private canvasWrapper: HTMLDivElement | null = null - private device: Device | null = null private showScreen = true private options = { autoScaleForRetina: true, density: Math.max(1, Math.min(1.5, devicePixelRatio || 1)), minScale: 0.36, } - private adjustedBoundSize = { + private adjustedBoundSize: ElementBoundSize = { width: 0, height: 0, } private screenRotation = 0 private isScreenStreamingJustStarted = false + /** + * Native (device-side framebuffer) dimensions. Seeded from the screen-streaming + * WebSocket's `start { realWidth, realHeight }` banner, and as a defence-in-depth + * fallback from the first decoded image frame. Independent of `device.display.*`, + * which can be transiently undefined right after device introduction. + */ + private nativeWidth = 0 + private nativeHeight = 0 + private hasNativeSize = false + + /** MobX autorun disposer, used to re-trigger connect when display.url becomes available. */ + private urlReactionDisposer: (() => void) | null = null + isAspectRatioModeLetterbox = false isScreenLoading = false isScreenRotated = false @@ -46,10 +64,6 @@ export class DeviceScreenStore { makeAutoObservable(this) } - get getDevice(): Device | null { - return this.device - } - get getCanvasWrapper(): HTMLDivElement | null { return this.canvasWrapper } @@ -62,10 +76,6 @@ export class DeviceScreenStore { this.isScreenLoading = value } - async init(): Promise { - this.device = await this.deviceBySerialStore.fetch() - } - async startScreenStreaming(canvas: HTMLCanvasElement, canvasWrapper: HTMLDivElement): Promise { runInAction(() => { this.setIsScreenLoading(true) @@ -80,13 +90,32 @@ export class DeviceScreenStore { this.context = canvas.getContext('bitmaprenderer') this.canvasWrapper = canvasWrapper - this.connectWithBackoff() + // Watch for display.url becoming available; trigger (re)connection when it appears + // while we're not already connected. This decouples the streaming WS lifecycle + // from a possibly-stale initial fetch. + this.urlReactionDisposer?.() + this.urlReactionDisposer = autorun(() => { + const url = this.deviceBySerialStore.deviceQueryResult().data?.display?.url + + if (!url) return + + if (this.disposed) return + + if (this.websocket && this.websocket.readyState === WebSocket.OPEN) return + + if (this.backoffPromise) return + + this.connectWithBackoff() + }) } stopScreenStreaming(): void { this.disposed = true this.backoffPromise = null + this.urlReactionDisposer?.() + this.urlReactionDisposer = null + if (this.websocket) { this.websocket.onclose = null this.websocket.close() @@ -150,25 +179,35 @@ export class DeviceScreenStore { } } + /** + * Compute the bound size the minicap projection should be re-encoded to. + * + * If the native (framebuffer) dimensions are not yet known (banner / first frame + * haven't arrived), we send the raw wrapper size multiplied by density. minicap + * accepts arbitrary projections, and as soon as `nativeWidth`/`nativeHeight` are + * known the next call clamps to `minScale` of the native size. + * + * This function intentionally never throws so that the WS auth_success path + * and ResizeObserver callbacks remain side-effect-safe even when `device.display` + * is transiently undefined right after device introduction. + */ private adjustBoundedSize(width: number, height: number): ElementBoundSize { - if (!this.device?.display?.width || !this.device?.display?.height) { - throw new Error('No display width or height') - } - - const scaledWidth = this.device.display.width * this.options.minScale - const scaledHeight = this.device.display.height * this.options.minScale - let sw = width * this.options.density let sh = height * this.options.density - if (sw < scaledWidth) { - sw *= scaledWidth / sw - sh *= scaledWidth / sh - } + if (this.hasNativeSize && this.nativeWidth > 0 && this.nativeHeight > 0) { + const scaledWidth = this.nativeWidth * this.options.minScale + const scaledHeight = this.nativeHeight * this.options.minScale + + if (sw < scaledWidth) { + sw *= scaledWidth / sw + sh *= scaledWidth / sh + } - if (sh < scaledHeight) { - sw *= scaledHeight / sw - sh *= scaledHeight / sh + if (sh < scaledHeight) { + sw *= scaledHeight / sw + sh *= scaledHeight / sh + } } return { @@ -195,6 +234,20 @@ export class DeviceScreenStore { return this.screenRotation === 90 || this.screenRotation === 270 } + private setNativeSize(width: number, height: number): void { + if (!width || !height) return + + if (this.isRotated()) { + this.nativeWidth = height + this.nativeHeight = width + } else { + this.nativeWidth = width + this.nativeHeight = height + } + + this.hasNativeSize = true + } + private updateImageArea(imageWidth: number, imageHeight: number): void { if (!this.context) { throw new Error('Context is not set') @@ -220,12 +273,25 @@ export class DeviceScreenStore { this.isScreenRotated = false } + if (!this.hasNativeSize) { + this.setNativeSize(imageWidth, imageHeight) + + // Re-send a size message now that we know the native dimensions. + try { + this.updateBounds() + } catch { + /* canvasWrapper not yet set, harmless */ + } + } + this.determineAspectRatioMode() } private connectWebsocket(): Promise { return new Promise((resolve, reject) => { - if (!this.device?.display?.url) { + const url = this.deviceBySerialStore.deviceQueryResult().data?.display?.url + + if (!url) { reject(new Error('No display url')) return @@ -237,23 +303,24 @@ export class DeviceScreenStore { return } - const ws = new WebSocket(this.device.display.url, `access_token.${authStore.jwt}`) + const ws = new WebSocket(url, `access_token.${authStore.jwt}`) ws.binaryType = 'blob' - ws.onopen = () => { + ws.onopen = (): void => { this.websocket = ws ws.onmessage = this.messageListener.bind(this) - ws.onerror = () => {} + ws.onerror = (): void => {} + ws.onclose = this.handleUnexpectedClose.bind(this) this.isScreenStreamingJustStarted = true resolve() } - ws.onerror = () => {} + ws.onerror = (): void => {} - ws.onclose = (event: CloseEvent) => { + ws.onclose = (event: CloseEvent): void => { if (event.code === 1008) { reject(new AuthError()) @@ -275,6 +342,7 @@ export class DeviceScreenStore { jitter: 'full', retry: (err) => { if (this.disposed) return false + if (err instanceof AuthError) return false return true @@ -348,9 +416,17 @@ export class DeviceScreenStore { console.info('WebSocket authentication successful') if (this.shouldUpdateScreen()) { - this.updateBounds() + // IMPORTANT: signal interest first so the server emits the `start` banner. + // The banner contains real device-side dimensions, after which a + // subsequent updateBounds() will apply the correct minScale floor. this.onScreenInterestGained() + try { + this.updateBounds() + } catch (err) { + console.warn('updateBounds skipped:', err) + } + return } @@ -371,19 +447,34 @@ export class DeviceScreenStore { const startRegex = /^start / - if (startRegex.test(message.data)) { - const startData: StartScreenStreamingMessage = JSON.parse(message.data.replace(startRegex, '')) + if (startRegex.test(message.data as string)) { + const payload = (message.data as string).replace(startRegex, '') + let startData: StartScreenStreamingMessage | null = null + + try { + startData = JSON.parse(payload) as StartScreenStreamingMessage | null + } catch { + startData = null + } this.isScreenStreamingJustStarted = true - this.screenRotation = startData.orientation - } - } -} + if (startData) { + this.screenRotation = startData.orientation ?? this.screenRotation -class AuthError extends Error { - constructor() { - super('Unauthorized') - this.name = 'AuthError' + const sourceWidth = startData.realWidth || startData.virtualWidth || 0 + const sourceHeight = startData.realHeight || startData.virtualHeight || 0 + + if (sourceWidth && sourceHeight) { + this.setNativeSize(sourceWidth, sourceHeight) + + try { + this.updateBounds() + } catch { + /* empty */ + } + } + } + } } }