From 1682804eb2fcd8872a3ea54234c89561063a4018 Mon Sep 17 00:00:00 2001 From: Johnny D Date: Wed, 9 Sep 2026 13:43:46 -0400 Subject: [PATCH 1/6] feat(browser): stage attributed browser bridge for guarded integration Adapted from Andrei-Kondrykau/switch2mac browser-bridge at 24b0cd3d225c77c9efcfca42cb4fd4325e2fccf3 (upstream PR #2). Keep the output sink unregistered until access and lifecycle guards are added. Retain the contributor's source and documentation, with our read-only checks. --- README.md | 35 ++- .../Output/WebSocketHub.swift | 227 ++++++++++++++ browser/README.md | 137 +++++++++ browser/extension/background.js | 69 +++++ browser/extension/bridge.js | 73 +++++ browser/extension/manifest.json | 46 +++ browser/extension/shim.js | 279 ++++++++++++++++++ 7 files changed, 863 insertions(+), 3 deletions(-) create mode 100644 Sources/FinallyTheControllerWorks/Output/WebSocketHub.swift create mode 100644 browser/README.md create mode 100644 browser/extension/background.js create mode 100644 browser/extension/bridge.js create mode 100644 browser/extension/manifest.json create mode 100644 browser/extension/shim.js diff --git a/README.md b/README.md index d085e1d..b3b5762 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,14 @@ which is **currently waiting on Apple's approval**. Until it arrives: that library sees real game controllers — including rumble flowing back to the controller. -Once Apple's approval lands, the SDL step disappears and controllers -will just show up system-wide. +- **To use controllers in a web game** — Xbox Cloud Gaming, GeForce NOW, + Luna — the app also serves controller state on + `ws://127.0.0.1:24810`, and a small browser extension in + [`browser/`](browser/) presents them to the page as standard + gamepads, rumble included. Chromium browsers only. + +Once Apple's approval lands, the SDL and browser steps become optional +and controllers will just show up system-wide. ## Install @@ -57,6 +63,26 @@ Gopher64 is SDL-based, so it works through the bridge today: The same recipe works for any SDL3-based emulator or game — see [`sdl/README.md`](sdl/README.md) for the general one-line launch method. +## Using it with Xbox Cloud Gaming (or any web game) + +Verified on xbox.com/play. Chromium browsers only (Chrome, Edge, Brave, +Arc, Vivaldi, Opera); Safari and Firefox cannot run this bridge. + +1. Get the extension folder: clone this repo, or download the ZIP from + GitHub (**Code → Download ZIP**) and unpack it. You need the + [`browser/extension`](browser/extension) folder. +2. In the browser open `chrome://extensions` (`edge://extensions`, + `brave://extensions`, …), turn on **Developer mode** (top right), + click **Load unpacked** and pick that `browser/extension` folder. +3. Start the menu-bar app and press a button on the controller so it + connects. +4. Open and play: the controller is a + standard gamepad, with rumble. Xbox prompts match the physical + positions (Switch B is where Xbox A is). + +Button table, adding other game sites, troubleshooting and the wire +protocol are in [`browser/README.md`](browser/README.md). + ## Features **Working now, in the beta UI** @@ -78,6 +104,8 @@ The same recipe works for any SDL3-based emulator or game — see - Button remapping per controller - Joy-Con 2 **mouse mode** (the optical sensor, used flat on the desk) - UDP/SDL bridge for games and emulators, with game rumble passthrough +- WebSocket/browser bridge for web games (Xbox Cloud Gaming, GeForce NOW), + with rumble passthrough - Signed auto-updates, first-run tour, settings import/export, live log with BLE gap diagnostics, launch-at-login @@ -117,7 +145,8 @@ Controller ──BLE──> BridgeEngine ──> ControllerSession (per slot) ▼ ControllerOutputSink protocol ├── VirtualHIDSink (CoreHID; entitlement-gated) - └── UDPHub (SDL-compat, ports 24800-24803) + ├── UDPHub (SDL-compat, ports 24800-24803) + └── WebSocketHub (browser extension, ws://127.0.0.1:24810) ``` - `Protocol/Switch2Protocol.swift` — the wire protocol, transport-free. diff --git a/Sources/FinallyTheControllerWorks/Output/WebSocketHub.swift b/Sources/FinallyTheControllerWorks/Output/WebSocketHub.swift new file mode 100644 index 0000000..d5d6c3b --- /dev/null +++ b/Sources/FinallyTheControllerWorks/Output/WebSocketHub.swift @@ -0,0 +1,227 @@ +// WebSocketHub.swift +// Browser sink: re-broadcasts controller state over a local WebSocket so a +// small browser extension (browser/extension) can present the controllers to +// web games through the Gamepad API — Xbox Cloud Gaming, GeForce NOW, Luna, +// gamepad testers — with rumble flowing back. No entitlement, no root, no +// driver: it is the UDP/SDL bridge idea applied to the browser. +// +// Endpoint: ws://127.0.0.1:24810 (loopback only). Messages are JSON text: +// hub → page: +// {"t":"hello","v":1} +// {"t":"connected","slot":0,"model":"Pro Controller 2","name":"…"} +// {"t":"name","slot":0,"name":"…"} +// {"t":"state","slot":0,"seq":123,"b":, +// "lx":…,"ly":…,"rx":…,"ry":…,"lt":0-255,"rt":0-255} (y: +1 = up) +// {"t":"disconnected","slot":0} +// {"t":"ping"} every 15 s (keeps extension service workers alive) +// page → hub: +// {"t":"rumble","slot":0,"strong":0…1,"weak":0…1} +// {"t":"stats",…} extension delivery telemetry, echoed to all clients +// Every new client receives "hello" plus one "connected"/"name" per +// currently connected player, so late joiners (a tab opened after the +// controller paired) see the full picture immediately. + +import Foundation +import Network + +final class WebSocketHub: ControllerOutputSink, @unchecked Sendable { + + static let port: UInt16 = 24810 + + var onRumble: ((Int, Double, Double) -> Void)? + + private let queue = DispatchQueue(label: "com.petersharma.ftcw.wshub") + private var listener: NWListener? + private var clients: [ObjectIdentifier: NWConnection] = [:] + private var connected: [Int: (model: String, name: String)] = [:] + private var seq: [Int: UInt32] = [:] + private var lastState: [Int: (buttons: UInt32, packet: Data)] = [:] + + private var pingTimer: DispatchSourceTimer? + + init() { + queue.async { [weak self] in + self?.startListener() + self?.startPing() + } + } + + /// Chrome unloads an idle extension service worker after ~30 s; a + /// periodic message keeps the bridge's socket owner alive between inputs. + private func startPing() { + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now() + 15, repeating: 15) + timer.setEventHandler { [weak self] in self?.broadcast(#"{"t":"ping"}"#) } + timer.resume() + pingTimer = timer + } + + // MARK: Listener + + private func startListener() { + let params = NWParameters.tcp + params.allowLocalEndpointReuse = true + params.requiredLocalEndpoint = NWEndpoint.hostPort( + host: "127.0.0.1", port: NWEndpoint.Port(rawValue: Self.port)!) + let ws = NWProtocolWebSocket.Options() + ws.autoReplyPing = true + params.defaultProtocolStack.applicationProtocols.insert(ws, at: 0) + + let listener: NWListener + do { + listener = try NWListener(using: params) + } catch { + bridgeLog(.warning, "wshub", "cannot create listener (\(error)) — retrying in 5 s") + queue.asyncAfter(deadline: .now() + 5) { [weak self] in self?.startListener() } + return + } + listener.stateUpdateHandler = { [weak self] state in + guard let self else { return } + switch state { + case .ready: + bridgeLog(.info, "wshub", "browser bridge on ws://127.0.0.1:\(Self.port)") + case .failed(let error): + bridgeLog(.warning, "wshub", "listener failed (\(error)) — retrying in 5 s") + listener.cancel() + self.listener = nil + self.queue.asyncAfter(deadline: .now() + 5) { [weak self] in self?.startListener() } + default: + break + } + } + listener.newConnectionHandler = { [weak self] connection in + self?.accept(connection) + } + self.listener = listener + listener.start(queue: queue) + } + + private func accept(_ connection: NWConnection) { + let id = ObjectIdentifier(connection) + connection.stateUpdateHandler = { [weak self] state in + guard let self else { return } + switch state { + case .ready: + self.clients[id] = connection + bridgeLog(.info, "wshub", "browser client connected (\(self.clients.count) total)") + self.send(#"{"t":"hello","v":1}"#, to: connection) + for (slot, info) in self.connected.sorted(by: { $0.key < $1.key }) { + self.send(Self.connectedMessage(slot: slot, model: info.model, name: info.name), + to: connection) + } + self.receive(on: connection) + case .failed, .cancelled: + if self.clients.removeValue(forKey: id) != nil { + bridgeLog(.info, "wshub", "browser client left (\(self.clients.count) total)") + // A page that disappears mid-rumble should not leave the + // controller buzzing. + for slot in self.connected.keys { self.onRumble?(slot, 0, 0) } + } + default: + break + } + } + connection.start(queue: queue) + } + + private func receive(on connection: NWConnection) { + connection.receiveMessage { [weak self] data, context, _, error in + guard let self else { return } + if let data, !data.isEmpty { self.handle(data) } + if error == nil, context?.isFinal != true { + self.receive(on: connection) + } else { + connection.cancel() + } + } + } + + private func handle(_ data: Data) { + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let type = object["t"] as? String else { return } + switch type { + case "rumble": + guard let slot = object["slot"] as? Int else { return } + let strong = min(max((object["strong"] as? Double) ?? 0, 0), 1) + let weak = min(max((object["weak"] as? Double) ?? 0, 0), 1) + onRumble?(slot, strong, weak) + case "stats": + // Page-side delivery telemetry from the extension: log it and + // echo to every client so it can be read outside the browser. + bridgeLog(.debug, "wshub", "client stats: \(String(decoding: data, as: UTF8.self))") + broadcast(String(decoding: data, as: UTF8.self)) + default: + break + } + } + + // MARK: Sending + + private func send(_ text: String, to connection: NWConnection) { + let metadata = NWProtocolWebSocket.Metadata(opcode: .text) + let context = NWConnection.ContentContext(identifier: "text", metadata: [metadata]) + connection.send(content: Data(text.utf8), contentContext: context, + isComplete: true, completion: .contentProcessed { _ in }) + } + + private func broadcast(_ text: String) { + for connection in clients.values { send(text, to: connection) } + } + + private static func connectedMessage(slot: Int, model: String, name: String) -> String { + #"{"t":"connected","slot":\#(slot),"model":\#(json(model)),"name":\#(json(name))}"# + } + + private static func json(_ string: String) -> String { + let data = (try? JSONSerialization.data(withJSONObject: [string])) ?? Data("[\"\"]".utf8) + let array = String(decoding: data, as: UTF8.self) + return String(array.dropFirst().dropLast()) + } + + // MARK: ControllerOutputSink (called on the Bluetooth queue) + + func controllerConnected(slot: Int, model: Switch2.Model) { + queue.async { [weak self] in + guard let self else { return } + let name = self.connected[slot]?.name ?? model.displayName + self.connected[slot] = (model.displayName, name) + self.seq[slot] = 0 + self.broadcast(Self.connectedMessage(slot: slot, model: model.displayName, name: name)) + } + } + + func controllerDisconnected(slot: Int) { + queue.async { [weak self] in + guard let self, self.connected.removeValue(forKey: slot) != nil else { return } + self.lastState.removeValue(forKey: slot) + self.broadcast(#"{"t":"disconnected","slot":\#(slot)}"#) + } + } + + func controllerName(slot: Int, name: String) { + queue.async { [weak self] in + guard let self else { return } + if let info = self.connected[slot] { + guard info.name != name else { return } + self.connected[slot] = (info.model, name) + self.broadcast(#"{"t":"name","slot":\#(slot),"name":\#(Self.json(name))}"#) + } else { + self.connected[slot] = ("", name) + } + } + } + + func controllerState(slot: Int, state: ControllerState) { + queue.async { [weak self] in + guard let self, !self.clients.isEmpty else { return } + let next = (self.seq[slot] ?? 0) &+ 1 + self.seq[slot] = next + let text = String( + format: #"{"t":"state","slot":%d,"seq":%u,"b":%u,"lx":%.4f,"ly":%.4f,"rx":%.4f,"ry":%.4f,"lt":%d,"rt":%d}"#, + slot, next, state.buttons.rawValue, + state.leftStick.x, state.leftStick.y, state.rightStick.x, state.rightStick.y, + Int(state.leftTrigger), Int(state.rightTrigger)) + self.broadcast(text) + } + } +} diff --git a/browser/README.md b/browser/README.md new file mode 100644 index 0000000..354e884 --- /dev/null +++ b/browser/README.md @@ -0,0 +1,137 @@ +# The browser bridge + +Use Switch 2 controllers in **web games** — Xbox Cloud Gaming +(xbox.com/play), GeForce NOW, Amazon Luna, any page that uses the +Gamepad API — today, without waiting for Apple's virtual-HID approval. + +Browsers only see gamepads that macOS knows about, and a Switch 2 +controller over Bluetooth LE is not a HID device macOS can pair with +(that is why it never appears in System Settings → Bluetooth). Until the +app can create system-wide virtual controllers, this bridge does for the +browser what the SDL bridge does for emulators: + +``` +Controller ──BLE──> menu-bar app ──ws://127.0.0.1:24810──> extension ──> navigator.getGamepads() + <──────── rumble ─────────────────── vibrationActuator +``` + +| File | What it is | +|---|---| +| `extension/manifest.json` | Manifest V3 extension for Chrome, Edge, Brave, Arc, Vivaldi, Opera — any Chromium browser. | +| `extension/background.js` | Service worker that owns the WebSocket to the app (auto-reconnects). Lives in the extension, so Chrome's *Local Network Access* permission (Chrome 138+) never prompts or blocks. | +| `extension/bridge.js` | Content script relaying messages between the service worker and the page. | +| `extension/shim.js` | Wraps `navigator.getGamepads()` with standard-mapping virtual gamepads and forwards rumble. | + +## Install (about a minute) + +1. Run the menu-bar app (v0.4+ / this branch). The log shows + `browser bridge on ws://127.0.0.1:24810`. +2. In your Chromium browser open `chrome://extensions` + (`edge://extensions`, `brave://extensions`, …), switch on + **Developer mode**, click **Load unpacked**, and choose this + `browser/extension` folder. +3. Open , press a button on the + controller: it appears as *Pro Controller 2 (STANDARD GAMEPAD …)* + with the standard layout. +4. Open and play. Rumble works. + +Safari is not supported: it blocks `ws://` connections from `https://` +pages and cannot load unpacked extensions. Firefox is not supported +either (different extension packaging); Chrome, Edge, Brave, Arc, +Vivaldi and Opera all work. + +Verified on xbox.com/play with a Pro Controller 2: sticks, buttons, +triggers and rumble. + +## If nothing shows up + +Work down the list; each step depends on the one before. + +1. **Is the app running with the bridge?** Open the dashboard log and + look for `browser bridge on ws://127.0.0.1:24810`. If it says the + port is busy, another copy of the app is running — quit it. +2. **Is the controller connected?** The menu-bar icon fills in and the + dashboard shows the player. If not, press any button (paired) or + hold Sync next to the USB-C port (new controller). +3. **Is the extension loaded and enabled?** `chrome://extensions` must + list *Finally the Controller Works — Browser Bridge* with the toggle + on, no red error badge. After editing any file in `extension/`, + click its reload icon. +4. **Is the site in the list?** The extension only runs on the sites in + `manifest.json` → `matches`. Add yours and reload the extension. +5. **Reload the game tab.** The shim installs when the page loads; a tab + that was open before the extension loaded never gets it. +6. **Still nothing?** Open — it is + in the list — and press a button. If the pad appears there but not + in the game, the game is the problem (some sites ignore gamepads + that connect after the page loaded: reload with the controller + already on). If it does not appear there either, open the tab's + DevTools console and look for `ftcw` errors, then file an issue with + that output. + +The service worker's own console (`chrome://extensions` → *Inspect +views: service worker*) shows the WebSocket state if you need to go +deeper. + +## Layout + +Buttons are mapped **by position** so on-screen Xbox prompts match your +thumb: the bottom face button (Switch **B**) is standard index 0 (Xbox +**A**), the right one (Switch **A**) is index 1 (Xbox **B**), and so on. +If you prefer label mapping (Switch A → Xbox A), set `NINTENDO_LABELS` +to `true` at the top of `shim.js` and reload the extension. + +| Standard index | Xbox name | Switch 2 control | +|---|---|---| +| 0 / 1 / 2 / 3 | A / B / X / Y | B / A / Y / X | +| 4 / 5 | LB / RB | L / R | +| 6 / 7 | LT / RT | ZL / ZR (analog on the GameCube pad) | +| 8 / 9 | View / Menu | − / + | +| 10 / 11 | LS / RS | stick clicks | +| 12–15 | D-pad | D-pad | +| 16 | Xbox | Home | +| 17 | Share | Capture | +| 18 / 19 / 20 | — | C / GL / GR (only with `EXTRA_BUTTONS = true` in `shim.js`) | + +Button remapping in the app's dashboard applies before the bridge, so +custom layouts carry over. + +## Identity (persona) + +Sites read the vendor id out of `gamepad.id` and choose glyphs and +vendor-specific handling from it. By default the pad keeps its real +identity (*Pro Controller 2 … Vendor: 057e*). `PERSONA_DEFAULT` in +`shim.js` switches every site to an *Xbox Wireless Controller* identity, +and a single site can be overridden from its DevTools console with +`localStorage.ftcwPersona = 'xbox'` (or `'nintendo'`; remove the key to +reset), then a reload. GeForce NOW sends an "is Xbox" flag to its servers +with every input packet, so try the Xbox identity there if sticks feel +off. + +## Adding a site + +The extension only injects into the sites listed in `manifest.json` +(`matches`). Add a pattern for another game site, then reload the +extension. Multiple tabs may be connected at once; each gets the same +controllers. + +## Protocol + +JSON text frames on `ws://127.0.0.1:24810`, loopback only: + +``` +hub → page {"t":"hello","v":1} + {"t":"connected","slot":0,"model":"Pro Controller 2","name":"…"} + {"t":"name","slot":0,"name":"…"} + {"t":"state","slot":0,"seq":123,"b":, + "lx":…,"ly":…,"rx":…,"ry":…,"lt":0-255,"rt":0-255} (+y = up) + {"t":"disconnected","slot":0} + {"t":"ping"} every 15 s +page → hub {"t":"rumble","slot":0,"strong":0…1,"weak":0…1} +``` + +`b` uses the app's `Switch2.Buttons` bit layout +(`Sources/FinallyTheControllerWorks/Protocol/Switch2Protocol.swift`). +New clients get `hello` plus a `connected` per active player. Anything +that speaks WebSocket can subscribe — the extension is just the +reference client. diff --git a/browser/extension/background.js b/browser/extension/background.js new file mode 100644 index 0000000..216135c --- /dev/null +++ b/browser/extension/background.js @@ -0,0 +1,69 @@ +// background.js — the extension's service worker owns the WebSocket to the +// menu-bar app (ws://127.0.0.1:24810). Extension contexts are not subject to +// Chrome's Local Network Access permission, which would otherwise prompt (or +// silently block) a public https:// page opening a loopback socket. +// +// Content scripts (bridge.js) attach through chrome.runtime.connect ports; +// every hub message is fanned out to all ports, rumble from any port goes to +// the hub. Hub pings (every 15 s) and port traffic keep the worker alive. + +const URL = 'ws://127.0.0.1:24810'; +const RETRY_MS = 2000; + +const ports = new Set(); +let socket = null; +let retryTimer = null; +let replay = new Map(); // slot → last "connected"/"name" JSON, for late ports + +const fanOut = (text) => { for (const port of ports) { try { port.postMessage(text); } catch {} } }; + +function connect() { + retryTimer = null; + if (socket || ports.size === 0) return; + try { socket = new WebSocket(URL); } catch { scheduleRetry(); return; } + socket.onopen = () => fanOut('{"t":"bridge","up":true}'); + socket.onmessage = (ev) => { + if (typeof ev.data !== 'string') return; + track(ev.data); + fanOut(ev.data); + }; + socket.onclose = () => { + socket = null; + replay = new Map(); + fanOut('{"t":"bridge","up":false}'); + scheduleRetry(); + }; + socket.onerror = () => {}; +} + +function scheduleRetry() { + if (retryTimer !== null || ports.size === 0) return; + retryTimer = setTimeout(connect, RETRY_MS); +} + +// Remember per-slot identity so a tab opened later sees connected pads. +function track(text) { + let m; + try { m = JSON.parse(text); } catch { return; } + if (m.t === 'connected' || m.t === 'name') replay.set(m.slot, text); + else if (m.t === 'disconnected') replay.delete(m.slot); +} + +chrome.runtime.onConnect.addListener((port) => { + if (port.name !== 'ftcw') return; + ports.add(port); + port.onMessage.addListener((text) => { + if (typeof text !== 'string' || text === 'ping') return; + if (socket && socket.readyState === WebSocket.OPEN) socket.send(text); + }); + port.onDisconnect.addListener(() => { + ports.delete(port); + if (ports.size === 0 && socket) { socket.close(); socket = null; } + }); + if (socket && socket.readyState === WebSocket.OPEN) { + port.postMessage('{"t":"bridge","up":true}'); + for (const text of replay.values()) port.postMessage(text); + } else { + connect(); + } +}); diff --git a/browser/extension/bridge.js b/browser/extension/bridge.js new file mode 100644 index 0000000..f75d433 --- /dev/null +++ b/browser/extension/bridge.js @@ -0,0 +1,73 @@ +// bridge.js — runs in the extension's isolated world. Relays hub messages to +// shim.js (main world) as DOM events, and rumble/telemetry the other way. +// +// Transport: the page tries a direct WebSocket to the app first (shortest +// path: no service-worker hop, which matters under a streaming video load). +// If the browser refuses it — Chrome's Local Network Access permission may +// gate loopback sockets in the future — it falls back to the service +// worker in background.js, which is outside that permission. +// +// Strings only across the world boundary: Chrome does not share objects +// between isolated and main worlds. + +(() => { + const URL = 'ws://127.0.0.1:24810'; + const RETRY_MS = 2000; + const PING_MS = 20000; + + let socket = null; // direct mode + let port = null; // relay mode + let directFailed = false; + let timer = null; + + const toPage = (text) => + document.dispatchEvent(new CustomEvent('ftcw-bridge', { detail: text })); + + function connectDirect() { + let opened = false; + try { socket = new WebSocket(URL); } catch { socket = null; directFailed = true; connectRelay(); return; } + socket.onopen = () => { opened = true; toPage('{"t":"bridge","up":true}'); }; + socket.onmessage = (ev) => { if (typeof ev.data === 'string') toPage(ev.data); }; + socket.onerror = () => {}; + socket.onclose = () => { + socket = null; + if (!opened) { + // Refused before opening: either the app is not running or the + // browser blocks page-initiated loopback sockets. The relay can tell + // the two apart, so hand over to it from now on. + directFailed = true; + connectRelay(); + return; + } + toPage('{"t":"bridge","up":false}'); + schedule(connectDirect); + }; + } + + function connectRelay() { + try { + port = chrome.runtime.connect({ name: 'ftcw' }); + } catch { + toPage('{"t":"bridge","up":false}'); // extension reloaded: orphaned script + return; + } + port.onMessage.addListener((text) => { if (typeof text === 'string') toPage(text); }); + port.onDisconnect.addListener(() => { + port = null; + clearInterval(timer); + toPage('{"t":"bridge","up":false}'); + setTimeout(connectRelay, RETRY_MS / 2); + }); + timer = setInterval(() => { try { port && port.postMessage('ping'); } catch {} }, PING_MS); + } + + function schedule(fn) { setTimeout(fn, RETRY_MS); } + + document.addEventListener('ftcw-up', (ev) => { + if (typeof ev.detail !== 'string') return; + if (socket && socket.readyState === WebSocket.OPEN) { socket.send(ev.detail); return; } + if (port) { try { port.postMessage(ev.detail); } catch {} } + }); + + connectDirect(); +})(); diff --git a/browser/extension/manifest.json b/browser/extension/manifest.json new file mode 100644 index 0000000..680baee --- /dev/null +++ b/browser/extension/manifest.json @@ -0,0 +1,46 @@ +{ + "manifest_version": 3, + "name": "Finally the Controller Works \u2014 Browser Bridge", + "version": "0.1.0", + "description": "Lets web games (Xbox Cloud Gaming, GeForce NOW, Luna, gamepad testers) see the Switch 2 controllers connected through the Finally the Controller Works menu-bar app.", + "content_scripts": [ + { + "matches": [ + "https://www.xbox.com/*", + "https://play.geforcenow.com/*", + "https://luna.amazon.com/*", + "https://luna.amazon.co.uk/*", + "https://luna.amazon.de/*", + "https://hardwaretester.com/*", + "https://gamepad-tester.com/*", + "https://greggman.github.io/html5-gamepad-test/*" + ], + "js": [ + "shim.js" + ], + "run_at": "document_start", + "world": "MAIN", + "all_frames": true + }, + { + "matches": [ + "https://www.xbox.com/*", + "https://play.geforcenow.com/*", + "https://luna.amazon.com/*", + "https://luna.amazon.co.uk/*", + "https://luna.amazon.de/*", + "https://hardwaretester.com/*", + "https://gamepad-tester.com/*", + "https://greggman.github.io/html5-gamepad-test/*" + ], + "js": [ + "bridge.js" + ], + "run_at": "document_start", + "all_frames": true + } + ], + "background": { + "service_worker": "background.js" + } +} diff --git a/browser/extension/shim.js b/browser/extension/shim.js new file mode 100644 index 0000000..1eb00f7 --- /dev/null +++ b/browser/extension/shim.js @@ -0,0 +1,279 @@ +// shim.js — runs in the page's main world. Wraps navigator.getGamepads() so +// controllers streamed by the Finally the Controller Works app appear as +// standard-mapping gamepads alongside any real ones, fires +// gamepadconnected/gamepaddisconnected, and forwards vibrationActuator +// effects back to the app (rumble). +// +// Button layout is POSITIONAL by default (the bottom face button is the +// standard "A"/index 0, exactly as an Xbox pad would report), so on-screen +// prompts in Xbox Cloud Gaming match what your thumb does. Set +// NINTENDO_LABELS to true to map by label instead (Switch A → standard A). + +(() => { + if (navigator.__ftcwBridge) return; + + const NINTENDO_LABELS = false; + // How the pad introduces itself. Sites classify controllers by the vendor + // id in this string (045e Xbox, 057e Nintendo, …) and pick glyphs and + // per-vendor handling from that. 'nintendo' keeps the real identity; + // 'xbox' presents as an Xbox Wireless Controller, which some streaming + // services treat on a better-trodden path (GeForce NOW sends an + // "is Xbox" flag to its servers with every input packet). + // Per-site override without touching files: in the site's DevTools console + // localStorage.ftcwPersona = 'xbox' (or 'nintendo'; remove to reset) + // then reload the page. + const PERSONA_DEFAULT = 'nintendo'; + const PERSONA = (() => { + try { const v = localStorage.getItem('ftcwPersona'); if (v === 'xbox' || v === 'nintendo') return v; } catch {} + return PERSONA_DEFAULT; + })(); + // Expose C / GL / GR as buttons 18-20. Off by default: real Xbox pads stop + // at 17 (Share), and some sites misbehave with extra indices. + const EXTRA_BUTTONS = false; + + // Switch2.Buttons bits (Protocol/Switch2Protocol.swift). + const BIT = { + y: 1 << 0, x: 1 << 1, b: 1 << 2, a: 1 << 3, r: 1 << 6, zr: 1 << 7, + minus: 1 << 8, plus: 1 << 9, rStick: 1 << 10, lStick: 1 << 11, + home: 1 << 12, capture: 1 << 13, c: 1 << 14, + dpadDown: 1 << 16, dpadUp: 1 << 17, dpadRight: 1 << 18, dpadLeft: 1 << 19, + l: 1 << 22, zl: 1 << 23, gr: 1 << 24, gl: 1 << 25, + }; + + // Standard Gamepad button indices → Switch2 bit. Indices 6/7 (triggers) + // are analog and handled separately; 17 = share/capture (Xbox Series X + // extension index), 18 = C, 19/20 = GL/GR back paddles. + const face = NINTENDO_LABELS + ? [BIT.a, BIT.b, BIT.x, BIT.y] // by label + : [BIT.b, BIT.a, BIT.y, BIT.x]; // by position: bottom, right, left, top + const BUTTON_BITS = [ + face[0], face[1], face[2], face[3], + BIT.l, BIT.r, 0, 0, + BIT.minus, BIT.plus, BIT.lStick, BIT.rStick, + BIT.dpadUp, BIT.dpadDown, BIT.dpadLeft, BIT.dpadRight, + BIT.home, BIT.capture, + ...(EXTRA_BUTTONS ? [BIT.c, BIT.gl, BIT.gr] : []), + ]; + const BUTTON_COUNT = BUTTON_BITS.length; + + const pads = new Map(); // slot → virtual pad + const nativeGetGamepads = Navigator.prototype.getGamepads; + let bridgeUp = false; + + const toApp = (obj) => + document.dispatchEvent(new CustomEvent('ftcw-up', { detail: JSON.stringify(obj) })); + const rumbleToApp = (slot, strong, weak) => toApp({ t: 'rumble', slot, strong, weak }); + + // Delivery telemetry: how state messages actually arrive in this page + // (intervals between them) and how often the site polls getGamepads(). + // Sent to the hub every 5 s as {"t":"stats"}; the hub rebroadcasts it to + // any other client, so it can be read outside the browser. + const STATS_WINDOW_MS = 5000; + let arrivals = new Map(); // slot → [performance.now(), …] + let getCalls = 0; + let statsTimer = null; + function noteArrival(slot) { + let a = arrivals.get(slot); + if (!a) { a = []; arrivals.set(slot, a); } + a.push(performance.now()); + if (statsTimer === null) statsTimer = setTimeout(flushStats, STATS_WINDOW_MS); + } + function flushStats() { + statsTimer = null; + for (const [slot, a] of arrivals) { + const gaps = []; + for (let i = 1; i < a.length; i++) gaps.push(a[i] - a[i - 1]); + gaps.sort((x, y) => x - y); + const q = (f) => gaps.length ? +gaps[Math.min(gaps.length - 1, Math.floor(gaps.length * f))].toFixed(1) : 0; + toApp({ t: 'stats', slot, win: STATS_WINDOW_MS, n: a.length, med: q(0.5), p95: q(0.95), + max: gaps.length ? +gaps[gaps.length - 1].toFixed(1) : 0, over60: gaps.filter((g) => g > 60).length, + gets: getCalls, hidden: document.hidden, url: location.host }); + } + arrivals = new Map(); + getCalls = 0; + } + + function makeActuator(slot) { + let timer = null; + let pending = null; + const finish = (result) => { + if (pending) { const p = pending; pending = null; p(result); } + }; + const stop = () => { + if (timer) { clearTimeout(timer); timer = null; } + rumbleToApp(slot, 0, 0); + }; + return { + type: 'dual-rumble', + effects: ['dual-rumble'], + playEffect(type, params = {}) { + if (type !== 'dual-rumble') return Promise.resolve('invalid-parameter'); + const strong = clamp01(params.strongMagnitude); + const weak = clamp01(params.weakMagnitude); + const duration = Math.max(0, Number(params.duration) || 0); + const startDelay = Math.max(0, Number(params.startDelay) || 0); + if (timer) clearTimeout(timer); + finish('preempted'); + return new Promise((resolve) => { + pending = resolve; + const start = () => { + rumbleToApp(slot, strong, weak); + timer = setTimeout(() => { timer = null; rumbleToApp(slot, 0, 0); finish('complete'); }, duration); + }; + if (startDelay > 0) timer = setTimeout(start, startDelay); else start(); + }); + }, + reset() { stop(); finish('preempted'); return Promise.resolve('complete'); }, + stop, + }; + } + + const clamp01 = (v) => Math.min(1, Math.max(0, Number(v) || 0)); + + const padId = (model, name) => PERSONA === 'xbox' + ? 'Xbox Wireless Controller (STANDARD GAMEPAD Vendor: 045e Product: 0b13)' + : `${name || model} (STANDARD GAMEPAD Vendor: 057e Product: 2069)`; + + function makePad(slot, model, name) { + const buttons = []; + for (let i = 0; i < BUTTON_COUNT; i++) { + const button = { pressed: false, touched: false, value: 0 }; + // Own properties shadow the native accessors, so `instanceof` checks + // pass without ever touching the (throwing) prototype getters. + if (typeof GamepadButton !== 'undefined') Object.setPrototypeOf(button, GamepadButton.prototype); + buttons.push(button); + } + const pad = { + id: padId(model, name), + index: -1, + connected: true, + mapping: 'standard', + timestamp: performance.now(), + axes: [0, 0, 0, 0], + buttons, + hapticActuators: [], + vibrationActuator: makeActuator(slot), + __ftcwSlot: slot, + }; + if (typeof Gamepad !== 'undefined') Object.setPrototypeOf(pad, Gamepad.prototype); + return pad; + } + + // Place virtual pads in the lowest indices not occupied by real gamepads. + function assignIndex(pad) { + const taken = new Set(); + for (const g of nativeGetGamepads.call(navigator)) if (g) taken.add(g.index); + for (const p of pads.values()) if (p !== pad && p.index >= 0) taken.add(p.index); + let i = 0; + while (taken.has(i)) i++; + pad.index = i; + } + + function fire(type, pad) { + const ev = new Event(type); + Object.defineProperty(ev, 'gamepad', { value: pad, enumerable: true }); + window.dispatchEvent(ev); + } + + function applyState(pad, m) { + const b = m.b >>> 0; + const btn = pad.buttons; + for (let i = 0; i < BUTTON_COUNT; i++) { + const bit = BUTTON_BITS[i]; + if (!bit) continue; + const on = (b & bit) !== 0; + btn[i].pressed = on; btn[i].touched = on; btn[i].value = on ? 1 : 0; + } + const lt = Math.max((b & BIT.zl) ? 1 : 0, (m.lt || 0) / 255); + const rt = Math.max((b & BIT.zr) ? 1 : 0, (m.rt || 0) / 255); + btn[6].value = lt; btn[6].pressed = lt > 0.5; btn[6].touched = lt > 0; + btn[7].value = rt; btn[7].pressed = rt > 0.5; btn[7].touched = rt > 0; + // App axes: +y = up. Standard Gamepad: +y = down. + pad.axes[0] = m.lx; pad.axes[1] = -m.ly; pad.axes[2] = m.rx; pad.axes[3] = -m.ry; + pad.timestamp = performance.now(); + } + + function disconnectAll() { + for (const [slot, pad] of pads) { + pads.delete(slot); + pad.connected = false; + pad.vibrationActuator.stop(); + fire('gamepaddisconnected', pad); + } + } + + document.addEventListener('ftcw-bridge', (ev) => { + let m; + try { m = JSON.parse(ev.detail); } catch { return; } + switch (m.t) { + case 'bridge': + bridgeUp = !!m.up; + if (!bridgeUp) disconnectAll(); + break; + case 'connected': { + let pad = pads.get(m.slot); + if (pad) { pad.id = padId(m.model, m.name); break; } + pad = makePad(m.slot, m.model, m.name); + assignIndex(pad); + pads.set(m.slot, pad); + fire('gamepadconnected', pad); + break; + } + case 'name': { + const pad = pads.get(m.slot); + if (pad) pad.id = padId('', m.name); + break; + } + case 'state': { + const pad = pads.get(m.slot); + if (pad) { applyState(pad, m); noteArrival(m.slot); } + break; + } + case 'disconnected': { + const pad = pads.get(m.slot); + if (!pad) break; + pads.delete(m.slot); + pad.connected = false; + pad.vibrationActuator.stop(); + fire('gamepaddisconnected', pad); + break; + } + } + }); + + // Chrome hands out a fresh immutable snapshot per getGamepads() call, and + // sites diff consecutive snapshots to detect edges. Mutating one shared + // object would make "previous" and "current" the same thing, so hand out + // copies the way the browser does. + function snapshot(pad) { + const copy = { + id: pad.id, index: pad.index, connected: pad.connected, mapping: pad.mapping, + timestamp: pad.timestamp, axes: pad.axes.slice(), + buttons: pad.buttons.map((b) => { + const button = { pressed: b.pressed, touched: b.touched, value: b.value }; + if (typeof GamepadButton !== 'undefined') Object.setPrototypeOf(button, GamepadButton.prototype); + return button; + }), + hapticActuators: pad.hapticActuators, + vibrationActuator: pad.vibrationActuator, + __ftcwSlot: pad.__ftcwSlot, + }; + if (typeof Gamepad !== 'undefined') Object.setPrototypeOf(copy, Gamepad.prototype); + return copy; + } + + Navigator.prototype.getGamepads = function () { + const real = Array.from(nativeGetGamepads.call(this)); + if (pads.size === 0) return real; + getCalls++; + for (const pad of pads.values()) { + while (real.length <= pad.index) real.push(null); + if (real[pad.index] === null || real[pad.index] === undefined) real[pad.index] = snapshot(pad); + } + return real; + }; + + Object.defineProperty(navigator, '__ftcwBridge', { + value: { get pads() { return [...pads.values()]; }, get up() { return bridgeUp; }, persona: PERSONA }, + }); +})(); From 069e39327bf5947cc59cd0401275ec2e74f9d3a6 Mon Sep 17 00:00:00 2001 From: Johnny D Date: Wed, 9 Sep 2026 14:03:04 -0400 Subject: [PATCH 2/6] fix(browser): guard replay, rumble ownership, and opt-in WebSocket access Preserve complete connection/state replay after rename and reject obsolete socket callbacks. Keep long effects alive only for their requested lifetime, settle cancelled effects, and reject retired actuators. Eight deterministic Node regressions cover these behaviors (seven fail on the imported source). Add an opt-in settings window, exact extension-Origin checks, bounded clients and messages, and per-client rumble ownership. GameCube HD rumble is withheld using the existing model capability. Origin checks are not native-process authentication. Exercise the actual Network.framework listener in macOS CI. Adapted browser feature retains attribution to Andrei-Kondrykau's upstream browser-bridge at 24b0cd3d225c77c9efcfca42cb4fd4325e2fccf3. --- .../FinallyTheControllerWorks/FTCWApp.swift | 5 + .../Output/WebSocketHub.swift | 304 +++++++++--------- .../UI/BrowserBridgeSettings.swift | 24 ++ browser/FORK-INTEGRATION.md | 37 +++ browser/extension/background.js | 68 ++-- browser/extension/shim.js | 52 +-- tests/browser/BrowserServer.swift | 34 ++ tests/browser/background.test.cjs | 74 +++++ tests/browser/run.sh | 19 ++ tests/browser/shim.test.cjs | 82 +++++ tests/browser/websocket_test.py | 145 +++++++++ 11 files changed, 655 insertions(+), 189 deletions(-) create mode 100644 Sources/FinallyTheControllerWorks/UI/BrowserBridgeSettings.swift create mode 100644 browser/FORK-INTEGRATION.md create mode 100644 tests/browser/BrowserServer.swift create mode 100644 tests/browser/background.test.cjs create mode 100755 tests/browser/run.sh create mode 100644 tests/browser/shim.test.cjs create mode 100644 tests/browser/websocket_test.py diff --git a/Sources/FinallyTheControllerWorks/FTCWApp.swift b/Sources/FinallyTheControllerWorks/FTCWApp.swift index 65c7d53..757a822 100644 --- a/Sources/FinallyTheControllerWorks/FTCWApp.swift +++ b/Sources/FinallyTheControllerWorks/FTCWApp.swift @@ -40,6 +40,9 @@ struct FTCWApp: App { } .defaultSize(width: 500, height: 480) + Window("Browser Bridge", id: "browser-bridge") { BrowserBridgeSettings() } + .windowResizability(.contentSize) + Window("About", id: "about") { AboutView() } .windowResizability(.contentSize) @@ -83,6 +86,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { func applicationDidFinishLaunching(_ notification: Notification) { bridgeLog(.info, "app", "Finally the Controller Works — starting bridge") engine.addSink(UDPHub()) + engine.addSink(WebSocketHub()) engine.addSink(VirtualHIDSink()) notifications.attach(to: engine) // Daily auto-update check (only if a feed URL is configured); results @@ -144,6 +148,7 @@ struct MenuContent: View { Divider() Button("Open Dashboard") { show("dashboard") } + Button("Browser Bridge Settings…") { show("browser-bridge") } // Hidden for the beta (AppInfo.showPreReleaseFeatures documents // the defaults key that brings them back). diff --git a/Sources/FinallyTheControllerWorks/Output/WebSocketHub.swift b/Sources/FinallyTheControllerWorks/Output/WebSocketHub.swift index d5d6c3b..d09acf4 100644 --- a/Sources/FinallyTheControllerWorks/Output/WebSocketHub.swift +++ b/Sources/FinallyTheControllerWorks/Output/WebSocketHub.swift @@ -1,226 +1,238 @@ -// WebSocketHub.swift -// Browser sink: re-broadcasts controller state over a local WebSocket so a -// small browser extension (browser/extension) can present the controllers to -// web games through the Gamepad API — Xbox Cloud Gaming, GeForce NOW, Luna, -// gamepad testers — with rumble flowing back. No entitlement, no root, no -// driver: it is the UDP/SDL bridge idea applied to the browser. -// -// Endpoint: ws://127.0.0.1:24810 (loopback only). Messages are JSON text: -// hub → page: -// {"t":"hello","v":1} -// {"t":"connected","slot":0,"model":"Pro Controller 2","name":"…"} -// {"t":"name","slot":0,"name":"…"} -// {"t":"state","slot":0,"seq":123,"b":, -// "lx":…,"ly":…,"rx":…,"ry":…,"lt":0-255,"rt":0-255} (y: +1 = up) -// {"t":"disconnected","slot":0} -// {"t":"ping"} every 15 s (keeps extension service workers alive) -// page → hub: -// {"t":"rumble","slot":0,"strong":0…1,"weak":0…1} -// {"t":"stats",…} extension delivery telemetry, echoed to all clients -// Every new client receives "hello" plus one "connected"/"name" per -// currently connected player, so late joiners (a tab opened after the -// controller paired) see the full picture immediately. - +// Browser output adapted from Andrei-Kondrykau/switch2mac, browser-bridge +// 24b0cd3d225c77c9efcfca42cb4fd4325e2fccf3. The existing JSON schema is retained. +// Opt-in and exact extension-Origin checks restrict browser access; they do +// not authenticate native processes already running as the local user. import Foundation import Network final class WebSocketHub: ControllerOutputSink, @unchecked Sendable { - static let port: UInt16 = 24810 - + static let enabledKey = "browserBridgeEnabled" + static let extensionIDsKey = "browserBridgeExtensionIDs" + private static let maxMessageBytes = 65536 + private static let maxClients = 8 var onRumble: ((Int, Double, Double) -> Void)? + private final class Client { + let connection: NWConnection + var ready = false + var pendingMessages = 0 + var pendingBytes = 0 + var received = 0 + var windowStart = ProcessInfo.processInfo.systemUptime + init(_ connection: NWConnection) { self.connection = connection } + } private let queue = DispatchQueue(label: "com.petersharma.ftcw.wshub") + private let allowedOrigins: Set private var listener: NWListener? - private var clients: [ObjectIdentifier: NWConnection] = [:] - private var connected: [Int: (model: String, name: String)] = [:] + private var clients: [ObjectIdentifier: Client] = [:] + private var connected: [Int: (model: String, name: String, rumble: Bool)] = [:] private var seq: [Int: UInt32] = [:] - private var lastState: [Int: (buttons: UInt32, packet: Data)] = [:] - + private var lastState: [Int: String] = [:] + private var rumbleOwners: [Int: ObjectIdentifier] = [:] private var pingTimer: DispatchSourceTimer? - init() { - queue.async { [weak self] in - self?.startListener() - self?.startPing() - } + static func origins(from ids: String) -> Set { + Set(ids.split(whereSeparator: { $0.isWhitespace || $0 == "," }).compactMap { id in + guard id.utf8.count == 32, id.utf8.allSatisfy({ (97...112).contains($0) }) else { return nil } + return "chrome-extension://\(id)" + }) } - /// Chrome unloads an idle extension service worker after ~30 s; a - /// periodic message keeps the bridge's socket owner alive between inputs. - private func startPing() { - let timer = DispatchSource.makeTimerSource(queue: queue) - timer.schedule(deadline: .now() + 15, repeating: 15) - timer.setEventHandler { [weak self] in self?.broadcast(#"{"t":"ping"}"#) } - timer.resume() - pingTimer = timer + init(enabled: Bool = UserDefaults.standard.bool(forKey: WebSocketHub.enabledKey), + allowedOrigins: Set = WebSocketHub.origins(from: + UserDefaults.standard.string(forKey: WebSocketHub.extensionIDsKey) ?? "")) { + self.allowedOrigins = allowedOrigins + guard enabled, !allowedOrigins.isEmpty else { return } + queue.async { [weak self] in self?.startListener() } } - // MARK: Listener - private func startListener() { + guard listener == nil else { return } let params = NWParameters.tcp - params.allowLocalEndpointReuse = true - params.requiredLocalEndpoint = NWEndpoint.hostPort( - host: "127.0.0.1", port: NWEndpoint.Port(rawValue: Self.port)!) + params.requiredLocalEndpoint = .hostPort(host: "127.0.0.1", port: .init(rawValue: Self.port)!) let ws = NWProtocolWebSocket.Options() ws.autoReplyPing = true + ws.maximumMessageSize = Self.maxMessageBytes + let origins = allowedOrigins + ws.setClientRequestHandler(queue) { _, headers in + let values = headers.filter { $0.name.lowercased() == "origin" }.map(\.value) + let accepted = values.count == 1 && origins.contains(values[0]) + return NWProtocolWebSocket.Response(status: accepted ? .accept : .reject, subprotocol: nil) + } params.defaultProtocolStack.applicationProtocols.insert(ws, at: 0) - - let listener: NWListener do { - listener = try NWListener(using: params) - } catch { - bridgeLog(.warning, "wshub", "cannot create listener (\(error)) — retrying in 5 s") - queue.asyncAfter(deadline: .now() + 5) { [weak self] in self?.startListener() } - return - } - listener.stateUpdateHandler = { [weak self] state in - guard let self else { return } - switch state { - case .ready: - bridgeLog(.info, "wshub", "browser bridge on ws://127.0.0.1:\(Self.port)") - case .failed(let error): - bridgeLog(.warning, "wshub", "listener failed (\(error)) — retrying in 5 s") - listener.cancel() - self.listener = nil - self.queue.asyncAfter(deadline: .now() + 5) { [weak self] in self?.startListener() } - default: - break + let owner = try NWListener(using: params) + listener = owner + owner.stateUpdateHandler = { [weak self, weak owner] state in + guard let self, let owner, self.listener === owner else { return } + switch state { + case .ready: + bridgeLog(.info, "wshub", "opt-in browser bridge on 127.0.0.1:\(Self.port)") + self.startPing() + case .failed(let error): + bridgeLog(.warning, "wshub", "listener failed: \(error)") + owner.cancel(); self.listener = nil + self.queue.asyncAfter(deadline: .now() + 5) { [weak self] in self?.startListener() } + default: break + } } + owner.newConnectionHandler = { [weak self, weak owner] connection in + guard let self, self.listener === owner else { connection.cancel(); return } + self.accept(connection) + } + owner.start(queue: queue) + } catch { + bridgeLog(.warning, "wshub", "cannot create listener: \(error)") } - listener.newConnectionHandler = { [weak self] connection in - self?.accept(connection) - } - self.listener = listener - listener.start(queue: queue) + } + + private func startPing() { + guard pingTimer == nil else { return } + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now() + 15, repeating: 15) + timer.setEventHandler { [weak self] in self?.broadcast(#"{"t":"ping"}"#) } + timer.resume(); pingTimer = timer } private func accept(_ connection: NWConnection) { + guard clients.count < Self.maxClients else { connection.cancel(); return } let id = ObjectIdentifier(connection) - connection.stateUpdateHandler = { [weak self] state in - guard let self else { return } + let client = Client(connection) + clients[id] = client // include incomplete handshakes in the bound + queue.asyncAfter(deadline: .now() + 5) { [weak self, weak client] in + guard let self, let client, self.clients[id] === client, !client.ready else { return } + self.remove(id) + } + connection.stateUpdateHandler = { [weak self, weak client] state in + guard let self, let client, self.clients[id] === client else { return } switch state { case .ready: - self.clients[id] = connection - bridgeLog(.info, "wshub", "browser client connected (\(self.clients.count) total)") - self.send(#"{"t":"hello","v":1}"#, to: connection) + client.ready = true + self.send(#"{"t":"hello","v":1}"#, to: client) for (slot, info) in self.connected.sorted(by: { $0.key < $1.key }) { - self.send(Self.connectedMessage(slot: slot, model: info.model, name: info.name), - to: connection) + self.send(Self.connectionMessage(slot, info.model, info.name), to: client) + if let state = self.lastState[slot] { self.send(state, to: client) } } - self.receive(on: connection) - case .failed, .cancelled: - if self.clients.removeValue(forKey: id) != nil { - bridgeLog(.info, "wshub", "browser client left (\(self.clients.count) total)") - // A page that disappears mid-rumble should not leave the - // controller buzzing. - for slot in self.connected.keys { self.onRumble?(slot, 0, 0) } - } - default: - break + self.receive(client) + case .failed, .cancelled: self.remove(id) + default: break } } connection.start(queue: queue) } - private func receive(on connection: NWConnection) { - connection.receiveMessage { [weak self] data, context, _, error in - guard let self else { return } - if let data, !data.isEmpty { self.handle(data) } - if error == nil, context?.isFinal != true { - self.receive(on: connection) - } else { - connection.cancel() + private func remove(_ id: ObjectIdentifier) { + guard let client = clients.removeValue(forKey: id) else { return } + client.connection.cancel() + // A departing observer cannot stop another client's active effect. + for slot in Array(rumbleOwners.keys) where rumbleOwners[slot] == id { + rumbleOwners.removeValue(forKey: slot) + onRumble?(slot, 0, 0) + } + } + + private func receive(_ client: Client) { + let connection = client.connection, id = ObjectIdentifier(client.connection) + connection.receiveMessage { [weak self, weak client] data, context, _, error in + guard let self, let client, self.clients[id] === client else { return } + let now = ProcessInfo.processInfo.systemUptime + if now - client.windowStart >= 1 { client.windowStart = now; client.received = 0 } + client.received += 1 + guard client.received <= 200, (data?.count ?? 0) <= Self.maxMessageBytes else { + self.remove(id); return } + if let data, !data.isEmpty { self.handle(data, from: id) } + if error == nil, context?.isFinal != true { self.receive(client) } + else { self.remove(id) } } } - private func handle(_ data: Data) { + private func handle(_ data: Data, from id: ObjectIdentifier) { guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let type = object["t"] as? String else { return } - switch type { - case "rumble": - guard let slot = object["slot"] as? Int else { return } - let strong = min(max((object["strong"] as? Double) ?? 0, 0), 1) - let weak = min(max((object["weak"] as? Double) ?? 0, 0), 1) + if type == "rumble" { + guard let slot = object["slot"] as? Int, (0..<4).contains(slot), connected[slot]?.rumble == true, + let strong = object["strong"] as? Double, strong.isFinite, + let weak = object["weak"] as? Double, weak.isFinite else { return } + let strong = min(1, max(0, strong)), weak = min(1, max(0, weak)) + if strong == 0 && weak == 0 { + guard rumbleOwners[slot] == id else { return } + rumbleOwners.removeValue(forKey: slot) + } else { rumbleOwners[slot] = id } onRumble?(slot, strong, weak) - case "stats": - // Page-side delivery telemetry from the extension: log it and - // echo to every client so it can be read outside the browser. - bridgeLog(.debug, "wshub", "client stats: \(String(decoding: data, as: UTF8.self))") - broadcast(String(decoding: data, as: UTF8.self)) - default: - break + } else if type == "stats" { + // Telemetry stays local to logging, not broadcast to unrelated tabs. + bridgeLog(.debug, "wshub", "client stats: \(String(decoding: data.prefix(2048), as: UTF8.self))") } } - // MARK: Sending - - private func send(_ text: String, to connection: NWConnection) { + private func send(_ text: String, to client: Client) { + let connection = client.connection, id = ObjectIdentifier(client.connection) + guard clients[id] === client, client.ready else { return } + let bytes = Data(text.utf8) + guard bytes.count <= Self.maxMessageBytes, client.pendingMessages < 128, + client.pendingBytes + bytes.count <= 256 * 1024 else { remove(id); return } + client.pendingMessages += 1; client.pendingBytes += bytes.count let metadata = NWProtocolWebSocket.Metadata(opcode: .text) let context = NWConnection.ContentContext(identifier: "text", metadata: [metadata]) - connection.send(content: Data(text.utf8), contentContext: context, - isComplete: true, completion: .contentProcessed { _ in }) + connection.send(content: bytes, contentContext: context, isComplete: true, + completion: .contentProcessed { [weak self, weak client] error in + guard let self, let client, self.clients[id] === client else { return } + client.pendingMessages -= 1; client.pendingBytes -= bytes.count + if error != nil { self.remove(id) } + }) } private func broadcast(_ text: String) { - for connection in clients.values { send(text, to: connection) } + for client in Array(clients.values) where client.ready { send(text, to: client) } } - private static func connectedMessage(slot: Int, model: String, name: String) -> String { - #"{"t":"connected","slot":\#(slot),"model":\#(json(model)),"name":\#(json(name))}"# + private static func json(_ object: [String: Any]) -> String? { + guard let data = try? JSONSerialization.data(withJSONObject: object) else { return nil } + return String(decoding: data, as: UTF8.self) } - - private static func json(_ string: String) -> String { - let data = (try? JSONSerialization.data(withJSONObject: [string])) ?? Data("[\"\"]".utf8) - let array = String(decoding: data, as: UTF8.self) - return String(array.dropFirst().dropLast()) + private static func connectionMessage(_ slot: Int, _ model: String, _ name: String) -> String { + json(["t":"connected", "slot":slot, "model":model, "name":name])! } - // MARK: ControllerOutputSink (called on the Bluetooth queue) - func controllerConnected(slot: Int, model: Switch2.Model) { queue.async { [weak self] in - guard let self else { return } - let name = self.connected[slot]?.name ?? model.displayName - self.connected[slot] = (model.displayName, name) + guard let self, (0..<4).contains(slot) else { return } + self.connected[slot] = (model.displayName, model.displayName, model.hasHDRumble) + self.lastState.removeValue(forKey: slot) + self.rumbleOwners.removeValue(forKey: slot) self.seq[slot] = 0 - self.broadcast(Self.connectedMessage(slot: slot, model: model.displayName, name: name)) + self.broadcast(Self.connectionMessage(slot, model.displayName, model.displayName)) } } - func controllerDisconnected(slot: Int) { queue.async { [weak self] in guard let self, self.connected.removeValue(forKey: slot) != nil else { return } self.lastState.removeValue(forKey: slot) + self.rumbleOwners.removeValue(forKey: slot) self.broadcast(#"{"t":"disconnected","slot":\#(slot)}"#) } } - func controllerName(slot: Int, name: String) { queue.async { [weak self] in - guard let self else { return } - if let info = self.connected[slot] { - guard info.name != name else { return } - self.connected[slot] = (info.model, name) - self.broadcast(#"{"t":"name","slot":\#(slot),"name":\#(Self.json(name))}"#) - } else { - self.connected[slot] = ("", name) - } + guard let self, let info = self.connected[slot], info.name != name else { return } + self.connected[slot] = (info.model, name, info.rumble) + if let text = Self.json(["t":"name", "slot":slot, "name":name]) { self.broadcast(text) } } } - func controllerState(slot: Int, state: ControllerState) { queue.async { [weak self] in - guard let self, !self.clients.isEmpty else { return } + guard let self, self.connected[slot] != nil else { return } let next = (self.seq[slot] ?? 0) &+ 1 self.seq[slot] = next - let text = String( - format: #"{"t":"state","slot":%d,"seq":%u,"b":%u,"lx":%.4f,"ly":%.4f,"rx":%.4f,"ry":%.4f,"lt":%d,"rt":%d}"#, - slot, next, state.buttons.rawValue, - state.leftStick.x, state.leftStick.y, state.rightStick.x, state.rightStick.y, - Int(state.leftTrigger), Int(state.rightTrigger)) + guard let text = Self.json([ + "t":"state", "slot":slot, "seq":next, "b":state.buttons.rawValue, + "lx":state.leftStick.x, "ly":state.leftStick.y, + "rx":state.rightStick.x, "ry":state.rightStick.y, + "lt":state.leftTrigger, "rt":state.rightTrigger + ]) else { return } + self.lastState[slot] = text self.broadcast(text) } } diff --git a/Sources/FinallyTheControllerWorks/UI/BrowserBridgeSettings.swift b/Sources/FinallyTheControllerWorks/UI/BrowserBridgeSettings.swift new file mode 100644 index 0000000..b03bb5f --- /dev/null +++ b/Sources/FinallyTheControllerWorks/UI/BrowserBridgeSettings.swift @@ -0,0 +1,24 @@ +import SwiftUI + +struct BrowserBridgeSettings: View { + @AppStorage(WebSocketHub.enabledKey) private var enabled = false + @AppStorage(WebSocketHub.extensionIDsKey) private var extensionIDs = "" + + var body: some View { + Form { + Toggle("Enable browser controller bridge", isOn: $enabled) + TextField("Allowed extension IDs", text: $extensionIDs, axis: .vertical) + .lineLimit(2...4) + .textFieldStyle(.roundedBorder) + Text("Copy the 32-letter ID from chrome://extensions after loading browser/extension. Separate multiple IDs with spaces. No websites or wildcards are accepted.") + .font(.caption) + Text("\(WebSocketHub.origins(from: extensionIDs).count) valid extension ID(s). Quit and relaunch this app after changing these settings.") + .font(.caption) + Text("Disabled by default. The listener is local-only and rejects other browser origins. Programs already running on this Mac can impersonate an origin; this is not native-client authentication.") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(20) + .frame(width: 460) + } +} diff --git a/browser/FORK-INTEGRATION.md b/browser/FORK-INTEGRATION.md new file mode 100644 index 0000000..53fa277 --- /dev/null +++ b/browser/FORK-INTEGRATION.md @@ -0,0 +1,37 @@ +# Fork integration: explicit opt-in and extension access + +This fork keeps the browser listener **disabled by default**. After loading the +unpacked extension, copy its 32-letter ID from chrome://extensions. In the +menu-bar app open **Browser Bridge Settings**, enter that ID, enable the bridge, +and quit/relaunch the app. Multiple IDs may be separated with spaces. An empty +or invalid allowlist does not open a listener. To disable it, clear the toggle +and relaunch. Moving the unpacked extension can change its ID. + +The listener binds only to 127.0.0.1 and accepts exactly one matching +chrome-extension:// Origin header. This excludes arbitrary websites; +it is **not authentication against other programs on the same Mac**, which +can forge an Origin. The extension still runs only on its manifest's named +sites. There is no wildcard-site permission or remote-network listener. + +The hub limits eight clients (including pending handshakes), 64 KiB received +messages, 200 messages/client/second, and 128 messages / 256 KiB awaiting send +completion per client. Slow or invalid clients are disconnected rather than +allowed to grow an unlimited send backlog. The per-report dispatch queue is +not a hard real-time or total-process-memory guarantee. + +Connection replay retains identity after renames and supplies the last state. +Retired sockets cannot clear a replacement. Rumble refreshes every 200 ms only +for the requested effect duration, and disconnect/preemption cancels and +settles it. Durations and start delays must be finite and at most 60 seconds. +A departing native client stops only the rumble it owns. GameCube HD rumble is +not forwarded; verified preset rumble remains unavailable, rather than sending +a motor format the model explicitly declares unsupported. + +Automated checks: node --test tests/browser/*.test.cjs and, on macOS, +bash tests/browser/run.sh. They include an actual Network.framework listener +and raw loopback WebSocket clients. Node uses simulated browser objects; no +Chromium/cloud-game or physical-controller acceptance is implied. + +This adapts Andrei-Kondrykau/switch2mac browser-bridge commit +24b0cd3d225c77c9efcfca42cb4fd4325e2fccf3. Upstream browser/README.md describes +the original integration; this document's opt-in requirements take precedence. diff --git a/browser/extension/background.js b/browser/extension/background.js index 216135c..07d6e2a 100644 --- a/browser/extension/background.js +++ b/browser/extension/background.js @@ -1,39 +1,38 @@ -// background.js — the extension's service worker owns the WebSocket to the -// menu-bar app (ws://127.0.0.1:24810). Extension contexts are not subject to -// Chrome's Local Network Access permission, which would otherwise prompt (or -// silently block) a public https:// page opening a loopback socket. -// -// Content scripts (bridge.js) attach through chrome.runtime.connect ports; -// every hub message is fanned out to all ports, rumble from any port goes to -// the hub. Hub pings (every 15 s) and port traffic keep the worker alive. - +// Service worker owns the loopback WebSocket; content scripts keep only ports. +// Adapted from Andrei-Kondrykau's browser-bridge, with owned callbacks and +// complete replay records. Native hub must explicitly allow this extension ID. const URL = 'ws://127.0.0.1:24810'; const RETRY_MS = 2000; - const ports = new Set(); let socket = null; let retryTimer = null; -let replay = new Map(); // slot → last "connected"/"name" JSON, for late ports +let replay = new Map(); // slot -> {connection, state}; a rename is not a connection -const fanOut = (text) => { for (const port of ports) { try { port.postMessage(text); } catch {} } }; +const fanOut = (text) => { + for (const port of ports) { try { port.postMessage(text); } catch {} } +}; function connect() { retryTimer = null; if (socket || ports.size === 0) return; - try { socket = new WebSocket(URL); } catch { scheduleRetry(); return; } - socket.onopen = () => fanOut('{"t":"bridge","up":true}'); - socket.onmessage = (ev) => { - if (typeof ev.data !== 'string') return; + let owner; + try { socket = owner = new WebSocket(URL); } catch { scheduleRetry(); return; } + owner.onopen = () => { + if (socket === owner) fanOut('{"t":"bridge","up":true}'); + }; + owner.onmessage = (ev) => { + if (socket !== owner || typeof ev.data !== 'string' || ev.data.length > 65536) return; track(ev.data); fanOut(ev.data); }; - socket.onclose = () => { + owner.onclose = () => { + if (socket !== owner) return; socket = null; - replay = new Map(); + replay.clear(); fanOut('{"t":"bridge","up":false}'); scheduleRetry(); }; - socket.onerror = () => {}; + owner.onerror = () => {}; // onclose owns failure/retry } function scheduleRetry() { @@ -41,28 +40,47 @@ function scheduleRetry() { retryTimer = setTimeout(connect, RETRY_MS); } -// Remember per-slot identity so a tab opened later sees connected pads. function track(text) { let m; try { m = JSON.parse(text); } catch { return; } - if (m.t === 'connected' || m.t === 'name') replay.set(m.slot, text); + if (!m || !Number.isInteger(m.slot) || m.slot < 0 || m.slot >= 4) return; + if (m.t === 'connected') replay.set(m.slot, {connection:m, state:null}); else if (m.t === 'disconnected') replay.delete(m.slot); + else { + const saved = replay.get(m.slot); + if (!saved) return; + if (m.t === 'name') saved.connection = {...saved.connection, name:m.name}; + if (m.t === 'state') saved.state = m; + } } chrome.runtime.onConnect.addListener((port) => { - if (port.name !== 'ftcw') return; + if (port.name !== 'ftcw' || ports.size >= 64) return; ports.add(port); port.onMessage.addListener((text) => { - if (typeof text !== 'string' || text === 'ping') return; + if (!ports.has(port) || typeof text !== 'string' || text.length > 65536) return; + let m; + try { m = JSON.parse(text); } catch { return; } + if (!m || !['rumble', 'stats'].includes(m.t) || + !Number.isInteger(m.slot) || m.slot < 0 || m.slot >= 4) return; if (socket && socket.readyState === WebSocket.OPEN) socket.send(text); }); port.onDisconnect.addListener(() => { ports.delete(port); - if (ports.size === 0 && socket) { socket.close(); socket = null; } + if (ports.size !== 0) return; + if (retryTimer !== null) clearTimeout(retryTimer); + retryTimer = null; + const old = socket; + socket = null; // retire before its asynchronous close callback can run + replay.clear(); + old?.close(); }); if (socket && socket.readyState === WebSocket.OPEN) { port.postMessage('{"t":"bridge","up":true}'); - for (const text of replay.values()) port.postMessage(text); + for (const saved of replay.values()) { + port.postMessage(JSON.stringify(saved.connection)); + if (saved.state) port.postMessage(JSON.stringify(saved.state)); + } } else { connect(); } diff --git a/browser/extension/shim.js b/browser/extension/shim.js index 1eb00f7..13978e0 100644 --- a/browser/extension/shim.js +++ b/browser/extension/shim.js @@ -94,40 +94,56 @@ } function makeActuator(slot) { - let timer = null; - let pending = null; + let timer = null, refresh = null, pending = null, generation = 0; + let actuator; + const current = () => bridgeUp && pads.get(slot)?.vibrationActuator === actuator; const finish = (result) => { - if (pending) { const p = pending; pending = null; p(result); } + if (pending) { const resolve = pending; pending = null; resolve(result); } }; - const stop = () => { - if (timer) { clearTimeout(timer); timer = null; } - rumbleToApp(slot, 0, 0); + const stop = (result = 'preempted') => { + generation++; + if (timer !== null) clearTimeout(timer); + if (refresh !== null) clearInterval(refresh); + timer = refresh = null; + if (current()) rumbleToApp(slot, 0, 0); + finish(result); }; - return { + actuator = { type: 'dual-rumble', effects: ['dual-rumble'], playEffect(type, params = {}) { if (type !== 'dual-rumble') return Promise.resolve('invalid-parameter'); - const strong = clamp01(params.strongMagnitude); - const weak = clamp01(params.weakMagnitude); - const duration = Math.max(0, Number(params.duration) || 0); - const startDelay = Math.max(0, Number(params.startDelay) || 0); - if (timer) clearTimeout(timer); - finish('preempted'); + if (!current()) return Promise.resolve('preempted'); + const duration = Number(params.duration ?? 0), delay = Number(params.startDelay ?? 0); + if (!Number.isFinite(duration) || !Number.isFinite(delay) || + duration < 0 || delay < 0 || duration > 60000 || delay > 60000) { + return Promise.resolve('invalid-parameter'); + } + const strong = clamp01(params.strongMagnitude), weak = clamp01(params.weakMagnitude); + stop(); + const owner = generation; return new Promise((resolve) => { pending = resolve; - const start = () => { + const pulse = () => { + if (owner !== generation || !current()) { stop(); return; } rumbleToApp(slot, strong, weak); - timer = setTimeout(() => { timer = null; rumbleToApp(slot, 0, 0); finish('complete'); }, duration); }; - if (startDelay > 0) timer = setTimeout(start, startDelay); else start(); + const start = () => { + if (owner !== generation || !current()) { stop(); return; } + pulse(); + // The native session expires intents after 500 ms. Refresh only + // for the requested effect lifetime; never change controller bytes. + refresh = setInterval(pulse, 200); + timer = setTimeout(() => stop('complete'), duration); + }; + if (delay > 0) timer = setTimeout(start, delay); else start(); }); }, - reset() { stop(); finish('preempted'); return Promise.resolve('complete'); }, + reset() { stop(); return Promise.resolve('complete'); }, stop, }; + return actuator; } - const clamp01 = (v) => Math.min(1, Math.max(0, Number(v) || 0)); const padId = (model, name) => PERSONA === 'xbox' diff --git a/tests/browser/BrowserServer.swift b/tests/browser/BrowserServer.swift new file mode 100644 index 0000000..7f26944 --- /dev/null +++ b/tests/browser/BrowserServer.swift @@ -0,0 +1,34 @@ +import Foundation + +enum LogLevel { case info, warning, error, debug } +func bridgeLog(_ level: LogLevel, _ category: String, _ message: String) { + if message.contains("opt-in browser bridge") { + FileHandle.standardOutput.write(Data("READY\n".utf8)) + } +} +protocol ControllerOutputSink: AnyObject { + var onRumble: ((Int, Double, Double) -> Void)? { get set } + func controllerConnected(slot: Int, model: Switch2.Model) + func controllerDisconnected(slot: Int) + func controllerName(slot: Int, name: String) + func controllerState(slot: Int, state: ControllerState) +} +@main +enum BrowserServer { + static func main() { + precondition(WebSocketHub.origins(from: "bad https://example.com *").isEmpty) + let ids = String(repeating: "a", count: 32) + let origins = WebSocketHub.origins(from: ids) + precondition(origins == ["chrome-extension://" + ids]) + let server = WebSocketHub(enabled: true, allowedOrigins: origins) + server.onRumble = { slot, strong, weak in + FileHandle.standardOutput.write(Data("RUMBLE \(slot) \(strong) \(weak)\n".utf8)) + } + server.controllerConnected(slot: 0, model: .proController2) + server.controllerName(slot: 0, name: "test pad") + var state = ControllerState(); state.buttons = [.a]; state.leftStick = (0.5, 0) + server.controllerState(slot: 0, state: state) + while let command = readLine(), command != "quit" {} + withExtendedLifetime(server) {} + } +} diff --git a/tests/browser/background.test.cjs b/tests/browser/background.test.cjs new file mode 100644 index 0000000..b67c1de --- /dev/null +++ b/tests/browser/background.test.cjs @@ -0,0 +1,74 @@ +const {test} = require('node:test'); +const assert = require('node:assert/strict'); +const vm = require('node:vm'); +const fs = require('node:fs'); +const path = require('node:path'); + +function fixture() { + const sockets = [], timers = new Map(); let next = 0, attach; + class Socket { + static OPEN = 1; + constructor() { this.readyState = 0; this.sent = []; sockets.push(this); } + open() { this.readyState = 1; this.onopen?.(); } + message(obj) { this.onmessage?.({data: JSON.stringify(obj)}); } + close() { this.readyState = 3; } + closed() { this.readyState = 3; this.onclose?.(); } + send(text) { this.sent.push(JSON.parse(text)); } + } + const context = {WebSocket: Socket, console, + setTimeout(fn) {timers.set(++next, fn); return next;}, + clearTimeout(id) {timers.delete(id);}, + chrome: {runtime: {onConnect: {addListener(fn) {attach = fn;}}}}, + }; + vm.runInNewContext(fs.readFileSync(process.env.BACKGROUND_SOURCE || path.join(__dirname, '../../browser/extension/background.js'), 'utf8'), context); + function port() { + const p = {name: 'ftcw', received: [], postMessage(text) {this.received.push(JSON.parse(text));}, + onMessage: {addListener(fn) {p.send = fn;}}, + onDisconnect: {addListener(fn) {p.close = fn;}}, + }; + attach(p); return p; + } + return {port, sockets, timers}; +} + +test('rename replay retains a connection record and latest state for a new tab', () => { + const f = fixture(), a = f.port(), ws = f.sockets[0]; ws.open(); + ws.message({t:'connected', slot:0, model:'NSO GameCube Controller', name:'original'}); + ws.message({t:'name', slot:0, name:'my pad'}); + ws.message({t:'state', slot:0, seq:9, b:8, lx:0.5, ly:0, rx:0, ry:0, lt:0, rt:0}); + const b = f.port(); + assert.deepEqual(b.received.find(e=>e.t==='connected'), {t:'connected',slot:0,model:'NSO GameCube Controller',name:'my pad'}); + assert.equal(b.received.find(e=>e.t==='state')?.b, 8); + a.close(); b.close(); +}); + +test('obsolete socket callbacks cannot clear or feed its replacement', () => { + const f = fixture(), a = f.port(), old = f.sockets[0]; old.open(); a.close(); + const b = f.port(), current = f.sockets[1]; current.open(); + const before = b.received.length; + old.message({t:'connected', slot:3, model:'obsolete', name:'obsolete'}); + old.closed(); + assert.equal(b.received.length, before); + b.send(JSON.stringify({t:'rumble',slot:0,strong:1,weak:0})); + assert.equal(current.sent.length, 1); + assert.equal(f.timers.size, 0); + b.close(); +}); + +test('last tab closure cancels retry and connection replay', () => { + const f = fixture(), a = f.port(), ws = f.sockets[0]; ws.open(); + ws.message({t:'connected',slot:0,model:'pad',name:'pad'}); + ws.closed(); assert.equal(f.timers.size, 1); + a.close(); assert.equal(f.timers.size, 0); + const b = f.port(); f.sockets[1].open(); + assert.equal(b.received.filter(e=>e.t==='connected').length, 0); + b.close(); +}); + +test('only bounded known command messages cross from a page to the local hub', () => { + const f = fixture(), p = f.port(), ws = f.sockets[0]; ws.open(); + for (const text of ['invalid', JSON.stringify({t:'unknown'}), JSON.stringify({t:'rumble',slot:9}), 'x'.repeat(65537)]) p.send(text); + assert.equal(ws.sent.length, 0); + p.send(JSON.stringify({t:'rumble',slot:1,strong:0.3,weak:0})); + assert.equal(ws.sent.length, 1); p.close(); +}); diff --git a/tests/browser/run.sh b/tests/browser/run.sh new file mode 100755 index 0000000..c6dde50 --- /dev/null +++ b/tests/browser/run.sh @@ -0,0 +1,19 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")/../.." +node --test tests/browser/*.test.cjs +[ "$(uname -s)" = Darwin ] || exit 0 +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +python3 - "$work" <<'PY' +from pathlib import Path +import sys +out=Path(sys.argv[1]);base=Path('Sources/FinallyTheControllerWorks') +s=(base/'Bluetooth/ControllerSession.swift').read_text() +a=s.index('struct ControllerState:');b=s.index('/// Called on the Bluetooth queue.',a) +out.joinpath('State.swift').write_text('import Foundation\n'+s[a:b]) +PY +swiftc -swift-version 5 Sources/FinallyTheControllerWorks/Protocol/Switch2Protocol.swift \ + "$work/State.swift" Sources/FinallyTheControllerWorks/Output/WebSocketHub.swift \ + tests/browser/BrowserServer.swift -o "$work/browser-server" +python3 tests/browser/websocket_test.py "$work/browser-server" diff --git a/tests/browser/shim.test.cjs b/tests/browser/shim.test.cjs new file mode 100644 index 0000000..3eeefb8 --- /dev/null +++ b/tests/browser/shim.test.cjs @@ -0,0 +1,82 @@ +const {test} = require('node:test'); +const assert = require('node:assert/strict'); +const vm = require('node:vm'); +const fs = require('node:fs'); +const path = require('node:path'); + +function fixture() { + let now = 0, next = 0; const timers = new Map(), listeners = new Map(), commands = []; + class Event {constructor(type, options={}) {this.type=type; Object.assign(this,options);}} + class Navigator {getGamepads() {return [];}} + const navigator = new Navigator(); + const document = {hidden:false, + addEventListener(type, fn) {listeners.set(type,fn);}, + dispatchEvent(ev) {if(ev.type==='ftcw-up') commands.push(JSON.parse(ev.detail)); else listeners.get(ev.type)?.(ev);}, + }; + const context = {navigator, Navigator, document, window:{dispatchEvent(){}}, Event, CustomEvent:Event, + localStorage:{getItem(){return null;}}, location:{host:'hardwaretester.com'}, performance:{now:()=>now}, + setTimeout(fn, ms=0) {timers.set(++next,{fn,at:now+ms});return next;}, + clearTimeout(id){timers.delete(id);}, + setInterval(fn, ms) {timers.set(++next,{fn,at:now+ms,interval:ms});return next;}, + clearInterval(id){timers.delete(id);}, + }; + vm.runInNewContext(fs.readFileSync(process.env.SHIM_SOURCE || path.join(__dirname,'../../browser/extension/shim.js'),'utf8'),context); + const emit = m => document.dispatchEvent(new Event('ftcw-bridge',{detail:JSON.stringify(m)})); + function advance(ms) { + const end = now+ms; + for(let i=0;i<10000;i++) { + const pending = [...timers].filter(([,t])=>t.at<=end).sort((a,b)=>a[1].at-b[1].at)[0]; + if(!pending) {now=end; return;} + const [id,t]=pending; now=t.at; + if(t.interval) t.at+=t.interval; else timers.delete(id); + t.fn(); + } + throw Error('Timer loop did not terminate'); + } + const pad=()=>navigator.getGamepads().find(Boolean); + emit({t:'bridge',up:true}); emit({t:'connected',slot:0,model:'Pro Controller 2',name:'pad'}); + return {emit,advance,commands,pad,navigator}; +} + +test('long rumble refreshes within the native half-second intent expiry', async()=>{ + const f=fixture(); const promise=f.pad().vibrationActuator.playEffect('dual-rumble',{strongMagnitude:1,duration:1100}); + f.advance(1000); + assert.ok(f.commands.filter(m=>m.t==='rumble' && m.strong===1).length>=5); + f.advance(100); assert.equal(await promise,'complete'); + assert.equal(f.commands.at(-1).strong,0); +}); + +test('disconnect cancels delayed effects and settles their promises', async()=>{ + const f=fixture(); let result; + const old=f.pad().vibrationActuator; + old.playEffect('dual-rumble',{strongMagnitude:1,startDelay:100,duration:2000}).then(v=>result=v); + f.emit({t:'disconnected',slot:0}); + await Promise.resolve(); + assert.equal(result,'preempted'); + f.emit({t:'connected',slot:0,model:'Pro Controller 2',name:'replacement'}); + const count=f.commands.length; + f.advance(2500); + assert.equal(f.commands.length,count); + await old.playEffect('dual-rumble',{strongMagnitude:1,duration:20}); + await old.reset(); + assert.equal(f.commands.length,count, 'A stale actuator must not address the replacement slot'); +}); + +test('preemption stops the old effect during a new start delay', async()=>{ + const f=fixture(), actuator=f.pad().vibrationActuator; + const first=actuator.playEffect('dual-rumble',{strongMagnitude:1,duration:1000}); + const second=actuator.playEffect('dual-rumble',{weakMagnitude:1,startDelay:400,duration:100}); + assert.equal(await first,'preempted'); assert.equal(f.commands.at(-1).strong,0); + f.advance(500); assert.equal(await second,'complete'); +}); + +test('input snapshots remain independent and trigger/axis mappings are preserved',()=>{ + const f=fixture(); + f.emit({t:'state',slot:0,b:4,lx:0.3,ly:0.5,rx:-0.1,ry:0,lt:128,rt:255}); + const before=f.pad(); + f.emit({t:'state',slot:0,b:0,lx:0,ly:0,rx:0,ry:0,lt:0,rt:0}); + const after=f.pad(); + assert.equal(before.buttons[0].pressed,true); assert.equal(after.buttons[0].pressed,false); + assert.equal(before.axes[1],-0.5); assert.equal(before.buttons[6].value,128/255); + assert.equal(before.buttons[7].value,1); +}); diff --git a/tests/browser/websocket_test.py b/tests/browser/websocket_test.py new file mode 100644 index 0000000..3690290 --- /dev/null +++ b/tests/browser/websocket_test.py @@ -0,0 +1,145 @@ +"""Real Apple Network.framework listener, synthetic clients, no browser/radio.""" +import base64 +import contextlib +import json +import os +import select +import socket +import struct +import subprocess +import sys +import time + +ORIGIN = 'chrome-extension://' + 'a' * 32 + + +def line(proc): + ready, _, _ = select.select([proc.stdout], [], [], 8) + assert ready, 'Native listener response timed out' + data = proc.stdout.readline().decode().strip() + assert data, 'Native listener exited unexpectedly' + return data + + +@contextlib.contextmanager +def server(): + proc = subprocess.Popen([sys.argv[1]], stdin=subprocess.PIPE, stdout=subprocess.PIPE) + try: + assert line(proc) == 'READY' + yield proc + finally: + if proc.poll() is None: + proc.stdin.write(b'quit\n'); proc.stdin.flush() + try: + proc.wait(timeout=3) + except subprocess.TimeoutExpired: + proc.kill(); proc.wait(timeout=3) + proc.stdin.close(); proc.stdout.close() + + +def connect(origin=ORIGIN, duplicate=False): + sock = socket.create_connection(('127.0.0.1', 24810), timeout=3) + key = base64.b64encode(os.urandom(16)).decode() + request = f'GET / HTTP/1.1\r\nHost: 127.0.0.1:24810\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: {key}\r\n' + if origin is not None: + request += f'Origin: {origin}\r\n' + if duplicate: + request += f'Origin: {ORIGIN}\r\n' + sock.sendall((request+'\r\n').encode()) + response = bytearray() + try: + while not response.endswith(b'\r\n\r\n'): + b = sock.recv(1) + if not b: + break + response.extend(b) + except (OSError, TimeoutError): + pass + return sock, b' 101 ' in response.split(b'\r\n')[0] + + +def exact(sock, size): + data = bytearray() + while len(data) < size: + chunk = sock.recv(size-len(data)) + if not chunk: + raise EOFError() + data.extend(chunk) + return bytes(data) + + +def frame(sock): + a, b = exact(sock, 2) + n = b & 127 + if n == 126: + n = struct.unpack('!H', exact(sock, 2))[0] + elif n == 127: + n = struct.unpack('!Q', exact(sock, 8))[0] + mask = exact(sock, 4) if b & 128 else None + data = exact(sock, n) + if mask: + data = bytes(x ^ mask[i % 4] for i, x in enumerate(data)) + return a & 15, data + + +def send(sock, message): + data = message if isinstance(message, bytes) else json.dumps(message).encode() + mask = b'\x12\x34\x56\x78' + if len(data) < 126: + prefix = bytes([0x81, 0x80 | len(data)]) + elif len(data) < 65536: + prefix = b'\x81\xfe' + struct.pack('!H', len(data)) + else: + prefix = b'\x81\xff' + struct.pack('!Q', len(data)) + sock.sendall(prefix + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(data))) + + +with server() as proc: + for origin, duplicate in [(None, False), ('https://example.com', False), (ORIGIN+'evil', False), (ORIGIN, True)]: + s, accepted = connect(origin, duplicate) + s.close() + assert not accepted, f'Unapproved origin accepted: {origin}' + a, accepted = connect(); assert accepted + messages = [json.loads(frame(a)[1]) for _ in range(3)] + assert [m['t'] for m in messages] == ['hello', 'connected', 'state'] + assert messages[1]['name'] == 'test pad' and messages[2]['b'] == 8 + # Malformed/unknown/out-of-range input must not produce a rumble callback. + for bad in [b'invalid', {'t':'rumble','slot':9,'strong':1,'weak':0}, {'t':'other'}]: + send(a, bad) + send(a, {'t':'rumble','slot':0,'strong':1,'weak':0}) + assert line(proc) == 'RUMBLE 0 1.0 0.0' + b, accepted = connect(); assert accepted + for _ in range(3): frame(b) + send(b, {'t':'rumble','slot':0,'strong':0.5,'weak':0}) + assert line(proc) == 'RUMBLE 0 0.5 0.0' + a.close(); time.sleep(0.1) + send(b, {'t':'rumble','slot':0,'strong':0.25,'weak':0}) + assert line(proc) == 'RUMBLE 0 0.25 0.0', 'Departing observer stopped another client effect' + b.close() + assert line(proc) == 'RUMBLE 0 0.0 0.0' + print('PASS origin checks, replay, framing and rumble ownership') + +with server() as proc: + sockets = [] + try: + for _ in range(8): + s, accepted = connect(); sockets.append(s); assert accepted + for _ in range(3): frame(s) + extra, accepted = connect(); extra.close() + assert not accepted, 'Connection cap not enforced' + finally: + for s in sockets: s.close() + print('PASS native connection capacity') + +with server() as proc: + s, accepted = connect(); assert accepted + for _ in range(3): frame(s) + send(s, b'x' * 65537) + try: + opcode, _ = frame(s) + assert opcode == 8, 'Oversized message was not rejected' + except (EOFError, ConnectionResetError): + pass + finally: + s.close() + print('PASS native message size bound') From 15e4414e3c03f6bab5801f78100710d1c1537dee Mon Sep 17 00:00:00 2001 From: Johnny D Date: Wed, 9 Sep 2026 14:18:34 -0400 Subject: [PATCH 3/6] fix(browser): distinguish validated rumble values from clamped magnitudes --- Sources/FinallyTheControllerWorks/Output/WebSocketHub.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/FinallyTheControllerWorks/Output/WebSocketHub.swift b/Sources/FinallyTheControllerWorks/Output/WebSocketHub.swift index d09acf4..1267891 100644 --- a/Sources/FinallyTheControllerWorks/Output/WebSocketHub.swift +++ b/Sources/FinallyTheControllerWorks/Output/WebSocketHub.swift @@ -153,9 +153,9 @@ final class WebSocketHub: ControllerOutputSink, @unchecked Sendable { let type = object["t"] as? String else { return } if type == "rumble" { guard let slot = object["slot"] as? Int, (0..<4).contains(slot), connected[slot]?.rumble == true, - let strong = object["strong"] as? Double, strong.isFinite, - let weak = object["weak"] as? Double, weak.isFinite else { return } - let strong = min(1, max(0, strong)), weak = min(1, max(0, weak)) + let rawStrong = object["strong"] as? Double, rawStrong.isFinite, + let rawWeak = object["weak"] as? Double, rawWeak.isFinite else { return } + let strong = min(1, max(0, rawStrong)), weak = min(1, max(0, rawWeak)) if strong == 0 && weak == 0 { guard rumbleOwners[slot] == id else { return } rumbleOwners.removeValue(forKey: slot) From d1af8a36b71dbc5a7ec184f607af5db336d2b3cf Mon Sep 17 00:00:00 2001 From: Johnny D Date: Wed, 9 Sep 2026 14:44:33 -0400 Subject: [PATCH 4/6] docs(browser): document fork opt-in setup and actual compatibility limits --- browser/README.md | 166 +++++++++++++++++++--------------------------- 1 file changed, 70 insertions(+), 96 deletions(-) diff --git a/browser/README.md b/browser/README.md index 354e884..d8a2674 100644 --- a/browser/README.md +++ b/browser/README.md @@ -1,119 +1,94 @@ # The browser bridge -Use Switch 2 controllers in **web games** — Xbox Cloud Gaming -(xbox.com/play), GeForce NOW, Amazon Luna, any page that uses the -Gamepad API — today, without waiting for Apple's virtual-HID approval. - -Browsers only see gamepads that macOS knows about, and a Switch 2 -controller over Bluetooth LE is not a HID device macOS can pair with -(that is why it never appears in System Settings → Bluetooth). Until the -app can create system-wide virtual controllers, this bridge does for the -browser what the SDL bridge does for emulators: +This optional output lets supported Chromium web games consume controller +state from the native menu-bar app without a system-wide virtual HID device. +It adapts Andrei-Kondrykau's browser-bridge contribution; the current fork's +access and lifecycle changes are described in [FORK-INTEGRATION.md](FORK-INTEGRATION.md). ``` Controller ──BLE──> menu-bar app ──ws://127.0.0.1:24810──> extension ──> navigator.getGamepads() <──────── rumble ─────────────────── vibrationActuator ``` -| File | What it is | +| File | Purpose | |---|---| -| `extension/manifest.json` | Manifest V3 extension for Chrome, Edge, Brave, Arc, Vivaldi, Opera — any Chromium browser. | -| `extension/background.js` | Service worker that owns the WebSocket to the app (auto-reconnects). Lives in the extension, so Chrome's *Local Network Access* permission (Chrome 138+) never prompts or blocks. | -| `extension/bridge.js` | Content script relaying messages between the service worker and the page. | -| `extension/shim.js` | Wraps `navigator.getGamepads()` with standard-mapping virtual gamepads and forwards rumble. | - -## Install (about a minute) - -1. Run the menu-bar app (v0.4+ / this branch). The log shows - `browser bridge on ws://127.0.0.1:24810`. -2. In your Chromium browser open `chrome://extensions` - (`edge://extensions`, `brave://extensions`, …), switch on - **Developer mode**, click **Load unpacked**, and choose this - `browser/extension` folder. -3. Open , press a button on the - controller: it appears as *Pro Controller 2 (STANDARD GAMEPAD …)* - with the standard layout. -4. Open and play. Rumble works. - -Safari is not supported: it blocks `ws://` connections from `https://` -pages and cannot load unpacked extensions. Firefox is not supported -either (different extension packaging); Chrome, Edge, Brave, Arc, -Vivaldi and Opera all work. - -Verified on xbox.com/play with a Pro Controller 2: sticks, buttons, -triggers and rumble. - -## If nothing shows up - -Work down the list; each step depends on the one before. - -1. **Is the app running with the bridge?** Open the dashboard log and - look for `browser bridge on ws://127.0.0.1:24810`. If it says the - port is busy, another copy of the app is running — quit it. -2. **Is the controller connected?** The menu-bar icon fills in and the - dashboard shows the player. If not, press any button (paired) or - hold Sync next to the USB-C port (new controller). -3. **Is the extension loaded and enabled?** `chrome://extensions` must - list *Finally the Controller Works — Browser Bridge* with the toggle - on, no red error badge. After editing any file in `extension/`, - click its reload icon. -4. **Is the site in the list?** The extension only runs on the sites in - `manifest.json` → `matches`. Add yours and reload the extension. -5. **Reload the game tab.** The shim installs when the page loads; a tab - that was open before the extension loaded never gets it. -6. **Still nothing?** Open — it is - in the list — and press a button. If the pad appears there but not - in the game, the game is the problem (some sites ignore gamepads - that connect after the page loaded: reload with the controller - already on). If it does not appear there either, open the tab's - DevTools console and look for `ftcw` errors, then file an issue with - that output. - -The service worker's own console (`chrome://extensions` → *Inspect -views: service worker*) shows the WebSocket state if you need to go -deeper. +| `extension/manifest.json` | Manifest V3; injection is limited to named game/test sites. | +| `extension/background.js` | Owns the WebSocket and complete connection/state replay for tabs. | +| `extension/bridge.js` | Relays messages between the service worker and page. | +| `extension/shim.js` | Exposes standard-mapping gamepad snapshots and forwards supported rumble. | + +## Install in this fork + +1. Build and run this branch's menu-bar app. The browser listener is disabled + by default; the upstream release does not include these fork repairs. +2. In a Chromium browser open `chrome://extensions`, enable **Developer mode**, + click **Load unpacked**, and select `browser/extension`. Copy its 32-letter ID. +3. Open **Browser Bridge Settings** in the menu-bar app. Enter that extension + ID, enable the bridge, and quit/relaunch the app. Empty/invalid IDs do not + open a listener. Moving the unpacked extension can change its ID. +4. Open and verify every control, then + test the intended game. Reload the extension after editing its files. + +The provided extension targets Chromium; no Safari or Firefox package is +included. The original contributor reported Xbox Cloud Gaming with a Pro +Controller 2. This fork has automated Node and real macOS WebSocket tests, +not a new physical-controller/cloud-game acceptance result. + +The listener accepts only configured extension Origins, not arbitrary +websites. Local native programs can forge Origin, so this is not authentication +against other software running as the same user. See the integration notes +for connection, message, and pending-send limits. + +GameCube HD rumble is intentionally not forwarded; verified preset rumble +remains unavailable. Other models' effects refresh only for their requested +lifetime, within the native session's existing 0.5-second intent timeout. + +## Troubleshooting + +Check the dashboard log for `opt-in browser bridge on 127.0.0.1:24810`. +Verify the toggle and allowed extension ID, then relaunch. A second running +copy can occupy the port. The controller must separately appear connected in +the dashboard before its input can reach the browser. + +Confirm that the extension is enabled and the current site matches an entry +in `manifest.json`. After loading/reloading the extension, reload game tabs: +the shim installs when the page loads. The service-worker console is available +under `chrome://extensions` → Inspect views. Include its errors, browser/OS +version and the tested app commit when reporting a problem. ## Layout -Buttons are mapped **by position** so on-screen Xbox prompts match your -thumb: the bottom face button (Switch **B**) is standard index 0 (Xbox -**A**), the right one (Switch **A**) is index 1 (Xbox **B**), and so on. -If you prefer label mapping (Switch A → Xbox A), set `NINTENDO_LABELS` -to `true` at the top of `shim.js` and reload the extension. +The inherited positional layout maps Switch B/A/Y/X to standard gamepad +indices 0/1/2/3. Set `NINTENDO_LABELS` to `true` in `shim.js` for label-based +mapping, then reload the extension. Check the GameCube's different physical +layout in the intended game rather than assuming Pro Controller ergonomics. | Standard index | Xbox name | Switch 2 control | |---|---|---| | 0 / 1 / 2 / 3 | A / B / X / Y | B / A / Y / X | | 4 / 5 | LB / RB | L / R | -| 6 / 7 | LT / RT | ZL / ZR (analog on the GameCube pad) | +| 6 / 7 | LT / RT | ZL / ZR, with analog values on GameCube | | 8 / 9 | View / Menu | − / + | | 10 / 11 | LS / RS | stick clicks | | 12–15 | D-pad | D-pad | | 16 | Xbox | Home | | 17 | Share | Capture | -| 18 / 19 / 20 | — | C / GL / GR (only with `EXTRA_BUTTONS = true` in `shim.js`) | - -Button remapping in the app's dashboard applies before the bridge, so -custom layouts carry over. +| 18 / 19 / 20 | — | C / GL / GR, only with `EXTRA_BUTTONS = true` | -## Identity (persona) +App-side remapping runs before this output. Trigger travel, physical clicks, +and per-game mappings still require hardware acceptance. -Sites read the vendor id out of `gamepad.id` and choose glyphs and -vendor-specific handling from it. By default the pad keeps its real -identity (*Pro Controller 2 … Vendor: 057e*). `PERSONA_DEFAULT` in -`shim.js` switches every site to an *Xbox Wireless Controller* identity, -and a single site can be overridden from its DevTools console with -`localStorage.ftcwPersona = 'xbox'` (or `'nintendo'`; remove the key to -reset), then a reload. GeForce NOW sends an "is Xbox" flag to its servers -with every input packet, so try the Xbox identity there if sticks feel -off. +## Identity and sites -## Adding a site +The default is a Nintendo compatibility persona (`Vendor: 057e Product: 2069`), +not an assertion of every model's physical product ID. `PERSONA_DEFAULT` in +`shim.js` can select the inherited Xbox persona. A site's local override is +`localStorage.ftcwPersona = 'xbox'` (or `'nintendo'`), followed by reload. -The extension only injects into the sites listed in `manifest.json` -(`matches`). Add a pattern for another game site, then reload the -extension. Multiple tabs may be connected at once; each gets the same -controllers. +The extension injects only into the sites listed in its manifest. Adding a +site broadens that access; add only intended game sites and reload. Multiple +tabs receive the same controllers. Native-client rumble ownership does not +arbitrate competing pages sharing the same extension connection. ## Protocol @@ -126,12 +101,11 @@ hub → page {"t":"hello","v":1} {"t":"state","slot":0,"seq":123,"b":, "lx":…,"ly":…,"rx":…,"ry":…,"lt":0-255,"rt":0-255} (+y = up) {"t":"disconnected","slot":0} - {"t":"ping"} every 15 s + {"t":"ping"} page → hub {"t":"rumble","slot":0,"strong":0…1,"weak":0…1} ``` -`b` uses the app's `Switch2.Buttons` bit layout -(`Sources/FinallyTheControllerWorks/Protocol/Switch2Protocol.swift`). -New clients get `hello` plus a `connected` per active player. Anything -that speaks WebSocket can subscribe — the extension is just the -reference client. +`b` retains the app's Switch2.Buttons layout. Approved clients receive hello, +complete current identity/name, and the last state for each active player. +A rename no longer replaces the connection record in replay. All controller +protocol decoding remains in the existing native implementation. From 1babdaf6b802d9e78db97c1cec32dcecd9778af7 Mon Sep 17 00:00:00 2001 From: Johnny D Date: Wed, 9 Sep 2026 19:31:57 -0400 Subject: [PATCH 5/6] fix(browser): preserve bridged controllers when native gamepads hotplug Move only a conflicting virtual index and announce the old/new indices, without mutating prior snapshots or hiding the native device. A regression fails against the previous shim and all nine Node cases pass after repair. --- browser/extension/shim.js | 11 +++++++++++ tests/browser/shim.test.cjs | 31 +++++++++++++++++++++++++++---- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/browser/extension/shim.js b/browser/extension/shim.js index 13978e0..3f5d713 100644 --- a/browser/extension/shim.js +++ b/browser/extension/shim.js @@ -282,6 +282,17 @@ const real = Array.from(nativeGetGamepads.call(this)); if (pads.size === 0) return real; getCalls++; + // A native device can occupy an index after a virtual pad was announced. + // Relocate only the collision; never silently hide a still-connected pad. + for (const pad of pads.values()) { + if (real[pad.index] != null) { + const previous = snapshot(pad); + previous.connected = false; + assignIndex(pad); + fire('gamepaddisconnected', previous); + fire('gamepadconnected', pad); + } + } for (const pad of pads.values()) { while (real.length <= pad.index) real.push(null); if (real[pad.index] === null || real[pad.index] === undefined) real[pad.index] = snapshot(pad); diff --git a/tests/browser/shim.test.cjs b/tests/browser/shim.test.cjs index 3eeefb8..f4eba94 100644 --- a/tests/browser/shim.test.cjs +++ b/tests/browser/shim.test.cjs @@ -5,15 +5,15 @@ const fs = require('node:fs'); const path = require('node:path'); function fixture() { - let now = 0, next = 0; const timers = new Map(), listeners = new Map(), commands = []; + let now = 0, next = 0; const timers = new Map(), listeners = new Map(), commands = [], events = [], nativePads = []; class Event {constructor(type, options={}) {this.type=type; Object.assign(this,options);}} - class Navigator {getGamepads() {return [];}} + class Navigator {getGamepads() {return nativePads;}} const navigator = new Navigator(); const document = {hidden:false, addEventListener(type, fn) {listeners.set(type,fn);}, dispatchEvent(ev) {if(ev.type==='ftcw-up') commands.push(JSON.parse(ev.detail)); else listeners.get(ev.type)?.(ev);}, }; - const context = {navigator, Navigator, document, window:{dispatchEvent(){}}, Event, CustomEvent:Event, + const context = {navigator, Navigator, document, window:{dispatchEvent(event){events.push(event);}}, Event, CustomEvent:Event, localStorage:{getItem(){return null;}}, location:{host:'hardwaretester.com'}, performance:{now:()=>now}, setTimeout(fn, ms=0) {timers.set(++next,{fn,at:now+ms});return next;}, clearTimeout(id){timers.delete(id);}, @@ -35,7 +35,7 @@ function fixture() { } const pad=()=>navigator.getGamepads().find(Boolean); emit({t:'bridge',up:true}); emit({t:'connected',slot:0,model:'Pro Controller 2',name:'pad'}); - return {emit,advance,commands,pad,navigator}; + return {emit,advance,commands,pad,navigator,events,nativePads}; } test('long rumble refreshes within the native half-second intent expiry', async()=>{ @@ -80,3 +80,26 @@ test('input snapshots remain independent and trigger/axis mappings are preserved assert.equal(before.axes[1],-0.5); assert.equal(before.buttons[6].value,128/255); assert.equal(before.buttons[7].value,1); }); + + +test('native hotplug cannot hide a bridged pad or move unrelated virtual indices',()=>{ + const f=fixture(); + f.emit({t:'connected',slot:1,model:'Pro Controller 2',name:'second'}); + f.emit({t:'state',slot:0,b:4,lx:0.25,ly:0,rx:0,ry:0,lt:0,rt:0}); + const old=f.navigator.getGamepads()[0]; + f.nativePads[0]={id:'native',index:0,connected:true}; + const pads=f.navigator.getGamepads(); + assert.equal(pads[0].id,'native'); + assert.equal(pads[1].__ftcwSlot,1); + assert.equal(pads[2]?.__ftcwSlot,0,'Native hotplug hid the bridged controller'); + assert.equal(pads[2].buttons[0].pressed,true); + assert.equal(old.index,0,'Existing snapshots must not be mutated'); + const removal=f.events.findLast(e=>e.type==='gamepaddisconnected'); + assert.equal(removal.gamepad.index,0); + assert.equal(removal.gamepad.connected,false); + assert.equal(f.events.at(-1).type,'gamepadconnected'); + assert.equal(f.events.at(-1).gamepad.index,2); + const count=f.events.length; + f.navigator.getGamepads(); + assert.equal(f.events.length,count,'Stable indices must not fire duplicate hotplug events'); +}); From 3d6f41300eb7fcad8815a50a1efcf143d4757b46 Mon Sep 17 00:00:00 2001 From: Johnny D Date: Wed, 9 Sep 2026 20:01:25 -0400 Subject: [PATCH 6/6] Adopt optional RetroArch network output with input-delivery regressions (#6) * feat(retroarch): import the optional network gamepad sink for regression validation Adapted from vialoh/switch2mac retroarch-network-gamepad at 2c7a396336f5a657a16a772b8c056d96ec6ff7f1 (upstream PR #1). Retain the contributor's opt-in default and attribution; validate actual Swift builds and repair edge/lifecycle behavior before marking ready. * fix(retroarch): preserve edges, refresh releases and retire old destinations Retain the attributed optional fork output and existing remote_message bytes. Replace latest-only button state with a bounded edge queue; overflow explicitly neutralizes this output until disable/re-enable. Include zeros in periodic refreshes and clear the old port before switching destinations. Reject nonfinite axes and remove the imported Array safe-subscript compile failure. Eight synthetic/real-loopback cases pass locally; the tap, release refresh, destination, overflow and finite-axis cases fail against the imported source. UDP acceptance is not acknowledgement; no physical-gameplay claim is made. * fix(retroarch): resume ordered input after cancelling a destination change A partially completed reset left switchIndex active when the user changed back to the original port. Subsequent button edges were never queued. Reassert the desired state and retire that reset; the new real-loopback regression fails against the previous head and the nine-case suite passes. * fix(fork): isolate app identity and disable upstream automatic updates (#9) Use io.github.jmonster.switch2mac and a distinct bundle name/defaults domain. Keep upstream credits but reject default or saved update feeds and updater entry points until a fork-specific signing/update trust path is established. Require explicit signing identities, notary credentials and fork-matching entitlements instead of silently consuming upstream signing configuration. No certificate, profile, notarization request or release is created here. Add metadata, actual feed-resolver and early signing-refusal regressions and build the actual ad-hoc fork app in the existing read-only macOS check. --- .github/workflows/macos-validation.yml | 6 +- README.md | 233 ++++++------------ Resources/Info.plist | 6 +- .../FinallyTheControllerWorks/FTCWApp.swift | 1 + .../Output/NetworkGamepadSink.swift | 218 ++++++++++++++++ .../UI/AboutAndOnboarding.swift | 11 +- .../UI/AppNotifications.swift | 11 + .../UI/DashboardView.swift | 19 ++ .../UI/Updater.swift | 13 +- docs/fork-identity.md | 33 +++ docs/retroarch-integration.md | 53 ++++ scripts/build-app.sh | 21 +- scripts/notarize.sh | 76 +----- tests/fork/check.py | 61 +++++ tests/fork/run.sh | 9 + tests/retroarch/NetworkTests.swift | 137 ++++++++++ tests/retroarch/run.sh | 21 ++ 17 files changed, 681 insertions(+), 248 deletions(-) create mode 100644 Sources/FinallyTheControllerWorks/Output/NetworkGamepadSink.swift create mode 100644 docs/fork-identity.md create mode 100644 docs/retroarch-integration.md create mode 100644 tests/fork/check.py create mode 100644 tests/fork/run.sh create mode 100644 tests/retroarch/NetworkTests.swift create mode 100644 tests/retroarch/run.sh diff --git a/.github/workflows/macos-validation.yml b/.github/workflows/macos-validation.yml index 029c7ac..0c66d87 100644 --- a/.github/workflows/macos-validation.yml +++ b/.github/workflows/macos-validation.yml @@ -23,13 +23,13 @@ jobs: swift --version xcodebuild -version git archive HEAD -o "$RUNNER_TEMP/source.zip" - - name: Run controller protocol regressions (no radio required) + - name: Run controller protocol and output regressions (no radio required) run: bash tests/run.sh - name: Build the actual app bundle (ad-hoc signing only) run: | bash scripts/build-app.sh - codesign --verify --strict "build/Finally the Controller Works.app" - plutil -lint "build/Finally the Controller Works.app/Contents/Info.plist" + codesign --verify --strict "build/Finally the Controller Works (jmonster).app" + plutil -lint "build/Finally the Controller Works (jmonster).app/Contents/Info.plist" - name: Preserve exact tested source if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 diff --git a/README.md b/README.md index b3b5762..b202d74 100644 --- a/README.md +++ b/README.md @@ -1,171 +1,76 @@ -# Finally the Controller Works +# switch2mac — jmonster development fork -Use Nintendo Switch 2 controllers on your Mac — Pro Controller 2, -Joy-Con 2 (solo or as a linked pair), and the NSO GameCube pad — over -Bluetooth, up to four at once. A native menu-bar app: launch it, press a -button on your controller, play. The first of its kind. +A macOS menu-bar bridge for Switch 2 Pro Controller, Joy-Con 2 and the NSO +GameCube controller, based on Peter Sharma's +[Finally the Controller Works](https://github.com/Peterksharma/switch2mac). +This fork concentrates on input delivery, controller ownership and tested +output integrations. It is not a Nintendo product or an official upstream release. -**Status: beta.** The release build is Developer ID-signed and works -today. One honest caveat, explained below: until Apple approves the -app's driver entitlement, games can't see the controllers *directly* — -you use the provided SDL bridge for that (Gopher64 works now). +## Build this fork -## The plan, and the Apple wait - -The goal is for every controller to appear to macOS as a normal game -controller that any app can use (CoreHID virtual gamepads, macOS 15+). -That requires the `com.apple.developer.hid.virtual.device` entitlement, -which is **currently waiting on Apple's approval**. Until it arrives: - -- Everything in the dashboard works: connection, battery, sensors, - calibration, rumble, LEDs, button remapping, Joy-Con mouse mode. -- **To use controllers in a game or emulator**, the app publishes - controller state over local UDP (`udp://127.0.0.1:24800-24803`, one - port per player), and a patched build of SDL with an `SDL_S2UDP` - joystick backend picks it up. Any SDL-based program launched with - that library sees real game controllers — including rumble flowing - back to the controller. - -- **To use controllers in a web game** — Xbox Cloud Gaming, GeForce NOW, - Luna — the app also serves controller state on - `ws://127.0.0.1:24810`, and a small browser extension in - [`browser/`](browser/) presents them to the page as standard - gamepads, rumble included. Chromium browsers only. - -Once Apple's approval lands, the SDL and browser steps become optional -and controllers will just show up system-wide. - -## Install - -1. Download the latest release from the - [Releases page](https://github.com/Peterksharma/switch2mac/releases), - unzip, and drag **Finally the Controller Works.app** to Applications. -2. Launch it — it lives in the menu bar (game-controller icon). -3. Pair: hold the **Sync** button on the controller (next to the USB-C - port) until the player LEDs sweep. After that first pairing, just - press any button to reconnect. -4. Grant Bluetooth permission when macOS asks. That's the only required - permission; Notifications and Accessibility are optional extras. - -The app auto-updates from this repository's releases (every update is -signature-verified before install). - -## Using it with Gopher64 (N64 emulator) - -Gopher64 is SDL-based, so it works through the bridge today: - -1. Get the patched SDL library from this repo: [`sdl/`](sdl/). -2. Launch Gopher64 with the patched library (see `sdl/README.md` for - the exact launch command). -3. Start the menu-bar app, connect your controller, and it appears in - Gopher64 as a standard game controller — sticks, buttons, and rumble. - -The same recipe works for any SDL3-based emulator or game — see -[`sdl/README.md`](sdl/README.md) for the general one-line launch method. - -## Using it with Xbox Cloud Gaming (or any web game) - -Verified on xbox.com/play. Chromium browsers only (Chrome, Edge, Brave, -Arc, Vivaldi, Opera); Safari and Firefox cannot run this bridge. - -1. Get the extension folder: clone this repo, or download the ZIP from - GitHub (**Code → Download ZIP**) and unpack it. You need the - [`browser/extension`](browser/extension) folder. -2. In the browser open `chrome://extensions` (`edge://extensions`, - `brave://extensions`, …), turn on **Developer mode** (top right), - click **Load unpacked** and pick that `browser/extension` folder. -3. Start the menu-bar app and press a button on the controller so it - connects. -4. Open and play: the controller is a - standard gamepad, with rumble. Xbox prompts match the physical - positions (Switch B is where Xbox A is). - -Button table, adding other game sites, troubleshooting and the wire -protocol are in [`browser/README.md`](browser/README.md). - -## Features - -**Working now, in the beta UI** - -- Bluetooth connection for up to 4 controllers (Pro Controller 2, - Joy-Con 2 L/R and linked pairs, NSO GameCube pad), auto-reconnecting - on any button press once paired -- The 1 Hz keep-alive write that stops macOS silently dropping the link - ~15 s in (empirically discovered; Linux/Windows don't need it) -- Live dashboard: input test, battery percentage with charge state, - hidden-sensor readouts (temperature, voltage trend, runtime estimate) -- Motion instruments: attitude bubble, gyro bars, tilt-compensated - compass -- Stick calibration (factory + user recenter), per-stick deadzones, - axis inversion, trigger thresholds -- Rumble with per-controller intensity, player-LED patterns -- **Find My Controller** — LED chase + rumble pulse + Bluetooth - proximity meter -- Button remapping per controller -- Joy-Con 2 **mouse mode** (the optical sensor, used flat on the desk) -- UDP/SDL bridge for games and emulators, with game rumble passthrough -- WebSocket/browser bridge for web games (Xbox Cloud Gaming, GeForce NOW), - with rumble passthrough -- Signed auto-updates, first-run tour, settings import/export, live - log with BLE gap diagnostics, launch-at-login - -**Built, but hidden until they're polished (or until Apple approval)** - -- Virtual system-wide game controllers (CoreHID) — blocked on the - entitlement above -- Keyboard mapping (controller buttons → keystrokes, per-app profiles) -- Air-gesture macros, Reaction Draft party game, Sensor Challenges -- Protocol experiments: NFC/amiibo reading, controller-audio research - -## Research - -The protocol knowledge behind this app — including original -reverse-engineering of the Switch 2 controller BLE protocol and the -ongoing controller-audio investigation — is published in -[`research/`](research/). Start with -[research/README.md](research/README.md). - -## Building from source +Use a macOS development environment with Swift 6 and Apple SDKs that provide +CoreHID. The hosted build check uses macOS 26; the package currently declares +macOS 15, but an actual macOS 15 runtime has not been qualified here. ```sh -./scripts/build-app.sh # ad-hoc: everything except virtual HID -SIGN_IDENTITY="Developer ID Application: …" \ -PROVISIONING_PROFILE=path/to.provisionprofile \ - ./scripts/build-app.sh # full build incl. virtual gamepads -``` - -Output: `build/Finally the Controller Works.app`. Swift 6 toolchain, -macOS 15+ target, no external dependencies. - -## Architecture - +git clone https://github.com/jmonster/switch2mac.git +cd switch2mac +bash tests/run.sh +bash scripts/build-app.sh ``` -Controller ──BLE──> BridgeEngine ──> ControllerSession (per slot) - │ handshake, keep-alive, decode, rumble - ▼ - ControllerOutputSink protocol - ├── VirtualHIDSink (CoreHID; entitlement-gated) - ├── UDPHub (SDL-compat, ports 24800-24803) - └── WebSocketHub (browser extension, ws://127.0.0.1:24810) -``` - -- `Protocol/Switch2Protocol.swift` — the wire protocol, transport-free. -- `Bluetooth/` — CoreBluetooth engine + per-controller session state machine. -- `Output/` — the two sinks. -- `UI/` — SwiftUI dashboard (status cards + live log) and menu bar. - -## Support - -If this saved your controller from a drawer: -[Buy me a coffee ☕](https://buymeacoffee.com/peterksharma) - -Issues and captures (especially audio-related — see the research docs) -are very welcome. - -## Credits -Protocol research: ndeadly/switch2_controller_research, -trevlars/switch2-controllers-linux (MIT), Nadeflore/switch2-controllers, -and the wider Switch 2 RE community. -macOS keep-alive discovery, CoreBluetooth port, and the research in -[`research/`](research/): this project. +The output is `build/Finally the Controller Works (jmonster).app`. Source on +an unmerged PR branch must be checked out before building that PR's changes. +The default build is ad-hoc signed for development, not a notarized release. +Upstream's downloadable application and automatic-update feed do not contain +this fork's changes. No physical-controller or game acceptance is implied by +a successful build or test run. + +This fork uses a separate bundle identifier, `io.github.jmonster.switch2mac`, +and disables automatic updates, including saved feed overrides. Establish +Bluetooth/privacy approvals, preferences and login-item registration for this +app separately. Do not run two bridges against the same controller at once. +See [fork identity and signing policy](docs/fork-identity.md). + +## Choose an output for the intended game + +- **SDL3 games:** the [SDL bridge](sdl/README.md) uses a custom library, not a + system-wide driver. The tracked upstream dylib is a historical binary; a + change to a source patch does not update it. Use the corrected library built + from the reviewed patch set, and check its source revision. The Gopher64 + helper creates a separate ad-hoc-signed copy, not a modification of the original. +- **RetroArch:** [network gamepad output](docs/retroarch-integration.md) is + disabled by default. It does not require replacing SDL, but its legacy UDP + protocol has no rumble return path and maps GameCube trigger travel to + digital L2/R2. Enable the unauthenticated receiver only on a trusted network. +- **Chromium web games:** the [browser bridge](browser/README.md) is disabled + by default. Load the supplied extension, allow its exact ID in Browser Bridge + Settings, then relaunch the app. No Safari/Firefox package is provided. + +CoreHID virtual-controller output requires Apple's restricted entitlement; +this fork has not established approval or universal game compatibility. +The browser path deliberately does not forward GameCube HD-motor commands; +verified GameCube preset rumble remains an outstanding hardware/protocol task. +Check analog trigger travel and digital clicks separately in the actual game. + +## Validation and contributions + +`bash tests/run.sh` runs the checked-in protocol and available output suites. +Tests use synthetic controller reports, simulated radio boundaries and, where +applicable, real localhost sockets. SDL regressions separately build the pinned +SDL source and exercise the actual joystick driver. Hosted macOS checks build +the complete app with Apple frameworks and verify the ad-hoc bundle. + +Keep fixes scoped and attach a reproducer. Report the exact commit, macOS and +controller firmware, transport, output backend and observed behavior. Hardware +pairing/reconnection, sleep/wake, latency, multiplayer and real-game testing +are separate from automated regression coverage. + +Original application and protocol research: **Peter Sharma** and the community +contributors credited in [research/PROTOCOL.md](research/PROTOCOL.md). +Optional browser output is adapted from **Andrei-Kondrykau**, and RetroArch +output from **vialoh**, with source revisions in their integration notes. +[Support the original author](https://buymeacoffee.com/peterksharma). +The SDL modifications retain their [separate license/provenance](sdl/README.md). +Application-wide licensing still needs clarification with upstream; this fork +does not invent a new license or relicense contributed work. diff --git a/Resources/Info.plist b/Resources/Info.plist index 5374b2a..3ea1e7d 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -3,11 +3,11 @@ CFBundleName - Finally the Controller Works + Finally the Controller Works (jmonster) CFBundleDisplayName - Finally the Controller Works + Finally the Controller Works (jmonster) CFBundleIdentifier - com.petersharma.finallythecontrollerworks + io.github.jmonster.switch2mac CFBundleExecutable FinallyTheControllerWorks CFBundleVersion diff --git a/Sources/FinallyTheControllerWorks/FTCWApp.swift b/Sources/FinallyTheControllerWorks/FTCWApp.swift index 757a822..22b28be 100644 --- a/Sources/FinallyTheControllerWorks/FTCWApp.swift +++ b/Sources/FinallyTheControllerWorks/FTCWApp.swift @@ -87,6 +87,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { bridgeLog(.info, "app", "Finally the Controller Works — starting bridge") engine.addSink(UDPHub()) engine.addSink(WebSocketHub()) + engine.addSink(NetworkGamepadSink()) engine.addSink(VirtualHIDSink()) notifications.attach(to: engine) // Daily auto-update check (only if a feed URL is configured); results diff --git a/Sources/FinallyTheControllerWorks/Output/NetworkGamepadSink.swift b/Sources/FinallyTheControllerWorks/Output/NetworkGamepadSink.swift new file mode 100644 index 0000000..3e54e2c --- /dev/null +++ b/Sources/FinallyTheControllerWorks/Output/NetworkGamepadSink.swift @@ -0,0 +1,218 @@ +// Optional RetroArch network-gamepad output, adapted from vialoh/switch2mac +// 2c7a396336f5a657a16a772b8c056d96ec6ff7f1. Disabled by default. +// Native little-endian remote_message: i32 port/device/index/id, u16 state, +// two padding bytes. One paced datagram per player per tick; no rumble ACKs. +import Foundation +import Darwin + +final class NetworkGamepadSink: ControllerOutputSink, @unchecked Sendable { + private static let sendInterval: TimeInterval = 1.0 / 60.0 + private static let refreshInterval: TimeInterval = 2.0 + private static let analogQuantum: Int32 = 512 + private static let maxEdges = 256 + private static let buttonMap: [(Switch2.Buttons, Int)] = [ + (.b, 0), (.y, 1), (.minus, 2), (.plus, 3), + (.dpadUp, 4), (.dpadDown, 5), (.dpadLeft, 6), (.dpadRight, 7), + (.a, 8), (.x, 9), (.l, 10), (.r, 11), (.zl, 12), (.zr, 13), + (.lStick, 14), (.rStick, 15), + ] + var onRumble: ((Int, Double, Double) -> Void)? + + private final class Player { + var wantButtons: UInt16 = 0 + var sentButtons: UInt16 = 0 + var wantAxes = [Int16](repeating: 0, count: 4) + var sentAxes = [Int16](repeating: 0, count: 4) + var edges: [(id: Int, state: UInt16)] = [] + var connected = false + var failed = false + var port: UInt16? + var switchIndex: Int? + var nextSendAt: TimeInterval = 0 + var lastRefreshAt: TimeInterval = 0 + var refreshIndex = 20 + var neutralPasses = 0 + + func neutralize() { + wantButtons = 0; wantAxes = [0, 0, 0, 0] + edges = (0..<16).filter { sentButtons & (1 << $0) != 0 }.map { ($0, 0) } + switchIndex = nil + refreshIndex = 0 + neutralPasses = 2 // best-effort repeat; UDP acceptance is not receipt + } + var pending: Bool { !edges.isEmpty || wantAxes != sentAxes || refreshIndex < 20 } + } + + private let queue = DispatchQueue(label: "com.petersharma.ftcw.netpad") + private let players = (0..= 0 { close(fd) } } + private func openSocket() { + fd = socket(AF_INET, SOCK_DGRAM, 0) + guard fd >= 0 else { + bridgeLog(.error, "netpad", "socket() failed: \(String(cString: strerror(errno)))"); return + } + _ = fcntl(fd, F_SETFL, O_NONBLOCK) + } + + func controllerConnected(slot: Int, model: Switch2.Model) { + queue.async { [weak self] in + guard let self, self.players.indices.contains(slot) else { return } + self.players[slot].connected = true + } + } + func controllerName(slot: Int, name: String) {} + func controllerDisconnected(slot: Int) { + queue.async { [weak self] in + guard let self, self.players.indices.contains(slot) else { return } + let p = self.players[slot] + p.connected = false; p.neutralize() + self.ensurePumping() + } + } + + func controllerState(slot: Int, state: ControllerState) { + guard AppConfig.networkGamepadEnabled else { return } + queue.async { [weak self] in + guard let self, self.players.indices.contains(slot), AppConfig.networkGamepadEnabled else { return } + let p = self.players[slot] + guard !p.failed else { return } + p.connected = true + var buttons: UInt16 = 0 + for (button, id) in Self.buttonMap where state.buttons.contains(button) { buttons |= 1 << id } + if state.leftTrigger >= 128 { buttons |= 1 << 12 } + if state.rightTrigger >= 128 { buttons |= 1 << 13 } + let changed = p.wantButtons ^ buttons + let edges = (0..<16).filter { changed & (1 << $0) != 0 }.map { ($0, (buttons >> $0) & 1) } + if p.switchIndex == nil { + guard p.edges.count + edges.count <= Self.maxEdges else { + p.failed = true; p.neutralize(); self.ensurePumping() + bridgeLog(.error, "netpad", "player \(slot + 1) edge queue exhausted; neutralizing. Disable/re-enable network output to retry.") + return + } + p.edges.append(contentsOf: edges) + } + p.wantButtons = buttons + p.wantAxes = [Self.axis(state.leftStick.x), Self.axis(-state.leftStick.y), + Self.axis(state.rightStick.x), Self.axis(-state.rightStick.y)] + self.ensurePumping() + } + } + + private static func axis(_ value: Double) -> Int16 { + guard value.isFinite else { return 0 } + let raw = Int32(max(-1, min(1, value)) * 32767) + return Int16(raw / analogQuantum * analogQuantum) + } + private func ensurePumping() { + guard timer == nil, fd >= 0 else { return } + let t = DispatchSource.makeTimerSource(queue: queue) + t.schedule(deadline: .now(), repeating: Self.sendInterval, leeway: .milliseconds(2)) + t.setEventHandler { [weak self] in self?.pump() } + t.resume(); timer = t + } + + private func pump() { + let basePort = AppConfig.networkGamepadBasePort + let enabled = AppConfig.networkGamepadEnabled && (1...65532).contains(basePort) + let now = ProcessInfo.processInfo.systemUptime + if !enabled && wasEnabled { + for p in players { p.neutralize(); p.failed = false } + } + wasEnabled = enabled + var busy = false + for (slot, p) in players.enumerated() { + let target: UInt16? = enabled ? UInt16(basePort + slot) : nil + if p.port == nil { p.port = target } + guard let port = p.port else { continue } + let refreshing = (enabled && p.connected) || p.neutralPasses > 0 + if refreshing && now - p.lastRefreshAt >= Self.refreshInterval && p.refreshIndex >= 20 { + p.lastRefreshAt = now; p.refreshIndex = 0 + } + busy = busy || p.pending || refreshing || (target != nil && target != port) + guard now >= p.nextSendAt else { continue } + + // Reverting a port edit can leave a partially neutralized receiver. + // Reassert every button, then resume ordered edges for later reports. + if p.switchIndex != nil && target == port { + p.switchIndex = nil + p.edges = (0..<16).map { ($0, (p.wantButtons >> $0) & 1) } + p.refreshIndex = 0 + } + + // Configuration changes retire the old destination before any new + // state is sent. Reports during this reset establish the new state. + if let target, target != port { + if p.switchIndex == nil { p.switchIndex = 0; p.edges.removeAll() } + let index = p.switchIndex! + guard send(Self.refreshMessage(slot, index, buttons: 0, axes: [0, 0, 0, 0]), port: port, now: now) else { continue } + p.switchIndex = index + 1 + if p.switchIndex == 20 { + p.port = target; p.switchIndex = nil + p.sentButtons = 0; p.sentAxes = [0, 0, 0, 0] + p.edges = (0..<16).filter { p.wantButtons & (1 << $0) != 0 }.map { ($0, 1) } + p.refreshIndex = 0 + } + p.nextSendAt = now + Self.sendInterval + continue + } + if let edge = p.edges.first { + guard send(Self.message(slot: slot, device: 1, index: 0, id: Int32(edge.id), state: edge.state), port: port, now: now) else { continue } + p.edges.removeFirst() + let mask = UInt16(1) << edge.id + if edge.state == 0 { p.sentButtons &= ~mask } else { p.sentButtons |= mask } + } else if let axis = (0..<4).first(where: { p.wantAxes[$0] != p.sentAxes[$0] }) { + guard send(Self.message(slot: slot, device: 5, index: Int32(axis / 2), id: Int32(axis % 2), state: UInt16(bitPattern: p.wantAxes[axis])), port: port, now: now) else { continue } + p.sentAxes[axis] = p.wantAxes[axis] + } else if p.refreshIndex < 20 { + guard send(Self.refreshMessage(slot, p.refreshIndex, buttons: p.wantButtons, axes: p.wantAxes), port: port, now: now) else { continue } + p.refreshIndex += 1 + if p.refreshIndex == 20 && p.neutralPasses > 0 { p.neutralPasses -= 1 } + } else { continue } + p.nextSendAt = now + Self.sendInterval + } + if !busy { timer?.cancel(); timer = nil } + } + + private static func refreshMessage(_ slot: Int, _ index: Int, buttons: UInt16, axes: [Int16]) -> [UInt8] { + if index < 16 { + return message(slot: slot, device: 1, index: 0, id: Int32(index), state: (buttons >> index) & 1) + } + let axis = index - 16 + return message(slot: slot, device: 5, index: Int32(axis / 2), id: Int32(axis % 2), state: UInt16(bitPattern: axes[axis])) + } + private static func message(slot: Int, device: Int32, index: Int32, id: Int32, state: UInt16) -> [UInt8] { + var d = [UInt8](); d.reserveCapacity(20) + for v in [Int32(slot), device, index, id] { + withUnsafeBytes(of: v.littleEndian) { d.append(contentsOf: $0) } + } + withUnsafeBytes(of: state.littleEndian) { d.append(contentsOf: $0) } + d.append(contentsOf: [0, 0]); return d + } + // sendto acceptance is NOT acknowledgement by RetroArch. Full refreshes + // include released/zero values so a lost release can converge later. + private func send(_ bytes: [UInt8], port: UInt16, now: TimeInterval) -> Bool { + var dest = sockaddr_in() + dest.sin_family = sa_family_t(AF_INET) + dest.sin_port = port.bigEndian + dest.sin_addr.s_addr = UInt32(0x7F000001).bigEndian + let sent = bytes.withUnsafeBytes { buf in + withUnsafePointer(to: &dest) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + sendto(fd, buf.baseAddress, buf.count, 0, $0, socklen_t(MemoryLayout.size)) + } + } + } + if sent == bytes.count { return true } + if now - lastSendErrorAt >= 1 { + lastSendErrorAt = now + bridgeLog(.warning, "netpad", "sendto 127.0.0.1:\(port) failed, retrying: \(String(cString: strerror(errno)))") + } + return false + } +} diff --git a/Sources/FinallyTheControllerWorks/UI/AboutAndOnboarding.swift b/Sources/FinallyTheControllerWorks/UI/AboutAndOnboarding.swift index 2bd15bc..32dceb5 100644 --- a/Sources/FinallyTheControllerWorks/UI/AboutAndOnboarding.swift +++ b/Sources/FinallyTheControllerWorks/UI/AboutAndOnboarding.swift @@ -13,17 +13,16 @@ enum AppInfo { Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "0" } - /// Built-in update feed: every GitHub release of this repo uploads - /// appcast.json as an asset, and releases/latest always points at the - /// newest one. The Configuration field remains an override for testing. - static let defaultUpdateFeedURL = - "https://github.com/Peterksharma/switch2mac/releases/latest/download/appcast.json" + /// This fork has no approved update signing identity/feed. Never consume + /// the upstream feed or a persisted override until that trust path exists. + static let updatesEnabled = false + static let defaultUpdateFeedURL = "" /// Pre-release features hidden from the beta UI: the party-game and /// gesture menu items, keyboard mapping, and the Experiments cluster. /// Deliberately a runtime flag rather than a build flag so a beta build /// can be un-hidden for development without recompiling: - /// defaults write com.petersharma.finallythecontrollerworks showPreReleaseFeatures -bool YES + /// defaults write io.github.jmonster.switch2mac showPreReleaseFeatures -bool YES /// (then relaunch; delete the key to hide again). static var showPreReleaseFeatures: Bool { UserDefaults.standard.bool(forKey: "showPreReleaseFeatures") diff --git a/Sources/FinallyTheControllerWorks/UI/AppNotifications.swift b/Sources/FinallyTheControllerWorks/UI/AppNotifications.swift index 4af3030..1357731 100644 --- a/Sources/FinallyTheControllerWorks/UI/AppNotifications.swift +++ b/Sources/FinallyTheControllerWorks/UI/AppNotifications.swift @@ -14,6 +14,8 @@ enum AppConfig { static let notifyConnectionsKey = "notifyConnections" // Bool, default true static let lowBatteryThresholdKey = "lowBatteryThreshold" // Double 0-1, default 0.15 static let idleSleepMinutesKey = "idleSleepMinutes" // Double, 0 = never, default 15 + static let networkGamepadEnabledKey = "networkGamepadEnabled" // Bool, default false + static let networkGamepadBasePortKey = "networkGamepadBasePort" // Int, default 55400 static var notifyEnabled: Bool { UserDefaults.standard.object(forKey: notifyEnabledKey) as? Bool ?? true @@ -24,6 +26,15 @@ enum AppConfig { static var lowBatteryThreshold: Double { UserDefaults.standard.object(forKey: lowBatteryThresholdKey) as? Double ?? 0.15 } + static var networkGamepadEnabled: Bool { + UserDefaults.standard.object(forKey: networkGamepadEnabledKey) as? Bool ?? false + } + /// Network gamepad base UDP port; player N uses base + N - 1 + /// (RetroArch's `network_remote_base_port`). + static var networkGamepadBasePort: Int { + let port = UserDefaults.standard.object(forKey: networkGamepadBasePortKey) as? Int ?? 55400 + return min(max(port, 1024), 65535 - BridgeEngine.maxPlayers) + } static var idleSleepMinutes: Double { UserDefaults.standard.object(forKey: idleSleepMinutesKey) as? Double ?? 15 } diff --git a/Sources/FinallyTheControllerWorks/UI/DashboardView.swift b/Sources/FinallyTheControllerWorks/UI/DashboardView.swift index 03cf18e..f2a73cb 100644 --- a/Sources/FinallyTheControllerWorks/UI/DashboardView.swift +++ b/Sources/FinallyTheControllerWorks/UI/DashboardView.swift @@ -738,6 +738,8 @@ struct ConfigurationSection: View { @AppStorage(AppConfig.notifyConnectionsKey) private var notifyConnections = true @AppStorage(AppConfig.lowBatteryThresholdKey) private var lowBattery = 0.15 @AppStorage(AppConfig.idleSleepMinutesKey) private var idleMinutes = 15.0 + @AppStorage(AppConfig.networkGamepadEnabledKey) private var networkGamepadEnabled = false + @AppStorage(AppConfig.networkGamepadBasePortKey) private var networkGamepadBasePort = 55400 var body: some View { VStack(alignment: .leading, spacing: 10) { @@ -766,6 +768,23 @@ struct ConfigurationSection: View { .font(.caption) .foregroundStyle(.secondary) Divider() + Toggle("Network gamepad output (RetroArch)", isOn: $networkGamepadEnabled) + HStack(spacing: 12) { + Text("Base port") + TextField("55400", value: $networkGamepadBasePort, format: .number.grouping(.never)) + .textFieldStyle(.roundedBorder) + .frame(width: 72) + .disabled(!networkGamepadEnabled) + Text("players 1–4 on \(String(networkGamepadBasePort))–\(String(networkGamepadBasePort + 3))") + .foregroundStyle(.secondary) + } + .padding(.leading, 18) + Text("Sends each player's state over local UDP to programs that accept a " + + "network gamepad — no SDL library needed; no rumble. " + + "RetroArch: Settings → Network → Network Gamepad, same base port.") + .font(.caption) + .foregroundStyle(.secondary) + Divider() HStack(spacing: 10) { Text("Settings backup") Button("Export…") { exportSettings() } diff --git a/Sources/FinallyTheControllerWorks/UI/Updater.swift b/Sources/FinallyTheControllerWorks/UI/Updater.swift index 0fa9e45..5ad4963 100644 --- a/Sources/FinallyTheControllerWorks/UI/Updater.swift +++ b/Sources/FinallyTheControllerWorks/UI/Updater.swift @@ -1,5 +1,5 @@ // Updater.swift -// Self-contained auto-updater for the Developer ID (non-App-Store) build. +// Retained upstream updater; disabled by AppInfo.updatesEnabled in this fork. // // Flow: fetch a small JSON "appcast" from a configurable feed URL → if it // advertises a newer build, download the .zip → verify its SHA-256 AND that @@ -29,7 +29,7 @@ struct AppcastEntry: Codable { @MainActor final class Updater: ObservableObject { - /// Our Developer ID team — downloads must be signed by this team. + /// Upstream Developer ID team. Fork updates remain disabled, not re-trusted. nonisolated static let requiredTeamID = "4BA4S6WKX7" enum State: Equatable { @@ -69,6 +69,7 @@ final class Updater: ObservableObject { } var feedURL: URL? { + guard AppInfo.updatesEnabled else { return nil } // The Configuration field overrides the built-in default, so a beta // build updates out of the box while testers can still point at a // staging feed. @@ -91,7 +92,7 @@ final class Updater: ObservableObject { func check(userInitiated: Bool) async { guard let url = feedURL else { - if userInitiated { state = .failed("No update feed URL is configured.") } + if userInitiated { state = .failed(AppInfo.updatesEnabled ? "No update feed URL is configured." : "Updates are disabled in this fork. Install reviewed builds manually.") } return } let prior = state @@ -123,6 +124,7 @@ final class Updater: ObservableObject { } func downloadAndInstall(_ entry: AppcastEntry) { + guard AppInfo.updatesEnabled else { return } Task { await self.performDownload(entry) } } @@ -153,6 +155,7 @@ final class Updater: ObservableObject { /// User-confirmed install: hand off to the detached installer and quit. func installNow() { + guard AppInfo.updatesEnabled else { return } guard case .readyToInstall = state, let app = verifiedApp else { return } verifiedApp = nil // a second click must be a no-op state = .installing @@ -366,7 +369,9 @@ struct UpdaterView: View { } if updater.feedURL == nil { - Text("Set an update feed URL in the dashboard's Configuration section to enable updates.") + Text(AppInfo.updatesEnabled + ? "Set an update feed URL in the dashboard's Configuration section to enable updates." + : "This fork uses manual updates until its own signing and update policy is configured.") .font(.caption).foregroundStyle(.tertiary).multilineTextAlignment(.center) } } diff --git a/docs/fork-identity.md b/docs/fork-identity.md new file mode 100644 index 0000000..0cfe812 --- /dev/null +++ b/docs/fork-identity.md @@ -0,0 +1,33 @@ +# Fork identity and update policy + +This build uses `io.github.jmonster.switch2mac` and the bundle name +**Finally the Controller Works (jmonster)**. It can coexist with upstream +without sharing its standard UserDefaults domain or overwriting the same +application filename. Original author/copyright/protocol credits are retained. + +The fork's built-in updater is disabled, including saved feed overrides and +its download/install entry points. No upstream release may silently replace +this build. This does not weaken or substitute the existing signature verifier; +there is no approved fork update trust path yet. Install reviewed builds +manually. Enabling automatic updates later requires an explicit decision on +the fork's own signing identity, feed, bundle verification and rollback policy. + +The changed bundle identity means macOS privacy approvals, launch-at-login +registration and preferences need to be established for this app. Settings +are not silently migrated from upstream; use a reviewed export/import. Existing +Bluetooth bonds are not deliberately rewritten by this metadata change. + +`bash scripts/build-app.sh` produces the distinct ad-hoc development bundle. +Signing with an embedded profile requires **SIGN_IDENTITY**, +**PROVISIONING_PROFILE**, and an explicit **SIGN_ENTITLEMENTS** file whose +application identifier matches the fork bundle ID and stated team. The script +never silently consumes upstream's entitlement plist. Passing that local check +does not prove Apple granted the capability or that a provisioning profile is +valid; runtime/signature/profile acceptance must still be verified. + +The notarization script has no built-in certificate or keychain account. It +requires the owner to supply SIGN_IDENTITY and NOTARY_KEYCHAIN_PROFILE and +uses the new bundle/zip names. It generates no appcast, tag, or release. +No production signing, entitlement approval or notarization was performed for +this PR. Metadata, disabled-feed and early signing-refusal tests run in CI, +alongside an actual ad-hoc app build with the new identifier and output path. diff --git a/docs/retroarch-integration.md b/docs/retroarch-integration.md new file mode 100644 index 0000000..3ec6799 --- /dev/null +++ b/docs/retroarch-integration.md @@ -0,0 +1,53 @@ +# RetroArch fork integration + +Adapted from vialoh/switch2mac retroarch-network-gamepad at +2c7a396336f5a657a16a772b8c056d96ec6ff7f1 (upstream PR #1). The optional +configuration toggle and default-off behavior remain. No Nintendo command, +BLE handshake, decoding or entitlement changes are required. + +## Setup + +In RetroArch, enable Settings > Network > Network Gamepad and the desired +Network Gamepad Users, then restart RetroArch. In this app's dashboard, +enable Configuration > Network gamepad output (RetroArch). Both base ports +must match (default 55400, with one port per player). The sink sends only to +127.0.0.1; the receiver may listen on other interfaces, so do not enable it +on an untrusted network. No patched SDL library is required for this path. + +The original desired-state timer erased taps completed before its next tick. +The corrected sink retains ordered button edges (up to 256 per player), +coalesces analog positions separately, and retains the existing 60/s pacing. +Overflow explicitly neutralizes that player's output and logs a failure; +disable/re-enable network output to retry. It never silently drops oldest +button edges while pretending the stream is intact. + +Full periodic refreshes include zeros/releases, not only held values, so a +lost release can converge later. A disconnect/disable attempts repeated +neutral refreshes. Changing the destination first paces 20 neutral control +messages to the old port, then establishes current state at the new port; +input during that explicit configuration reset becomes the new initial state. +Cancelling a destination change reasserts current state at the original port +and resumes ordered edge delivery. Normal input/taps do not use that reset +policy. Send failures retain pending work; sendto success means kernel +acceptance, not remote acknowledgement. + +The 20-byte native little-endian message layout was cross-checked against +libretro/RetroArch commit 81478f2aa2abb942cfacb2109cbc25a4bd3b46ca, +input/input_driver.h and tools/ra_stress.md. This legacy protocol updates one +control per datagram and receiver polling/pacing can lose UDP traffic. It is +not an atomic full-state or lossless channel, and it has no rumble return path. +GameCube analog triggers still map to digital L2/R2 at the inherited threshold; +this is not full analog-trigger fidelity. Use only on a trusted local network +when enabling RetroArch's unauthenticated listener. + +Nine tests use the actual sink and real loopback sockets. Tests reproduce +lost taps, missing release refreshes and cancelled destination changes against +the corresponding earlier implementation. The visibility-only test copy +avoids unrelated UI initialization; the complete app is separately built with +Apple frameworks. Synthetic tests do not establish gameplay, physical timing +or controller compatibility. + +Run bash tests/retroarch/run.sh. The per-report DispatchQueue itself is not +byte-bounded; this PR bounds retained digital edges and documents overload, +not all memory. The optional browser output may coexist with this sink; +neither listener is enabled by default. diff --git a/scripts/build-app.sh b/scripts/build-app.sh index dae309e..03401c2 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -5,17 +5,30 @@ # ./scripts/build-app.sh # ad-hoc signed (no virtual HID) # SIGN_IDENTITY="Developer ID Application: ..." \ # PROVISIONING_PROFILE=path/to.provisionprofile \ +# SIGN_ENTITLEMENTS=path/to/fork-entitlements.plist \ # ./scripts/build-app.sh # full signing incl. HID entitlement # -# Output: build/Finally the Controller Works.app +# Output: build/Finally the Controller Works (jmonster).app set -euo pipefail cd "$(dirname "$0")/.." -APP_NAME="Finally the Controller Works" +APP_NAME="Finally the Controller Works (jmonster)" EXE=FinallyTheControllerWorks OUT="build/$APP_NAME.app" +# Never silently sign this fork with the upstream application's entitlements. +if [ -n "${PROVISIONING_PROFILE:-}" ]; then + : "${SIGN_IDENTITY:?Set your own Developer ID signing identity}" + : "${SIGN_ENTITLEMENTS:?Provide a fork-specific entitlement plist explicitly}" + [ -f "$PROVISIONING_PROFILE" ] && [ -f "$SIGN_ENTITLEMENTS" ] || { echo "Signing input missing" >&2; exit 2; } + PB=/usr/libexec/PlistBuddy + BUNDLE_ID=$($PB -c 'Print :CFBundleIdentifier' Resources/Info.plist) + TEAM=$($PB -c 'Print :com.apple.developer.team-identifier' "$SIGN_ENTITLEMENTS") + APP_ID=$($PB -c 'Print :com.apple.application-identifier' "$SIGN_ENTITLEMENTS") + [ -n "$TEAM" ] && [ "$APP_ID" = "$TEAM.$BUNDLE_ID" ] || { echo "Entitlements do not identify this fork" >&2; exit 2; } +fi + swift build -c release rm -rf "$OUT" @@ -33,9 +46,9 @@ if [ -n "${SIGN_IDENTITY:-}" ]; then # Full build: profile-gated HID entitlement → system-wide virtual pads. cp "$PROVISIONING_PROFILE" "$OUT/Contents/embedded.provisionprofile" codesign --force --options runtime --timestamp \ - --entitlements Resources/entitlements-dev.plist \ + --entitlements "$SIGN_ENTITLEMENTS" \ --sign "$SIGN_IDENTITY" "$OUT" - echo "Signed with: $SIGN_IDENTITY (virtual HID enabled)" + echo "Signed with: $SIGN_IDENTITY (supplied profile; runtime entitlement approval still required)" else # Developer ID without profile: notarizable, UDP/SDL path only. # (Signing the restricted entitlement without an embedded profile diff --git a/scripts/notarize.sh b/scripts/notarize.sh index 2d4bf02..35023f5 100755 --- a/scripts/notarize.sh +++ b/scripts/notarize.sh @@ -1,74 +1,22 @@ #!/bin/bash -# notarize.sh — build, sign, notarize, and staple the app for distribution. -# -# Prerequisites (one-time): -# 1. A Developer ID Application certificate in the login keychain -# (already installed for this project). -# 2. An app-specific password from https://account.apple.com -# (Sign-In & Security → App-Specific Passwords), stored in the keychain: -# xcrun notarytool store-credentials ftcw-notary \ -# --apple-id "peterksharma@gmail.com" \ -# --team-id 4BA4S6WKX7 \ -# --password "" -# -# Then just run: ./scripts/notarize.sh -# -# Result: build/Finally the Controller Works.app is notarized + stapled, and -# build/FinallyTheControllerWorks.zip is ready to distribute. - +# Explicit fork signing/notarization only. No inherited identity, credential +# profile, update feed or release publication. Read docs/fork-identity.md first. set -euo pipefail cd "$(dirname "$0")/.." +: "${SIGN_IDENTITY:?Supply your own Developer ID signing identity}" +: "${NOTARY_KEYCHAIN_PROFILE:?Supply your own notarytool keychain profile}" +APP="build/Finally the Controller Works (jmonster).app" +ZIP="build/switch2mac-jmonster.zip" -APP="build/Finally the Controller Works.app" -ZIP="build/FinallyTheControllerWorks.zip" -IDENTITY="Developer ID Application: Peter Sharma (4BA4S6WKX7)" -KEYCHAIN_PROFILE="ftcw-notary" - -echo "==> Building signed app" -SIGN_IDENTITY="$IDENTITY" ./scripts/build-app.sh - -echo "==> Zipping for submission" +bash scripts/build-app.sh +codesign --verify --strict "$APP" rm -f "$ZIP" ditto -c -k --keepParent "$APP" "$ZIP" - -echo "==> Submitting to Apple notary service (this takes a few minutes)" -xcrun notarytool submit "$ZIP" \ - --keychain-profile "$KEYCHAIN_PROFILE" \ - --wait - -echo "==> Stapling the notarization ticket" +xcrun notarytool submit "$ZIP" --keychain-profile "$NOTARY_KEYCHAIN_PROFILE" --wait xcrun stapler staple "$APP" xcrun stapler validate "$APP" - -echo "==> Re-zipping the stapled app for distribution" rm -f "$ZIP" ditto -c -k --keepParent "$APP" "$ZIP" - -echo "==> Generating appcast.json for the auto-updater" -VERSION=$(/usr/libexec/PlistBuddy -c "Print CFBundleShortVersionString" "$APP/Contents/Info.plist") -BUILD=$(/usr/libexec/PlistBuddy -c "Print CFBundleVersion" "$APP/Contents/Info.plist") -SHA=$(shasum -a 256 "$ZIP" | awk '{print $1}') -# Hosted on GitHub Releases: each release v$VERSION carries the zip and -# appcast.json as assets. The app's feed reads releases/latest/download/ -# appcast.json (a stable URL), while the zip URL below is version-pinned -# so an appcast always references its own release's asset. -DOWNLOAD_BASE="${DOWNLOAD_BASE:-https://github.com/Peterksharma/switch2mac/releases/download}" -cat > build/appcast.json < Void)? { get set } + func controllerConnected(slot: Int, model: Switch2.Model) + func controllerDisconnected(slot: Int) + func controllerName(slot: Int, name: String) + func controllerState(slot: Int, state: ControllerState) +} + +@main +enum NetworkTests { + static func receiver(_ port: UInt16) -> Int32 { + let fd = socket(AF_INET, datagram, 0); precondition(fd >= 0) + var addr = sockaddr_in(); addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = port.bigEndian; addr.sin_addr.s_addr = UInt32(0x7f000001).bigEndian + precondition(withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } == 0) + _ = fcntl(fd, F_SETFL, O_NONBLOCK) + return fd + } + static func packets(_ fd: Int32) -> [Data] { + var result: [Data] = [], bytes = [UInt8](repeating: 0, count: 64) + var ready = pollfd(fd: fd, events: Int16(POLLIN), revents: 0) + while poll(&ready, 1, 20) == 1 { + let n = recv(fd, &bytes, bytes.count, 0) + precondition(n == 20, "Expected exactly one 20-byte remote_message") + result.append(Data(bytes.prefix(n))) + } + return result + } + static func tick(_ sink: NetworkGamepadSink, count: Int = 1) { + sink.queue.sync { + sink.timer?.cancel(); sink.timer = nil + for _ in 0.. ControllerState { + var s = ControllerState(); if down { s.buttons = [.a] }; return s + } + static func aValues(_ packets: [Data]) -> [UInt16] { + packets.filter { Switch2.u32($0, 4) == 1 && Switch2.u32($0, 12) == 8 }.map { Switch2.u16($0, 16) } + } + static func main() { + let selected = CommandLine.arguments.last! + for name in ["wire", "tap", "refresh-release", "destination", "disable", "overflow", "send-failure", "finite-axis", "cancel-destination"] { + if selected != "all" && selected != name { continue } + AppConfig.networkGamepadEnabled = true; AppConfig.networkGamepadBasePort = 55400 + let a = receiver(55400), b = receiver(55410), sink = NetworkGamepadSink() + defer { sink.queue.sync { sink.timer?.cancel(); sink.timer = nil }; close(a); close(b) } + sink.queue.sync { logged.removeAll() } + sink.controllerConnected(slot: 0, model: .nsoGameCube) + switch name { + case "wire": + let m = NetworkGamepadSink.message(slot: 0, device: 1, index: 0, id: 8, state: 1) + precondition(m == [0,0,0,0,1,0,0,0,0,0,0,0,8,0,0,0,1,0,0,0]) + case "tap": + sink.queue.sync { sink.controllerState(slot: 0, state: state(true)); sink.controllerState(slot: 0, state: state(false)) } + tick(sink, count: 2) + precondition(Array(aValues(packets(a)).prefix(2)) == [1,0], "Complete tap disappeared before the send timer") + case "refresh-release": + sink.controllerState(slot: 0, state: state(true)); tick(sink); _ = packets(a) + sink.controllerState(slot: 0, state: state(false)); tick(sink); _ = packets(a) // simulate loss of release + sink.queue.sync { sink.players[0].lastRefreshAt = -100 } + tick(sink, count: 25) + precondition(aValues(packets(a)).contains(0), "Periodic refresh omitted released buttons") + case "destination": + sink.controllerState(slot: 0, state: state(true)); tick(sink); _ = packets(a) + AppConfig.networkGamepadBasePort = 55410 + tick(sink, count: 21) + precondition(aValues(packets(a)).contains(0), "Old destination never received neutralization") + precondition(aValues(packets(b)).first == 1, "New destination did not receive current state") + case "disable": + sink.controllerState(slot: 0, state: state(true)); tick(sink); _ = packets(a) + AppConfig.networkGamepadEnabled = false; tick(sink, count: 20) + precondition(aValues(packets(a)).contains(0)) + case "overflow": + sink.queue.sync { + for i in 0..<600 { sink.controllerState(slot: 0, state: state(i % 2 == 0)) } + } + tick(sink, count: 20) + precondition(sink.queue.sync { logged.contains { $0.contains("edge queue exhausted") } }, "Overflow silently lost input") + precondition(aValues(packets(a)).allSatisfy { $0 == 0 }) + AppConfig.networkGamepadEnabled = false; tick(sink) + AppConfig.networkGamepadEnabled = true + sink.controllerState(slot: 0, state: state(true)); tick(sink) + precondition(aValues(packets(a)).contains(1), "Explicit disable/re-enable did not recover") + case "send-failure": + sink.queue.sync { close(sink.fd); sink.fd = -1 } + sink.controllerState(slot: 0, state: state(true)); tick(sink) + precondition(sink.queue.sync { sink.players[0].sentButtons == 0 }) + case "cancel-destination": + sink.controllerState(slot: 0, state: state(true)); tick(sink); _ = packets(a) + AppConfig.networkGamepadBasePort = 55410 + tick(sink, count: 10) // old destination has received A's release + precondition(aValues(packets(a)).contains(0)) + AppConfig.networkGamepadBasePort = 55400 // cancel before reset finishes + tick(sink, count: 40) + precondition(aValues(packets(a)).contains(1), "Cancelled destination reset did not restore held input") + sink.queue.sync { + sink.controllerState(slot: 0, state: state(false)) + sink.controllerState(slot: 0, state: state(true)) + sink.controllerState(slot: 0, state: state(false)) + } + tick(sink, count: 3) + precondition(Array(aValues(packets(a)).prefix(3)) == [0,1,0], "Cancelled port switch disabled edge delivery") + precondition(packets(b).isEmpty, "Cancelled destination received input") + case "finite-axis": + precondition(NetworkGamepadSink.axis(.nan) == 0) + precondition(NetworkGamepadSink.axis(.infinity) == 0) + default: fatalError() + } + print("PASS \(name)") + } + } +} diff --git a/tests/retroarch/run.sh b/tests/retroarch/run.sh new file mode 100644 index 0000000..0e95b1c --- /dev/null +++ b/tests/retroarch/run.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")/../.." +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +python3 - "$work" <<'PY' +from pathlib import Path +import os,re,sys +out=Path(sys.argv[1]);base=Path('Sources/FinallyTheControllerWorks') +s=Path(os.environ.get('NETPAD_SOURCE', base/'Output/NetworkGamepadSink.swift')).read_text() +s=re.sub(r'\bprivate\s+', '', s) +if sys.platform != 'darwin': + s=s.replace('import Darwin', 'import Glibc\nimport CoreFoundation').replace('SOCK_DGRAM,','Int32(SOCK_DGRAM.rawValue),') +out.joinpath('Sink.swift').write_text(s) +s=(base/'Bluetooth/ControllerSession.swift').read_text() +a=s.index('struct ControllerState:');b=s.index('/// Called on the Bluetooth queue.',a) +out.joinpath('State.swift').write_text('import Foundation\n'+s[a:b]) +PY +swiftc -swift-version 5 Sources/FinallyTheControllerWorks/Protocol/Switch2Protocol.swift \ + "$work/State.swift" "$work/Sink.swift" tests/retroarch/NetworkTests.swift -o "$work/test" +"$work/test" "${NETPAD_CASE:-all}"