Skip to content

Commit

Permalink
feat: ✨ Support pairing and connecting to devices via QR code
Browse files Browse the repository at this point in the history
  • Loading branch information
viarotel committed Dec 13, 2024
1 parent e0687e8 commit 14306b2
Show file tree
Hide file tree
Showing 9 changed files with 354 additions and 14 deletions.
214 changes: 214 additions & 0 deletions electron/exposes/adb/helpers/adbConnectionMonitor/index.js
Original file line number Diff line number Diff line change
@@ -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()
17 changes: 17 additions & 0 deletions electron/exposes/adb/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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

Expand All @@ -281,6 +296,7 @@ export default {
getDevices,
deviceShell,
kill,
pair,
connect,
disconnect,
getDeviceIP,
Expand All @@ -294,4 +310,5 @@ export default {
pull,
watch,
readdir,
connectCode,
}
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -48,13 +49,15 @@
"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",
"pinyin-pro": "^3.26.0",
"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",
Expand Down
2 changes: 1 addition & 1 deletion src/locales/languages/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
"common.warning": "警告",
"common.info": "消息",
"common.danger": "错误",
"common.connect": "连接",
"common.connecting": "连接中",

"common.language.name": "语言",
Expand Down Expand Up @@ -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": "错误详情",
Expand Down
2 changes: 1 addition & 1 deletion src/pages/device/components/ConnectAction/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
62 changes: 62 additions & 0 deletions src/pages/device/components/WirelessGroup/QrAction/index.vue
Original file line number Diff line number Diff line change
@@ -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>
Loading

0 comments on commit 14306b2

Please sign in to comment.