diff --git a/.github/workflows/macos-validation.yml b/.github/workflows/macos-validation.yml new file mode 100644 index 0000000..0c66d87 --- /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 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 (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 + with: + name: tested-source + path: ${{ runner.temp }}/source.zip + retention-days: 7 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/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/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/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/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/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/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 <> 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/browser/BrowserServer.swift b/tests/browser/BrowserServer.swift new file mode 100644 index 0000000..7f26944 --- /dev/null +++ b/tests/browser/BrowserServer.swift @@ -0,0 +1,34 @@ +import Foundation + +enum LogLevel { case info, warning, error, debug } +func bridgeLog(_ level: LogLevel, _ category: String, _ message: String) { + if message.contains("opt-in browser bridge") { + FileHandle.standardOutput.write(Data("READY\n".utf8)) + } +} +protocol ControllerOutputSink: AnyObject { + var onRumble: ((Int, Double, Double) -> Void)? { get set } + func controllerConnected(slot: Int, model: Switch2.Model) + func controllerDisconnected(slot: Int) + func controllerName(slot: Int, name: String) + func controllerState(slot: Int, state: ControllerState) +} +@main +enum BrowserServer { + static func main() { + precondition(WebSocketHub.origins(from: "bad https://example.com *").isEmpty) + let ids = String(repeating: "a", count: 32) + let origins = WebSocketHub.origins(from: ids) + precondition(origins == ["chrome-extension://" + ids]) + let server = WebSocketHub(enabled: true, allowedOrigins: origins) + server.onRumble = { slot, strong, weak in + FileHandle.standardOutput.write(Data("RUMBLE \(slot) \(strong) \(weak)\n".utf8)) + } + server.controllerConnected(slot: 0, model: .proController2) + server.controllerName(slot: 0, name: "test pad") + var state = ControllerState(); state.buttons = [.a]; state.leftStick = (0.5, 0) + server.controllerState(slot: 0, state: state) + while let command = readLine(), command != "quit" {} + withExtendedLifetime(server) {} + } +} diff --git a/tests/browser/background.test.cjs b/tests/browser/background.test.cjs new file mode 100644 index 0000000..b67c1de --- /dev/null +++ b/tests/browser/background.test.cjs @@ -0,0 +1,74 @@ +const {test} = require('node:test'); +const assert = require('node:assert/strict'); +const vm = require('node:vm'); +const fs = require('node:fs'); +const path = require('node:path'); + +function fixture() { + const sockets = [], timers = new Map(); let next = 0, attach; + class Socket { + static OPEN = 1; + constructor() { this.readyState = 0; this.sent = []; sockets.push(this); } + open() { this.readyState = 1; this.onopen?.(); } + message(obj) { this.onmessage?.({data: JSON.stringify(obj)}); } + close() { this.readyState = 3; } + closed() { this.readyState = 3; this.onclose?.(); } + send(text) { this.sent.push(JSON.parse(text)); } + } + const context = {WebSocket: Socket, console, + setTimeout(fn) {timers.set(++next, fn); return next;}, + clearTimeout(id) {timers.delete(id);}, + chrome: {runtime: {onConnect: {addListener(fn) {attach = fn;}}}}, + }; + vm.runInNewContext(fs.readFileSync(process.env.BACKGROUND_SOURCE || path.join(__dirname, '../../browser/extension/background.js'), 'utf8'), context); + function port() { + const p = {name: 'ftcw', received: [], postMessage(text) {this.received.push(JSON.parse(text));}, + onMessage: {addListener(fn) {p.send = fn;}}, + onDisconnect: {addListener(fn) {p.close = fn;}}, + }; + attach(p); return p; + } + return {port, sockets, timers}; +} + +test('rename replay retains a connection record and latest state for a new tab', () => { + const f = fixture(), a = f.port(), ws = f.sockets[0]; ws.open(); + ws.message({t:'connected', slot:0, model:'NSO GameCube Controller', name:'original'}); + ws.message({t:'name', slot:0, name:'my pad'}); + ws.message({t:'state', slot:0, seq:9, b:8, lx:0.5, ly:0, rx:0, ry:0, lt:0, rt:0}); + const b = f.port(); + assert.deepEqual(b.received.find(e=>e.t==='connected'), {t:'connected',slot:0,model:'NSO GameCube Controller',name:'my pad'}); + assert.equal(b.received.find(e=>e.t==='state')?.b, 8); + a.close(); b.close(); +}); + +test('obsolete socket callbacks cannot clear or feed its replacement', () => { + const f = fixture(), a = f.port(), old = f.sockets[0]; old.open(); a.close(); + const b = f.port(), current = f.sockets[1]; current.open(); + const before = b.received.length; + old.message({t:'connected', slot:3, model:'obsolete', name:'obsolete'}); + old.closed(); + assert.equal(b.received.length, before); + b.send(JSON.stringify({t:'rumble',slot:0,strong:1,weak:0})); + assert.equal(current.sent.length, 1); + assert.equal(f.timers.size, 0); + b.close(); +}); + +test('last tab closure cancels retry and connection replay', () => { + const f = fixture(), a = f.port(), ws = f.sockets[0]; ws.open(); + ws.message({t:'connected',slot:0,model:'pad',name:'pad'}); + ws.closed(); assert.equal(f.timers.size, 1); + a.close(); assert.equal(f.timers.size, 0); + const b = f.port(); f.sockets[1].open(); + assert.equal(b.received.filter(e=>e.t==='connected').length, 0); + b.close(); +}); + +test('only bounded known command messages cross from a page to the local hub', () => { + const f = fixture(), p = f.port(), ws = f.sockets[0]; ws.open(); + for (const text of ['invalid', JSON.stringify({t:'unknown'}), JSON.stringify({t:'rumble',slot:9}), 'x'.repeat(65537)]) p.send(text); + assert.equal(ws.sent.length, 0); + p.send(JSON.stringify({t:'rumble',slot:1,strong:0.3,weak:0})); + assert.equal(ws.sent.length, 1); p.close(); +}); diff --git a/tests/browser/run.sh b/tests/browser/run.sh new file mode 100755 index 0000000..c6dde50 --- /dev/null +++ b/tests/browser/run.sh @@ -0,0 +1,19 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")/../.." +node --test tests/browser/*.test.cjs +[ "$(uname -s)" = Darwin ] || exit 0 +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +python3 - "$work" <<'PY' +from pathlib import Path +import sys +out=Path(sys.argv[1]);base=Path('Sources/FinallyTheControllerWorks') +s=(base/'Bluetooth/ControllerSession.swift').read_text() +a=s.index('struct ControllerState:');b=s.index('/// Called on the Bluetooth queue.',a) +out.joinpath('State.swift').write_text('import Foundation\n'+s[a:b]) +PY +swiftc -swift-version 5 Sources/FinallyTheControllerWorks/Protocol/Switch2Protocol.swift \ + "$work/State.swift" Sources/FinallyTheControllerWorks/Output/WebSocketHub.swift \ + tests/browser/BrowserServer.swift -o "$work/browser-server" +python3 tests/browser/websocket_test.py "$work/browser-server" diff --git a/tests/browser/shim.test.cjs b/tests/browser/shim.test.cjs new file mode 100644 index 0000000..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}" diff --git a/tests/run.sh b/tests/run.sh new file mode 100644 index 0000000..0e68652 --- /dev/null +++ b/tests/run.sh @@ -0,0 +1,14 @@ +#!/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" +for suite in tests/*/run.sh; do + [ -f "$suite" ] || continue + bash "$suite" +done 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}" 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}"