Detect connected controllers automatically and keep a single current launcher
This commit is contained in:
1 parent
49b36b3ff1
commit
df11bd3e44
12 files changed
+151
-8
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 |
@@ -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,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
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user