From 97d9a0d3d09db482d87a6be658df4b189bcb8e69 Mon Sep 17 00:00:00 2001 From: maelstr0m <96124638+mlstr0m@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:22:08 +0200 Subject: [PATCH] Menubar polish: Start at Login, packets/s rate, single-source version - "Start at Login" toggle via SMAppService (macOS 13+); enabled only when running from a bundled .app (py2app sys.frozen), disabled in dev mode, errors surfaced as an alert; new pyobjc-framework-ServiceManagement dep - Detail line now shows a live packets/s rate next to the counter - APP_VERSION in Switch2Bridge.py is the single source of truth: read by setup_app.py (regex, no import side effects) and build_dmg.sh (sed); version shown at the bottom of the menu Co-Authored-By: Claude Fable 5 --- README.md | 1 + Switch2Bridge.py | 66 +++++++++++++++++++++++++++++++++++++++++++++++- build_dmg.sh | 2 +- requirements.txt | 3 ++- setup_app.py | 13 +++++++--- 5 files changed, 79 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ca5856e..ec72efb 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ A Python menubar app that connects to the Switch 2 Pro Controller via BLE and tr - ✅ **Auto-reconnect** — if the controller sleeps or drops, the bridge retries for 60 s - ✅ **C button** — the Switch 2's new C button can be mapped (experimental) - ✅ **DSU server (cemuhook)** — true **analog sticks** in Dolphin, Cemu & other DSU clients, no driver needed +- ✅ **Start at Login** — one click in the menubar (bundled .app, macOS 13+) ## 🤔 Why This Exists diff --git a/Switch2Bridge.py b/Switch2Bridge.py index 0a0f49c..97ef197 100644 --- a/Switch2Bridge.py +++ b/Switch2Bridge.py @@ -54,12 +54,19 @@ from dsu_server import DSUServer +SMAppService = None +try: + from ServiceManagement import SMAppService +except ImportError: + pass # "Start at Login" simply won't be offered + # ============================================================ # CONSTANTS # ============================================================ APP_NAME = "Switch2 Bridge" +APP_VERSION = "1.2.0" # single source of truth — read by setup_app.py & build_dmg.sh INPUT_CHAR_UUID = "7492866c-ec3e-4619-8258-32755ffcc0f9" # Bluetooth SIG company identifier for Nintendo @@ -671,7 +678,9 @@ def __init__(self): self._reveal_item = rumps.MenuItem("Edit mappings file…", callback=self._reveal_mappings) self._reload_item = rumps.MenuItem("Reload mappings", callback=self._reload_mappings) self._dsu_item = rumps.MenuItem("DSU server", callback=self._toggle_dsu) + self._login_item = rumps.MenuItem("Start at Login", callback=self._toggle_login) self._logs_item = rumps.MenuItem("Open logs…", callback=self._open_logs) + self._version_item = rumps.MenuItem(f"{APP_NAME} v{APP_VERSION}") self._quit_item = rumps.MenuItem("Quit", callback=self._on_quit) self.menu = [ @@ -685,11 +694,17 @@ def __init__(self): self._reload_item, None, self._dsu_item, + self._login_item, self._logs_item, None, + self._version_item, self._quit_item, ] self._sync_dsu() + self._sync_login_item() + # packets/s: sampled by the UI tick + self._rate_prev_count = 0 + self._rate_prev_time = time.monotonic() self._last_state = None self._accessibility_checked = False @@ -782,7 +797,12 @@ def _tick(self, _): self._apply_state(state) elif state == 'connected': # in-place title update — no menu rebuild - detail = f" {self.bridge.packet_count} packets" + count = self.bridge.packet_count + now = time.monotonic() + elapsed = now - self._rate_prev_time + rate = max(0.0, (count - self._rate_prev_count) / elapsed) if elapsed > 0 else 0.0 + self._rate_prev_count, self._rate_prev_time = count, now + detail = f" {count} pkts · {rate:.0f}/s" if self.dsu.running: n = self.dsu.client_count() if n: @@ -868,6 +888,50 @@ def _sync_dsu(self): self._dsu_item.title = "DSU server" self._dsu_item.state = 0 + def _login_service(self): + """SMAppService for the main app, or None when unavailable. + + Registration only works from a bundled .app (py2app sets sys.frozen), + and needs the pyobjc ServiceManagement framework (macOS 13+). + """ + if SMAppService is None or not getattr(sys, "frozen", False): + return None + try: + return SMAppService.mainAppService() + except Exception: + log.exception("SMAppService unavailable") + return None + + def _sync_login_item(self): + svc = self._login_service() + if svc is None: + self._login_item.set_callback(None) # disabled outside a bundled .app + self._login_item.state = 0 + return + self._login_item.set_callback(self._toggle_login) + self._login_item.state = 1 if svc.status() == 1 else 0 # 1 = enabled + + def _toggle_login(self, _): + svc = self._login_service() + if svc is None: + return + try: + if svc.status() == 1: + res = svc.unregisterAndReturnError_(None) + else: + res = svc.registerAndReturnError_(None) + ok, err = res if isinstance(res, tuple) else (res, None) + if not ok: + raise RuntimeError(err) + except Exception as e: + log.exception("login item toggle failed") + rumps.alert( + title=APP_NAME, + message=f"Could not update the login item: {e}", + ok="OK", + ) + self._sync_login_item() + def _open_logs(self, _): try: subprocess.Popen(["open", str(LOG_DIR)]) diff --git a/build_dmg.sh b/build_dmg.sh index 27763be..1b69795 100755 --- a/build_dmg.sh +++ b/build_dmg.sh @@ -7,7 +7,7 @@ set -e APP_NAME="Switch2 Bridge" DMG_NAME="Switch2Bridge-Installer" -VERSION="1.1.0" +VERSION=$(sed -n 's/^APP_VERSION = "\(.*\)".*/\1/p' Switch2Bridge.py) echo "" echo "╔═══════════════════════════════════════════════════════════╗" diff --git a/requirements.txt b/requirements.txt index 9828b9e..365f879 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ # Switch2 Bridge - Dependencies bleak>=0.21.0 # BLE connection -pynput>=1.7.6 # Keyboard simulation +pynput>=1.7.6 # Keyboard simulation rumps>=0.4.0 # Menubar app py2app>=0.28.0 # Build .app +pyobjc-framework-ServiceManagement>=9.0 # "Start at Login" (SMAppService) diff --git a/setup_app.py b/setup_app.py index 34a84b5..68e65fa 100644 --- a/setup_app.py +++ b/setup_app.py @@ -5,11 +5,18 @@ python setup_app.py py2app """ +import re + from setuptools import setup APP = ['Switch2Bridge.py'] ICON = 'AppIcon.icns' +# Single source of truth for the version (avoids importing the app module, +# which has import-time side effects) +with open('Switch2Bridge.py') as f: + VERSION = re.search(r'^APP_VERSION = "(.+)"', f.read(), re.M).group(1) + OPTIONS = { 'argv_emulation': False, 'iconfile': ICON, @@ -17,8 +24,8 @@ 'CFBundleName': 'Switch2 Bridge', 'CFBundleDisplayName': 'Switch2 Bridge', 'CFBundleIdentifier': 'com.aureliendesert.switch2bridge', - 'CFBundleVersion': '1.2.0', - 'CFBundleShortVersionString': '1.2.0', + 'CFBundleVersion': VERSION, + 'CFBundleShortVersionString': VERSION, 'CFBundleIconFile': 'AppIcon', 'LSMinimumSystemVersion': '13.0', 'LSUIElement': True, # Menubar only, no dock icon @@ -31,7 +38,7 @@ }, 'packages': ['bleak', 'pynput', 'rumps', 'objc'], 'includes': ['Foundation', 'AppKit', 'CoreBluetooth', 'ApplicationServices', - 'dsu_server'], + 'ServiceManagement', 'dsu_server'], } setup(