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
2 changes: 1 addition & 1 deletion DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ pip install pyinstaller
pyinstaller Mouser-linux.spec --noconfirm
```

Output: `dist/Mouser/`. The release pipeline additionally bundles `69-mouser-logitech.rules` and `install-linux-permissions.sh`, runs `ldd` on the resulting binary to flag missing libraries, and performs an offscreen smoke test (`QT_QPA_PLATFORM=offscreen`).
Output: `dist/Mouser/`. The release pipeline additionally bundles the Linux permission helper files and hicolor app-icon ladder, runs `ldd` on the resulting binary to flag missing libraries, and performs an offscreen smoke test (`QT_QPA_PLATFORM=offscreen`).

## Desktop shortcut (Windows)

Expand Down
4 changes: 4 additions & 0 deletions Mouser-linux.spec
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ a = Analysis(
os.path.join(ROOT, "packaging", "linux", "io.github.tombadash.mouser.desktop.in"),
"linux",
),
(
os.path.join(ROOT, "packaging", "linux", "icons"),
os.path.join("linux", "icons"),
),
(BUILD_INFO_DATA, "."),
],
hiddenimports=[
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ The first normal Linux launch creates or refreshes:
~/.local/share/applications/io.github.tombadash.mouser.desktop
```

The generated launcher uses absolute paths for the current portable app or source checkout. If you move the checkout, launch Mouser once from the new path to refresh the app-menu entry. Enabling **Start at login** also manages:
The generated launcher uses absolute paths for the current portable app or source checkout, and syncs Mouser's app icon into the per-user hicolor icon theme when possible. If you move the checkout, launch Mouser once from the new path to refresh the app-menu entry. Enabling **Start at login** also manages:

```text
~/.config/autostart/io.github.tombadash.mouser.desktop
Expand Down
118 changes: 117 additions & 1 deletion core/startup.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import os
import plistlib
import shutil
import subprocess
import sys
import tempfile
Expand All @@ -15,9 +16,13 @@
MACOS_PLIST_NAME = f"{MACOS_LAUNCH_AGENT_LABEL}.plist"

# Linux
LINUX_APP_ID = "io.github.tombadash.mouser"
LINUX_DESKTOP_ENTRY_NAME = "io.github.tombadash.mouser.desktop"
LINUX_DESKTOP_TEMPLATE_NAME = f"{LINUX_DESKTOP_ENTRY_NAME}.in"
LINUX_AUTOSTART_DELAY_SECONDS = 15
LINUX_ICON_NAME = LINUX_APP_ID
LINUX_ICON_FILENAME = f"{LINUX_ICON_NAME}.png"
LINUX_ICON_SIZES = (16, 24, 32, 48, 64, 128, 256, 512)
APP_DISPLAY_NAME = "Mouser"


Expand Down Expand Up @@ -147,6 +152,117 @@ def _linux_icon_path() -> str:
return os.path.join(_repo_root_dir(), "images", "logo_icon.png")


def _linux_icon_theme_source_root() -> str:
candidates = []
if getattr(sys, "frozen", False):
bundle_root = getattr(sys, "_MEIPASS", "")
if bundle_root:
candidates.append(os.path.join(bundle_root, "linux", "icons", "hicolor"))
candidates.append(
os.path.join(_runtime_root_dir(), "linux", "icons", "hicolor")
)
candidates.append(
os.path.join(_repo_root_dir(), "packaging", "linux", "icons", "hicolor")
)
for candidate in candidates:
if os.path.isdir(candidate):
return candidate
return ""


def _linux_user_icon_theme_root() -> str:
xdg_data_home = os.environ.get("XDG_DATA_HOME", "").strip()
data_home = (
os.path.expanduser(xdg_data_home)
if xdg_data_home
else os.path.expanduser(os.path.join("~", ".local", "share"))
)
return os.path.join(data_home, "icons", "hicolor")


def _linux_icon_source_path(source_root: str, size: int) -> str:
return os.path.join(
source_root,
f"{size}x{size}",
"apps",
LINUX_ICON_FILENAME,
)


def _linux_icon_destination_path(destination_root: str, size: int) -> str:
return os.path.join(
destination_root,
f"{size}x{size}",
"apps",
LINUX_ICON_FILENAME,
)


def _refresh_linux_icon_theme_cache(destination_root: str) -> None:
tool = shutil.which("gtk-update-icon-cache")
if not tool:
return
try:
subprocess.run(
[tool, "-qtf", destination_root],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except OSError:
pass


def _sync_linux_icon_theme() -> bool:
source_root = _linux_icon_theme_source_root()
if not source_root:
return False
sources = [
_linux_icon_source_path(source_root, size)
for size in LINUX_ICON_SIZES
]
if not all(os.path.isfile(source) for source in sources):
return False
destination_root = _linux_user_icon_theme_root()
try:
for size, source in zip(LINUX_ICON_SIZES, sources):
destination = _linux_icon_destination_path(destination_root, size)
os.makedirs(os.path.dirname(destination), exist_ok=True)
shutil.copyfile(source, destination)
try:
os.chmod(destination, 0o644)
except OSError:
pass
except OSError as exc:
print(f"[startup] failed to sync Linux icon theme: {exc}")
return False
_refresh_linux_icon_theme_cache(destination_root)
return True


def _linux_icon_name_or_path() -> str:
if _sync_linux_icon_theme():
return LINUX_ICON_NAME
return _linux_icon_path()


def sync_linux_icon_theme() -> bool:
"""Best-effort sync of Mouser's hicolor icons into the user's icon theme."""
return _sync_linux_icon_theme()


def linux_runtime_icon_path(preferred_size: int = 256) -> str:
"""Return the best Linux runtime icon asset for window/tray surfaces."""
source_root = _linux_icon_theme_source_root()
if source_root:
sizes = sorted(LINUX_ICON_SIZES, key=lambda size: abs(size - preferred_size))
for size in sizes:
candidate = _linux_icon_source_path(source_root, size)
if os.path.isfile(candidate):
return candidate
return _linux_icon_path()


def _linux_source_path() -> str:
if getattr(sys, "frozen", False):
return os.path.abspath(sys.executable)
Expand Down Expand Up @@ -177,7 +293,7 @@ def _render_linux_desktop_entry(*, autostart: bool) -> str:
"@EXEC@": _desktop_exec_string(exec_parts),
"@TRY_EXEC@": exec_parts[0],
"@WORKDIR@": _runtime_root_dir(),
"@ICON@": _linux_icon_path(),
"@ICON@": _linux_icon_name_or_path(),
"@SOURCE_PATH@": _linux_source_path(),
"@AUTOSTART_LINES@": autostart_lines,
}
Expand Down
Binary file modified images/AppIcon.icns
Binary file not shown.
Binary file modified images/logo.ico
Binary file not shown.
Binary file modified images/logo_icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
64 changes: 61 additions & 3 deletions main_qml.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def _resolve_root_dir():

_t1 = _time.perf_counter()
from PySide6.QtWidgets import QApplication, QSystemTrayIcon, QMenu, QFileIconProvider, QMessageBox
from PySide6.QtGui import QAction, QColor, QIcon, QPainter, QPixmap, QWindow
from PySide6.QtGui import QAction, QColor, QGuiApplication, QIcon, QPainter, QPixmap, QWindow
from PySide6.QtCore import QObject, Property, QCoreApplication, QRectF, Qt, QUrl, Signal, QFileInfo, QEvent, QTimer
from PySide6.QtQml import QQmlApplicationEngine
from PySide6.QtQuick import QQuickImageProvider
Expand All @@ -66,6 +66,7 @@ def _resolve_root_dir():
from core.engine import Engine
from core.hid_gesture import set_backend_preference as set_hid_backend_preference
from core.accessibility import is_process_trusted
from core.startup import linux_runtime_icon_path, sync_linux_icon_theme
from core.version import APP_BUILD_MODE, APP_COMMIT_DISPLAY, APP_VERSION
from ui.backend import Backend
from ui.locale_manager import LocaleManager
Expand All @@ -78,6 +79,10 @@ def _print_startup_times():
print(f"[Startup] Total imports: {(_t4-_t0)*1000:7.1f} ms")


LINUX_DESKTOP_FILE_BASENAME = "io.github.tombadash.mouser"
WINDOWS_APP_USER_MODEL_ID = "TomBadash.Mouser"


def _parse_cli_args(argv):
qt_argv = [argv[0]]
hid_backend = None
Expand Down Expand Up @@ -168,8 +173,14 @@ def _app_icon() -> QIcon:
pixmap path produced. Logs and returns an empty QIcon if the asset
file is missing.
"""
icon_name = "logo_icon.png" if sys.platform == "darwin" else "logo.ico"
icon_path = os.path.join(ROOT, "images", icon_name)
if sys.platform == "linux":
icon_path = linux_runtime_icon_path()
elif sys.platform == "win32":
icon_name = "logo.ico"
icon_path = os.path.join(ROOT, "images", icon_name)
else:
icon_name = "logo_icon.png"
icon_path = os.path.join(ROOT, "images", icon_name)
if not os.path.isfile(icon_path):
print(f"[Mouser] App icon missing: {icon_path}")
return QIcon()
Expand Down Expand Up @@ -218,6 +229,35 @@ def _tray_icon() -> QIcon:
return icon


def _configure_windows_app_user_model_id() -> None:
if sys.platform != "win32":
return
try:
import ctypes
from ctypes import wintypes

set_app_id = ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID
set_app_id.argtypes = [wintypes.LPCWSTR]
set_app_id.restype = getattr(wintypes, "HRESULT", ctypes.c_long)
result = int(set_app_id(WINDOWS_APP_USER_MODEL_ID))
if result != 0:
print(
"[Mouser] Failed to set Windows AppUserModelID: "
f"0x{result & 0xFFFFFFFF:08X}"
)
except Exception as exc:
print(f"[Mouser] Failed to set Windows AppUserModelID: {exc}")


def _configure_linux_desktop_file_name(app: QGuiApplication) -> None:
if sys.platform != "linux":
return
try:
app.setDesktopFileName(LINUX_DESKTOP_FILE_BASENAME)
except Exception as exc:
print(f"[Mouser] Failed to set Linux desktop file name: {exc}")


_MACOS_RELAUNCH_GUARD = "MOUSER_MACOS_RELAUNCHED"


Expand Down Expand Up @@ -595,6 +635,16 @@ def _install_macos_dock_icon():
print(f"[Mouser] Failed to apply macOS Dock icon: {exc}")


def _schedule_macos_dock_icon_refresh() -> None:
if sys.platform != "darwin":
return
try:
QTimer.singleShot(0, _install_macos_dock_icon)
QTimer.singleShot(250, _install_macos_dock_icon)
except Exception:
_install_macos_dock_icon()


def _set_macos_activation_policy(regular: bool) -> None:
"""Toggle between the Regular (foreground, Dock + Cmd+Tab) and
Accessory (menu-bar only) policies. On a Regular promotion AppKit
Expand All @@ -608,6 +658,8 @@ def _set_macos_activation_policy(regular: bool) -> None:
if sys.platform != "darwin":
return
if _MACOS_ACTIVATION_POLICY_REGULAR == regular:
if regular:
_schedule_macos_dock_icon_refresh()
return
appkit = _macos_appkit()
if appkit is None:
Expand All @@ -624,6 +676,7 @@ def _set_macos_activation_policy(regular: bool) -> None:
_MACOS_ACTIVATION_POLICY_REGULAR = regular
if regular:
_install_macos_dock_icon()
_schedule_macos_dock_icon_refresh()


def _activate_macos_window():
Expand Down Expand Up @@ -965,12 +1018,16 @@ def main():
# surfaces that read from `[NSBundle mainBundle]` (application menu
# first item, Force Quit, notification banners) say "Mouser" too.
_rename_macos_bundle_for_dock()
_configure_windows_app_user_model_id()

QCoreApplication.setAttribute(Qt.ApplicationAttribute.AA_ShareOpenGLContexts)
app = QApplication(argv)
app.setApplicationName("Mouser")
app.setApplicationVersion(APP_VERSION)
app.setOrganizationName("Mouser")
_configure_linux_desktop_file_name(app)
if sys.platform == "linux":
sync_linux_icon_theme()
app.setWindowIcon(_app_icon())
app.setQuitOnLastWindowClosed(False)
_configure_macos_app_mode()
Expand Down Expand Up @@ -1088,6 +1145,7 @@ def show_main_window():
root_window.showNormal()
root_window.raise_()
root_window.requestActivate()
_schedule_macos_dock_icon_refresh()
_activate_macos_window()

def _on_window_visibility_changed(visibility):
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading