feat: Support pairing and connecting to devices via QR code

This commit is contained in:
viarotel 2024-12-13 18:26:34 +08:00
parent e0687e895a
commit 14306b2353
9 changed files with 354 additions and 14 deletions

View File

@ -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()

View File

@ -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,
}

View File

@ -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",

View File

@ -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": "错误详情",

View File

@ -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)"
>
</EleTooltipButton>

View File

@ -0,0 +1,62 @@
<template>
<el-popover
placement="top"
:width="200"
trigger="click"
popper-class=""
@hide="onHide"
>
<template #reference>
<el-button
type="primary"
:icon="loading ? '' : 'FullScreen'"
:loading="loading"
class="flex-none !border-none"
@click="handleClick"
>
{{ loading ? $t('common.connecting') : $t('device.wireless.connect.qr') }}
</el-button>
</template>
<el-image :key="dataUrl" class="!w-full" fit="contain" :src="dataUrl"></el-image>
</el-popover>
</template>
<script setup>
import { generateAdbPairingQR } from '$/utils/device/generateAdbPairingQR/index.js'
const props = defineProps({
handleRefresh: {
type: Function,
default: () => false,
},
})
const dataUrl = ref('')
const loading = ref(false)
async function handleClick() {
const data = await generateAdbPairingQR()
dataUrl.value = data.dataUrl
loading.value = true
try {
await window.adb.connectCode(data.password)
}
catch (error) {
console.warn(error.message)
}
props.handleRefresh()
loading.value = false
}
function onHide() {
loading.value = false
}
</script>
<style></style>

View File

@ -64,6 +64,8 @@
</el-button>
</el-button-group>
<QrAction v-bind="{ handleRefresh }" />
<PairDialog ref="pairDialog" @success="onPairSuccess" />
</div>
</template>
@ -71,11 +73,12 @@
<script>
import { sleep } from '$/utils'
import PairDialog from './PairDialog/index.vue'
import { useDeviceStore } from '$/store/device/index.js'
import QrAction from './QrAction/index.vue'
export default {
components: {
PairDialog,
QrAction,
},
props: {
handleRefresh: {
@ -86,8 +89,13 @@ export default {
data() {
return {
loading: false,
formData: {},
showAutocomplete: false,
formData: {
id: void 0,
host: void 0,
port: void 0,
},
}
},
computed: {
@ -111,16 +119,15 @@ export default {
},
fullHost: {
get() {
if (!this.formData.host) {
return ''
}
return [this.formData.host, this.formData.port].join(':')
return this.formData.id
},
set(value) {
this.formData.id = value
const [host, port] = value.split(':')
this.formData.host = host
this.formData.port = port
this.formData.port = port || 5555
},
},
},
@ -132,7 +139,7 @@ export default {
this.formData = {
host: lastWireless.host,
port: lastWireless.port,
port: lastWireless.port || 5555,
}
},
immediate: true,

View File

@ -69,8 +69,9 @@ export function mergeDevices(historyDevices, currentDevices) {
* 保存设备信息到存储
*/
export function saveDevicesToStore(devices) {
const cleanedDevices = devices.map(device =>
omit(device, ['status']),
)
const cleanedDevices = devices
.filter(device => !['unauthorized'].includes(device.status))
.map(device => omit(device, ['status']))
window.appStore.set('device', keyBy(cleanedDevices, 'id'))
}

View File

@ -0,0 +1,36 @@
import * as qrCode from 'qrcode'
import { primaryColor } from '$/configs/index.js'
/**
* Generates a QR code data URL for ADB wireless pairing
* @param options Configuration options
* @returns Promise containing the QR code data URL
*/
export async function generateAdbPairingQR(options = {}) {
// Generate random password (default 6 digits)
const passwordLength = options.passwordLength || 6
const minValue = 10 ** (passwordLength - 1)
const maxValue = 10 ** passwordLength - 1
const password = Math.floor(Math.random() * (maxValue - minValue) + minValue).toString()
// Format the ADB pairing text
const pairingText = `WIFI:T:ADB;S:ADBQR-connectPhoneOverWifi;P:${password};;`
// Generate QR code
const dataUrl = await qrCode.toDataURL(pairingText, {
type: 'image/webp',
rendererOpts: { quality: 1 },
color: {
dark: primaryColor,
light: '#ffffff',
},
margin: 0,
})
return {
dataUrl,
password, // Return password so it can be displayed to user
}
}
export default generateAdbPairingQR