perf: 🚀 支持通过操作栏安装应用并提供相应安装反馈

This commit is contained in:
viarotel 2023-10-17 17:54:29 +08:00
parent 763d6c56a2
commit 3bd2075324
13 changed files with 323 additions and 34 deletions

View File

@ -6,7 +6,7 @@ import dayjs from 'dayjs'
import { Adb } from '@devicefarmer/adbkit'
import { adbPath } from '@electron/configs/index.js'
console.log('adbPath', adbPath)
// console.log('adbPath', adbPath)
const exec = util.promisify(child_process.exec)
@ -19,12 +19,11 @@ window.addEventListener('beforeunload', () => {
})
const shell = async command => exec(`${adbPath} ${command}`)
const getDevices = async () => await client.listDevicesWithPaths()
const deviceShell = async (id, command) =>
await client.getDevice(id).shell(command)
const kill = async (...params) => await client.kill(...params)
const connect = async (...params) => await client.connect(...params)
const disconnect = async (...params) => await client.disconnect(...params)
const getDevices = async () => client.listDevicesWithPaths()
const deviceShell = async (id, command) => client.getDevice(id).shell(command)
const kill = async (...params) => client.kill(...params)
const connect = async (...params) => client.connect(...params)
const disconnect = async (...params) => client.disconnect(...params)
const getDeviceIP = async (id) => {
try {
@ -40,7 +39,7 @@ const getDeviceIP = async (id) => {
}
}
const tcpip = async (id, port = 5555) => await client.getDevice(id).tcpip(port)
const tcpip = async (id, port = 5555) => client.getDevice(id).tcpip(port)
const screencap = async (deviceId, options = {}) => {
let fileStream = null
@ -74,6 +73,8 @@ const screencap = async (deviceId, options = {}) => {
})
}
const install = async (id, path) => client.getDevice(id).install(path)
const watch = async (callback) => {
const tracker = await client.trackDevices()
tracker.on('add', async (ret) => {
@ -112,6 +113,7 @@ export default () => {
getDeviceIP,
tcpip,
screencap,
install,
watch,
}
}

View File

@ -1,11 +1,48 @@
import util from 'node:util'
import child_process from 'node:child_process'
import { spawn } from 'node:child_process'
import { adbPath, scrcpyPath } from '@electron/configs/index.js'
const exec = util.promisify(child_process.exec)
const shell = async (command, { stdout, stderr } = {}) => {
const args = command.split(' ')
const scrcpyProcess = spawn(scrcpyPath, args, {
env: { ...process.env, ADB: adbPath },
shell: true,
})
const shell = command =>
exec(`${scrcpyPath} ${command}`, { env: { ...process.env, ADB: adbPath } })
scrcpyProcess.stdout.on('data', (data) => {
const stringData = data.toString()
console.log('scrcpyProcess.stdout.data:', stringData)
if (stdout) {
stdout(stringData)
}
})
scrcpyProcess.stderr.on('data', (data) => {
const stringData = data.toString()
console.error('scrcpyProcess.stderr.data:', stringData)
if (stderr) {
stderr(stringData)
}
})
return new Promise((resolve, reject) => {
scrcpyProcess.on('close', (code) => {
if (code === 0) {
resolve()
}
else {
reject(new Error(`Command failed with code ${code}`))
}
})
scrcpyProcess.on('error', (err) => {
reject(err)
})
})
}
export default () => ({
shell,

View File

@ -14,6 +14,7 @@
"build:linux": "vite build && electron-builder --linux",
"preview": "vite preview",
"lint": "eslint . --ext .md,.vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --ignore-path .eslintignore --fix",
"svgo": "svgo -f src/icons/svg --config=src/icons/svgo.config.js",
"postinstall": "electron-builder install-app-deps"
},
"dependencies": {
@ -40,6 +41,7 @@
"vite-plugin-electron": "^0.14.0",
"vite-plugin-electron-renderer": "^0.14.5",
"vite-plugin-eslint": "^1.8.1",
"vite-svg-loader": "^4.0.0",
"vue-tsc": "^1.8.8"
}
}

67
pnpm-lock.yaml generated
View File

@ -70,6 +70,9 @@ devDependencies:
vite-plugin-eslint:
specifier: ^1.8.1
version: 1.8.1(eslint@8.51.0)(vite@4.4.11)
vite-svg-loader:
specifier: ^4.0.0
version: 4.0.0
vue-tsc:
specifier: ^1.8.8
version: 1.8.19(typescript@5.2.2)
@ -781,6 +784,11 @@ packages:
engines: {node: '>= 10'}
dev: true
/@trysound/sax@0.2.0:
resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
engines: {node: '>=10.13.0'}
dev: true
/@types/cacheable-request@6.0.3:
resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==}
dependencies:
@ -2035,6 +2043,11 @@ packages:
engines: {node: '>= 6'}
dev: true
/commander@7.2.0:
resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
engines: {node: '>= 10'}
dev: true
/commander@9.5.0:
resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==}
engines: {node: ^12.20.0 || >=14}
@ -2084,6 +2097,16 @@ packages:
which: 2.0.2
dev: true
/css-select@5.1.0:
resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==}
dependencies:
boolbase: 1.0.0
css-what: 6.1.0
domhandler: 5.0.3
domutils: 3.1.0
nth-check: 2.1.1
dev: true
/css-selector-tokenizer@0.8.0:
resolution: {integrity: sha512-Jd6Ig3/pe62/qe5SBPTN8h8LeUg/pT4lLgtavPf7updwwHpvFzxvOQBHYj2LZDMjUnBzgvIUSjRcf6oT5HzHFg==}
dependencies:
@ -2091,6 +2114,14 @@ packages:
fastparse: 1.1.2
dev: true
/css-tree@2.2.1:
resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
dependencies:
mdn-data: 2.0.28
source-map-js: 1.0.2
dev: true
/css-tree@2.3.1:
resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
@ -2099,12 +2130,24 @@ packages:
source-map-js: 1.0.2
dev: true
/css-what@6.1.0:
resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==}
engines: {node: '>= 6'}
dev: true
/cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
hasBin: true
dev: true
/csso@5.0.5:
resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
dependencies:
css-tree: 2.2.1
dev: true
/csstype@3.1.2:
resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==}
@ -3696,6 +3739,10 @@ packages:
resolution: {integrity: sha512-AW4DRS3QbBayY/jJmD8437V1Gombjf8RSOUCMFBuo5iHi58AGEgVCKQ+ezHkZZDpAQS75hcBMpLqjpJTjtUL7w==}
dev: true
/mdn-data@2.0.28:
resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==}
dev: true
/mdn-data@2.0.30:
resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==}
dev: true
@ -4644,6 +4691,19 @@ packages:
engines: {node: '>= 0.4'}
dev: true
/svgo@3.0.2:
resolution: {integrity: sha512-Z706C1U2pb1+JGP48fbazf3KxHrWOsLme6Rv7imFBn5EnuanDW1GPaA/P1/dvObE670JDePC3mnj0k0B7P0jjQ==}
engines: {node: '>=14.0.0'}
hasBin: true
dependencies:
'@trysound/sax': 0.2.0
commander: 7.2.0
css-select: 5.1.0
css-tree: 2.3.1
csso: 5.0.5
picocolors: 1.0.0
dev: true
/tailwindcss@3.3.3:
resolution: {integrity: sha512-A0KgSkef7eE4Mf+nKJ83i75TMyq8HqY3qmFIJSWy8bNt0v1lG7jUcpGpoTFxAwYcWOphcTBLPPJg+bDfhDf52w==}
engines: {node: '>=14.0.0'}
@ -4991,6 +5051,13 @@ packages:
vite: 4.4.11
dev: true
/vite-svg-loader@4.0.0:
resolution: {integrity: sha512-0MMf1yzzSYlV4MGePsLVAOqXsbF5IVxbn4EEzqRnWxTQl8BJg/cfwIzfQNmNQxZp5XXwd4kyRKF1LytuHZTnqA==}
dependencies:
'@vue/compiler-sfc': 3.3.4
svgo: 3.0.2
dev: true
/vite@4.4.11:
resolution: {integrity: sha512-ksNZJlkcU9b0lBwAGZGGaZHCMqHsc8OpgtoYhsQ4/I2v5cnpmmmqe5pM4nv/4Hn6G/2GhTdj0DhZh2e+Er1q5A==}
engines: {node: ^14.18.0 || >=16.0.0}

View File

@ -6,11 +6,16 @@
type="primary"
plain
class="!border-none !mx-0 bg-transparent !rounded-0"
:icon="item.icon"
:disabled="device.$unauthorized"
:title="item.tips"
@click="handleClick(item)"
>
<template #icon>
<svg-icon v-if="item.svgIcon" :name="item.svgIcon"></svg-icon>
<el-icon v-else-if="item.elIcon" class="">
<component :is="item.elIcon" />
</el-icon>
</template>
{{ item.label }}
</el-button>
</div>
@ -32,37 +37,43 @@ export default {
controlModel: [
{
label: '切换键',
icon: 'Switch',
elIcon: 'Switch',
command: 'input keyevent KEYCODE_APP_SWITCH',
},
{
label: '主屏幕键',
icon: 'HomeFilled',
elIcon: 'HomeFilled',
command: 'input keyevent KEYCODE_HOME',
},
{
label: '返回键',
icon: 'Back',
elIcon: 'Back',
command: 'input keyevent KEYCODE_BACK',
},
{
label: '菜单键',
icon: 'Menu',
elIcon: 'Menu',
command: 'input keyevent KEYCODE_MENU',
tips: '不要和切换键搞错啦',
},
{
label: '电源键',
icon: 'SwitchButton',
elIcon: 'SwitchButton',
command: 'input keyevent KEYCODE_POWER',
tips: '可以用来开启或关闭屏幕',
},
{
label: '截取屏幕',
icon: 'Crop',
elIcon: 'Crop',
handle: this.handleScreenCap,
tips: '',
},
{
label: '安装应用',
svgIcon: 'install',
handle: this.handleInstall,
tips: '',
},
],
}
},
@ -72,6 +83,54 @@ export default {
},
},
methods: {
async handleInstall(device) {
const files = await this.$electron.ipcRenderer.invoke(
'show-open-dialog',
{
properties: ['openFile', 'multiSelections'],
filters: [{ name: '请选择要安装的应用', extensions: ['apk'] }],
},
)
if (!files) {
return false
}
const messageEl = this.$message({
message: ` 正在为 ${device.name} 安装应用中...`,
icon: LoadingIcon,
duration: 0,
})
let failCount = 0
for (let index = 0; index < files.length; index++) {
const item = files[index]
await this.$adb.install(device.id, item).catch((e) => {
console.warn(e)
++failCount
})
}
messageEl.close()
const totalCount = files.length
const successCount = totalCount - failCount
if (successCount) {
if (totalCount > 1) {
this.$message.success(
`已成功将应用安装到 ${device.name} 中,共 ${totalCount}个,成功 ${successCount}个,失败 ${failCount}`,
)
}
else {
this.$message.success(`已成功将应用安装到 ${device.name}`)
}
return
}
this.$message.warning('安装应用失败,请检查安装包后重试')
},
handleClick(row) {
if (row.command) {
this.$adb.deviceShell(this.device.id, row.command)
@ -84,16 +143,19 @@ export default {
}
},
async handleScreenCap(device) {
const deviceName = device.name || device.id
const messageEl = this.$message({
message: ` 正在截取 ${deviceName} 的屏幕快照...`,
message: ` 正在截取 ${device.name} 的屏幕快照...`,
icon: LoadingIcon,
duration: 0,
})
const fileName = `${deviceName}-screencap-${dayjs().format('YYYY-MM-DD-HH-mm-ss')}.png`
const savePath = this.$path.resolve(this.scrcpyConfig['--record'], fileName)
const fileName = `${device.name}-screencap-${dayjs().format(
'YYYY-MM-DD-HH-mm-ss',
)}.png`
const savePath = this.$path.resolve(
this.scrcpyConfig['--record'],
fileName,
)
try {
await this.$adb.screencap(device.id, { savePath })

View File

@ -1,7 +1,12 @@
<template>
<div class="h-full flex flex-col">
<div class="flex items-center flex-none space-x-2">
<el-input v-model="formData.host" placeholder="192.168.0.1" class="!w-86 flex-none" clearable>
<el-input
v-model="formData.host"
placeholder="192.168.0.1"
class="!w-86 flex-none"
clearable
>
<template #prepend>
无线连接
</template>
@ -53,8 +58,18 @@
<template #empty>
<el-empty description="设备列表为空" />
</template>
<el-table-column prop="id" label="设备 ID" show-overflow-tooltip align="left" />
<el-table-column prop="name" label="设备名称" show-overflow-tooltip align="left">
<el-table-column
prop="id"
label="设备 ID"
show-overflow-tooltip
align="left"
/>
<el-table-column
prop="name"
label="设备名称"
show-overflow-tooltip
align="left"
>
<template #default="{ row }">
<div class="flex items-center">
<el-tooltip
@ -85,7 +100,7 @@
:icon="row.$loading ? '' : 'Monitor'"
@click="handleMirror(row)"
>
{{ row.$loading ? '正在镜像' : '开始镜像' }}
{{ row.$loading ? "正在镜像" : "开始镜像" }}
</el-button>
<el-button
@ -96,17 +111,21 @@
:icon="row.$recordLoading ? '' : 'VideoCamera'"
@click="handleRecord(row)"
>
{{ row.$recordLoading ? '正在录制' : '开始录制' }}
{{ row.$recordLoading ? "正在录制" : "开始录制" }}
</el-button>
<el-button
v-if="!row.$wireless"
type="primary"
text
:disabled="row.$unauthorized || row.$loading || row.$recordLoading"
:icon="row.$recordLoading ? '' : 'Switch'"
:disabled="
row.$unauthorized || row.$loading || row.$recordLoading
"
@click="handleWifi(row)"
>
<template #icon>
<svg-icon name="wifi"></svg-icon>
</template>
无线模式
</el-button>
@ -119,7 +138,7 @@
:icon="row.$stopLoading ? '' : 'CircleClose'"
@click="handleStop(row)"
>
{{ row.$stopLoading ? '正在断开' : '断开连接' }}
{{ row.$stopLoading ? "正在断开" : "断开连接" }}
</el-button>
</template>
</el-table-column>
@ -189,6 +208,7 @@ export default {
})
},
methods: {
onStdout() {},
toggleRowExpansion(...params) {
this.$refs.elTable.toggleRowExpansion(...params)
},
@ -213,7 +233,7 @@ export default {
console.log('handleRecord.command', command)
await this.$scrcpy.shell(command)
await this.$scrcpy.shell(command, { stdout: this.onStdout })
await this.$confirm('是否前往录制位置进行查看?', '录制成功', {
confirmButtonText: '确定',
@ -239,6 +259,7 @@ export default {
try {
await this.$scrcpy.shell(
`--serial=${row.id} --window-title=${row.name}-${row.id} ${this.stringScrcpyConfig}`,
{ stdout: this.onStdout },
)
}
catch (error) {

View File

@ -0,0 +1,75 @@
<script lang="jsx">
export default {
name: 'SvgIcon',
inheritAttrs: false,
props: {
name: {
type: String,
default: '',
},
},
data() {
return {
SvgComponent: null,
}
},
created() {
this.getSvgComponent()
},
methods: {
async getSvgComponent() {
if (!this.name) {
return
}
const module = await import(`../svg/${this.name}.svg?component`)
this.SvgComponent = module.default.render()
// console.log('this.SvgComponent', this.SvgComponent)
},
},
render() {
console.log('this', this)
if (this.SvgComponent) {
const props = this.SvgComponent.props
return {
...this.SvgComponent,
props: {
...props,
...this.$attrs,
class: ['svg-icon', props.class || '', this.$attrs.class || ''].join(
' ',
),
},
}
}
if (!this.name && this.$slots.default) {
const SlotComponent = this.$slots.default()[0]
const props = SlotComponent.props || {}
return {
...SlotComponent,
props: {
...props,
...this.$attrs,
class: ['svg-icon', props.class || '', this.$attrs.class || ''].join(
' ',
),
},
}
}
return ''
},
}
</script>
<style>
.svg-icon {
width: 1em;
height: 1em;
display: inline-block;
vertical-align: -0.1em;
fill: currentColor;
overflow: hidden;
}
</style>

7
src/icons/index.js Normal file
View File

@ -0,0 +1,7 @@
import SvgIcon from './components/SvgIcon.vue'
export default {
install(app) {
app.component('SvgIcon', SvgIcon)
},
}

View File

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1697530402297" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1513" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M56.888889 284.444444h853.333333v682.666667H56.888889V284.444444z m56.888889 56.888889v568.888889h739.555555V341.333333H113.777778zM199.111111 170.666667L125.155556 284.444444H56.888889l113.777778-170.666666h625.777777l113.777778 170.666666h-68.266666l-73.955556-113.777777h-568.888889zM125.155556 284.444444H56.888889l113.777778-170.666666h625.777777l113.777778 170.666666h-68.266666l-73.955556-113.777777h-568.888889L125.155556 284.444444z" p-id="1514"/><path d="M227.555556 603.022222l39.822222-34.133333 221.866666 193.422222 216.177778-193.422222 39.822222 34.133333-256 227.555556z" p-id="1515"/><path d="M455.111111 398.222222h56.888889v369.777778H455.111111z" p-id="1516"/></svg>

After

Width:  |  Height:  |  Size: 1020 B

1
src/icons/svg/wifi.svg Normal file
View File

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1697531019281" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5297" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M723 620.5C666.8 571.6 593.4 542 513 542s-153.8 29.6-210.1 78.6c-3.2 2.8-3.6 7.8-0.8 11.2l36 42.9c2.9 3.4 8 3.8 11.4 0.9C393.1 637.2 450.3 614 513 614s119.9 23.2 163.5 61.5c3.4 2.9 8.5 2.5 11.4-0.9l36-42.9c2.8-3.3 2.4-8.3-0.9-11.2zM840.4 480.4C751.7 406.5 637.6 362 513 362s-238.7 44.5-327.5 118.4c-3.4 2.8-3.8 7.9-1 11.3l36 42.9c2.8 3.4 7.9 3.8 11.2 1C308 472.2 406.1 434 513 434s205 38.2 281.2 101.6c3.4 2.8 8.4 2.4 11.2-1l36-42.9c2.8-3.4 2.4-8.5-1-11.3z" p-id="5298"/><path d="M957.1 341.4C835.7 241.8 680.3 182 511 182c-168.2 0-322.6 59-443.7 157.4-3.5 2.8-4 7.9-1.1 11.4l36 42.9c2.8 3.3 7.8 3.8 11.1 1.1C222 306.7 360.3 254 511 254c151.8 0 291 53.5 400 142.7 3.4 2.8 8.4 2.3 11.2-1.1l36-42.9c2.9-3.4 2.4-8.5-1.1-11.3z" p-id="5299"/><path d="M512 778m-64 0a64 64 0 1 0 128 0 64 64 0 1 0-128 0Z" p-id="5300"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

10
src/icons/svgo.config.js Normal file
View File

@ -0,0 +1,10 @@
module.exports = {
plugins: [
{
name: 'removeAttrs',
params: {
attrs: '(fill|fill-rule)',
},
},
],
}

View File

@ -4,6 +4,7 @@ import App from './App.vue'
import store from './store/index.js'
import plugins from './plugins/index.js'
import icons from './icons/index.js'
import 'virtual:uno.css'
import './styles/index.js'
@ -13,6 +14,7 @@ const app = createApp(App)
app.use(store)
app.use(plugins)
app.use(icons)
app.config.globalProperties.$electron = window.electron
app.config.globalProperties.$adb = window.adbkit

View File

@ -6,6 +6,7 @@ import useRenderer from 'vite-plugin-electron-renderer'
import useVue from '@vitejs/plugin-vue'
import useEslint from 'vite-plugin-eslint'
import useUnoCSS from 'unocss/vite'
import useSvg from 'vite-svg-loader'
const merge = config =>
mergeConfig(
@ -32,6 +33,7 @@ export default merge(
plugins: [
useEslint(),
useUnoCSS(),
useSvg(),
useVue(),
useElectron([
{