From 14306b2353b2d70999c6b13ea8715dcf19314be5 Mon Sep 17 00:00:00 2001 From: viarotel Date: Fri, 13 Dec 2024 18:26:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E2=9C=A8=20Support=20pairing=20and=20c?= =?UTF-8?q?onnecting=20to=20devices=20via=20QR=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adb/helpers/adbConnectionMonitor/index.js | 214 ++++++++++++++++++ electron/exposes/adb/index.js | 17 ++ package.json | 3 + src/locales/languages/zh-CN.json | 2 +- .../device/components/ConnectAction/index.vue | 2 +- .../WirelessGroup/QrAction/index.vue | 62 +++++ .../device/components/WirelessGroup/index.vue | 25 +- src/store/device/helpers/index.js | 7 +- .../device/generateAdbPairingQR/index.js | 36 +++ 9 files changed, 354 insertions(+), 14 deletions(-) create mode 100644 electron/exposes/adb/helpers/adbConnectionMonitor/index.js create mode 100644 src/pages/device/components/WirelessGroup/QrAction/index.vue create mode 100644 src/utils/device/generateAdbPairingQR/index.js diff --git a/electron/exposes/adb/helpers/adbConnectionMonitor/index.js b/electron/exposes/adb/helpers/adbConnectionMonitor/index.js new file mode 100644 index 0000000..60bee22 --- /dev/null +++ b/electron/exposes/adb/helpers/adbConnectionMonitor/index.js @@ -0,0 +1,214 @@ +import { Bonjour } from 'bonjour-service' +import net from 'node:net' + +export const MDNS_CONFIG = { + PAIRING_TYPE: 'adb-tls-pairing', + CONNECT_TYPE: 'adb-tls-connect', + DEFAULT_TIMEOUT: 60 * 1000, + CONNECT_TIMEOUT: 30 * 1000, +} + +export const ERROR_CODES = { + TIMEOUT: 'TIMEOUT', + PAIRING_FAILED: 'PAIRING_FAILED', + CONNECTION_FAILED: 'CONNECTION_FAILED', + INVALID_PARAMS: 'INVALID_PARAMS', +} + +export class DeviceData { + constructor(name, address, port) { + this.name = name + this.address = address + this.port = port + } + + static fromMdnsService(service) { + const ipv4Address = service.addresses?.find(addr => net.isIP(addr) === 4) + if (!ipv4Address) + return null + + return new DeviceData( + service.name, + ipv4Address, + service.port, + ) + } +} + +export class MonitorError extends Error { + constructor(code, message) { + super(message) + this.code = code + } +} + +export class DeviceScanner { + constructor() { + this.bonjour = null + this.scanner = null + } + + async startScanning(type, callback) { + this.bonjour = new Bonjour() + + return new Promise((resolve, reject) => { + this.scanner = this.bonjour.find({ type }, (service) => { + const device = DeviceData.fromMdnsService(service) + if (device) { + callback(device) + } + }) + }) + } + + dispose() { + if (this.scanner) { + this.scanner.stop() + this.scanner = null + } + if (this.bonjour) { + this.bonjour.destroy() + this.bonjour = null + } + } +} + +export class AdbConnectionMonitor { + constructor() { + this.deviceScanner = new DeviceScanner() + this.isActive = false + this.adb = null + } + + async startQrCodeScanning(options) { + this.validateOptions(options) + + const { + adb, + password, + onStatus = () => {}, + onError = () => {}, + } = options + + this.adb = adb + this.isActive = true + + try { + const device = await this.scanForDevice(onStatus) + await this.pairWithDevice(device, password) + onStatus('Paired successfully, waiting to connect...') + + const connectDevice = await this.waitForDeviceConnect(device) + console.log('connectDevice', connectDevice) + await this.connectToDevice(connectDevice) + + return { + success: true, + device, + } + } + catch (error) { + onError(error.message) + return { + success: false, + error: error.message, + } + } + finally { + this.dispose() + } + } + + validateOptions(options) { + if (!options?.adb) { + throw new MonitorError( + ERROR_CODES.INVALID_PARAMS, + 'Adb is required', + ) + } + if (!options?.password) { + throw new MonitorError( + ERROR_CODES.INVALID_PARAMS, + 'Password is required', + ) + } + } + + async scanForDevice(onStatus) { + onStatus('Waiting for device to scan QR code...') + + return new Promise((resolve, reject) => { + const timeoutHandle = setTimeout(() => { + this.dispose() + reject(new MonitorError( + ERROR_CODES.TIMEOUT, + 'Connection attempt timed out', + )) + }, MDNS_CONFIG.DEFAULT_TIMEOUT) + + this.deviceScanner.startScanning( + MDNS_CONFIG.PAIRING_TYPE, + (device) => { + clearTimeout(timeoutHandle) + resolve(device) + }, + ) + }) + } + + async pairWithDevice(device, password) { + try { + await this.adb.pair(device.address, device.port, password) + } + catch (error) { + throw new MonitorError( + ERROR_CODES.PAIRING_FAILED, + 'Unable to pair with device', + ) + } + } + + async waitForDeviceConnect(device) { + return new Promise((resolve, reject) => { + const scanner = new DeviceScanner() + + const timeoutHandle = setTimeout(() => { + scanner.dispose() + reject(new MonitorError( + ERROR_CODES.TIMEOUT, + 'Device connect timeout', + )) + }, MDNS_CONFIG.CONNECT_TIMEOUT) + + scanner.startScanning( + MDNS_CONFIG.CONNECT_TYPE, + (connectDevice) => { + if (connectDevice.address === device.address) { + clearTimeout(timeoutHandle) + scanner.dispose() + resolve(connectDevice) + } + }, + ) + }) + } + + async connectToDevice(device) { + try { + await this.adb.connect(device.address, device.port) + } + catch (error) { + throw new MonitorError( + ERROR_CODES.CONNECTION_FAILED, + `Failed to connect to device: ${error.message}`, + ) + } + } + + dispose() { + this.deviceScanner.dispose() + this.isActive = false + } +} + +export default new AdbConnectionMonitor() diff --git a/electron/exposes/adb/index.js b/electron/exposes/adb/index.js index 6915434..20d990d 100644 --- a/electron/exposes/adb/index.js +++ b/electron/exposes/adb/index.js @@ -8,6 +8,7 @@ import { formatFileSize } from '$renderer/utils/index' import { Adb } from '@devicefarmer/adbkit' import dayjs from 'dayjs' import { uniq } from 'lodash-es' +import adbConnectionMonitor from './helpers/adbConnectionMonitor/index.js' const exec = util.promisify(_exec) @@ -266,6 +267,20 @@ async function pull(id, filePath, args = {}) { }) } +async function pair(host, port, code) { + return shell(`pair ${host}:${port} ${code}`) +} + +async function connectCode(password) { + return adbConnectionMonitor.startQrCodeScanning({ + password, + adb: { + pair, + connect, + }, + }) +} + function init() { const bin = appStore.get('common.adbPath') || adbPath @@ -281,6 +296,7 @@ export default { getDevices, deviceShell, kill, + pair, connect, disconnect, getDeviceIP, @@ -294,4 +310,5 @@ export default { pull, watch, readdir, + connectCode, } diff --git a/package.json b/package.json index b3c4fcd..ba75b38 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@viarotel-org/unocss-preset-shades": "0.8.2", "@vitejs/plugin-vue": "5.0.4", "@vueuse/core": "10.9.0", + "bonjour-service": "^1.3.0", "dayjs": "1.11.11", "electron": "33.0.2", "electron-builder": "25.1.8", @@ -48,6 +49,7 @@ "fs-extra": "11.2.0", "husky": "9.0.11", "lodash-es": "4.17.21", + "multicast-dns": "^7.2.5", "nanoid": "5.0.7", "pinia": "2.1.7", "pinia-plugin-persistedstate": "3.2.1", @@ -55,6 +57,7 @@ "postcss": "8.4.38", "postcss-nested": "6.0.1", "postcss-scss": "4.0.9", + "qrcode": "^1.5.4", "rimraf": "^6.0.1", "simple-git": "^3.27.0", "unocss": "0.62.3", diff --git a/src/locales/languages/zh-CN.json b/src/locales/languages/zh-CN.json index 0c04517..8548943 100644 --- a/src/locales/languages/zh-CN.json +++ b/src/locales/languages/zh-CN.json @@ -27,7 +27,6 @@ "common.warning": "警告", "common.info": "消息", "common.danger": "错误", - "common.connect": "连接", "common.connecting": "连接中", "common.language.name": "语言", @@ -89,6 +88,7 @@ "device.wireless.name": "无线", "device.wireless.mode": "无线模式", "device.wireless.mode.error": "没有获取到局域网连接地址,请检查网络", + "device.wireless.connect.qr": "二维码连接", "device.wireless.connect.name": "连接设备", "device.wireless.connect.error.title": "连接设备失败", "device.wireless.connect.error.detail": "错误详情", diff --git a/src/pages/device/components/ConnectAction/index.vue b/src/pages/device/components/ConnectAction/index.vue index e28fa22..4c70893 100644 --- a/src/pages/device/components/ConnectAction/index.vue +++ b/src/pages/device/components/ConnectAction/index.vue @@ -5,7 +5,7 @@ :loading="loading" :icon="loading ? '' : 'Connection'" placement="top" - :content="loading ? $t('common.connecting') : $t('common.connect')" + :content="loading ? $t('common.connecting') : $t('device.wireless.connect.name')" @click="handleClick(device)" > diff --git a/src/pages/device/components/WirelessGroup/QrAction/index.vue b/src/pages/device/components/WirelessGroup/QrAction/index.vue new file mode 100644 index 0000000..754aa2d --- /dev/null +++ b/src/pages/device/components/WirelessGroup/QrAction/index.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/src/pages/device/components/WirelessGroup/index.vue b/src/pages/device/components/WirelessGroup/index.vue index 71a9ce1..550a41b 100644 --- a/src/pages/device/components/WirelessGroup/index.vue +++ b/src/pages/device/components/WirelessGroup/index.vue @@ -64,6 +64,8 @@ + + @@ -71,11 +73,12 @@