Detect connected controllers automatically and keep a single current launcher

This commit is contained in:
Erick Sanchez committed 2026-10-26 13:22:06 -06:00
1 parent 49b36b3ff1
commit df11bd3e44
12 files changed
+151 -8

No files matched your search

+2
View File
@@ -206,6 +206,8 @@ Los juegos preparados usan GameController de Apple en **Mac, iPhone y iPad**:
- **PS5:** DualSense y DualSense Edge.
- **PS4:** DualShock 4.
La app muestra automáticamente el nombre del control conectado y **Listo en este Mac** cuando Apple reconoce un perfil compatible. Se actualiza al conectar o desconectar el control. Si preparas para iPhone/iPad, te recuerda conectarlo **a ese dispositivo**: estar conectado al Mac no lo conecta al teléfono.
Conecta el control desde **Bluetooth del dispositivo** y abre tu juego. No necesitas instalar drivers ni activar opciones en la herramienta. Se mapean sticks, cruceta, botones, gatillos, botones superiores, clics de sticks y menús. En iPhone/iPad los controles táctiles se ocultan al conectar uno físico.
Los botones siguen las **posiciones de Nintendo**, no las letras impresas en Xbox: A de Switch = B de Xbox / círculo de PlayStation; B = A / cruz; X = Y / triángulo; Y = X / cuadrado.
+2
View File
@@ -204,6 +204,8 @@ The game apps use Apple's GameController framework on **Mac, iPhone and iPad**:
- **PS5:** DualSense and DualSense Edge.
- **PS4:** DualShock 4.
The preparation app automatically shows connected controller names and **Ready on this Mac** when Apple exposes a compatible gamepad profile. It updates when you connect or disconnect a controller. For an iPhone/iPad build, it reminds you to pair the controller **on that device**; a controller connected to the Mac is not a remote connection to the phone.
Pair your controller in the device's **Bluetooth settings**, then open the prepared game. No extra driver or launcher setting is needed. Sticks, D-pad, face buttons, shoulders, triggers, stick clicks and menu buttons are mapped automatically. On iPhone/iPad, touch controls hide while a physical controller is connected.
Face buttons follow **Nintendo positions**, rather than the letters printed on an Xbox controller: Switch A = Xbox B / PlayStation Circle; Switch B = Xbox A / Cross; Switch X = Xbox Y / Triangle; Switch Y = Xbox X / Square.
@@ -0,0 +1,47 @@
import AppKit
import Combine
import GameController
struct DetectedController: Identifiable {
let id: ObjectIdentifier
let name: String
let supported: Bool
}
// Observe connections only. Never take input handlers away from a running game.
@MainActor final class ControllerMonitor: ObservableObject {
@Published private(set) var controllers: [DetectedController] = []
private let query: () -> [GCController]
private let center: NotificationCenter
private var observers: [NSObjectProtocol] = []
init(center:NotificationCenter = .default, query:(() -> [GCController])? = nil) {
self.center=center
self.query=query ?? {GCController.controllers()}
for name in [NSNotification.Name.GCControllerDidConnect, .GCControllerDidDisconnect,
NSApplication.didBecomeActiveNotification] {
observers.append(center.addObserver(forName:name,object:nil,queue:.main) { [weak self] _ in
Task { @MainActor in self?.refresh() }
})
}
refresh()
}
deinit {for observer in observers {center.removeObserver(observer)}}
func refresh() {
controllers=query().map {
DetectedController(id:ObjectIdentifier($0),name:$0.vendorName ?? "",supported:$0.extendedGamepad != nil)
}
}
}
// All launchers share one app identity, even when opened through an old shortcut.
final class LauncherDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification:Notification) {
guard let identifier=Bundle.main.bundleIdentifier else {return}
if let existing=NSRunningApplication.runningApplications(withBundleIdentifier:identifier)
.first(where:{$0.processIdentifier != ProcessInfo.processInfo.processIdentifier && !$0.isTerminated}) {
existing.activate(options:[.activateAllWindows])
NSApplication.shared.terminate(nil)
}
}
}
+6
View File
@@ -12,6 +12,12 @@ enum EasyLanguage: String, CaseIterable {
struct EasyStrings {
static let english: [String:String] = [
"Sin control conectado en este Mac": "No controller connected to this Mac",
"Conéctalo por Bluetooth y aparecerá aquí.": "Connect it via Bluetooth and it will appear here.",
"Control": "Controller",
"Listo en este Mac": "Ready on this Mac",
"Sin perfil compatible": "No compatible gamepad profile",
"Para jugar en iPhone/iPad, conecta el control a ese dispositivo.": "To play on iPhone/iPad, connect the controller to that device.",
"¿Dónde quieres jugar?": "Where do you want to play?",
"Elige un dispositivo": "Choose a device",
"Elige dónde quieres jugar y añade tus archivos.": "Choose where to play, then add your own files.",
+24 -3
View File
@@ -237,7 +237,7 @@ struct CompactWindow: NSViewRepresentable {
adjusted=true
DispatchQueue.main.async {
let width=max(760,window.contentLayoutRect.width)
window.setContentSize(NSSize(width:width,height:650))
window.setContentSize(NSSize(width:width,height:710))
}
}
}
@@ -253,6 +253,7 @@ enum AppArtwork {
struct ContentView: View {
@StateObject private var model=BuildModel()
@StateObject private var controllerMonitor=ControllerMonitor()
private let ink=Color(red:0.93,green:0.94,blue:0.90)
private let lime=Color(red:0.79,green:0.95,blue:0.37)
var body: some View {
@@ -297,6 +298,25 @@ struct ContentView: View {
Text("iPhone / iPad").tag("ios")
Text("Mac").tag("mac")
}.pickerStyle(.menu).disabled(model.running)
HStack(alignment:.top,spacing:10) {
Image(systemName:controllerMonitor.controllers.contains(where:{$0.supported}) ? "checkmark.circle.fill" : "gamecontroller")
.foregroundStyle(controllerMonitor.controllers.contains(where:{$0.supported}) ? lime : .gray)
VStack(alignment:.leading,spacing:4) {
if controllerMonitor.controllers.isEmpty {
Text(model.text("Sin control conectado en este Mac")).font(.system(size:13,weight:.medium))
Text(model.text("Conéctalo por Bluetooth y aparecerá aquí.")).font(.system(size:12)).foregroundStyle(.secondary)
} else {
ForEach(controllerMonitor.controllers) { controller in
Text((controller.name.isEmpty ? model.text("Control") : controller.name)+" · "+model.text(controller.supported ? "Listo en este Mac" : "Sin perfil compatible"))
.font(.system(size:13,weight:.medium))
}
}
if model.target == "ios" {
Text(model.text("Para jugar en iPhone/iPad, conecta el control a ese dispositivo."))
.font(.system(size:12)).foregroundStyle(.secondary)
}
}
}.accessibilityIdentifier("controller-status")
HStack(spacing:12) {
Image(systemName:"folder.fill").font(.system(size:24)).foregroundStyle(lime)
VStack(alignment:.leading,spacing:4) {
@@ -452,12 +472,13 @@ struct GameCatalogView: View {
}
@main struct NxNativeEasyApp: App {
@NSApplicationDelegateAdaptor(LauncherDelegate.self) private var delegate
var body: some Scene {
WindowGroup("nxnative Easy") {
Window("nxnative Easy",id:"main") {
ContentView().onAppear {
NSApplication.shared.applicationIconImage=AppArtwork.icon
}
}
.defaultSize(width:820,height:650)
.defaultSize(width:820,height:710)
}
}
@@ -0,0 +1,32 @@
import AppKit
import GameController
@main struct ControllerChecks {
@MainActor static func main() async {
let center=NotificationCenter()
var connected: [GCController]=[]
let monitor=ControllerMonitor(center:center,query:{connected})
precondition(monitor.controllers.isEmpty,"No controller must never be ready")
let supported=GCController.withExtendedGamepad()
precondition(supported.extendedGamepad != nil)
let preconnected=ControllerMonitor(query:{[supported]})
precondition(preconnected.controllers.count == 1 && preconnected.controllers[0].supported,"A controller connected before launch must be discovered")
connected=[supported]
center.post(name:.GCControllerDidConnect,object:supported)
try? await Task.sleep(nanoseconds:30_000_000)
precondition(monitor.controllers.count == 1 && monitor.controllers[0].supported,"Connect must automatically report the recognized gamepad")
connected=[]
center.post(name:.GCControllerDidDisconnect,object:supported)
try? await Task.sleep(nanoseconds:30_000_000)
precondition(monitor.controllers.isEmpty,"Disconnect must clear stale readiness")
connected=[supported]
center.post(name:NSApplication.didBecomeActiveNotification,object:nil)
try? await Task.sleep(nanoseconds:30_000_000)
precondition(monitor.controllers.count == 1,"Returning to the app must refresh device status")
let unsupported=GCController()
connected=[unsupported]
monitor.refresh()
precondition(monitor.controllers.count == 1 && !monitor.controllers[0].supported,"An unsupported profile must not be declared ready")
print("Controller discovery: initial, connect, disconnect, foreground refresh and unsupported profile passed")
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 423 KiB

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 418 KiB

+15
View File
@@ -193,3 +193,18 @@ uses the upstream `--platform mac-app` packer with explicit ad-hoc signing, a un
output path per build and a separate `nxnative-mac.json` output record. It uses the
upstream per-title save location without replacing iOS state. Apple silicon is
required. Successful packaging is not a new claim of gameplay verification.
## Controller readiness and one launcher
`desktop/NxNativeEasy/ControllerMonitor.swift` enumerates `GCController.controllers`
at launch and refreshes on connect, disconnect and foreground notifications. Only
an extended gamepad profile is marked ready. This is local Mac input readiness,
not a claim of a phone connection or a successful game boot. It does not install
input handlers or change pairing, so it cannot take input away from a game.
Notification tests use Apple's snapshot controllers, including unsupported profiles;
they do not claim testing physical Xbox or PlayStation hardware.
The launcher has one SwiftUI Window scene and activates an existing process with
the same bundle ID rather than keeping a duplicate process. The installer and
local opening command now use `~/Applications/nxnative Easy.app` by default.
+1 -1
View File
@@ -1,5 +1,5 @@
#!/bin/bash
set -euo pipefail
nxe_repo=$(cd "$(dirname "$0")/.." && pwd -P)
nxe_output="$(dirname "$nxe_repo")/nxnative-easy/nxnative.app"
nxe_output="$HOME/Applications/nxnative Easy.app"
/usr/bin/python3 "$nxe_repo/scripts/build_desktop_app.py" --output "$nxe_output" --open
+4 -4
View File
@@ -20,17 +20,17 @@ def main():
if output.suffix!='.app':parser.error('Output must end with .app')
contents=output/'Contents';macos=contents/'MacOS';macos.mkdir(parents=True,exist_ok=True)
subprocess.run(['xcrun','swiftc','-parse-as-library','-swift-version','5',
str(REPO/'desktop/NxNativeEasy/main.swift'),str(REPO/'desktop/NxNativeEasy/Localization.swift'),str(REPO/'desktop/NxNativeEasy/GameCatalog.swift'),'-o',str(macos/'NxNativeEasy'),
'-framework','SwiftUI','-framework','AppKit'],check=True)
str(REPO/'desktop/NxNativeEasy/main.swift'),str(REPO/'desktop/NxNativeEasy/Localization.swift'),str(REPO/'desktop/NxNativeEasy/GameCatalog.swift'),str(REPO/'desktop/NxNativeEasy/ControllerMonitor.swift'),'-o',str(macos/'NxNativeEasy'),
'-framework','SwiftUI','-framework','AppKit','-framework','GameController'],check=True)
resources=contents/'Resources';resources.mkdir(exist_ok=True)
shutil.copy2(REPO/'desktop/NxNativeEasy/Assets/AppIcon.png',resources/'BrandIcon.png')
with tempfile.TemporaryDirectory(prefix='nxnative-icon-') as temporary:
iconset=Path(temporary)/'AppIcon.iconset';iconset.mkdir()
subprocess.run(['xcrun','swift',str(REPO/'desktop/NxNativeEasy/Assets/draw_icon.swift'),str(iconset)],check=True)
subprocess.run(['iconutil','-c','icns',str(iconset),'-o',str(resources/'AppIcon.icns')],check=True)
info=dict(CFBundleIconFile='AppIcon', CFBundleIdentifier='com.nalancer08.nxnative.easy',CFBundleName='nxnative',
info=dict(CFBundleIconFile='AppIcon', CFBundleIdentifier='com.nalancer08.nxnative.easy',CFBundleName='nxnative Easy',
CFBundleDisplayName='nxnative Easy',CFBundleExecutable='NxNativeEasy',
CFBundlePackageType='APPL',CFBundleShortVersionString='1.1',CFBundleVersion='2',
CFBundlePackageType='APPL',CFBundleShortVersionString='1.2',CFBundleVersion='3',
LSMinimumSystemVersion='26.0',CFBundleDevelopmentRegion='en',CFBundleLocalizations=['en','es'],NSHighResolutionCapable=True,NxNativeSource=str(REPO))
(contents/'Info.plist').write_bytes(plistlib.dumps(info))
# Explicit ad-hoc signing: '-' never selects an Apple certificate or team.
+18
View File
@@ -0,0 +1,18 @@
"""Exercise live GameController profiles and notification refresh with snapshot devices."""
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
ROOT=Path(__file__).resolve().parents[2]
@unittest.skipUnless(sys.platform=='darwin','requires Apple GameController')
class DesktopController(unittest.TestCase):
def test_controller_lifecycle(self):
with tempfile.TemporaryDirectory() as tmp:
executable=Path(tmp)/'controllers'
subprocess.run(['xcrun','swiftc','-parse-as-library','-swift-version','5',
str(ROOT/'desktop/NxNativeEasy/ControllerMonitor.swift'),
str(ROOT/'desktop/NxNativeEasy/tests/controller_check.swift'),
'-framework','AppKit','-framework','GameController','-o',str(executable)],check=True)
subprocess.run([str(executable)],check=True)