From d3782c5ca39942a304dfab3e048a2da42f32737c Mon Sep 17 00:00:00 2001 From: Johnny D Date: Wed, 9 Sep 2026 13:09:10 -0400 Subject: [PATCH 1/5] test: add protocol fixtures and read-only macOS app build checks --- .github/workflows/macos-validation.yml | 39 +++++++++++++++++++++ tests/ProtocolTests.swift | 47 ++++++++++++++++++++++++++ tests/run.sh | 10 ++++++ 3 files changed, 96 insertions(+) create mode 100644 .github/workflows/macos-validation.yml create mode 100644 tests/ProtocolTests.swift create mode 100644 tests/run.sh diff --git a/.github/workflows/macos-validation.yml b/.github/workflows/macos-validation.yml new file mode 100644 index 0000000..029c7ac --- /dev/null +++ b/.github/workflows/macos-validation.yml @@ -0,0 +1,39 @@ +name: macOS build and regressions +on: + pull_request: + push: + branches: [main] + workflow_dispatch: +permissions: + contents: read +concurrency: + group: macos-${{ github.ref }} + cancel-in-progress: true +jobs: + validate: + runs-on: macos-26 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Record source and toolchain + run: | + git rev-parse HEAD + swift --version + xcodebuild -version + git archive HEAD -o "$RUNNER_TEMP/source.zip" + - name: Run controller protocol 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" + - name: Preserve exact tested source + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: tested-source + path: ${{ runner.temp }}/source.zip + retention-days: 7 diff --git a/tests/ProtocolTests.swift b/tests/ProtocolTests.swift new file mode 100644 index 0000000..1f8cd12 --- /dev/null +++ b/tests/ProtocolTests.swift @@ -0,0 +1,47 @@ +import Foundation + +@main +enum ProtocolTests { + static func main() { + // Synthetic fixtures, not hardware captures. Preserve existing bytes. + let led = Switch2.buildCommand(0x09, 0x07, data: Data([3, 0, 0, 0])) + precondition(led == Data([9, 0x91, 1, 7, 0, 4, 0, 0, 3, 0, 0, 0])) + precondition(Switch2.memoryReadPayload(length: 4, address: 0x13000) + == Data([4, 0x7e, 0, 0, 0, 0x30, 1, 0])) + precondition(Switch2.Model.nsoGameCube.hasAnalogTriggers) + precondition(!Switch2.Model.nsoGameCube.hasHDRumble) + precondition(Switch2.Model.proController2.hasHDRumble) + precondition(!Switch2.Model.joyCon2Right.hasSecondStick) + + for length in 0..<60 { + precondition(Switch2.InputReport(data: Data(repeating: 0, count: length)) == nil) + } + for value in UInt8.min...UInt8.max { + var bytes = Data(repeating: 0, count: 63) + bytes[4] = value + bytes[60] = value + bytes[61] = 255 - value + let r = Switch2.InputReport(data: bytes)! + precondition(r.buttons.rawValue == UInt32(value)) + precondition(r.leftTriggerRaw == value && r.rightTriggerRaw == 255 - value) + // Nonzero Data.startIndex must not shift any protocol field. + let sliced = (Data([0xaa]) + bytes).dropFirst() + let s = Switch2.InputReport(data: sliced)! + precondition(s.buttons == r.buttons && s.leftTriggerRaw == r.leftTriggerRaw) + } + let packed = Switch2.stickXY(Data([0xff, 0x0f, 0x80]), 0) + precondition(packed.0 == 4095 && packed.1 == 2048) + for model in Switch2.Model.allCases { + var advert = Data(repeating: 0, count: 16) + advert[3] = 0x7e; advert[4] = 0x05 + advert[5] = UInt8(model.rawValue & 0xff) + advert[6] = UInt8(model.rawValue >> 8) + let parsed = Switch2.parseAdvertisement(manufacturerData: advert)! + precondition(parsed.model == model && parsed.isPairing) + advert[10] = 1 + precondition(!Switch2.parseAdvertisement(manufacturerData: advert)!.isPairing) + } + precondition(Switch2.parseAdvertisement(manufacturerData: Data(repeating: 0, count: 15)) == nil) + print("Protocol regression fixtures passed (all 256 trigger values, models and sliced data).") + } +} diff --git a/tests/run.sh b/tests/run.sh new file mode 100644 index 0000000..ecc0624 --- /dev/null +++ b/tests/run.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# Compile the production protocol decoder directly, without opening Bluetooth. +set -euo pipefail +cd "$(dirname "$0")/.." +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +swiftc -swift-version 5 \ + Sources/FinallyTheControllerWorks/Protocol/Switch2Protocol.swift \ + tests/ProtocolTests.swift -o "$work/protocol-tests" +"$work/protocol-tests" From d32ed57a6bc2d6d991777638b65c08c1664caafb Mon Sep 17 00:00:00 2001 From: Johnny D Date: Wed, 9 Sep 2026 13:13:16 -0400 Subject: [PATCH 2/5] test: run isolated regression groups alongside protocol fixtures --- tests/run.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/run.sh b/tests/run.sh index ecc0624..0e68652 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -8,3 +8,7 @@ swiftc -swift-version 5 \ Sources/FinallyTheControllerWorks/Protocol/Switch2Protocol.swift \ tests/ProtocolTests.swift -o "$work/protocol-tests" "$work/protocol-tests" +for suite in tests/*/run.sh; do + [ -f "$suite" ] || continue + bash "$suite" +done From 20bfc6b4e654f3a518d11054f426a349ef1806a8 Mon Sep 17 00:00:00 2001 From: Johnny D Date: Wed, 9 Sep 2026 20:01:37 -0400 Subject: [PATCH 3/5] Adopt the browser bridge with replay, lifecycle, and access guards (#5) * 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. * 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. * fix(browser): distinguish validated rumble values from clamped magnitudes * docs(browser): document fork opt-in setup and actual compatibility limits * 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. * 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 | 204 ++++-------- Resources/Info.plist | 6 +- .../FinallyTheControllerWorks/FTCWApp.swift | 6 + .../Output/NetworkGamepadSink.swift | 218 +++++++++++++ .../Output/WebSocketHub.swift | 239 ++++++++++++++ .../UI/AboutAndOnboarding.swift | 11 +- .../UI/AppNotifications.swift | 11 + .../UI/BrowserBridgeSettings.swift | 24 ++ .../UI/DashboardView.swift | 19 ++ .../UI/Updater.swift | 13 +- browser/FORK-INTEGRATION.md | 37 +++ browser/README.md | 111 +++++++ browser/extension/background.js | 87 +++++ browser/extension/bridge.js | 73 +++++ browser/extension/manifest.json | 46 +++ browser/extension/shim.js | 306 ++++++++++++++++++ docs/fork-identity.md | 33 ++ docs/retroarch-integration.md | 53 +++ scripts/build-app.sh | 21 +- scripts/notarize.sh | 76 +---- tests/browser/BrowserServer.swift | 34 ++ tests/browser/background.test.cjs | 74 +++++ tests/browser/run.sh | 19 ++ tests/browser/shim.test.cjs | 105 ++++++ tests/browser/websocket_test.py | 145 +++++++++ tests/fork/check.py | 61 ++++ tests/fork/run.sh | 9 + tests/retroarch/NetworkTests.swift | 137 ++++++++ tests/retroarch/run.sh | 21 ++ 30 files changed, 1986 insertions(+), 219 deletions(-) create mode 100644 Sources/FinallyTheControllerWorks/Output/NetworkGamepadSink.swift create mode 100644 Sources/FinallyTheControllerWorks/Output/WebSocketHub.swift create mode 100644 Sources/FinallyTheControllerWorks/UI/BrowserBridgeSettings.swift create mode 100644 browser/FORK-INTEGRATION.md 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 create mode 100644 docs/fork-identity.md create mode 100644 docs/retroarch-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 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 d085e1d..b202d74 100644 --- a/README.md +++ b/README.md @@ -1,142 +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. - -Once Apple's approval lands, the SDL step disappears 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. - -## 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 -- 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 - -``` -Controller ──BLE──> BridgeEngine ──> ControllerSession (per slot) - │ handshake, keep-alive, decode, rumble - ▼ - ControllerOutputSink protocol - ├── VirtualHIDSink (CoreHID; entitlement-gated) - └── UDPHub (SDL-compat, ports 24800-24803) +git clone https://github.com/jmonster/switch2mac.git +cd switch2mac +bash tests/run.sh +bash scripts/build-app.sh ``` -- `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 65c7d53..22b28be 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,8 @@ 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(NetworkGamepadSink()) engine.addSink(VirtualHIDSink()) notifications.attach(to: engine) // Daily auto-update check (only if a feed URL is configured); results @@ -144,6 +149,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/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/Output/WebSocketHub.swift b/Sources/FinallyTheControllerWorks/Output/WebSocketHub.swift new file mode 100644 index 0000000..1267891 --- /dev/null +++ b/Sources/FinallyTheControllerWorks/Output/WebSocketHub.swift @@ -0,0 +1,239 @@ +// 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: Client] = [:] + private var connected: [Int: (model: String, name: String, rumble: Bool)] = [:] + private var seq: [Int: UInt32] = [:] + private var lastState: [Int: String] = [:] + private var rumbleOwners: [Int: ObjectIdentifier] = [:] + private var pingTimer: DispatchSourceTimer? + + 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)" + }) + } + + 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() } + } + + private func startListener() { + guard listener == nil else { return } + let params = NWParameters.tcp + 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) + do { + 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)") + } + } + + 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) + 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: + 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.connectionMessage(slot, info.model, info.name), to: client) + if let state = self.lastState[slot] { self.send(state, to: client) } + } + self.receive(client) + case .failed, .cancelled: self.remove(id) + default: break + } + } + connection.start(queue: queue) + } + + 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, from id: ObjectIdentifier) { + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + 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 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) + } else { rumbleOwners[slot] = id } + onRumble?(slot, strong, weak) + } 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))") + } + } + + 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: 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 client in Array(clients.values) where client.ready { send(text, to: client) } + } + + 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 connectionMessage(_ slot: Int, _ model: String, _ name: String) -> String { + json(["t":"connected", "slot":slot, "model":model, "name":name])! + } + + func controllerConnected(slot: Int, model: Switch2.Model) { + queue.async { [weak self] in + 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.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, 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.connected[slot] != nil else { return } + let next = (self.seq[slot] ?? 0) &+ 1 + self.seq[slot] = next + 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/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/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/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/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/README.md b/browser/README.md new file mode 100644 index 0000000..d8a2674 --- /dev/null +++ b/browser/README.md @@ -0,0 +1,111 @@ +# The browser bridge + +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 | Purpose | +|---|---| +| `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 + +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, 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` | + +App-side remapping runs before this output. Trigger travel, physical clicks, +and per-game mappings still require hardware acceptance. + +## Identity and sites + +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 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 + +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"} +page → hub {"t":"rumble","slot":0,"strong":0…1,"weak":0…1} +``` + +`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. diff --git a/browser/extension/background.js b/browser/extension/background.js new file mode 100644 index 0000000..07d6e2a --- /dev/null +++ b/browser/extension/background.js @@ -0,0 +1,87 @@ +// 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 -> {connection, state}; a rename is not a connection + +const fanOut = (text) => { + for (const port of ports) { try { port.postMessage(text); } catch {} } +}; + +function connect() { + retryTimer = null; + if (socket || ports.size === 0) 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); + }; + owner.onclose = () => { + if (socket !== owner) return; + socket = null; + replay.clear(); + fanOut('{"t":"bridge","up":false}'); + scheduleRetry(); + }; + owner.onerror = () => {}; // onclose owns failure/retry +} + +function scheduleRetry() { + if (retryTimer !== null || ports.size === 0) return; + retryTimer = setTimeout(connect, RETRY_MS); +} + +function track(text) { + let m; + try { m = JSON.parse(text); } catch { return; } + 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' || ports.size >= 64) return; + ports.add(port); + port.onMessage.addListener((text) => { + 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) 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 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/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..3f5d713 --- /dev/null +++ b/browser/extension/shim.js @@ -0,0 +1,306 @@ +// 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, refresh = null, pending = null, generation = 0; + let actuator; + const current = () => bridgeUp && pads.get(slot)?.vibrationActuator === actuator; + const finish = (result) => { + if (pending) { const resolve = pending; pending = null; resolve(result); } + }; + 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); + }; + actuator = { + type: 'dual-rumble', + effects: ['dual-rumble'], + playEffect(type, params = {}) { + if (type !== 'dual-rumble') return Promise.resolve('invalid-parameter'); + 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 pulse = () => { + if (owner !== generation || !current()) { stop(); return; } + rumbleToApp(slot, strong, weak); + }; + 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(); 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' + ? '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++; + // 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); + } + return real; + }; + + Object.defineProperty(navigator, '__ftcwBridge', { + value: { get pads() { return [...pads.values()]; }, get up() { return bridgeUp; }, persona: PERSONA }, + }); +})(); 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 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..f4eba94 --- /dev/null +++ b/tests/browser/shim.test.cjs @@ -0,0 +1,105 @@ +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 = [], events = [], nativePads = []; + class Event {constructor(type, options={}) {this.type=type; Object.assign(this,options);}} + 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){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);}, + 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,events,nativePads}; +} + +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); +}); + + +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'); +}); 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') diff --git a/tests/fork/check.py b/tests/fork/check.py new file mode 100644 index 0000000..07767a8 --- /dev/null +++ b/tests/fork/check.py @@ -0,0 +1,61 @@ +"""Metadata/signing guards and the production updater's actual feed resolver.""" +from pathlib import Path +import os +import plistlib +import re +import subprocess +import sys + +root = Path(__file__).resolve().parents[2] +info = plistlib.loads((root / 'Resources/Info.plist').read_bytes()) +assert info['CFBundleIdentifier'] == 'io.github.jmonster.switch2mac' +assert info['CFBundleDisplayName'].endswith('(jmonster)') +assert 'Peter Sharma' in info['NSHumanReadableCopyright'] +about = (root / 'Sources/FinallyTheControllerWorks/UI/AboutAndOnboarding.swift').read_text() +assert re.search(r'static let updatesEnabled\s*=\s*false', about) +assert re.search(r'static let defaultUpdateFeedURL\s*=\s*""', about) +assert 'https://buymeacoffee.com/peterksharma' in about +updater = (root / 'Sources/FinallyTheControllerWorks/UI/Updater.swift').read_text() +# Extract the actual resolver without its unrelated AppKit/SwiftUI UI. Use +# an upstream-like nonempty default and an explicit saved override to show +# both are rejected by the production policy guard. +a = updater.index(' var feedURL: URL? {') +b = updater.index('\n /// Auto-check', a) +resolver = updater[a:b] +Path(sys.argv[1]).write_text('''import Foundation +enum AppInfo { + static let updatesEnabled = false + static let defaultUpdateFeedURL = "https://example.invalid/upstream.json" +} +final class Updater { + static let feedURLKey = "fork-update-policy-test" +''' + resolver + ''' +} +@main enum Test { + static func main() { + let defaults = UserDefaults.standard + defaults.removeObject(forKey: Updater.feedURLKey) + defer { defaults.removeObject(forKey: Updater.feedURLKey) } + precondition(Updater().feedURL == nil, "Default feed must be disabled") + defaults.set("https://example.invalid/override.json", forKey: Updater.feedURLKey) + precondition(Updater().feedURL == nil, "Saved override must not bypass fork policy") + print("PASS disabled default and override update feeds") + } +} +''') +for method in ['downloadAndInstall(_ entry: AppcastEntry)', 'installNow()']: + start = updater.index(' func ' + method) + assert 'guard AppInfo.updatesEnabled else { return }' in updater[start:start + 150] +# No signed build or credentials are accessed by these negative controls. +env = dict(os.environ) +for name in ('SIGN_IDENTITY', 'SIGN_ENTITLEMENTS', 'NOTARY_KEYCHAIN_PROFILE', 'PROVISIONING_PROFILE'): + env.pop(name, None) +def rejected(script, expected): + result = subprocess.run(['bash', str(root / script)], env=env, capture_output=True, text=True, timeout=3) + assert result.returncode != 0 and expected in result.stderr, result.stderr +rejected('scripts/notarize.sh', 'Supply your own Developer ID') +env['SIGN_IDENTITY'] = 'test-only-not-a-certificate' +rejected('scripts/notarize.sh', 'Supply your own notarytool') +env['PROVISIONING_PROFILE'] = '/nonexistent-test-profile' +rejected('scripts/build-app.sh', 'Provide a fork-specific entitlement plist') +print('PASS fork metadata, attribution, and fail-closed signing configuration') diff --git a/tests/fork/run.sh b/tests/fork/run.sh new file mode 100644 index 0000000..1c9b244 --- /dev/null +++ b/tests/fork/run.sh @@ -0,0 +1,9 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")/../.." +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +python3 tests/fork/check.py "$work/UpdatePolicyTests.swift" +swiftc -swift-version 5 -parse-as-library "$work/UpdatePolicyTests.swift" -o "$work/test" +"$work/test" +bash -n scripts/build-app.sh scripts/notarize.sh diff --git a/tests/retroarch/NetworkTests.swift b/tests/retroarch/NetworkTests.swift new file mode 100644 index 0000000..545493c --- /dev/null +++ b/tests/retroarch/NetworkTests.swift @@ -0,0 +1,137 @@ +import Foundation +#if os(Linux) +import Glibc +let datagram = Int32(SOCK_DGRAM.rawValue) +#else +import Darwin +let datagram = SOCK_DGRAM +#endif + +enum BridgeEngine { static let maxPlayers = 4 } +enum AppConfig { + static var networkGamepadEnabled = true + static var networkGamepadBasePort = 55400 +} +enum LogLevel { case info, warning, error, debug } +var logged: [String] = [] // examined only with the sink queue quiescent +func bridgeLog(_ level: LogLevel, _ category: String, _ message: String) { logged.append(message) } +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 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}" From 4a4f5b56faee763b21635e001ad6a5807cd107f1 Mon Sep 17 00:00:00 2001 From: Johnny D Date: Wed, 9 Sep 2026 20:01:49 -0400 Subject: [PATCH 4/5] Neutralize disconnected UDP controllers and bound subscriber processing (#4) * fix(udp): neutralize disconnected slots and bound subscriber handling * test(udp): synchronize real datagram arrival before subscriber assertions --- .../Output/UDPHub.swift | 25 +++- tests/udp/UDPTests.swift | 112 ++++++++++++++++++ tests/udp/run.sh | 21 ++++ 3 files changed, 152 insertions(+), 6 deletions(-) create mode 100644 tests/udp/UDPTests.swift create mode 100755 tests/udp/run.sh diff --git a/Sources/FinallyTheControllerWorks/Output/UDPHub.swift b/Sources/FinallyTheControllerWorks/Output/UDPHub.swift index 20358ab..b2b6f86 100644 --- a/Sources/FinallyTheControllerWorks/Output/UDPHub.swift +++ b/Sources/FinallyTheControllerWorks/Output/UDPHub.swift @@ -8,7 +8,7 @@ // | f32 lx,ly,rx,ry | u8 lt,rt | u16 battery_mv // | i16 gyro[3] | i16 accel[3] // Rumble in (6 bytes): "S2R1" | u8 strong | u8 weak -// Any inbound datagram registers its sender as a subscriber (30 s TTL). +// An empty hello or valid rumble registers its sender (30 s TTL). import Foundation import Darwin @@ -17,6 +17,7 @@ final class UDPHub: ControllerOutputSink, @unchecked Sendable { private static let basePort: UInt16 = 24800 private static let peerTTL: TimeInterval = 30 + private static let maxPeers = 64 var onRumble: ((Int, Double, Double) -> Void)? @@ -93,7 +94,8 @@ final class UDPHub: ControllerOutputSink, @unchecked Sendable { private func drainSocket(slot: Int) { guard let s = slots[slot] else { return } var buf = [UInt8](repeating: 0, count: 64) - while true { + // Bound each read dispatch so a noisy local peer cannot starve output. + for _ in 0..<256 { var from = sockaddr_in() var fromLen = socklen_t(MemoryLayout.size) let n = withUnsafeMutablePointer(to: &from) { @@ -102,14 +104,19 @@ final class UDPHub: ControllerOutputSink, @unchecked Sendable { } } if n < 0 { break } // EWOULDBLOCK: drained + let isRumble = n == 6 && buf[0...3].elementsEqual([0x53, 0x32, 0x52, 0x31]) + guard n == 0 || isRumble else { continue } + let now = ProcessInfo.processInfo.systemUptime + s.peers = s.peers.filter { now - $0.value <= Self.peerTTL } let peer = SockAddr(addr: from.sin_addr.s_addr, port: from.sin_port) let isNewPeer = s.peers[peer] == nil - s.peers[peer] = CFAbsoluteTimeGetCurrent() + guard !isNewPeer || s.peers.count < Self.maxPeers else { continue } + s.peers[peer] = now if isNewPeer, !s.name.isEmpty { // Late joiners get the name before their first state packet. send(Self.namePacket(s.name), to: peer, via: s.fd) } - if n >= 6, buf[0] == 0x53, buf[1] == 0x32, buf[2] == 0x52, buf[3] == 0x31 { // "S2R1" + if isRumble { onRumble?(slot, Double(buf[4]) / 255.0, Double(buf[5]) / 255.0) } } @@ -154,7 +161,13 @@ final class UDPHub: ControllerOutputSink, @unchecked Sendable { func controllerDisconnected(slot: Int) { queue.async { [weak self] in - self?.slots[slot]?.seq = 0 + guard let self, let s = self.slots[slot] else { return } + // Release held input immediately on orderly disconnect. The SDL + // presence watchdog remains necessary for crashes or packet loss. + s.seq &+= 1 + let neutral = Self.statePacket(seq: s.seq, state: ControllerState()) + for peer in s.peers.keys { self.send(neutral, to: peer, via: s.fd) } + s.name = "" } } @@ -163,7 +176,7 @@ final class UDPHub: ControllerOutputSink, @unchecked Sendable { guard let self, let s = self.slots[slot], !s.peers.isEmpty else { return } s.seq &+= 1 let packet = Self.statePacket(seq: s.seq, state: state) - let now = CFAbsoluteTimeGetCurrent() + let now = ProcessInfo.processInfo.systemUptime for (peer, seen) in s.peers { if now - seen > Self.peerTTL { s.peers.removeValue(forKey: peer) diff --git a/tests/udp/UDPTests.swift b/tests/udp/UDPTests.swift new file mode 100644 index 0000000..eead0f4 --- /dev/null +++ b/tests/udp/UDPTests.swift @@ -0,0 +1,112 @@ +import Foundation +#if os(Linux) +import Glibc +let datagram = Int32(SOCK_DGRAM.rawValue) +#else +import Darwin +let datagram = SOCK_DGRAM +#endif + +enum BridgeEngine { static let maxPlayers = 4 } +enum LogLevel { case info, warning, error, debug } +func bridgeLog(_ level: LogLevel, _ category: String, _ message: String) {} +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 UDPTests { + static func client(_ port: UInt16) -> Int32 { + let fd = socket(AF_INET, datagram, 0) + precondition(fd >= 0) + var address = sockaddr_in() + address.sin_family = sa_family_t(AF_INET) + address.sin_port = port.bigEndian + address.sin_addr.s_addr = UInt32(0x7f000001).bigEndian + let result = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + connect(fd, $0, socklen_t(MemoryLayout.size)) + } + } + precondition(result == 0) + var timeout = timeval(tv_sec: 1, tv_usec: 0) + precondition(setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) == 0) + return fd + } + static func sendBytes(_ fd: Int32, _ data: [UInt8]) { + let n = data.withUnsafeBytes { send(fd, $0.baseAddress, $0.count, 0) } + precondition(n == data.count) + } + static func receive(_ fd: Int32) -> Data { + var bytes = [UInt8](repeating: 0, count: 128) + let n = recv(fd, &bytes, bytes.count, 0) + precondition(n >= 0, "Expected UDP packet was not delivered") + return Data(bytes.prefix(n)) + } + static func sendAndDrain(_ hub: UDPHub, slot: Int, fd: Int32, bytes: [UInt8]) { + // Hold the hub's serial queue so its dispatch source cannot consume + // the packet between poll() and the explicit production drain call. + hub.queue.sync { + sendBytes(fd, bytes) + var ready = pollfd(fd: hub.slots[slot]!.fd, events: Int16(POLLIN), revents: 0) + precondition(poll(&ready, 1, 1000) == 1, "Loopback packet did not arrive") + hub.drainSocket(slot: slot) + } + } + static func main() { + let selected = CommandLine.arguments.last! + let hub = UDPHub() + hub.queue.sync { precondition(hub.slots.count == 4) } + let a = client(24800), b = client(24801) + defer { close(a); close(b) } + sendBytes(a, []); sendBytes(b, []) + for _ in 0..<100 { + if hub.queue.sync(execute: { !hub.slots[0]!.peers.isEmpty && !hub.slots[1]!.peers.isEmpty }) { break } + Thread.sleep(forTimeInterval: 0.005) + } + if selected == "all" || selected == "neutral" { + var held = ControllerState(); held.buttons = [.a]; held.leftTrigger = 255 + hub.controllerState(slot: 0, state: held) + precondition(Switch2.u32(receive(a), 8) == Switch2.Buttons.a.rawValue) + hub.controllerDisconnected(slot: 0) + let packet = receive(a) + precondition(packet.count == 44 && packet.prefix(4) == Data("S2B1".utf8)) + precondition(packet.dropFirst(8).allSatisfy { $0 == 0 }, "Disconnect must emit neutral state") + hub.controllerState(slot: 1, state: held) + precondition(Switch2.u32(receive(b), 8) == Switch2.Buttons.a.rawValue) + print("PASS orderly neutralization and unrelated slot") + } + if selected == "all" || selected == "malformed" { + let c = client(24802); defer { close(c) } + sendAndDrain(hub, slot: 2, fd: c, bytes: [1, 2, 3]) + hub.queue.sync { + precondition(hub.slots[2]!.peers.isEmpty, "Malformed packets must not allocate subscribers") + } + print("PASS malformed subscription rejection") + } + if selected == "all" || selected == "capacity" { + hub.queue.sync { + let s = hub.slots[3]! + for port in 1...64 { + s.peers[UDPHub.SockAddr(addr: 0x0100007f, port: UInt16(port))] = ProcessInfo.processInfo.systemUptime + } + } + let c = client(24803); defer { close(c) } + sendAndDrain(hub, slot: 3, fd: c, bytes: []) + hub.queue.sync { + precondition(hub.slots[3]!.peers.count == 64, "Peer table must stay bounded") + } + // Expired peers must not block a new subscriber even without state traffic. + hub.queue.sync { hub.slots[3]!.peers = hub.slots[3]!.peers.mapValues { _ in -100 } } + sendAndDrain(hub, slot: 3, fd: c, bytes: []) + hub.queue.sync { + precondition(hub.slots[3]!.peers.count == 1) + } + print("PASS peer cap and expiry without input") + } + } +} diff --git a/tests/udp/run.sh b/tests/udp/run.sh new file mode 100755 index 0000000..5466e79 --- /dev/null +++ b/tests/udp/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]) +s=Path(os.environ.get('UDP_SOURCE','Sources/FinallyTheControllerWorks/Output/UDPHub.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('UDPHub.swift').write_text(s) +s=Path('Sources/FinallyTheControllerWorks/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/UDPHub.swift" tests/udp/UDPTests.swift -o "$work/test" +"$work/test" "${UDP_CASE:-all}" From 5ae4d21d9acefe175586c2db462536123019c59e Mon Sep 17 00:00:00 2001 From: Johnny D Date: Wed, 9 Sep 2026 20:01:59 -0400 Subject: [PATCH 5/5] Fix session response matching, fallback sticks, and GameCube motor gating (#3) * fix(session): preserve command correlation and respect model capabilities * fix(session): retire callbacks before teardown and require usable input for readiness (#8) Guard late notifications, queued commands and timers after session retirement. Clear notify completion before invocation to preserve reentrant replacement. Require isNotifying on essential channels and one existing-decoder input report before readiness, retaining the established keep-alive and handshake bytes. Eleven synthetic production-session tests pass locally; five new targeted cases fail against the prior session source. Full Apple-framework build and regressions remain the hosted gate. No new connection deadline or protocol. --- .../Bluetooth/ControllerSession.swift | 116 +++++++++----- docs/session-retirement.md | 30 ++++ tests/session/FrameworkFakes.swift | 42 ++++++ tests/session/SessionTests.swift | 142 ++++++++++++++++++ tests/session/run.sh | 20 +++ 5 files changed, 316 insertions(+), 34 deletions(-) create mode 100644 docs/session-retirement.md create mode 100644 tests/session/FrameworkFakes.swift create mode 100644 tests/session/SessionTests.swift create mode 100755 tests/session/run.sh diff --git a/Sources/FinallyTheControllerWorks/Bluetooth/ControllerSession.swift b/Sources/FinallyTheControllerWorks/Bluetooth/ControllerSession.swift index ae1ff9c..888190f 100644 --- a/Sources/FinallyTheControllerWorks/Bluetooth/ControllerSession.swift +++ b/Sources/FinallyTheControllerWorks/Bluetooth/ControllerSession.swift @@ -68,6 +68,9 @@ final class ControllerSession: NSObject, @unchecked Sendable { private var chars: [UUID: CBCharacteristic] = [:] private var handshakeStarted = false + private var ended = false + private var handshakeComplete = false + private var readyReported = false private var pendingCommand: (id: UInt8, completion: (Data?) -> Void)? private var commandTimeout: DispatchWorkItem? private var handshakeSteps: [(String, (@escaping (Bool) -> Void) -> Void)] = [] @@ -125,11 +128,18 @@ final class ControllerSession: NSObject, @unchecked Sendable { /// Called by the engine once CoreBluetooth reports the connect. func begin() { + guard !ended else { return } log(.info, "slot \(slot + 1): discovering services") peripheral.discoverServices(nil) } func teardown() { + guard !ended else { return } + ended = true + notifyCompletion = nil + handshakeSteps.removeAll() + onState = nil + onRSSI = nil keepAliveTimer?.cancel() keepAliveTimer = nil commandTimeout?.cancel() @@ -143,12 +153,14 @@ final class ControllerSession: NSObject, @unchecked Sendable { } private func fail(_ reason: String) { + guard !ended else { return } log(.error, "slot \(slot + 1): \(reason)") teardown() delegate?.sessionFailed(self, reason: reason) } private func runHandshake() { + guard !ended else { return } // Order matters and mirrors the console: command-response subscribe // must precede any command; identity before vibration char choice. handshakeSteps = [ @@ -163,15 +175,17 @@ final class ControllerSession: NSObject, @unchecked Sendable { } private func advanceHandshake() { + guard !ended else { return } guard !handshakeSteps.isEmpty else { + handshakeComplete = true + // Keep the existing keep-alive while awaiting the first report. startKeepAlive() - log(.info, "slot \(slot + 1): handshake complete — \(displayName) serial \(serialNumber)") - delegate?.sessionReady(self) + if announceReady() { onState?(slot, state) } return } let (name, step) = handshakeSteps.removeFirst() step { [weak self] ok in - guard let self else { return } + guard let self, !self.ended else { return } if ok { self.advanceHandshake() } else { @@ -180,6 +194,16 @@ final class ControllerSession: NSObject, @unchecked Sendable { } } + /// Notification subscription alone is not usable controller input. + @discardableResult + private func announceReady() -> Bool { + guard !ended, handshakeComplete, reportCount > 0, !readyReported else { return false } + readyReported = true + log(.info, "slot \(slot + 1): handshake and first input complete — \(displayName)") + delegate?.sessionReady(self) + return true + } + // MARK: Handshake steps private func stepReadInfo(_ done: @escaping (Bool) -> Void) { @@ -196,7 +220,7 @@ final class ControllerSession: NSObject, @unchecked Sendable { private func stepReadCalibration(_ done: @escaping (Bool) -> Void) { readStickCalibration(user: Switch2.Address.userStick1, factory: Switch2.Address.factoryStick1) { [weak self] cal in - guard let self else { return } + guard let self, !self.ended else { return } self.leftCal = cal guard self.model.hasSecondStick else { done(true) // single-stick unit: slot-2 holds no valid data @@ -204,7 +228,7 @@ final class ControllerSession: NSObject, @unchecked Sendable { } self.readStickCalibration(user: Switch2.Address.userStick2, factory: Switch2.Address.factoryStick2) { [weak self] cal in - guard let self else { return } + guard let self, !self.ended else { return } self.rightCal = cal done(true) // calibration is best-effort; defaults are usable } @@ -214,7 +238,7 @@ final class ControllerSession: NSObject, @unchecked Sendable { private func readStickCalibration(user: UInt32, factory: UInt32, _ done: @escaping (Switch2.StickCalibration?) -> Void) { readMemory(length: 0x0B, address: user) { [weak self] data in - guard let self else { return } + guard let self, !self.ended else { return } if let data, !Switch2.StickCalibration.isBlank(data) { done(Switch2.StickCalibration(data: data)) return @@ -274,7 +298,7 @@ final class ControllerSession: NSObject, @unchecked Sendable { private func writeCommand(_ command: UInt8, _ subcommand: UInt8, _ data: Data, flag: UInt8 = 0x01, completion: @escaping (Data?) -> Void) { - guard let writeChar = chars[Switch2.GATT.commandWrite] else { + guard !ended, let writeChar = chars[Switch2.GATT.commandWrite] else { completion(nil); return } guard pendingCommand == nil else { @@ -298,7 +322,10 @@ final class ControllerSession: NSObject, @unchecked Sendable { } private func handleCommandResponse(_ data: Data) { - guard let pending = pendingCommand else { return } + // An unrelated notification must not consume the command or its timer. + guard !ended, let pending = pendingCommand, data.count >= 8, + data[data.startIndex] == pending.id, + data[data.startIndex + 1] == 0x01 || data[data.startIndex + 1] == 0x02 else { return } commandTimeout?.cancel() pendingCommand = nil // NFC experiments: log the COMPLETE frame (header included) — the @@ -310,11 +337,6 @@ final class ControllerSession: NSObject, @unchecked Sendable { // status/error reply (same shape, payload starts with a status code). // Both correlate to our command — pass the payload up and let the // caller interpret the status byte. - guard data.count >= 8, data[data.startIndex] == pending.id, - data[data.startIndex + 1] == 0x01 || data[data.startIndex + 1] == 0x02 else { - pending.completion(nil) - return - } pending.completion(data.subdata(in: data.startIndex + 8 ..< data.endIndex)) } @@ -323,7 +345,8 @@ final class ControllerSession: NSObject, @unchecked Sendable { let payload = Switch2.memoryReadPayload(length: length, address: address) writeCommand(Switch2.Command.memory, Switch2.Subcommand.memoryRead, payload) { resp in guard let resp, resp.count >= 8 + Int(length), - resp[resp.startIndex] == length else { + resp[resp.startIndex] == length, + Switch2.u32(resp, 4) == address else { completion(nil); return } completion(resp.subdata(in: resp.startIndex + 8 ..< resp.endIndex)) @@ -350,7 +373,7 @@ final class ControllerSession: NSObject, @unchecked Sendable { /// bypasses persisted patterns. Restores normal LEDs when `nil`. func setRawLEDs(_ pattern: UInt8?) { queue.async { [weak self] in - guard let self else { return } + guard let self, !self.ended else { return } if let pattern { self.writeCommand(Switch2.Command.leds, Switch2.Subcommand.ledsSetPlayer, Data([pattern, 0, 0, 0])) { _ in } @@ -368,7 +391,10 @@ final class ControllerSession: NSObject, @unchecked Sendable { /// Read the current RSSI; result arrives via the rssi callback. var onRSSI: ((Int) -> Void)? func requestRSSI() { - queue.async { [weak self] in self?.peripheral.readRSSI() } + queue.async { [weak self] in + guard let self, !self.ended else { return } + self.peripheral.readRSSI() + } } // MARK: - Experiments (NFC probing, audio capture) @@ -393,7 +419,7 @@ final class ControllerSession: NSObject, @unchecked Sendable { private(set) var promiscuousNotify = false func setPromiscuousNotify(_ enabled: Bool) { queue.async { [weak self] in - guard let self else { return } + guard let self, !self.ended else { return } self.promiscuousNotify = enabled let known: Set = [Switch2.GATT.inputReport, Switch2.GATT.commandResponse, @@ -415,7 +441,7 @@ final class ControllerSession: NSObject, @unchecked Sendable { /// queue only). Returns false when the characteristic is absent. @discardableResult func writeAudioFrame(_ data: Data) -> Bool { - guard let ch = chars[Self.audioOutputUUID] else { return false } + guard !ended, let ch = chars[Self.audioOutputUUID] else { return false } peripheral.writeValue(data, for: ch, type: .withoutResponse) lastWriteAt = CFAbsoluteTimeGetCurrent() return true @@ -464,7 +490,7 @@ final class ControllerSession: NSObject, @unchecked Sendable { private(set) var audioExperimentName: String? func beginAudioExperiment(_ name: String) -> Bool { dispatchPrecondition(condition: .onQueue(queue)) - guard audioExperimentName == nil else { return false } + guard !ended, audioExperimentName == nil else { return false } audioExperimentName = name return true } @@ -488,7 +514,7 @@ final class ControllerSession: NSObject, @unchecked Sendable { next: @escaping () -> Data?, done: @escaping (AudioStreamStats) -> Void) { queue.async { [weak self] in - guard let self else { return } + guard let self, !self.ended else { return } self.audioStreamTimer?.cancel() self.audioStreamQueue.removeAll() self.audioStreamStats = AudioStreamStats(chunkLimit: self.audioWriteChunkLimit) @@ -534,7 +560,7 @@ final class ControllerSession: NSObject, @unchecked Sendable { /// Write queued chunks until the stack refuses; `peripheralIsReady` /// re-enters. Runs on the Bluetooth queue only. fileprivate func drainAudioStream() { - guard audioStreamNext != nil, let ch = chars[Self.audioOutputUUID] else { return } + guard !ended, audioStreamNext != nil, let ch = chars[Self.audioOutputUUID] else { return } while !audioStreamQueue.isEmpty { guard peripheral.canSendWriteWithoutResponse else { audioStreamStats.stalls += 1 @@ -571,9 +597,8 @@ final class ControllerSession: NSObject, @unchecked Sendable { /// Returns false via completion when the firmware doesn't expose it. func setAudioCapture(_ enabled: Bool, completion: @escaping (Bool) -> Void) { queue.async { [weak self] in - guard let self, let ch = self.chars[Self.audioInputUUID] else { - completion(false); return - } + guard let self, !self.ended, let ch = self.chars[Self.audioInputUUID] else { + completion(false); return } self.peripheral.setNotifyValue(enabled, for: ch) completion(true) } @@ -583,13 +608,14 @@ final class ControllerSession: NSObject, @unchecked Sendable { func setRumble(strong: Double, weak: Double) { queue.async { [weak self] in - guard let self else { return } + guard let self, !self.ended else { return } self.rumbleTarget = (strong, weak) self.rumbleSetAt = CFAbsoluteTimeGetCurrent() } } private func startKeepAlive() { + guard !ended, keepAliveTimer == nil else { return } let timer = DispatchSource.makeTimerSource(queue: queue) timer.schedule(deadline: .now() + 0.05, repeating: 0.05) timer.setEventHandler { [weak self] in self?.maintainTick() } @@ -598,13 +624,16 @@ final class ControllerSession: NSObject, @unchecked Sendable { } private func maintainTick() { + guard !ended else { return } let now = CFAbsoluteTimeGetCurrent() var (strong, weakMag) = rumbleTarget // Failsafe: rumble intents expire after 0.5 s so a crashed consumer // can never leave the motor running. if now - rumbleSetAt > 0.5 { strong = 0; weakMag = 0 } - if strong > 0.001 || weakMag > 0.001 || rumbleActive { + // The GameCube model explicitly lacks this motor protocol. Do not + // suppress its LED keep-alive when an unsupported rumble is requested. + if model.hasHDRumble && (strong > 0.001 || weakMag > 0.001 || rumbleActive) { let active = strong > 0.001 || weakMag > 0.001 writeMotor(Switch2.Vibration.waveform(strong: strong, weak: weakMag)) rumbleActive = active @@ -617,7 +646,8 @@ final class ControllerSession: NSObject, @unchecked Sendable { } private func writeMotor(_ vib: Switch2.Vibration) { - guard let motor = chars[Switch2.GATT.vibration(for: model)] else { return } + guard !ended, model.hasHDRumble, + let motor = chars[Switch2.GATT.vibration(for: model)] else { return } let packet = Switch2.motorPacket(vib, packetID: vibrationPacketID, model: model) vibrationPacketID &+= 1 peripheral.writeValue(packet, for: motor, type: .withoutResponse) @@ -626,8 +656,18 @@ final class ControllerSession: NSObject, @unchecked Sendable { // MARK: - Input reports + /// Nominal 12-bit range only when factory/user calibration is unavailable. + /// This is degraded, uncalibrated input, not a claim of factory accuracy. + private static func uncalibratedStick(_ raw: (UInt16, UInt16)) -> (Double, Double) { + func axis(_ value: UInt16) -> Double { + let offset = Double(value) - 2048 + return max(-1, min(1, offset / (offset >= 0 ? 2047 : 2048))) + } + return (axis(raw.0), axis(raw.1)) + } + private func handleInputReport(_ data: Data) { - guard let report = Switch2.InputReport(data: data) else { return } + guard !ended, let report = Switch2.InputReport(data: data) else { return } let now = CFAbsoluteTimeGetCurrent() if lastReportAt > 0, now - lastReportAt > 0.100 { gapCount += 1 @@ -641,14 +681,14 @@ final class ControllerSession: NSObject, @unchecked Sendable { switch model { case .joyCon2Left: // One stick, reporting in the first field, calibrated by slot 1. - s.leftStick = leftCal?.apply(report.leftStickRaw) ?? (0, 0) + s.leftStick = leftCal?.apply(report.leftStickRaw) ?? Self.uncalibratedStick(report.leftStickRaw) case .joyCon2Right: // One stick, reporting in the SECOND field — but calibrated by // the unit's slot-1 data (a Joy-Con has no slot-2 calibration). - s.rightStick = leftCal?.apply(report.rightStickRaw) ?? (0, 0) + s.rightStick = leftCal?.apply(report.rightStickRaw) ?? Self.uncalibratedStick(report.rightStickRaw) default: - s.leftStick = leftCal?.apply(report.leftStickRaw) ?? (0, 0) - s.rightStick = rightCal?.apply(report.rightStickRaw) ?? (0, 0) + s.leftStick = leftCal?.apply(report.leftStickRaw) ?? Self.uncalibratedStick(report.leftStickRaw) + s.rightStick = rightCal?.apply(report.rightStickRaw) ?? Self.uncalibratedStick(report.rightStickRaw) } if model.hasAnalogTriggers { s.leftTrigger = report.leftTriggerRaw @@ -683,6 +723,7 @@ final class ControllerSession: NSObject, @unchecked Sendable { } state = s + announceReady() onState?(slot, s) // Battery / rate refresh for the UI at ~1 Hz. @@ -701,6 +742,7 @@ final class ControllerSession: NSObject, @unchecked Sendable { extension ControllerSession: CBPeripheralDelegate { func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + guard !ended else { return } if let error { fail("service discovery: \(error.localizedDescription)"); return } for service in peripheral.services ?? [] { peripheral.discoverCharacteristics(nil, for: service) @@ -710,6 +752,7 @@ extension ControllerSession: CBPeripheralDelegate { func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + guard !ended else { return } if let error { fail("characteristic discovery: \(error.localizedDescription)"); return } for ch in service.characteristics ?? [] { if let uuid = UUID(uuidString: ch.uuid.uuidString) { @@ -730,6 +773,7 @@ extension ControllerSession: CBPeripheralDelegate { func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { + guard !ended else { return } let uuid = UUID(uuidString: characteristic.uuid.uuidString) if let error { // Only the essential channels are fatal — an experimental @@ -741,11 +785,15 @@ extension ControllerSession: CBPeripheralDelegate { } return } + if uuid == Switch2.GATT.commandResponse || uuid == Switch2.GATT.inputReport { + guard characteristic.isNotifying else { fail("essential notifications stopped"); return } + } if uuid == Switch2.GATT.commandResponse { runHandshake() } else if uuid == Switch2.GATT.inputReport { - notifyCompletion?(true) + let completion = notifyCompletion notifyCompletion = nil + completion?(true) } } @@ -761,7 +809,7 @@ extension ControllerSession: CBPeripheralDelegate { func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { - guard error == nil, let data = characteristic.value else { return } + guard !ended, error == nil, let data = characteristic.value else { return } let uuid = UUID(uuidString: characteristic.uuid.uuidString) if uuid == Switch2.GATT.inputReport { handleInputReport(data) diff --git a/docs/session-retirement.md b/docs/session-retirement.md new file mode 100644 index 0000000..f996f95 --- /dev/null +++ b/docs/session-retirement.md @@ -0,0 +1,30 @@ +# Session retirement and usable readiness + +A controller session now has an explicit terminal state. Teardown marks it +ended before clearing pending notifications, callbacks, handshake steps and +timers. Late CoreBluetooth notifications, queued commands/rumble and audio +work cannot restart or emit input from that retired session. Essential +notification-state callbacks must actually report isNotifying. Completion +storage is cleared before invocation so a reentrant callback cannot erase new +work. + +Readiness now requires both the existing handshake and a report accepted by +the existing decoder. No report lengths, controller bytes, pairing sequence, +GATT identifiers or connection timeouts are changed. The normal keep-alive +starts when the handshake completes even while awaiting input. If input +arrived during the handshake, its latest state is delivered when readiness is +announced. Readiness is announced once, before delivering the first usable +state, allowing the engine to attach output without losing that state. + +Eleven production-session tests pass with fake CoreBluetooth boundaries. Five +new selected failure cases were first run against the prior PR #3 source and +failed: retired notifications, retired input/commands, false readiness, +disabled notifications, and reentrant completion. Additional tests check early +input and stopped keep-alives. The complete app is compiled separately with +Apple SDKs; these fixtures are synthetic, not controller captures. + +Run bash tests/session/run.sh. Physical pairing/reconnect and keep-alive +behavior still need acceptance on the target firmware. Engine-level slot +replacement and delayed player-number callbacks remain separate ownership +boundaries; this session-local guard is not a claim that every lifecycle race +in the application has been eliminated. diff --git a/tests/session/FrameworkFakes.swift b/tests/session/FrameworkFakes.swift new file mode 100644 index 0000000..1ffd74b --- /dev/null +++ b/tests/session/FrameworkFakes.swift @@ -0,0 +1,42 @@ +// Test-only stand-ins. The actual macOS app is separately built with Apple SDKs. +import Foundation + +struct CBCharacteristicProperties: OptionSet { + let rawValue: Int + static let notify = Self(rawValue: 1) +} +final class CBUUID { + let uuidString: String + init(_ uuid: UUID) { uuidString = uuid.uuidString } +} +final class CBCharacteristic { + let uuid: CBUUID + var properties: CBCharacteristicProperties = [.notify] + var value: Data? + var isNotifying = false + init(_ uuid: UUID) { self.uuid = CBUUID(uuid) } +} +final class CBService { var characteristics: [CBCharacteristic]? } +protocol CBPeripheralDelegate: AnyObject {} +enum CBCharacteristicWriteType { case withoutResponse } +final class CBPeripheral { + weak var delegate: CBPeripheralDelegate? + var services: [CBService]? + var canSendWriteWithoutResponse = true + var writes: [(Data, CBCharacteristic)] = [] + var identifier = UUID() + func discoverServices(_ uuids: [CBUUID]?) {} + func discoverCharacteristics(_ uuids: [CBUUID]?, for service: CBService) {} + func setNotifyValue(_ enabled: Bool, for ch: CBCharacteristic) { ch.isNotifying = enabled } + func writeValue(_ data: Data, for ch: CBCharacteristic, type: CBCharacteristicWriteType) { + writes.append((data, ch)) + } + func readRSSI() {} + func maximumWriteValueLength(for type: CBCharacteristicWriteType) -> Int { 180 } +} +final class IOBluetoothHostController { + static func `default`() -> IOBluetoothHostController? { nil } + func addressAsString() -> String? { nil } +} +enum LogLevel { case debug, info, warning, error } +func bridgeLog(_ level: LogLevel, _ category: String, _ message: String) {} diff --git a/tests/session/SessionTests.swift b/tests/session/SessionTests.swift new file mode 100644 index 0000000..a6b1805 --- /dev/null +++ b/tests/session/SessionTests.swift @@ -0,0 +1,142 @@ +import Foundation + +final class Delegate: ControllerSessionDelegate { + var ready = 0 + var failures = 0 + func sessionReady(_ session: ControllerSession) { ready += 1 } + func sessionFailed(_ session: ControllerSession, reason: String) { failures += 1 } + func sessionDidUpdateState(_ session: ControllerSession) {} +} + +final class StateCounter: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + func increment() { lock.lock(); defer { lock.unlock() }; count += 1 } + var value: Int { lock.lock(); defer { lock.unlock() }; return count } +} + +@main +enum SessionTests { + static func fixture() -> (ControllerSession, CBPeripheral, DispatchQueue, Delegate) { + let p = CBPeripheral(), q = DispatchQueue(label: "session-test"), d = Delegate() + let s = ControllerSession(peripheral: p, slot: 0, wasPairingMode: false, queue: q, delegate: d) + for uuid in [Switch2.GATT.commandWrite, Switch2.GATT.commandResponse, Switch2.GATT.inputReport, + Switch2.GATT.vibrationPro, Switch2.GATT.vibrationJoyConL, Switch2.GATT.vibrationJoyConR] { + s.chars[uuid] = CBCharacteristic(uuid) + } + return (s, p, q, d) + } + static func main() { + let selected = CommandLine.arguments.last! + func run(_ name: String, _ test: () -> Void) { + if selected == "all" || selected == name { test(); print("PASS \(name)") } + } + run("retired-notification") { + let (s, _, _, d) = fixture(); defer { _ = d } + var calls = 0 + s.notifyCompletion = { _ in calls += 1 } + s.teardown() + let input = s.chars[Switch2.GATT.inputReport]! + input.isNotifying = true + s.peripheral(s.peripheral, didUpdateNotificationStateFor: input, error: nil) + precondition(calls == 0, "A retired subscription callback resumed its handshake") + } + run("retired-input-command") { + let (s, p, q, d) = fixture(); defer { _ = d } + let states = StateCounter() + s.onState = { _, _ in states.increment() } + s.teardown() + s.handleInputReport(Data(repeating: 0, count: 63)) + s.experimentalCommand(9, 7, payload: Data()) { _ in } + q.sync {} + precondition(states.value == 0 && p.writes.isEmpty, "Retired input/commands must not reach outputs") + } + run("ready-needs-input") { + let (s, _, _, d) = fixture(); defer { s.teardown() } + s.advanceHandshake() + precondition(d.ready == 0, "Subscription/handshake without a valid input report is not ready") + s.handleInputReport(Data(repeating: 0, count: 10)) + precondition(d.ready == 0) + s.handleInputReport(Data(repeating: 0, count: 63)) + precondition(d.ready == 1) + s.handleInputReport(Data(repeating: 0, count: 63)) + precondition(d.ready == 1) + } + run("early-report-is-delivered") { + let (s, _, _, d) = fixture(); defer { s.teardown() } + s.handleInputReport(Data(repeating: 0, count: 63)) + precondition(d.ready == 0) + let states = StateCounter() + s.onState = { _, _ in states.increment() } + s.advanceHandshake() + precondition(d.ready == 1 && states.value == 1, "Input received during handshake must not be lost") + } + run("retired-keepalive") { + let (s, p, q, d) = fixture(); defer { _ = d } + q.sync { + s.advanceHandshake() + s.teardown() + s.maintainTick() + s.begin() + precondition(s.keepAliveTimer == nil && p.writes.isEmpty) + } + } + run("notification-disabled") { + let (s, _, _, d) = fixture(); defer { s.teardown() } + let input = s.chars[Switch2.GATT.inputReport]! + input.isNotifying = false + s.peripheral(s.peripheral, didUpdateNotificationStateFor: input, error: nil) + precondition(d.failures == 1, "Disabled essential notifications are not success") + } + run("notification-completion-reentrancy") { + let (s, _, _, d) = fixture(); defer { s.teardown(); _ = d } + let input = s.chars[Switch2.GATT.inputReport]! + input.isNotifying = true + s.notifyCompletion = { _ in s.notifyCompletion = { _ in } } + s.peripheral(s.peripheral, didUpdateNotificationStateFor: input, error: nil) + precondition(s.notifyCompletion != nil, "A completed callback erased replacement work") + } + run("rumble") { + for model in Switch2.Model.allCases { + let (s, p, q, d) = fixture(); defer { s.teardown(); _ = d } + s.model = model + s.setRumble(strong: 1, weak: 0) + q.sync { s.maintainTick() } + let motors = p.writes.filter { $0.1.uuid.uuidString == Switch2.GATT.vibration(for: model).uuidString } + precondition(motors.isEmpty == !model.hasHDRumble, "GameCube must not receive unsupported HD motor writes") + if !model.hasHDRumble { + precondition(p.writes.contains { $0.0.first == Switch2.Command.leds }, "Unsupported rumble must not suppress keep-alive") + let count = p.writes.count + s.writeMotor(.tone(freqHz: 200, amp: 1)) + precondition(p.writes.count == count, "Direct/experimental motor calls must obey capability") + } + } + } + run("calibration") { + let (s, _, _, d) = fixture(); defer { s.teardown(); _ = d } + var bytes = Data(repeating: 0, count: 63) + bytes[10] = 0xff; bytes[11] = 0x0f; bytes[12] = 0x00 // X=4095, Y=0 + bytes[13] = 0x00; bytes[14] = 0x08; bytes[15] = 0x80 // centered + s.handleInputReport(bytes) + precondition(s.state.leftStick.x == 1 && s.state.leftStick.y == -1, "Missing calibration must not freeze the stick") + precondition(s.state.rightStick == (0, 0)) + } + run("unrelated-response") { + let (s, _, _, d) = fixture(); defer { s.teardown(); _ = d } + var calls = 0 + s.writeCommand(0x09, 0x07, Data()) { _ in calls += 1 } + s.handleCommandResponse(Data([2, 1, 0, 0, 0, 0, 0, 0])) + precondition(s.pendingCommand != nil && calls == 0, "Unrelated reply consumed the active command") + s.handleCommandResponse(Data([9, 1, 0, 0, 0, 0, 0, 0])) + precondition(calls == 1 && s.pendingCommand == nil) + } + run("memory-address") { + let (s, _, _, d) = fixture(); defer { s.teardown(); _ = d } + var succeeded = false + s.readMemory(length: 1, address: 0x13000) { succeeded = $0 != nil } + let frame = Data([2, 1, 0, 0, 0, 0, 0, 0, 1, 0x7e, 0, 0, 0x42, 0x30, 1, 0, 0xaa]) + s.handleCommandResponse(frame) + precondition(!succeeded, "A different memory address must not supply calibration/identity data") + } + } +} diff --git a/tests/session/run.sh b/tests/session/run.sh new file mode 100755 index 0000000..c449f65 --- /dev/null +++ b/tests/session/run.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")/../.." +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +# Change visibility/imports only, not method bodies. Fake CoreBluetooth objects +# allow the production session callbacks to run without a radio or permission. +python3 - "$work/ControllerSession.swift" <<'PY' +from pathlib import Path +import os, re, sys +source = Path(os.environ.get('SESSION_SOURCE', 'Sources/FinallyTheControllerWorks/Bluetooth/ControllerSession.swift')).read_text() +source = re.sub(r'^import (CoreBluetooth|IOBluetooth)$', '', source, flags=re.M) +source = re.sub(r'\b(?:fileprivate|private)(?:\(set\))?\s+', '', source) +Path(sys.argv[1]).write_text('import CoreFoundation\n' + source) +PY +swiftc -swift-version 5 \ + Sources/FinallyTheControllerWorks/Protocol/Switch2Protocol.swift \ + "$work/ControllerSession.swift" tests/session/FrameworkFakes.swift \ + tests/session/SessionTests.swift -o "$work/session-tests" +"$work/session-tests" "${SESSION_CASE:-all}"