Skip to content

Commit e7fc678

Browse files
committed
feat: roll out app icon identity across platforms
1 parent d7aa358 commit e7fc678

17 files changed

Lines changed: 396 additions & 19 deletions

DEVELOPMENT.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,7 @@ pip install pyinstaller
319319
pyinstaller Mouser-linux.spec --noconfirm
320320
```
321321

322-
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`).
322+
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`).
323323

324324
## Desktop shortcut (Windows)
325325

Mouser-linux.spec

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@ a = Analysis(
102102
os.path.join(ROOT, "packaging", "linux", "io.github.tombadash.mouser.desktop.in"),
103103
"linux",
104104
),
105+
(
106+
os.path.join(ROOT, "packaging", "linux", "icons"),
107+
os.path.join("linux", "icons"),
108+
),
105109
(BUILD_INFO_DATA, "."),
106110
],
107111
hiddenimports=[

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ The first normal Linux launch creates or refreshes:
292292
~/.local/share/applications/io.github.tombadash.mouser.desktop
293293
```
294294

295-
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:
295+
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:
296296

297297
```text
298298
~/.config/autostart/io.github.tombadash.mouser.desktop

core/startup.py

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import os
44
import plistlib
5+
import shutil
56
import subprocess
67
import sys
78
import tempfile
@@ -15,9 +16,13 @@
1516
MACOS_PLIST_NAME = f"{MACOS_LAUNCH_AGENT_LABEL}.plist"
1617

1718
# Linux
19+
LINUX_APP_ID = "io.github.tombadash.mouser"
1820
LINUX_DESKTOP_ENTRY_NAME = "io.github.tombadash.mouser.desktop"
1921
LINUX_DESKTOP_TEMPLATE_NAME = f"{LINUX_DESKTOP_ENTRY_NAME}.in"
2022
LINUX_AUTOSTART_DELAY_SECONDS = 15
23+
LINUX_ICON_NAME = LINUX_APP_ID
24+
LINUX_ICON_FILENAME = f"{LINUX_ICON_NAME}.png"
25+
LINUX_ICON_SIZES = (16, 24, 32, 48, 64, 128, 256, 512)
2126
APP_DISPLAY_NAME = "Mouser"
2227

2328

@@ -147,6 +152,84 @@ def _linux_icon_path() -> str:
147152
return os.path.join(_repo_root_dir(), "images", "logo_icon.png")
148153

149154

155+
def _linux_icon_theme_source_root() -> str:
156+
candidates = []
157+
if getattr(sys, "frozen", False):
158+
bundle_root = getattr(sys, "_MEIPASS", "")
159+
if bundle_root:
160+
candidates.append(os.path.join(bundle_root, "linux", "icons", "hicolor"))
161+
candidates.append(
162+
os.path.join(_runtime_root_dir(), "linux", "icons", "hicolor")
163+
)
164+
candidates.append(
165+
os.path.join(_repo_root_dir(), "packaging", "linux", "icons", "hicolor")
166+
)
167+
for candidate in candidates:
168+
if os.path.isdir(candidate):
169+
return candidate
170+
return ""
171+
172+
173+
def _linux_user_icon_theme_root() -> str:
174+
xdg_data_home = os.environ.get("XDG_DATA_HOME", "").strip()
175+
data_home = (
176+
os.path.expanduser(xdg_data_home)
177+
if xdg_data_home
178+
else os.path.expanduser(os.path.join("~", ".local", "share"))
179+
)
180+
return os.path.join(data_home, "icons", "hicolor")
181+
182+
183+
def _linux_icon_source_path(source_root: str, size: int) -> str:
184+
return os.path.join(
185+
source_root,
186+
f"{size}x{size}",
187+
"apps",
188+
LINUX_ICON_FILENAME,
189+
)
190+
191+
192+
def _linux_icon_destination_path(destination_root: str, size: int) -> str:
193+
return os.path.join(
194+
destination_root,
195+
f"{size}x{size}",
196+
"apps",
197+
LINUX_ICON_FILENAME,
198+
)
199+
200+
201+
def _sync_linux_icon_theme() -> bool:
202+
source_root = _linux_icon_theme_source_root()
203+
if not source_root:
204+
return False
205+
sources = [
206+
_linux_icon_source_path(source_root, size)
207+
for size in LINUX_ICON_SIZES
208+
]
209+
if not all(os.path.isfile(source) for source in sources):
210+
return False
211+
destination_root = _linux_user_icon_theme_root()
212+
try:
213+
for size, source in zip(LINUX_ICON_SIZES, sources):
214+
destination = _linux_icon_destination_path(destination_root, size)
215+
os.makedirs(os.path.dirname(destination), exist_ok=True)
216+
shutil.copyfile(source, destination)
217+
try:
218+
os.chmod(destination, 0o644)
219+
except OSError:
220+
pass
221+
except OSError as exc:
222+
print(f"[startup] failed to sync Linux icon theme: {exc}")
223+
return False
224+
return True
225+
226+
227+
def _linux_icon_name_or_path() -> str:
228+
if _sync_linux_icon_theme():
229+
return LINUX_ICON_NAME
230+
return _linux_icon_path()
231+
232+
150233
def _linux_source_path() -> str:
151234
if getattr(sys, "frozen", False):
152235
return os.path.abspath(sys.executable)
@@ -177,7 +260,7 @@ def _render_linux_desktop_entry(*, autostart: bool) -> str:
177260
"@EXEC@": _desktop_exec_string(exec_parts),
178261
"@TRY_EXEC@": exec_parts[0],
179262
"@WORKDIR@": _runtime_root_dir(),
180-
"@ICON@": _linux_icon_path(),
263+
"@ICON@": _linux_icon_name_or_path(),
181264
"@SOURCE_PATH@": _linux_source_path(),
182265
"@AUTOSTART_LINES@": autostart_lines,
183266
}

main_qml.py

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ def _resolve_root_dir():
4747

4848
_t1 = _time.perf_counter()
4949
from PySide6.QtWidgets import QApplication, QSystemTrayIcon, QMenu, QFileIconProvider, QMessageBox
50-
from PySide6.QtGui import QAction, QColor, QIcon, QPainter, QPixmap, QWindow
50+
from PySide6.QtGui import QAction, QColor, QGuiApplication, QIcon, QPainter, QPixmap, QWindow
5151
from PySide6.QtCore import QObject, Property, QCoreApplication, QRectF, Qt, QUrl, Signal, QFileInfo, QEvent, QTimer
5252
from PySide6.QtQml import QQmlApplicationEngine
5353
from PySide6.QtQuick import QQuickImageProvider
@@ -78,6 +78,10 @@ def _print_startup_times():
7878
print(f"[Startup] Total imports: {(_t4-_t0)*1000:7.1f} ms")
7979

8080

81+
LINUX_DESKTOP_FILE_BASENAME = "io.github.tombadash.mouser"
82+
WINDOWS_APP_USER_MODEL_ID = "TomBadash.Mouser"
83+
84+
8185
def _parse_cli_args(argv):
8286
qt_argv = [argv[0]]
8387
hid_backend = None
@@ -168,7 +172,10 @@ def _app_icon() -> QIcon:
168172
pixmap path produced. Logs and returns an empty QIcon if the asset
169173
file is missing.
170174
"""
171-
icon_name = "logo_icon.png" if sys.platform == "darwin" else "logo.ico"
175+
if sys.platform == "win32":
176+
icon_name = "logo.ico"
177+
else:
178+
icon_name = "logo_icon.png"
172179
icon_path = os.path.join(ROOT, "images", icon_name)
173180
if not os.path.isfile(icon_path):
174181
print(f"[Mouser] App icon missing: {icon_path}")
@@ -218,6 +225,35 @@ def _tray_icon() -> QIcon:
218225
return icon
219226

220227

228+
def _configure_windows_app_user_model_id() -> None:
229+
if sys.platform != "win32":
230+
return
231+
try:
232+
import ctypes
233+
from ctypes import wintypes
234+
235+
set_app_id = ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID
236+
set_app_id.argtypes = [wintypes.LPCWSTR]
237+
set_app_id.restype = getattr(wintypes, "HRESULT", ctypes.c_long)
238+
result = int(set_app_id(WINDOWS_APP_USER_MODEL_ID))
239+
if result != 0:
240+
print(
241+
"[Mouser] Failed to set Windows AppUserModelID: "
242+
f"0x{result & 0xFFFFFFFF:08X}"
243+
)
244+
except Exception as exc:
245+
print(f"[Mouser] Failed to set Windows AppUserModelID: {exc}")
246+
247+
248+
def _configure_linux_desktop_file_name(app: QGuiApplication) -> None:
249+
if sys.platform != "linux":
250+
return
251+
try:
252+
app.setDesktopFileName(LINUX_DESKTOP_FILE_BASENAME)
253+
except Exception as exc:
254+
print(f"[Mouser] Failed to set Linux desktop file name: {exc}")
255+
256+
221257
_MACOS_RELAUNCH_GUARD = "MOUSER_MACOS_RELAUNCHED"
222258

223259

@@ -595,6 +631,16 @@ def _install_macos_dock_icon():
595631
print(f"[Mouser] Failed to apply macOS Dock icon: {exc}")
596632

597633

634+
def _schedule_macos_dock_icon_refresh() -> None:
635+
if sys.platform != "darwin":
636+
return
637+
try:
638+
QTimer.singleShot(0, _install_macos_dock_icon)
639+
QTimer.singleShot(250, _install_macos_dock_icon)
640+
except Exception:
641+
_install_macos_dock_icon()
642+
643+
598644
def _set_macos_activation_policy(regular: bool) -> None:
599645
"""Toggle between the Regular (foreground, Dock + Cmd+Tab) and
600646
Accessory (menu-bar only) policies. On a Regular promotion AppKit
@@ -608,6 +654,8 @@ def _set_macos_activation_policy(regular: bool) -> None:
608654
if sys.platform != "darwin":
609655
return
610656
if _MACOS_ACTIVATION_POLICY_REGULAR == regular:
657+
if regular:
658+
_schedule_macos_dock_icon_refresh()
611659
return
612660
appkit = _macos_appkit()
613661
if appkit is None:
@@ -624,6 +672,7 @@ def _set_macos_activation_policy(regular: bool) -> None:
624672
_MACOS_ACTIVATION_POLICY_REGULAR = regular
625673
if regular:
626674
_install_macos_dock_icon()
675+
_schedule_macos_dock_icon_refresh()
627676

628677

629678
def _activate_macos_window():
@@ -965,12 +1014,14 @@ def main():
9651014
# surfaces that read from `[NSBundle mainBundle]` (application menu
9661015
# first item, Force Quit, notification banners) say "Mouser" too.
9671016
_rename_macos_bundle_for_dock()
1017+
_configure_windows_app_user_model_id()
9681018

9691019
QCoreApplication.setAttribute(Qt.ApplicationAttribute.AA_ShareOpenGLContexts)
9701020
app = QApplication(argv)
9711021
app.setApplicationName("Mouser")
9721022
app.setApplicationVersion(APP_VERSION)
9731023
app.setOrganizationName("Mouser")
1024+
_configure_linux_desktop_file_name(app)
9741025
app.setWindowIcon(_app_icon())
9751026
app.setQuitOnLastWindowClosed(False)
9761027
_configure_macos_app_mode()
@@ -1088,6 +1139,7 @@ def show_main_window():
10881139
root_window.showNormal()
10891140
root_window.raise_()
10901141
root_window.requestActivate()
1142+
_schedule_macos_dock_icon_refresh()
10911143
_activate_macos_window()
10921144

10931145
def _on_window_visibility_changed(visibility):
6.04 KB
Loading
646 Bytes
Loading
1.1 KB
Loading
11.8 KB
Loading
1.41 KB
Loading

0 commit comments

Comments
 (0)