From 332c975c3e4c1d5ebfb38cd69f61ead9414edbea Mon Sep 17 00:00:00 2001 From: Johnny D Date: Fri, 18 Sep 2026 13:21:15 -0400 Subject: [PATCH 1/4] Add opt-in persistent Switch2Kit automatic connection Start continuous discovery once on the main run loop after saved consent. Keep explicit Disconnect authoritative, preserve live input on opt-out, and retain controller identities and mappings. Persist the setting atomically without replacing malformed configuration; keep action errors visible. Pin the matching SDK SDLHost adapter, add lifecycle/configuration regressions and integration guards, retain native build gates, and document acceptance limits. --- .github/workflows/native-switch2kit.yml | 4 +- README.md | 8 +- dependencies/Switch2Kit | 2 +- docs/Switch2Kit.md | 93 ++++++- src/gui/wxgui/input/InputSettings2.cpp | 26 +- src/input/api/SDL/SDLControllerProvider.cpp | 49 +++- src/input/api/SDL/SDLControllerProvider.h | 2 + .../api/SDL/Switch2KitAutoConnectConfig.h | 95 +++++++ src/input/api/SDL/Switch2KitSession.h | 92 ++++++- tests/switch2kit/AutoConnectTests.cpp | 245 ++++++++++++++++++ tests/switch2kit/PolicyTests.cpp | 2 + tests/switch2kit/run.py | 6 + tests/switch2kit/test_autoconnect.py | 39 +++ tests/switch2kit/test_autoconnect_wiring.py | 102 ++++++++ 14 files changed, 730 insertions(+), 35 deletions(-) create mode 100644 src/input/api/SDL/Switch2KitAutoConnectConfig.h create mode 100644 tests/switch2kit/AutoConnectTests.cpp create mode 100644 tests/switch2kit/test_autoconnect.py create mode 100644 tests/switch2kit/test_autoconnect_wiring.py diff --git a/.github/workflows/native-switch2kit.yml b/.github/workflows/native-switch2kit.yml index c00447e411..5bf63ca96c 100644 --- a/.github/workflows/native-switch2kit.yml +++ b/.github/workflows/native-switch2kit.yml @@ -21,7 +21,9 @@ jobs: ref: f87239e71e42da91ca317a12eefb82cfbf3393eb path: test-sdl persist-credentials: false - - name: Execute mapping, identity, setup rollback and session regressions + - name: Install headers for production INI policy tests + run: sudo apt-get update && sudo apt-get install -y libboost-dev + - name: Execute mapping, identity, setup rollback, session and autoconnect regressions run: python3 tests/switch2kit/run.py --sdl test-sdl --sanitize macos: if: github.repository == 'jmonster/Cemu' diff --git a/README.md b/README.md index ee4b87facb..f85a407469 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,13 @@ These are development builds, not notarized releases. For an unverified-develope ### Connect and play -1. Turn on your Mac's Bluetooth and close other apps managing the controller, including the Switch2Kit dashboard or Dolphin. In Cemu, open **Options > Input settings**, click **Find Switch 2 Controllers**, allow Bluetooth access, and hold the controller's **Sync** button until its player lights sweep. The search lasts 60 seconds; click Find again to retry. +1. Turn on your Mac's Bluetooth and close other apps managing the controller, including the Switch2Kit dashboard or Dolphin. In Cemu, open **Options > Input settings**, click **Find Switch 2 Controllers**, allow Bluetooth access, and hold the controller's **Sync** button until its player lights sweep. With automatic connection off, the search lasts 60 seconds; click Find again to retry. 2. On the desired controller tab, select your **GameCube** or **Pro Controller 2** in the dropdown beside **Emulated controller**. Cemu applies the button and stick mappings automatically. An empty first slot becomes a **Wii U GamePad**; other empty slots become **Wii U Pro Controllers**. Use the emulated controller type your game supports. 3. Check that the input display responds to button presses, releases, and stick movement. Open the physical controller's **Settings** to adjust **Rumble** and click **Test rumble**. Close Input settings and open your Wii U game. -Cemu saves assignments and mappings. On later launches, use **Find Switch 2 Controllers** again to connect; **Disconnect Switch 2 Controllers** stops the current session without erasing profiles. Replacing a populated slot asks for confirmation and saves a backup; reconnecting does not reset custom mappings. +Cemu saves assignments and mappings. To reconnect on later launches or after long pauses without reopening Input settings, enable **Automatically connect Switch 2 controllers** in Input settings. This is off by default and uses Bluetooth radio resources. Unchecking it stops automatic discovery without disconnecting ready controllers. With it off, use **Find Switch 2 Controllers** for a bounded search. + +**Disconnect Switch 2 Controllers** stops support for the current run without erasing profiles or the saved automatic-connection choice. Use **Find** or turn the automatic option off and on to resume deliberately; the saved choice applies again on the next launch. Replacing a populated slot asks for confirmation and saves a backup; reconnecting never reapplies presets or resets custom mappings. See [Automatic connection](docs/Switch2Kit.md#automatic-connection) for consent, pairing and troubleshooting details. The NSO GameCube controller has no stick-click buttons, so bind those actions to spare buttons or a keyboard when a game needs them. A controller does not replace the Wii U touchscreen. Motion requires a measured, device-matching `.s2kmotion` profile and **Use motion** in the physical controller's Settings; it is not automatically calibrated and is not required for ordinary button/stick input. See the [full controller guide](docs/Switch2Kit.md) for these details and for adding both Joy-Con 2 halves through **+ > SDLController**. @@ -103,7 +105,7 @@ The old bug tracker can be found at [bugs.cemu.info](https://bugs.cemu.info) and ## Contributing -If you want to contribute you can take a look at our [contribution guidelines](/CONTRIBUTING.md). +To contribute, take a look at our [contribution guidelines](/CONTRIBUTING.md). ## License Cemu is licensed under [Mozilla Public License 2.0](/LICENSE.txt). Exempt from this are all files in the dependencies directory for which the licenses of the original code apply as well as some individual files in the src folder, as specified in those file headers respectively. diff --git a/dependencies/Switch2Kit b/dependencies/Switch2Kit index 8088ce3ef6..731bd5f97c 160000 --- a/dependencies/Switch2Kit +++ b/dependencies/Switch2Kit @@ -1 +1 @@ -Subproject commit 8088ce3ef6845fe90d8ff7a579e6498668bd65de +Subproject commit 731bd5f97c806e15277c9cd120d55515cef57940 diff --git a/docs/Switch2Kit.md b/docs/Switch2Kit.md index 9c2b536cab..5d5949bee6 100644 --- a/docs/Switch2Kit.md +++ b/docs/Switch2Kit.md @@ -30,7 +30,8 @@ It does not replace existing settings or install a system driver. Close any other app managing the same controllers. Open **Options > Input settings**, click **Find Switch 2 Controllers**, allow Bluetooth, and hold the -controller's **Sync** button. Scanning lasts 60 seconds. +controller's **Sync** button. With automatic connection off, scanning lasts +60 seconds. Find opens another bounded window when needed. Choose the connected **GameCube** or **Pro Controller 2** in the new dropdown beside **Emulated controller** on the desired controller tab. Cemu applies the @@ -49,8 +50,52 @@ reset custom mappings. A physical controller already assigned to another slot must be removed there before using the shortcut. Assignments persist by physical identity, including when identical controllers -reconnect in a different order. Use **Find** again after restarting Cemu. Use -**Disconnect Switch 2 Controllers** to stop this backend without erasing profiles. +reconnect in a different order. Use **Find** again after restarting Cemu when +automatic connection is off. Use **Disconnect Switch 2 Controllers** to stop this +backend without erasing profiles. + +## Automatic connection + +Enable **Automatically connect Switch 2 controllers** in Input settings to start +support on future launches and keep discovery available after long controller +absences or paused gameplay. It is **off by default**. Cemu saves this consent +before applying the radio policy. Startup happens once on the main run loop after +SDL initialization, even when Input settings is never opened. + +This uses the SDK's continuous discovery policy, not a timer repeatedly calling +Find or renewing a 60-second window. While Cemu remains open, supported advertising +controllers can be discovered when Bluetooth and connection capacity permit. It +uses radio resources and is **not a remembered-device allowlist**. It does not +pair arbitrary Bluetooth devices, wake a powered-off controller, grant permission, +assign a player slot, or reapply mappings. Initial pairing still requires Sync +and permission; turn the controller on to reconnect thereafter. + +Unchecking the option returns discovery to on-demand mode without detaching +ready controllers. An already admitted connection attempt may finish. To cancel +attempts and disconnect all native controllers, use **Disconnect** instead. +Disconnect is authoritative for the current run: input polling, reopening +settings, resuming a game, and a delayed startup callback cannot restart support. +Use **Find**, or turn the option off and on, to resume deliberately. The saved +choice is retained for the next application launch. After Disconnect, the SDK's +asynchronous stop may briefly report busy; wait a moment and retry explicitly. + +The preference is `[Settings] AutoConnect=true` or `false` in `Switch2Kit.ini`, +beside Cemu's `settings.xml`, not inside controller profiles. Missing files/keys +mean off. Updates re-read the file, preserve unrelated entries and sections, and +use Cemu's atomic writer. INI comments/formatting are not preserved. Malformed, +oversized, inaccessible or non-regular files are not silently replaced. A failed +save leaves the previous choice and radio policy intact; the checkbox reflects +the saved choice. Repair file access or malformed configuration and retry the +option. Configuration and policy/start errors remain visible through successful +polling and status refreshes. Find retries starting support, not saving settings. + +The SDK submodule is pinned to +`731bd5f97c806e15277c9cd120d55515cef57940`, which includes the additive automatic +C API and the shared `SDLHost` forwarding methods from +[Switch2Kit PR #74](https://github.com/jmonster/Switch2Kit/pull/74). Do not link an +older library that lacks `s2k_set_automatic_discovery`. The source-integration +checks verify the matching pin. This follows the opt-in lifecycle behavior in +[Dolphin PR #3](https://github.com/jmonster/dolphin/pull/3). ## Controller differences @@ -95,15 +140,45 @@ and does not compile or link Switch2Kit. The build helper enables these options, embeds the SDK and its distribution notices, then ad-hoc signs the completed app. These are development builds, not notarized releases. -`python3 tests/switch2kit/run.py --sanitize` runs mapping, identity, session and -backup/rollback policies after the native build. Use `--sdl /path/to/SDL-source` -and `--sdk /path/to/Switch2Kit` for a separate source checkout. Policy tests use -real enum definitions with controlled host/storage boundaries; they do not claim -Bluetooth or GUI interaction. Native CI compiles the full Cemu app on Apple -silicon and Intel, checks its bundle/signature, and launches, normally quits and +`python3 tests/switch2kit/run.py --sanitize` runs mapping, identity, session, +autoconnect, configuration and backup/rollback policies after the native build. +Use `--sdl /path/to/SDL-source` and `--sdk /path/to/Switch2Kit` for a separate +checkout of the same pinned SDK revision. The standalone automatic-connection +suite can also run with a C++20 compiler and Boost headers: + +```sh +python3 tests/switch2kit/test_autoconnect.py --sanitize +CXX=g++ python3 tests/switch2kit/test_autoconnect.py --sanitize +``` + +These tests execute the production session/configuration policies with controlled +SDK-host and atomic-writer boundaries and real INI file reads. They cover default +off, one-shot consent, repeated polling without renewal, stop/shutdown fences, +live-session preservation, save/read failure, retry and unrelated INI entries. +Source checks guard the startup hook, checkbox, SDK pin and build gates; they are +not native GUI execution. Native CI compiles the full Cemu app on Apple silicon +and Intel, checks its bundle/signature, and launches, normally quits and relaunches the exact ZIP with build dependencies denied. The SDK job also tests real SDL, motor packets and the Cemu motion consumer. +### Physical acceptance checklist (not established by policy tests) + +1. With a fresh configuration, verify automatic connection is off. Enable it, + pair a controller, close Input settings, and relaunch Cemu without opening + settings. Verify input, releases, sticks and rumble remain usable. +2. Power off a controller for longer than 60 seconds, then turn it on. Repeat + several cycles with a game paused and with settings closed. Also test + Bluetooth off/on and recovery after permission is granted. +3. Reconnect two controllers in reverse order and verify saved player identity, + custom mappings and motion-profile selection do not change. +4. Disable automatic connection while playing: existing input should remain. + After disconnecting the controller, use Find for manual discovery. +5. Use Disconnect with automatic connection enabled, then wake controllers, + reopen settings and resume gameplay. Support must remain stopped until Find, + explicit re-enabling, or a later launch with saved consent. +6. Check unreadable/malformed settings and failed saves on a real installation: + the checkbox and error status must accurately report the retained choice. + Physical pairing, input, reconnect, multiplayer, rumble start/stop, measured motion and gameplay still require a real Mac and controller. There is no claim that CI tests exercise physical hardware or downloaded-app Gatekeeper approval. diff --git a/src/gui/wxgui/input/InputSettings2.cpp b/src/gui/wxgui/input/InputSettings2.cpp index 5acc2230cb..13a99c3eb6 100644 --- a/src/gui/wxgui/input/InputSettings2.cpp +++ b/src/gui/wxgui/input/InputSettings2.cpp @@ -18,6 +18,7 @@ #include #ifdef HAVE_SWITCH2KIT #include +#include #include #include "input/api/SDL/SDLController.h" #include "input/api/SDL/Switch2KitMapping.h" @@ -94,10 +95,22 @@ InputSettings2::InputSettings2(wxWindow* parent) auto* stop = new wxButton(this, wxID_ANY, _("Disconnect Switch 2 Controllers")); discovery->Add(find, 0, wxALL, 5); discovery->Add(stop, 0, wxALL, 5); - m_switch2Status = new wxStaticText(this, wxID_ANY, _("Hold Sync while searching, then choose your controller beside Emulated controller.")); + auto* auto_connect = new wxCheckBox(this, wxID_ANY, _("Automatically connect Switch 2 controllers")); + auto_connect->SetValue(SDLControllerProvider::Switch2AutoConnect()); + auto_connect->SetToolTip(_("Start on future launches and continuously discover supported controllers, including after long pauses. Uses Bluetooth radio resources. Existing mappings are not changed. Unchecking keeps connected controllers; Disconnect stops support for this run until Find or re-enabling.")); + m_switch2Status = new wxStaticText(this, wxID_ANY, wxString::FromUTF8(SDLControllerProvider::Switch2ControllerStatus())); + m_switch2Status->SetMinSize(wxSize(600, m_switch2Status->GetCharHeight() * 4)); m_switch2Status->Wrap(600); sizer->Add(discovery, 0, wxEXPAND); + sizer->Add(auto_connect, 0, wxLEFT | wxRIGHT | wxBOTTOM, 5); sizer->Add(m_switch2Status, 0, wxLEFT | wxRIGHT | wxBOTTOM, 5); + auto_connect->Bind(wxEVT_CHECKBOX, [this, auto_connect](wxCommandEvent& event) { + const int result = SDLControllerProvider::SetSwitch2AutoConnect(event.IsChecked()); + // Reflect the saved choice, not an unsuccessful write or a runtime guess. + auto_connect->SetValue(SDLControllerProvider::Switch2AutoConnect()); + if (result != 0) + wxMessageBox(wxString::Format(_("The automatic connection setting could not be saved or applied (%d). Check Switch2Kit.ini access and Bluetooth permission. After Disconnect, wait a moment before retrying. The checkbox shows the saved choice; Find retries starting support."), result), _("Switch 2 Controllers"), wxOK | wxICON_WARNING, this); + }); find->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { const int result = SDLControllerProvider::FindSwitch2Controllers(); if (result != 0) @@ -145,8 +158,10 @@ InputSettings2::InputSettings2(wxWindow* parent) m_timer->Start(25); #ifdef HAVE_SWITCH2KIT m_switch2Timer = new wxTimer(this); - Bind(wxEVT_TIMER, [this](wxTimerEvent&) { + Bind(wxEVT_TIMER, [this, auto_connect](wxTimerEvent&) { + auto_connect->SetValue(SDLControllerProvider::Switch2AutoConnect()); m_switch2Status->SetLabel(wxString::FromUTF8(SDLControllerProvider::Switch2ControllerStatus())); + m_switch2Status->Wrap(600); if (m_switch2DevicesChanged->Consume()) on_controller_changed(); }, m_switch2Timer->GetId()); @@ -369,7 +384,7 @@ wxWindow* InputSettings2::initialize_page(size_t index) update_state(); RefreshSwitch2Controllers(); }); - sizer->Add(recommended, wxGBPosition(5, 3), wxGBSpan(1, 3), wxALIGN_CENTER_VERTICAL | wxALL, 5); + sizer->Add(recommended, wxGBPosition(5, 3), wxGBSpan(1, 3), wxALIGN_CENTER_VERTICAL | wxALL | wxEXPAND, 5); #endif } @@ -857,7 +872,6 @@ void InputSettings2::on_emulated_controller_dropdown(wxCommandEvent& event) emulated_controllers->Clear(); emulated_controllers->AppendString(_("Disabled")); - if (vpad_count < InputManager::kMaxVPADControllers || is_gamepad_selected) emulated_controllers->Append(wxString::FromUTF8(EmulatedController::type_to_string(EmulatedController::Type::VPAD))); @@ -1024,7 +1038,7 @@ void InputSettings2::on_controller_clear(wxCommandEvent& event) if (page_data.m_controller) { const auto type = page_data.m_controller->type(); - page_data.m_panels[type]->reset_configuration(); + page_data.m_panels[page_data.m_controller->type()]->reset_configuration(); page_data.m_controller->clear_mappings(); } } @@ -1112,4 +1126,4 @@ void InputSettings2::RefreshSwitch2Controllers() choice->Enable(choice->GetCount() > 1); } } -#endif +#endif \ No newline at end of file diff --git a/src/input/api/SDL/SDLControllerProvider.cpp b/src/input/api/SDL/SDLControllerProvider.cpp index 88c97a2d32..09834da4db 100644 --- a/src/input/api/SDL/SDLControllerProvider.cpp +++ b/src/input/api/SDL/SDLControllerProvider.cpp @@ -22,7 +22,11 @@ std::unordered_map SDLContro #ifdef HAVE_SWITCH2KIT #include "SDLHost.hpp" +#include "config/ActiveSettings.h" +#include "input/api/SDL/Switch2KitAutoConnectConfig.h" #include "input/api/SDL/Switch2KitSession.h" +#include +#include static Switch2KitSession& NativeSession() { static Switch2KitSession session; @@ -32,17 +36,46 @@ static Switch2Kit::SDLHost& nativeControllers() { return NativeSession().GetHost(); } +static bool LoadSwitch2AutoConnect(bool& enabled) +{ + return CemuSwitch2Kit::AutoConnectConfig::Load(ActiveSettings::GetConfigPath("Switch2Kit.ini"), enabled); +} +static bool SaveSwitch2AutoConnect(bool enabled) +{ + return CemuSwitch2Kit::AutoConnectConfig::Save(ActiveSettings::GetConfigPath("Switch2Kit.ini"), enabled, + [](const auto& path, std::string bytes) { + return FileStream::WriteFileAtomic(path, + std::span(reinterpret_cast(bytes.data()), bytes.size())); + }); +} int SDLControllerProvider::FindSwitch2Controllers() { return NativeSession().Discover(); } int SDLControllerProvider::DisconnectSwitch2Controllers() { return NativeSession().Stop(); } +bool SDLControllerProvider::Switch2AutoConnect() { return NativeSession().AutoConnect(); } +int SDLControllerProvider::SetSwitch2AutoConnect(bool enabled) +{ + return NativeSession().SetAutoConnect(enabled, SaveSwitch2AutoConnect); +} std::string SDLControllerProvider::Switch2ControllerStatus() { + auto& session = NativeSession(); + const int error = session.Error(); + if (error == Switch2KitSession::ConfigurationError) + return "Could not read or save Switch2Kit.ini. Check configuration access, then retry the automatic connection setting."; + if (error != 0) + return "Controller input error " + std::to_string(error) + ". Use Find to retry; after Disconnect, wait a moment."; + if (!session.IsEnabled()) + return "Switch 2 controller support is stopped. Use Find to resume, then hold Sync for initial pairing."; const auto state = nativeControllers().snapshot(); - if (!NativeSession().IsEnabled()) return "Select Find, then hold the controller Sync button."; if (state.bluetooth == S2K_BT_UNAUTHORIZED) return "Allow Cemu Bluetooth access in System Settings."; if (state.bluetooth == S2K_BT_OFF) return "Turn on Bluetooth in System Settings."; if (state.bluetooth == S2K_BT_UNSUPPORTED) return "Bluetooth is unavailable on this Mac."; - return std::to_string(state.count) + " connected; " + - (state.discovery == S2K_DISCOVERY_SCANNING ? "searching" : "discovery idle"); + const auto connected = std::to_string(state.count) + " connected; "; + if (session.AutoConnect()) + return connected + (state.discovery == S2K_DISCOVERY_SCANNING ? + "listening for Switch 2 controllers. Turn the controller on to reconnect; hold Sync for initial pairing." : + "automatic connection enabled; waiting for Bluetooth or discovery capacity."); + return connected + (state.discovery == S2K_DISCOVERY_SCANNING ? + "searching for 60 seconds; hold Sync." : "discovery idle. Use Find to search again."); } SDL_JoystickID SDLControllerProvider::FindSwitch2Device(std::string_view key) { @@ -229,6 +262,12 @@ void SDLControllerProvider::InitSDL() { cemuLog_log(LogType::Force, "Couldn't enable SDL gamecontroller event polling: {}", SDL_GetError()); } +#ifdef HAVE_SWITCH2KIT + NativeSession().LoadAutoConnect(LoadSwitch2AutoConnect); + // Saved consent starts once on the main run loop, never from input polling. + // Stop/Shutdown and explicit settings actions fence this deferred callback. + wxTheApp->CallAfter([] { NativeSession().StartOnce(); }); +#endif } void SDLControllerProvider::ShutdownSDL() @@ -293,7 +332,7 @@ void SDLControllerProvider::HandleSDLEvent(SDL_Event& event) { break; } - case SDL_EVENT_GAMEPAD_TOUCHPAD_DOWN: /**< Game controller touchpad was touched */ + case SDL_EVENT_GAMEPAD_TOUCHPAD_DOWN: /**< Game controller touchpad finger was touched */ { break; } @@ -416,4 +455,4 @@ void SDLControllerProvider::event_thread() HandleSDLEvent(event); } ShutdownSDL(); -} +} \ No newline at end of file diff --git a/src/input/api/SDL/SDLControllerProvider.h b/src/input/api/SDL/SDLControllerProvider.h index 98e3b2007d..24911876af 100644 --- a/src/input/api/SDL/SDLControllerProvider.h +++ b/src/input/api/SDL/SDLControllerProvider.h @@ -26,6 +26,8 @@ class SDLControllerProvider : public ControllerProviderBase #ifdef HAVE_SWITCH2KIT static int FindSwitch2Controllers(); static int DisconnectSwitch2Controllers(); + static bool Switch2AutoConnect(); + static int SetSwitch2AutoConnect(bool enabled); static std::string Switch2ControllerStatus(); static SDL_JoystickID FindSwitch2Device(std::string_view key); static int LoadSwitch2MotionProfile(const std::string& path, const std::string& key); diff --git a/src/input/api/SDL/Switch2KitAutoConnectConfig.h b/src/input/api/SDL/Switch2KitAutoConnectConfig.h new file mode 100644 index 0000000000..6e94814a14 --- /dev/null +++ b/src/input/api/SDL/Switch2KitAutoConnectConfig.h @@ -0,0 +1,95 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace CemuSwitch2Kit +{ +// This application-owned file is separate from controllerProfiles. Existing INI +// entries/sections survive updates; malformed or unreadable files are not reset. +class AutoConnectConfig +{ +public: + static bool Load(const std::filesystem::path& path, bool& enabled) + { + boost::property_tree::ptree data; + bool value = false; + if (!Read(path, data, value)) + return false; + enabled = value; + return true; + } + + // The caller supplies Cemu's atomic file writer. Read the current document + // again before a save, rather than clobbering it with a startup-time snapshot. + template + static bool Save(const std::filesystem::path& path, bool enabled, WriteAtomic&& writeAtomic) + { + boost::property_tree::ptree data; + bool previous = false; + if (!Read(path, data, previous)) + return false; + try + { + data.put("Settings.AutoConnect", enabled ? "true" : "false"); + std::ostringstream stream; + boost::property_tree::write_ini(stream, data); + return writeAtomic(path, stream.str()); + } + catch (const std::exception&) + { + return false; + } + } + +private: + static bool Read(const std::filesystem::path& path, boost::property_tree::ptree& data, bool& enabled) + { + try + { + std::error_code error; + const auto status = std::filesystem::symlink_status(path, error); + if (status.type() == std::filesystem::file_type::not_found && + (!error || error == std::errc::no_such_file_or_directory)) + { + enabled = false; + return true; + } + // Do not replace symlinks, directories, special files, or files whose + // status is inaccessible. Bound this small preference file's input. + if (error || !std::filesystem::is_regular_file(status)) + return false; + const auto size = std::filesystem::file_size(path, error); + if (error || size > 65536) + return false; + std::ifstream file(path, std::ios::binary); + if (!file) + return false; + std::string bytes(65537, '\0'); + file.read(bytes.data(), static_cast(bytes.size())); + if (file.bad() || !file.eof() || file.gcount() > 65536) + return false; + bytes.resize(static_cast(file.gcount())); + if (bytes.find('\0') != std::string::npos) + return false; + std::istringstream input(bytes); + boost::property_tree::read_ini(input, data); + const auto value = data.get_optional("Settings.AutoConnect"); + if (!value || *value == "false" || *value == "0") + enabled = false; + else if (*value == "true" || *value == "1") + enabled = true; + else + return false; + return true; + } + catch (const std::exception&) + { + return false; + } + } +}; +} diff --git a/src/input/api/SDL/Switch2KitSession.h b/src/input/api/SDL/Switch2KitSession.h index a4d762d0fb..1e9f705449 100644 --- a/src/input/api/SDL/Switch2KitSession.h +++ b/src/input/api/SDL/Switch2KitSession.h @@ -1,41 +1,113 @@ #pragma once -// Start/Stop/Pump are called on Cemu's macOS main thread. The SDK host serializes -// identity, profile and status access from controller/configuration threads. -// Keeping the state here also prevents a failed first start from enabling polling. +#include + +// Lifecycle, preference and polling methods belong to Cemu's macOS main thread. +// The SDK host serializes identity/profile access from configuration threads. +// Pump never loads settings, changes discovery policy, or starts support. template class Switch2KitSession { public: + static constexpr int ConfigurationError = -1000; + static constexpr int ShutdownError = -1001; + + // Load once after SDL initialization, before queueing the main-loop startup. + // A failed read must not be interpreted as new consent or overwrite the file. + template + void LoadAutoConnect(Load&& load) + { + if (std::exchange(m_loaded, true) || m_shutdown) + return; + bool enabled = false; + if (!load(enabled)) + m_configError = ConfigurationError; + else + m_autoConnect = enabled; + } + + int StartOnce() + { + if (std::exchange(m_startupConsumed, true) || m_shutdown || !m_autoConnect) + return 0; + return StartSupport(); + } + int Discover() { - const int result = m_host.discover(); - if (result == 0) - m_enabled = true; - return result; + m_startupConsumed = true; + if (m_shutdown) + return m_actionError = ShutdownError; + // Find is also the explicit retry/resume action. Automatic mode does not + // create a finite discovery window, even when Find is pressed. + return StartSupport(); + } + + template + int SetAutoConnect(bool enabled, Save&& save) + { + if (m_shutdown) + return m_actionError = ShutdownError; + // Persist first. Failed writes must not alter the choice or radio policy. + if (!save(enabled)) + return m_configError = ConfigurationError; + m_configError = 0; + m_autoConnect = enabled; + m_startupConsumed = true; + if (enabled) + return StartSupport(); + // Do not stop the host or detach ready controllers when opting out. + return m_actionError = m_host.setAutomaticDiscovery(false); } int Stop() { + m_startupConsumed = true; m_enabled = false; - return m_host.stop(); + m_pumpError = 0; + if (m_shutdown) + return 0; + return m_actionError = m_host.stop(); } int Pump() { - return m_enabled ? m_host.pump() : 0; + return m_pumpError = m_enabled ? m_host.pump() : 0; } void Shutdown() { + m_startupConsumed = true; m_enabled = false; - m_host.shutdown(); + if (!std::exchange(m_shutdown, true)) + m_host.shutdown(); } bool IsEnabled() const { return m_enabled; } + bool AutoConnect() const { return m_autoConnect; } + // Successful polling/status reads must not erase failed user actions. + int Error() const { return m_configError ? m_configError : m_actionError ? m_actionError : m_pumpError; } Host& GetHost() { return m_host; } private: + int StartSupport() + { + m_actionError = m_host.setAutomaticDiscovery(m_autoConnect); + if (m_actionError == 0) + m_actionError = m_autoConnect ? m_host.start() : m_host.discover(); + // A failed additional discovery must not stop an already live session. + if (m_actionError == 0) + m_enabled = true; + return m_actionError; + } + Host m_host; bool m_enabled = false; + bool m_autoConnect = false; + bool m_loaded = false; + bool m_startupConsumed = false; + bool m_shutdown = false; + int m_configError = 0; + int m_actionError = 0; + int m_pumpError = 0; }; diff --git a/tests/switch2kit/AutoConnectTests.cpp b/tests/switch2kit/AutoConnectTests.cpp new file mode 100644 index 0000000000..9ae6c852d7 --- /dev/null +++ b/tests/switch2kit/AutoConnectTests.cpp @@ -0,0 +1,245 @@ +#include +#include +#include +#include +#include +#include +#include +#include "input/api/SDL/Switch2KitSession.h" +#include "input/api/SDL/Switch2KitAutoConnectConfig.h" + +// Test boundaries only: no Bluetooth, input, or emulator behavior is fabricated. +struct AutoHost +{ + int starts = 0, discoveries = 0, policies = 0, pumps = 0, stops = 0, shutdowns = 0; + int startResult = 0, discoveryResult = 0, policyResult = 0, stopResult = 0, pumpResult = 0; + bool automatic = false; + int setAutomaticDiscovery(bool enabled) + { + ++policies; + if (!policyResult) automatic = enabled; + return policyResult; + } + int start() { ++starts; return startResult; } + int discover() { ++discoveries; return discoveryResult; } + int pump() { ++pumps; return pumpResult; } + int stop() { ++stops; return stopResult; } + void shutdown() { ++shutdowns; } +}; +using Session = Switch2KitSession; + +void StartupTests() +{ + Session off; + auto& manual = off.GetHost(); + int reads = 0; + off.LoadAutoConnect([&](bool& value) { ++reads; value = false; return true; }); + off.LoadAutoConnect([&](bool&) { ++reads; return false; }); + assert(off.StartOnce() == 0 && off.StartOnce() == 0); + for (int i = 0; i < 10000; ++i) assert(off.Pump() == 0); + assert(reads == 1 && !off.AutoConnect() && !off.IsEnabled()); + assert(manual.policies == 0 && manual.starts == 0 && manual.discoveries == 0 && manual.pumps == 0); + assert(off.Discover() == 0 && off.IsEnabled()); + assert(manual.discoveries == 1 && manual.starts == 0 && !manual.automatic); + + Session on; + auto& host = on.GetHost(); + on.LoadAutoConnect([&](bool& value) { ++reads; value = true; return true; }); + assert(on.StartOnce() == 0 && on.StartOnce() == 0 && on.AutoConnect()); + for (int i = 0; i < 10000; ++i) assert(on.Pump() == 0); + assert(host.policies == 1 && host.starts == 1 && host.discoveries == 0); + assert(host.pumps == 10000 && host.automatic && reads == 2); + assert(on.Stop() == 0 && on.AutoConnect()); + for (int i = 0; i < 10000; ++i) { on.Pump(); on.StartOnce(); } + assert(host.starts == 1 && host.pumps == 10000 && !on.IsEnabled()); + assert(on.Discover() == 0 && host.starts == 2 && host.discoveries == 0); + + Session deferred; + deferred.LoadAutoConnect([](bool& value) { value = true; return true; }); + assert(deferred.Stop() == 0); + assert(deferred.StartOnce() == 0); + assert(deferred.GetHost().starts == 0 && deferred.GetHost().policies == 0); + assert(deferred.AutoConnect() && !deferred.IsEnabled()); + assert(deferred.Discover() == 0 && deferred.GetHost().starts == 1); + + Session explicitFirst; + explicitFirst.LoadAutoConnect([](bool& value) { value = true; return true; }); + assert(explicitFirst.Discover() == 0); + assert(explicitFirst.StartOnce() == 0 && explicitFirst.GetHost().starts == 1); + std::cout << "PASS default-off, saved-consent one-shot startup, no polling renewal, explicit-stop fence\n"; +} + +void PreferenceTests() +{ + Session session; + auto& host = session.GetHost(); + int writes = 0; + bool saved = false; + auto save = [&](bool value) { ++writes; saved = value; return true; }; + assert(session.SetAutoConnect(true, save) == 0); + assert(saved && session.AutoConnect() && host.starts == 1 && host.automatic); + assert(session.StartOnce() == 0 && host.starts == 1); + assert(session.SetAutoConnect(false, save) == 0); + assert(!saved && !session.AutoConnect() && !host.automatic); + assert(session.IsEnabled() && host.stops == 0 && host.starts == 1); + session.Pump(); + assert(host.pumps == 1); + assert(session.Discover() == 0 && host.discoveries == 1); + + const auto policies = host.policies; + assert(session.SetAutoConnect(true, [&](bool) { ++writes; return false; }) == Session::ConfigurationError); + assert(!session.AutoConnect() && !saved && host.policies == policies && host.starts == 1); + for (int i = 0; i < 1000; ++i) session.Pump(); + assert(session.Error() == Session::ConfigurationError && session.IsEnabled()); + assert(session.SetAutoConnect(true, save) == 0 && session.Error() == 0); + assert(saved && writes == 4 && session.AutoConnect()); + assert(session.Stop() == 0); + assert(session.SetAutoConnect(true, save) == 0 && session.IsEnabled()); + + Session nextLaunch; + nextLaunch.LoadAutoConnect([&](bool& value) { value = saved; return true; }); + assert(nextLaunch.StartOnce() == 0 && nextLaunch.GetHost().starts == 1); + std::cout << "PASS atomic-save boundary, opt-out keeps live input, explicit re-enable, next-launch consent\n"; +} + +void ErrorAndShutdownTests() +{ + Session session; + auto& host = session.GetHost(); + session.LoadAutoConnect([](bool& value) { value = true; return true; }); + host.policyResult = 5; + assert(session.StartOnce() == 5 && !session.IsEnabled()); + assert(host.starts == 0); + session.Pump(); + assert(session.Error() == 5); + host.policyResult = 0; + assert(session.StartOnce() == 0 && host.starts == 0); // no automatic retry + host.startResult = 4; + assert(session.Discover() == 4 && !session.IsEnabled()); + session.Pump(); + assert(session.Error() == 4); + host.startResult = 0; + assert(session.Discover() == 0 && session.Error() == 0); + host.policyResult = 5; + assert(session.SetAutoConnect(false, [](bool) { return true; }) == 5); + assert(!session.AutoConnect() && session.IsEnabled() && host.automatic); + for (int i = 0; i < 1000; ++i) session.Pump(); + assert(session.Error() == 5); // polling cannot erase failed policy application + host.policyResult = 0; + assert(session.Discover() == 0 && !host.automatic && host.discoveries == 1); + host.discoveryResult = 6; + assert(session.Discover() == 6 && session.IsEnabled()); + session.Pump(); + assert(session.Error() == 6); + host.stopResult = 5; + assert(session.Stop() == 5 && !session.IsEnabled()); + const auto pumps = host.pumps; + session.Pump(); session.StartOnce(); + assert(host.pumps == pumps && session.Error() == 5); + session.Shutdown(); session.Shutdown(); + assert(host.shutdowns == 1 && !session.IsEnabled()); + const auto starts = host.starts; + assert(session.Discover() == Session::ShutdownError); + assert(session.SetAutoConnect(true, [](bool) { assert(false); return true; }) == Session::ShutdownError); + session.StartOnce(); session.Pump(); + assert(host.starts == starts && host.pumps == pumps); + + Session beforeCallback; + beforeCallback.LoadAutoConnect([](bool& value) { value = true; return true; }); + beforeCallback.Shutdown(); + assert(beforeCallback.StartOnce() == 0 && beforeCallback.GetHost().starts == 0); + Session unreadable; + unreadable.LoadAutoConnect([](bool& value) { value = true; return false; }); + assert(!unreadable.AutoConnect() && unreadable.Error() == Session::ConfigurationError); + unreadable.StartOnce(); unreadable.Pump(); + assert(unreadable.GetHost().policies == 0 && unreadable.GetHost().starts == 0); + assert(unreadable.SetAutoConnect(true, [](bool) { return true; }) == 0); + assert(unreadable.AutoConnect() && unreadable.Error() == 0); + std::cout << "PASS sticky policy/start/stop errors, retry, failed reads and shutdown-before-start\n"; +} + +std::string ReadBytes(const std::filesystem::path& path) +{ + std::ifstream input(path, std::ios::binary); + return {std::istreambuf_iterator(input), std::istreambuf_iterator()}; +} +void WriteBytes(const std::filesystem::path& path, const std::string& bytes) +{ + std::ofstream output(path, std::ios::binary | std::ios::trunc); + output.write(bytes.data(), static_cast(bytes.size())); + output.close(); + assert(output); +} + +void ConfigurationTests(const std::filesystem::path& directory) +{ + using CemuSwitch2Kit::AutoConnectConfig; + const auto path = directory / "Switch2Kit.ini"; + bool value = true; + assert(AutoConnectConfig::Load(path, value) && !value && !std::filesystem::exists(path)); + int writes = 0; + // This writer is a test boundary. Production uses FileStream::WriteFileAtomic. + auto write = [&](const auto& target, const std::string& bytes) { + ++writes; + auto temporary = target; temporary += ".test-tmp"; + WriteBytes(temporary, bytes); + std::filesystem::rename(temporary, target); + return true; + }; + assert(AutoConnectConfig::Save(path, true, write)); + assert(AutoConnectConfig::Load(path, value) && value && writes == 1); + WriteBytes(path, "[Settings]\nAutoConnect=1\nOther=retained\n[Identities]\ncontroller=s2k:0123456789abcdef0123456789abcdef\n"); + assert(AutoConnectConfig::Load(path, value) && value); + // An edit made since loading must also survive the next write. + std::ofstream(path, std::ios::app) << "external=changed\n"; + assert(AutoConnectConfig::Save(path, false, write)); + assert(AutoConnectConfig::Load(path, value) && !value); + const auto before = ReadBytes(path); + assert(before.find("Other=retained") != std::string::npos); + assert(before.find("controller=s2k:0123456789abcdef0123456789abcdef") != std::string::npos); + assert(before.find("external=changed") != std::string::npos); + assert(!AutoConnectConfig::Save(path, true, [](const auto&, const auto&) { return false; })); + assert(ReadBytes(path) == before); + assert(!AutoConnectConfig::Save(path, true, [](const auto&, const auto&) -> bool { throw std::runtime_error("write"); })); + assert(ReadBytes(path) == before); + + for (const auto& invalid : {std::string("[Settings\nAutoConnect=true\n"), + std::string("[Settings]\nAutoConnect=maybe\n"), + std::string("[Settings]\nAutoConnect=true\nAutoConnect=false\n"), + std::string("[Settings]\nAutoConnect=true\n[Settings]\nOther=duplicate\n"), + std::string("[Settings]\nAutoConnect=true\n\0", 29), std::string(65537, 'x')}) + { + WriteBytes(path, invalid); + value = true; + assert(!AutoConnectConfig::Load(path, value) && value); + assert(!AutoConnectConfig::Save(path, false, write)); + assert(ReadBytes(path) == invalid && writes == 2); + } + std::filesystem::remove(path); + std::filesystem::create_directory(path); + assert(!AutoConnectConfig::Load(path, value)); + assert(!AutoConnectConfig::Save(path, true, write)); + std::filesystem::remove(path); + const auto target = directory / "target.ini"; + WriteBytes(target, before); + std::filesystem::create_symlink(target, path); + assert(!AutoConnectConfig::Load(path, value)); + assert(!AutoConnectConfig::Save(path, true, write)); + assert(ReadBytes(target) == before); + std::filesystem::remove(path); + std::filesystem::remove(target); + std::filesystem::create_symlink(target, path); // dangling link is not a missing preference + assert(!AutoConnectConfig::Load(path, value)); + assert(!AutoConnectConfig::Save(path, true, write)); + std::filesystem::remove(path); + std::cout << "PASS real INI reads, unrelated entries, fresh-save reads, malformed/oversized/type rejection and failed-write preservation\n"; +} + +int main(int argc, char** argv) +{ + assert(argc == 2); + StartupTests(); + PreferenceTests(); + ErrorAndShutdownTests(); + ConfigurationTests(argv[1]); +} diff --git a/tests/switch2kit/PolicyTests.cpp b/tests/switch2kit/PolicyTests.cpp index aa8811d2a8..a822f3d60a 100644 --- a/tests/switch2kit/PolicyTests.cpp +++ b/tests/switch2kit/PolicyTests.cpp @@ -19,6 +19,8 @@ struct Host { int result = 0, discoveries = 0, pumps = 0, stops = 0, shutdowns = 0; + int setAutomaticDiscovery(bool) { return 0; } + int start() { return result; } int discover() { ++discoveries; return result; } int pump() { ++pumps; return 0; } int stop() { ++stops; return 0; } diff --git a/tests/switch2kit/run.py b/tests/switch2kit/run.py index 565d019d1c..9ba57c27de 100644 --- a/tests/switch2kit/run.py +++ b/tests/switch2kit/run.py @@ -55,6 +55,12 @@ def run(): subprocess.run(command, check=True, timeout=120) subprocess.run([str(binary)], check=True, timeout=30) subprocess.run(['python3', str(ROOT / 'tests/switch2kit/test_wiring.py')], check=True) + automatic = ['python3', str(ROOT / 'tests/switch2kit/test_autoconnect.py'), '--include', str(sdl)] + if args.sanitize: + automatic.append('--sanitize') + subprocess.run(automatic, check=True) + subprocess.run(['python3', str(ROOT / 'tests/switch2kit/test_autoconnect_wiring.py'), + '--sdk', str(sdk)], check=True) if __name__ == '__main__': run() diff --git a/tests/switch2kit/test_autoconnect.py b/tests/switch2kit/test_autoconnect.py new file mode 100644 index 0000000000..b91615105e --- /dev/null +++ b/tests/switch2kit/test_autoconnect.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Execute the production automatic-connection session and configuration policies. + +Only the SDK host and atomic writer are test boundaries. No controller hardware +or full application build is represented by these tests. +""" +import argparse +import os +from pathlib import Path +import shutil +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parents[2] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--sanitize', action='store_true') + parser.add_argument('--include', action='append', default=[], type=Path) + args = parser.parse_args() + compiler = os.environ.get('CXX') or shutil.which('clang++') or shutil.which('g++') + if not compiler: + raise SystemExit('A C++20 compiler is required') + with tempfile.TemporaryDirectory(prefix='cemu-autoconnect-') as directory: + work = Path(directory) + binary = work / 'autoconnect' + command = [compiler, '-std=c++20', '-pthread', '-Wall', '-Wextra', '-Werror', '-UNDEBUG', + '-I' + str(ROOT / 'src')] + command += ['-I' + str(path.resolve()) for path in args.include] + command += [str(ROOT / 'tests/switch2kit/AutoConnectTests.cpp'), '-o', str(binary)] + if args.sanitize: + command += ['-fsanitize=address,undefined', '-fno-omit-frame-pointer'] + subprocess.run(command, check=True, timeout=120) + subprocess.run([str(binary), str(work)], check=True, timeout=30) + + +if __name__ == '__main__': + main() diff --git a/tests/switch2kit/test_autoconnect_wiring.py b/tests/switch2kit/test_autoconnect_wiring.py new file mode 100644 index 0000000000..12b845acac --- /dev/null +++ b/tests/switch2kit/test_autoconnect_wiring.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Source integration guards, not GUI, Bluetooth, or full-build acceptance.""" +import argparse +from pathlib import Path +import subprocess +import unittest + +ROOT = Path(__file__).resolve().parents[2] +SDK = ROOT / 'dependencies/Switch2Kit' +PIN = '731bd5f97c806e15277c9cd120d55515cef57940' + + +def source(path): + return (ROOT / path).read_text() + + +def between(text, start, end): + return text.split(start, 1)[1].split(end, 1)[0] + + +class AutoConnectWiring(unittest.TestCase): + def test_main_loop_start_is_one_shot_and_after_sdl(self): + provider = source('src/input/api/SDL/SDLControllerProvider.cpp') + startup = between(provider, 'void SDLControllerProvider::InitSDL()', + 'void SDLControllerProvider::ShutdownSDL()') + self.assertLess(startup.index('SDL_InitSubSystem('), startup.index('LoadAutoConnect(')) + self.assertIn('#ifdef HAVE_SWITCH2KIT\n\tNativeSession().LoadAutoConnect', startup) + self.assertIn('wxTheApp->CallAfter([] { NativeSession().StartOnce(); });', startup) + self.assertEqual(provider.count('NativeSession().StartOnce()'), 1) + app = source('src/gui/wxgui/CemuApp.cpp') + self.assertLess(app.index('DeterminePaths(failedWriteAccess)'), app.index('SDLControllerProvider::InitSDL()')) + + def test_input_polling_neither_starts_nor_reads_preferences(self): + provider = source('src/input/api/SDL/SDLControllerProvider.cpp') + polling = between(provider, 'void SDLControllerProvider::PumpSDLEvents()', + 'void SDLControllerProvider::HandleSDLEvent(') + self.assertIn('NativeSession().Pump()', polling) + for forbidden in ('StartOnce', 'Discover(', 'AutoConnect', 'GetConfigPath', 'read_ini'): + self.assertNotIn(forbidden, polling) + session = source('src/input/api/SDL/Switch2KitSession.h') + polling = between(session, '\tint Pump()', '\tvoid Shutdown()') + self.assertIn('m_enabled ? m_host.pump() : 0', polling) + for forbidden in ('start(', 'discover(', 'setAutomaticDiscovery', 'LoadAutoConnect'): + self.assertNotIn(forbidden, polling) + + def test_checkbox_reports_saved_choice_without_assigning_controllers(self): + ui = source('src/gui/wxgui/input/InputSettings2.cpp') + self.assertIn('Automatically connect Switch 2 controllers', ui) + handler = between(ui, 'auto_connect->Bind(wxEVT_CHECKBOX', 'find->Bind(wxEVT_BUTTON') + self.assertIn('SetSwitch2AutoConnect(event.IsChecked())', handler) + self.assertIn('auto_connect->SetValue(SDLControllerProvider::Switch2AutoConnect())', handler) + self.assertIn('wxMessageBox', handler) + for forbidden in ('ApplySwitch2KitSetup', 'set_default_mapping', 'set_controller', 'load_controller'): + self.assertNotIn(forbidden, handler) + timer = between(ui, 'm_switch2Timer = new wxTimer(this);', 'm_switch2Timer->Start(500)') + self.assertIn('Switch2AutoConnect()', timer) + self.assertNotIn('FindSwitch2Controllers', timer) + self.assertNotIn('SetSwitch2AutoConnect', timer) + + def test_persistence_is_separate_atomic_and_errors_precede_snapshot(self): + provider = source('src/input/api/SDL/SDLControllerProvider.cpp') + save = between(provider, 'static bool SaveSwitch2AutoConnect(', + 'int SDLControllerProvider::FindSwitch2Controllers()') + self.assertIn('AutoConnectConfig::Save', save) + self.assertIn('ActiveSettings::GetConfigPath("Switch2Kit.ini")', save) + self.assertIn('FileStream::WriteFileAtomic', save) + self.assertNotIn('controllerProfiles', save) + status = between(provider, 'std::string SDLControllerProvider::Switch2ControllerStatus()', + 'SDL_JoystickID SDLControllerProvider::FindSwitch2Device(') + self.assertLess(status.index('session.Error()'), status.index('nativeControllers().snapshot()')) + + def test_matching_sdk_exposes_policy_and_start(self): + revision = subprocess.check_output(['git', '-C', str(SDK), 'rev-parse', 'HEAD'], text=True).strip() + self.assertEqual(revision, PIN, 'Update the reviewed SDK pin and its tests together') + host = (SDK / 'Integrations/Emulators/SDLHost.hpp').read_text() + setter = between(host, 'S2KResult setAutomaticDiscovery(bool enabled)', 'S2KResult start()') + self.assertIn('Guard lock(mutex_)', setter) + self.assertIn('s2k_set_automatic_discovery(context_, enabled ? 1u : 0u)', setter) + self.assertNotIn('s2k_start(', setter) + start = between(host, 'S2KResult start()', 'S2KResult discover(') + self.assertIn('s2k_start(context_)', start) + self.assertNotIn('s2k_discover(', start) + self.assertIn('s2k_set_automatic_discovery(', + (SDK / 'Sources/Switch2KitCABI/include/Switch2KitC.h').read_text()) + + def test_native_build_and_backend_disabled_gates_are_retained(self): + workflow = source('.github/workflows/native-switch2kit.yml') + for gate in ('arch: arm64', 'arch: x86_64', 'tests/switch2kit/run.py --sanitize', + 'verify-bundle.py', 'tests/emulator-launch/verify.py', + '-DENABLE_SWITCH2KIT=OFF', 'tests/emulator-host/verify.sh'): + self.assertIn(gate, workflow) + runner = source('tests/switch2kit/run.py') + self.assertIn('test_autoconnect.py', runner) + self.assertIn('test_autoconnect_wiring.py', runner) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--sdk', type=Path, default=SDK) + args = parser.parse_args() + SDK = args.sdk.resolve() + unittest.main(argv=[__file__]) From a8788eda078ba9054d9bb41c6f4b7786e0ba70a2 Mon Sep 17 00:00:00 2001 From: Johnny D Date: Fri, 18 Sep 2026 13:28:47 -0400 Subject: [PATCH 2/4] Keep surrounding controller UI and event code unchanged Remove incidental layout, expression, comment and whitespace edits from the automatic-connection change. Retain only the intended startup/settings behavior. --- src/gui/wxgui/input/InputSettings2.cpp | 7 ++++--- src/input/api/SDL/SDLControllerProvider.cpp | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/gui/wxgui/input/InputSettings2.cpp b/src/gui/wxgui/input/InputSettings2.cpp index 13a99c3eb6..41a8cac4f6 100644 --- a/src/gui/wxgui/input/InputSettings2.cpp +++ b/src/gui/wxgui/input/InputSettings2.cpp @@ -384,7 +384,7 @@ wxWindow* InputSettings2::initialize_page(size_t index) update_state(); RefreshSwitch2Controllers(); }); - sizer->Add(recommended, wxGBPosition(5, 3), wxGBSpan(1, 3), wxALIGN_CENTER_VERTICAL | wxALL | wxEXPAND, 5); + sizer->Add(recommended, wxGBPosition(5, 3), wxGBSpan(1, 3), wxALIGN_CENTER_VERTICAL | wxALL, 5); #endif } @@ -872,6 +872,7 @@ void InputSettings2::on_emulated_controller_dropdown(wxCommandEvent& event) emulated_controllers->Clear(); emulated_controllers->AppendString(_("Disabled")); + if (vpad_count < InputManager::kMaxVPADControllers || is_gamepad_selected) emulated_controllers->Append(wxString::FromUTF8(EmulatedController::type_to_string(EmulatedController::Type::VPAD))); @@ -1038,7 +1039,7 @@ void InputSettings2::on_controller_clear(wxCommandEvent& event) if (page_data.m_controller) { const auto type = page_data.m_controller->type(); - page_data.m_panels[page_data.m_controller->type()]->reset_configuration(); + page_data.m_panels[type]->reset_configuration(); page_data.m_controller->clear_mappings(); } } @@ -1126,4 +1127,4 @@ void InputSettings2::RefreshSwitch2Controllers() choice->Enable(choice->GetCount() > 1); } } -#endif \ No newline at end of file +#endif diff --git a/src/input/api/SDL/SDLControllerProvider.cpp b/src/input/api/SDL/SDLControllerProvider.cpp index 09834da4db..b4aa5b68de 100644 --- a/src/input/api/SDL/SDLControllerProvider.cpp +++ b/src/input/api/SDL/SDLControllerProvider.cpp @@ -332,7 +332,7 @@ void SDLControllerProvider::HandleSDLEvent(SDL_Event& event) { break; } - case SDL_EVENT_GAMEPAD_TOUCHPAD_DOWN: /**< Game controller touchpad finger was touched */ + case SDL_EVENT_GAMEPAD_TOUCHPAD_DOWN: /**< Game controller touchpad was touched */ { break; } @@ -455,4 +455,4 @@ void SDLControllerProvider::event_thread() HandleSDLEvent(event); } ShutdownSDL(); -} \ No newline at end of file +} From 3b0af7dcad3652ecb8f2243efe321131528c8e49 Mon Sep 17 00:00:00 2001 From: Johnny D Date: Fri, 18 Sep 2026 14:22:51 -0400 Subject: [PATCH 3/4] fix: reject autoconnect saves that exceed the read limit Reproduce the exact-limit true-to-false growth and missing-setting overflow, retain both disk bytes and runtime consent on refusal, and test successful 64 KiB round trips. Run the new regression through the existing sanitizer runner. Pin Switch2Kit to its direct SDLHost lifecycle coverage and CI fixes. --- dependencies/Switch2Kit | 2 +- docs/Switch2Kit.md | 16 ++-- .../api/SDL/Switch2KitAutoConnectConfig.h | 16 +++- .../switch2kit/AutoConnectConfigSizeTests.cpp | 85 +++++++++++++++++++ tests/switch2kit/test_autoconnect.py | 17 ++-- tests/switch2kit/test_autoconnect_wiring.py | 2 +- 6 files changed, 118 insertions(+), 20 deletions(-) create mode 100644 tests/switch2kit/AutoConnectConfigSizeTests.cpp diff --git a/dependencies/Switch2Kit b/dependencies/Switch2Kit index 731bd5f97c..51e36a8223 160000 --- a/dependencies/Switch2Kit +++ b/dependencies/Switch2Kit @@ -1 +1 @@ -Subproject commit 731bd5f97c806e15277c9cd120d55515cef57940 +Subproject commit 51e36a8223f2c4254b8d9d9f43dc10c8cdb3ff33 diff --git a/docs/Switch2Kit.md b/docs/Switch2Kit.md index 5d5949bee6..c22d046ed9 100644 --- a/docs/Switch2Kit.md +++ b/docs/Switch2Kit.md @@ -83,14 +83,16 @@ The preference is `[Settings] AutoConnect=true` or `false` in `Switch2Kit.ini`, beside Cemu's `settings.xml`, not inside controller profiles. Missing files/keys mean off. Updates re-read the file, preserve unrelated entries and sections, and use Cemu's atomic writer. INI comments/formatting are not preserved. Malformed, -oversized, inaccessible or non-regular files are not silently replaced. A failed -save leaves the previous choice and radio policy intact; the checkbox reflects -the saved choice. Repair file access or malformed configuration and retry the -option. Configuration and policy/start errors remain visible through successful -polling and status refreshes. Find retries starting support, not saving settings. +oversized, inaccessible or non-regular files are not silently replaced. A save +that would exceed the 64 KiB read limit is rejected before writing, so a successful +save remains readable on the next launch. A failed save leaves the previous +choice and radio policy intact; the checkbox reflects the saved choice. Repair +file access or malformed configuration and retry the option. Configuration and +policy/start errors remain visible through successful polling and status +refreshes. Find retries starting support, not saving settings. The SDK submodule is pinned to -`731bd5f97c806e15277c9cd120d55515cef57940`, which includes the additive automatic +`51e36a8223f2c4254b8d9d9f43dc10c8cdb3ff33`, which includes the additive automatic C API and the shared `SDLHost` forwarding methods from [Switch2Kit PR #74](https://github.com/jmonster/Switch2Kit/pull/74). Do not link an older library that lacks `s2k_set_automatic_discovery`. The source-integration @@ -155,6 +157,8 @@ These tests execute the production session/configuration policies with controlle SDK-host and atomic-writer boundaries and real INI file reads. They cover default off, one-shot consent, repeated polling without renewal, stop/shutdown fences, live-session preservation, save/read failure, retry and unrelated INI entries. +The size regressions verify exact-limit round trips and preservation of both the +file and runtime choice when an update would overflow the input bound. Source checks guard the startup hook, checkbox, SDK pin and build gates; they are not native GUI execution. Native CI compiles the full Cemu app on Apple silicon and Intel, checks its bundle/signature, and launches, normally quits and diff --git a/src/input/api/SDL/Switch2KitAutoConnectConfig.h b/src/input/api/SDL/Switch2KitAutoConnectConfig.h index 6e94814a14..2f1c2d3033 100644 --- a/src/input/api/SDL/Switch2KitAutoConnectConfig.h +++ b/src/input/api/SDL/Switch2KitAutoConnectConfig.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -37,7 +38,12 @@ class AutoConnectConfig data.put("Settings.AutoConnect", enabled ? "true" : "false"); std::ostringstream stream; boost::property_tree::write_ini(stream, data); - return writeAtomic(path, stream.str()); + // A successful save must remain readable on the next launch. Adding + // the key or changing true to false can grow a valid near-limit INI. + const auto bytes = stream.str(); + if (bytes.size() > MaxBytes) + return false; + return writeAtomic(path, bytes); } catch (const std::exception&) { @@ -46,6 +52,8 @@ class AutoConnectConfig } private: + static constexpr std::size_t MaxBytes = 65536; + static bool Read(const std::filesystem::path& path, boost::property_tree::ptree& data, bool& enabled) { try @@ -63,14 +71,14 @@ class AutoConnectConfig if (error || !std::filesystem::is_regular_file(status)) return false; const auto size = std::filesystem::file_size(path, error); - if (error || size > 65536) + if (error || size > MaxBytes) return false; std::ifstream file(path, std::ios::binary); if (!file) return false; - std::string bytes(65537, '\0'); + std::string bytes(MaxBytes + 1, '\0'); file.read(bytes.data(), static_cast(bytes.size())); - if (file.bad() || !file.eof() || file.gcount() > 65536) + if (file.bad() || !file.eof() || file.gcount() > static_cast(MaxBytes)) return false; bytes.resize(static_cast(file.gcount())); if (bytes.find('\0') != std::string::npos) diff --git a/tests/switch2kit/AutoConnectConfigSizeTests.cpp b/tests/switch2kit/AutoConnectConfigSizeTests.cpp new file mode 100644 index 0000000000..6e4a0e7c0d --- /dev/null +++ b/tests/switch2kit/AutoConnectConfigSizeTests.cpp @@ -0,0 +1,85 @@ +#include +#include +#include +#include +#include +#include +#include "input/api/SDL/Switch2KitAutoConnectConfig.h" +#include "input/api/SDL/Switch2KitSession.h" + +namespace +{ +std::string Read(const std::filesystem::path& path) +{ + std::ifstream input(path, std::ios::binary); + return {std::istreambuf_iterator(input), std::istreambuf_iterator()}; +} +void Write(const std::filesystem::path& path, const std::string& bytes) +{ + std::ofstream output(path, std::ios::binary | std::ios::trunc); + output.write(bytes.data(), static_cast(bytes.size())); + output.close(); + assert(output); +} +// Only the host boundary is controlled. The production session and INI parser +// execute unchanged; this test makes no claim about physical Bluetooth. +struct Host +{ + bool automatic = false; + int policies = 0; + int setAutomaticDiscovery(bool enabled) { automatic = enabled; ++policies; return 0; } + int start() { return 0; } + int discover() { return 0; } +}; +} +int main(int argc, char** argv) +{ + assert(argc == 2); + using CemuSwitch2Kit::AutoConnectConfig; + using Session = Switch2KitSession; + constexpr std::size_t limit = 65536; + const auto path = std::filesystem::path(argv[1]) / "size-limit.ini"; + int writes = 0; + // Deliberately simple writer: rejected output must never reach it. + auto write = [&](const auto& target, const std::string& bytes) { + ++writes; + Write(target, bytes); + return true; + }; + const std::string prefix = "[Settings]\nAutoConnect=true\nPadding="; + const auto full = prefix + std::string(limit - prefix.size() - 1, 'x') + "\n"; + Write(path, full); + bool enabled = false; + assert(AutoConnectConfig::Load(path, enabled) && enabled); + Session session; + session.LoadAutoConnect([&](bool& value) { return AutoConnectConfig::Load(path, value); }); + assert(session.StartOnce() == 0 && session.IsEnabled()); + const auto policies = session.GetHost().policies; + // Changing true to false grows a valid, full document by one byte. + assert(session.SetAutoConnect(false, [&](bool value) { + return AutoConnectConfig::Save(path, value, write); + }) == Session::ConfigurationError); + assert(writes == 0 && Read(path) == full); + assert(session.AutoConnect() && session.GetHost().automatic); + assert(session.IsEnabled() && session.GetHost().policies == policies); + assert(AutoConnectConfig::Load(path, enabled) && enabled); + + // An exactly-at-limit document must still round-trip when it does not grow. + assert(AutoConnectConfig::Save(path, true, write) && writes == 1); + assert(Read(path).size() == limit && AutoConnectConfig::Load(path, enabled) && enabled); + const auto almostFull = prefix + std::string(limit - prefix.size() - 2, 'x') + "\n"; + Write(path, almostFull); + assert(AutoConnectConfig::Save(path, false, write) && writes == 2); + assert(Read(path).size() == limit && AutoConnectConfig::Load(path, enabled) && !enabled); + + // Adding the missing setting/section can also push an otherwise valid INI + // over the read bound. Preserve every byte and never call the writer. + const std::string otherPrefix = "[Other]\nPadding="; + const auto other = otherPrefix + std::string(limit - otherPrefix.size() - 1, 'x') + "\n"; + Write(path, other); + assert(AutoConnectConfig::Load(path, enabled) && !enabled); + assert(!AutoConnectConfig::Save(path, true, write)); + assert(writes == 2 && Read(path) == other); + std::filesystem::remove(path); + std::cout << "PASS output size bound, exact-limit round trips, file and runtime preservation\n"; +} diff --git a/tests/switch2kit/test_autoconnect.py b/tests/switch2kit/test_autoconnect.py index b91615105e..a82fd9e574 100644 --- a/tests/switch2kit/test_autoconnect.py +++ b/tests/switch2kit/test_autoconnect.py @@ -24,15 +24,16 @@ def main(): raise SystemExit('A C++20 compiler is required') with tempfile.TemporaryDirectory(prefix='cemu-autoconnect-') as directory: work = Path(directory) - binary = work / 'autoconnect' - command = [compiler, '-std=c++20', '-pthread', '-Wall', '-Wextra', '-Werror', '-UNDEBUG', - '-I' + str(ROOT / 'src')] - command += ['-I' + str(path.resolve()) for path in args.include] - command += [str(ROOT / 'tests/switch2kit/AutoConnectTests.cpp'), '-o', str(binary)] + flags = [compiler, '-std=c++20', '-pthread', '-Wall', '-Wextra', '-Werror', '-UNDEBUG', + '-I' + str(ROOT / 'src')] + flags += ['-I' + str(path.resolve()) for path in args.include] if args.sanitize: - command += ['-fsanitize=address,undefined', '-fno-omit-frame-pointer'] - subprocess.run(command, check=True, timeout=120) - subprocess.run([str(binary), str(work)], check=True, timeout=30) + flags += ['-fsanitize=address,undefined', '-fno-omit-frame-pointer'] + for name in ('AutoConnectTests', 'AutoConnectConfigSizeTests'): + binary = work / name + command = flags + [str(ROOT / f'tests/switch2kit/{name}.cpp'), '-o', str(binary)] + subprocess.run(command, check=True, timeout=120) + subprocess.run([str(binary), str(work)], check=True, timeout=30) if __name__ == '__main__': diff --git a/tests/switch2kit/test_autoconnect_wiring.py b/tests/switch2kit/test_autoconnect_wiring.py index 12b845acac..c514ffa020 100644 --- a/tests/switch2kit/test_autoconnect_wiring.py +++ b/tests/switch2kit/test_autoconnect_wiring.py @@ -7,7 +7,7 @@ ROOT = Path(__file__).resolve().parents[2] SDK = ROOT / 'dependencies/Switch2Kit' -PIN = '731bd5f97c806e15277c9cd120d55515cef57940' +PIN = '51e36a8223f2c4254b8d9d9f43dc10c8cdb3ff33' def source(path): From d0fbce0b0cdb594cc84f133d1b6d9f943b81db13 Mon Sep 17 00:00:00 2001 From: Johnny D Date: Sat, 19 Sep 2026 18:28:05 -0400 Subject: [PATCH 4/4] Pin automatic connection to the merged Switch2Kit SDK Select d9129e3876f0d68aa7d13395dcff8e2abb38d609 after Switch2Kit #74 merged. Its source tree 50e0685fc5503ebcf1bbdcb5dac1f08007d94f5a is identical to the previously selected 1e6eac15fd4d4c93f15244eb66d79021c92d29fc; retain the full desktop transport/runtime fixes and SDLHost automatic-discovery methods without relying on a deleted feature branch. Update the gitlink, exact-pin integration guard and guide together. All Cemu production code, workflows and regression assertions remain unchanged. Verified original documentation/test blob hashes, the matching immutable SDK tree, Python syntax and whitespace locally. Earlier full Clang/GCC sanitizer results apply to the unchanged production source, not to a new native application CI run. Current-head native Cemu checks remain required before merge. No force-push, test bypass or release is performed. --- dependencies/Switch2Kit | 2 +- docs/Switch2Kit.md | 9 +++++---- tests/switch2kit/test_autoconnect_wiring.py | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/dependencies/Switch2Kit b/dependencies/Switch2Kit index 1e6eac15fd..d9129e3876 160000 --- a/dependencies/Switch2Kit +++ b/dependencies/Switch2Kit @@ -1 +1 @@ -Subproject commit 1e6eac15fd4d4c93f15244eb66d79021c92d29fc +Subproject commit d9129e3876f0d68aa7d13395dcff8e2abb38d609 diff --git a/docs/Switch2Kit.md b/docs/Switch2Kit.md index 087c7cda71..c713a1a30c 100644 --- a/docs/Switch2Kit.md +++ b/docs/Switch2Kit.md @@ -109,15 +109,16 @@ policy/start errors remain visible through successful polling and status refreshes. Find retries starting support, not saving settings. The SDK submodule is pinned to -`1e6eac15fd4d4c93f15244eb66d79021c92d29fc`, which combines the shared `SDLHost` +`d9129e3876f0d68aa7d13395dcff8e2abb38d609`, which combines the shared `SDLHost` automatic-discovery and policy-only start methods from [Switch2Kit PR #74](https://github.com/jmonster/Switch2Kit/pull/74) with the merged [desktop transport and runtime fixes](https://github.com/jmonster/Switch2Kit/pull/75). Do not substitute the older automatic-connection pin, which lacks those desktop fixes, or a library missing `s2k_set_automatic_discovery`. Source-integration -checks verify the matching pin. The SDK companion and this combined application's -own current-revision native checks remain merge gates; earlier separate branch -results do not qualify this combination. +checks verify the matching pin. Both SDK changes are merged; this permanent +commit has the same source tree as the previously reviewed SDK revision. This +application's own current-revision native checks remain required; earlier +separate branch results do not qualify the combined application. ## Controller differences and motion diff --git a/tests/switch2kit/test_autoconnect_wiring.py b/tests/switch2kit/test_autoconnect_wiring.py index e7a75dd768..14028d1327 100644 --- a/tests/switch2kit/test_autoconnect_wiring.py +++ b/tests/switch2kit/test_autoconnect_wiring.py @@ -7,7 +7,7 @@ ROOT = Path(__file__).resolve().parents[2] SDK = ROOT / 'dependencies/Switch2Kit' -PIN = '1e6eac15fd4d4c93f15244eb66d79021c92d29fc' +PIN = 'd9129e3876f0d68aa7d13395dcff8e2abb38d609' def source(path):