feat: 内置并统一使用项目 ADB (#149)

This commit is contained in:
chengma
2026-08-11 12:01:07 +08:00
parent 56b1a46c5c
commit 8f2037db2f
21 changed files with 355 additions and 46 deletions
+12 -2
View File
@@ -108,6 +108,13 @@ VSVersionInfo(
$distPath = Join-Path $buildRoot "dist" $distPath = Join-Path $buildRoot "dist"
$workPath = Join-Path $buildRoot "work" $workPath = Join-Path $buildRoot "work"
$specPath = Join-Path $buildRoot "spec" $specPath = Join-Path $buildRoot "spec"
$bundledAdbSource = Join-Path $clientRoot "vendor\android-platform-tools\windows"
foreach ($adbFileName in @("adb.exe", "AdbWinApi.dll", "AdbWinUsbApi.dll")) {
$adbSourceFile = Join-Path $bundledAdbSource $adbFileName
if (-not (Test-Path -LiteralPath $adbSourceFile -PathType Leaf)) {
throw "A required bundled ADB source file is missing: $adbSourceFile"
}
}
Write-Host "[4/7] Building the main application..." Write-Host "[4/7] Building the main application..."
Invoke-Checked -FailureMessage "Failed to build the main application" -Command { Invoke-Checked -FailureMessage "Failed to build the main application" -Command {
@@ -118,6 +125,7 @@ VSVersionInfo(
--collect-all qfluentwidgets ` --collect-all qfluentwidgets `
--collect-all uiautomator2 ` --collect-all uiautomator2 `
--collect-all adbutils ` --collect-all adbutils `
--add-binary "$bundledAdbSource;vendor/android-platform-tools/windows" `
(Join-Path $clientRoot "buyer_main.py") (Join-Path $clientRoot "buyer_main.py")
} }
@@ -131,11 +139,13 @@ VSVersionInfo(
$mainDist = Join-Path $distPath "CMAutoBuy" $mainDist = Join-Path $distPath "CMAutoBuy"
$launcherExe = Join-Path $distPath "Launcher.exe" $launcherExe = Join-Path $distPath "Launcher.exe"
$adbExe = Join-Path $mainDist "dependencies\adbutils\binaries\adb.exe" $bundledAdbDist = Join-Path $mainDist "dependencies\vendor\android-platform-tools\windows"
foreach ($requiredPath in @( foreach ($requiredPath in @(
(Join-Path $mainDist "CMAutoBuy.exe"), (Join-Path $mainDist "CMAutoBuy.exe"),
(Join-Path $mainDist "dependencies\PyQt5\Qt5\plugins\platforms\qwindows.dll"), (Join-Path $mainDist "dependencies\PyQt5\Qt5\plugins\platforms\qwindows.dll"),
$adbExe, (Join-Path $bundledAdbDist "adb.exe"),
(Join-Path $bundledAdbDist "AdbWinApi.dll"),
(Join-Path $bundledAdbDist "AdbWinUsbApi.dll"),
$launcherExe $launcherExe
)) { )) {
if (-not (Test-Path -LiteralPath $requiredPath -PathType Leaf)) { if (-not (Test-Path -LiteralPath $requiredPath -PathType Leaf)) {
+55
View File
@@ -0,0 +1,55 @@
"""项目内置 ADB 的路径、完整性检查和 adbutils 环境配置。"""
import os
from pathlib import Path
from typing import Tuple
from .db import app_dir
ADB_RELATIVE_DIRECTORY = Path("vendor/android-platform-tools/windows")
REQUIRED_ADB_FILES: Tuple[str, ...] = (
"adb.exe",
"AdbWinApi.dll",
"AdbWinUsbApi.dll",
)
class BundledAdbError(RuntimeError):
"""项目内置 ADB 缺失或不完整。"""
def bundled_adb_directory() -> Path:
"""返回项目内置 ADB 目录,不创建目录。"""
return app_dir() / ADB_RELATIVE_DIRECTORY
def validate_bundled_adb() -> Path:
"""检查 ADB 和两个配套 DLL,完整时返回 adb.exe 绝对路径。"""
directory = bundled_adb_directory()
missing_files = [
name for name in REQUIRED_ADB_FILES
if not (directory / name).is_file()
]
if missing_files:
names = "、".join(missing_files)
raise BundledAdbError(
f"内置 ADB 文件缺失:{names}。请重新安装完整的软件包。"
)
return (directory / "adb.exe").resolve()
def bundled_adb_path() -> Path:
"""返回预期的项目内 adb.exe 路径;不访问系统 PATH。"""
return (bundled_adb_directory() / "adb.exe").resolve()
def configure_bundled_adb_environment() -> Path:
"""校验内置 ADB,并强制 adbutils/uiautomator2 使用同一文件。"""
adb_path = validate_bundled_adb()
os.environ["ADBUTILS_ADB_PATH"] = str(adb_path)
return adb_path
+43 -9
View File
@@ -4,8 +4,15 @@ from dataclasses import dataclass
import ipaddress import ipaddress
import subprocess import subprocess
import time import time
from pathlib import Path
from typing import Callable, List, Optional, Sequence from typing import Callable, List, Optional, Sequence
from .adb_runtime import (
BundledAdbError,
bundled_adb_path,
validate_bundled_adb,
)
DEFAULT_ADB_TIMEOUT_SECONDS = 5.0 DEFAULT_ADB_TIMEOUT_SECONDS = 5.0
DEFAULT_DEVICE_READY_CHECKS = 8 DEFAULT_DEVICE_READY_CHECKS = 8
@@ -78,19 +85,24 @@ class AndroidDeviceService:
command_runner: CommandRunner = run_adb_command, command_runner: CommandRunner = run_adb_command,
timeout_seconds: float = DEFAULT_ADB_TIMEOUT_SECONDS, timeout_seconds: float = DEFAULT_ADB_TIMEOUT_SECONDS,
sleeper: Sleeper = time.sleep, sleeper: Sleeper = time.sleep,
adb_executable: Optional[Path] = None,
): ):
if timeout_seconds <= 0: if timeout_seconds <= 0:
raise ValueError("ADB 超时时间必须大于 0") raise ValueError("ADB 超时时间必须大于 0")
self._run_command = command_runner self._run_command = command_runner
self._timeout_seconds = timeout_seconds self._timeout_seconds = timeout_seconds
self._sleep = sleeper self._sleep = sleeper
self._uses_bundled_adb = adb_executable is None
self._adb_executable = str(adb_executable or bundled_adb_path())
def search( def search(
self, is_cancelled: Optional[Callable[[], bool]] = None self, is_cancelled: Optional[Callable[[], bool]] = None
) -> List[AndroidDevice]: ) -> List[AndroidDevice]:
"""返回当前 ADB 设备;取消后停止补充设备属性。""" """返回当前 ADB 设备;取消后停止补充设备属性。"""
result = self._run(["adb", "devices", "-l"], "搜索设备") result = self._run(
[self._adb_executable, "devices", "-l"], "搜索设备"
)
devices = self.parse_devices(result.stdout or "") devices = self.parse_devices(result.stdout or "")
enriched = [] enriched = []
@@ -158,7 +170,15 @@ class AndroidDeviceService:
raise AndroidDeviceSearchError("应用包名格式不正确") raise AndroidDeviceSearchError("应用包名格式不正确")
result = self._run( result = self._run(
["adb", "-s", serial, "shell", "pm", "path", package_name], [
self._adb_executable,
"-s",
serial,
"shell",
"pm",
"path",
package_name,
],
"检测 PDD 应用", "检测 PDD 应用",
) )
return any( return any(
@@ -193,7 +213,7 @@ class AndroidDeviceService:
self._check_cancelled(is_cancelled) self._check_cancelled(is_cancelled)
self._report(on_progress, "正在读取勾选设备的 Wi-Fi 地址…") self._report(on_progress, "正在读取勾选设备的 Wi-Fi 地址…")
route = self._run( route = self._run(
["adb", "-s", usb_serial, "shell", "ip", "route"], [self._adb_executable, "-s", usb_serial, "shell", "ip", "route"],
"读取 Wi-Fi 地址", "读取 Wi-Fi 地址",
) )
ip_address = self.parse_wifi_ipv4(route.stdout or "") ip_address = self.parse_wifi_ipv4(route.stdout or "")
@@ -202,7 +222,7 @@ class AndroidDeviceService:
self._check_cancelled(is_cancelled) self._check_cancelled(is_cancelled)
self._report(on_progress, "正在开启 ADB 5555 端口…") self._report(on_progress, "正在开启 ADB 5555 端口…")
self._run( self._run(
["adb", "-s", usb_serial, "tcpip", "5555"], [self._adb_executable, "-s", usb_serial, "tcpip", "5555"],
"开启 Wi-Fi 调试", "开启 Wi-Fi 调试",
) )
@@ -312,7 +332,14 @@ class AndroidDeviceService:
try: try:
result = self._run( result = self._run(
["adb", "-s", serial, "shell", "getprop", property_name], [
self._adb_executable,
"-s",
serial,
"shell",
"getprop",
property_name,
],
"读取设备信息", "读取设备信息",
) )
except AndroidDeviceSearchError: except AndroidDeviceSearchError:
@@ -343,12 +370,14 @@ class AndroidDeviceService:
self._sleep(DEFAULT_DEVICE_READY_INTERVAL_SECONDS) self._sleep(DEFAULT_DEVICE_READY_INTERVAL_SECONDS)
def _list_devices(self) -> List[AndroidDevice]: def _list_devices(self) -> List[AndroidDevice]:
result = self._run(["adb", "devices", "-l"], "读取设备列表") result = self._run(
[self._adb_executable, "devices", "-l"], "读取设备列表"
)
return self.parse_devices(result.stdout or "") return self.parse_devices(result.stdout or "")
def _connect_wifi(self, wifi_serial: str) -> None: def _connect_wifi(self, wifi_serial: str) -> None:
result = self._run( result = self._run(
["adb", "connect", wifi_serial], [self._adb_executable, "connect", wifi_serial],
"连接 Wi-Fi 设备", "连接 Wi-Fi 设备",
) )
detail = " ".join( detail = " ".join(
@@ -401,11 +430,16 @@ class AndroidDeviceService:
callback(message) callback(message)
def _run(self, command: Sequence[str], action: str): def _run(self, command: Sequence[str], action: str):
if self._uses_bundled_adb:
try:
validate_bundled_adb()
except BundledAdbError as exc:
raise AndroidDeviceSearchError(str(exc)) from exc
try: try:
result = self._run_command(command, self._timeout_seconds) result = self._run_command(command, self._timeout_seconds)
except FileNotFoundError as exc: except FileNotFoundError as exc:
raise AndroidDeviceSearchError( raise AndroidDeviceSearchError(
"未找到 adb,请安装 Android platform-tools 并把 adb 加入 PATH 后重试" "内置 adb.exe 无法启动,请重新安装完整的软件包"
) from exc ) from exc
except subprocess.TimeoutExpired as exc: except subprocess.TimeoutExpired as exc:
raise AndroidDeviceSearchError( raise AndroidDeviceSearchError(
@@ -413,7 +447,7 @@ class AndroidDeviceService:
) from exc ) from exc
except OSError as exc: except OSError as exc:
raise AndroidDeviceSearchError( raise AndroidDeviceSearchError(
f"无法启动 adb:{str(exc) or '系统拒绝执行'}" f"无法启动内置 adb:{str(exc) or '系统拒绝执行'}"
) from exc ) from exc
if result.returncode != 0: if result.returncode != 0:
+8
View File
@@ -34,6 +34,14 @@ def data_dir() -> Path:
return directory return directory
def app_dir() -> Path:
"""返回只读资源根目录;源码和打包运行使用同一相对结构。"""
if getattr(sys, "frozen", False):
return Path(sys._MEIPASS).resolve()
return Path(__file__).resolve().parents[1]
def default_database_path() -> Path: def default_database_path() -> Path:
"""返回默认数据库文件路径。""" """返回默认数据库文件路径。"""
+5
View File
@@ -12,6 +12,7 @@ from contextlib import AbstractContextManager, contextmanager
from contextvars import ContextVar from contextvars import ContextVar
from typing import Any, Callable, Iterator, Optional from typing import Any, Callable, Iterator, Optional
from .adb_runtime import BundledAdbError, configure_bundled_adb_environment
from .performance_timing import current_performance_trace from .performance_timing import current_performance_trace
@@ -80,6 +81,10 @@ class PddDeviceService:
@staticmethod @staticmethod
def _default_connector(serial: str) -> Any: def _default_connector(serial: str) -> Any:
try:
configure_bundled_adb_environment()
except BundledAdbError as exc:
raise PddDeviceError("DEVICE_ADB_MISSING", str(exc)) from exc
try: try:
import uiautomator2 as u2 import uiautomator2 as u2
except ImportError as exc: except ImportError as exc:
+14 -2
View File
@@ -15,6 +15,7 @@ from PyQt5.QtCore import (
pyqtSlot, pyqtSlot,
) )
from .adb_runtime import BundledAdbError, validate_bundled_adb
from .android_device_service import ( from .android_device_service import (
AndroidDeviceConversionCancelled, AndroidDeviceConversionCancelled,
AndroidDeviceSearchError, AndroidDeviceSearchError,
@@ -359,6 +360,12 @@ class SettingsPageEventBinder(QObject):
self._live_purchase_adapter_ready = bool( self._live_purchase_adapter_ready = bool(
live_purchase_adapter_ready live_purchase_adapter_ready
) )
self._adb_unavailable_message = ""
if android_device_service is None:
try:
validate_bundled_adb()
except BundledAdbError as exc:
self._adb_unavailable_message = str(exc)
self._android_device_service = ( self._android_device_service = (
android_device_service or AndroidDeviceService() android_device_service or AndroidDeviceService()
) )
@@ -416,8 +423,12 @@ class SettingsPageEventBinder(QObject):
self._load_current_client() self._load_current_client()
self._load_selected_android_device() self._load_selected_android_device()
if self._adb_unavailable_message:
self._page.deviceStatusLabel.setText(
self._adb_unavailable_message
)
self._sync_button_state() self._sync_button_state()
if self._saved_android_serial: if self._saved_android_serial and not self._adb_unavailable_message:
QTimer.singleShot(0, self._request_restore_saved_android_device) QTimer.singleShot(0, self._request_restore_saved_android_device)
@pyqtSlot() @pyqtSlot()
@@ -910,7 +921,8 @@ class SettingsPageEventBinder(QObject):
and not self._android_setting_busy and not self._android_setting_busy
) )
device_commands_enabled = ( device_commands_enabled = (
not self._busy not self._adb_unavailable_message
and not self._busy
and not self._search_busy and not self._search_busy
and not self._wifi_conversion_busy and not self._wifi_conversion_busy
and not self._android_setting_busy and not self._android_setting_busy
+24 -1
View File
@@ -17,16 +17,18 @@
import sys import sys
from PyQt5.QtCore import Qt from PyQt5.QtCore import Qt, QTimer
from PyQt5.QtWidgets import QApplication from PyQt5.QtWidgets import QApplication
from qfluentwidgets import ( from qfluentwidgets import (
FluentIcon as FIF, FluentIcon as FIF,
FluentWindow, FluentWindow,
MessageBox,
NavigationItemPosition, NavigationItemPosition,
Theme, Theme,
setTheme, setTheme,
) )
from .adb_runtime import BundledAdbError, configure_bundled_adb_environment
from .pdd_ui import PDDTaskPage from .pdd_ui import PDDTaskPage
from .pdd_ui_event import PDDTaskPageEvent from .pdd_ui_event import PDDTaskPageEvent
from .pdd_u2_purchase_adapter import ( from .pdd_u2_purchase_adapter import (
@@ -147,8 +149,19 @@ def ui_main():
app = QApplication(sys.argv) app = QApplication(sys.argv)
setTheme(Theme.AUTO) setTheme(Theme.AUTO)
adb_error = ""
try:
configure_bundled_adb_environment()
except BundledAdbError as exc:
adb_error = str(exc)
window = MainWindow() window = MainWindow()
window.showMaximized() window.showMaximized()
if adb_error:
QTimer.singleShot(
0,
lambda: _show_adb_missing_dialog(window, adb_error),
)
try: try:
mark_current_version_healthy() mark_current_version_healthy()
except OSError: except OSError:
@@ -157,5 +170,15 @@ def ui_main():
return app.exec_() return app.exec_()
def _show_adb_missing_dialog(parent: MainWindow, message: str) -> None:
"""在主窗口中央提示内置 ADB 缺失,用户可用大按钮关闭。"""
dialog = MessageBox("Android 调试组件缺失", message, parent)
dialog.yesButton.setText("知道了")
dialog.yesButton.setMinimumSize(120, 40)
dialog.cancelButton.hide()
dialog.exec()
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(ui_main()) sys.exit(ui_main())
+50
View File
@@ -0,0 +1,50 @@
"""项目内置 ADB 路径与完整性检查测试。"""
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from src.adb_runtime import (
BundledAdbError,
REQUIRED_ADB_FILES,
bundled_adb_path,
configure_bundled_adb_environment,
validate_bundled_adb,
)
class BundledAdbRuntimeTest(unittest.TestCase):
def test_project_adb_is_complete(self):
adb_path = validate_bundled_adb()
self.assertEqual(adb_path, bundled_adb_path())
self.assertTrue(adb_path.is_absolute())
for file_name in REQUIRED_ADB_FILES:
self.assertTrue((adb_path.parent / file_name).is_file())
def test_missing_file_reports_name_without_path_fallback(self):
with tempfile.TemporaryDirectory() as directory, patch(
"src.adb_runtime.app_dir", return_value=Path(directory)
):
with self.assertRaisesRegex(BundledAdbError, "adb.exe"):
validate_bundled_adb()
def test_configure_forces_adbutils_to_use_project_adb(self):
old_value = os.environ.get("ADBUTILS_ADB_PATH")
try:
configured = configure_bundled_adb_environment()
self.assertEqual(
os.environ["ADBUTILS_ADB_PATH"],
str(configured),
)
finally:
if old_value is None:
os.environ.pop("ADBUTILS_ADB_PATH", None)
else:
os.environ["ADBUTILS_ADB_PATH"] = old_value
if __name__ == "__main__":
unittest.main()
+23 -19
View File
@@ -3,12 +3,16 @@
import subprocess import subprocess
import unittest import unittest
from src.adb_runtime import bundled_adb_path
from src.android_device_service import ( from src.android_device_service import (
AndroidDeviceSearchError, AndroidDeviceSearchError,
AndroidDeviceService, AndroidDeviceService,
) )
ADB = str(bundled_adb_path())
def completed(command, stdout="", stderr="", returncode=0): def completed(command, stdout="", stderr="", returncode=0):
"""创建一条假的 ADB 命令结果。""" """创建一条假的 ADB 命令结果。"""
@@ -56,7 +60,7 @@ USB-002 unauthorized usb:1-2 transport_id:3
def runner(command, _timeout): def runner(command, _timeout):
commands.append(list(command)) commands.append(list(command))
if command == ["adb", "devices", "-l"]: if command == [ADB, "devices", "-l"]:
return completed( return completed(
command, command,
"List of devices attached\n" "List of devices attached\n"
@@ -111,12 +115,12 @@ USB-002 unauthorized usb:1-2 transport_id:3
def runner(command, _timeout): def runner(command, _timeout):
command = list(command) command = list(command)
commands.append(command) commands.append(command)
if command[:2] == ["adb", "connect"]: if command[:2] == [ADB, "connect"]:
return completed( return completed(
command, command,
"already connected to 192.168.0.173:5555\n", "already connected to 192.168.0.173:5555\n",
) )
if command == ["adb", "devices", "-l"]: if command == [ADB, "devices", "-l"]:
return completed( return completed(
command, command,
"List of devices attached\n" "List of devices attached\n"
@@ -131,7 +135,7 @@ USB-002 unauthorized usb:1-2 transport_id:3
self.assertEqual(devices[0].serial, "192.168.0.173:5555") self.assertEqual(devices[0].serial, "192.168.0.173:5555")
self.assertEqual( self.assertEqual(
commands[0], commands[0],
["adb", "connect", "192.168.0.173:5555"], [ADB, "connect", "192.168.0.173:5555"],
) )
def test_restore_saved_wifi_waits_for_authorization_handshake(self): def test_restore_saved_wifi_waits_for_authorization_handshake(self):
@@ -141,9 +145,9 @@ USB-002 unauthorized usb:1-2 transport_id:3
def runner(command, _timeout): def runner(command, _timeout):
nonlocal device_list_count nonlocal device_list_count
command = list(command) command = list(command)
if command[:2] == ["adb", "connect"]: if command[:2] == [ADB, "connect"]:
return completed(command, "connected to 192.168.0.173:5555\n") return completed(command, "connected to 192.168.0.173:5555\n")
if command == ["adb", "devices", "-l"]: if command == [ADB, "devices", "-l"]:
device_list_count += 1 device_list_count += 1
status = "unauthorized" if device_list_count == 1 else "device" status = "unauthorized" if device_list_count == 1 else "device"
return completed( return completed(
@@ -167,7 +171,7 @@ USB-002 unauthorized usb:1-2 transport_id:3
def runner(command, _timeout): def runner(command, _timeout):
command = list(command) command = list(command)
commands.append(command) commands.append(command)
if command == ["adb", "devices", "-l"]: if command == [ADB, "devices", "-l"]:
return completed( return completed(
command, command,
"List of devices attached\n" "List of devices attached\n"
@@ -181,12 +185,12 @@ USB-002 unauthorized usb:1-2 transport_id:3
self.assertEqual(devices[0].serial, "USB-001") self.assertEqual(devices[0].serial, "USB-001")
self.assertFalse( self.assertFalse(
any(command[:2] == ["adb", "connect"] for command in commands) any(command[:2] == [ADB, "connect"] for command in commands)
) )
def test_missing_model_is_read_from_device(self): def test_missing_model_is_read_from_device(self):
def runner(command, _timeout): def runner(command, _timeout):
if command == ["adb", "devices", "-l"]: if command == [ADB, "devices", "-l"]:
return completed( return completed(
command, command,
"List of devices attached\nUSB-001 device transport_id:1\n", "List of devices attached\nUSB-001 device transport_id:1\n",
@@ -202,7 +206,7 @@ USB-002 unauthorized usb:1-2 transport_id:3
def test_property_failure_keeps_discovered_device(self): def test_property_failure_keeps_discovered_device(self):
def runner(command, _timeout): def runner(command, _timeout):
if command == ["adb", "devices", "-l"]: if command == [ADB, "devices", "-l"]:
return completed( return completed(
command, command,
"List of devices attached\nUSB-001 device model:Phone\n", "List of devices attached\nUSB-001 device model:Phone\n",
@@ -218,7 +222,7 @@ USB-002 unauthorized usb:1-2 transport_id:3
def runner(_command, _timeout): def runner(_command, _timeout):
raise FileNotFoundError("adb") raise FileNotFoundError("adb")
with self.assertRaisesRegex(AndroidDeviceSearchError, "未找到 adb"): with self.assertRaisesRegex(AndroidDeviceSearchError, "内置 adb.exe"):
AndroidDeviceService(runner).search() AndroidDeviceService(runner).search()
def test_timeout_has_recovery_message(self): def test_timeout_has_recovery_message(self):
@@ -253,7 +257,7 @@ USB-002 unauthorized usb:1-2 transport_id:3
self.assertEqual( self.assertEqual(
commands, commands,
[[ [[
"adb", ADB,
"-s", "-s",
"USB-001", "USB-001",
"shell", "shell",
@@ -296,9 +300,9 @@ USB-002 unauthorized usb:1-2 transport_id:3
"192.168.0.0/24 dev wlan0 scope link " "192.168.0.0/24 dev wlan0 scope link "
"src 192.168.0.173\n", "src 192.168.0.173\n",
) )
if command[:2] == ["adb", "connect"]: if command[:2] == [ADB, "connect"]:
return completed(command, "connected to 192.168.0.173:5555\n") return completed(command, "connected to 192.168.0.173:5555\n")
if command == ["adb", "devices", "-l"]: if command == [ADB, "devices", "-l"]:
return completed( return completed(
command, command,
"List of devices attached\n" "List of devices attached\n"
@@ -323,14 +327,14 @@ USB-002 unauthorized usb:1-2 transport_id:3
["USB-001", "192.168.0.173:5555"], ["USB-001", "192.168.0.173:5555"],
) )
self.assertIn( self.assertIn(
["adb", "-s", "USB-001", "tcpip", "5555"], [ADB, "-s", "USB-001", "tcpip", "5555"],
commands, commands,
) )
self.assertEqual( self.assertEqual(
commands.count(["adb", "connect", "192.168.0.173:5555"]), commands.count([ADB, "connect", "192.168.0.173:5555"]),
1, 1,
) )
self.assertEqual(commands.count(["adb", "devices", "-l"]), 3) self.assertEqual(commands.count([ADB, "devices", "-l"]), 3)
self.assertTrue( self.assertTrue(
any("保存后拔掉 USB" in message for message in progress) any("保存后拔掉 USB" in message for message in progress)
) )
@@ -346,9 +350,9 @@ USB-002 unauthorized usb:1-2 transport_id:3
command = list(command) command = list(command)
if command[-3:] == ["shell", "ip", "route"]: if command[-3:] == ["shell", "ip", "route"]:
return completed(command, "local src 192.168.0.173\n") return completed(command, "local src 192.168.0.173\n")
if command[:2] == ["adb", "connect"]: if command[:2] == [ADB, "connect"]:
return completed(command, "connected to 192.168.0.173:5555\n") return completed(command, "connected to 192.168.0.173:5555\n")
if command == ["adb", "devices", "-l"]: if command == [ADB, "devices", "-l"]:
return completed( return completed(
command, command,
"List of devices attached\n" "List of devices attached\n"
+21 -1
View File
@@ -21,7 +21,7 @@ from build_tools.release_manifest import (
versioned_manifest_file_name, versioned_manifest_file_name,
write_manifest, write_manifest,
) )
from src.db import data_dir from src.db import app_dir, data_dir
class LauncherPathTest(unittest.TestCase): class LauncherPathTest(unittest.TestCase):
@@ -139,6 +139,26 @@ class PackagedDataPathTest(unittest.TestCase):
): ):
self.assertEqual(data_dir(), root / "data") self.assertEqual(data_dir(), root / "data")
def test_packaged_resource_path_uses_pyinstaller_directory(self):
with tempfile.TemporaryDirectory() as directory, patch(
"src.db.sys.frozen", True, create=True
), patch("src.db.sys._MEIPASS", directory, create=True):
self.assertEqual(app_dir(), Path(directory).resolve())
class BundledAdbPackagingTest(unittest.TestCase):
def test_build_script_packages_and_checks_all_adb_files(self):
client_root = Path(__file__).resolve().parents[1]
script = (client_root / "packaging" / "build.ps1").read_text(
encoding="utf-8-sig"
)
self.assertIn("--add-binary", script)
self.assertIn("vendor/android-platform-tools/windows", script)
self.assertIn("adb.exe", script)
self.assertIn("AdbWinApi.dll", script)
self.assertIn("AdbWinUsbApi.dll", script)
class ReleaseManifestTest(unittest.TestCase): class ReleaseManifestTest(unittest.TestCase):
def test_manifest_name_and_hash_match_release_files(self): def test_manifest_name_and_hash_match_release_files(self):
+35
View File
@@ -14,6 +14,7 @@ from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QApplication from PyQt5.QtWidgets import QApplication
from qfluentwidgets import InfoBarPosition from qfluentwidgets import InfoBarPosition
from src.adb_runtime import BundledAdbError
from src.android_device_service import AndroidDeviceSearchError from src.android_device_service import AndroidDeviceSearchError
from src.db import open_database from src.db import open_database
from src.pdd_ui import PDDTaskPage from src.pdd_ui import PDDTaskPage
@@ -1174,6 +1175,40 @@ class PDDTaskPageEventTest(unittest.TestCase):
self.assertEqual(result, 0) self.assertEqual(result, 0)
self.assertTrue(window.maximized) self.assertTrue(window.maximized)
def test_application_startup_shows_dialog_when_bundled_adb_is_missing(self):
class FakeApplication:
@staticmethod
def setAttribute(*_args):
pass
def __init__(self, _args):
pass
def exec_(self):
return 0
class FakeWindow:
def showMaximized(self):
pass
window = FakeWindow()
message = "内置 ADB 文件缺失:adb.exe。请重新安装完整的软件包。"
with patch("src.ui_main.QApplication", FakeApplication), patch(
"src.ui_main.MainWindow", return_value=window
), patch("src.ui_main.setTheme"), patch(
"src.ui_main.mark_current_version_healthy"
), patch(
"src.ui_main.configure_bundled_adb_environment",
side_effect=BundledAdbError(message),
), patch(
"src.ui_main.QTimer.singleShot",
side_effect=lambda _delay, callback: callback(),
), patch("src.ui_main._show_adb_missing_dialog") as show_dialog:
result = ui_main()
self.assertEqual(result, 0)
show_dialog.assert_called_once_with(window, message)
def test_main_window_marks_and_clears_update_navigation(self): def test_main_window_marks_and_clears_update_navigation(self):
window = MainWindow( window = MainWindow(
task_repository=self.repository, task_repository=self.repository,
+20
View File
@@ -5,6 +5,7 @@ import tempfile
import time import time
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
@@ -12,6 +13,7 @@ from PyQt5.QtCore import Qt, QTimer
from PyQt5.QtTest import QTest from PyQt5.QtTest import QTest
from PyQt5.QtWidgets import QApplication from PyQt5.QtWidgets import QApplication
from src.adb_runtime import BundledAdbError
from src.android_device_service import ( from src.android_device_service import (
AndroidDevice, AndroidDevice,
AndroidDeviceConversionCancelled, AndroidDeviceConversionCancelled,
@@ -218,6 +220,24 @@ class SettingsPageEventTest(unittest.TestCase):
page.eventBinder.shutdown() page.eventBinder.shutdown()
page.deleteLater() page.deleteLater()
def test_missing_bundled_adb_disables_device_commands(self):
message = "内置 ADB 文件缺失:adb.exe。请重新安装完整的软件包。"
with patch(
"src.settings_ui_event.validate_bundled_adb",
side_effect=BundledAdbError(message),
):
page = SettingsPage(
settings_repository=self.repository,
admin_gateway=MockAdminGateway(),
)
self.assertEqual(page.deviceStatusLabel.text(), message)
self.assertFalse(page.searchButton.isEnabled())
self.assertFalse(page.convertWifiButton.isEnabled())
self.assertTrue(page.currentDeviceSaveButton.isEnabled())
page.eventBinder.shutdown()
page.deleteLater()
def test_compact_form_layout_keeps_related_fields_on_same_row(self): def test_compact_form_layout_keeps_related_fields_on_same_row(self):
page = SettingsPage( page = SettingsPage(
settings_repository=self.repository, settings_repository=self.repository,
+13 -2
View File
@@ -16,6 +16,8 @@ import xml.etree.ElementTree as ET
from collections.abc import Callable from collections.abc import Callable
from typing import Any from typing import Any
from src.adb_runtime import configure_bundled_adb_environment
PDD_PACKAGE_NAME = "com.xunmeng.pinduoduo" PDD_PACKAGE_NAME = "com.xunmeng.pinduoduo"
READY_MARKERS = ("发起拼单", "立即购买", "单独购买", "免拼购买", "快要抢光") READY_MARKERS = ("发起拼单", "立即购买", "单独购买", "免拼购买", "快要抢光")
@@ -59,11 +61,20 @@ def wait_goods_ready(device: Any, first_xml: str, timeout: float) -> None:
def measure_once(serial: str, goods_url: str, cold: bool) -> dict[str, int]: def measure_once(serial: str, goods_url: str, cold: bool) -> dict[str, int]:
adb_executable = str(configure_bundled_adb_environment())
import uiautomator2 as u2 import uiautomator2 as u2
if cold: if cold:
subprocess.run( subprocess.run(
["adb", "-s", serial, "shell", "am", "force-stop", PDD_PACKAGE_NAME], [
adb_executable,
"-s",
serial,
"shell",
"am",
"force-stop",
PDD_PACKAGE_NAME,
],
check=True, check=True,
capture_output=True, capture_output=True,
text=True, text=True,
@@ -80,7 +91,7 @@ def measure_once(serial: str, goods_url: str, cold: bool) -> dict[str, int]:
_, result["adb_device_check"] = measure_call( _, result["adb_device_check"] = measure_call(
"adb_device_check", "adb_device_check",
lambda: subprocess.run( lambda: subprocess.run(
["adb", "-s", serial, "get-state"], [adb_executable, "-s", serial, "get-state"],
check=True, check=True,
capture_output=True, capture_output=True,
text=True, text=True,
+4 -2
View File
@@ -26,6 +26,7 @@ from src.pdd_page_classifier import ( # noqa: E402
PAGE_HOME, PAGE_HOME,
classify_pdd_page, classify_pdd_page,
) )
from src.adb_runtime import configure_bundled_adb_environment # noqa: E402
TEST_URL = "https://mobile.yangkeduo.com/goods.html" TEST_URL = "https://mobile.yangkeduo.com/goods.html"
@@ -143,15 +144,16 @@ def run_diagnostic(
address = str(device_address or "").strip() address = str(device_address or "").strip()
if not address: if not address:
raise ValueError("必须提供 Android 设备地址") raise ValueError("必须提供 Android 设备地址")
adb_executable = str(configure_bundled_adb_environment())
adb_runner( adb_runner(
["adb", "-s", address, "get-state"], [adb_executable, "-s", address, "get-state"],
check=True, check=True,
capture_output=True, capture_output=True,
text=True, text=True,
) )
adb_runner( adb_runner(
[ [
"adb", "-s", address, "shell", "am", "start", adb_executable, "-s", address, "shell", "am", "start",
"-a", "android.intent.action.VIEW", "-d", TEST_URL, "-a", "android.intent.action.VIEW", "-d", TEST_URL,
], ],
check=True, check=True,
Binary file not shown.
Binary file not shown.
+15
View File
@@ -0,0 +1,15 @@
# 内置 ADB 文件说明
- 来源目录:用户指定的 `D:\Portable\adb`
- 复制日期:2026-08-11
- ADB 版本:1.0.32
运行所需文件及 SHA-256:
| 文件 | SHA-256 |
|---|---|
| `adb.exe` | `AD5D27384D1B5BCEB6342BB3204FEE977217422BCAFD252AB18F763A3DE43931` |
| `AdbWinApi.dll` | `14A51482AA003DB79A400F4B15C158397FE6D57EE6606B3D633FA431A7BFDF4B` |
| `AdbWinUsbApi.dll` | `041C6859BB4FC78D3A903DD901298CD1ECFB75B6BE0646B74954CD722280A407` |
升级 ADB 时必须整体替换这三个文件,重新记录版本和哈希,并完成 USB、Wi-Fi 和 uiautomator2 真机验证。
Binary file not shown.
+7 -6
View File
@@ -15,12 +15,12 @@
|---|---|---| |---|---|---|
| Python | 3.10 | 打开 PowerShell 执行 `C:/Python310/python.exe --version`,输出 `Python 3.10.x` | | Python | 3.10 | 打开 PowerShell 执行 `C:/Python310/python.exe --version`,输出 `Python 3.10.x` |
| Git | 任意较新版本 | `git --version` 有输出 | | Git | 任意较新版本 | `git --version` 有输出 |
| adb(安卓调试工具) | 任意较新版本 | `adb version` 有输出 | | adb(安卓调试工具) | 项目内置 1.0.32 | `./vendor/android-platform-tools/windows/adb.exe version` 有输出 |
说明: 说明:
- 项目固定使用 `C:/Python310/python.exe`。**不要用 `python` 这个命令**,因为电脑上可能装了多个 Python,`python` 指向哪一个不确定。本文档所有命令都写全路径。 - 项目固定使用 `C:/Python310/python.exe`。**不要用 `python` 这个命令**,因为电脑上可能装了多个 Python,`python` 指向哪一个不确定。本文档所有命令都写全路径。
- adb 一般随 Android SDK Platform Tools 安装。装好后要把它所在目录加入系统环境变量 `Path`,否则 `adb` 命令找不到。 - Client 固定使用 `client/vendor/android-platform-tools/windows/` 中的 ADB,不读取系统环境变量 `Path`。三个内置文件必须同时存在;缺失时程序会弹窗提示重新安装。
## 2. 安装依赖 ## 2. 安装依赖
@@ -50,12 +50,12 @@ C:/Python310/python.exe -m pip freeze | Select-String "xxx"
只做采集/采购的界面开发时**可以跳过这一步**,界面不连手机也能打开。 只做采集/采购的界面开发时**可以跳过这一步**,界面不连手机也能打开。
1. 手机打开「开发者选项」→「USB 调试」。 1. 手机打开「开发者选项」→「USB 调试」。
2. 用数据线连电脑,执行 `adb devices`,能看到一行设备号 + `device`。 2. 用数据线连电脑,在 `client/` 目录执行 `./vendor/android-platform-tools/windows/adb.exe devices`,能看到一行设备号 + `device`。
3. 打开无线调试(这样后面不用一直插着线): 3. 打开无线调试(这样后面不用一直插着线):
```powershell ```powershell
adb tcpip 5555 ./vendor/android-platform-tools/windows/adb.exe tcpip 5555
adb connect 192.168.0.173:5555 ./vendor/android-platform-tools/windows/adb.exe connect 192.168.0.173:5555
``` ```
把 `192.168.0.173` 换成手机的实际 IP(手机「设置 → 关于手机 → 状态信息」里能看到)。 把 `192.168.0.173` 换成手机的实际 IP(手机「设置 → 关于手机 → 状态信息」里能看到)。
@@ -95,7 +95,8 @@ C:/Python310/python.exe buyer_main.py
| `ModuleNotFoundError: No module named 'qfluentwidgets'` | 依赖没装,或装到了别的 Python 上 | 回到第 2 步,注意用 `C:/Python310/python.exe -m pip` | | `ModuleNotFoundError: No module named 'qfluentwidgets'` | 依赖没装,或装到了别的 Python 上 | 回到第 2 步,注意用 `C:/Python310/python.exe -m pip` |
| `ImportError: DLL load failed`(导入 PyQt5 时) | 装了多个 Qt 绑定互相冲突 | 卸载干净再装:`pip uninstall PyQt5 PyQt6 PySide2 PySide6 -y`,然后重新执行第 2 步 | | `ImportError: DLL load failed`(导入 PyQt5 时) | 装了多个 Qt 绑定互相冲突 | 卸载干净再装:`pip uninstall PyQt5 PyQt6 PySide2 PySide6 -y`,然后重新执行第 2 步 |
| 窗口一闪就没了 | 直接双击 .py 文件运行的 | 必须在 PowerShell 里跑,才能看到错误信息 | | 窗口一闪就没了 | 直接双击 .py 文件运行的 | 必须在 PowerShell 里跑,才能看到错误信息 |
| `adb: device offline` / `connect failed` | 手机和电脑不在同一个 WiFi,或手机重启过 | 重新执行 `adb connect <手机IP>:5555` | | `adb: device offline` / `connect failed` | 手机和电脑不在同一个 WiFi,或手机重启过 | 使用项目内 adb 重新执行 `./vendor/android-platform-tools/windows/adb.exe connect <手机IP>:5555` |
| `内置 ADB 文件缺失` | 发布包不完整,或三个 ADB 文件没有一起复制 | 重新安装完整软件包;不要改用系统 PATH 中的 adb |
| 跑 demo 后目录里多出 `xxx_home.xml`、`xxx_home.png` | `src/demo1/auto_v1.py` 会把控件树和截图写到当前目录 | 正常现象,这些是调试产物,**不要提交到 Git** | | 跑 demo 后目录里多出 `xxx_home.xml`、`xxx_home.png` | `src/demo1/auto_v1.py` 会把控件树和截图写到当前目录 | 正常现象,这些是调试产物,**不要提交到 Git** |
界面相关的问题排查不了时,先确认是不是 Qt 主线程被卡住了 —— 见 `02-architecture.md` §5。 界面相关的问题排查不了时,先确认是不是 Qt 主线程被卡住了 —— 见 `02-architecture.md` §5。
+5 -2
View File
@@ -198,7 +198,10 @@ CMAutoBuy/ 整个文件夹拷到任何机器都能用
│ │ ├── bin/ Qt5Core.dll / Qt5Gui.dll / Qt5Widgets.dll … │ │ ├── bin/ Qt5Core.dll / Qt5Gui.dll / Qt5Widgets.dll …
│ │ └── plugins/platforms/qwindows.dll │ │ └── plugins/platforms/qwindows.dll
│ ├── qfluentwidgets/ qss 样式、字体、图标资源 │ ├── qfluentwidgets/ qss 样式、字体、图标资源
│ ├── adbutils/binaries/adb.exe │ ├── vendor/android-platform-tools/windows/
│ │ ├── adb.exe
│ │ ├── AdbWinApi.dll
│ │ └── AdbWinUsbApi.dll
│ └── … │ └── …
└── data/ 本地数据,升级时保留(见 [03 数据模型](03-data-model.md) §2.1) └── data/ 本地数据,升级时保留(见 [03 数据模型](03-data-model.md) §2.1)
├── client.db ├── client.db
@@ -246,7 +249,7 @@ CMAutoBuy/ 整个文件夹拷到任何机器都能用
|---|---| |---|---|
| Qt 平台插件没收集到 | 启动即报 `could not find or load the Qt platform plugin "windows"` | | Qt 平台插件没收集到 | 启动即报 `could not find or load the Qt platform plugin "windows"` |
| `qfluentwidgets` 资源没收集到 | 能启动,但界面全白、控件没样式 | | `qfluentwidgets` 资源没收集到 | 能启动,但界面全白、控件没样式 |
| `adbutils` 的 adb 二进制没收集到 | 界面正常,但连不上手机 | | 项目内置 ADB 或配套 DLL 没收集到 | 启动时弹窗提示,设备操作保持中断 |
| `uiautomator2` 初始化流程 | 打包后没有 `python -m uiautomator2 init` 这条命令,需确认辅助 App 怎么装 | | `uiautomator2` 初始化流程 | 打包后没有 `python -m uiautomator2 init` 这条命令,需确认辅助 App 怎么装 |
| 杀软误报 | 空白版本信息的 exe 最容易被拦。必须给 exe 加图标和版本信息(产品名、版本号)。根治需要代码签名证书 | | 杀软误报 | 空白版本信息的 exe 最容易被拦。必须给 exe 加图标和版本信息(产品名、版本号)。根治需要代码签名证书 |
| 装到 `C:\Program Files\` | 无写权限,数据被重定向到 VirtualStore,设置改了不生效 | | 装到 `C:\Program Files\` | 无写权限,数据被重定向到 VirtualStore,设置改了不生效 |
+1
View File
@@ -299,6 +299,7 @@ class TaskTableModel(QAbstractTableModel):
### Android 设备 ### Android 设备
- Client 只使用发布包内置的 ADB,不回退系统 `Path`;启动时缺少 `adb.exe` 或配套 DLL,应在主窗口中央弹出带明显关闭按钮的中文提示,并禁用需要 ADB 的设备命令;
- ADB 地址; - ADB 地址;
- PDD 包名; - PDD 包名;
- “测试连接”; - “测试连接”;