diff --git a/client/packaging/build.ps1 b/client/packaging/build.ps1 index 952ff5e..e8957c8 100644 --- a/client/packaging/build.ps1 +++ b/client/packaging/build.ps1 @@ -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)) { diff --git a/client/src/adb_runtime.py b/client/src/adb_runtime.py new file mode 100644 index 0000000..87c911c --- /dev/null +++ b/client/src/adb_runtime.py @@ -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 diff --git a/client/src/android_device_service.py b/client/src/android_device_service.py index 83f7cf6..f888460 100644 --- a/client/src/android_device_service.py +++ b/client/src/android_device_service.py @@ -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: diff --git a/client/src/db.py b/client/src/db.py index 2a88330..a972f5d 100644 --- a/client/src/db.py +++ b/client/src/db.py @@ -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: """返回默认数据库文件路径。""" diff --git a/client/src/pdd_device_service.py b/client/src/pdd_device_service.py index 60ba2c9..caa7779 100644 --- a/client/src/pdd_device_service.py +++ b/client/src/pdd_device_service.py @@ -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: diff --git a/client/src/settings_ui_event.py b/client/src/settings_ui_event.py index cec7c41..d649db0 100644 --- a/client/src/settings_ui_event.py +++ b/client/src/settings_ui_event.py @@ -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 diff --git a/client/src/ui_main.py b/client/src/ui_main.py index 89cac5f..1e17068 100644 --- a/client/src/ui_main.py +++ b/client/src/ui_main.py @@ -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()) diff --git a/client/test/test_adb_runtime.py b/client/test/test_adb_runtime.py new file mode 100644 index 0000000..d313790 --- /dev/null +++ b/client/test/test_adb_runtime.py @@ -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() diff --git a/client/test/test_android_device_service.py b/client/test/test_android_device_service.py index 5711cd9..fa6ad55 100644 --- a/client/test/test_android_device_service.py +++ b/client/test/test_android_device_service.py @@ -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" diff --git a/client/test/test_packaging.py b/client/test/test_packaging.py index e40a434..78e3c39 100644 --- a/client/test/test_packaging.py +++ b/client/test/test_packaging.py @@ -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): diff --git a/client/test/test_pdd_ui_event.py b/client/test/test_pdd_ui_event.py index c1eb0eb..057e490 100644 --- a/client/test/test_pdd_ui_event.py +++ b/client/test/test_pdd_ui_event.py @@ -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, diff --git a/client/test/test_settings_ui_event.py b/client/test/test_settings_ui_event.py index 35bc727..e878574 100644 --- a/client/test/test_settings_ui_event.py +++ b/client/test/test_settings_ui_event.py @@ -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, diff --git a/client/tools/measure_goods_open.py b/client/tools/measure_goods_open.py index 49c9dd4..56dd301 100644 --- a/client/tools/measure_goods_open.py +++ b/client/tools/measure_goods_open.py @@ -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, diff --git a/client/tools/test_pdd_home_deeplink.py b/client/tools/test_pdd_home_deeplink.py index 8832a9a..0692cea 100644 --- a/client/tools/test_pdd_home_deeplink.py +++ b/client/tools/test_pdd_home_deeplink.py @@ -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, diff --git a/client/vendor/android-platform-tools/windows/AdbWinApi.dll b/client/vendor/android-platform-tools/windows/AdbWinApi.dll new file mode 100644 index 0000000..b5586eb Binary files /dev/null and b/client/vendor/android-platform-tools/windows/AdbWinApi.dll differ diff --git a/client/vendor/android-platform-tools/windows/AdbWinUsbApi.dll b/client/vendor/android-platform-tools/windows/AdbWinUsbApi.dll new file mode 100644 index 0000000..0c9e00b Binary files /dev/null and b/client/vendor/android-platform-tools/windows/AdbWinUsbApi.dll differ diff --git a/client/vendor/android-platform-tools/windows/README.md b/client/vendor/android-platform-tools/windows/README.md new file mode 100644 index 0000000..e6d3105 --- /dev/null +++ b/client/vendor/android-platform-tools/windows/README.md @@ -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 真机验证。 diff --git a/client/vendor/android-platform-tools/windows/adb.exe b/client/vendor/android-platform-tools/windows/adb.exe new file mode 100644 index 0000000..dc1ff1e Binary files /dev/null and b/client/vendor/android-platform-tools/windows/adb.exe differ diff --git a/docs/client/00-getting-started.md b/docs/client/00-getting-started.md index ff178cc..b828256 100644 --- a/docs/client/00-getting-started.md +++ b/docs/client/00-getting-started.md @@ -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。 diff --git a/docs/client/01-requirements.md b/docs/client/01-requirements.md index 778f551..340de1b 100644 --- a/docs/client/01-requirements.md +++ b/docs/client/01-requirements.md @@ -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,设置改了不生效 | diff --git a/docs/client/05-ui-specification.md b/docs/client/05-ui-specification.md index 86b891c..1f9cfa0 100644 --- a/docs/client/05-ui-specification.md +++ b/docs/client/05-ui-specification.md @@ -299,6 +299,7 @@ class TaskTableModel(QAbstractTableModel): ### Android 设备 +- Client 只使用发布包内置的 ADB,不回退系统 `Path`;启动时缺少 `adb.exe` 或配套 DLL,应在主窗口中央弹出带明显关闭按钮的中文提示,并禁用需要 ADB 的设备命令; - ADB 地址; - PDD 包名; - “测试连接”;