diff --git a/.github/workflows/macos-validation.yml b/.github/workflows/macos-validation.yml index 029c7ac..0c66d87 100644 --- a/.github/workflows/macos-validation.yml +++ b/.github/workflows/macos-validation.yml @@ -23,13 +23,13 @@ jobs: swift --version xcodebuild -version git archive HEAD -o "$RUNNER_TEMP/source.zip" - - name: Run controller protocol regressions (no radio required) + - name: Run controller protocol and output regressions (no radio required) run: bash tests/run.sh - name: Build the actual app bundle (ad-hoc signing only) run: | bash scripts/build-app.sh - codesign --verify --strict "build/Finally the Controller Works.app" - plutil -lint "build/Finally the Controller Works.app/Contents/Info.plist" + codesign --verify --strict "build/Finally the Controller Works (jmonster).app" + plutil -lint "build/Finally the Controller Works (jmonster).app/Contents/Info.plist" - name: Preserve exact tested source if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 diff --git a/README.md b/README.md index b3b5762..b202d74 100644 --- a/README.md +++ b/README.md @@ -1,171 +1,76 @@ -# Finally the Controller Works +# switch2mac — jmonster development fork -Use Nintendo Switch 2 controllers on your Mac — Pro Controller 2, -Joy-Con 2 (solo or as a linked pair), and the NSO GameCube pad — over -Bluetooth, up to four at once. A native menu-bar app: launch it, press a -button on your controller, play. The first of its kind. +A macOS menu-bar bridge for Switch 2 Pro Controller, Joy-Con 2 and the NSO +GameCube controller, based on Peter Sharma's +[Finally the Controller Works](https://github.com/Peterksharma/switch2mac). +This fork concentrates on input delivery, controller ownership and tested +output integrations. It is not a Nintendo product or an official upstream release. -**Status: beta.** The release build is Developer ID-signed and works -today. One honest caveat, explained below: until Apple approves the -app's driver entitlement, games can't see the controllers *directly* — -you use the provided SDL bridge for that (Gopher64 works now). +## Build this fork -## The plan, and the Apple wait - -The goal is for every controller to appear to macOS as a normal game -controller that any app can use (CoreHID virtual gamepads, macOS 15+). -That requires the `com.apple.developer.hid.virtual.device` entitlement, -which is **currently waiting on Apple's approval**. Until it arrives: - -- Everything in the dashboard works: connection, battery, sensors, - calibration, rumble, LEDs, button remapping, Joy-Con mouse mode. -- **To use controllers in a game or emulator**, the app publishes - controller state over local UDP (`udp://127.0.0.1:24800-24803`, one - port per player), and a patched build of SDL with an `SDL_S2UDP` - joystick backend picks it up. Any SDL-based program launched with - that library sees real game controllers — including rumble flowing - back to the controller. - -- **To use controllers in a web game** — Xbox Cloud Gaming, GeForce NOW, - Luna — the app also serves controller state on - `ws://127.0.0.1:24810`, and a small browser extension in - [`browser/`](browser/) presents them to the page as standard - gamepads, rumble included. Chromium browsers only. - -Once Apple's approval lands, the SDL and browser steps become optional -and controllers will just show up system-wide. - -## Install - -1. Download the latest release from the - [Releases page](https://github.com/Peterksharma/switch2mac/releases), - unzip, and drag **Finally the Controller Works.app** to Applications. -2. Launch it — it lives in the menu bar (game-controller icon). -3. Pair: hold the **Sync** button on the controller (next to the USB-C - port) until the player LEDs sweep. After that first pairing, just - press any button to reconnect. -4. Grant Bluetooth permission when macOS asks. That's the only required - permission; Notifications and Accessibility are optional extras. - -The app auto-updates from this repository's releases (every update is -signature-verified before install). - -## Using it with Gopher64 (N64 emulator) - -Gopher64 is SDL-based, so it works through the bridge today: - -1. Get the patched SDL library from this repo: [`sdl/`](sdl/). -2. Launch Gopher64 with the patched library (see `sdl/README.md` for - the exact launch command). -3. Start the menu-bar app, connect your controller, and it appears in - Gopher64 as a standard game controller — sticks, buttons, and rumble. - -The same recipe works for any SDL3-based emulator or game — see -[`sdl/README.md`](sdl/README.md) for the general one-line launch method. - -## Using it with Xbox Cloud Gaming (or any web game) - -Verified on xbox.com/play. Chromium browsers only (Chrome, Edge, Brave, -Arc, Vivaldi, Opera); Safari and Firefox cannot run this bridge. - -1. Get the extension folder: clone this repo, or download the ZIP from - GitHub (**Code → Download ZIP**) and unpack it. You need the - [`browser/extension`](browser/extension) folder. -2. In the browser open `chrome://extensions` (`edge://extensions`, - `brave://extensions`, …), turn on **Developer mode** (top right), - click **Load unpacked** and pick that `browser/extension` folder. -3. Start the menu-bar app and press a button on the controller so it - connects. -4. Open and play: the controller is a - standard gamepad, with rumble. Xbox prompts match the physical - positions (Switch B is where Xbox A is). - -Button table, adding other game sites, troubleshooting and the wire -protocol are in [`browser/README.md`](browser/README.md). - -## Features - -**Working now, in the beta UI** - -- Bluetooth connection for up to 4 controllers (Pro Controller 2, - Joy-Con 2 L/R and linked pairs, NSO GameCube pad), auto-reconnecting - on any button press once paired -- The 1 Hz keep-alive write that stops macOS silently dropping the link - ~15 s in (empirically discovered; Linux/Windows don't need it) -- Live dashboard: input test, battery percentage with charge state, - hidden-sensor readouts (temperature, voltage trend, runtime estimate) -- Motion instruments: attitude bubble, gyro bars, tilt-compensated - compass -- Stick calibration (factory + user recenter), per-stick deadzones, - axis inversion, trigger thresholds -- Rumble with per-controller intensity, player-LED patterns -- **Find My Controller** — LED chase + rumble pulse + Bluetooth - proximity meter -- Button remapping per controller -- Joy-Con 2 **mouse mode** (the optical sensor, used flat on the desk) -- UDP/SDL bridge for games and emulators, with game rumble passthrough -- WebSocket/browser bridge for web games (Xbox Cloud Gaming, GeForce NOW), - with rumble passthrough -- Signed auto-updates, first-run tour, settings import/export, live - log with BLE gap diagnostics, launch-at-login - -**Built, but hidden until they're polished (or until Apple approval)** - -- Virtual system-wide game controllers (CoreHID) — blocked on the - entitlement above -- Keyboard mapping (controller buttons → keystrokes, per-app profiles) -- Air-gesture macros, Reaction Draft party game, Sensor Challenges -- Protocol experiments: NFC/amiibo reading, controller-audio research - -## Research - -The protocol knowledge behind this app — including original -reverse-engineering of the Switch 2 controller BLE protocol and the -ongoing controller-audio investigation — is published in -[`research/`](research/). Start with -[research/README.md](research/README.md). - -## Building from source +Use a macOS development environment with Swift 6 and Apple SDKs that provide +CoreHID. The hosted build check uses macOS 26; the package currently declares +macOS 15, but an actual macOS 15 runtime has not been qualified here. ```sh -./scripts/build-app.sh # ad-hoc: everything except virtual HID -SIGN_IDENTITY="Developer ID Application: …" \ -PROVISIONING_PROFILE=path/to.provisionprofile \ - ./scripts/build-app.sh # full build incl. virtual gamepads -``` - -Output: `build/Finally the Controller Works.app`. Swift 6 toolchain, -macOS 15+ target, no external dependencies. - -## Architecture - +git clone https://github.com/jmonster/switch2mac.git +cd switch2mac +bash tests/run.sh +bash scripts/build-app.sh ``` -Controller ──BLE──> BridgeEngine ──> ControllerSession (per slot) - │ handshake, keep-alive, decode, rumble - ▼ - ControllerOutputSink protocol - ├── VirtualHIDSink (CoreHID; entitlement-gated) - ├── UDPHub (SDL-compat, ports 24800-24803) - └── WebSocketHub (browser extension, ws://127.0.0.1:24810) -``` - -- `Protocol/Switch2Protocol.swift` — the wire protocol, transport-free. -- `Bluetooth/` — CoreBluetooth engine + per-controller session state machine. -- `Output/` — the two sinks. -- `UI/` — SwiftUI dashboard (status cards + live log) and menu bar. - -## Support - -If this saved your controller from a drawer: -[Buy me a coffee ☕](https://buymeacoffee.com/peterksharma) - -Issues and captures (especially audio-related — see the research docs) -are very welcome. - -## Credits -Protocol research: ndeadly/switch2_controller_research, -trevlars/switch2-controllers-linux (MIT), Nadeflore/switch2-controllers, -and the wider Switch 2 RE community. -macOS keep-alive discovery, CoreBluetooth port, and the research in -[`research/`](research/): this project. +The output is `build/Finally the Controller Works (jmonster).app`. Source on +an unmerged PR branch must be checked out before building that PR's changes. +The default build is ad-hoc signed for development, not a notarized release. +Upstream's downloadable application and automatic-update feed do not contain +this fork's changes. No physical-controller or game acceptance is implied by +a successful build or test run. + +This fork uses a separate bundle identifier, `io.github.jmonster.switch2mac`, +and disables automatic updates, including saved feed overrides. Establish +Bluetooth/privacy approvals, preferences and login-item registration for this +app separately. Do not run two bridges against the same controller at once. +See [fork identity and signing policy](docs/fork-identity.md). + +## Choose an output for the intended game + +- **SDL3 games:** the [SDL bridge](sdl/README.md) uses a custom library, not a + system-wide driver. The tracked upstream dylib is a historical binary; a + change to a source patch does not update it. Use the corrected library built + from the reviewed patch set, and check its source revision. The Gopher64 + helper creates a separate ad-hoc-signed copy, not a modification of the original. +- **RetroArch:** [network gamepad output](docs/retroarch-integration.md) is + disabled by default. It does not require replacing SDL, but its legacy UDP + protocol has no rumble return path and maps GameCube trigger travel to + digital L2/R2. Enable the unauthenticated receiver only on a trusted network. +- **Chromium web games:** the [browser bridge](browser/README.md) is disabled + by default. Load the supplied extension, allow its exact ID in Browser Bridge + Settings, then relaunch the app. No Safari/Firefox package is provided. + +CoreHID virtual-controller output requires Apple's restricted entitlement; +this fork has not established approval or universal game compatibility. +The browser path deliberately does not forward GameCube HD-motor commands; +verified GameCube preset rumble remains an outstanding hardware/protocol task. +Check analog trigger travel and digital clicks separately in the actual game. + +## Validation and contributions + +`bash tests/run.sh` runs the checked-in protocol and available output suites. +Tests use synthetic controller reports, simulated radio boundaries and, where +applicable, real localhost sockets. SDL regressions separately build the pinned +SDL source and exercise the actual joystick driver. Hosted macOS checks build +the complete app with Apple frameworks and verify the ad-hoc bundle. + +Keep fixes scoped and attach a reproducer. Report the exact commit, macOS and +controller firmware, transport, output backend and observed behavior. Hardware +pairing/reconnection, sleep/wake, latency, multiplayer and real-game testing +are separate from automated regression coverage. + +Original application and protocol research: **Peter Sharma** and the community +contributors credited in [research/PROTOCOL.md](research/PROTOCOL.md). +Optional browser output is adapted from **Andrei-Kondrykau**, and RetroArch +output from **vialoh**, with source revisions in their integration notes. +[Support the original author](https://buymeacoffee.com/peterksharma). +The SDL modifications retain their [separate license/provenance](sdl/README.md). +Application-wide licensing still needs clarification with upstream; this fork +does not invent a new license or relicense contributed work. diff --git a/Resources/Info.plist b/Resources/Info.plist index 5374b2a..3ea1e7d 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -3,11 +3,11 @@ CFBundleName - Finally the Controller Works + Finally the Controller Works (jmonster) CFBundleDisplayName - Finally the Controller Works + Finally the Controller Works (jmonster) CFBundleIdentifier - com.petersharma.finallythecontrollerworks + io.github.jmonster.switch2mac CFBundleExecutable FinallyTheControllerWorks CFBundleVersion diff --git a/Sources/FinallyTheControllerWorks/FTCWApp.swift b/Sources/FinallyTheControllerWorks/FTCWApp.swift index 757a822..22b28be 100644 --- a/Sources/FinallyTheControllerWorks/FTCWApp.swift +++ b/Sources/FinallyTheControllerWorks/FTCWApp.swift @@ -87,6 +87,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { bridgeLog(.info, "app", "Finally the Controller Works — starting bridge") engine.addSink(UDPHub()) engine.addSink(WebSocketHub()) + engine.addSink(NetworkGamepadSink()) engine.addSink(VirtualHIDSink()) notifications.attach(to: engine) // Daily auto-update check (only if a feed URL is configured); results diff --git a/Sources/FinallyTheControllerWorks/Output/NetworkGamepadSink.swift b/Sources/FinallyTheControllerWorks/Output/NetworkGamepadSink.swift new file mode 100644 index 0000000..3e54e2c --- /dev/null +++ b/Sources/FinallyTheControllerWorks/Output/NetworkGamepadSink.swift @@ -0,0 +1,218 @@ +// Optional RetroArch network-gamepad output, adapted from vialoh/switch2mac +// 2c7a396336f5a657a16a772b8c056d96ec6ff7f1. Disabled by default. +// Native little-endian remote_message: i32 port/device/index/id, u16 state, +// two padding bytes. One paced datagram per player per tick; no rumble ACKs. +import Foundation +import Darwin + +final class NetworkGamepadSink: ControllerOutputSink, @unchecked Sendable { + private static let sendInterval: TimeInterval = 1.0 / 60.0 + private static let refreshInterval: TimeInterval = 2.0 + private static let analogQuantum: Int32 = 512 + private static let maxEdges = 256 + private static let buttonMap: [(Switch2.Buttons, Int)] = [ + (.b, 0), (.y, 1), (.minus, 2), (.plus, 3), + (.dpadUp, 4), (.dpadDown, 5), (.dpadLeft, 6), (.dpadRight, 7), + (.a, 8), (.x, 9), (.l, 10), (.r, 11), (.zl, 12), (.zr, 13), + (.lStick, 14), (.rStick, 15), + ] + var onRumble: ((Int, Double, Double) -> Void)? + + private final class Player { + var wantButtons: UInt16 = 0 + var sentButtons: UInt16 = 0 + var wantAxes = [Int16](repeating: 0, count: 4) + var sentAxes = [Int16](repeating: 0, count: 4) + var edges: [(id: Int, state: UInt16)] = [] + var connected = false + var failed = false + var port: UInt16? + var switchIndex: Int? + var nextSendAt: TimeInterval = 0 + var lastRefreshAt: TimeInterval = 0 + var refreshIndex = 20 + var neutralPasses = 0 + + func neutralize() { + wantButtons = 0; wantAxes = [0, 0, 0, 0] + edges = (0..<16).filter { sentButtons & (1 << $0) != 0 }.map { ($0, 0) } + switchIndex = nil + refreshIndex = 0 + neutralPasses = 2 // best-effort repeat; UDP acceptance is not receipt + } + var pending: Bool { !edges.isEmpty || wantAxes != sentAxes || refreshIndex < 20 } + } + + private let queue = DispatchQueue(label: "com.petersharma.ftcw.netpad") + private let players = (0..= 0 { close(fd) } } + private func openSocket() { + fd = socket(AF_INET, SOCK_DGRAM, 0) + guard fd >= 0 else { + bridgeLog(.error, "netpad", "socket() failed: \(String(cString: strerror(errno)))"); return + } + _ = fcntl(fd, F_SETFL, O_NONBLOCK) + } + + func controllerConnected(slot: Int, model: Switch2.Model) { + queue.async { [weak self] in + guard let self, self.players.indices.contains(slot) else { return } + self.players[slot].connected = true + } + } + func controllerName(slot: Int, name: String) {} + func controllerDisconnected(slot: Int) { + queue.async { [weak self] in + guard let self, self.players.indices.contains(slot) else { return } + let p = self.players[slot] + p.connected = false; p.neutralize() + self.ensurePumping() + } + } + + func controllerState(slot: Int, state: ControllerState) { + guard AppConfig.networkGamepadEnabled else { return } + queue.async { [weak self] in + guard let self, self.players.indices.contains(slot), AppConfig.networkGamepadEnabled else { return } + let p = self.players[slot] + guard !p.failed else { return } + p.connected = true + var buttons: UInt16 = 0 + for (button, id) in Self.buttonMap where state.buttons.contains(button) { buttons |= 1 << id } + if state.leftTrigger >= 128 { buttons |= 1 << 12 } + if state.rightTrigger >= 128 { buttons |= 1 << 13 } + let changed = p.wantButtons ^ buttons + let edges = (0..<16).filter { changed & (1 << $0) != 0 }.map { ($0, (buttons >> $0) & 1) } + if p.switchIndex == nil { + guard p.edges.count + edges.count <= Self.maxEdges else { + p.failed = true; p.neutralize(); self.ensurePumping() + bridgeLog(.error, "netpad", "player \(slot + 1) edge queue exhausted; neutralizing. Disable/re-enable network output to retry.") + return + } + p.edges.append(contentsOf: edges) + } + p.wantButtons = buttons + p.wantAxes = [Self.axis(state.leftStick.x), Self.axis(-state.leftStick.y), + Self.axis(state.rightStick.x), Self.axis(-state.rightStick.y)] + self.ensurePumping() + } + } + + private static func axis(_ value: Double) -> Int16 { + guard value.isFinite else { return 0 } + let raw = Int32(max(-1, min(1, value)) * 32767) + return Int16(raw / analogQuantum * analogQuantum) + } + private func ensurePumping() { + guard timer == nil, fd >= 0 else { return } + let t = DispatchSource.makeTimerSource(queue: queue) + t.schedule(deadline: .now(), repeating: Self.sendInterval, leeway: .milliseconds(2)) + t.setEventHandler { [weak self] in self?.pump() } + t.resume(); timer = t + } + + private func pump() { + let basePort = AppConfig.networkGamepadBasePort + let enabled = AppConfig.networkGamepadEnabled && (1...65532).contains(basePort) + let now = ProcessInfo.processInfo.systemUptime + if !enabled && wasEnabled { + for p in players { p.neutralize(); p.failed = false } + } + wasEnabled = enabled + var busy = false + for (slot, p) in players.enumerated() { + let target: UInt16? = enabled ? UInt16(basePort + slot) : nil + if p.port == nil { p.port = target } + guard let port = p.port else { continue } + let refreshing = (enabled && p.connected) || p.neutralPasses > 0 + if refreshing && now - p.lastRefreshAt >= Self.refreshInterval && p.refreshIndex >= 20 { + p.lastRefreshAt = now; p.refreshIndex = 0 + } + busy = busy || p.pending || refreshing || (target != nil && target != port) + guard now >= p.nextSendAt else { continue } + + // Reverting a port edit can leave a partially neutralized receiver. + // Reassert every button, then resume ordered edges for later reports. + if p.switchIndex != nil && target == port { + p.switchIndex = nil + p.edges = (0..<16).map { ($0, (p.wantButtons >> $0) & 1) } + p.refreshIndex = 0 + } + + // Configuration changes retire the old destination before any new + // state is sent. Reports during this reset establish the new state. + if let target, target != port { + if p.switchIndex == nil { p.switchIndex = 0; p.edges.removeAll() } + let index = p.switchIndex! + guard send(Self.refreshMessage(slot, index, buttons: 0, axes: [0, 0, 0, 0]), port: port, now: now) else { continue } + p.switchIndex = index + 1 + if p.switchIndex == 20 { + p.port = target; p.switchIndex = nil + p.sentButtons = 0; p.sentAxes = [0, 0, 0, 0] + p.edges = (0..<16).filter { p.wantButtons & (1 << $0) != 0 }.map { ($0, 1) } + p.refreshIndex = 0 + } + p.nextSendAt = now + Self.sendInterval + continue + } + if let edge = p.edges.first { + guard send(Self.message(slot: slot, device: 1, index: 0, id: Int32(edge.id), state: edge.state), port: port, now: now) else { continue } + p.edges.removeFirst() + let mask = UInt16(1) << edge.id + if edge.state == 0 { p.sentButtons &= ~mask } else { p.sentButtons |= mask } + } else if let axis = (0..<4).first(where: { p.wantAxes[$0] != p.sentAxes[$0] }) { + guard send(Self.message(slot: slot, device: 5, index: Int32(axis / 2), id: Int32(axis % 2), state: UInt16(bitPattern: p.wantAxes[axis])), port: port, now: now) else { continue } + p.sentAxes[axis] = p.wantAxes[axis] + } else if p.refreshIndex < 20 { + guard send(Self.refreshMessage(slot, p.refreshIndex, buttons: p.wantButtons, axes: p.wantAxes), port: port, now: now) else { continue } + p.refreshIndex += 1 + if p.refreshIndex == 20 && p.neutralPasses > 0 { p.neutralPasses -= 1 } + } else { continue } + p.nextSendAt = now + Self.sendInterval + } + if !busy { timer?.cancel(); timer = nil } + } + + private static func refreshMessage(_ slot: Int, _ index: Int, buttons: UInt16, axes: [Int16]) -> [UInt8] { + if index < 16 { + return message(slot: slot, device: 1, index: 0, id: Int32(index), state: (buttons >> index) & 1) + } + let axis = index - 16 + return message(slot: slot, device: 5, index: Int32(axis / 2), id: Int32(axis % 2), state: UInt16(bitPattern: axes[axis])) + } + private static func message(slot: Int, device: Int32, index: Int32, id: Int32, state: UInt16) -> [UInt8] { + var d = [UInt8](); d.reserveCapacity(20) + for v in [Int32(slot), device, index, id] { + withUnsafeBytes(of: v.littleEndian) { d.append(contentsOf: $0) } + } + withUnsafeBytes(of: state.littleEndian) { d.append(contentsOf: $0) } + d.append(contentsOf: [0, 0]); return d + } + // sendto acceptance is NOT acknowledgement by RetroArch. Full refreshes + // include released/zero values so a lost release can converge later. + private func send(_ bytes: [UInt8], port: UInt16, now: TimeInterval) -> Bool { + var dest = sockaddr_in() + dest.sin_family = sa_family_t(AF_INET) + dest.sin_port = port.bigEndian + dest.sin_addr.s_addr = UInt32(0x7F000001).bigEndian + let sent = bytes.withUnsafeBytes { buf in + withUnsafePointer(to: &dest) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + sendto(fd, buf.baseAddress, buf.count, 0, $0, socklen_t(MemoryLayout.size)) + } + } + } + if sent == bytes.count { return true } + if now - lastSendErrorAt >= 1 { + lastSendErrorAt = now + bridgeLog(.warning, "netpad", "sendto 127.0.0.1:\(port) failed, retrying: \(String(cString: strerror(errno)))") + } + return false + } +} diff --git a/Sources/FinallyTheControllerWorks/UI/AboutAndOnboarding.swift b/Sources/FinallyTheControllerWorks/UI/AboutAndOnboarding.swift index 2bd15bc..32dceb5 100644 --- a/Sources/FinallyTheControllerWorks/UI/AboutAndOnboarding.swift +++ b/Sources/FinallyTheControllerWorks/UI/AboutAndOnboarding.swift @@ -13,17 +13,16 @@ enum AppInfo { Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "0" } - /// Built-in update feed: every GitHub release of this repo uploads - /// appcast.json as an asset, and releases/latest always points at the - /// newest one. The Configuration field remains an override for testing. - static let defaultUpdateFeedURL = - "https://github.com/Peterksharma/switch2mac/releases/latest/download/appcast.json" + /// This fork has no approved update signing identity/feed. Never consume + /// the upstream feed or a persisted override until that trust path exists. + static let updatesEnabled = false + static let defaultUpdateFeedURL = "" /// Pre-release features hidden from the beta UI: the party-game and /// gesture menu items, keyboard mapping, and the Experiments cluster. /// Deliberately a runtime flag rather than a build flag so a beta build /// can be un-hidden for development without recompiling: - /// defaults write com.petersharma.finallythecontrollerworks showPreReleaseFeatures -bool YES + /// defaults write io.github.jmonster.switch2mac showPreReleaseFeatures -bool YES /// (then relaunch; delete the key to hide again). static var showPreReleaseFeatures: Bool { UserDefaults.standard.bool(forKey: "showPreReleaseFeatures") diff --git a/Sources/FinallyTheControllerWorks/UI/AppNotifications.swift b/Sources/FinallyTheControllerWorks/UI/AppNotifications.swift index 4af3030..1357731 100644 --- a/Sources/FinallyTheControllerWorks/UI/AppNotifications.swift +++ b/Sources/FinallyTheControllerWorks/UI/AppNotifications.swift @@ -14,6 +14,8 @@ enum AppConfig { static let notifyConnectionsKey = "notifyConnections" // Bool, default true static let lowBatteryThresholdKey = "lowBatteryThreshold" // Double 0-1, default 0.15 static let idleSleepMinutesKey = "idleSleepMinutes" // Double, 0 = never, default 15 + static let networkGamepadEnabledKey = "networkGamepadEnabled" // Bool, default false + static let networkGamepadBasePortKey = "networkGamepadBasePort" // Int, default 55400 static var notifyEnabled: Bool { UserDefaults.standard.object(forKey: notifyEnabledKey) as? Bool ?? true @@ -24,6 +26,15 @@ enum AppConfig { static var lowBatteryThreshold: Double { UserDefaults.standard.object(forKey: lowBatteryThresholdKey) as? Double ?? 0.15 } + static var networkGamepadEnabled: Bool { + UserDefaults.standard.object(forKey: networkGamepadEnabledKey) as? Bool ?? false + } + /// Network gamepad base UDP port; player N uses base + N - 1 + /// (RetroArch's `network_remote_base_port`). + static var networkGamepadBasePort: Int { + let port = UserDefaults.standard.object(forKey: networkGamepadBasePortKey) as? Int ?? 55400 + return min(max(port, 1024), 65535 - BridgeEngine.maxPlayers) + } static var idleSleepMinutes: Double { UserDefaults.standard.object(forKey: idleSleepMinutesKey) as? Double ?? 15 } diff --git a/Sources/FinallyTheControllerWorks/UI/DashboardView.swift b/Sources/FinallyTheControllerWorks/UI/DashboardView.swift index 03cf18e..f2a73cb 100644 --- a/Sources/FinallyTheControllerWorks/UI/DashboardView.swift +++ b/Sources/FinallyTheControllerWorks/UI/DashboardView.swift @@ -738,6 +738,8 @@ struct ConfigurationSection: View { @AppStorage(AppConfig.notifyConnectionsKey) private var notifyConnections = true @AppStorage(AppConfig.lowBatteryThresholdKey) private var lowBattery = 0.15 @AppStorage(AppConfig.idleSleepMinutesKey) private var idleMinutes = 15.0 + @AppStorage(AppConfig.networkGamepadEnabledKey) private var networkGamepadEnabled = false + @AppStorage(AppConfig.networkGamepadBasePortKey) private var networkGamepadBasePort = 55400 var body: some View { VStack(alignment: .leading, spacing: 10) { @@ -766,6 +768,23 @@ struct ConfigurationSection: View { .font(.caption) .foregroundStyle(.secondary) Divider() + Toggle("Network gamepad output (RetroArch)", isOn: $networkGamepadEnabled) + HStack(spacing: 12) { + Text("Base port") + TextField("55400", value: $networkGamepadBasePort, format: .number.grouping(.never)) + .textFieldStyle(.roundedBorder) + .frame(width: 72) + .disabled(!networkGamepadEnabled) + Text("players 1–4 on \(String(networkGamepadBasePort))–\(String(networkGamepadBasePort + 3))") + .foregroundStyle(.secondary) + } + .padding(.leading, 18) + Text("Sends each player's state over local UDP to programs that accept a " + + "network gamepad — no SDL library needed; no rumble. " + + "RetroArch: Settings → Network → Network Gamepad, same base port.") + .font(.caption) + .foregroundStyle(.secondary) + Divider() HStack(spacing: 10) { Text("Settings backup") Button("Export…") { exportSettings() } diff --git a/Sources/FinallyTheControllerWorks/UI/Updater.swift b/Sources/FinallyTheControllerWorks/UI/Updater.swift index 0fa9e45..5ad4963 100644 --- a/Sources/FinallyTheControllerWorks/UI/Updater.swift +++ b/Sources/FinallyTheControllerWorks/UI/Updater.swift @@ -1,5 +1,5 @@ // Updater.swift -// Self-contained auto-updater for the Developer ID (non-App-Store) build. +// Retained upstream updater; disabled by AppInfo.updatesEnabled in this fork. // // Flow: fetch a small JSON "appcast" from a configurable feed URL → if it // advertises a newer build, download the .zip → verify its SHA-256 AND that @@ -29,7 +29,7 @@ struct AppcastEntry: Codable { @MainActor final class Updater: ObservableObject { - /// Our Developer ID team — downloads must be signed by this team. + /// Upstream Developer ID team. Fork updates remain disabled, not re-trusted. nonisolated static let requiredTeamID = "4BA4S6WKX7" enum State: Equatable { @@ -69,6 +69,7 @@ final class Updater: ObservableObject { } var feedURL: URL? { + guard AppInfo.updatesEnabled else { return nil } // The Configuration field overrides the built-in default, so a beta // build updates out of the box while testers can still point at a // staging feed. @@ -91,7 +92,7 @@ final class Updater: ObservableObject { func check(userInitiated: Bool) async { guard let url = feedURL else { - if userInitiated { state = .failed("No update feed URL is configured.") } + if userInitiated { state = .failed(AppInfo.updatesEnabled ? "No update feed URL is configured." : "Updates are disabled in this fork. Install reviewed builds manually.") } return } let prior = state @@ -123,6 +124,7 @@ final class Updater: ObservableObject { } func downloadAndInstall(_ entry: AppcastEntry) { + guard AppInfo.updatesEnabled else { return } Task { await self.performDownload(entry) } } @@ -153,6 +155,7 @@ final class Updater: ObservableObject { /// User-confirmed install: hand off to the detached installer and quit. func installNow() { + guard AppInfo.updatesEnabled else { return } guard case .readyToInstall = state, let app = verifiedApp else { return } verifiedApp = nil // a second click must be a no-op state = .installing @@ -366,7 +369,9 @@ struct UpdaterView: View { } if updater.feedURL == nil { - Text("Set an update feed URL in the dashboard's Configuration section to enable updates.") + Text(AppInfo.updatesEnabled + ? "Set an update feed URL in the dashboard's Configuration section to enable updates." + : "This fork uses manual updates until its own signing and update policy is configured.") .font(.caption).foregroundStyle(.tertiary).multilineTextAlignment(.center) } } diff --git a/docs/fork-identity.md b/docs/fork-identity.md new file mode 100644 index 0000000..0cfe812 --- /dev/null +++ b/docs/fork-identity.md @@ -0,0 +1,33 @@ +# Fork identity and update policy + +This build uses `io.github.jmonster.switch2mac` and the bundle name +**Finally the Controller Works (jmonster)**. It can coexist with upstream +without sharing its standard UserDefaults domain or overwriting the same +application filename. Original author/copyright/protocol credits are retained. + +The fork's built-in updater is disabled, including saved feed overrides and +its download/install entry points. No upstream release may silently replace +this build. This does not weaken or substitute the existing signature verifier; +there is no approved fork update trust path yet. Install reviewed builds +manually. Enabling automatic updates later requires an explicit decision on +the fork's own signing identity, feed, bundle verification and rollback policy. + +The changed bundle identity means macOS privacy approvals, launch-at-login +registration and preferences need to be established for this app. Settings +are not silently migrated from upstream; use a reviewed export/import. Existing +Bluetooth bonds are not deliberately rewritten by this metadata change. + +`bash scripts/build-app.sh` produces the distinct ad-hoc development bundle. +Signing with an embedded profile requires **SIGN_IDENTITY**, +**PROVISIONING_PROFILE**, and an explicit **SIGN_ENTITLEMENTS** file whose +application identifier matches the fork bundle ID and stated team. The script +never silently consumes upstream's entitlement plist. Passing that local check +does not prove Apple granted the capability or that a provisioning profile is +valid; runtime/signature/profile acceptance must still be verified. + +The notarization script has no built-in certificate or keychain account. It +requires the owner to supply SIGN_IDENTITY and NOTARY_KEYCHAIN_PROFILE and +uses the new bundle/zip names. It generates no appcast, tag, or release. +No production signing, entitlement approval or notarization was performed for +this PR. Metadata, disabled-feed and early signing-refusal tests run in CI, +alongside an actual ad-hoc app build with the new identifier and output path. diff --git a/docs/retroarch-integration.md b/docs/retroarch-integration.md new file mode 100644 index 0000000..3ec6799 --- /dev/null +++ b/docs/retroarch-integration.md @@ -0,0 +1,53 @@ +# RetroArch fork integration + +Adapted from vialoh/switch2mac retroarch-network-gamepad at +2c7a396336f5a657a16a772b8c056d96ec6ff7f1 (upstream PR #1). The optional +configuration toggle and default-off behavior remain. No Nintendo command, +BLE handshake, decoding or entitlement changes are required. + +## Setup + +In RetroArch, enable Settings > Network > Network Gamepad and the desired +Network Gamepad Users, then restart RetroArch. In this app's dashboard, +enable Configuration > Network gamepad output (RetroArch). Both base ports +must match (default 55400, with one port per player). The sink sends only to +127.0.0.1; the receiver may listen on other interfaces, so do not enable it +on an untrusted network. No patched SDL library is required for this path. + +The original desired-state timer erased taps completed before its next tick. +The corrected sink retains ordered button edges (up to 256 per player), +coalesces analog positions separately, and retains the existing 60/s pacing. +Overflow explicitly neutralizes that player's output and logs a failure; +disable/re-enable network output to retry. It never silently drops oldest +button edges while pretending the stream is intact. + +Full periodic refreshes include zeros/releases, not only held values, so a +lost release can converge later. A disconnect/disable attempts repeated +neutral refreshes. Changing the destination first paces 20 neutral control +messages to the old port, then establishes current state at the new port; +input during that explicit configuration reset becomes the new initial state. +Cancelling a destination change reasserts current state at the original port +and resumes ordered edge delivery. Normal input/taps do not use that reset +policy. Send failures retain pending work; sendto success means kernel +acceptance, not remote acknowledgement. + +The 20-byte native little-endian message layout was cross-checked against +libretro/RetroArch commit 81478f2aa2abb942cfacb2109cbc25a4bd3b46ca, +input/input_driver.h and tools/ra_stress.md. This legacy protocol updates one +control per datagram and receiver polling/pacing can lose UDP traffic. It is +not an atomic full-state or lossless channel, and it has no rumble return path. +GameCube analog triggers still map to digital L2/R2 at the inherited threshold; +this is not full analog-trigger fidelity. Use only on a trusted local network +when enabling RetroArch's unauthenticated listener. + +Nine tests use the actual sink and real loopback sockets. Tests reproduce +lost taps, missing release refreshes and cancelled destination changes against +the corresponding earlier implementation. The visibility-only test copy +avoids unrelated UI initialization; the complete app is separately built with +Apple frameworks. Synthetic tests do not establish gameplay, physical timing +or controller compatibility. + +Run bash tests/retroarch/run.sh. The per-report DispatchQueue itself is not +byte-bounded; this PR bounds retained digital edges and documents overload, +not all memory. The optional browser output may coexist with this sink; +neither listener is enabled by default. diff --git a/scripts/build-app.sh b/scripts/build-app.sh index dae309e..03401c2 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -5,17 +5,30 @@ # ./scripts/build-app.sh # ad-hoc signed (no virtual HID) # SIGN_IDENTITY="Developer ID Application: ..." \ # PROVISIONING_PROFILE=path/to.provisionprofile \ +# SIGN_ENTITLEMENTS=path/to/fork-entitlements.plist \ # ./scripts/build-app.sh # full signing incl. HID entitlement # -# Output: build/Finally the Controller Works.app +# Output: build/Finally the Controller Works (jmonster).app set -euo pipefail cd "$(dirname "$0")/.." -APP_NAME="Finally the Controller Works" +APP_NAME="Finally the Controller Works (jmonster)" EXE=FinallyTheControllerWorks OUT="build/$APP_NAME.app" +# Never silently sign this fork with the upstream application's entitlements. +if [ -n "${PROVISIONING_PROFILE:-}" ]; then + : "${SIGN_IDENTITY:?Set your own Developer ID signing identity}" + : "${SIGN_ENTITLEMENTS:?Provide a fork-specific entitlement plist explicitly}" + [ -f "$PROVISIONING_PROFILE" ] && [ -f "$SIGN_ENTITLEMENTS" ] || { echo "Signing input missing" >&2; exit 2; } + PB=/usr/libexec/PlistBuddy + BUNDLE_ID=$($PB -c 'Print :CFBundleIdentifier' Resources/Info.plist) + TEAM=$($PB -c 'Print :com.apple.developer.team-identifier' "$SIGN_ENTITLEMENTS") + APP_ID=$($PB -c 'Print :com.apple.application-identifier' "$SIGN_ENTITLEMENTS") + [ -n "$TEAM" ] && [ "$APP_ID" = "$TEAM.$BUNDLE_ID" ] || { echo "Entitlements do not identify this fork" >&2; exit 2; } +fi + swift build -c release rm -rf "$OUT" @@ -33,9 +46,9 @@ if [ -n "${SIGN_IDENTITY:-}" ]; then # Full build: profile-gated HID entitlement → system-wide virtual pads. cp "$PROVISIONING_PROFILE" "$OUT/Contents/embedded.provisionprofile" codesign --force --options runtime --timestamp \ - --entitlements Resources/entitlements-dev.plist \ + --entitlements "$SIGN_ENTITLEMENTS" \ --sign "$SIGN_IDENTITY" "$OUT" - echo "Signed with: $SIGN_IDENTITY (virtual HID enabled)" + echo "Signed with: $SIGN_IDENTITY (supplied profile; runtime entitlement approval still required)" else # Developer ID without profile: notarizable, UDP/SDL path only. # (Signing the restricted entitlement without an embedded profile diff --git a/scripts/notarize.sh b/scripts/notarize.sh index 2d4bf02..35023f5 100755 --- a/scripts/notarize.sh +++ b/scripts/notarize.sh @@ -1,74 +1,22 @@ #!/bin/bash -# notarize.sh — build, sign, notarize, and staple the app for distribution. -# -# Prerequisites (one-time): -# 1. A Developer ID Application certificate in the login keychain -# (already installed for this project). -# 2. An app-specific password from https://account.apple.com -# (Sign-In & Security → App-Specific Passwords), stored in the keychain: -# xcrun notarytool store-credentials ftcw-notary \ -# --apple-id "peterksharma@gmail.com" \ -# --team-id 4BA4S6WKX7 \ -# --password "" -# -# Then just run: ./scripts/notarize.sh -# -# Result: build/Finally the Controller Works.app is notarized + stapled, and -# build/FinallyTheControllerWorks.zip is ready to distribute. - +# Explicit fork signing/notarization only. No inherited identity, credential +# profile, update feed or release publication. Read docs/fork-identity.md first. set -euo pipefail cd "$(dirname "$0")/.." +: "${SIGN_IDENTITY:?Supply your own Developer ID signing identity}" +: "${NOTARY_KEYCHAIN_PROFILE:?Supply your own notarytool keychain profile}" +APP="build/Finally the Controller Works (jmonster).app" +ZIP="build/switch2mac-jmonster.zip" -APP="build/Finally the Controller Works.app" -ZIP="build/FinallyTheControllerWorks.zip" -IDENTITY="Developer ID Application: Peter Sharma (4BA4S6WKX7)" -KEYCHAIN_PROFILE="ftcw-notary" - -echo "==> Building signed app" -SIGN_IDENTITY="$IDENTITY" ./scripts/build-app.sh - -echo "==> Zipping for submission" +bash scripts/build-app.sh +codesign --verify --strict "$APP" rm -f "$ZIP" ditto -c -k --keepParent "$APP" "$ZIP" - -echo "==> Submitting to Apple notary service (this takes a few minutes)" -xcrun notarytool submit "$ZIP" \ - --keychain-profile "$KEYCHAIN_PROFILE" \ - --wait - -echo "==> Stapling the notarization ticket" +xcrun notarytool submit "$ZIP" --keychain-profile "$NOTARY_KEYCHAIN_PROFILE" --wait xcrun stapler staple "$APP" xcrun stapler validate "$APP" - -echo "==> Re-zipping the stapled app for distribution" rm -f "$ZIP" ditto -c -k --keepParent "$APP" "$ZIP" - -echo "==> Generating appcast.json for the auto-updater" -VERSION=$(/usr/libexec/PlistBuddy -c "Print CFBundleShortVersionString" "$APP/Contents/Info.plist") -BUILD=$(/usr/libexec/PlistBuddy -c "Print CFBundleVersion" "$APP/Contents/Info.plist") -SHA=$(shasum -a 256 "$ZIP" | awk '{print $1}') -# Hosted on GitHub Releases: each release v$VERSION carries the zip and -# appcast.json as assets. The app's feed reads releases/latest/download/ -# appcast.json (a stable URL), while the zip URL below is version-pinned -# so an appcast always references its own release's asset. -DOWNLOAD_BASE="${DOWNLOAD_BASE:-https://github.com/Peterksharma/switch2mac/releases/download}" -cat > build/appcast.json < Void)? { get set } + func controllerConnected(slot: Int, model: Switch2.Model) + func controllerDisconnected(slot: Int) + func controllerName(slot: Int, name: String) + func controllerState(slot: Int, state: ControllerState) +} + +@main +enum NetworkTests { + static func receiver(_ port: UInt16) -> Int32 { + let fd = socket(AF_INET, datagram, 0); precondition(fd >= 0) + var addr = sockaddr_in(); addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = port.bigEndian; addr.sin_addr.s_addr = UInt32(0x7f000001).bigEndian + precondition(withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } == 0) + _ = fcntl(fd, F_SETFL, O_NONBLOCK) + return fd + } + static func packets(_ fd: Int32) -> [Data] { + var result: [Data] = [], bytes = [UInt8](repeating: 0, count: 64) + var ready = pollfd(fd: fd, events: Int16(POLLIN), revents: 0) + while poll(&ready, 1, 20) == 1 { + let n = recv(fd, &bytes, bytes.count, 0) + precondition(n == 20, "Expected exactly one 20-byte remote_message") + result.append(Data(bytes.prefix(n))) + } + return result + } + static func tick(_ sink: NetworkGamepadSink, count: Int = 1) { + sink.queue.sync { + sink.timer?.cancel(); sink.timer = nil + for _ in 0.. ControllerState { + var s = ControllerState(); if down { s.buttons = [.a] }; return s + } + static func aValues(_ packets: [Data]) -> [UInt16] { + packets.filter { Switch2.u32($0, 4) == 1 && Switch2.u32($0, 12) == 8 }.map { Switch2.u16($0, 16) } + } + static func main() { + let selected = CommandLine.arguments.last! + for name in ["wire", "tap", "refresh-release", "destination", "disable", "overflow", "send-failure", "finite-axis", "cancel-destination"] { + if selected != "all" && selected != name { continue } + AppConfig.networkGamepadEnabled = true; AppConfig.networkGamepadBasePort = 55400 + let a = receiver(55400), b = receiver(55410), sink = NetworkGamepadSink() + defer { sink.queue.sync { sink.timer?.cancel(); sink.timer = nil }; close(a); close(b) } + sink.queue.sync { logged.removeAll() } + sink.controllerConnected(slot: 0, model: .nsoGameCube) + switch name { + case "wire": + let m = NetworkGamepadSink.message(slot: 0, device: 1, index: 0, id: 8, state: 1) + precondition(m == [0,0,0,0,1,0,0,0,0,0,0,0,8,0,0,0,1,0,0,0]) + case "tap": + sink.queue.sync { sink.controllerState(slot: 0, state: state(true)); sink.controllerState(slot: 0, state: state(false)) } + tick(sink, count: 2) + precondition(Array(aValues(packets(a)).prefix(2)) == [1,0], "Complete tap disappeared before the send timer") + case "refresh-release": + sink.controllerState(slot: 0, state: state(true)); tick(sink); _ = packets(a) + sink.controllerState(slot: 0, state: state(false)); tick(sink); _ = packets(a) // simulate loss of release + sink.queue.sync { sink.players[0].lastRefreshAt = -100 } + tick(sink, count: 25) + precondition(aValues(packets(a)).contains(0), "Periodic refresh omitted released buttons") + case "destination": + sink.controllerState(slot: 0, state: state(true)); tick(sink); _ = packets(a) + AppConfig.networkGamepadBasePort = 55410 + tick(sink, count: 21) + precondition(aValues(packets(a)).contains(0), "Old destination never received neutralization") + precondition(aValues(packets(b)).first == 1, "New destination did not receive current state") + case "disable": + sink.controllerState(slot: 0, state: state(true)); tick(sink); _ = packets(a) + AppConfig.networkGamepadEnabled = false; tick(sink, count: 20) + precondition(aValues(packets(a)).contains(0)) + case "overflow": + sink.queue.sync { + for i in 0..<600 { sink.controllerState(slot: 0, state: state(i % 2 == 0)) } + } + tick(sink, count: 20) + precondition(sink.queue.sync { logged.contains { $0.contains("edge queue exhausted") } }, "Overflow silently lost input") + precondition(aValues(packets(a)).allSatisfy { $0 == 0 }) + AppConfig.networkGamepadEnabled = false; tick(sink) + AppConfig.networkGamepadEnabled = true + sink.controllerState(slot: 0, state: state(true)); tick(sink) + precondition(aValues(packets(a)).contains(1), "Explicit disable/re-enable did not recover") + case "send-failure": + sink.queue.sync { close(sink.fd); sink.fd = -1 } + sink.controllerState(slot: 0, state: state(true)); tick(sink) + precondition(sink.queue.sync { sink.players[0].sentButtons == 0 }) + case "cancel-destination": + sink.controllerState(slot: 0, state: state(true)); tick(sink); _ = packets(a) + AppConfig.networkGamepadBasePort = 55410 + tick(sink, count: 10) // old destination has received A's release + precondition(aValues(packets(a)).contains(0)) + AppConfig.networkGamepadBasePort = 55400 // cancel before reset finishes + tick(sink, count: 40) + precondition(aValues(packets(a)).contains(1), "Cancelled destination reset did not restore held input") + sink.queue.sync { + sink.controllerState(slot: 0, state: state(false)) + sink.controllerState(slot: 0, state: state(true)) + sink.controllerState(slot: 0, state: state(false)) + } + tick(sink, count: 3) + precondition(Array(aValues(packets(a)).prefix(3)) == [0,1,0], "Cancelled port switch disabled edge delivery") + precondition(packets(b).isEmpty, "Cancelled destination received input") + case "finite-axis": + precondition(NetworkGamepadSink.axis(.nan) == 0) + precondition(NetworkGamepadSink.axis(.infinity) == 0) + default: fatalError() + } + print("PASS \(name)") + } + } +} diff --git a/tests/retroarch/run.sh b/tests/retroarch/run.sh new file mode 100644 index 0000000..0e95b1c --- /dev/null +++ b/tests/retroarch/run.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")/../.." +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +python3 - "$work" <<'PY' +from pathlib import Path +import os,re,sys +out=Path(sys.argv[1]);base=Path('Sources/FinallyTheControllerWorks') +s=Path(os.environ.get('NETPAD_SOURCE', base/'Output/NetworkGamepadSink.swift')).read_text() +s=re.sub(r'\bprivate\s+', '', s) +if sys.platform != 'darwin': + s=s.replace('import Darwin', 'import Glibc\nimport CoreFoundation').replace('SOCK_DGRAM,','Int32(SOCK_DGRAM.rawValue),') +out.joinpath('Sink.swift').write_text(s) +s=(base/'Bluetooth/ControllerSession.swift').read_text() +a=s.index('struct ControllerState:');b=s.index('/// Called on the Bluetooth queue.',a) +out.joinpath('State.swift').write_text('import Foundation\n'+s[a:b]) +PY +swiftc -swift-version 5 Sources/FinallyTheControllerWorks/Protocol/Switch2Protocol.swift \ + "$work/State.swift" "$work/Sink.swift" tests/retroarch/NetworkTests.swift -o "$work/test" +"$work/test" "${NETPAD_CASE:-all}"