Skip to content
35 changes: 32 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 <https://www.xbox.com/play> 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**
Expand All @@ -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

Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions Sources/FinallyTheControllerWorks/FTCWApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
227 changes: 227 additions & 0 deletions Sources/FinallyTheControllerWorks/Output/WebSocketHub.swift
Original file line number Diff line number Diff line change
@@ -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":<Switch2.Buttons raw u32>,
// "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)
}
}
}
Loading