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 2d0898ce00..3e0e962c1c 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Use this controller-enabled Cemu directly: connect the controller, choose a play ## Quick start -Sign in to GitHub and select a successful application run for `feature/switch2kit-desktop-platforms` while this PR is unmerged. Choose the named **application** artifact below and check its native build/launch jobs, not a source or diagnostics archive. These are expiring development builds, not published production releases. Use [Build from source](#build-from-source-alternative) when a matching application artifact is unavailable. Ordinary upstream downloads do not include this integration. +Sign in to GitHub and select a successful application run for `feature/switch2kit-auto-connect` while this PR is unmerged. Choose the named **application** artifact below and check its native build/launch jobs, not a source or diagnostics archive. These are expiring development builds, not published production releases. Use [Build from source](#build-from-source-alternative) when a matching application artifact is unavailable. Ordinary upstream downloads do not include this integration. ### macOS @@ -33,11 +33,13 @@ Keep its DLLs, `resources`, `gameProfiles` and `Switch2KitNotices` beside it. Th ### Connect and play -1. Close competing controller apps/consoles. In Cemu, open **Options > Input settings**, click **Find Switch 2 Controllers**, and hold the controller's **Sync** button until its player lights sweep. Allow legitimate Bluetooth access prompts. The search lasts 60 seconds; repeat Find to retry. +1. Close competing controller apps/consoles. In Cemu, open **Options > Input settings**, click **Find Switch 2 Controllers**, and hold the controller's **Sync** button until its player lights sweep. Allow legitimate Bluetooth access prompts. With automatic connection off, the search lasts 60 seconds; repeat Find to retry. 2. Select the desired controller tab (for example, **Controller 1**). In the physical-controller dropdown beside **Emulated controller**, select the connected **GameCube** or **Pro Controller 2**. Cemu applies recommended mappings. An empty first slot becomes a Wii U GamePad, other empty slots become Wii U Pro Controllers; existing GamePad/Pro/Classic types are retained. Choose a type the game accepts. 3. Verify buttons, sticks and triggers in Input settings. Open the physical controller's **Settings** to adjust **Rumble** and use **Test rumble**, then open the game. NSO GameCube trigger travel and full clicks remain independent inputs; Pro triggers are digital. GameCube sticks have no click buttons, so games needing those actions require additional bindings. -Saved assignments follow the physical controller rather than its discovery order. Reopen the same application and use **Find** again on subsequent launches; Cemu does not implement Dolphin's automatic-reconnect option. **Disconnect Switch 2 Controllers** stops the backend without erasing saved profiles. Replacing a populated slot asks first and creates a **Before Switch2Kit-…** backup; reconnecting does not overwrite custom mappings. +Saved assignments follow the physical controller rather than its discovery order. To connect on later launches and after long pauses without reopening Input settings, enable **Automatically connect Switch 2 controllers**. It is off by default and uses Bluetooth radio resources. Unchecking it stops automatic discovery without disconnecting ready controllers. With it off, use **Find** 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; saved consent applies again on the next launch. Replacing a populated slot asks first and creates a **Before Switch2Kit-…** backup; reconnecting does not overwrite custom mappings. See [Automatic connection](docs/Switch2Kit.md#automatic-connection) for consent and troubleshooting details. For Joy-Con 2, discover each half with Find/Sync, choose the emulated controller type, and add both via **+ > SDLController** to the same player slot. These are separate complementary sources, not a system-wide paired virtual controller. Motion requires a measured, device-matching `.s2kmotion` profile selected in physical-controller Settings and **Use motion** enabled. Never use synthetic test profiles for gameplay. See [controller differences and motion](docs/Switch2Kit.md#controller-differences-and-motion). @@ -46,7 +48,7 @@ For Joy-Con 2, discover each half with Find/Sync, choose the emulated controller Follow the [platform build guide](docs/Switch2Kit.md#build-from-source). Start from this implementation branch while the PR is unmerged: ```sh -git clone --branch feature/switch2kit-desktop-platforms --recurse-submodules https://github.com/jmonster/Cemu.git cemu-switch2kit +git clone --branch feature/switch2kit-auto-connect --recurse-submodules https://github.com/jmonster/Cemu.git cemu-switch2kit cd cemu-switch2kit ``` @@ -101,7 +103,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 3d3ce3a605..d9129e3876 160000 --- a/dependencies/Switch2Kit +++ b/dependencies/Switch2Kit @@ -1 +1 @@ -Subproject commit 3d3ce3a605733c47db061af687168ad5914cbf0c +Subproject commit d9129e3876f0d68aa7d13395dcff8e2abb38d609 diff --git a/docs/Switch2Kit.md b/docs/Switch2Kit.md index fb4cee3c4f..c713a1a30c 100644 --- a/docs/Switch2Kit.md +++ b/docs/Switch2Kit.md @@ -6,7 +6,7 @@ Use the [controller-enabled downloads](../README.md#quick-start), launch that Ce ## Applications and prerequisites -Sign in to GitHub and select a successful run for `feature/switch2kit-desktop-platforms` while this PR is unmerged. Download the application artifact, not a source or diagnostics archive. The outer GitHub artifact ZIP contains the application ZIP/tarball. Desktop application artifacts expire after 14 days; use the source fallback when no matching successful artifact remains. These are development builds, not published production releases. A workflow configuration or a different revision's result is not qualification of the selected download. +Sign in to GitHub and select a successful run for `feature/switch2kit-auto-connect` while this PR is unmerged. Download the application artifact, not a source or diagnostics archive. The outer GitHub artifact ZIP contains the application ZIP/tarball. Desktop application artifacts expire after 14 days; use the source fallback when no matching successful artifact remains. These are development builds, not published production releases. A workflow configuration or a different revision's result is not qualification of the selected download. ### macOS @@ -46,7 +46,8 @@ Enable **Settings > Bluetooth & devices > Bluetooth** and use Cemu's Find/Sync p 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 @@ -65,8 +66,59 @@ 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 GUI main run loop +after SDL initialization, even when Input settings is never opened, on every +platform with this native backend enabled. + +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; retry explicitly after it finishes. + +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 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 +`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. 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 @@ -107,7 +159,7 @@ backend does not invent sensor values or integrate across a disconnect or gap. `ENABLE_SWITCH2KIT` is OFF by default. Disabled builds do not require Swift and retain upstream platform/deployment requirements. Enabled Linux/Windows builds use native SDL3 and the desktop backend; only enabled macOS builds require a macOS 15+ app bundle. Initialize the SDK revision selected by this maintained fork, not a moving SDK branch or the SDK's separate upstream patches. While the PR is unmerged: ```sh -git clone --branch feature/switch2kit-desktop-platforms --recurse-submodules https://github.com/jmonster/Cemu.git cemu-switch2kit +git clone --branch feature/switch2kit-auto-connect --recurse-submodules https://github.com/jmonster/Cemu.git cemu-switch2kit cd cemu-switch2kit ``` @@ -141,10 +193,45 @@ For updates, quit Cemu, run `git pull --ff-only`, update the recorded submodules ## Qualification and troubleshooting -A missing **Find Switch 2 Controllers** button means a backend-disabled binary was launched. For absent input, verify Bluetooth power/access, Sync mode, competing connections, physical selection and the emulated type accepted by the game, then retry Find. A controller already assigned to another slot must be removed there before the quick setup shortcut can move it. Cemu requires Find again after restarting; it does not implement Dolphin's automatic-reconnection option. A changed adapter or rotating device address can change physical identity, so verify player assignments after such a change. +A missing **Find Switch 2 Controllers** button means a backend-disabled binary was launched. For absent input, verify Bluetooth power/access, Sync mode, competing connections, physical selection and the emulated type accepted by the game, then retry Find. A controller already assigned to another slot must be removed there before the quick setup shortcut can move it. Cemu requires Find again after restarting when automatic connection is off; with it enabled, saved consent starts continuous discovery on the next launch. A changed adapter or rotating device address can change physical identity, so verify player assignments after such a change. Desktop CI builds the complete application, archives it, extracts that exact archive into a new location, checks that the GUI loads its packaged controller/Swift libraries, requests normal quit and relaunches with a private profile. It deliberately seeds noninteractive test settings; pristine first-use dialogs, downloaded-app security approval and physical hardware are not tested. No existing user configuration is erased. The artifact is qualified only after these jobs pass for its exact revision. -`python3 tests/switch2kit/run.py --sanitize` retains executable mapping, identity, lifecycle and rollback regressions against controlled host/storage boundaries. `python3 tests/switch2kit/test_host_file.py --sdk dependencies/Switch2Kit --sanitize` exercises the real bounded file reader; native MSVC coverage is retained. SDK tests cover protocol values, real C/SDL consumers, calibrated sample handling, rumble bounds, cancellation, runtime relocation and required notices. Source-contract checks supplement those tests, not prose length or English wording restrictions. +`python3 tests/switch2kit/run.py --sanitize` retains executable mapping, identity, lifecycle, rollback, automatic-connection and configuration regressions against controlled host/storage boundaries. Use `--sdl /path/to/SDL-source` and `--sdk /path/to/Switch2Kit` for a separate checkout of the same pinned SDK revision. `python3 tests/switch2kit/test_host_file.py --sdk dependencies/Switch2Kit --sanitize` exercises the real bounded file reader; native MSVC coverage is retained. SDK tests cover protocol values, real C/SDL consumers, calibrated sample handling, rumble bounds, cancellation, runtime relocation and required notices. Source-contract checks supplement those tests, not prose length or English wording restrictions. + +The standalone automatic-connection suite needs 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, polling without renewal, stop/shutdown fences, live-session +preservation, save/read failure, retry and unrelated INI entries. Size regressions +verify exact-limit round trips and preservation of both the file and runtime choice +when an update would exceed the input bound. Source checks guard the startup hook, +checkbox, SDK pin and native/backend-disabled gates; they are not GUI or physical +Bluetooth execution. The desktop lifecycle and Debug/Release adapter CRT configure +regressions remain in the same runner. + +### Automatic-connection hardware acceptance + +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. Record separate hardware acceptance for each model/firmware/OS/adapter and tested commit: first pairing and denied-access retry; all controls and releases; independent GameCube trigger travel/clicks; rumble start/stop; two identical controllers reconnecting in reversed order; persisted player assignments after restart; Bluetooth/adapter loss; explicit disconnect; normal shutdown; measured motion where used; and an actual gameplay session. Joy-Con 2 acceptance must include both complementary sources in one slot. No fixture profile or automated pass substitutes for those physical results. diff --git a/src/gui/wxgui/input/InputSettings2.cpp b/src/gui/wxgui/input/InputSettings2.cpp index 5acc2230cb..41a8cac4f6 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()); diff --git a/src/input/api/SDL/SDLControllerProvider.cpp b/src/input/api/SDL/SDLControllerProvider.cpp index 773abc59e3..6635842be3 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"); + if (state.bluetooth == S2K_BT_UNAUTHORIZED) return "Allow Cemu Bluetooth access in your system settings."; + if (state.bluetooth == S2K_BT_OFF) return "Turn on Bluetooth in your system settings."; + if (state.bluetooth == S2K_BT_UNSUPPORTED) return "Bluetooth is unavailable on this system."; + 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() diff --git a/src/input/api/SDL/SDLControllerProvider.h b/src/input/api/SDL/SDLControllerProvider.h index d5acd5009f..c25d31b908 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..2f1c2d3033 --- /dev/null +++ b/src/input/api/SDL/Switch2KitAutoConnectConfig.h @@ -0,0 +1,103 @@ +#pragma once + +#include +#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); + // 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&) + { + return false; + } + } + +private: + static constexpr std::size_t MaxBytes = 65536; + + 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 > MaxBytes) + return false; + std::ifstream file(path, std::ios::binary); + if (!file) + return false; + std::string bytes(MaxBytes + 1, '\0'); + file.read(bytes.data(), static_cast(bytes.size())); + 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) + 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 84332d5e1f..b5290dd00f 100644 --- a/src/input/api/SDL/Switch2KitSession.h +++ b/src/input/api/SDL/Switch2KitSession.h @@ -1,41 +1,114 @@ #pragma once -// Start/Stop/Pump are called on Cemu's GUI main thread on every native platform. 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 GUI main thread +// on every native platform. 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/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/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 547bb6371e..3630287b90 100755 --- a/tests/switch2kit/run.py +++ b/tests/switch2kit/run.py @@ -60,6 +60,12 @@ def run(): host_file.append('--sanitize') subprocess.run(host_file, check=True) 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) subprocess.run(['python3', str(ROOT / 'tests/switch2kit/test_desktop_lifecycle.py'), '--sdk', str(sdk)], check=True) diff --git a/tests/switch2kit/test_autoconnect.py b/tests/switch2kit/test_autoconnect.py new file mode 100644 index 0000000000..a82fd9e574 --- /dev/null +++ b/tests/switch2kit/test_autoconnect.py @@ -0,0 +1,40 @@ +#!/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) + 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: + 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__': + main() diff --git a/tests/switch2kit/test_autoconnect_wiring.py b/tests/switch2kit/test_autoconnect_wiring.py new file mode 100644 index 0000000000..14028d1327 --- /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 = 'd9129e3876f0d68aa7d13395dcff8e2abb38d609' + + +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__])