Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/macos-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
233 changes: 69 additions & 164 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 <https://www.xbox.com/play> and play: the controller is a
standard gamepad, with rumble. Xbox prompts match the physical
positions (Switch B is where Xbox A is).

Button table, adding other game sites, troubleshooting and the wire
protocol are in [`browser/README.md`](browser/README.md).

## Features

**Working now, in the beta UI**

- 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.
6 changes: 3 additions & 3 deletions Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>Finally the Controller Works</string>
<string>Finally the Controller Works (jmonster)</string>
<key>CFBundleDisplayName</key>
<string>Finally the Controller Works</string>
<string>Finally the Controller Works (jmonster)</string>
<key>CFBundleIdentifier</key>
<string>com.petersharma.finallythecontrollerworks</string>
<string>io.github.jmonster.switch2mac</string>
<key>CFBundleExecutable</key>
<string>FinallyTheControllerWorks</string>
<key>CFBundleVersion</key>
Expand Down
11 changes: 5 additions & 6 deletions Sources/FinallyTheControllerWorks/UI/AboutAndOnboarding.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
13 changes: 9 additions & 4 deletions Sources/FinallyTheControllerWorks/UI/Updater.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -123,6 +124,7 @@ final class Updater: ObservableObject {
}

func downloadAndInstall(_ entry: AppcastEntry) {
guard AppInfo.updatesEnabled else { return }
Task { await self.performDownload(entry) }
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
Expand Down
33 changes: 33 additions & 0 deletions docs/fork-identity.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading