mirror of
https://github.com/viarotel-org/escrcpy.git
synced 2025-01-19 01:24:12 +01:00
feat: ✨ Support pairing and connecting to devices via QR code
This commit is contained in:
parent
e0687e895a
commit
14306b2353
214
electron/exposes/adb/helpers/adbConnectionMonitor/index.js
Normal file
214
electron/exposes/adb/helpers/adbConnectionMonitor/index.js
Normal 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()
|
@ -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,
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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": "错误详情",
|
||||
|
@ -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>
|
||||
|
62
src/pages/device/components/WirelessGroup/QrAction/index.vue
Normal file
62
src/pages/device/components/WirelessGroup/QrAction/index.vue
Normal 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>
|
@ -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,
|
||||
|
@ -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'))
|
||||
}
|
||||
|
36
src/utils/device/generateAdbPairingQR/index.js
Normal file
36
src/utils/device/generateAdbPairingQR/index.js
Normal 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
|
Loading…
x
Reference in New Issue
Block a user