diff --git a/README-es.md b/README-es.md index dd5f6d6..53f636c 100644 --- a/README-es.md +++ b/README-es.md @@ -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. diff --git a/README.md b/README.md index edd8f5a..3c6755d 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/desktop/NxNativeEasy/ControllerMonitor.swift b/desktop/NxNativeEasy/ControllerMonitor.swift new file mode 100644 index 0000000..4e9792a --- /dev/null +++ b/desktop/NxNativeEasy/ControllerMonitor.swift @@ -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) + } + } +} diff --git a/desktop/NxNativeEasy/Localization.swift b/desktop/NxNativeEasy/Localization.swift index 9d89c4c..202655a 100644 --- a/desktop/NxNativeEasy/Localization.swift +++ b/desktop/NxNativeEasy/Localization.swift @@ -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.", diff --git a/desktop/NxNativeEasy/main.swift b/desktop/NxNativeEasy/main.swift index 68c88c6..6724e23 100644 --- a/desktop/NxNativeEasy/main.swift +++ b/desktop/NxNativeEasy/main.swift @@ -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) } } diff --git a/desktop/NxNativeEasy/tests/controller_check.swift b/desktop/NxNativeEasy/tests/controller_check.swift new file mode 100644 index 0000000..6453986 --- /dev/null +++ b/desktop/NxNativeEasy/tests/controller_check.swift @@ -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") + } +} diff --git a/docs/images/nxnative-easy-es.png b/docs/images/nxnative-easy-es.png index d925011..04db4d3 100644 Binary files a/docs/images/nxnative-easy-es.png and b/docs/images/nxnative-easy-es.png differ diff --git a/docs/images/nxnative-easy.png b/docs/images/nxnative-easy.png index 0df176c..1cef08f 100644 Binary files a/docs/images/nxnative-easy.png and b/docs/images/nxnative-easy.png differ diff --git a/docs/personal-ios.md b/docs/personal-ios.md index 9f3a065..4a5340b 100644 --- a/docs/personal-ios.md +++ b/docs/personal-ios.md @@ -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. diff --git a/scripts/Abrir-interfaz.command b/scripts/Abrir-interfaz.command index a2b6ccc..e32606a 100755 --- a/scripts/Abrir-interfaz.command +++ b/scripts/Abrir-interfaz.command @@ -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 diff --git a/scripts/build_desktop_app.py b/scripts/build_desktop_app.py index d19212e..604885a 100755 --- a/scripts/build_desktop_app.py +++ b/scripts/build_desktop_app.py @@ -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. diff --git a/scripts/tests/test_desktop_controller.py b/scripts/tests/test_desktop_controller.py new file mode 100644 index 0000000..977be44 --- /dev/null +++ b/scripts/tests/test_desktop_controller.py @@ -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)