feat: 内置并统一使用项目 ADB (#149)
This commit is contained in:
@@ -108,6 +108,13 @@ VSVersionInfo(
|
||||
$distPath = Join-Path $buildRoot "dist"
|
||||
$workPath = Join-Path $buildRoot "work"
|
||||
$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..."
|
||||
Invoke-Checked -FailureMessage "Failed to build the main application" -Command {
|
||||
@@ -118,6 +125,7 @@ VSVersionInfo(
|
||||
--collect-all qfluentwidgets `
|
||||
--collect-all uiautomator2 `
|
||||
--collect-all adbutils `
|
||||
--add-binary "$bundledAdbSource;vendor/android-platform-tools/windows" `
|
||||
(Join-Path $clientRoot "buyer_main.py")
|
||||
}
|
||||
|
||||
@@ -131,11 +139,13 @@ VSVersionInfo(
|
||||
|
||||
$mainDist = Join-Path $distPath "CMAutoBuy"
|
||||
$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 @(
|
||||
(Join-Path $mainDist "CMAutoBuy.exe"),
|
||||
(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
|
||||
)) {
|
||||
if (-not (Test-Path -LiteralPath $requiredPath -PathType Leaf)) {
|
||||
|
||||
@@ -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
|
||||
@@ -4,8 +4,15 @@ from dataclasses import dataclass
|
||||
import ipaddress
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
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_DEVICE_READY_CHECKS = 8
|
||||
@@ -78,19 +85,24 @@ class AndroidDeviceService:
|
||||
command_runner: CommandRunner = run_adb_command,
|
||||
timeout_seconds: float = DEFAULT_ADB_TIMEOUT_SECONDS,
|
||||
sleeper: Sleeper = time.sleep,
|
||||
adb_executable: Optional[Path] = None,
|
||||
):
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("ADB 超时时间必须大于 0")
|
||||
self._run_command = command_runner
|
||||
self._timeout_seconds = timeout_seconds
|
||||
self._sleep = sleeper
|
||||
self._uses_bundled_adb = adb_executable is None
|
||||
self._adb_executable = str(adb_executable or bundled_adb_path())
|
||||
|
||||
def search(
|
||||
self, is_cancelled: Optional[Callable[[], bool]] = None
|
||||
) -> List[AndroidDevice]:
|
||||
"""返回当前 ADB 设备;取消后停止补充设备属性。"""
|
||||
|
||||
result = self._run(["adb", "devices", "-l"], "搜索设备")
|
||||
result = self._run(
|
||||
[self._adb_executable, "devices", "-l"], "搜索设备"
|
||||
)
|
||||
devices = self.parse_devices(result.stdout or "")
|
||||
|
||||
enriched = []
|
||||
@@ -158,7 +170,15 @@ class AndroidDeviceService:
|
||||
raise AndroidDeviceSearchError("应用包名格式不正确")
|
||||
|
||||
result = self._run(
|
||||
["adb", "-s", serial, "shell", "pm", "path", package_name],
|
||||
[
|
||||
self._adb_executable,
|
||||
"-s",
|
||||
serial,
|
||||
"shell",
|
||||
"pm",
|
||||
"path",
|
||||
package_name,
|
||||
],
|
||||
"检测 PDD 应用",
|
||||
)
|
||||
return any(
|
||||
@@ -193,7 +213,7 @@ class AndroidDeviceService:
|
||||
self._check_cancelled(is_cancelled)
|
||||
self._report(on_progress, "正在读取勾选设备的 Wi-Fi 地址…")
|
||||
route = self._run(
|
||||
["adb", "-s", usb_serial, "shell", "ip", "route"],
|
||||
[self._adb_executable, "-s", usb_serial, "shell", "ip", "route"],
|
||||
"读取 Wi-Fi 地址",
|
||||
)
|
||||
ip_address = self.parse_wifi_ipv4(route.stdout or "")
|
||||
@@ -202,7 +222,7 @@ class AndroidDeviceService:
|
||||
self._check_cancelled(is_cancelled)
|
||||
self._report(on_progress, "正在开启 ADB 5555 端口…")
|
||||
self._run(
|
||||
["adb", "-s", usb_serial, "tcpip", "5555"],
|
||||
[self._adb_executable, "-s", usb_serial, "tcpip", "5555"],
|
||||
"开启 Wi-Fi 调试",
|
||||
)
|
||||
|
||||
@@ -312,7 +332,14 @@ class AndroidDeviceService:
|
||||
|
||||
try:
|
||||
result = self._run(
|
||||
["adb", "-s", serial, "shell", "getprop", property_name],
|
||||
[
|
||||
self._adb_executable,
|
||||
"-s",
|
||||
serial,
|
||||
"shell",
|
||||
"getprop",
|
||||
property_name,
|
||||
],
|
||||
"读取设备信息",
|
||||
)
|
||||
except AndroidDeviceSearchError:
|
||||
@@ -343,12 +370,14 @@ class AndroidDeviceService:
|
||||
self._sleep(DEFAULT_DEVICE_READY_INTERVAL_SECONDS)
|
||||
|
||||
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 "")
|
||||
|
||||
def _connect_wifi(self, wifi_serial: str) -> None:
|
||||
result = self._run(
|
||||
["adb", "connect", wifi_serial],
|
||||
[self._adb_executable, "connect", wifi_serial],
|
||||
"连接 Wi-Fi 设备",
|
||||
)
|
||||
detail = " ".join(
|
||||
@@ -401,11 +430,16 @@ class AndroidDeviceService:
|
||||
callback(message)
|
||||
|
||||
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:
|
||||
result = self._run_command(command, self._timeout_seconds)
|
||||
except FileNotFoundError as exc:
|
||||
raise AndroidDeviceSearchError(
|
||||
"未找到 adb,请安装 Android platform-tools 并把 adb 加入 PATH 后重试"
|
||||
"内置 adb.exe 无法启动,请重新安装完整的软件包"
|
||||
) from exc
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise AndroidDeviceSearchError(
|
||||
@@ -413,7 +447,7 @@ class AndroidDeviceService:
|
||||
) from exc
|
||||
except OSError as exc:
|
||||
raise AndroidDeviceSearchError(
|
||||
f"无法启动 adb:{str(exc) or '系统拒绝执行'}"
|
||||
f"无法启动内置 adb:{str(exc) or '系统拒绝执行'}"
|
||||
) from exc
|
||||
|
||||
if result.returncode != 0:
|
||||
|
||||
@@ -34,6 +34,14 @@ def data_dir() -> Path:
|
||||
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:
|
||||
"""返回默认数据库文件路径。"""
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from contextlib import AbstractContextManager, contextmanager
|
||||
from contextvars import ContextVar
|
||||
from typing import Any, Callable, Iterator, Optional
|
||||
|
||||
from .adb_runtime import BundledAdbError, configure_bundled_adb_environment
|
||||
from .performance_timing import current_performance_trace
|
||||
|
||||
|
||||
@@ -80,6 +81,10 @@ class PddDeviceService:
|
||||
|
||||
@staticmethod
|
||||
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:
|
||||
import uiautomator2 as u2
|
||||
except ImportError as exc:
|
||||
|
||||
@@ -15,6 +15,7 @@ from PyQt5.QtCore import (
|
||||
pyqtSlot,
|
||||
)
|
||||
|
||||
from .adb_runtime import BundledAdbError, validate_bundled_adb
|
||||
from .android_device_service import (
|
||||
AndroidDeviceConversionCancelled,
|
||||
AndroidDeviceSearchError,
|
||||
@@ -359,6 +360,12 @@ class SettingsPageEventBinder(QObject):
|
||||
self._live_purchase_adapter_ready = bool(
|
||||
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 = (
|
||||
android_device_service or AndroidDeviceService()
|
||||
)
|
||||
@@ -416,8 +423,12 @@ class SettingsPageEventBinder(QObject):
|
||||
|
||||
self._load_current_client()
|
||||
self._load_selected_android_device()
|
||||
if self._adb_unavailable_message:
|
||||
self._page.deviceStatusLabel.setText(
|
||||
self._adb_unavailable_message
|
||||
)
|
||||
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)
|
||||
|
||||
@pyqtSlot()
|
||||
@@ -910,7 +921,8 @@ class SettingsPageEventBinder(QObject):
|
||||
and not self._android_setting_busy
|
||||
)
|
||||
device_commands_enabled = (
|
||||
not self._busy
|
||||
not self._adb_unavailable_message
|
||||
and not self._busy
|
||||
and not self._search_busy
|
||||
and not self._wifi_conversion_busy
|
||||
and not self._android_setting_busy
|
||||
|
||||
+24
-1
@@ -17,16 +17,18 @@
|
||||
|
||||
import sys
|
||||
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtCore import Qt, QTimer
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
from qfluentwidgets import (
|
||||
FluentIcon as FIF,
|
||||
FluentWindow,
|
||||
MessageBox,
|
||||
NavigationItemPosition,
|
||||
Theme,
|
||||
setTheme,
|
||||
)
|
||||
|
||||
from .adb_runtime import BundledAdbError, configure_bundled_adb_environment
|
||||
from .pdd_ui import PDDTaskPage
|
||||
from .pdd_ui_event import PDDTaskPageEvent
|
||||
from .pdd_u2_purchase_adapter import (
|
||||
@@ -147,8 +149,19 @@ def ui_main():
|
||||
app = QApplication(sys.argv)
|
||||
setTheme(Theme.AUTO)
|
||||
|
||||
adb_error = ""
|
||||
try:
|
||||
configure_bundled_adb_environment()
|
||||
except BundledAdbError as exc:
|
||||
adb_error = str(exc)
|
||||
|
||||
window = MainWindow()
|
||||
window.showMaximized()
|
||||
if adb_error:
|
||||
QTimer.singleShot(
|
||||
0,
|
||||
lambda: _show_adb_missing_dialog(window, adb_error),
|
||||
)
|
||||
try:
|
||||
mark_current_version_healthy()
|
||||
except OSError:
|
||||
@@ -157,5 +170,15 @@ def ui_main():
|
||||
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__":
|
||||
sys.exit(ui_main())
|
||||
|
||||
@@ -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()
|
||||
@@ -3,12 +3,16 @@
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
from src.adb_runtime import bundled_adb_path
|
||||
from src.android_device_service import (
|
||||
AndroidDeviceSearchError,
|
||||
AndroidDeviceService,
|
||||
)
|
||||
|
||||
|
||||
ADB = str(bundled_adb_path())
|
||||
|
||||
|
||||
def completed(command, stdout="", stderr="", returncode=0):
|
||||
"""创建一条假的 ADB 命令结果。"""
|
||||
|
||||
@@ -56,7 +60,7 @@ USB-002 unauthorized usb:1-2 transport_id:3
|
||||
|
||||
def runner(command, _timeout):
|
||||
commands.append(list(command))
|
||||
if command == ["adb", "devices", "-l"]:
|
||||
if command == [ADB, "devices", "-l"]:
|
||||
return completed(
|
||||
command,
|
||||
"List of devices attached\n"
|
||||
@@ -111,12 +115,12 @@ USB-002 unauthorized usb:1-2 transport_id:3
|
||||
def runner(command, _timeout):
|
||||
command = list(command)
|
||||
commands.append(command)
|
||||
if command[:2] == ["adb", "connect"]:
|
||||
if command[:2] == [ADB, "connect"]:
|
||||
return completed(
|
||||
command,
|
||||
"already connected to 192.168.0.173:5555\n",
|
||||
)
|
||||
if command == ["adb", "devices", "-l"]:
|
||||
if command == [ADB, "devices", "-l"]:
|
||||
return completed(
|
||||
command,
|
||||
"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(
|
||||
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):
|
||||
@@ -141,9 +145,9 @@ USB-002 unauthorized usb:1-2 transport_id:3
|
||||
def runner(command, _timeout):
|
||||
nonlocal device_list_count
|
||||
command = list(command)
|
||||
if command[:2] == ["adb", "connect"]:
|
||||
if command[:2] == [ADB, "connect"]:
|
||||
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
|
||||
status = "unauthorized" if device_list_count == 1 else "device"
|
||||
return completed(
|
||||
@@ -167,7 +171,7 @@ USB-002 unauthorized usb:1-2 transport_id:3
|
||||
def runner(command, _timeout):
|
||||
command = list(command)
|
||||
commands.append(command)
|
||||
if command == ["adb", "devices", "-l"]:
|
||||
if command == [ADB, "devices", "-l"]:
|
||||
return completed(
|
||||
command,
|
||||
"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.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 runner(command, _timeout):
|
||||
if command == ["adb", "devices", "-l"]:
|
||||
if command == [ADB, "devices", "-l"]:
|
||||
return completed(
|
||||
command,
|
||||
"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 runner(command, _timeout):
|
||||
if command == ["adb", "devices", "-l"]:
|
||||
if command == [ADB, "devices", "-l"]:
|
||||
return completed(
|
||||
command,
|
||||
"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):
|
||||
raise FileNotFoundError("adb")
|
||||
|
||||
with self.assertRaisesRegex(AndroidDeviceSearchError, "未找到 adb"):
|
||||
with self.assertRaisesRegex(AndroidDeviceSearchError, "内置 adb.exe"):
|
||||
AndroidDeviceService(runner).search()
|
||||
|
||||
def test_timeout_has_recovery_message(self):
|
||||
@@ -253,7 +257,7 @@ USB-002 unauthorized usb:1-2 transport_id:3
|
||||
self.assertEqual(
|
||||
commands,
|
||||
[[
|
||||
"adb",
|
||||
ADB,
|
||||
"-s",
|
||||
"USB-001",
|
||||
"shell",
|
||||
@@ -296,9 +300,9 @@ USB-002 unauthorized usb:1-2 transport_id:3
|
||||
"192.168.0.0/24 dev wlan0 scope link "
|
||||
"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")
|
||||
if command == ["adb", "devices", "-l"]:
|
||||
if command == [ADB, "devices", "-l"]:
|
||||
return completed(
|
||||
command,
|
||||
"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"],
|
||||
)
|
||||
self.assertIn(
|
||||
["adb", "-s", "USB-001", "tcpip", "5555"],
|
||||
[ADB, "-s", "USB-001", "tcpip", "5555"],
|
||||
commands,
|
||||
)
|
||||
self.assertEqual(
|
||||
commands.count(["adb", "connect", "192.168.0.173:5555"]),
|
||||
commands.count([ADB, "connect", "192.168.0.173:5555"]),
|
||||
1,
|
||||
)
|
||||
self.assertEqual(commands.count(["adb", "devices", "-l"]), 3)
|
||||
self.assertEqual(commands.count([ADB, "devices", "-l"]), 3)
|
||||
self.assertTrue(
|
||||
any("保存后拔掉 USB" in message for message in progress)
|
||||
)
|
||||
@@ -346,9 +350,9 @@ USB-002 unauthorized usb:1-2 transport_id:3
|
||||
command = list(command)
|
||||
if command[-3:] == ["shell", "ip", "route"]:
|
||||
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")
|
||||
if command == ["adb", "devices", "-l"]:
|
||||
if command == [ADB, "devices", "-l"]:
|
||||
return completed(
|
||||
command,
|
||||
"List of devices attached\n"
|
||||
|
||||
@@ -21,7 +21,7 @@ from build_tools.release_manifest import (
|
||||
versioned_manifest_file_name,
|
||||
write_manifest,
|
||||
)
|
||||
from src.db import data_dir
|
||||
from src.db import app_dir, data_dir
|
||||
|
||||
|
||||
class LauncherPathTest(unittest.TestCase):
|
||||
@@ -139,6 +139,26 @@ class PackagedDataPathTest(unittest.TestCase):
|
||||
):
|
||||
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):
|
||||
def test_manifest_name_and_hash_match_release_files(self):
|
||||
|
||||
@@ -14,6 +14,7 @@ from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
from qfluentwidgets import InfoBarPosition
|
||||
|
||||
from src.adb_runtime import BundledAdbError
|
||||
from src.android_device_service import AndroidDeviceSearchError
|
||||
from src.db import open_database
|
||||
from src.pdd_ui import PDDTaskPage
|
||||
@@ -1174,6 +1175,40 @@ class PDDTaskPageEventTest(unittest.TestCase):
|
||||
self.assertEqual(result, 0)
|
||||
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):
|
||||
window = MainWindow(
|
||||
task_repository=self.repository,
|
||||
|
||||
@@ -5,6 +5,7 @@ import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
@@ -12,6 +13,7 @@ from PyQt5.QtCore import Qt, QTimer
|
||||
from PyQt5.QtTest import QTest
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
|
||||
from src.adb_runtime import BundledAdbError
|
||||
from src.android_device_service import (
|
||||
AndroidDevice,
|
||||
AndroidDeviceConversionCancelled,
|
||||
@@ -218,6 +220,24 @@ class SettingsPageEventTest(unittest.TestCase):
|
||||
page.eventBinder.shutdown()
|
||||
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):
|
||||
page = SettingsPage(
|
||||
settings_repository=self.repository,
|
||||
|
||||
@@ -16,6 +16,8 @@ import xml.etree.ElementTree as ET
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from src.adb_runtime import configure_bundled_adb_environment
|
||||
|
||||
|
||||
PDD_PACKAGE_NAME = "com.xunmeng.pinduoduo"
|
||||
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]:
|
||||
adb_executable = str(configure_bundled_adb_environment())
|
||||
import uiautomator2 as u2
|
||||
|
||||
if cold:
|
||||
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,
|
||||
capture_output=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(
|
||||
"adb_device_check",
|
||||
lambda: subprocess.run(
|
||||
["adb", "-s", serial, "get-state"],
|
||||
[adb_executable, "-s", serial, "get-state"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
|
||||
@@ -26,6 +26,7 @@ from src.pdd_page_classifier import ( # noqa: E402
|
||||
PAGE_HOME,
|
||||
classify_pdd_page,
|
||||
)
|
||||
from src.adb_runtime import configure_bundled_adb_environment # noqa: E402
|
||||
|
||||
|
||||
TEST_URL = "https://mobile.yangkeduo.com/goods.html"
|
||||
@@ -143,15 +144,16 @@ def run_diagnostic(
|
||||
address = str(device_address or "").strip()
|
||||
if not address:
|
||||
raise ValueError("必须提供 Android 设备地址")
|
||||
adb_executable = str(configure_bundled_adb_environment())
|
||||
adb_runner(
|
||||
["adb", "-s", address, "get-state"],
|
||||
[adb_executable, "-s", address, "get-state"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
adb_runner(
|
||||
[
|
||||
"adb", "-s", address, "shell", "am", "start",
|
||||
adb_executable, "-s", address, "shell", "am", "start",
|
||||
"-a", "android.intent.action.VIEW", "-d", TEST_URL,
|
||||
],
|
||||
check=True,
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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.
@@ -15,12 +15,12 @@
|
||||
|---|---|---|
|
||||
| Python | 3.10 | 打开 PowerShell 执行 `C:/Python310/python.exe --version`,输出 `Python 3.10.x` |
|
||||
| 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` 指向哪一个不确定。本文档所有命令都写全路径。
|
||||
- adb 一般随 Android SDK Platform Tools 安装。装好后要把它所在目录加入系统环境变量 `Path`,否则 `adb` 命令找不到。
|
||||
- Client 固定使用 `client/vendor/android-platform-tools/windows/` 中的 ADB,不读取系统环境变量 `Path`。三个内置文件必须同时存在;缺失时程序会弹窗提示重新安装。
|
||||
|
||||
## 2. 安装依赖
|
||||
|
||||
@@ -50,12 +50,12 @@ C:/Python310/python.exe -m pip freeze | Select-String "xxx"
|
||||
只做采集/采购的界面开发时**可以跳过这一步**,界面不连手机也能打开。
|
||||
|
||||
1. 手机打开「开发者选项」→「USB 调试」。
|
||||
2. 用数据线连电脑,执行 `adb devices`,能看到一行设备号 + `device`。
|
||||
2. 用数据线连电脑,在 `client/` 目录执行 `./vendor/android-platform-tools/windows/adb.exe devices`,能看到一行设备号 + `device`。
|
||||
3. 打开无线调试(这样后面不用一直插着线):
|
||||
|
||||
```powershell
|
||||
adb tcpip 5555
|
||||
adb connect 192.168.0.173:5555
|
||||
./vendor/android-platform-tools/windows/adb.exe tcpip 5555
|
||||
./vendor/android-platform-tools/windows/adb.exe connect 192.168.0.173:5555
|
||||
```
|
||||
|
||||
把 `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` |
|
||||
| `ImportError: DLL load failed`(导入 PyQt5 时) | 装了多个 Qt 绑定互相冲突 | 卸载干净再装:`pip uninstall PyQt5 PyQt6 PySide2 PySide6 -y`,然后重新执行第 2 步 |
|
||||
| 窗口一闪就没了 | 直接双击 .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** |
|
||||
|
||||
界面相关的问题排查不了时,先确认是不是 Qt 主线程被卡住了 —— 见 `02-architecture.md` §5。
|
||||
|
||||
@@ -198,7 +198,10 @@ CMAutoBuy/ 整个文件夹拷到任何机器都能用
|
||||
│ │ ├── bin/ Qt5Core.dll / Qt5Gui.dll / Qt5Widgets.dll …
|
||||
│ │ └── plugins/platforms/qwindows.dll
|
||||
│ ├── 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)
|
||||
├── client.db
|
||||
@@ -246,7 +249,7 @@ CMAutoBuy/ 整个文件夹拷到任何机器都能用
|
||||
|---|---|
|
||||
| Qt 平台插件没收集到 | 启动即报 `could not find or load the Qt platform plugin "windows"` |
|
||||
| `qfluentwidgets` 资源没收集到 | 能启动,但界面全白、控件没样式 |
|
||||
| `adbutils` 的 adb 二进制没收集到 | 界面正常,但连不上手机 |
|
||||
| 项目内置 ADB 或配套 DLL 没收集到 | 启动时弹窗提示,设备操作保持中断 |
|
||||
| `uiautomator2` 初始化流程 | 打包后没有 `python -m uiautomator2 init` 这条命令,需确认辅助 App 怎么装 |
|
||||
| 杀软误报 | 空白版本信息的 exe 最容易被拦。必须给 exe 加图标和版本信息(产品名、版本号)。根治需要代码签名证书 |
|
||||
| 装到 `C:\Program Files\` | 无写权限,数据被重定向到 VirtualStore,设置改了不生效 |
|
||||
|
||||
@@ -299,6 +299,7 @@ class TaskTableModel(QAbstractTableModel):
|
||||
|
||||
### Android 设备
|
||||
|
||||
- Client 只使用发布包内置的 ADB,不回退系统 `Path`;启动时缺少 `adb.exe` 或配套 DLL,应在主窗口中央弹出带明显关闭按钮的中文提示,并禁用需要 ADB 的设备命令;
|
||||
- ADB 地址;
|
||||
- PDD 包名;
|
||||
- “测试连接”;
|
||||
|
||||
Reference in New Issue
Block a user