diff --git a/main_qml.py b/main_qml.py index 7d8a969..e8f81ed 100644 --- a/main_qml.py +++ b/main_qml.py @@ -1,5 +1,5 @@ """ -Mouser — QML Entry Point +Mouser -- QML Entry Point ============================== Launches the Qt Quick / QML UI with PySide6. Replaces the old tkinter-based main.py. @@ -17,7 +17,7 @@ import time from urllib.parse import parse_qs, unquote -# Ensure project root on path — works for both normal Python and PyInstaller. +# 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(): @@ -47,8 +47,8 @@ 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 -from PySide6.QtCore import QObject, Property, QCoreApplication, QRectF, Qt, QUrl, Signal, QFileInfo +from PySide6.QtGui import QAction, QColor, 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 from PySide6.QtSvg import QSvgRenderer @@ -161,21 +161,19 @@ def _single_instance_acquire(app: QApplication, server_name: str): def _app_icon() -> QIcon: - if sys.platform == "darwin": - icon = QIcon() - source = QPixmap(os.path.join(ROOT, "images", "logo_icon.png")) - if not source.isNull(): - for size in (16, 32, 64, 128, 256): - icon.addPixmap( - source.scaled( - size, - size, - Qt.AspectRatioMode.KeepAspectRatio, - Qt.TransformationMode.SmoothTransformation, - ) - ) - return icon - return QIcon(os.path.join(ROOT, "images", "logo.ico")) + """Build the QIcon for the window title bar. On macOS, hand QIcon the + full-resolution 1024px PNG so AppKit's setApplicationIconImage_ + (called via QApplication.setWindowIcon) renders crisply at the full + Dock tile size instead of the upscaled-256px blur the pre-scaled + 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 not os.path.isfile(icon_path): + print(f"[Mouser] App icon missing: {icon_path}") + return QIcon() + return QIcon(icon_path) def _render_svg_pixmap(path: str, color: QColor, size: int) -> QPixmap: @@ -220,16 +218,412 @@ def _tray_icon() -> QIcon: return icon -def _configure_macos_app_mode(): +_MACOS_RELAUNCH_GUARD = "MOUSER_MACOS_RELAUNCHED" + + +def _macos_named_executable_path() -> str: + """Return a stable path for the `Mouser`-named launcher symlink. + + When ``sys.executable`` is in a virtualenv, place the symlink next to + the venv's python shim so `pyvenv.cfg` discovery still resolves + site-packages after the re-exec. Otherwise fall back to a path inside + the project tree so it stays stable across reboots. + """ + exec_dir = os.path.dirname(sys.executable) + pyvenv_cfg = os.path.join(os.path.dirname(exec_dir), "pyvenv.cfg") + if os.path.isfile(pyvenv_cfg): + return os.path.join(exec_dir, "Mouser") + return os.path.join(ROOT, "build", "macos", "bin", "Mouser") + + +def _maybe_relaunch_with_mouser_process_name() -> None: + """Re-exec the interpreter through a `Mouser`-named symlink. + + macOS reads the user-visible process name from the Mach-O image + header at execve() time. For a bundle-less launch (``python + main_qml.py``) that means the Dock tile, Cmd+Tab caption, Force + Quit, and Activity Monitor all read "python", and there is no + in-process API to rename the image afterwards. Re-execing through + a symlink whose basename is `Mouser` is the only reliable fix. + + Returns immediately on non-macOS, on PyInstaller-frozen bundles + (already correctly named), when the env-var guard shows we already + relaunched, when the basename already starts with "mouser", or + when the symlink can't be staged. + """ + if sys.platform != "darwin": + return + if getattr(sys, "frozen", False): + return + if os.environ.get(_MACOS_RELAUNCH_GUARD) == "1": + return + source_executable = sys.executable + if not source_executable or not os.path.isfile(source_executable): + print("[Mouser] sys.executable missing or not a file; skipping relaunch") + return + # Important: link the venv shim (`sys.executable`), NOT the underlying + # interpreter (`os.path.realpath(sys.executable)`). The shim is what + # holds the venv's identity; the real interpreter has no venv context. + current_basename = os.path.basename(source_executable) + if current_basename.lower().startswith("mouser"): + return + target = _macos_named_executable_path() + target_dir = os.path.dirname(target) + # Stage atomically via a unique temp symlink + os.replace(). This + # avoids the unlink/symlink TOCTOU window where a concurrent launch + # could observe `target` missing, and never leaves a moment when the + # launcher path doesn't resolve to a usable executable. + staging = f"{target}.staging.{os.getpid()}" + try: + os.makedirs(target_dir, exist_ok=True) + try: + os.symlink(source_executable, staging) + except FileExistsError: + # Crashed prior run left a staging symlink behind. Clear it + # and retry once; any further failure falls through to the + # outer except and the in-place fallback. + os.remove(staging) + os.symlink(source_executable, staging) + try: + os.replace(staging, target) + except OSError: + # Best-effort cleanup of our staging entry so we don't leak + # one per failed relaunch attempt. + try: + os.remove(staging) + except OSError: + pass + raise + except OSError as exc: + print(f"[Mouser] Could not stage Mouser-named launcher: {exc}") + return + os.environ[_MACOS_RELAUNCH_GUARD] = "1" + new_argv = [target, *sys.argv] + print( + f"[Mouser] Re-execing through {target} so the Dock shows 'Mouser' " + f"instead of '{current_basename}'" + ) + try: + os.execv(target, new_argv) + except OSError as exc: + # If exec fails for any reason, fall back to in-place launch so + # the user still gets a working app, just with the wrong label. + print(f"[Mouser] Re-exec failed: {exc}; continuing with current process") + os.environ.pop(_MACOS_RELAUNCH_GUARD, None) + + +def _rename_macos_bundle_for_dock(): + """Override CFBundleName / CFBundleDisplayName before NSApplication + is constructed. AppKit reads `[NSBundle mainBundle]` once during init + to populate the application menu, Force Quit, notification banners, + etc. The Dock label itself is still driven by the relaunch above. + """ if sys.platform != "darwin": return + try: + from Foundation import NSBundle + bundle = NSBundle.mainBundle() + info = bundle.localizedInfoDictionary() or bundle.infoDictionary() + if info is None: + return + info["CFBundleName"] = "Mouser" + info["CFBundleDisplayName"] = "Mouser" + info.setdefault("CFBundleExecutable", "Mouser") + except Exception as exc: + print(f"[Mouser] Could not pre-rename bundle for Dock: {exc}") + + +# Cached AppKit module + Dock-icon NSImage + last-applied activation policy. +# These are populated only after the corresponding AppKit calls succeed so +# a failure path doesn't leave the cached state out of sync with reality. +# Caching matters because `visibilityChanged` can fire repeatedly under +# rapid window state churn (minimize/restore storms, Spaces switches), and +# without a cache each fire would re-decode the 1024px PNG and re-issue +# AppKit calls that are no-ops for the Dock anyway. +_MACOS_APPKIT = None +_MACOS_DOCK_ICON_NSIMAGE = None +_MACOS_ACTIVATION_POLICY_REGULAR: "bool | None" = None +_MACOS_NATIVE_STATUS_ITEM = None +_MACOS_NATIVE_STATUS_TARGET = None +_MACOS_QUIT_FILTER = None +_MACOS_SYSTEM_QUIT_REASONS = { + "quia", # kAEQuitAll + "shut", # kAEShutDown + "rest", # kAERestart + "rlgo", # kAEReallyLogOut + "logo", # kAELogOut + "rrst", # kAEShowRestartDialog + "rsdn", # kAEShowShutdownDialog +} + + +try: + from Foundation import NSObject as _MacOSNSObject # type: ignore[import-not-found] +except Exception: # pragma: no cover - Foundation is only available on macOS + _MacOSNSObject = None + + +def _call_objc_value(obj, name, default=None): + try: + value = getattr(obj, name) + except Exception: + return default + try: + return value() if callable(value) else value + except Exception: + return default + + +def _int_const(module, *names, default=0): + for name in names: + value = getattr(module, name, None) + if value is not None: + try: + return int(value) + except (TypeError, ValueError): + return value + return default + + +def _four_char_code(value: str) -> int: + if len(value) != 4: + raise ValueError("four-character codes must be exactly 4 characters") + return int.from_bytes(value.encode("mac_roman"), "big") + + +def _descriptor_code_value(descriptor): + if descriptor is None: + return None + for attr_name in ("enumCodeValue", "typeCodeValue", "int32Value"): + value = _call_objc_value(descriptor, attr_name) + if value is None: + continue + try: + return int(value) + except (TypeError, ValueError): + continue + return None + + +def _macos_current_quit_is_system_session_event() -> bool: + """Return True for logout/restart/shutdown AppleEvent quit reasons.""" + if sys.platform != "darwin": + return False + appkit = _macos_appkit() + if appkit is None: + return False + try: + manager = appkit.NSAppleEventManager.sharedAppleEventManager() + apple_event = manager.currentAppleEvent() + if apple_event is None: + return False + reason = apple_event.attributeDescriptorForKeyword_( + _four_char_code("why?") + ) + except Exception: + return False + reason_code = _descriptor_code_value(reason) + if reason_code is None: + return False + return reason_code in { + _four_char_code(value) for value in _MACOS_SYSTEM_QUIT_REASONS + } + + +def _macos_status_event_opens_menu(event, appkit) -> bool: + """Return True when an NSStatusItem click should open the tray menu.""" + if event is None or appkit is None: + return False + event_type = _call_objc_value(event, "type") + try: + event_type = int(event_type) + except (TypeError, ValueError): + pass + + menu_event_types = { + _int_const(appkit, "NSRightMouseDown", "NSEventTypeRightMouseDown", default=None), + _int_const(appkit, "NSOtherMouseDown", "NSEventTypeOtherMouseDown", default=None), + } + menu_event_types.discard(None) + if event_type in menu_event_types: + return True + + modifiers = _call_objc_value(event, "modifierFlags", 0) or 0 + try: + modifiers = int(modifiers) + except (TypeError, ValueError): + modifiers = 0 + menu_modifiers = ( + _int_const(appkit, "NSControlKeyMask", "NSEventModifierFlagControl") + | _int_const(appkit, "NSAlternateKeyMask", "NSEventModifierFlagOption") + ) + return bool(modifiers & menu_modifiers) + + +def _dispatch_macos_status_item_click(handlers): + appkit = handlers.get("appkit") + event = None + try: + event = appkit.NSApp.currentEvent() + except Exception: + pass + key = "menu" if _macos_status_event_opens_menu(event, appkit) else "primary" + handler = handlers.get(key) or handlers.get("primary") + if handler is not None: + handler() + + +if _MacOSNSObject is not None: + class _MacOSStatusItemTarget(_MacOSNSObject): + """Objective-C target that forwards NSStatusItem clicks to Python.""" + + def setPyHandlers_(self, handlers): # type: ignore[override] + self._py_handlers = handlers + + def statusItemClicked_(self, sender): # type: ignore[override] + try: + _dispatch_macos_status_item_click(getattr(self, "_py_handlers", {})) + except Exception as exc: # noqa: BLE001 + print(f"[Mouser] status-item click handler raised: {exc}") +else: + _MacOSStatusItemTarget = None + + +class _MacOSQuitToTrayFilter(QObject): + """Intercept app-level quit requests and hide the window instead.""" + + def __init__(self, root_window, parent=None): + super().__init__(parent) + self._root_window = root_window + self._allow_quit = False + + def allow_quit(self) -> None: + self._allow_quit = True + + def eventFilter(self, watched, event): # noqa: N802 - Qt override + if self._allow_quit: + return False + try: + if event.type() != QEvent.Type.Quit: + return False + if _macos_current_quit_is_system_session_event(): + self.allow_quit() + return False + self._root_window.hide() + if hasattr(event, "ignore"): + event.ignore() + return True + except Exception as exc: # noqa: BLE001 + print(f"[Mouser] Failed to hide on macOS quit event: {exc}") + return False + + +def _allow_macos_session_quit_if_requested(quit_filter) -> bool: + """Allow quit only when the current macOS event is a session shutdown.""" + if quit_filter is None: + return False + if not _macos_current_quit_is_system_session_event(): + return False + quit_filter.allow_quit() + return True + + +def _macos_appkit(): + """Lazy-import + cache of the AppKit module. Returns None on import + failure (logged once) so callers can no-op cleanly.""" + global _MACOS_APPKIT + if _MACOS_APPKIT is not None: + return _MACOS_APPKIT try: import AppKit - AppKit.NSApp.setActivationPolicy_( - AppKit.NSApplicationActivationPolicyAccessory + except Exception as exc: + print(f"[Mouser] Failed to import AppKit: {exc}") + return None + _MACOS_APPKIT = AppKit + return AppKit + + +def _configure_macos_app_mode(): + """Initial activation policy at launch time. Stays Accessory (menu-bar + only) until the window opens, at which point we promote to Regular so + Mouser becomes a real Cmd+Tab-able foreground app.""" + _set_macos_activation_policy(regular=False) + + +def _install_macos_dock_icon(): + """Replace the Dock / Cmd+Tab / Mission Control icon with Mouser's + logo. Qt's ``app.setWindowIcon()`` only covers the title bar on + macOS, so without this override a bare ``python main_qml.py`` shows + the generic Python launcher icon. The decoded NSImage is cached at + module scope so repeated calls only re-issue the cheap + ``setApplicationIconImage_`` syscall. + """ + global _MACOS_DOCK_ICON_NSIMAGE + if sys.platform != "darwin": + return + appkit = _macos_appkit() + if appkit is None: + return + if _MACOS_DOCK_ICON_NSIMAGE is None: + icon_path = os.path.join(ROOT, "images", "logo_icon.png") + if not os.path.isfile(icon_path): + print(f"[Mouser] Could not load Dock icon from {icon_path}") + return + try: + ns_image = appkit.NSImage.alloc().initWithContentsOfFile_(icon_path) + except Exception as exc: + print(f"[Mouser] Failed to decode Dock icon {icon_path}: {exc}") + return + if ns_image is None: + print(f"[Mouser] Could not load Dock icon from {icon_path}") + return + # NSImage may flag the image as "template" (auto-tinted to the + # system colors, which strips our gradient and renders the + # silhouette in monochrome black/white). Force-disable template + # mode so the full-color PNG comes through. + if hasattr(ns_image, "setTemplate_"): + ns_image.setTemplate_(False) + size = ns_image.size() + print( + f"[Mouser] Dock icon loaded {icon_path} " + f"size={size.width:.0f}x{size.height:.0f}" ) + _MACOS_DOCK_ICON_NSIMAGE = ns_image + try: + appkit.NSApp.setApplicationIconImage_(_MACOS_DOCK_ICON_NSIMAGE) except Exception as exc: - print(f"[Mouser] Failed to configure macOS app mode: {exc}") + print(f"[Mouser] Failed to apply macOS Dock icon: {exc}") + + +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 + creates the Dock tile lazily and seeds the icon from the running + executable's bundle, so this also re-applies the Mouser Dock icon + after the flip. Skips the AppKit round-trip when the requested + state already matches the last-applied one, which keeps rapid + ``visibilityChanged`` storms cheap. + """ + global _MACOS_ACTIVATION_POLICY_REGULAR + if sys.platform != "darwin": + return + if _MACOS_ACTIVATION_POLICY_REGULAR == regular: + return + appkit = _macos_appkit() + if appkit is None: + return + try: + policy = ( + appkit.NSApplicationActivationPolicyRegular if regular + else appkit.NSApplicationActivationPolicyAccessory + ) + appkit.NSApp.setActivationPolicy_(policy) + except Exception as exc: + print(f"[Mouser] Failed to set macOS activation policy: {exc}") + return + _MACOS_ACTIVATION_POLICY_REGULAR = regular + if regular: + _install_macos_dock_icon() def _activate_macos_window(): @@ -242,6 +636,132 @@ def _activate_macos_window(): print(f"[Mouser] Failed to activate macOS window: {exc}") +def _install_native_macos_status_item(qmenu, on_left_click): + """Install a native AppKit ``NSStatusItem`` for the menu-bar. + + Qt's ``QSystemTrayIcon`` uses a fixed square ``NSStatusItem`` on + macOS. On notched MacBooks, constrained menu-bar space can hide + status items in ways Apple does not expose through a reliable API. + Creating the item directly with ``NSVariableStatusItemLength`` keeps + the icon as narrow as its content and avoids Qt's Cocoa wrapper path. + + The existing Qt ``QMenu`` remains the single source for localized + labels and action wiring: plain left click shows the window, while + right-click, control-click, and option-click pop up the menu. + + Returns the retained ``NSStatusItem`` on success, ``None`` on + any failure -- callers should fall back to ``QSystemTrayIcon``. + """ + global _MACOS_NATIVE_STATUS_ITEM, _MACOS_NATIVE_STATUS_TARGET + if sys.platform != "darwin": + return None + appkit = _macos_appkit() + if appkit is None: + return None + if _MacOSStatusItemTarget is None: + print("[Mouser] Foundation.NSObject unavailable; using Qt tray icon") + return None + try: + from PySide6.QtGui import QCursor + from PySide6.QtCore import QPoint + except Exception as exc: + print(f"[Mouser] Native status-item bootstrap failed: {exc}") + return None + + icon_svg = os.path.join(ROOT, "images", "icons", "mouse-simple.svg") + if not os.path.isfile(icon_svg): + print(f"[Mouser] mouse-simple.svg not found at {icon_svg}") + return None + + # Render the SVG into a 22 px square NSImage. 22 is the macOS- + # idiomatic menu-bar height (matches Apple's own SF Symbols). + # Drawing at 2x and letting AppKit downsample preserves crisp + # edges on both retina and non-retina displays. + icon_png = _render_svg_pixmap(icon_svg, _qcolor_white(), 22) + if icon_png.isNull(): + print("[Mouser] could not render mouse-simple.svg for status item") + return None + icon_bytes = _qpixmap_to_png_bytes(icon_png) + ns_image = appkit.NSImage.alloc().initWithData_(icon_bytes) + if ns_image is None or ns_image.isValid() is False: + print("[Mouser] NSImage failed to decode status-item PNG") + return None + ns_image.setTemplate_(True) + ns_image.setSize_(appkit.NSMakeSize(22, 22)) + + status_bar = appkit.NSStatusBar.systemStatusBar() + # NSVariableStatusItemLength == -1.0; lets AppKit auto-position + # the item right-of-notch alongside every other modern status app. + status_item = status_bar.statusItemWithLength_(-1.0) + button = status_item.button() + if button is None: + print("[Mouser] NSStatusItem has no button; bailing") + status_bar.removeStatusItem_(status_item) + return None + button.setImage_(ns_image) + button.setToolTip_("Mouser") + + # Attach the existing QMenu as the right-click / control-click + # menu via a tiny NSMenu shim that pops the Qt menu at the + # status-item's screen position. Qt's QMenu carries all the + # localised labels, action wiring, and live-update bindings the + # rest of the app already depends on, so we don't duplicate it + # into a parallel NSMenu. + def _open_menu_at_cursor(): + try: + cursor_pos = QCursor.pos() + except Exception: # noqa: BLE001 + cursor_pos = QPoint(0, 0) + try: + qmenu.popup(cursor_pos) + except Exception as exc: # noqa: BLE001 + print(f"[Mouser] failed to popup tray menu: {exc}") + + target = _MacOSStatusItemTarget.alloc().init() + target.setPyHandlers_( + {"primary": on_left_click, "menu": _open_menu_at_cursor, "appkit": appkit} + ) + button.setTarget_(target) + button.setAction_(b"statusItemClicked:") + try: + click_mask = ( + _int_const(appkit, "NSEventMaskLeftMouseDown") + | _int_const(appkit, "NSEventMaskRightMouseDown") + | _int_const(appkit, "NSEventMaskOtherMouseDown") + ) + button.sendActionOn_(click_mask) + except Exception as exc: + print(f"[Mouser] Could not configure status-item click mask: {exc}") + status_bar.removeStatusItem_(status_item) + return None + + # Cache the item + target globally so PyObjC doesn't release them + # while the app keeps running. + _MACOS_NATIVE_STATUS_ITEM = status_item + _MACOS_NATIVE_STATUS_TARGET = target + return status_item + + +def _qcolor_white(): + """Cached white QColor used to fill the SVG silhouette for the + template-image rendering path. Module-level cache because the + cached colour is identity-equal across all callers.""" + from PySide6.QtGui import QColor + return QColor("#FFFFFF") + + +def _qpixmap_to_png_bytes(pixmap): + """Serialise a QPixmap to PNG bytes via an in-memory QBuffer so + AppKit's ``NSImage.initWithData_`` can consume it without a + round trip through the filesystem.""" + from PySide6.QtCore import QBuffer, QByteArray, QIODevice + buf = QBuffer() + buf.open(QIODevice.OpenModeFlag.WriteOnly) + pixmap.save(buf, "PNG") + buf.close() + return bytes(buf.data()) + + class UiState(QObject): appearanceModeChanged = Signal() systemAppearanceChanged = Signal() @@ -360,27 +880,28 @@ def requestPixmap(self, icon_id, size, requested_size): def _check_accessibility(locale_mgr: "LocaleManager") -> bool: - """On macOS, check if Accessibility permission is granted. - - Returns True if already trusted, False otherwise. + """Verify the macOS Accessibility grant. Returns True only when + AXIsProcessTrustedWithOptions confirms the grant; any other path + (no grant, exception during the check) returns False so callers + fail closed. """ if sys.platform != "darwin": return True try: trusted = is_process_trusted(prompt=True) - if not trusted: - print("[Mouser] Accessibility permission not granted") - msg = QMessageBox() - msg.setIcon(QMessageBox.Icon.Warning) - msg.setWindowTitle(locale_mgr.tr("accessibility.title")) - msg.setText(locale_mgr.tr("accessibility.text")) - msg.setInformativeText(locale_mgr.tr("accessibility.info")) - msg.setStandardButtons(QMessageBox.StandardButton.Ok) - msg.exec() - return bool(trusted) except Exception as exc: print(f"[Mouser] Accessibility check failed: {exc}") - return True + return False + if not trusted: + print("[Mouser] Accessibility permission not granted") + msg = QMessageBox() + msg.setIcon(QMessageBox.Icon.Warning) + msg.setWindowTitle(locale_mgr.tr("accessibility.title")) + msg.setText(locale_mgr.tr("accessibility.text")) + msg.setInformativeText(locale_mgr.tr("accessibility.info")) + msg.setStandardButtons(QMessageBox.StandardButton.Ok) + msg.exec() + return bool(trusted) def _runtime_launch_path() -> str: @@ -389,7 +910,38 @@ def _runtime_launch_path() -> str: return os.path.abspath(__file__) +def _schedule_engine_start(engine, *, accessibility_granted: bool) -> bool: + if not accessibility_granted: + print("[Mouser] Engine not started -- Accessibility permission is required") + return False + QTimer.singleShot(0, lambda: ( + engine.start(), + print("[Mouser] Engine started -- remapping is active"), + )) + return True + + +def _schedule_tray_minimized_notice(tray, locale_mgr) -> None: + def _tray_minimized_notice(): + tray.showMessage( + "Mouser", + locale_mgr.tr("tray.tray_message"), + QSystemTrayIcon.MessageIcon.Information, + 5000, + ) + + QTimer.singleShot(400, _tray_minimized_notice) + + def main(): + # Re-exec through a `Mouser`-named symlink BEFORE anything Qt or + # AppKit related runs. Necessary because macOS reads the Dock label / + # Cmd+Tab caption from the executable basename at process creation; + # there is no in-process API to rename a Mach-O image after the fact. + # No-op when already relaunched, on non-macOS platforms, or when the + # symlink can't be created. + _maybe_relaunch_with_mouser_process_name() + _print_startup_times() _t5 = _time.perf_counter() argv, hid_backend, start_hidden, force_show = _parse_cli_args(sys.argv) @@ -405,6 +957,11 @@ def main(): except ValueError as exc: raise SystemExit(f"Invalid --hid-backend setting: {exc}") from exc + # Also: also mutate the bundle's display name keys so + # surfaces that read from `[NSBundle mainBundle]` (application menu + # first item, Force Quit, notification banners) say "Mouser" too. + _rename_macos_bundle_for_dock() + QCoreApplication.setAttribute(Qt.ApplicationAttribute.AA_ShareOpenGLContexts) app = QApplication(argv) app.setApplicationName("Mouser") @@ -413,6 +970,7 @@ def main(): app.setWindowIcon(_app_icon()) app.setQuitOnLastWindowClosed(False) _configure_macos_app_mode() + _install_macos_dock_icon() ui_state = UiState(app) print(f"[Mouser] Version: {APP_VERSION} ({APP_BUILD_MODE})") @@ -489,11 +1047,55 @@ def _sync_linux_ui_passthrough(*_args): _sync_linux_ui_passthrough() def show_main_window(): + # Promote BEFORE show so the window registers with WindowServer's + # foreground-app surfaces (Dock + Cmd+Tab + Mission Control) at + # creation time on macOS. visibilityChanged below also catches the + # transition (idempotent), so promotion is correct on the initial + # launch path where this function is never called. + _set_macos_activation_policy(regular=True) root_window.showNormal() root_window.raise_() root_window.requestActivate() _activate_macos_window() + def _on_window_visibility_changed(visibility): + # QWindow.Visibility: Hidden = 0; any other value (Windowed, + # Maximized, FullScreen, Minimized) means there is an on-screen + # window. macOS Cmd+Tab / Mission Control / Dock representation + # depends on the activation policy, which we toggle to mirror + # whether a window is currently shown: + # shown → Regular (real foreground app) + # hidden → Accessory (menu-bar only) + # The QML `onClosing { close.accepted = false; root.hide() }` + # handler in Main.qml turns Cmd+W and the red traffic light into + # `hide()` calls so window state collapses cleanly to Hidden. + # `_set_macos_activation_policy` is idempotent, so the storm of + # visibilityChanged emits during a window state transition + # collapses to at most one AppKit round-trip per direction. + is_visible = visibility != QWindow.Visibility.Hidden + _set_macos_activation_policy(regular=is_visible) + if is_visible: + # Window just became visible -- bring the app forward so the + # user actually sees it (covers initial launch + tray clicks). + _activate_macos_window() + + if sys.platform == "darwin": + root_window.visibilityChanged.connect(_on_window_visibility_changed) + # The window was created visible (QML `visible: !launchHidden`) before + # this handler was connected, so its initial visibility transition has + # already fired with no listener. Reconcile the activation policy now + # so the Dock tile shows on a `--show-window` / non-hidden start. + _on_window_visibility_changed(root_window.visibility()) + global _MACOS_QUIT_FILTER + _MACOS_QUIT_FILTER = _MacOSQuitToTrayFilter(root_window, app) + app.installEventFilter(_MACOS_QUIT_FILTER) + app.commitDataRequest.connect( + lambda *_: _allow_macos_session_quit_if_requested(_MACOS_QUIT_FILTER) + ) + app.saveStateRequest.connect( + lambda *_: _allow_macos_session_quit_if_requested(_MACOS_QUIT_FILTER) + ) + def _on_second_instance_activate(): _drain_local_activate_socket(single_server.nextPendingConnection()) show_main_window() @@ -506,17 +1108,13 @@ def _on_second_instance_activate(): print(f"[Startup] TOTAL to window: {(_t8-_t0)*1000:7.1f} ms") # ── Accessibility check (macOS) ────────────────────────────── - _check_accessibility(locale_mgr) + accessibility_granted = _check_accessibility(locale_mgr) if sys.platform == "linux": engine.set_ui_passthrough(not launch_hidden) # ── Start engine AFTER window is ready (deferred) ────────── - from PySide6.QtCore import QTimer - QTimer.singleShot(0, lambda: ( - engine.start(), - print("[Mouser] Engine started — remapping is active"), - )) + _schedule_engine_start(engine, accessibility_granted=accessibility_granted) # ── System Tray ──────────────────────────────────────────── tray = QSystemTrayIcon(_tray_icon(), app) @@ -573,6 +1171,8 @@ def toggle_debug_mode(): quit_action = QAction(locale_mgr.tr("tray.quit"), tray_menu) def quit_app(): + if _MACOS_QUIT_FILTER is not None: + _MACOS_QUIT_FILTER.allow_quit() engine.stop() tray.hide() app.quit() @@ -620,17 +1220,20 @@ def _save_language(): ) else None) tray.show() - if launch_hidden and QSystemTrayIcon.isSystemTrayAvailable(): - - def _tray_minimized_notice(): - tray.showMessage( - "Mouser", - locale_mgr.tr("tray.tray_message"), - QSystemTrayIcon.MessageIcon.Information, - 5000, - ) + # macOS only: install a native NSStatusItem so the menu-bar icon + # can use AppKit's variable-length item path on notched MacBooks + # where Qt's square status item can disappear under constrained + # menu-bar space. We keep QSystemTrayIcon alive for notifications + # and hide only its icon surface to avoid two menu-bar items. + if sys.platform == "darwin": + native_tray = _install_native_macos_status_item( + tray_menu, show_main_window + ) + if native_tray is not None: + tray.setVisible(False) - QTimer.singleShot(400, _tray_minimized_notice) + if launch_hidden and QSystemTrayIcon.isSystemTrayAvailable(): + _schedule_tray_minimized_notice(tray, locale_mgr) # ── Run ──────────────────────────────────────────────────── try: diff --git a/tests/test_macos_app_shell.py b/tests/test_macos_app_shell.py new file mode 100644 index 0000000..1241de5 --- /dev/null +++ b/tests/test_macos_app_shell.py @@ -0,0 +1,312 @@ +import os +import sys +import unittest +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +try: + import main_qml +except Exception: # pragma: no cover - env without PySide6 / project deps + main_qml = None + + +@unittest.skipIf(main_qml is None, "main_qml / PySide6 not available") +class MacOSLauncherPathTests(unittest.TestCase): + def test_named_executable_uses_venv_shim_directory(self): + executable = "/tmp/Mouser/.venv/bin/python" + + def fake_isfile(path): + return path in {executable, "/tmp/Mouser/.venv/pyvenv.cfg"} + + with ( + patch.object(main_qml.sys, "executable", executable), + patch.object(main_qml.os.path, "isfile", side_effect=fake_isfile), + ): + self.assertEqual( + main_qml._macos_named_executable_path(), + "/tmp/Mouser/.venv/bin/Mouser", + ) + + def test_named_executable_uses_project_fallback_outside_venv(self): + with ( + patch.object(main_qml.sys, "executable", "/usr/bin/python3"), + patch.object(main_qml.os.path, "isfile", return_value=False), + patch.object(main_qml, "ROOT", "/tmp/Mouser"), + ): + self.assertEqual( + main_qml._macos_named_executable_path(), + "/tmp/Mouser/build/macos/bin/Mouser", + ) + + def test_relaunch_noops_off_macos(self): + with ( + patch.object(main_qml.sys, "platform", "linux"), + patch.object(main_qml.os, "execv") as execv, + ): + main_qml._maybe_relaunch_with_mouser_process_name() + execv.assert_not_called() + + def test_relaunch_stages_symlink_and_execs_named_path(self): + executable = "/tmp/Mouser/.venv/bin/python" + target = "/tmp/Mouser/.venv/bin/Mouser" + staging = f"{target}.staging.1234" + + def fake_isfile(path): + return path in {executable, "/tmp/Mouser/.venv/pyvenv.cfg"} + + with ( + patch.object(main_qml.sys, "platform", "darwin"), + patch.object(main_qml.sys, "executable", executable), + patch.object(main_qml.sys, "argv", ["main_qml.py", "--show-window"]), + patch.object(main_qml.os.path, "isfile", side_effect=fake_isfile), + patch.object(main_qml.os, "makedirs") as makedirs, + patch.object(main_qml.os, "symlink") as symlink, + patch.object(main_qml.os, "replace") as replace, + patch.object(main_qml.os, "getpid", return_value=1234), + patch.object(main_qml.os, "execv") as execv, + patch.dict(main_qml.os.environ, {}, clear=True), + ): + main_qml._maybe_relaunch_with_mouser_process_name() + + makedirs.assert_called_once_with("/tmp/Mouser/.venv/bin", exist_ok=True) + symlink.assert_called_once_with(executable, staging) + replace.assert_called_once_with(staging, target) + execv.assert_called_once_with(target, [target, "main_qml.py", "--show-window"]) + + +@unittest.skipIf(main_qml is None, "main_qml / PySide6 not available") +class MacOSStatusItemEventTests(unittest.TestCase): + def _appkit(self, event=None): + return SimpleNamespace( + NSLeftMouseDown=1, + NSRightMouseDown=3, + NSOtherMouseDown=25, + NSControlKeyMask=1 << 18, + NSAlternateKeyMask=1 << 19, + NSApp=SimpleNamespace(currentEvent=lambda: event), + ) + + def _event(self, event_type, modifiers=0): + return SimpleNamespace(type=lambda: event_type, modifierFlags=lambda: modifiers) + + def test_status_event_routes_plain_left_click_to_primary(self): + appkit = self._appkit() + event = self._event(appkit.NSLeftMouseDown) + self.assertFalse(main_qml._macos_status_event_opens_menu(event, appkit)) + + def test_status_event_routes_right_control_and_option_click_to_menu(self): + appkit = self._appkit() + self.assertTrue( + main_qml._macos_status_event_opens_menu( + self._event(appkit.NSRightMouseDown), + appkit, + ) + ) + self.assertTrue( + main_qml._macos_status_event_opens_menu( + self._event(appkit.NSLeftMouseDown, appkit.NSControlKeyMask), + appkit, + ) + ) + self.assertTrue( + main_qml._macos_status_event_opens_menu( + self._event(appkit.NSLeftMouseDown, appkit.NSAlternateKeyMask), + appkit, + ) + ) + + def test_status_click_dispatches_to_selected_handler(self): + calls = [] + menu_event = self._event(3) + appkit = self._appkit(menu_event) + + main_qml._dispatch_macos_status_item_click({ + "appkit": appkit, + "primary": lambda: calls.append("primary"), + "menu": lambda: calls.append("menu"), + }) + + self.assertEqual(calls, ["menu"]) + + +@unittest.skipIf(main_qml is None, "main_qml / PySide6 not available") +class MacOSSystemQuitReasonTests(unittest.TestCase): + def _descriptor(self, value): + return SimpleNamespace(enumCodeValue=lambda: main_qml._four_char_code(value)) + + def _appkit(self, *, descriptor): + apple_event = SimpleNamespace( + attributeDescriptorForKeyword_=lambda keyword: ( + descriptor if keyword == main_qml._four_char_code("why?") else None + ) + ) + manager = SimpleNamespace(currentAppleEvent=lambda: apple_event) + return SimpleNamespace( + NSAppleEventManager=SimpleNamespace( + sharedAppleEventManager=lambda: manager + ) + ) + + def test_system_quit_reason_codes_are_allowed_through(self): + for reason in ("quia", "shut", "rest", "rlgo", "logo", "rrst", "rsdn"): + with self.subTest(reason=reason): + with ( + patch.object(main_qml.sys, "platform", "darwin"), + patch.object( + main_qml, + "_macos_appkit", + return_value=self._appkit(descriptor=self._descriptor(reason)), + ), + ): + self.assertTrue( + main_qml._macos_current_quit_is_system_session_event() + ) + + def test_missing_or_unknown_quit_reason_stays_user_quit(self): + for descriptor in (None, self._descriptor("quit")): + with self.subTest(descriptor=descriptor): + with ( + patch.object(main_qml.sys, "platform", "darwin"), + patch.object( + main_qml, + "_macos_appkit", + return_value=self._appkit(descriptor=descriptor), + ), + ): + self.assertFalse( + main_qml._macos_current_quit_is_system_session_event() + ) + + +@unittest.skipIf(main_qml is None, "main_qml / PySide6 not available") +class MacOSQuitAndAccessibilityTests(unittest.TestCase): + def test_quit_filter_hides_window_and_blocks_app_quit(self): + root_window = MagicMock() + event = MagicMock() + event.type.return_value = main_qml.QEvent.Type.Quit + event_filter = main_qml._MacOSQuitToTrayFilter(root_window) + + self.assertTrue(event_filter.eventFilter(None, event)) + + root_window.hide.assert_called_once() + event.ignore.assert_called_once() + + def test_quit_filter_allows_explicit_tray_quit(self): + root_window = MagicMock() + event = MagicMock() + event.type.return_value = main_qml.QEvent.Type.Quit + event_filter = main_qml._MacOSQuitToTrayFilter(root_window) + + event_filter.allow_quit() + + self.assertFalse(event_filter.eventFilter(None, event)) + root_window.hide.assert_not_called() + event.ignore.assert_not_called() + + def test_quit_filter_allows_system_session_quit(self): + root_window = MagicMock() + event = MagicMock() + event.type.return_value = main_qml.QEvent.Type.Quit + event_filter = main_qml._MacOSQuitToTrayFilter(root_window) + + with patch.object( + main_qml, + "_macos_current_quit_is_system_session_event", + return_value=True, + ): + self.assertFalse(event_filter.eventFilter(None, event)) + + root_window.hide.assert_not_called() + event.ignore.assert_not_called() + + def test_session_quit_fallback_does_not_allow_ordinary_quit(self): + event_filter = main_qml._MacOSQuitToTrayFilter(MagicMock()) + + with patch.object( + main_qml, + "_macos_current_quit_is_system_session_event", + return_value=False, + ): + self.assertFalse( + main_qml._allow_macos_session_quit_if_requested(event_filter) + ) + + event = MagicMock() + event.type.return_value = main_qml.QEvent.Type.Quit + self.assertTrue(event_filter.eventFilter(None, event)) + + def test_session_quit_fallback_allows_system_quit(self): + event_filter = main_qml._MacOSQuitToTrayFilter(MagicMock()) + + with patch.object( + main_qml, + "_macos_current_quit_is_system_session_event", + return_value=True, + ): + self.assertTrue( + main_qml._allow_macos_session_quit_if_requested(event_filter) + ) + + event = MagicMock() + event.type.return_value = main_qml.QEvent.Type.Quit + self.assertFalse(event_filter.eventFilter(None, event)) + + def test_engine_start_not_scheduled_without_accessibility(self): + engine = MagicMock() + + with patch("PySide6.QtCore.QTimer.singleShot") as single_shot: + started = main_qml._schedule_engine_start( + engine, + accessibility_granted=False, + ) + + self.assertFalse(started) + single_shot.assert_not_called() + engine.start.assert_not_called() + + def test_engine_start_schedules_when_accessibility_is_granted(self): + engine = MagicMock() + + with patch("PySide6.QtCore.QTimer.singleShot") as single_shot: + started = main_qml._schedule_engine_start( + engine, + accessibility_granted=True, + ) + + self.assertTrue(started) + delay, callback = single_shot.call_args.args + self.assertEqual(delay, 0) + + callback() + engine.start.assert_called_once() + + def test_accessibility_check_exception_fails_closed(self): + locale_mgr = SimpleNamespace(tr=lambda key: key) + + with ( + patch.object(main_qml.sys, "platform", "darwin"), + patch.object(main_qml, "is_process_trusted", side_effect=RuntimeError("boom")), + ): + self.assertFalse(main_qml._check_accessibility(locale_mgr)) + + def test_tray_minimized_notice_is_scheduled_with_module_qtimer(self): + tray = MagicMock() + locale_mgr = SimpleNamespace(tr=lambda key: f"translated:{key}") + + with patch.object(main_qml.QTimer, "singleShot") as single_shot: + main_qml._schedule_tray_minimized_notice(tray, locale_mgr) + + delay, callback = single_shot.call_args.args + self.assertEqual(delay, 400) + + callback() + tray.showMessage.assert_called_once_with( + "Mouser", + "translated:tray.tray_message", + main_qml.QSystemTrayIcon.MessageIcon.Information, + 5000, + ) + + +if __name__ == "__main__": + unittest.main()