Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
20 changes: 13 additions & 7 deletions README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 构建

Expand Down
20 changes: 16 additions & 4 deletions build.bat
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────
Expand Down
7 changes: 5 additions & 2 deletions core/hid_gesture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
28 changes: 20 additions & 8 deletions main_qml.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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))
Expand Down
16 changes: 13 additions & 3 deletions tests/test_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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):
Expand All @@ -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"
Expand Down
11 changes: 9 additions & 2 deletions ui/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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))
Expand Down
2 changes: 1 addition & 1 deletion ui/qml/MousePage.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading