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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
66 changes: 65 additions & 1 deletion Switch2Bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = [
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)])
Expand Down
2 changes: 1 addition & 1 deletion build_dmg.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 "╔═══════════════════════════════════════════════════════════╗"
Expand Down
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -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)
13 changes: 10 additions & 3 deletions setup_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,27 @@
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,
'plist': {
'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
Expand All @@ -31,7 +38,7 @@
},
'packages': ['bleak', 'pynput', 'rumps', 'objc'],
'includes': ['Foundation', 'AppKit', 'CoreBluetooth', 'ApplicationServices',
'dsu_server'],
'ServiceManagement', 'dsu_server'],
}

setup(
Expand Down
Loading