diff --git a/README.md b/README.md index 2d56c6f..7f1fc6f 100644 --- a/README.md +++ b/README.md @@ -235,17 +235,23 @@ $s.Save() Windows portable build: ```bash -# 1. Install PyInstaller (inside your venv) -pip install pyinstaller +# Preferred: run the build script +# It installs requirements, verifies `hidapi`, and packages the app +build.bat -# 2. Build using the included spec file -pyinstaller Mouser.spec --noconfirm +# For packaging/debugging issues, force a clean rebuild +build.bat --clean -# — or simply run the build script — -build.bat +# Manual path: install build/runtime dependencies first +pip install -r requirements.txt pyinstaller + +# Then build using the included spec file +pyinstaller Mouser.spec --noconfirm ``` -The output is in `dist\Mouser\`. Zip that entire folder and distribute it. +The output is in `dist\Mouser\`. Zip that entire folder and distribute it. `build.bat` +fails early if `hidapi` is not importable, which avoids producing a packaged app that +cannot detect Logitech devices. macOS native bundle: diff --git a/README_CN.md b/README_CN.md index 1b62eb1..f5d4f42 100644 --- a/README_CN.md +++ b/README_CN.md @@ -244,17 +244,23 @@ $s.Save() #### Windows 便携版构建 ```bash -# 1. 安装 PyInstaller(在 venv 内) -pip install pyinstaller +# 推荐:直接运行构建脚本 +# 它会安装依赖、校验 `hidapi`,然后再打包 +build.bat -# 2. 使用 spec 文件构建 -pyinstaller Mouser.spec --noconfirm +# 如果在排查打包问题,建议强制完整重建 +build.bat --clean -# 或直接运行脚本: -build.bat +# 手动方式:先安装构建和运行依赖 +pip install -r requirements.txt pyinstaller + +# 然后使用 spec 文件构建 +pyinstaller Mouser.spec --noconfirm ``` -输出目录为 `dist\Mouser\`,将整个目录打包 zip 即可分发。 +输出目录为 `dist\Mouser\`,将整个目录打包 zip 即可分发。`build.bat` +会在打包前先检查 `hidapi` 是否可导入,避免生成一个无法检测 Logitech +设备的安装包。 #### macOS 原生 App Bundle 构建 diff --git a/build.bat b/build.bat index 6088aca..9209b4e 100644 --- a/build.bat +++ b/build.bat @@ -24,11 +24,23 @@ if exist ".venv\Scripts\activate.bat" ( echo [!] No .venv found — using system Python ) -:: ── 2. Ensure PyInstaller is installed ─────────────────────── -pip show pyinstaller >nul 2>&1 +:: ── 2. Install and verify build dependencies ───────────────── +echo [*] Installing requirements... +python -m pip install -r requirements.txt if %errorlevel% neq 0 ( - echo [*] Installing PyInstaller... - pip install pyinstaller + echo. + echo [ERROR] Failed to install requirements. + pause + exit /b 1 +) + +echo [*] Verifying hidapi import... +python -c "import hid; print('[*] hidapi:', hid.__file__)" +if %errorlevel% neq 0 ( + echo. + echo [ERROR] hidapi is not importable. The packaged app would not detect Logitech devices. + pause + exit /b 1 ) :: ── 3. Clean previous build ────────────────────────────────── diff --git a/core/hid_gesture.py b/core/hid_gesture.py index 948e4e3..c3de4f5 100644 --- a/core/hid_gesture.py +++ b/core/hid_gesture.py @@ -26,11 +26,13 @@ try: import hid as _hid HIDAPI_OK = True + HIDAPI_IMPORT_ERROR = None # On macOS, allow non-exclusive HID access so the mouse keeps working if sys.platform == "darwin" and hasattr(_hid, "hid_darwin_set_open_exclusive"): _hid.hid_darwin_set_open_exclusive(0) -except ImportError: +except Exception as exc: HIDAPI_OK = False + HIDAPI_IMPORT_ERROR = exc # Support both "pip install hidapi" (hid.device) and "pip install hid" (hid.Device) _HID_API_STYLE = None @@ -617,7 +619,8 @@ def __init__(self, on_down=None, on_up=None, on_move=None, def start(self): if not HIDAPI_OK and not _MAC_NATIVE_OK: - print("[HidGesture] no HID backend available; install hidapi") + details = f": {HIDAPI_IMPORT_ERROR!r}" if HIDAPI_IMPORT_ERROR else "" + print(f"[HidGesture] no HID backend available; install hidapi{details}") return False if not HIDAPI_OK and _MAC_NATIVE_OK: print("[HidGesture] hidapi unavailable; using native macOS HID backend only") diff --git a/main_qml.py b/main_qml.py index fe081c5..b8c605a 100644 --- a/main_qml.py +++ b/main_qml.py @@ -17,11 +17,25 @@ import time from urllib.parse import parse_qs, unquote -# Ensure project root on path — works for both normal Python and PyInstaller -if getattr(sys, "frozen", False): - ROOT = getattr(sys, "_MEIPASS", os.path.dirname(sys.executable)) -else: - ROOT = os.path.dirname(os.path.abspath(__file__)) +# Ensure project root on path — works for both normal Python and PyInstaller. +# PyInstaller on Windows/Linux stores bundled data in `_internal/` next to the +# executable, while macOS app bundles expose resources from `Contents/Resources`. +def _resolve_root_dir(): + if not getattr(sys, "frozen", False): + return os.path.dirname(os.path.abspath(__file__)) + if sys.platform == "darwin": + resources_dir = os.path.abspath( + os.path.join(os.path.dirname(sys.executable), "..", "Resources") + ) + return getattr(sys, "_MEIPASS", resources_dir) + return getattr( + sys, + "_MEIPASS", + os.path.join(os.path.dirname(sys.executable), "_internal"), + ) + + +ROOT = _resolve_root_dir() sys.path.insert(0, ROOT) from core.log_setup import setup_logging @@ -426,7 +440,7 @@ def _dump_threads(sig, frame): _t7 = _time.perf_counter() # ── QML Backend ──────────────────────────────────────────── - backend = Backend(engine) + backend = Backend(engine, root_dir=ROOT) ui_state.appearanceMode = backend.appearanceMode backend.settingsChanged.connect( lambda: setattr(ui_state, "appearanceMode", backend.appearanceMode) @@ -445,8 +459,6 @@ def _dump_threads(sig, frame): qml_engine.rootContext().setContextProperty("appCommit", APP_COMMIT_DISPLAY) qml_engine.rootContext().setContextProperty( "appLaunchPath", _runtime_launch_path().replace("\\", "/")) - qml_engine.rootContext().setContextProperty( - "applicationDirPath", ROOT.replace("\\", "/")) qml_path = os.path.join(ROOT, "ui", "qml", "Main.qml") qml_engine.load(QUrl.fromLocalFile(qml_path)) diff --git a/tests/test_backend.py b/tests/test_backend.py index fad789b..eaa55a0 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -7,11 +7,12 @@ from core.config import DEFAULT_CONFIG try: - from PySide6.QtCore import QCoreApplication + from PySide6.QtCore import QCoreApplication, QUrl from ui.backend import Backend except ModuleNotFoundError: Backend = None QCoreApplication = None + QUrl = None def _ensure_qapp(): @@ -69,12 +70,12 @@ def set_debug_enabled(self, enabled): @unittest.skipIf(Backend is None, "PySide6 not installed in test environment") class BackendDeviceLayoutTests(unittest.TestCase): - def _make_backend(self, engine=None): + def _make_backend(self, engine=None, root_dir=None): with ( patch("ui.backend.load_config", return_value=copy.deepcopy(DEFAULT_CONFIG)), patch("ui.backend.save_config"), ): - return Backend(engine=engine) + return Backend(engine=engine, root_dir=root_dir) @staticmethod def _fake_create_profile(cfg, name, label=None, copy_from="default", apps=None): @@ -92,6 +93,15 @@ def test_defaults_to_generic_layout_without_connected_device(self): self.assertEqual(backend.effectiveDeviceLayoutKey, "generic_mouse") self.assertFalse(backend.hasInteractiveDeviceLayout) + def test_device_image_source_uses_encoded_file_url(self): + backend = self._make_backend(root_dir="/tmp/Mouser Build") + + expected = QUrl.fromLocalFile( + "/tmp/Mouser Build/images/icons/mouse-simple.svg" + ).toString() + + self.assertEqual(backend.deviceImageSource, expected) + def test_disconnected_override_request_does_not_persist(self): backend = self._make_backend() backend._connected_device_key = "mx_master_3" diff --git a/ui/backend.py b/ui/backend.py index d16f7ad..7a8eaee 100644 --- a/ui/backend.py +++ b/ui/backend.py @@ -8,7 +8,7 @@ import sys import time -from PySide6.QtCore import QMetaObject, QObject, Property, QTimer, Signal, Slot, Qt +from PySide6.QtCore import QMetaObject, QObject, Property, QTimer, Signal, Slot, Qt, QUrl from core.accessibility import is_process_trusted from core.config import ( @@ -71,9 +71,10 @@ class Backend(QObject): _smartShiftReadRequest = Signal() _statusMessageRequest = Signal(str) - def __init__(self, engine=None, parent=None): + def __init__(self, engine=None, parent=None, root_dir=None): super().__init__(parent) self._engine = engine + self._root_dir = root_dir or os.path.dirname(os.path.dirname(os.path.abspath(__file__))) self._cfg = load_config() self._mouse_connected = False self._device_display_name = "Logitech mouse" @@ -373,6 +374,12 @@ def deviceDpiMax(self): def deviceImageAsset(self): return self._device_layout.get("image_asset", "mouse.png") + @Property(str, notify=deviceLayoutChanged) + def deviceImageSource(self): + asset = self._device_layout.get("image_asset", "mouse.png") + path = os.path.join(self._root_dir, "images", asset) + return QUrl.fromLocalFile(os.path.abspath(path)).toString() + @Property(int, notify=deviceLayoutChanged) def deviceImageWidth(self): return int(self._device_layout.get("image_width", 460)) diff --git a/ui/qml/MousePage.qml b/ui/qml/MousePage.qml index 8364d97..edae281 100644 --- a/ui/qml/MousePage.qml +++ b/ui/qml/MousePage.qml @@ -982,7 +982,7 @@ Item { Image { id: mouseImg - source: "file:///" + applicationDirPath + "/images/" + backend.deviceImageAsset + source: backend.deviceImageSource fillMode: Image.PreserveAspectFit width: backend.deviceImageWidth height: backend.deviceImageHeight