From 3e982c7757d75939d7fbf3f21be861605061d3ba Mon Sep 17 00:00:00 2001 From: Johnny D Date: Wed, 9 Sep 2026 13:46:12 -0400 Subject: [PATCH 1/4] feat(retroarch): import the optional network gamepad sink for regression validation Adapted from vialoh/switch2mac retroarch-network-gamepad at 2c7a396336f5a657a16a772b8c056d96ec6ff7f1 (upstream PR #1). Retain the contributor's opt-in default and attribution; validate actual Swift builds and repair edge/lifecycle behavior before marking ready. --- README.md | 34 ++- .../FinallyTheControllerWorks/FTCWApp.swift | 1 + .../Output/NetworkGamepadSink.swift | 271 ++++++++++++++++++ .../UI/AppNotifications.swift | 11 + .../UI/DashboardView.swift | 19 ++ 5 files changed, 334 insertions(+), 2 deletions(-) create mode 100644 Sources/FinallyTheControllerWorks/Output/NetworkGamepadSink.swift diff --git a/README.md b/README.md index d085e1d..5675a1f 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,34 @@ Gopher64 is SDL-based, so it works through the bridge today: 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 RetroArch + +RetroArch on macOS doesn't use SDL for input, so the bridge above can't +reach it. Instead the app can feed RetroArch's built-in **Network +Gamepad** directly — no extra library, nothing to patch: + +1. In RetroArch: **Settings → Network → Network Gamepad** → on. Leave + the base port at 55400, and turn on **Network Gamepad User 1** (and + 2–4 for more players). Restart RetroArch. +2. In the menu-bar app's dashboard, open **Configuration** and turn on + **Network gamepad output (RetroArch)**. +3. Load a game. Your controller drives the RetroPad for player 1 + directly (no "bind all" step needed) — sticks, D-pad, A/B/X/Y, + L/R/ZL/ZR, stick clicks, +/−. + +Caveats: RetroArch's network protocol is one-way (no rumble), and Home, +Capture, C, GL and GR have no RetroPad equivalent (use the app's +button remapper for those). RetroArch listens on all interfaces with no +authentication, so only enable its network gamepad on a network you +trust. + +Looking ahead: RetroArch gained an SDL3 joypad driver upstream in +mid-2026, but the official macOS builds aren't compiled with it yet. If +that changes, the SDL bridge above will work with RetroArch too — with +rumble — by launching it with the patched `libSDL3` like any other SDL3 +app, and the network gamepad becomes the fallback rather than the only +route. + ## Features **Working now, in the beta UI** @@ -78,6 +106,7 @@ The same recipe works for any SDL3-based emulator or game — see - 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 +- RetroArch network-gamepad output (no SDL needed; off by default) - Signed auto-updates, first-run tour, settings import/export, live log with BLE gap diagnostics, launch-at-login @@ -117,12 +146,13 @@ Controller ──BLE──> BridgeEngine ──> ControllerSession (per slot) ▼ ControllerOutputSink protocol ├── VirtualHIDSink (CoreHID; entitlement-gated) - └── UDPHub (SDL-compat, ports 24800-24803) + ├── UDPHub (SDL-compat, ports 24800-24803) + └── NetworkGamepadSink (network gamepad / RetroArch, 55400-55403) ``` - `Protocol/Switch2Protocol.swift` — the wire protocol, transport-free. - `Bluetooth/` — CoreBluetooth engine + per-controller session state machine. -- `Output/` — the two sinks. +- `Output/` — the sinks. - `UI/` — SwiftUI dashboard (status cards + live log) and menu bar. ## Support diff --git a/Sources/FinallyTheControllerWorks/FTCWApp.swift b/Sources/FinallyTheControllerWorks/FTCWApp.swift index 65c7d53..5371178 100644 --- a/Sources/FinallyTheControllerWorks/FTCWApp.swift +++ b/Sources/FinallyTheControllerWorks/FTCWApp.swift @@ -83,6 +83,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { func applicationDidFinishLaunching(_ notification: Notification) { bridgeLog(.info, "app", "Finally the Controller Works — starting bridge") engine.addSink(UDPHub()) + 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..193a8ca --- /dev/null +++ b/Sources/FinallyTheControllerWorks/Output/NetworkGamepadSink.swift @@ -0,0 +1,271 @@ +// NetworkGamepadSink.swift +// Optional sink: publishes controller state as "network gamepad" datagrams +// over localhost UDP, one port per player (base port + slot, default +// 55400-55403), for programs that accept a remote gamepad over the network +// instead of reading input devices — RetroArch's Network Gamepad (Remote +// RetroPad) is the one this was built for, and it has no SDL input path on +// macOS, so the SDL bridge can't reach it. Off by default; toggled in +// Configuration. +// +// Wire format is the libretro remote protocol (RetroArch +// input/input_driver.h `struct remote_message`, 20 bytes, native +// little-endian): +// i32 port | i32 device | i32 index | i32 id | u16 state | 2 pad +// device 1 (JOYPAD): id = RetroPad button, state 0/1 +// device 5 (ANALOG): index 0 = left stick, 1 = right; id 0 = X, 1 = Y; +// state = Int16, +Y down +// The receiver reads ONE datagram per player per frame and drops the rest, +// so this sink sends diffs only, paced at 60/s per player, button edges +// before analog drift, and re-asserts held state every 2 s (a receiver +// launched mid-hold converges; zeros match its initial state). The +// protocol is one-way: no rumble. + +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 + /// Quantize axes so centre jitter doesn't eat the per-frame send budget. + private static let analogQuantum: Int32 = 512 + + private static let retroDeviceJoypad: Int32 = 1 + private static let retroDeviceAnalog: Int32 = 5 + private static let retroPadL2 = 12 + private static let retroPadR2 = 13 + + /// Switch 2 button → RetroPad id (libretro.h RETRO_DEVICE_ID_JOYPAD_*). + /// Home/Capture/C/GL/GR have no RetroPad slot; the remapper covers those. + 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) // LX LY RX RY + var sentAxes = [Int16](repeating: 0, count: 4) + var nextSendAt: TimeInterval = 0 + var lastRefreshAt: TimeInterval = 0 + /// Held state still to re-assert after the last refresh. Kept apart + /// from sent*, which must stay the true model of what the receiver + /// holds — clearing sent-bits to force a resend is how a release got + /// skipped and a direction stayed stuck. + var refreshButtons: UInt16 = 0 + var refreshAxes = [Bool](repeating: false, count: 4) + var announced = false + + var reassertButtons: UInt16 { refreshButtons & wantButtons } + var reassertAxis: Int? { + (0..<4).first { refreshAxes[$0] && wantAxes[$0] != 0 } + } + var hasPending: Bool { + wantButtons != sentButtons || wantAxes != sentAxes + || reassertButtons != 0 || reassertAxis != nil + } + var isHeld: Bool { + wantButtons != 0 || wantAxes.contains { $0 != 0 } + } + } + + 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) + } + + // MARK: ControllerOutputSink (called on the Bluetooth queue) + + func controllerConnected(slot: Int, model: Switch2.Model) {} + func controllerName(slot: Int, name: String) {} + + func controllerDisconnected(slot: Int) { + queue.async { [weak self] in + guard let self, let p = self.players[safe: slot] else { return } + p.wantButtons = 0 + p.wantAxes = [0, 0, 0, 0] + self.ensurePumping() + } + } + + func controllerState(slot: Int, state: ControllerState) { + guard AppConfig.networkGamepadEnabled else { return } + queue.async { [weak self] in + guard let self, let p = self.players[safe: slot] else { return } + var buttons: UInt16 = 0 + for (button, id) in Self.buttonMap where state.buttons.contains(button) { + buttons |= 1 << id + } + // GameCube-style analog triggers report in lt/rt without ZL/ZR. + if state.leftTrigger >= 128 { buttons |= 1 << Self.retroPadL2 } + if state.rightTrigger >= 128 { buttons |= 1 << Self.retroPadR2 } + p.wantButtons = buttons + p.wantAxes = [ + Self.axis(state.leftStick.x), + Self.axis(-state.leftStick.y), // +Y up here, +Y down in RetroPad + Self.axis(state.rightStick.x), + Self.axis(-state.rightStick.y), + ] + self.ensurePumping() + } + } + + private static func axis(_ value: Double) -> Int16 { + let raw = Int32(max(-1, min(1, value)) * 32767) + return Int16(raw / analogQuantum * analogQuantum) + } + + // MARK: Pump (queue-confined) + + /// The timer only runs while something is pending or held, so an idle + /// or disabled sink costs nothing. + 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 enabled = AppConfig.networkGamepadEnabled + let basePort = AppConfig.networkGamepadBasePort + let now = CFAbsoluteTimeGetCurrent() + var busy = false + + for (slot, p) in players.enumerated() { + if !enabled { + // Toggled off mid-hold: release everything, then go quiet. + p.wantButtons = 0 + p.wantAxes = [0, 0, 0, 0] + p.announced = false + } else if now - p.lastRefreshAt >= Self.refreshInterval { + p.lastRefreshAt = now + p.refreshButtons = p.wantButtons + p.refreshAxes = p.wantAxes.map { $0 != 0 } + } + + busy = busy || p.hasPending || p.isHeld + guard now >= p.nextSendAt, p.hasPending else { continue } + + // Priority: button edges, analog changes, then re-asserts. Only + // record a message as delivered once sendto accepts it; a + // transient failure (ENOBUFS etc.) would otherwise drop a button + // release for good, since only diffs are ever sent. + let port = UInt16(clamping: basePort + slot) + let diff = p.wantButtons ^ p.sentButtons + let axis = (0..<4).max { a, b in + abs(Int(p.wantAxes[a]) - Int(p.sentAxes[a])) + < abs(Int(p.wantAxes[b]) - Int(p.sentAxes[b])) + }! + if diff != 0 { + let id = diff.trailingZeroBitCount + let pressed = (p.wantButtons >> id) & 1 + guard send(Self.message(slot: slot, device: Self.retroDeviceJoypad, + index: 0, id: Int32(id), state: pressed), + port: port, now: now) else { continue } + p.sentButtons ^= 1 << id + p.refreshButtons &= ~(UInt16(1) << id) // an edge supersedes it + } else if p.wantAxes[axis] != p.sentAxes[axis] { + guard send(Self.message(slot: slot, device: Self.retroDeviceAnalog, + 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] + p.refreshAxes[axis] = false + } else if p.reassertButtons != 0 { + let id = p.reassertButtons.trailingZeroBitCount + guard send(Self.message(slot: slot, device: Self.retroDeviceJoypad, + index: 0, id: Int32(id), state: 1), + port: port, now: now) else { continue } + p.refreshButtons &= ~(UInt16(1) << id) + } else if let held = p.reassertAxis { + guard send(Self.message(slot: slot, device: Self.retroDeviceAnalog, + index: Int32(held / 2), id: Int32(held % 2), + state: UInt16(bitPattern: p.wantAxes[held])), + port: port, now: now) else { continue } + p.refreshAxes[held] = false + } else { + continue + } + p.nextSendAt = now + Self.sendInterval + if !p.announced { + p.announced = true + bridgeLog(.info, "netpad", + "player \(slot + 1) → udp://127.0.0.1:\(port) (network gamepad)") + } + } + + if !busy { + timer?.cancel() + timer = nil + } + } + + 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 + } + + /// True once the kernel accepted the datagram. Failures are logged at + /// most once a second; the caller retries on the next tick. + 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 // 127.0.0.1 + let sent = bytes.withUnsafeBytes { buf in + withUnsafePointer(to: &dest) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { destPtr in + sendto(fd, buf.baseAddress, buf.count, 0, + destPtr, socklen_t(MemoryLayout.size)) + } + } + } + if sent == bytes.count { return true } + if now - lastSendErrorAt >= 1.0 { + lastSendErrorAt = now + bridgeLog(.warning, "netpad", + "sendto 127.0.0.1:\(port) failed, retrying: \(String(cString: strerror(errno)))") + } + return false + } +} + +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} 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() } From 6879a31ef4e1c94befff980cf13d75a874d7794b Mon Sep 17 00:00:00 2001 From: Johnny D Date: Wed, 9 Sep 2026 14:14:12 -0400 Subject: [PATCH 2/4] fix(retroarch): preserve edges, refresh releases and retire old destinations Retain the attributed optional fork output and existing remote_message bytes. Replace latest-only button state with a bounded edge queue; overflow explicitly neutralizes this output until disable/re-enable. Include zeros in periodic refreshes and clear the old port before switching destinations. Reject nonfinite axes and remove the imported Array safe-subscript compile failure. Eight synthetic/real-loopback cases pass locally; the tap, release refresh, destination, overflow and finite-axis cases fail against the imported source. UDP acceptance is not acknowledgement; no physical-gameplay claim is made. --- .../Output/NetworkGamepadSink.swift | 287 +++++++----------- docs/retroarch-integration.md | 40 +++ tests/retroarch/NetworkTests.swift | 121 ++++++++ tests/retroarch/run.sh | 21 ++ 4 files changed, 295 insertions(+), 174 deletions(-) create mode 100644 docs/retroarch-integration.md create mode 100644 tests/retroarch/NetworkTests.swift create mode 100755 tests/retroarch/run.sh diff --git a/Sources/FinallyTheControllerWorks/Output/NetworkGamepadSink.swift b/Sources/FinallyTheControllerWorks/Output/NetworkGamepadSink.swift index 193a8ca..4c2b4b2 100644 --- a/Sources/FinallyTheControllerWorks/Output/NetworkGamepadSink.swift +++ b/Sources/FinallyTheControllerWorks/Output/NetworkGamepadSink.swift @@ -1,77 +1,46 @@ -// NetworkGamepadSink.swift -// Optional sink: publishes controller state as "network gamepad" datagrams -// over localhost UDP, one port per player (base port + slot, default -// 55400-55403), for programs that accept a remote gamepad over the network -// instead of reading input devices — RetroArch's Network Gamepad (Remote -// RetroPad) is the one this was built for, and it has no SDL input path on -// macOS, so the SDL bridge can't reach it. Off by default; toggled in -// Configuration. -// -// Wire format is the libretro remote protocol (RetroArch -// input/input_driver.h `struct remote_message`, 20 bytes, native -// little-endian): -// i32 port | i32 device | i32 index | i32 id | u16 state | 2 pad -// device 1 (JOYPAD): id = RetroPad button, state 0/1 -// device 5 (ANALOG): index 0 = left stick, 1 = right; id 0 = X, 1 = Y; -// state = Int16, +Y down -// The receiver reads ONE datagram per player per frame and drops the rest, -// so this sink sends diffs only, paced at 60/s per player, button edges -// before analog drift, and re-asserts held state every 2 s (a receiver -// launched mid-hold converges; zeros match its initial state). The -// protocol is one-way: no rumble. - +// 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 - /// Quantize axes so centre jitter doesn't eat the per-frame send budget. private static let analogQuantum: Int32 = 512 - - private static let retroDeviceJoypad: Int32 = 1 - private static let retroDeviceAnalog: Int32 = 5 - private static let retroPadL2 = 12 - private static let retroPadR2 = 13 - - /// Switch 2 button → RetroPad id (libretro.h RETRO_DEVICE_ID_JOYPAD_*). - /// Home/Capture/C/GL/GR have no RetroPad slot; the remapper covers those. + 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) // LX LY RX RY + 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 - /// Held state still to re-assert after the last refresh. Kept apart - /// from sent*, which must stay the true model of what the receiver - /// holds — clearing sent-bits to force a resend is how a release got - /// skipped and a direction stayed stuck. - var refreshButtons: UInt16 = 0 - var refreshAxes = [Bool](repeating: false, count: 4) - var announced = false - - var reassertButtons: UInt16 { refreshButtons & wantButtons } - var reassertAxis: Int? { - (0..<4).first { refreshAxes[$0] && wantAxes[$0] != 0 } - } - var hasPending: Bool { - wantButtons != sentButtons || wantAxes != sentAxes - || reassertButtons != 0 || reassertAxis != nil - } - var isHeld: Bool { - wantButtons != 0 || wantAxes.contains { $0 != 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") @@ -79,35 +48,30 @@ final class NetworkGamepadSink: ControllerOutputSink, @unchecked Sendable { private var fd: Int32 = -1 private var timer: DispatchSourceTimer? private var lastSendErrorAt: TimeInterval = 0 + private var wasEnabled = false - init() { - queue.async { [weak self] in self?.openSocket() } - } - - deinit { - timer?.cancel() - if fd >= 0 { close(fd) } - } - + init() { queue.async { [weak self] in self?.openSocket() } } + deinit { timer?.cancel(); if fd >= 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 + bridgeLog(.error, "netpad", "socket() failed: \(String(cString: strerror(errno)))"); return } _ = fcntl(fd, F_SETFL, O_NONBLOCK) } - // MARK: ControllerOutputSink (called on the Bluetooth queue) - - func controllerConnected(slot: Int, model: Switch2.Model) {} + 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, let p = self.players[safe: slot] else { return } - p.wantButtons = 0 - p.wantAxes = [0, 0, 0, 0] + guard let self, self.players.indices.contains(slot) else { return } + let p = self.players[slot] + p.connected = false; p.neutralize() self.ensurePumping() } } @@ -115,157 +79,132 @@ final class NetworkGamepadSink: ControllerOutputSink, @unchecked Sendable { func controllerState(slot: Int, state: ControllerState) { guard AppConfig.networkGamepadEnabled else { return } queue.async { [weak self] in - guard let self, let p = self.players[safe: slot] else { return } + 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 + 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) } - // GameCube-style analog triggers report in lt/rt without ZL/ZR. - if state.leftTrigger >= 128 { buttons |= 1 << Self.retroPadL2 } - if state.rightTrigger >= 128 { buttons |= 1 << Self.retroPadR2 } p.wantButtons = buttons - p.wantAxes = [ - Self.axis(state.leftStick.x), - Self.axis(-state.leftStick.y), // +Y up here, +Y down in RetroPad - Self.axis(state.rightStick.x), - Self.axis(-state.rightStick.y), - ] + 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) } - - // MARK: Pump (queue-confined) - - /// The timer only runs while something is pending or held, so an idle - /// or disabled sink costs nothing. 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 + t.resume(); timer = t } private func pump() { - let enabled = AppConfig.networkGamepadEnabled let basePort = AppConfig.networkGamepadBasePort - let now = CFAbsoluteTimeGetCurrent() + 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() { - if !enabled { - // Toggled off mid-hold: release everything, then go quiet. - p.wantButtons = 0 - p.wantAxes = [0, 0, 0, 0] - p.announced = false - } else if now - p.lastRefreshAt >= Self.refreshInterval { - p.lastRefreshAt = now - p.refreshButtons = p.wantButtons - p.refreshAxes = p.wantAxes.map { $0 != 0 } + 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.hasPending || p.isHeld - guard now >= p.nextSendAt, p.hasPending else { continue } - - // Priority: button edges, analog changes, then re-asserts. Only - // record a message as delivered once sendto accepts it; a - // transient failure (ENOBUFS etc.) would otherwise drop a button - // release for good, since only diffs are ever sent. - let port = UInt16(clamping: basePort + slot) - let diff = p.wantButtons ^ p.sentButtons - let axis = (0..<4).max { a, b in - abs(Int(p.wantAxes[a]) - Int(p.sentAxes[a])) - < abs(Int(p.wantAxes[b]) - Int(p.sentAxes[b])) - }! - if diff != 0 { - let id = diff.trailingZeroBitCount - let pressed = (p.wantButtons >> id) & 1 - guard send(Self.message(slot: slot, device: Self.retroDeviceJoypad, - index: 0, id: Int32(id), state: pressed), - port: port, now: now) else { continue } - p.sentButtons ^= 1 << id - p.refreshButtons &= ~(UInt16(1) << id) // an edge supersedes it - } else if p.wantAxes[axis] != p.sentAxes[axis] { - guard send(Self.message(slot: slot, device: Self.retroDeviceAnalog, - 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] - p.refreshAxes[axis] = false - } else if p.reassertButtons != 0 { - let id = p.reassertButtons.trailingZeroBitCount - guard send(Self.message(slot: slot, device: Self.retroDeviceJoypad, - index: 0, id: Int32(id), state: 1), - port: port, now: now) else { continue } - p.refreshButtons &= ~(UInt16(1) << id) - } else if let held = p.reassertAxis { - guard send(Self.message(slot: slot, device: Self.retroDeviceAnalog, - index: Int32(held / 2), id: Int32(held % 2), - state: UInt16(bitPattern: p.wantAxes[held])), - port: port, now: now) else { continue } - p.refreshAxes[held] = false - } else { + busy = busy || p.pending || refreshing || (target != nil && target != port) + guard now >= p.nextSendAt else { continue } + + // 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 !p.announced { - p.announced = true - bridgeLog(.info, "netpad", - "player \(slot + 1) → udp://127.0.0.1:\(port) (network gamepad)") - } } + if !busy { timer?.cancel(); timer = nil } + } - 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) + 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 + d.append(contentsOf: [0, 0]); return d } - - /// True once the kernel accepted the datagram. Failures are logged at - /// most once a second; the caller retries on the next tick. + // 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 // 127.0.0.1 + dest.sin_addr.s_addr = UInt32(0x7F000001).bigEndian let sent = bytes.withUnsafeBytes { buf in withUnsafePointer(to: &dest) { - $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { destPtr in - sendto(fd, buf.baseAddress, buf.count, 0, - destPtr, socklen_t(MemoryLayout.size)) + $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.0 { + if now - lastSendErrorAt >= 1 { lastSendErrorAt = now - bridgeLog(.warning, "netpad", - "sendto 127.0.0.1:\(port) failed, retrying: \(String(cString: strerror(errno)))") + bridgeLog(.warning, "netpad", "sendto 127.0.0.1:\(port) failed, retrying: \(String(cString: strerror(errno)))") } return false } } - -private extension Array { - subscript(safe index: Int) -> Element? { - indices.contains(index) ? self[index] : nil - } -} diff --git a/docs/retroarch-integration.md b/docs/retroarch-integration.md new file mode 100644 index 0000000..030628b --- /dev/null +++ b/docs/retroarch-integration.md @@ -0,0 +1,40 @@ +# 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. + +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. +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. + +Eight tests use the actual sink and real loopback sockets; four delivery cases +and the non-finite-axis case fail against the imported source and pass after +repair. 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. NODE/Swift framework dependencies are not +added by this feature. The per-report DispatchQueue itself is not byte-bounded; +this PR bounds retained digital edges and documents overload, not all memory. diff --git a/tests/retroarch/NetworkTests.swift b/tests/retroarch/NetworkTests.swift new file mode 100644 index 0000000..53bdba5 --- /dev/null +++ b/tests/retroarch/NetworkTests.swift @@ -0,0 +1,121 @@ +import Foundation +#if os(Linux) +import Glibc +let datagram = Int32(SOCK_DGRAM.rawValue) +#else +import Darwin +let datagram = SOCK_DGRAM +#endif + +enum BridgeEngine { static let maxPlayers = 4 } +enum AppConfig { + static var networkGamepadEnabled = true + static var networkGamepadBasePort = 55400 +} +enum LogLevel { case info, warning, error, debug } +var logged: [String] = [] // examined only with the sink queue quiescent +func bridgeLog(_ level: LogLevel, _ category: String, _ message: String) { logged.append(message) } +protocol ControllerOutputSink: AnyObject { + var onRumble: ((Int, Double, Double) -> Void)? { get set } + func controllerConnected(slot: Int, model: Switch2.Model) + func controllerDisconnected(slot: Int) + func controllerName(slot: Int, name: String) + func controllerState(slot: Int, state: ControllerState) +} + +@main +enum NetworkTests { + static func receiver(_ port: UInt16) -> Int32 { + let fd = socket(AF_INET, datagram, 0); precondition(fd >= 0) + var addr = sockaddr_in(); addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = port.bigEndian; addr.sin_addr.s_addr = UInt32(0x7f000001).bigEndian + precondition(withUnsafePointer(to: &addr) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } == 0) + _ = fcntl(fd, F_SETFL, O_NONBLOCK) + return fd + } + static func packets(_ fd: Int32) -> [Data] { + var result: [Data] = [], bytes = [UInt8](repeating: 0, count: 64) + var ready = pollfd(fd: fd, events: Int16(POLLIN), revents: 0) + while poll(&ready, 1, 20) == 1 { + let n = recv(fd, &bytes, bytes.count, 0) + precondition(n == 20, "Expected exactly one 20-byte remote_message") + result.append(Data(bytes.prefix(n))) + } + return result + } + static func tick(_ sink: NetworkGamepadSink, count: Int = 1) { + sink.queue.sync { + sink.timer?.cancel(); sink.timer = nil + for _ in 0.. ControllerState { + var s = ControllerState(); if down { s.buttons = [.a] }; return s + } + static func aValues(_ packets: [Data]) -> [UInt16] { + packets.filter { Switch2.u32($0, 4) == 1 && Switch2.u32($0, 12) == 8 }.map { Switch2.u16($0, 16) } + } + static func main() { + let selected = CommandLine.arguments.last! + for name in ["wire", "tap", "refresh-release", "destination", "disable", "overflow", "send-failure", "finite-axis"] { + 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 "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 100755 index 0000000..0e95b1c --- /dev/null +++ b/tests/retroarch/run.sh @@ -0,0 +1,21 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")/../.." +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +python3 - "$work" <<'PY' +from pathlib import Path +import os,re,sys +out=Path(sys.argv[1]);base=Path('Sources/FinallyTheControllerWorks') +s=Path(os.environ.get('NETPAD_SOURCE', base/'Output/NetworkGamepadSink.swift')).read_text() +s=re.sub(r'\bprivate\s+', '', s) +if sys.platform != 'darwin': + s=s.replace('import Darwin', 'import Glibc\nimport CoreFoundation').replace('SOCK_DGRAM,','Int32(SOCK_DGRAM.rawValue),') +out.joinpath('Sink.swift').write_text(s) +s=(base/'Bluetooth/ControllerSession.swift').read_text() +a=s.index('struct ControllerState:');b=s.index('/// Called on the Bluetooth queue.',a) +out.joinpath('State.swift').write_text('import Foundation\n'+s[a:b]) +PY +swiftc -swift-version 5 Sources/FinallyTheControllerWorks/Protocol/Switch2Protocol.swift \ + "$work/State.swift" "$work/Sink.swift" tests/retroarch/NetworkTests.swift -o "$work/test" +"$work/test" "${NETPAD_CASE:-all}" From 14137249f4bc3c91faceb7a0a1986feca5d5c558 Mon Sep 17 00:00:00 2001 From: Johnny D Date: Wed, 9 Sep 2026 19:29:38 -0400 Subject: [PATCH 3/4] fix(retroarch): resume ordered input after cancelling a destination change A partially completed reset left switchIndex active when the user changed back to the original port. Subsequent button edges were never queued. Reassert the desired state and retire that reset; the new real-loopback regression fails against the previous head and the nine-case suite passes. --- .../Output/NetworkGamepadSink.swift | 8 ++++++++ tests/retroarch/NetworkTests.swift | 18 +++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/Sources/FinallyTheControllerWorks/Output/NetworkGamepadSink.swift b/Sources/FinallyTheControllerWorks/Output/NetworkGamepadSink.swift index 4c2b4b2..3e54e2c 100644 --- a/Sources/FinallyTheControllerWorks/Output/NetworkGamepadSink.swift +++ b/Sources/FinallyTheControllerWorks/Output/NetworkGamepadSink.swift @@ -137,6 +137,14 @@ final class NetworkGamepadSink: ControllerOutputSink, @unchecked Sendable { 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 { diff --git a/tests/retroarch/NetworkTests.swift b/tests/retroarch/NetworkTests.swift index 53bdba5..545493c 100644 --- a/tests/retroarch/NetworkTests.swift +++ b/tests/retroarch/NetworkTests.swift @@ -64,7 +64,7 @@ enum NetworkTests { } static func main() { let selected = CommandLine.arguments.last! - for name in ["wire", "tap", "refresh-release", "destination", "disable", "overflow", "send-failure", "finite-axis"] { + 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() @@ -110,6 +110,22 @@ enum NetworkTests { 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) From c7e6fdc06b0e6210a1d28c0186f48fd1050a281a Mon Sep 17 00:00:00 2001 From: Johnny D Date: Wed, 9 Sep 2026 20:00:50 -0400 Subject: [PATCH 4/4] fix(fork): isolate app identity and disable upstream automatic updates (#9) Use io.github.jmonster.switch2mac and a distinct bundle name/defaults domain. Keep upstream credits but reject default or saved update feeds and updater entry points until a fork-specific signing/update trust path is established. Require explicit signing identities, notary credentials and fork-matching entitlements instead of silently consuming upstream signing configuration. No certificate, profile, notarization request or release is created here. Add metadata, actual feed-resolver and early signing-refusal regressions and build the actual ad-hoc fork app in the existing read-only macOS check. --- .github/workflows/macos-validation.yml | 6 +- README.md | 233 ++++++------------ Resources/Info.plist | 6 +- .../UI/AboutAndOnboarding.swift | 11 +- .../UI/Updater.swift | 13 +- docs/fork-identity.md | 33 +++ scripts/build-app.sh | 21 +- scripts/notarize.sh | 76 +----- tests/fork/check.py | 61 +++++ tests/fork/run.sh | 9 + 10 files changed, 221 insertions(+), 248 deletions(-) create mode 100644 docs/fork-identity.md create mode 100644 tests/fork/check.py create mode 100644 tests/fork/run.sh diff --git a/.github/workflows/macos-validation.yml b/.github/workflows/macos-validation.yml index 029c7ac..0c66d87 100644 --- a/.github/workflows/macos-validation.yml +++ b/.github/workflows/macos-validation.yml @@ -23,13 +23,13 @@ jobs: swift --version xcodebuild -version git archive HEAD -o "$RUNNER_TEMP/source.zip" - - name: Run controller protocol regressions (no radio required) + - name: Run controller protocol and output regressions (no radio required) run: bash tests/run.sh - name: Build the actual app bundle (ad-hoc signing only) run: | bash scripts/build-app.sh - codesign --verify --strict "build/Finally the Controller Works.app" - plutil -lint "build/Finally the Controller Works.app/Contents/Info.plist" + codesign --verify --strict "build/Finally the Controller Works (jmonster).app" + plutil -lint "build/Finally the Controller Works (jmonster).app/Contents/Info.plist" - name: Preserve exact tested source if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 diff --git a/README.md b/README.md index 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 <