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/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/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/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 <