From 50f395656025f4d8c7802fd8af267b8b1615e60c Mon Sep 17 00:00:00 2001 From: maelstr0m <96124638+mlstr0m@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:09:29 +0200 Subject: [PATCH] Fix run-from-source Bluetooth permission trap + 30s search window (v1.2.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reddit user report: only the Accessibility prompt ever appeared, scans always came back empty, and they were watching System Settings → Bluetooth for the controller (where it will never show up). - Detect CBManager.authorization() == denied/restricted when clicking Connect: explain that the permission belongs to Terminal/Python when running from source, with a button opening the Bluetooth privacy pane - Initial search now keeps scanning for 30 s (rounds of 5 s) instead of a single window — no more racing the pairing-mode advertising - Scan failures map to actionable messages (unauthorized → permission steps, powered off → enable Bluetooth) instead of raw bleak errors - "Not found" error and README now state explicitly that the controller never appears in System Settings → Bluetooth, and that the Bluetooth prompt fires on first scan (Terminal owns it in run-from-source) - Tests: scan retry round, error message mapping (52 bridge checks) Co-Authored-By: Claude Fable 5 --- README.md | 12 ++++--- Switch2Bridge.py | 85 +++++++++++++++++++++++++++++++++++++------- tests/test_bridge.py | 26 +++++++++++++- 3 files changed, 104 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index ec72efb..108a49b 100644 --- a/README.md +++ b/README.md @@ -63,11 +63,11 @@ pip install -r requirements.txt python Switch2Bridge.py ``` -On first launch, macOS will prompt for: -1. **Bluetooth** — to connect to the controller -2. **Accessibility** — to simulate keyboard input +macOS will ask for two permissions: +1. **Accessibility** — prompted at first launch (to simulate keyboard input) +2. **Bluetooth** — prompted the first time you click **Connect Controller** (not at launch!) -Grant both in `System Settings → Privacy & Security`. +⚠️ **When running from source, the Bluetooth permission belongs to _Terminal_ (or your Python interpreter), not to the app.** If no prompt ever appears, add/enable Terminal manually in `System Settings → Privacy & Security → Bluetooth`, then relaunch. The app detects a denied permission and offers to open the right settings pane. ## 📦 Build a standalone .app + DMG @@ -188,7 +188,9 @@ switch2bridge-macos/ ## 🩺 Troubleshooting -- **"Controller not found"** — make sure the Switch 2 Pro Controller is on and not paired with a console. Hold the small pair button on the back until the LEDs cycle, then click **Connect Controller** again. +- **The controller never appears in System Settings → Bluetooth** — that's **expected**, and not a failure. This bridge is a BLE client: there is no system-level pairing, so macOS will never list the controller. The only place to watch is the app's menubar icon (🔍 → 🟢). +- **"Controller not found"** — make sure the controller is **not paired with a console nearby** (unpair it or put the console to sleep far away). Click **Connect Controller** *first* — the search now runs for 30 s — *then* hold the small pair button on the back until the LEDs sweep back and forth. +- **No Bluetooth prompt ever appeared (run-from-source)** — the permission belongs to Terminal/Python, not the app. Check `System Settings → Privacy & Security → Bluetooth` and enable Terminal, then relaunch. Without it, scans silently find nothing. - **Menubar says 🟢 connected but inputs don't reach the emulator** — macOS Accessibility permission is missing. Grant it in *System Settings → Privacy & Security → Accessibility*, then relaunch the app. (The app should also pop an alert about this on first launch.) - **Logs** — written to `~/Library/Logs/Switch2Bridge/bridge.log`. Open a terminal and `tail -f` it to watch what's happening in real time. diff --git a/Switch2Bridge.py b/Switch2Bridge.py index 97ef197..7d4fd79 100644 --- a/Switch2Bridge.py +++ b/Switch2Bridge.py @@ -66,7 +66,7 @@ # ============================================================ APP_NAME = "Switch2 Bridge" -APP_VERSION = "1.2.0" # single source of truth — read by setup_app.py & build_dmg.sh +APP_VERSION = "1.2.1" # 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 @@ -76,6 +76,9 @@ SCAN_TIMEOUT = 5.0 CONNECT_TIMEOUT = 15.0 +# Keep scanning this long on the first search — pairing-mode advertising is +# easy to miss with a single short window +INITIAL_SCAN_WINDOW = 30.0 # After an unexpected drop, keep trying to reconnect for this long RECONNECT_WINDOW = 60.0 @@ -540,9 +543,23 @@ async def _session(self, address, name, reconnected=False): except Exception as e: log.warning("BLE disconnect error: %s", e) + @staticmethod + def _scan_error_message(exc): + """Turn a bleak scan failure into an actionable message.""" + text = str(exc).lower() + if "unauthorized" in text or "not authorized" in text or "denied" in text: + return ( + "macOS refused Bluetooth access. Grant it in System Settings → " + "Privacy & Security → Bluetooth.\nWhen running from source, the " + "permission belongs to Terminal (or your Python), not the app." + ) + if "turned off" in text or "powered off" in text: + return "Bluetooth is turned off. Enable it in Control Center and retry." + return f"Bluetooth scan failed: {exc}" + async def _connect_async(self): was_connected = False - deadline = None + deadline = time.monotonic() + INITIAL_SCAN_WINDOW try: while not self._stop_event.is_set(): self.is_searching = True @@ -551,16 +568,15 @@ async def _connect_async(self): address, name = await self._find_controller() except Exception as e: log.exception("BLE scan failed") - self.last_error = f"Bluetooth scan failed: {e}" + self.last_error = self._scan_error_message(e) return - finally: - self.is_searching = False if self._stop_event.is_set(): return streamed = False if address: + self.is_searching = False streamed = await self._session( address, name, reconnected=was_connected ) @@ -578,22 +594,28 @@ async def _connect_async(self): log.info("connection dropped, entering reconnect loop") continue - if not was_connected: - # First attempt failed outright — surface and stop. - if not address: - self.last_error = ( - "Controller not found. Make sure it's on and in pairing mode." - ) + if address and not was_connected: + # Found it but couldn't connect — surface and stop. return - if deadline is not None and time.monotonic() > deadline: - self.last_error = "Could not reconnect to the controller." + if time.monotonic() > deadline: + self.last_error = ( + "Could not reconnect to the controller." + if was_connected else + "Controller not found after 30 s. Hold the small pair " + "button on the back until the LEDs sweep, while the " + "search is running.\nNote: the controller will never " + "appear in System Settings → Bluetooth — watch the " + "menubar instead." + ) return + # keep is_searching set during the pause so the UI doesn't flicker await asyncio.sleep(1.0) except asyncio.CancelledError: log.info("bridge task cancelled") finally: + self.is_searching = False self.is_reconnecting = False # --- public API --- @@ -814,6 +836,8 @@ def _tick(self, _): def _on_action(self, _): state = self._current_state() if state == 'idle': + if not self._check_bluetooth(): + return self.bridge.connect() elif state != 'stopping': self.bridge.disconnect() @@ -973,6 +997,41 @@ def _check_accessibility(self): "?Privacy_Accessibility", ]) + def _check_bluetooth(self): + """Return False (and explain) when Bluetooth permission is denied. + + CBManagerAuthorization: 0 not determined (prompt will appear on first + scan), 1 restricted, 2 denied, 3 allowed. + """ + try: + from CoreBluetooth import CBManager # ships with bleak's backend + auth = int(CBManager.authorization()) + except Exception: + return True # can't check — let the scan surface any error + if auth in (1, 2): + log.warning("Bluetooth permission denied (auth=%s)", auth) + clicked_ok = rumps.alert( + title="Bluetooth Permission Required", + message=( + f"macOS is blocking Bluetooth access for {APP_NAME}.\n\n" + "Grant it in:\n" + "System Settings → Privacy & Security → Bluetooth\n\n" + "⚠️ When running from source, the permission belongs to " + "Terminal (or your Python interpreter) — enable that entry, " + "then relaunch." + ), + ok="Open System Settings", + cancel="Later", + ) + if clicked_ok == 1: + subprocess.Popen([ + "open", + "x-apple.systempreferences:com.apple.preference.security" + "?Privacy_Bluetooth", + ]) + return False + return True + def _surface_mappings_messages(self): if self.mappings.last_error: err = self.mappings.last_error diff --git a/tests/test_bridge.py b/tests/test_bridge.py index 4840f02..f21cb2b 100644 --- a/tests/test_bridge.py +++ b/tests/test_bridge.py @@ -168,14 +168,18 @@ def packet(b2=0, b3=0, b4=0, lx=2048, ly=2048, rx=2048, ry=2048): class MockScanner: delay = 0.3 result = {} + queue = [] # optional per-round results, popped first @staticmethod async def discover(timeout=None, return_adv=False): await asyncio.sleep(MockScanner.delay) + if MockScanner.queue: + return MockScanner.queue.pop(0) return MockScanner.result S2B.BleakScanner = MockScanner -# not found path +# not found path (shrink the 30 s initial window for the test) +S2B.INITIAL_SCAN_WINDOW = 0.0 br3 = S2B.ControllerBridge(mm) br3.connect() br3._thread.join(3) @@ -251,6 +255,26 @@ class Dev: check("no error after user disconnect", br5.last_error is None, br5.last_error) check("client disconnected", not MockClient.instances[-1].is_connected) +# initial search keeps scanning: nothing on round 1, found on round 2 +print("== initial scan retry ==") +S2B.INITIAL_SCAN_WINDOW = 10.0 +MockScanner.queue = [{}] +br6 = S2B.ControllerBridge(mm) +br6.connect() +time.sleep(2.0) # scan (0.05) + 1 s pause + scan + connect +check("found on second scan round", br6.is_connected) +check("no error during retry", br6.last_error is None, br6.last_error) +br6.disconnect(wait=True, timeout=3.0) + +# scan error -> actionable messages +print("== scan error messages ==") +msg = S2B.ControllerBridge._scan_error_message(Exception("CBCentralManager is not authorized")) +check("unauthorized -> permission hint", "Privacy & Security" in msg and "Terminal" in msg, msg) +msg = S2B.ControllerBridge._scan_error_message(Exception("Bluetooth device is turned off")) +check("powered off -> enable hint", "turned off" in msg, msg) +msg = S2B.ControllerBridge._scan_error_message(Exception("boom")) +check("generic passthrough", "boom" in msg, msg) + print() if FAILURES: print(f"FAILED: {len(FAILURES)} -> {FAILURES}")