From b0bc2332ba7c67467decf7cbfa4a8b04eef37b47 Mon Sep 17 00:00:00 2001 From: Johnny D Date: Thu, 17 Sep 2026 21:40:35 -0400 Subject: [PATCH 1/2] Add opt-in automatic Switch 2 controller connection and recovery Keep the native discovery policy listening beyond the manual 60-second window. Persist AutoConnect separately from controller identities, initialize on the main run loop after SDL, and honor explicit Disconnect without polling-based restarts or mapping changes. Pin the matching C facade and add lifecycle, preference and UI wiring regressions. AI-assisted implementation. Local production-host tests pass under Clang and GCC ASan/UBSan; physical macOS controller recovery still requires acceptance. --- .github/workflows/native-switch2kit.yml | 4 +- Docs/Switch2Kit.md | 53 +++++++- Externals/Switch2Kit | 2 +- .../Core/DolphinQt/Config/ControllersPane.cpp | 38 +++++- Source/Core/DolphinQt/Main.cpp | 11 ++ .../ControllerInterface/SDL/Switch2Kit.cpp | 116 +++++++++++++++--- .../ControllerInterface/SDL/Switch2Kit.h | 7 +- Tools/switch2kit/HostStubs.h | 41 ++++++- Tools/switch2kit/HostTest.cpp | 105 ++++++++++++++++ Tools/test_switch2kit_autoconnect.py | 61 +++++++++ 10 files changed, 405 insertions(+), 33 deletions(-) create mode 100644 Tools/test_switch2kit_autoconnect.py diff --git a/.github/workflows/native-switch2kit.yml b/.github/workflows/native-switch2kit.yml index 5d2c5f0ad522..a65b00422895 100644 --- a/.github/workflows/native-switch2kit.yml +++ b/.github/workflows/native-switch2kit.yml @@ -24,6 +24,7 @@ jobs: Source/Core/DolphinQt/Config/Mapping/MappingWindow.cpp \ Source/Core/DolphinQt/Config/Mapping/Switch2KitMapping.cpp - run: python3 Tools/test_switch2kit.py + - run: python3 Tools/test_switch2kit_autoconnect.py - run: python3 Tools/test_switch2kit_mapping.py --sanitize - name: Execute host lifecycle and identity regressions with sanitizers run: python3 Tools/test_switch2kit_host.py --sanitize @@ -76,8 +77,9 @@ jobs: run: brew install cmake ninja nasm automake libtool qt@6 - name: Check source pin and integration run: | - test "$(git -C Externals/Switch2Kit rev-parse HEAD)" = 8088ce3ef6845fe90d8ff7a579e6498668bd65de + test "$(git -C Externals/Switch2Kit rev-parse HEAD)" = ef26a17eb018ab6db6c944c33153ced332e2de91 python3 Tools/test_switch2kit.py + python3 Tools/test_switch2kit_autoconnect.py python3 Tools/test_switch2kit_host.py --sanitize python3 Tools/test_switch2kit_mapping.py --sanitize - name: Build the complete application diff --git a/Docs/Switch2Kit.md b/Docs/Switch2Kit.md index e5d5305ca311..3d064f691e35 100644 --- a/Docs/Switch2Kit.md +++ b/Docs/Switch2Kit.md @@ -33,9 +33,42 @@ clicks. The Pro preset matches printed Nintendo A/B/X/Y labels; + is Start, R is GameCube Z, and ZL/ZR provide on/off GameCube L/R. Pro triggers cannot produce GameCube-style variable squeeze. -Open the game normally. Port bindings use Dolphin's existing settings. Use Find -again after restarting Dolphin; merely launching the app does not scan or request -Bluetooth permission. This is not wired GameCube USB-adapter mode. +Open the game normally. Port bindings use Dolphin's existing settings. +This is not wired GameCube USB-adapter mode. + +### Automatic connection and recovery + +Enable **Automatically connect Switch 2 controllers** in Controller Settings. +This starts listening now and saves your choice for future Dolphin launches. After +initial pairing, turn the controller on again after a long pause: Dolphin can +rediscover it without reopening settings or pressing Find. Discovery runs on the +SDK's Bluetooth queue, independently of emulation pause and the settings window. +There is no repeating Find timer or 60-second limit in automatic mode. + +The option is off by default. With it off, Find still searches for 60 seconds, +and merely launching Dolphin does not start Bluetooth or request permission. +With it on, Dolphin starts once on the main run loop after SDL initialization. +The status distinguishes continuous listening from a finite manual search. + +**Disconnect Switch 2 Controllers** stops input and discovery for the current +session, even with this option checked. Polling, returning to the app, resuming a +game or reopening settings cannot undo that explicit stop. Use Find or re-enable +the option to resume; the saved option still applies on the next app launch. +Unchecking the option stops automatic discovery without disconnecting ready +controllers. A connection handshake already admitted may finish. + +Automatic discovery connects available **supported** controllers, not arbitrary +Bluetooth devices and not only a saved allowlist. Close competing controller apps. +It does not overwrite custom mappings or assign a new controller to a game port. +The transport retains its capacity limits, serialized handshakes, duplicate-filtered +scans and bounded retry behavior; continuous listening still uses Bluetooth radio +resources. It cannot wake a powered-off controller or bypass initial pairing or +Bluetooth permission. + +The choice is stored as `[Settings] AutoConnect` in `Switch2Kit.ini`, alongside +but separate from physical identities. Failed reads/saves do not overwrite existing +settings or change the runtime choice. A saved choice whose start failed remains +visible; use Find to retry after resolving the reported problem. The controller's physical identity is assigned a persistent Dolphin device number in `Switch2Kit.ini` in Dolphin's user configuration directory. Reconnecting two @@ -49,7 +82,7 @@ selected device. No controller identifiers are written to diagnostic messages. The backend is OFF by default. Disabled builds do not require Swift and retain Dolphin's existing macOS deployment target and other platforms. Enabled builds require macOS 15+, Xcode 26+ with Swift 6.2+, and Dolphin's normal build dependencies. -The submodule pins Switch2Kit to `8088ce3ef6845fe90d8ff7a579e6498668bd65de`. +The submodule pins Switch2Kit to `ef26a17eb018ab6db6c944c33153ced332e2de91`. ```sh git submodule update --init --recursive @@ -90,6 +123,10 @@ main run loop. The linked Swift library remains resident for process lifetime. `python3 Tools/test_switch2kit_mapping.py --sanitize` executes the production mapping helper against test-only UI/configuration boundaries, including cancellation and backup failures. These boundaries do not replace the code in application builds. +`python3 Tools/test_switch2kit_host.py --sanitize` exercises the production host +wrapper against test-only SDK/SDL/file boundaries, including automatic policy, +saved consent, failed configuration, stable IDs, start-once and explicit stop. +`python3 Tools/test_switch2kit_autoconnect.py` checks guarded UI/startup wiring. The native workflow compiles the complete application and inspects the embedded library and Bluetooth description. These checks do not establish physical controller behavior, clean-Mac startup, latency, or gameplay acceptance. @@ -99,7 +136,13 @@ and physical rumble still need acceptance on a real NSO GameCube controller: pair/permission refusal and retry; every button and axis; light L/R travel without a click and independent full clicks; disconnect while holding input; reconnect; two identical controllers; app restart; normal quit; motor start/stop; and an actual game session. -Retain the distinction between build success and these hardware results. +Automatic recovery additionally needs a real Mac/controller test: play, pause, +leave the controller off longer than 60 seconds, resume, and power it on without +opening settings. Repeat several cycles, test Bluetooth off/on and two controllers +returning in reverse order, then verify Disconnect stays stopped and disabling +auto-connect preserves a live controller. These hardware checks have not been +performed for this change. Retain the distinction between build success and these +hardware results. This contribution was prepared with AI assistance. The build glue, host wrapper, UI integration, profile, documentation and checks require human review. The diff --git a/Externals/Switch2Kit b/Externals/Switch2Kit index 8088ce3ef684..ef26a17eb018 160000 --- a/Externals/Switch2Kit +++ b/Externals/Switch2Kit @@ -1 +1 @@ -Subproject commit 8088ce3ef6845fe90d8ff7a579e6498668bd65de +Subproject commit ef26a17eb018ab6db6c944c33153ced332e2de91 diff --git a/Source/Core/DolphinQt/Config/ControllersPane.cpp b/Source/Core/DolphinQt/Config/ControllersPane.cpp index 003f3f418341..18e025621ccf 100644 --- a/Source/Core/DolphinQt/Config/ControllersPane.cpp +++ b/Source/Core/DolphinQt/Config/ControllersPane.cpp @@ -6,10 +6,12 @@ #include #ifdef HAVE_SWITCH2KIT +#include #include #include #include #include +#include #include #include "InputCommon/ControllerInterface/SDL/Switch2Kit.h" @@ -42,6 +44,13 @@ void ControllersPane::CreateMainLayout() actions->addWidget(find); actions->addWidget(stop); layout->addLayout(actions); + auto* const auto_connect = new QCheckBox(tr("Automatically connect Switch 2 controllers"), this); + auto_connect->setToolTip( + tr("Listen for available supported controllers while Dolphin is open, including after a " + "controller powers off. This uses Bluetooth and starts automatically on future launches. " + "Disconnect stops it until you use Find or restart Dolphin. Mappings are not changed.")); + auto_connect->setChecked(ciface::SDL::GetSwitch2KitStatus().auto_connect); + layout->addWidget(auto_connect); auto* const status = new QLabel(this); status->setWordWrap(true); layout->addWidget(status); @@ -62,16 +71,28 @@ void ControllersPane::CreateMainLayout() .arg(result)); }); connect(stop, &QPushButton::clicked, this, [] { ciface::SDL::StopSwitch2Controllers(); }); - const auto update_status = [status, find, stop] { + connect(auto_connect, &QCheckBox::toggled, this, [this, auto_connect](bool enabled) { + const int result = ciface::SDL::SetSwitch2KitAutoConnect(enabled); + const QSignalBlocker blocker(auto_connect); + auto_connect->setChecked(ciface::SDL::GetSwitch2KitStatus().auto_connect); + if (result != 0) + QMessageBox::warning(this, tr("Switch 2 Controllers"), + tr("The automatic connection setting could not be saved or applied " + "(error %1). Check configuration access and Bluetooth permission. " + "The checkbox shows the saved choice; use Find to retry connection.") + .arg(result)); + }); + const auto update_status = [status, find, stop, auto_connect] { const auto state = ciface::SDL::GetSwitch2KitStatus(); find->setEnabled(state.available && !state.stopping); stop->setEnabled(state.running && !state.stopping); + auto_connect->setEnabled(state.available && !state.stopping); + const QSignalBlocker blocker(auto_connect); + auto_connect->setChecked(state.auto_connect); if (!state.available) status->setText(tr("SDL controller input is unavailable.")); else if (state.stopping) status->setText(tr("Disconnecting controllers...")); - else if (!state.running) - status->setText(tr("Switch 2 controller support is stopped.")); else if (state.bluetooth == ciface::SDL::Switch2KitBluetooth::Unauthorized) status->setText(tr("Allow Dolphin in System Settings > Privacy & Security > Bluetooth.")); else if (state.bluetooth == ciface::SDL::Switch2KitBluetooth::Off) @@ -79,10 +100,19 @@ void ControllersPane::CreateMainLayout() else if (state.bluetooth == ciface::SDL::Switch2KitBluetooth::Unsupported) status->setText(tr("Bluetooth is not supported on this Mac.")); else if (state.error != 0) - status->setText(tr("Controller input error %1. Try discovery again.").arg(state.error)); + status->setText(tr("Controller input error %1. Use Find to retry.").arg(state.error)); + else if (!state.running) + status->setText(tr("Switch 2 controller support is stopped. Use Find to resume.")); + else if (state.scanning && state.auto_connect) + status->setText(tr("Listening for Switch 2 controllers. Turn it on to reconnect; " + "hold Sync for initial pairing. Connected: %1.") + .arg(state.controllers)); else if (state.scanning) status->setText( tr("Searching for 60 seconds: hold Sync. Connected: %1.").arg(state.controllers)); + else if (state.auto_connect) + status->setText(tr("Automatic connection is enabled. Connected Switch 2 controllers: %1.") + .arg(state.controllers)); else status->setText(tr("Connected Switch 2 controllers: %1. Use Find to add another.") .arg(state.controllers)); diff --git a/Source/Core/DolphinQt/Main.cpp b/Source/Core/DolphinQt/Main.cpp index 7f8f9b9d048b..0a5c8d9aad2d 100644 --- a/Source/Core/DolphinQt/Main.cpp +++ b/Source/Core/DolphinQt/Main.cpp @@ -20,6 +20,11 @@ #include #include +#ifdef HAVE_SWITCH2KIT +#include +#include "InputCommon/ControllerInterface/SDL/Switch2Kit.h" +#endif + #include "Common/Config/Config.h" #include "Common/MsgHandler.h" #include "Common/ScopeGuard.h" @@ -278,6 +283,12 @@ int main(int argc, char* argv[]) MainWindow win{Core::System::GetInstance(), std::move(boot), static_cast(options.get("movie"))}; +#ifdef HAVE_SWITCH2KIT + // MainWindow has initialized SDL; create Bluetooth support on the main run loop. + // The saved opt-in is consumed once and explicit Disconnect cancels this attempt. + QTimer::singleShot(0, &win, [] { ciface::SDL::StartSwitch2KitAutoConnect(); }); +#endif + #if defined(USE_ANALYTICS) && USE_ANALYTICS if (!Config::Get(Config::MAIN_ANALYTICS_PERMISSION_ASKED)) { diff --git a/Source/Core/InputCommon/ControllerInterface/SDL/Switch2Kit.cpp b/Source/Core/InputCommon/ControllerInterface/SDL/Switch2Kit.cpp index 648d99175c1f..b31ccc450449 100644 --- a/Source/Core/InputCommon/ControllerInterface/SDL/Switch2Kit.cpp +++ b/Source/Core/InputCommon/ControllerInterface/SDL/Switch2Kit.cpp @@ -25,6 +25,9 @@ std::unique_ptr s_adapter; S2KContext* s_context = nullptr; bool s_available = false; bool s_started = false; +bool s_auto_connect = false; +bool s_auto_start_pending = false; +S2KResult s_action_error = S2K_OK; S2KResult s_error = S2K_OK; // SDL callbacks run with this lock held. Always take it before our own mutex. @@ -46,33 +49,107 @@ std::string PhysicalKey(const S2KID& id) } return key; } +// Both helpers run outside the SDL/adapter lock and share the identity-file lock. +bool LoadAutoConnect() +{ + const std::lock_guard settings_lock(s_settings_mutex); + const auto path = File::GetUserPath(D_CONFIG_IDX) + "Switch2Kit.ini"; + Common::IniFile ini; + if (!ini.Load(path)) + return false; + bool enabled = false; + ini.GetOrCreateSection("Settings")->Get("AutoConnect", &enabled, false); + return enabled; +} + +bool SaveAutoConnect(bool enabled) +{ + const std::lock_guard settings_lock(s_settings_mutex); + const auto path = File::GetUserPath(D_CONFIG_IDX) + "Switch2Kit.ini"; + Common::IniFile ini; + if (File::Exists(path) && !ini.Load(path)) + return false; + ini.GetOrCreateSection("Settings")->Set("AutoConnect", enabled); + return ini.Save(path); +} + +// Caller holds s_mutex and is on the main thread. Never called from input polling. +int StartSwitch2KitLocked() +{ + if (!s_available) + return s_action_error = S2K_NOT_READY; + // Creation/permission presentation belongs to the main run loop, even when + // startup was explicitly authorized by a preference from a previous launch. + if (!s_context) + s_context = s2k_create(nullptr, &s_action_error); + if (!s_context) + return s_action_error; + s_action_error = s2k_set_automatic_discovery(s_context, s_auto_connect ? 1 : 0); + if (s_action_error != S2K_OK) + return s_action_error; + s_action_error = s2k_start(s_context); + if (s_action_error == S2K_OK) + { + s_started = true; + if (!s_auto_connect) + s_action_error = s2k_discover(s_context, 60.0); + } + return s_action_error; +} } // namespace void InitializeSwitch2Kit() { + const bool auto_connect = LoadAutoConnect(); const std::lock_guard lock(s_mutex); s_available = true; + s_auto_connect = auto_connect; + s_auto_start_pending = auto_connect; + s_action_error = S2K_OK; s_error = S2K_OK; } int FindSwitch2Controllers() { const std::lock_guard lock(s_mutex); - if (!s_available) - return S2K_NOT_READY; - // Creation is deliberately deferred to the main-thread GUI action. Creating - // the SDL backend on a worker thread must never create a Bluetooth manager. - if (!s_context) - s_context = s2k_create(nullptr, &s_error); - if (!s_context) - return s_error; - s_error = s2k_start(s_context); - if (s_error == S2K_OK) + s_auto_start_pending = false; + return StartSwitch2KitLocked(); +} + +int StartSwitch2KitAutoConnect() +{ + const std::lock_guard lock(s_mutex); + if (!s_auto_start_pending) + return S2K_OK; + s_auto_start_pending = false; + if (!s_auto_connect || s_started) + return S2K_OK; + // One startup attempt only. A failure remains visible and Find can retry it. + return StartSwitch2KitLocked(); +} + +int SetSwitch2KitAutoConnect(bool enabled) +{ { - s_started = true; - s_error = s2k_discover(s_context, 60.0); + const std::lock_guard lock(s_mutex); + if (!s_available) + return S2K_NOT_READY; } - return s_error; + // Preserve every existing section and fail without changing the preference + // or radio policy if configuration cannot be read/saved. + if (!SaveAutoConnect(enabled)) + { + const std::lock_guard lock(s_mutex); + return s_action_error = S2K_INTERNAL_ERROR; + } + const std::lock_guard lock(s_mutex); + s_auto_connect = enabled; + s_auto_start_pending = false; + if (enabled) + return StartSwitch2KitLocked(); + // Disabling discovery retains ready controllers and their current mappings. + s_action_error = s_context && s_started ? s2k_set_automatic_discovery(s_context, 0) : S2K_OK; + return s_action_error; } void UpdateSwitch2Kit() @@ -93,11 +170,14 @@ void StopSwitch2Controllers() const JoystickLock joystick_lock; const std::lock_guard lock(s_mutex); // Keep subsequent input polls from recreating the adapter while stopped or - // while asynchronous Bluetooth teardown is still completing. Only Find starts it. + // while asynchronous Bluetooth teardown is still completing. Explicit Find or + // enabling Auto-connect can restart it; a deferred startup callback cannot. + s_auto_start_pending = false; s_started = false; s_adapter.reset(); + s_error = S2K_OK; if (s_context) - s_error = s2k_stop(s_context); + s_action_error = s2k_stop(s_context); // Stop is asynchronous. Never block the main run loop waiting for teardown. } @@ -107,6 +187,9 @@ void ShutdownSwitch2Kit() const std::lock_guard lock(s_mutex); s_available = false; s_started = false; + s_auto_start_pending = false; + s_auto_connect = false; + s_action_error = S2K_OK; // Destroy SDL devices before their borrowed C context and before SDL_Quit. s_adapter.reset(); if (s_context) @@ -123,7 +206,8 @@ Switch2KitStatus GetSwitch2KitStatus() const std::lock_guard lock(s_mutex); Switch2KitStatus status; status.available = s_available; - status.error = s_error; + status.auto_connect = s_auto_connect; + status.error = s_action_error != S2K_OK ? s_action_error : s_error; if (!s_context) return status; S2KSnapshot snapshot{}; diff --git a/Source/Core/InputCommon/ControllerInterface/SDL/Switch2Kit.h b/Source/Core/InputCommon/ControllerInterface/SDL/Switch2Kit.h index 7152b2cd074c..1a40bfbe09e6 100644 --- a/Source/Core/InputCommon/ControllerInterface/SDL/Switch2Kit.h +++ b/Source/Core/InputCommon/ControllerInterface/SDL/Switch2Kit.h @@ -24,6 +24,7 @@ struct Switch2KitStatus bool running = false; bool scanning = false; bool stopping = false; + bool auto_connect = false; std::uint32_t controllers = 0; Switch2KitBluetooth bluetooth = Switch2KitBluetooth::Unknown; int error = 0; @@ -35,8 +36,12 @@ void InitializeSwitch2Kit(); void ShutdownSwitch2Kit(); void UpdateSwitch2Kit(); -// Find must be called on the macOS main thread with its run loop running. +// These three actions must run on the macOS main thread with its run loop running. int FindSwitch2Controllers(); +// Consume the saved opt-in once after backend initialization; never a polling task. +int StartSwitch2KitAutoConnect(); +// Save first, then apply; disabling retains ready connections. Returns an SDK error. +int SetSwitch2KitAutoConnect(bool enabled); void StopSwitch2Controllers(); Switch2KitStatus GetSwitch2KitStatus(); std::optional GetSwitch2KitPreferredId(std::uint32_t instance); diff --git a/Tools/switch2kit/HostStubs.h b/Tools/switch2kit/HostStubs.h index 96d5bc031a1b..ecafebf9f2a9 100644 --- a/Tools/switch2kit/HostStubs.h +++ b/Tools/switch2kit/HostStubs.h @@ -13,7 +13,7 @@ #include using S2KResult = int; -constexpr int S2K_OK = 0, S2K_BUSY = 5, S2K_NOT_READY = 6; +constexpr int S2K_OK = 0, S2K_BUSY = 5, S2K_NOT_READY = 6, S2K_INTERNAL_ERROR = 13; constexpr int S2K_BT_RESETTING = 1, S2K_BT_UNSUPPORTED = 2, S2K_BT_UNAUTHORIZED = 3; constexpr int S2K_BT_OFF = 4, S2K_BT_ON = 5, S2K_DISCOVERY_SCANNING = 1; constexpr unsigned S2K_MAX_CONTROLLERS = 64; @@ -23,7 +23,7 @@ struct S2KSnapshot { unsigned running{}, stopping{}, discovery{}, count{}, bluetooth{}; }; -struct S2KContext { S2KSnapshot state; }; +struct S2KContext { S2KSnapshot state; bool automatic = false; }; namespace Fake { @@ -31,6 +31,9 @@ inline std::recursive_mutex joystick_mutex; inline thread_local int joystick_depth = 0; inline std::atomic created = 0, destroyed = 0, adapters = 0, constructions = 0, pumps = 0; inline S2KResult create_error = 0, start_error = 0, read_error = 0, pump_error = 0; +inline S2KResult configure_error = 0; +inline int starts = 0, discoveries = 0, configurations = 0, loads = 0; +inline std::map settings; inline S2KContext* context = nullptr; inline const auto main_thread = std::this_thread::get_id(); inline bool exists = false, readable = true, writable = true; @@ -50,13 +53,29 @@ inline S2KContext* s2k_create(const void*, S2KResult* result) } inline int s2k_start(S2KContext* context) { + ++Fake::starts; if (Fake::start_error) return Fake::start_error; if (context->state.stopping) return S2K_BUSY; context->state.running = true; + if (context->automatic) context->state.discovery = S2K_DISCOVERY_SCANNING; + return S2K_OK; +} +inline int s2k_set_automatic_discovery(S2KContext* context, unsigned enabled) +{ + assert(enabled <= 1); + ++Fake::configurations; + if (Fake::configure_error) return Fake::configure_error; + if (context->state.stopping) return S2K_BUSY; + if (context->automatic != (enabled != 0)) + { + context->automatic = enabled != 0; + context->state.discovery = context->state.running && enabled ? S2K_DISCOVERY_SCANNING : 0; + } return S2K_OK; } inline int s2k_discover(S2KContext* context, double seconds) { + ++Fake::discoveries; assert(seconds == 60.0); context->state.discovery = S2K_DISCOVERY_SCANNING; return S2K_OK; @@ -134,20 +153,32 @@ class IniFile std::map values; void Get(const std::string& key, std::string* value) { *value = values[key]; } void Set(const std::string& key, const std::string& value) { values[key] = value; } - } section; + void Get(const std::string& key, bool* value, bool fallback) + { + const auto it = values.find(key); + *value = it == values.end() ? fallback : it->second == "True"; + } + void Set(const std::string& key, bool value) { values[key] = value ? "True" : "False"; } + } section, settings; bool Load(const std::string&) { assert(Fake::joystick_depth == 0); // Never perform config I/O under SDL's lock. + ++Fake::loads; section.values = Fake::saved; - return Fake::readable; + settings.values = Fake::settings; + return Fake::exists && Fake::readable; + } + Section* GetOrCreateSection(const char* name) + { + return std::string(name) == "Settings" ? &settings : §ion; } - Section* GetOrCreateSection(const char*) { return §ion; } bool Save(const std::string&) { assert(Fake::joystick_depth == 0); ++Fake::saves; if (!Fake::writable) return false; Fake::saved = section.values; + Fake::settings = settings.values; Fake::exists = true; return true; } diff --git a/Tools/switch2kit/HostTest.cpp b/Tools/switch2kit/HostTest.cpp index 898533bdd7cb..5349b5535c62 100644 --- a/Tools/switch2kit/HostTest.cpp +++ b/Tools/switch2kit/HostTest.cpp @@ -117,4 +117,109 @@ int main() ShutdownSwitch2Kit(); assert(Fake::created == 2 && Fake::destroyed == 2 && Fake::adapters == 0); std::cout << "PASS: concurrent poll/status/enumeration/stop/shutdown and reinitialization\n"; + + // Auto-connect is a saved opt-in, not a repeated Find operation. + Fake::saved.clear(); + Fake::settings.clear(); + Fake::exists = false; + InitializeSwitch2Kit(); + assert(!GetSwitch2KitStatus().auto_connect); + assert(StartSwitch2KitAutoConnect() == S2K_OK); + assert(Fake::context == nullptr); + const auto manual_windows = Fake::discoveries; + assert(SetSwitch2KitAutoConnect(true) == S2K_OK); + assert(Fake::settings["AutoConnect"] == "True"); + assert(Fake::context->automatic && GetSwitch2KitStatus().auto_connect); + UpdateSwitch2Kit(); + const auto starts = Fake::starts, configurations = Fake::configurations; + const auto setting_saves = Fake::saves, setting_loads = Fake::loads; + for (int i = 0; i != 1000; ++i) + { + UpdateSwitch2Kit(); + (void)GetSwitch2KitStatus(); + assert(StartSwitch2KitAutoConnect() == S2K_OK); + } + assert(Fake::starts == starts && Fake::configurations == configurations); + assert(Fake::discoveries == manual_windows); + assert(Fake::saves == setting_saves && Fake::loads == setting_loads); + assert(GetSwitch2KitPreferredId(40) == 0); + const auto identities = Fake::saved; + assert(Fake::settings["AutoConnect"] == "True"); + const auto adapter_count = Fake::constructions.load(); + assert(SetSwitch2KitAutoConnect(false) == S2K_OK); + assert(!Fake::context->automatic && GetSwitch2KitStatus().running); + assert(Fake::adapters == 1 && Fake::constructions == adapter_count); + assert(Fake::saved == identities && Fake::settings["AutoConnect"] == "False"); + std::cout << "PASS: opt-in uses continuous policy without window renewal, remapping or per-poll I/O\n"; + + Fake::readable = false; + assert(SetSwitch2KitAutoConnect(true) == S2K_INTERNAL_ERROR); + assert(!GetSwitch2KitStatus().auto_connect && !Fake::context->automatic); + Fake::readable = true; + Fake::writable = false; + assert(SetSwitch2KitAutoConnect(true) == S2K_INTERNAL_ERROR); + assert(!GetSwitch2KitStatus().auto_connect && Fake::saved == identities); + Fake::writable = true; + Fake::configure_error = S2K_BUSY; + assert(SetSwitch2KitAutoConnect(true) == S2K_BUSY); + UpdateSwitch2Kit(); + assert(GetSwitch2KitStatus().error == S2K_BUSY); // Input cannot hide a failed policy change. + Fake::configure_error = 0; + assert(FindSwitch2Controllers() == S2K_OK); + assert(Fake::context->automatic && GetSwitch2KitStatus().error == S2K_OK); + StopSwitch2Controllers(); + Fake::context->state.stopping = false; + const auto stopped_starts = Fake::starts; + for (int i = 0; i != 100; ++i) + { + UpdateSwitch2Kit(); + assert(StartSwitch2KitAutoConnect() == S2K_OK); + } + assert(Fake::starts == stopped_starts && Fake::adapters == 0); + assert(!GetSwitch2KitStatus().running && GetSwitch2KitStatus().auto_connect); + assert(FindSwitch2Controllers() == S2K_OK); + assert(Fake::context->automatic && Fake::discoveries == manual_windows); + ShutdownSwitch2Kit(); + std::cout << "PASS: failed settings/policy changes are visible; manual Disconnect beats auto-connect\n"; + + const auto before_relaunch = Fake::created.load(); + InitializeSwitch2Kit(); + UpdateSwitch2Kit(); + assert(Fake::created == before_relaunch && GetSwitch2KitStatus().auto_connect); + assert(StartSwitch2KitAutoConnect() == S2K_OK); + assert(Fake::created == before_relaunch + 1 && Fake::context->automatic); + ShutdownSwitch2Kit(); + InitializeSwitch2Kit(); + StopSwitch2Controllers(); // A deferred startup callback must not undo an explicit stop. + assert(StartSwitch2KitAutoConnect() == S2K_OK); + assert(Fake::context == nullptr); + ShutdownSwitch2Kit(); + InitializeSwitch2Kit(); + Fake::create_error = 4; + assert(StartSwitch2KitAutoConnect() == 4); + assert(StartSwitch2KitAutoConnect() == S2K_OK); + assert(GetSwitch2KitStatus().error == 4 && Fake::context == nullptr); + Fake::create_error = 0; + Fake::start_error = S2K_BUSY; + assert(FindSwitch2Controllers() == S2K_BUSY); + UpdateSwitch2Kit(); + assert(Fake::adapters == 0); + Fake::start_error = 0; + assert(FindSwitch2Controllers() == S2K_OK); + assert(Fake::context->automatic); + ShutdownSwitch2Kit(); + Fake::readable = false; + InitializeSwitch2Kit(); + assert(!GetSwitch2KitStatus().auto_connect); + assert(StartSwitch2KitAutoConnect() == S2K_OK && Fake::context == nullptr); + ShutdownSwitch2Kit(); + Fake::readable = true; + Fake::settings["AutoConnect"] = "invalid"; + InitializeSwitch2Kit(); + assert(!GetSwitch2KitStatus().auto_connect); + assert(StartSwitch2KitAutoConnect() == S2K_OK && Fake::context == nullptr); + ShutdownSwitch2Kit(); + assert(Fake::adapters == 0 && Fake::created == Fake::destroyed); + std::cout << "PASS: saved consent, main-thread lazy startup, one-shot failures and stop-before-start\n"; + } diff --git a/Tools/test_switch2kit_autoconnect.py b/Tools/test_switch2kit_autoconnect.py new file mode 100644 index 000000000000..45e9813977b9 --- /dev/null +++ b/Tools/test_switch2kit_autoconnect.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +# Copyright 2026 Dolphin Emulator Project +# SPDX-License-Identifier: GPL-2.0-or-later +"""Static UI/startup guards; execute test_switch2kit_host.py for host behavior.""" +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +class AutomaticConnectionWiringTests(unittest.TestCase): + def read(self, path): + return (ROOT / path).read_text() + + def test_startup_is_guarded_once_and_after_window_initialization(self): + main = self.read("Source/Core/DolphinQt/Main.cpp") + hook = "QTimer::singleShot(0, &win, [] { ciface::SDL::StartSwitch2KitAutoConnect(); });" + self.assertEqual(main.count(hook), 1) + self.assertLess(main.index("MainWindow win{"), main.index(hook)) + self.assertLess(main.index(hook), main.index("retval = app.exec();")) + guarded = main.rsplit("#ifdef HAVE_SWITCH2KIT", 1)[1].split("#endif", 1)[0] + self.assertIn(hook, guarded) + self.assertIn('#ifdef HAVE_SWITCH2KIT\n#include ', main) + + def test_checkbox_updates_do_not_start_or_remap_controllers(self): + pane = self.read("Source/Core/DolphinQt/Config/ControllersPane.cpp") + self.assertIn('tr("Automatically connect Switch 2 controllers")', pane) + self.assertIn("&QCheckBox::toggled", pane) + self.assertIn("SetSwitch2KitAutoConnect(enabled)", pane) + self.assertNotIn("StartSwitch2KitAutoConnect", pane) + update = pane.split("const auto update_status =", 1)[1].split("auto* const timer", 1)[0] + self.assertIn("QSignalBlocker", update) + self.assertIn("state.scanning && state.auto_connect", update) + for action in ("FindSwitch2Controllers", "SetSwitch2KitAutoConnect", "Mapping::Apply"): + self.assertNotIn(action, update) + + def test_polling_and_initialization_never_start_bluetooth(self): + native = self.read("Source/Core/InputCommon/ControllerInterface/SDL/Switch2Kit.cpp") + initialize = native.split("void InitializeSwitch2Kit()", 1)[1].split("int Find", 1)[0] + polling = native.split("void UpdateSwitch2Kit()", 1)[1].split("void Stop", 1)[0] + for body in (initialize, polling): + for forbidden in ("s2k_create(", "s2k_start(", "s2k_discover(", "StartSwitch2KitLocked("): + self.assertNotIn(forbidden, body) + self.assertNotIn("SaveAutoConnect", polling) + self.assertNotIn("LoadAutoConnect", polling) + stop = native.split("void StopSwitch2Controllers()", 1)[1].split("void Shutdown", 1)[0] + self.assertIn("s_auto_start_pending = false;", stop) + self.assertIn("s_started = false;", stop) + + def test_ci_retains_regressions_and_documented_sdk_pin(self): + workflow = self.read(".github/workflows/native-switch2kit.yml") + for script in ("test_switch2kit.py", "test_switch2kit_host.py --sanitize", + "test_switch2kit_mapping.py --sanitize", "test_switch2kit_autoconnect.py"): + self.assertIn(script, workflow) + sdk_pin = workflow.split('rev-parse HEAD)" = ', 1)[1].splitlines()[0] + self.assertEqual(len(sdk_pin), 40) + self.assertIn(f"`{sdk_pin}`", self.read("Docs/Switch2Kit.md")) + + +if __name__ == "__main__": + unittest.main(verbosity=2) From 30c86e60dea8c521c2bddd8f3140971c8684c9d6 Mon Sep 17 00:00:00 2001 From: Johnny D Date: Thu, 17 Sep 2026 21:59:48 -0400 Subject: [PATCH 2/2] Pin the SDK correction for automatic-discovery native fixture builds Advance Externals/Switch2Kit to a9d43b1f63d94f8844755a510bf6ecc876bc8c51 and update the documentation and CI-enforced pin together. The SDK fixes SDLTestSource protocol conformance and compiles the native fixture in ordinary Swift tests. Preserve all application behavior and validation gates. --- .github/workflows/native-switch2kit.yml | 2 +- Docs/Switch2Kit.md | 2 +- Externals/Switch2Kit | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/native-switch2kit.yml b/.github/workflows/native-switch2kit.yml index a65b00422895..75fb27e17350 100644 --- a/.github/workflows/native-switch2kit.yml +++ b/.github/workflows/native-switch2kit.yml @@ -77,7 +77,7 @@ jobs: run: brew install cmake ninja nasm automake libtool qt@6 - name: Check source pin and integration run: | - test "$(git -C Externals/Switch2Kit rev-parse HEAD)" = ef26a17eb018ab6db6c944c33153ced332e2de91 + test "$(git -C Externals/Switch2Kit rev-parse HEAD)" = a9d43b1f63d94f8844755a510bf6ecc876bc8c51 python3 Tools/test_switch2kit.py python3 Tools/test_switch2kit_autoconnect.py python3 Tools/test_switch2kit_host.py --sanitize diff --git a/Docs/Switch2Kit.md b/Docs/Switch2Kit.md index 3d064f691e35..55e11d8bd598 100644 --- a/Docs/Switch2Kit.md +++ b/Docs/Switch2Kit.md @@ -82,7 +82,7 @@ selected device. No controller identifiers are written to diagnostic messages. The backend is OFF by default. Disabled builds do not require Swift and retain Dolphin's existing macOS deployment target and other platforms. Enabled builds require macOS 15+, Xcode 26+ with Swift 6.2+, and Dolphin's normal build dependencies. -The submodule pins Switch2Kit to `ef26a17eb018ab6db6c944c33153ced332e2de91`. +The submodule pins Switch2Kit to `a9d43b1f63d94f8844755a510bf6ecc876bc8c51`. ```sh git submodule update --init --recursive diff --git a/Externals/Switch2Kit b/Externals/Switch2Kit index ef26a17eb018..a9d43b1f63d9 160000 --- a/Externals/Switch2Kit +++ b/Externals/Switch2Kit @@ -1 +1 @@ -Subproject commit ef26a17eb018ab6db6c944c33153ced332e2de91 +Subproject commit a9d43b1f63d94f8844755a510bf6ecc876bc8c51