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/FTCWApp.swift b/Sources/FinallyTheControllerWorks/FTCWApp.swift index 65c7d53..42b8632 100644 --- a/Sources/FinallyTheControllerWorks/FTCWApp.swift +++ b/Sources/FinallyTheControllerWorks/FTCWApp.swift @@ -83,6 +83,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 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 }, + }); +})();