diff --git a/.github/workflows/linux-bluez.yml b/.github/workflows/linux-bluez.yml index 0dc6ed8..a41d132 100644 --- a/.github/workflows/linux-bluez.yml +++ b/.github/workflows/linux-bluez.yml @@ -24,7 +24,7 @@ jobs: - name: Install test dependencies run: | apt-get update - apt-get install -y --no-install-recommends cmake ninja-build make g++ python3 dbus libsystemd0 nodejs + apt-get install -y --no-install-recommends cmake ninja-build make g++ python3 dbus libsystemd0 nodejs libx11-dev xvfb openbox wmctrl x11-utils - name: Preserve the exact source under test run: | # The runner owns this bind-mounted checkout, but the Swift container @@ -43,6 +43,8 @@ jobs: swift test -Xswiftc -warnings-as-errors bash tests/linux-bluez/run.sh bash tests/run.sh + - name: Exercise the real isolated X11 observer and readiness regression + run: python3 tests/emulator-launch/test_linux_native.py - name: Release package tests run: swift test -c release -Xswiftc -warnings-as-errors - name: Relocated native facade and real C++ consumers diff --git a/.github/workflows/windows-native.yml b/.github/workflows/windows-native.yml new file mode 100644 index 0000000..025479b --- /dev/null +++ b/.github/workflows/windows-native.yml @@ -0,0 +1,86 @@ +name: Windows native controllers +on: + pull_request: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: windows-native-${{ github.ref }} + cancel-in-progress: true +jobs: + windows: + strategy: + fail-fast: false + matrix: + include: + - os: windows-2022 + swift: 6.2.1 + - os: windows-2025-vs2026 + swift: 6.3.3 + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - uses: compnerd/gha-setup-swift@397094e75494a93fa8d81db0268dbc8f5d6cf7c6 + with: + swift-version: swift-${{ matrix.swift }}-release + swift-build: ${{ matrix.swift }}-RELEASE + - name: Build native WinRT transport and run portable package tests + shell: pwsh + run: | + swift --version + swift test -Xswiftc -warnings-as-errors 2>&1 | Tee-Object windows-tests.log + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + swift build -c release --product Switch2KitC 2>&1 | Tee-Object windows-build.log + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + swiftc -print-target-info > windows-target-info.log + Get-ChildItem .build -Recurse -Include '*Switch2KitC*' | Select-Object FullName | Out-File windows-library-paths.log + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + repository: libsdl-org/SDL + ref: f87239e71e42da91ca317a12eefb82cfbf3393eb + path: test-sdl + persist-credentials: false + - name: Build and execute the real C and SDL consumers + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + $vs = & $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath + if (-not $vs) { throw 'Visual C++ x64 tools are required' } + cmd /c "`"$vs\Common7\Tools\VsDevCmd.bat`" -arch=x64 -host_arch=x64 >nul && set" | ForEach-Object { + if ($_ -match '^([^=]+)=(.*)$') { [Environment]::SetEnvironmentVariable($Matches[1], $Matches[2], 'Process') } + } + cmake -S tests/c-consumer -B build-c -G Ninja -DCMAKE_BUILD_TYPE=Release + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cmake --build build-c --parallel 3 2>&1 | Tee-Object windows-c-build.log + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + ctest --test-dir build-c --output-on-failure 2>&1 | Tee-Object windows-c-tests.log + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cmake -S tests/sdl-inprocess -B build-sdl -G Ninja -DCMAKE_BUILD_TYPE=Release "-DS2K_SDL_SOURCE=$PWD/test-sdl" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cmake --build build-sdl --parallel 3 2>&1 | Tee-Object windows-sdl-build.log + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + ctest --test-dir build-sdl --output-on-failure 2>&1 | Tee-Object windows-sdl-tests.log + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + python tests/emulator-launch/test_windows.py 2>&1 | Tee-Object windows-host-supervisor-tests.log + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Execute extracted consumers without the compiler or developer PATH + shell: pwsh + run: | + python tests/windows-relocation/test_relocation.py + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + python tests/c-consumer/relocate-windows.py --c-build build-c --sdl-build build-sdl 2>&1 | Tee-Object windows-relocation.log + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Preserve native diagnostics + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: windows-native-diagnostics-${{ matrix.swift }} + path: | + windows-*.log + windows-relocation.json + if-no-files-found: warn + retention-days: 7 diff --git a/Integrations/CMake/CMakeLists.txt b/Integrations/CMake/CMakeLists.txt index 0c558fc..4802699 100644 --- a/Integrations/CMake/CMakeLists.txt +++ b/Integrations/CMake/CMakeLists.txt @@ -17,7 +17,12 @@ file(GLOB_RECURSE _s2k_sources CONFIGURE_DEPENDS "${SWITCH2KIT_SOURCE_ROOT}/Sources/Switch2Kit/*.swift" "${SWITCH2KIT_SOURCE_ROOT}/Sources/Switch2KitC/*.swift" "${SWITCH2KIT_SOURCE_ROOT}/Sources/Switch2KitCABI/*" - "${SWITCH2KIT_SOURCE_ROOT}/Sources/Switch2KitDBus/*") + "${SWITCH2KIT_SOURCE_ROOT}/Sources/Switch2KitDBus/*" + "${SWITCH2KIT_SOURCE_ROOT}/Sources/Switch2KitWinRT/*") +if(WIN32) + include("${CMAKE_CURRENT_LIST_DIR}/Windows.cmake") + return() +endif() if(APPLE) set(_s2k_archs "${CMAKE_OSX_ARCHITECTURES}") if(NOT _s2k_archs) @@ -70,7 +75,7 @@ elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") DEPENDS ${_s2k_sources} "${SWITCH2KIT_SOURCE_ROOT}/Package.swift" "${CMAKE_CURRENT_LIST_FILE}" VERBATIM) else() - message(FATAL_ERROR "Switch2Kit supports macOS and Linux hosts only") + message(FATAL_ERROR "Switch2Kit supports macOS, Linux and Windows desktop hosts") endif() add_custom_target(Switch2KitCBuild DEPENDS "${_s2k_library}") add_library(Switch2Kit::C SHARED IMPORTED GLOBAL) diff --git a/Integrations/CMake/Linux.cmake b/Integrations/CMake/Linux.cmake index e8f99f0..6c41bac 100644 --- a/Integrations/CMake/Linux.cmake +++ b/Integrations/CMake/Linux.cmake @@ -1,5 +1,8 @@ -# Native Linux installation. Swift runtime libraries remain distribution/host -# dependencies; unlike a macOS bundle, this does not claim a self-contained app. +# Native Linux installation. Bundle the compiler-selected Swift runtime closure; +# libc, desktop libraries, graphics drivers and BlueZ remain OS prerequisites. +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + include("${CMAKE_CURRENT_LIST_DIR}/Runtime.cmake") +endif() function(switch2kit_install_linux target) if(NOT CMAKE_SYSTEM_NAME STREQUAL "Linux") message(FATAL_ERROR "switch2kit_install_linux requires a Linux target") @@ -8,7 +11,14 @@ function(switch2kit_install_linux target) get_filename_component(_root "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/../.." ABSOLUTE) file(RELATIVE_PATH _library_from_bin "${CMAKE_INSTALL_FULL_BINDIR}" "${CMAKE_INSTALL_FULL_LIBDIR}") set_property(TARGET "${target}" APPEND PROPERTY INSTALL_RPATH "$ORIGIN/${_library_from_bin}") - install(FILES "$" DESTINATION "${CMAKE_INSTALL_LIBDIR}") + install(CODE " + execute_process(COMMAND \"${CMAKE_COMMAND}\" + \"-DS2K_LIBRARY=$\" + \"-DS2K_DESTINATION=\$ENV{DESTDIR}\${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_LIBDIR}\" + \"-DS2K_NOTICES=\$ENV{DESTDIR}\${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_DATADIR}/Switch2KitNotices\" + \"-DS2K_RUNTIME_CONFIG=${SWITCH2KIT_RUNTIME_CONFIG}\" + -P \"${SWITCH2KIT_RUNTIME_SCRIPT}\" COMMAND_ERROR_IS_FATAL ANY) + ") install(FILES "${_root}/CREDITS.md" DESTINATION "${CMAKE_INSTALL_DATADIR}/Switch2KitNotices") install(DIRECTORY "${_root}/LICENSES" DESTINATION "${CMAKE_INSTALL_DATADIR}/Switch2KitNotices") endfunction() diff --git a/Integrations/CMake/Runtime.cmake b/Integrations/CMake/Runtime.cmake new file mode 100644 index 0000000..0a651d5 --- /dev/null +++ b/Integrations/CMake/Runtime.cmake @@ -0,0 +1,70 @@ +# Build-time runtime discovery. Included only by enabled Linux/Windows hosts. +execute_process(COMMAND "${SWITCH2KIT_SWIFTC}" -print-target-info + OUTPUT_VARIABLE _s2k_runtime_info COMMAND_ERROR_IS_FATAL ANY) +string(JSON _s2k_count LENGTH "${_s2k_runtime_info}" paths runtimeLibraryPaths) +set(_s2k_runtime_paths) +if(_s2k_count GREATER 0) + math(EXPR _s2k_last "${_s2k_count} - 1") + foreach(_index RANGE 0 ${_s2k_last}) + string(JSON _path GET "${_s2k_runtime_info}" paths runtimeLibraryPaths ${_index}) + file(TO_CMAKE_PATH "${_path}" _path) + if(IS_DIRECTORY "${_path}") + list(APPEND _s2k_runtime_paths "${_path}") + endif() + endforeach() +endif() +if(NOT _s2k_runtime_paths) + message(FATAL_ERROR "The selected Swift compiler did not identify runtime library directories") +endif() +get_filename_component(_s2k_swift_bin "${SWITCH2KIT_SWIFTC}" DIRECTORY) +find_file(SWITCH2KIT_SWIFT_LICENSE LICENSE.txt + PATHS "${_s2k_swift_bin}/../share/swift" NO_DEFAULT_PATH) +find_program(_s2k_git git REQUIRED) +string(JSON _s2k_compiler_version GET "${_s2k_runtime_info}" compilerVersion) +if(_s2k_compiler_version MATCHES "Swift version 6\\.2\\.1([ (]|$)") + set(_s2k_swift_tag swift-6.2.1-RELEASE) + set(_s2k_icu_tag release-74-1) + set(_s2k_icu_blob 9f54372febca06e134bbc9d4700a1ebfeda13843) +elseif(_s2k_compiler_version MATCHES "Swift version 6\\.3\\.3([ (]|$)") + set(_s2k_swift_tag swift-6.3.3-RELEASE) + set(_s2k_icu_tag release-76-1) + set(_s2k_icu_blob 180db98fcc66ca7797940b7fad5a7f3987d9f145) +endif() +set(SWITCH2KIT_RUNTIME_ICU_LICENSE "" CACHE FILEPATH + "Explicit ICU license/third-party notices for a different Swift runtime distribution") +set(_s2k_system_paths) +if(WIN32) + # OS libraries are prerequisites, not part of the Swift redistribution. + file(TO_CMAKE_PATH "$ENV{SystemRoot}" _s2k_windows_root) + if(NOT IS_DIRECTORY "${_s2k_windows_root}/System32") + message(FATAL_ERROR "SystemRoot must identify the Windows installation") + endif() + set(_s2k_system_paths "${_s2k_windows_root}/System32" "${_s2k_windows_root}") + find_program(_s2k_inspector dumpbin REQUIRED) + set(_s2k_platform "windows+pe") + set(_s2k_inspector_kind dumpbin) +else() + find_program(_s2k_inspector objdump REQUIRED) + find_program(_s2k_readelf readelf REQUIRED) + set(_s2k_platform "linux+elf") + set(_s2k_inspector_kind objdump) +endif() +set(SWITCH2KIT_RUNTIME_CONFIG "${CMAKE_CURRENT_BINARY_DIR}/Switch2KitRuntimePaths.cmake" + CACHE INTERNAL "Build-only Swift runtime deployment inputs") +file(CONFIGURE OUTPUT "${SWITCH2KIT_RUNTIME_CONFIG}" CONTENT [=[ +set(S2K_RUNTIME_DIRS [==[@_s2k_runtime_paths@]==]) +set(S2K_SYSTEM_RUNTIME_DIRS [==[@_s2k_system_paths@]==]) +set(S2K_SWIFT_LICENSE [==[@SWITCH2KIT_SWIFT_LICENSE@]==]) +set(CMAKE_GET_RUNTIME_DEPENDENCIES_PLATFORM "@_s2k_platform@") +set(CMAKE_GET_RUNTIME_DEPENDENCIES_TOOL "@_s2k_inspector_kind@") +set(CMAKE_GET_RUNTIME_DEPENDENCIES_COMMAND [==[@_s2k_inspector@]==]) +set(S2K_READELF [==[@_s2k_readelf@]==]) +set(S2K_GIT [==[@_s2k_git@]==]) +set(S2K_SWIFT_TAG "@_s2k_swift_tag@") +set(S2K_ICU_TAG "@_s2k_icu_tag@") +set(S2K_ICU_BLOB "@_s2k_icu_blob@") +set(S2K_ICU_LICENSE [==[@SWITCH2KIT_RUNTIME_ICU_LICENSE@]==]) +set(S2K_COMPILER_VERSION [==[@_s2k_compiler_version@]==]) +]=] @ONLY) +set(SWITCH2KIT_RUNTIME_SCRIPT "${CMAKE_CURRENT_LIST_DIR}/StageDesktopRuntime.cmake" + CACHE INTERNAL "Swift runtime deployment script") diff --git a/Integrations/CMake/RuntimeNotices.cmake b/Integrations/CMake/RuntimeNotices.cmake new file mode 100644 index 0000000..5e2d927 --- /dev/null +++ b/Integrations/CMake/RuntimeNotices.cmake @@ -0,0 +1,47 @@ +# Exact upstream license bytes may be preseeded in this build-only cache for +# offline builds. Never silently archive a runtime without its license texts. +function(_s2k_license name url blob output) + get_filename_component(_cache "${S2K_RUNTIME_CONFIG}" DIRECTORY) + set(_path "${_cache}/runtime-notices/${name}") + file(MAKE_DIRECTORY "${_cache}/runtime-notices") + # A concurrent deployment must not hash a partially downloaded cache file. + # This per-license lock is independent of the application copy lock and is + # released on function return. The download itself remains bounded at 60 s. + file(LOCK "${_path}.lock" GUARD FUNCTION TIMEOUT 120) + if(NOT EXISTS "${_path}") + file(DOWNLOAD "${url}" "${_path}" TLS_VERIFY ON STATUS _status TIMEOUT 60) + list(GET _status 0 _code) + if(NOT _code EQUAL 0) + file(REMOVE "${_path}") + message(FATAL_ERROR "Could not obtain ${name}: ${_status}; preseed ${_path} for offline packaging") + endif() + endif() + execute_process(COMMAND "${S2K_GIT}" hash-object --no-filters "${_path}" + OUTPUT_VARIABLE _actual OUTPUT_STRIP_TRAILING_WHITESPACE COMMAND_ERROR_IS_FATAL ANY) + if(NOT _actual STREQUAL "${blob}") + message(FATAL_ERROR "Altered or incorrect upstream license: ${_path}") + endif() + set(${output} "${_path}" PARENT_SCOPE) +endfunction() + +macro(switch2kit_runtime_notices) + if(NOT EXISTS "${S2K_SWIFT_LICENSE}") + if(NOT S2K_SWIFT_TAG) + message(FATAL_ERROR "Set SWITCH2KIT_SWIFT_LICENSE to the selected Swift distribution's LICENSE.txt") + endif() + _s2k_license(Swift-${S2K_SWIFT_TAG}.txt + "https://raw.githubusercontent.com/swiftlang/swift/${S2K_SWIFT_TAG}/LICENSE.txt" + "61b0c78195f2d00acaf658000eeca6ad406a3a29" S2K_SWIFT_LICENSE) + endif() + if(NOT S2K_ICU_LICENSE) + if(NOT S2K_ICU_TAG OR NOT S2K_ICU_BLOB) + message(FATAL_ERROR "Supply SWITCH2KIT_RUNTIME_ICU_LICENSE for this Swift distribution; automated deployment is qualified with 6.2.1 and 6.3.3") + endif() + _s2k_license(ICU-${S2K_ICU_TAG}.txt + "https://raw.githubusercontent.com/unicode-org/icu/${S2K_ICU_TAG}/LICENSE" + "${S2K_ICU_BLOB}" S2K_ICU_LICENSE) + endif() + if(NOT EXISTS "${S2K_ICU_LICENSE}") + message(FATAL_ERROR "The runtime ICU license and third-party notices are required") + endif() +endmacro() diff --git a/Integrations/CMake/StageDesktopRuntime.cmake b/Integrations/CMake/StageDesktopRuntime.cmake new file mode 100644 index 0000000..f349cb9 --- /dev/null +++ b/Integrations/CMake/StageDesktopRuntime.cmake @@ -0,0 +1,177 @@ +cmake_minimum_required(VERSION 3.24) +if(POLICY CMP0207) + cmake_policy(SET CMP0207 NEW) +endif() +# Operate on application-owned copies, never on the compiler installation. +foreach(_argument S2K_LIBRARY S2K_DESTINATION S2K_NOTICES S2K_RUNTIME_CONFIG) + if(NOT DEFINED ${_argument} OR "${${_argument}}" STREQUAL "") + message(FATAL_ERROR "Runtime deployment requires ${_argument}") + endif() +endforeach() +include("${S2K_RUNTIME_CONFIG}") +# The facade is not the only Swift client: a linked host/plugin/fixture can +# import runtime libraries that an optimized facade does not reference. +set(_application_libraries "${S2K_LIBRARY}" ${S2K_EXTRA_LIBRARIES}) +list(REMOVE_DUPLICATES _application_libraries) +set(_application_directories) +foreach(_library IN LISTS _application_libraries) + if(NOT EXISTS "${_library}" OR IS_DIRECTORY "${_library}") + message(FATAL_ERROR "A built application library is required: ${_library}") + endif() + get_filename_component(_directory "${_library}" DIRECTORY) + list(APPEND _application_directories "${_directory}") +endforeach() +set(_executables) +if(DEFINED S2K_EXECUTABLE AND NOT S2K_EXECUTABLE STREQUAL "") + if(NOT EXISTS "${S2K_EXECUTABLE}" OR IS_DIRECTORY "${S2K_EXECUTABLE}") + message(FATAL_ERROR "The built host executable is required: ${S2K_EXECUTABLE}") + endif() + list(APPEND _executables "${S2K_EXECUTABLE}") + get_filename_component(_directory "${S2K_EXECUTABLE}" DIRECTORY) + list(APPEND _application_directories "${_directory}") +endif() +list(REMOVE_DUPLICATES _application_directories) +# Stop at files actually resolved inside the Windows installation. Filtering +# names before resolution would also hide missing application DLLs. Filtering +# after this traversal would be too late: OS internals can have optional imports +# and another copy of the C++ runtime, neither belonging in the Swift closure. +set(_system_libraries) +foreach(_directory IN LISTS S2K_SYSTEM_RUNTIME_DIRS) + file(GLOB _libraries LIST_DIRECTORIES false "${_directory}/*.[dD][lL][lL]") + list(APPEND _system_libraries ${_libraries}) +endforeach() +set(_scan_libraries ${_application_libraries}) +set(_scan_executables ${_executables}) +set(_scan_directories ${_application_directories} ${S2K_RUNTIME_DIRS} ${S2K_SYSTEM_RUNTIME_DIRS}) +if(CMAKE_GET_RUNTIME_DEPENDENCIES_PLATFORM STREQUAL "windows+pe") + # Windows searches beside each importing binary before DIRECTORIES. Inspect + # isolated copies of declared application roots so an old staged runtime + # cannot mask a missing compiler dependency or conflict with the original. + # Independent scans must not serialize behind the deployment copy lock. + get_filename_component(_destination_key "${S2K_DESTINATION}" ABSOLUTE) + string(TOLOWER "${_destination_key}" _destination_key) + string(SHA256 _destination_key "${_destination_key}") + set(_deployment_lock "${S2K_RUNTIME_CONFIG}.${_destination_key}.lock") + set(_root_names) + foreach(_root IN LISTS _application_libraries _executables) + get_filename_component(_name "${_root}" NAME) + string(TOLOWER "${_name}" _name) + if(_name IN_LIST _root_names) + message(FATAL_ERROR "Ambiguous application library/executable name: ${_name}") + endif() + list(APPEND _root_names "${_name}") + endforeach() + string(RANDOM LENGTH 16 ALPHABET 0123456789abcdef _scan_id) + set(_scan_root "${S2K_RUNTIME_CONFIG}.scan-${_scan_id}") + file(MAKE_DIRECTORY "${_scan_root}") + set(_scan_libraries) + set(_scan_executables) + foreach(_kind libraries executables) + if(_kind STREQUAL "libraries") + set(_roots ${_application_libraries}) + else() + set(_roots ${_executables}) + endif() + foreach(_root IN LISTS _roots) + get_filename_component(_name "${_root}" NAME) + file(COPY "${_root}" DESTINATION "${_scan_root}") + list(APPEND _scan_${_kind} "${_scan_root}/${_name}") + endforeach() + endforeach() + set(_scan_directories "${_scan_root}" ${S2K_RUNTIME_DIRS} ${S2K_SYSTEM_RUNTIME_DIRS}) +endif() +file(GET_RUNTIME_DEPENDENCIES + EXECUTABLES ${_scan_executables} + LIBRARIES ${_scan_libraries} + DIRECTORIES ${_scan_directories} + POST_EXCLUDE_FILES ${_system_libraries} + PRE_EXCLUDE_REGEXES "^api-ms-" "^ext-ms-" + RESOLVED_DEPENDENCIES_VAR _resolved + UNRESOLVED_DEPENDENCIES_VAR _unresolved + CONFLICTING_DEPENDENCIES_PREFIX _conflicts) +if(_unresolved) + message(FATAL_ERROR "Unresolved runtime dependencies: ${_unresolved}") +endif() +if(_conflicts_FILENAMES) + set(_details "") + foreach(_name IN LISTS _conflicts_FILENAMES) + string(APPEND _details "\n ${_name}: ${_conflicts_${_name}}") + endforeach() + message(FATAL_ERROR "Ambiguous runtime dependencies:${_details}") +endif() +# License acquisition is build/install work, never an application startup request. +# Git blob validation pins complete upstream license texts, not documentation prose. +include("${CMAKE_CURRENT_LIST_DIR}/RuntimeNotices.cmake") +switch2kit_runtime_notices() +file(REAL_PATH "${S2K_DESTINATION}" _destination) +foreach(_directory IN LISTS S2K_RUNTIME_DIRS) + file(REAL_PATH "${_directory}" _runtime) + cmake_path(IS_PREFIX _runtime "${_destination}" NORMALIZE _inside_runtime) + if(_inside_runtime) + message(FATAL_ERROR "Refusing to stage runtime libraries into the compiler installation") + endif() +endforeach() +set(_copies ${_application_libraries}) +set(_runtime_libraries) +foreach(_library IN LISTS _resolved) + file(REAL_PATH "${_library}" _real) + foreach(_directory IN LISTS S2K_RUNTIME_DIRS) + file(REAL_PATH "${_directory}" _root) + # CMake's path-prefix comparison is case-sensitive even on Windows. + if(CMAKE_GET_RUNTIME_DEPENDENCIES_PLATFORM STREQUAL "windows+pe") + string(TOLOWER "${_real}" _candidate) + string(TOLOWER "${_root}" _root) + else() + set(_candidate "${_real}") + endif() + cmake_path(IS_PREFIX _root "${_candidate}" NORMALIZE _owned) + if(_owned) + list(APPEND _runtime_libraries "${_library}") + break() + endif() + endforeach() +endforeach() +if(DEFINED _scan_root) + file(REMOVE_RECURSE "${_scan_root}") +endif() +if(NOT _runtime_libraries) + message(FATAL_ERROR "No Swift runtime dependency was resolved inside the selected compiler's runtime directories") +endif() +list(APPEND _copies ${_runtime_libraries}) +list(REMOVE_DUPLICATES _copies) +list(LENGTH _copies _count) +# Dependency inspection and license acquisition above operate independently. +# Serialize only the short mutation of shared application-owned output files. +if(DEFINED _deployment_lock) + file(LOCK "${_deployment_lock}" GUARD PROCESS TIMEOUT 60) +endif() +file(MAKE_DIRECTORY "${S2K_DESTINATION}" "${S2K_NOTICES}/SwiftRuntime") +foreach(_source IN LISTS _copies) + get_filename_component(_name "${_source}" NAME) + set(_copy "${S2K_DESTINATION}/${_name}") + if(CMAKE_GET_RUNTIME_DEPENDENCIES_PLATFORM STREQUAL "windows+pe") + # Equal timestamps do not prove that an existing DLL has identical bytes. + file(COPY_FILE "${_source}" "${_copy}" ONLY_IF_DIFFERENT) + else() + file(COPY "${_source}" DESTINATION "${S2K_DESTINATION}" FOLLOW_SYMLINK_CHAIN) + endif() + if(CMAKE_GET_RUNTIME_DEPENDENCIES_PLATFORM STREQUAL "linux+elf") + execute_process(COMMAND "${S2K_READELF}" -d "${_copy}" + OUTPUT_VARIABLE _dynamic COMMAND_ERROR_IS_FATAL ANY) + string(REGEX MATCH "\\((RUNPATH|RPATH)\\)[^\n]*\\[([^]]*)\\]" _path "${_dynamic}") + if(_path) + set(_old "${CMAKE_MATCH_2}") + if(NOT _old STREQUAL "$ORIGIN") + file(RPATH_CHANGE FILE "${_copy}" OLD_RPATH "${_old}" NEW_RPATH "$ORIGIN") + endif() + endif() + endif() +endforeach() +file(COPY_FILE "${S2K_SWIFT_LICENSE}" "${S2K_NOTICES}/SwiftRuntime/LICENSE.txt" ONLY_IF_DIFFERENT) +# Retain complete upstream component license and third-party notice texts. +file(COPY_FILE "${S2K_ICU_LICENSE}" "${S2K_NOTICES}/SwiftRuntime/ICU.txt" ONLY_IF_DIFFERENT) +file(WRITE "${S2K_NOTICES}/SwiftRuntime/NOTICE.txt" + "Swift, Foundation, Dispatch and BlocksRuntime: Apple Inc. and the Swift project authors.\nApache License 2.0 with Runtime Library Exception: LICENSE.txt.\nICU license and third-party notices: ICU.txt.\nCompiler: ${S2K_COMPILER_VERSION}\n") +file(WRITE "${S2K_NOTICES}/SwiftRuntime/deployment.txt" + "Swift runtime libraries are copied from the compiler-selected runtime directories.\nELF copies have application-relative runtime search paths. System libraries and drivers are not redistributed by this helper.\n") +message(STATUS "Staged ${_count} facade/runtime libraries into ${S2K_DESTINATION}") diff --git a/Integrations/CMake/Windows.cmake b/Integrations/CMake/Windows.cmake new file mode 100644 index 0000000..1617468 --- /dev/null +++ b/Integrations/CMake/Windows.cmake @@ -0,0 +1,55 @@ +# Native x64 Windows C ABI. SwiftPM owns the Swift and C++/WinRT compilation. +if(NOT WIN32 OR NOT MSVC OR NOT CMAKE_SIZEOF_VOID_P EQUAL 8 OR CMAKE_CROSSCOMPILING) + message(FATAL_ERROR "Switch2Kit Windows requires a native x64 MSVC-compatible host build") +endif() +execute_process(COMMAND "${SWITCH2KIT_SWIFTC}" -print-target-info + OUTPUT_VARIABLE _s2k_target_info COMMAND_ERROR_IS_FATAL ANY) +string(JSON _s2k_triple GET "${_s2k_target_info}" target triple) +if(NOT _s2k_triple MATCHES "^x86_64-.*windows-msvc$" OR + CMAKE_CXX_COMPILER_ARCHITECTURE_ID MATCHES "ARM" OR + (CMAKE_GENERATOR_PLATFORM AND NOT CMAKE_GENERATOR_PLATFORM STREQUAL "x64")) + message(FATAL_ERROR "Use the x64 Swift toolchain and an x64 C/C++ build; Windows cross-compilation is not supported") +endif() +set(_s2k_args --package-path "${SWITCH2KIT_SOURCE_ROOT}" + --scratch-path "${CMAKE_CURRENT_BINARY_DIR}/swift" + --configuration "${SWITCH2KIT_SWIFT_CONFIGURATION}") +execute_process(COMMAND "${SWITCH2KIT_SWIFT}" build ${_s2k_args} --show-bin-path + OUTPUT_VARIABLE _s2k_bin OUTPUT_STRIP_TRAILING_WHITESPACE COMMAND_ERROR_IS_FATAL ANY) +set(_s2k_library "${_s2k_bin}/Switch2KitC.dll") +set(_s2k_import "${_s2k_bin}/Switch2KitC.lib") +add_custom_command(OUTPUT "${_s2k_library}" "${_s2k_import}" + COMMAND "${SWITCH2KIT_SWIFT}" build ${_s2k_args} --product Switch2KitC + DEPENDS ${_s2k_sources} "${SWITCH2KIT_SOURCE_ROOT}/Package.swift" + "${CMAKE_CURRENT_LIST_FILE}" + COMMENT "Building Switch2Kit C and native WinRT transport (x64)" VERBATIM) +add_custom_target(Switch2KitCBuild DEPENDS "${_s2k_library}" "${_s2k_import}") +add_library(Switch2Kit::C SHARED IMPORTED GLOBAL) +set_target_properties(Switch2Kit::C PROPERTIES + IMPORTED_LOCATION "${_s2k_library}" IMPORTED_IMPLIB "${_s2k_import}" + INTERFACE_INCLUDE_DIRECTORIES "${SWITCH2KIT_SOURCE_ROOT}/Sources/Switch2KitCABI/include") +add_dependencies(Switch2Kit::C Switch2KitCBuild) +set(SWITCH2KIT_C_BINARY_DIR "${_s2k_bin}" CACHE INTERNAL "Built C facade directory") + +include("${CMAKE_CURRENT_LIST_DIR}/Runtime.cmake") + +# Scan the actual host and its linked DLLs, not just the facade. A separately +# compiled Swift client can have additional runtime imports. System DLLs and +# graphics/Bluetooth drivers remain operating-system prerequisites. +function(switch2kit_embed_windows target) + get_filename_component(_root "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/../.." ABSOLUTE) + add_custom_command(TARGET "${target}" POST_BUILD + COMMAND "${CMAKE_COMMAND}" + "-DS2K_LIBRARY=$" + "-DS2K_EXECUTABLE=$" + "-DS2K_EXTRA_LIBRARIES=$" + "-DS2K_DESTINATION=$" + "-DS2K_NOTICES=$/Switch2KitNotices" + "-DS2K_RUNTIME_CONFIG=${SWITCH2KIT_RUNTIME_CONFIG}" + -P "${SWITCH2KIT_RUNTIME_SCRIPT}" + COMMAND "${CMAKE_COMMAND}" -E make_directory "$/Switch2KitNotices" + COMMAND "${CMAKE_COMMAND}" -E copy_if_different "${_root}/CREDITS.md" + "$/Switch2KitNotices/CREDITS.md" + COMMAND "${CMAKE_COMMAND}" -E copy_directory "${_root}/LICENSES" + "$/Switch2KitNotices/LICENSES" + VERBATIM) +endfunction() diff --git a/Integrations/Emulators/HostFile.hpp b/Integrations/Emulators/HostFile.hpp index 6169349..2c5de94 100644 --- a/Integrations/Emulators/HostFile.hpp +++ b/Integrations/Emulators/HostFile.hpp @@ -1,36 +1,81 @@ #pragma once -#include #include #include +#include #include +#if defined(_WIN32) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#else +#include #include -#include #include #include +#endif namespace Switch2Kit { enum class HostFileResult { OK, Missing, Invalid }; /** Explicit host settings/calibration reads only. No default path, persistence, - * watcher or Bluetooth work. Reject special files via the opened descriptor, + * watcher or Bluetooth work. Reject special files via the opened handle, * bound paths/bytes/retries, and leave the caller's output unchanged on failure. */ inline HostFileResult readHostFile(const std::string& path, size_t limit, std::string& output) { if (path.empty() || path.size() > 4096 || path.find('\0') != std::string::npos || !limit || limit > 524288) return HostFileResult::Invalid; if (std::any_of(path.begin(), path.end(), [](unsigned char c) { return c < 32 || c == 127; })) return HostFileResult::Invalid; // Host INI/XML selections must round-trip without injected records. +#if defined(_WIN32) + // Accept UTF-8 host paths, not ANSI-code-page truncation or device namespaces. + auto normalized = path; + std::replace(normalized.begin(), normalized.end(), '/', '\\'); + if (normalized.rfind("\\\\.\\", 0) == 0 || normalized.rfind("\\\\?\\", 0) == 0 || + normalized.rfind("\\??\\", 0) == 0) return HostFileResult::Invalid; + const int length = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, path.data(), + static_cast(path.size()), nullptr, 0); + if (!length) return HostFileResult::Invalid; + std::wstring wide(static_cast(length), L'\0'); + if (MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, path.data(), + static_cast(path.size()), wide.data(), length) != length) + return HostFileResult::Invalid; + // Allow atomic replacement of settings, but not concurrent in-place writes. + const HANDLE file = CreateFileW(wide.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_DELETE, + nullptr, OPEN_EXISTING, FILE_FLAG_SEQUENTIAL_SCAN, nullptr); + if (file == INVALID_HANDLE_VALUE) { + const auto error = GetLastError(); + return error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND ? + HostFileResult::Missing : HostFileResult::Invalid; + } + struct Close { HANDLE file; ~Close() { CloseHandle(file); } } close{file}; + BY_HANDLE_FILE_INFORMATION info{}; + LARGE_INTEGER size{}; + if (GetFileType(file) != FILE_TYPE_DISK || !GetFileInformationByHandle(file, &info) || + (info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) || !GetFileSizeEx(file, &size) || + size.QuadPart < 0 || static_cast(size.QuadPart) > limit) + return HostFileResult::Invalid; + const auto expected = static_cast(size.QuadPart); +#else const int fd = ::open(path.c_str(), O_RDONLY | O_NONBLOCK | O_CLOEXEC); if (fd < 0) return errno == ENOENT ? HostFileResult::Missing : HostFileResult::Invalid; struct Close { int fd; ~Close() { ::close(fd); } } close{fd}; struct stat info{}; if (::fstat(fd, &info) != 0 || !S_ISREG(info.st_mode) || info.st_size < 0 || static_cast(info.st_size) > limit) return HostFileResult::Invalid; - std::string bytes(static_cast(info.st_size) + 1, '\0'); + const auto expected = static_cast(info.st_size); +#endif + std::string bytes(expected + 1, '\0'); size_t count = 0; for (unsigned attempts = 0; attempts < 32; ++attempts) { +#if defined(_WIN32) + DWORD n = 0; + if (!ReadFile(file, bytes.data() + count, static_cast(bytes.size() - count), &n, nullptr)) + return HostFileResult::Invalid; +#else const auto n = ::read(fd, bytes.data() + count, bytes.size() - count); if (n < 0) { if (errno == EINTR) continue; return HostFileResult::Invalid; } +#endif if (!n) { - if (count != static_cast(info.st_size)) return HostFileResult::Invalid; + if (count != expected) return HostFileResult::Invalid; bytes.resize(count); output = std::move(bytes); return HostFileResult::OK; } count += static_cast(n); diff --git a/Integrations/SDL3/CMakeLists.txt b/Integrations/SDL3/CMakeLists.txt index b468221..244d38c 100644 --- a/Integrations/SDL3/CMakeLists.txt +++ b/Integrations/SDL3/CMakeLists.txt @@ -12,9 +12,14 @@ target_compile_features(Switch2KitSDL3 PUBLIC cxx_std_17) target_include_directories(Switch2KitSDL3 PUBLIC "${CMAKE_CURRENT_LIST_DIR}") target_link_libraries(Switch2KitSDL3 PUBLIC Switch2Kit::C SDL3::SDL3) set_target_properties(Switch2KitSDL3 PROPERTIES POSITION_INDEPENDENT_CODE ON) +if(WIN32) + target_link_libraries(Switch2KitSDL3 PUBLIC mincore) +endif() # Compile the adapter's constructor checks even when the host disables exceptions. -if(CMAKE_CXX_COMPILER_ID MATCHES "Clang|GNU") +if(MSVC) + target_compile_options(Switch2KitSDL3 PRIVATE /EHsc) +elseif(CMAKE_CXX_COMPILER_ID MATCHES "Clang|GNU") target_compile_options(Switch2KitSDL3 PRIVATE -fexceptions) endif() diff --git a/Integrations/SDL3/MotionClock.hpp b/Integrations/SDL3/MotionClock.hpp index 43daa0b..b39cd35 100644 --- a/Integrations/SDL3/MotionClock.hpp +++ b/Integrations/SDL3/MotionClock.hpp @@ -7,6 +7,12 @@ #include #elif defined(__linux__) #include +#elif defined(_WIN32) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include #endif namespace Switch2Kit::Detail { @@ -42,6 +48,13 @@ inline std::uint64_t continuousTimeNS() noexcept { const auto base = seconds * second; const auto tail = static_cast(value.tv_nsec); return tail > limit - base ? 0 : base + tail; +#elif defined(_WIN32) + // Interrupt time includes suspend; the precise API avoids coarse system + // tick quantization falsely tripping the five-millisecond freshness guard. + // Its 100 ns units are an interval clock, never a sensor timestamp. + ULONGLONG value{}; + QueryInterruptTimePrecise(&value); + return scaledTicksNS(value, 100, 1); #else return 0; // Unsupported freshness clock: fail closed for motion, not controls. #endif diff --git a/Integrations/SDL3/Switch2KitSDL3.cpp b/Integrations/SDL3/Switch2KitSDL3.cpp index a732030..ad3e9aa 100644 --- a/Integrations/SDL3/Switch2KitSDL3.cpp +++ b/Integrations/SDL3/Switch2KitSDL3.cpp @@ -467,7 +467,7 @@ struct SDL3Adapter::Impl { SDL_UpdateJoysticks(); std::array events{}; uint32_t count{}, flags{}; - const auto result = s2k_read(context, events.data(), events.size(), sizeof(S2KEvent), &count, + const auto result = s2k_read(context, events.data(), S2K_EVENT_CAPACITY, sizeof(S2KEvent), &count, &snapshot, sizeof(snapshot), &flags); if (result != S2K_OK) { clear(); return error = result; } const auto clock = ClockPair::sample(); diff --git a/Package.swift b/Package.swift index 7e54b96..bafd632 100644 --- a/Package.swift +++ b/Package.swift @@ -4,9 +4,17 @@ import PackageDescription var radioDependencies: [Target.Dependency] = [] #if os(Linux) radioDependencies = [.target(name: "Switch2KitDBus")] +#elseif os(Windows) +radioDependencies = [.target(name: "Switch2KitWinRT")] #endif var products: [Product] = [.library(name: "Switch2Kit", targets: ["Switch2Kit"])] +#if os(Windows) +// PE exports are generated for product targets, not merely their transitive +// dependencies. Keep the shared engine's Swift symbols in this same DLL. +products.append(.library(name: "Switch2KitC", type: .dynamic, targets: ["Switch2KitC", "Switch2Kit"])) +#else products.append(.library(name: "Switch2KitC", type: .dynamic, targets: ["Switch2KitC"])) +#endif var targets: [Target] = [ .target(name: "Switch2KitCABI"), .target(name: "Switch2KitC", dependencies: ["Switch2Kit", "Switch2KitCABI"], @@ -16,16 +24,20 @@ var targets: [Target] = [ // Compile the actual native SDL fixture during swift test, not just its separate CMake build. .testTarget(name: "Switch2KitSDLFixtureTests", dependencies: ["Switch2Kit", "Switch2KitC", "Switch2KitCABI"], path: "tests/sdl-inprocess", - exclude: ["CMakeLists.txt", "Clock.cpp", "Clock.hpp", "main.cpp", "motion.cpp", "verify.sh"], + exclude: ["CMakeLists.txt", "Clock.cpp", "Clock.hpp", "main.cpp", "motion.cpp", "verify.sh", "run.sh", "version_test.py"], sources: ["Fixture.swift", "FixtureTests.swift"], swiftSettings: [.swiftLanguageMode(.v6)]), .target(name: "Switch2Kit", dependencies: radioDependencies, path: "Sources/Switch2Kit", swiftSettings: [.swiftLanguageMode(.v6)]), - .testTarget(name: "Switch2KitTests", dependencies: ["Switch2Kit"], path: "Tests/Switch2KitTests", + .testTarget(name: "Switch2KitTests", dependencies: [.target(name: "Switch2Kit")] + radioDependencies, path: "Tests/Switch2KitTests", swiftSettings: [.swiftLanguageMode(.v6)]) ] #if os(Linux) targets.append(.target(name: "Switch2KitDBus", linkerSettings: [.linkedLibrary("dl")])) #endif +#if os(Windows) +targets.append(.target(name: "Switch2KitWinRT", cxxSettings: [.define("NOMINMAX"), .define("WIN32_LEAN_AND_MEAN")], + linkerSettings: [.linkedLibrary("windowsapp")])) +#endif #if os(macOS) products += [ .executable(name: "Switch2KitApp", targets: ["Switch2KitApp"]), @@ -38,4 +50,4 @@ targets += [ swiftSettings: [.swiftLanguageMode(.v6)]) ] #endif -let package = Package(name: "Switch2Kit", platforms: [.macOS(.v15)], products: products, targets: targets) +let package = Package(name: "Switch2Kit", platforms: [.macOS(.v15)], products: products, targets: targets, cxxLanguageStandard: .cxx20) diff --git a/README.md b/README.md index a2d206f..4eadb15 100644 --- a/README.md +++ b/README.md @@ -2,18 +2,20 @@ **Use your Nintendo Switch Online GameCube controller, Nintendo Switch 2 Pro Controller, and Joy-Con 2 in apps and games.** -Switch2Kit provides controller support that any app can integrate on a supported platform. Our [Dolphin](https://github.com/jmonster/dolphin#quick-start-macos-15) and [Cemu](https://github.com/jmonster/Cemu#quick-start-macos-15) forks are maintained reference apps with Switch2Kit already built in: get the app, connect your controller, and play. You do not need to install or run Switch2Kit separately. +Switch2Kit provides controller support that any app can integrate on a supported platform. Our [Dolphin](https://github.com/jmonster/dolphin/blob/041a158aea44abb8b9625ce3359b8c231ad931da/Readme.md#quick-start) and [Cemu](https://github.com/jmonster/Cemu/blob/9a8a563c93791634bc2a5288fde885a25fbcddd4/README.md#quick-start) forks are maintained reference apps with Switch2Kit already built in: get the app, connect your controller, and play. You do not need to install or run Switch2Kit separately. ## Start playing -Choose the emulator for your games. Both controller-enabled forks run on **macOS 15 or newer, on Apple Silicon and Intel Macs**. +Choose the emulator for your games. The maintained forks embed Switch2Kit on **macOS 15+, Linux, and Windows x64**. Linux and Windows support is experimental; their setup guides identify the required runtime and Bluetooth dependencies. | Your games | App with Switch2Kit built in | Get started | | --- | --- | --- | -| GameCube and Wii | [Dolphin fork](https://github.com/jmonster/dolphin) | [Download, connect, and play](https://github.com/jmonster/dolphin#quick-start-macos-15) | -| Wii U | [Cemu fork](https://github.com/jmonster/Cemu) | [Download, connect, and play](https://github.com/jmonster/Cemu#quick-start-macos-15) | +| GameCube and Wii | [Dolphin fork](https://github.com/jmonster/dolphin) | [Download, connect, and play](https://github.com/jmonster/dolphin/blob/041a158aea44abb8b9625ce3359b8c231ad931da/Readme.md#quick-start) | +| Wii U | [Cemu fork](https://github.com/jmonster/Cemu) | [Download, connect, and play](https://github.com/jmonster/Cemu/blob/9a8a563c93791634bc2a5288fde885a25fbcddd4/README.md#quick-start) | -1. **Get a controller-enabled app** from the linked fork's README. Prebuilt development apps are available through each fork's **Native Switch2Kit** GitHub Actions workflow; downloading them requires signing in to GitHub. These are not notarized releases. Each README also includes build-and-launch instructions when a download is unavailable. +The linked setup guides describe the reviewed desktop builds. While [Dolphin #5](https://github.com/jmonster/dolphin/pull/5) or [Cemu #3](https://github.com/jmonster/Cemu/pull/3) is unmerged, select application artifacts from `feature/switch2kit-desktop-platforms`; the forks’ default-branch READMEs may still describe older macOS-only builds. The guides include the exact artifact names, extraction paths, and source-build fallbacks. + +1. **Get a controller-enabled app** from the linked fork's README. Use the platform-specific **Switch2Kit** GitHub Actions build linked in that README; downloading artifacts requires signing in to GitHub. Only successful runs with an application artifact provide a download. These are development builds, not published releases. Each README also includes build-and-launch instructions when a download is unavailable. 2. **Connect over Bluetooth.** Open **Controllers** in Dolphin or **Options > Input settings** in Cemu, click **Find Switch 2 Controllers**, allow Bluetooth access, and hold the controller's **Sync** button. Close other apps managing the same controller first. 3. **Select your controller and play.** For GameCube games in Dolphin, select the GameCube or Pro controller beside the desired GameCube port. In Cemu, select it beside **Emulated controller**. The forks apply the recommended button and stick mappings automatically. Their guides cover rumble, reconnecting, and controller-specific limitations; Wii Remote setup in Dolphin remains separate. @@ -21,7 +23,7 @@ Use the linked **fork builds**, not the ordinary upstream downloads: these forks ## Use it with other apps -Switch2Kit is not limited to Dolphin and Cemu. Apps can embed the same support directly, and the optional [Switch2Kit dashboard](#dashboard) provides output paths for compatible [SDL3 games](sdl/README.md), [Chromium browser games](browser/README.md), and [RetroArch](docs/retroarch-integration.md). +Switch2Kit is not limited to Dolphin and Cemu. Apps can embed the same support directly, and the optional macOS [Switch2Kit dashboard](#dashboard) provides output paths for compatible [SDL3 games](sdl/README.md), [Chromium browser games](browser/README.md), and [RetroArch](docs/retroarch-integration.md). For an app without built-in support, follow the [dashboard setup guide](docs/quick-start.md) and the instructions for its output path. Installing Switch2Kit alone does not make a controller appear in every app: it is not a universal system-wide controller driver. @@ -40,10 +42,11 @@ This is support for the **wireless NSO GameCube controller**, not an original wi | Platform | Current Switch2Kit support | | --- | --- | | macOS 15+ (Apple Silicon and Intel) | Live Bluetooth controller support, the maintained Dolphin/Cemu reference forks, Swift and C/C++ hosts, and the optional dashboard. | -| Linux / BlueZ (experimental) | Live Bluetooth backend, Swift and C/C++ hosts, and optional native SDL3/emulator source integrations. This is separate from the macOS-only reference-fork builds above. See [requirements and qualification limits](docs/switch2kit/linux.md). | -| Windows and Android | No supported Switch2Kit controller backend or host build. | +| Linux / BlueZ (experimental) | Native Bluetooth controller engine, Swift/C/C++ hosts, and the maintained Dolphin/Cemu forks. [Requirements and setup](docs/switch2kit/linux.md). | +| Windows x64 / WinRT (experimental) | Native Bluetooth LE controller engine, Swift/C/C++ hosts, and the maintained Dolphin/Cemu forks. [Requirements and setup](docs/switch2kit/windows.md). | +| Android | No Switch2Kit controller backend. | -Dolphin and Cemu have their own upstream platform support; that does not mean their Switch2Kit backends support every upstream platform. Linux radio tests use an isolated synthetic BlueZ service; physical-controller and gameplay qualification remain separate. +A controller-enabled build is required on every platform. The emulators' ordinary upstream builds do not include this integration. Automated tests cover native code and controlled transport boundaries; physical-controller pairing, reconnect, rumble, and gameplay qualification remain separate. The dashboard is macOS-only, but the controller engine is not. ## Developer integration @@ -59,7 +62,7 @@ The SDL3 integrations run in the emulator's existing input backend. The emulator ### Library -Requires Swift 6.2+. macOS hosts require macOS 15+ and Xcode 26+; Linux hosts use [BlueZ and the native Swift toolchain](docs/switch2kit/linux.md). +Requires Swift 6.2+. macOS hosts require macOS 15+ and Xcode 26+; Linux hosts use [BlueZ and the native Swift toolchain](docs/switch2kit/linux.md); Windows hosts use [WinRT and the x64 Swift toolchain](docs/switch2kit/windows.md). ```swift .package(url: "https://github.com/jmonster/Switch2Kit.git", branch: "main") diff --git a/Sources/Switch2Kit/Bluetooth/ControllerSession.swift b/Sources/Switch2Kit/Bluetooth/ControllerSession.swift index 036215d..8e38031 100644 --- a/Sources/Switch2Kit/Bluetooth/ControllerSession.swift +++ b/Sources/Switch2Kit/Bluetooth/ControllerSession.swift @@ -1,4 +1,4 @@ -#if canImport(CoreBluetooth) || os(Linux) +#if canImport(CoreBluetooth) || os(Linux) || os(Windows) // ControllerSession.swift // One connected Switch 2 controller: GATT handshake, command serialization, // input decoding, keep-alive, and rumble. @@ -125,7 +125,7 @@ package final class ControllerSession: NSObject, @unchecked Sendable { /// Last time the HUMAN did something (button/stick/trigger change) — /// reports stream constantly, so idleness must be judged on content. - package private(set) var lastActivityAt: TimeInterval = ProcessInfo.processInfo.systemUptime + package private(set) var lastActivityAt: TimeInterval = ControllerClock.now /// Internal queue-confined receiver; never a host output callback. package var onState: (@Sendable (Int, ControllerState) -> Void)? @@ -323,7 +323,7 @@ package final class ControllerSession: NSObject, @unchecked Sendable { } private var hostAddressBytesLE: Data? { - #if os(Linux) && !S2K_RADIO_FIXTURE + #if (os(Linux) || os(Windows)) && !S2K_RADIO_FIXTURE return peripheral.hostAddressBytesLE #else return HostBluetooth.macAddressBytesLE @@ -424,13 +424,13 @@ package final class ControllerSession: NSObject, @unchecked Sendable { writeStallTimeout = nil // Rumble stop/replacement must not wait for a command response. if let motor = pendingMotor, let characteristic = chars[Switch2.GATT.vibration(for: model)] { - let value = ProcessInfo.processInfo.systemUptime < motor.expires + let value = ControllerClock.now < motor.expires ? motor.value : Switch2.MotorVibration.stopped let packet = Switch2.motorPacket(value, packetID: vibrationPacketID, model: model) if packet.count <= peripheral.maximumWriteValueLength(for: .withoutResponse) { peripheral.writeValue(packet, for: characteristic, type: .withoutResponse) vibrationPacketID &+= 1 - lastWriteAt = ProcessInfo.processInfo.systemUptime + lastWriteAt = ControllerClock.now } pendingMotor = nil } @@ -456,7 +456,7 @@ package final class ControllerSession: NSObject, @unchecked Sendable { commandTimeout = timeout queue.asyncAfter(deadline: .now() + 2, execute: timeout) peripheral.writeValue(request.frame, for: writeChar, type: .withoutResponse) - lastWriteAt = ProcessInfo.processInfo.systemUptime + lastWriteAt = ControllerClock.now } } @@ -568,7 +568,7 @@ package final class ControllerSession: NSObject, @unchecked Sendable { rumbleStopTimer?.schedule(deadline: .distantFuture) rumbleTarget = (strong.isFinite ? max(0, min(1, strong)) : 0, weak.isFinite ? max(0, min(1, weak)) : 0) - rumbleSetAt = ProcessInfo.processInfo.systemUptime + rumbleSetAt = ControllerClock.now maintainTick() } @@ -585,7 +585,7 @@ package final class ControllerSession: NSObject, @unchecked Sendable { guard !ended, isReady else { completion?(.controllerNotReady); return } let level = intensity.isFinite ? max(0, min(1, intensity)) : 0 guard level > 0 else { completion?(nil); return } - let now = ProcessInfo.processInfo.systemUptime + let now = ControllerClock.now guard now - lastRumbleFeedbackAt >= 0.5 else { completion?(.operationBusy); return } guard isCommandIdle, peripheral.canSendWriteWithoutResponse else { completion?(.operationBusy); return @@ -633,7 +633,7 @@ package final class ControllerSession: NSObject, @unchecked Sendable { applyRumble(strong: strong, weak: weak) guard model.capabilities.contains(.continuousRumble), rumbleTarget.strong > 0.001 || rumbleTarget.weak > 0.001 else { return } let delay = max(0, min(5, duration)) - rumbleStopDeadline = ProcessInfo.processInfo.systemUptime + delay + rumbleStopDeadline = ControllerClock.now + delay rumbleStopGeneration = rumbleGeneration if rumbleStopTimer == nil { let timer = DispatchSource.makeTimerSource(queue: queue) @@ -651,7 +651,7 @@ package final class ControllerSession: NSObject, @unchecked Sendable { private func finishRumblePulse() { guard !ended, rumbleStopGeneration == rumbleGeneration, let deadline = rumbleStopDeadline else { return } - let remaining = deadline - ProcessInfo.processInfo.systemUptime + let remaining = deadline - ControllerClock.now guard remaining <= 0 else { rumbleStopTimer?.schedule(deadline: .now() + remaining) return @@ -673,10 +673,10 @@ package final class ControllerSession: NSObject, @unchecked Sendable { defer { // One deadline while idle, sustain cadence only while rumbling. let active = rumbleActive || pendingMotor != nil - let delay = active ? 0.05 : max(0.05, 1 - (ProcessInfo.processInfo.systemUptime - lastWriteAt)) + let delay = active ? 0.05 : max(0.05, 1 - (ControllerClock.now - lastWriteAt)) keepAliveTimer?.schedule(deadline: .now() + delay, leeway: .milliseconds(2)) } - let now = ProcessInfo.processInfo.systemUptime + let now = ControllerClock.now var (strong, weakMag) = rumbleTarget // Failsafe: rumble intents expire after 0.5 s so a crashed consumer // can never leave the motor running. @@ -719,7 +719,7 @@ package final class ControllerSession: NSObject, @unchecked Sendable { return false } warnedMotorUnavailable = false - pendingMotor = (motors, ProcessInfo.processInfo.systemUptime + 0.5) + pendingMotor = (motors, ControllerClock.now + 0.5) pumpWrites() return true } @@ -738,7 +738,7 @@ package final class ControllerSession: NSObject, @unchecked Sendable { private func handleInputReport(_ data: Data) { guard !ended, let report = Switch2.InputReport(data: data) else { return } - let now = ProcessInfo.processInfo.systemUptime + let now = ControllerClock.now if lastReportAt > 0, now - lastReportAt > 0.100 { gapCount += 1 log(.warning, "slot \(slot + 1): BLE input gap #\(gapCount): \(Int((now - lastReportAt) * 1000)) ms") diff --git a/Sources/Switch2Kit/Bluetooth/ControllerTransport.swift b/Sources/Switch2Kit/Bluetooth/ControllerTransport.swift index 8c10eb2..741de54 100644 --- a/Sources/Switch2Kit/Bluetooth/ControllerTransport.swift +++ b/Sources/Switch2Kit/Bluetooth/ControllerTransport.swift @@ -1,4 +1,4 @@ -#if canImport(CoreBluetooth) || os(Linux) +#if canImport(CoreBluetooth) || os(Linux) || os(Windows) import Foundation #if canImport(CoreBluetooth) import CoreBluetooth @@ -80,7 +80,7 @@ package final class ControllerTransport: NSObject, @unchecked Sendable { guard expectedConnection == nil || current == expectedConnection else { return false } if inbox.pending[id] != nil || inbox.pending.count < 64 { inbox.pending[id] = RumbleIntent(strong: strong, weak: weak, duration: duration, feedback: feedback, - submittedAt: ProcessInfo.processInfo.systemUptime, + submittedAt: ControllerClock.now, generation: expectedConnection ?? current) } else { inbox.overflowed = true } guard !inbox.scheduled else { return false } @@ -100,7 +100,7 @@ package final class ControllerTransport: NSObject, @unchecked Sendable { guard let session = sessions.values.first(where: { $0.peripheral.identifier == id.rawValue }), !session.isRetired else { failure(id, .controllerNotReady); continue } guard intent.generation == session.lifetime.id else { continue } - guard ProcessInfo.processInfo.systemUptime - intent.submittedAt < 0.5 else { + guard ControllerClock.now - intent.submittedAt < 0.5 else { session.applyRumble(strong: 0, weak: 0); continue } if intent.feedback { @@ -125,7 +125,7 @@ package final class ControllerTransport: NSObject, @unchecked Sendable { guard let self, !self.running else { return } self.running = true if self.central == nil { self.central = CBCentralManager(delegate: self, queue: self.btQueue) } - #if os(Linux) && !S2K_RADIO_FIXTURE + #if (os(Linux) || os(Windows)) && !S2K_RADIO_FIXTURE self.central.restart() #endif self.updateScanning() @@ -150,7 +150,7 @@ package final class ControllerTransport: NSObject, @unchecked Sendable { controlInbox.withLock { $0.pending.removeAll(); $0.overflowed = false } if central != nil { resetConnections(cancel: true, reason: .stopped) } central?.delegate = nil - #if os(Linux) && !S2K_RADIO_FIXTURE + #if (os(Linux) || os(Windows)) && !S2K_RADIO_FIXTURE central?.shutdown() #endif central = nil @@ -325,7 +325,7 @@ package final class ControllerTransport: NSObject, @unchecked Sendable { } private func noteConnectionFailure(_ id: UUID) { - let now = ProcessInfo.processInfo.systemUptime + let now = ControllerClock.now retryAfter = retryAfter.filter { $0.value > now || disconnecting.contains($0.key) } retryAdvertisements = retryAdvertisements.filter { $0.value.expiresAt > now } retryAdvertisements.removeValue(forKey: id) @@ -352,7 +352,7 @@ package final class ControllerTransport: NSObject, @unchecked Sendable { private func armRetryWake() { guard running, !suspended, central.state == .poweredOn, connecting.isEmpty, central.isScanning else { cancelRetryWake(); return } - let now = ProcessInfo.processInfo.systemUptime + let now = ControllerClock.now let future = Array(retryAfter.values) + [retryBlockedUntil] guard let deadline = future.filter({ $0 > now }).min() else { cancelRetryWake(); return } guard retryWake == nil || retryWakeAt != deadline else { return } @@ -365,7 +365,7 @@ package final class ControllerTransport: NSObject, @unchecked Sendable { private func wakeConnectionRetries(generation: UInt64) { guard generation == retryWakeGeneration, retryWake != nil else { return } - let now = ProcessInfo.processInfo.systemUptime + let now = ControllerClock.now let deadline = retryWakeAt retryWake = nil; retryWakeAt = nil guard running, !suspended, central.state == .poweredOn else { resetConnectionRetries(); return } @@ -381,7 +381,7 @@ package final class ControllerTransport: NSObject, @unchecked Sendable { private func beginConnection(_ peripheral: CBPeripheral, wasPairingMode: Bool) -> Bool { let id = peripheral.identifier - let now = ProcessInfo.processInfo.systemUptime + let now = ControllerClock.now guard running, !suspended, central.state == .poweredOn, connecting.isEmpty, !disconnecting.contains(id), now >= retryBlockedUntil, now >= (retryAfter[id] ?? 0), @@ -418,7 +418,7 @@ package final class ControllerTransport: NSObject, @unchecked Sendable { central.stopScan(); resetConnectionRetries(); publishState(.paused); return } guard central.state == .poweredOn else { resetConnectionRetries(); publishState(.off); return } - let now = ProcessInfo.processInfo.systemUptime + let now = ControllerClock.now retryAdvertisements = retryAdvertisements.filter { $0.value.expiresAt > now } // Complete one connection/handshake before admitting another. Existing // ready sessions continue delivering input while a retry waits. @@ -459,7 +459,7 @@ package final class ControllerTransport: NSObject, @unchecked Sendable { private func sweepIdleSessions() { guard running, !suspended else { return } - let now = ProcessInfo.processInfo.systemUptime + let now = ControllerClock.now for session in Array(sessions.values) { if now - session.lastReportAt > 5 { diagnostics.emit(.warning, .session, "Input stream stopped; retiring stale session") @@ -515,7 +515,7 @@ extension ControllerTransport: CBCentralManagerDelegate { else { return } let id = peripheral.identifier - let now = ProcessInfo.processInfo.systemUptime + let now = ControllerClock.now if let notBefore = retryAfter[id], now < notBefore || disconnecting.contains(id) { // Retain only the validated identity and pairing flag, not arbitrary // advertisement data. Never reuse observations older than 10 seconds. diff --git a/Sources/Switch2Kit/Bluetooth/DiscoveryPolicy.swift b/Sources/Switch2Kit/Bluetooth/DiscoveryPolicy.swift index 79198a6..7475d56 100644 --- a/Sources/Switch2Kit/Bluetooth/DiscoveryPolicy.swift +++ b/Sources/Switch2Kit/Bluetooth/DiscoveryPolicy.swift @@ -32,7 +32,7 @@ package final class ControllerDiscoveryPolicy: @unchecked Sendable { for id in remembered.prefix(64) where !known.contains(id) { known.append(id) } self.remembered = Array(known.prefix(capacity)) } - package func shouldScan(readyIDs: [UUID], now: TimeInterval = ProcessInfo.processInfo.systemUptime) -> Bool { + package func shouldScan(readyIDs: [UUID], now: TimeInterval = ControllerClock.now) -> Bool { dispatchPrecondition(condition: .onQueue(queue)) guard now.isFinite else { cancelWindow(); return mode != .onDemand } if mode == .automatic { wasQuiet = false; cancelWindow(); return true } @@ -52,12 +52,12 @@ package final class ControllerDiscoveryPolicy: @unchecked Sendable { if let until, now >= until { cancelWindow() } return windowIsOpen(now: now) || remembered.isEmpty || !Set(remembered).isSubset(of: Set(ready)) } - package func windowIsOpen(now: TimeInterval = ProcessInfo.processInfo.systemUptime) -> Bool { + package func windowIsOpen(now: TimeInterval = ControllerClock.now) -> Bool { dispatchPrecondition(condition: .onQueue(queue)) return now.isFinite && (until.map { now < $0 } ?? false) } @discardableResult - package func openWindow(seconds: TimeInterval = 60, now: TimeInterval = ProcessInfo.processInfo.systemUptime) -> Bool { + package func openWindow(seconds: TimeInterval = 60, now: TimeInterval = ControllerClock.now) -> Bool { dispatchPrecondition(condition: .onQueue(queue)) guard seconds.isFinite, (0.1...300).contains(seconds), now.isFinite else { return false } guard mode != .automatic else { return true } diff --git a/Sources/Switch2Kit/Platform/BlueZ/Identity.swift b/Sources/Switch2Kit/Platform/BlueZ/Identity.swift index 22a6a16..1a55bce 100644 --- a/Sources/Switch2Kit/Platform/BlueZ/Identity.swift +++ b/Sources/Switch2Kit/Platform/BlueZ/Identity.swift @@ -1,4 +1,4 @@ -#if os(Linux) +#if os(Linux) || os(Windows) import Foundation // RFC 4122 name-based UUIDs: stable for an adapter/address/type tuple, without diff --git a/Sources/Switch2Kit/Platform/ControllerClock.swift b/Sources/Switch2Kit/Platform/ControllerClock.swift new file mode 100644 index 0000000..d875626 --- /dev/null +++ b/Sources/Switch2Kit/Platform/ControllerClock.swift @@ -0,0 +1,26 @@ +import Foundation +#if os(Windows) +import WinSDK +#endif + +/// Shared host receive/deadline clock. Never use wall-clock time for input. +/// Windows Foundation uptime is too coarse for consecutive controller reports; +/// QPC supplies the same high-resolution clock domain to the engine and C ABI. +package enum ControllerClock { +#if os(Windows) + private static let frequency: Double = { + var value = LARGE_INTEGER() + guard QueryPerformanceFrequency(&value), value.QuadPart > 0 else { return .nan } + return Double(value.QuadPart) + }() +#endif + package static var now: TimeInterval { +#if os(Windows) + var value = LARGE_INTEGER() + guard QueryPerformanceCounter(&value), value.QuadPart >= 0 else { return .nan } + return Double(value.QuadPart) / frequency +#else + return ProcessInfo.processInfo.systemUptime +#endif + } +} diff --git a/Sources/Switch2Kit/Platform/Windows/Radio.swift b/Sources/Switch2Kit/Platform/Windows/Radio.swift new file mode 100644 index 0000000..0562fde --- /dev/null +++ b/Sources/Switch2Kit/Platform/Windows/Radio.swift @@ -0,0 +1,272 @@ +#if os(Windows) +import Foundation +import Switch2KitWinRT + +// Private adaptation surface consumed by ControllerTransport/ControllerSession. +// The public controller API and Nintendo protocol engine are shared unchanged. +package typealias CBCentralManager = WindowsCentral +package typealias CBPeripheral = WindowsPeripheral +package typealias CBService = WindowsService +package typealias CBCharacteristic = WindowsCharacteristic +package struct CBUUID: Sendable { + package let uuidString: String + package init(_ value: UUID) { uuidString = value.uuidString } +} +package enum CBManagerState { case unknown, resetting, unsupported, unauthorized, poweredOff, poweredOn } +package enum CBCharacteristicWriteType { case withoutResponse } +package let CBAdvertisementDataManufacturerDataKey = "manufacturerData" +package let CBCentralManagerScanOptionAllowDuplicatesKey = "allowDuplicates" +package protocol CBCentralManagerDelegate: AnyObject { + func centralManagerDidUpdateState(_ central: CBCentralManager) + func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi: NSNumber) + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) + func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) + func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) +} +package protocol CBPeripheralDelegate: AnyObject { + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) + func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) + func peripheral(_ peripheral: CBPeripheral, didReadRSSI rssi: NSNumber, error: Error?) + func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) + func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) +} +package struct WindowsRadioError: Error { package let code: Int32 } +package final class WindowsService { + package var characteristics: [CBCharacteristic]? + package init(_ characteristics: [CBCharacteristic]) { self.characteristics = characteristics } +} +package final class WindowsCharacteristic { + package let index: UInt32 + package let uuid: CBUUID + package let flags: UInt32 + package var value: Data? + package var isNotifying = false + package init(index: UInt32, uuid: UUID, flags: UInt32) { + self.index = index; self.uuid = CBUUID(uuid); self.flags = flags + } +} + +// A fake can replace only the operating-system boundary in Windows unit tests. +// Production always constructs NativeWindowsRadio and the actual WinRT driver. +package protocol WindowsRadio: AnyObject { + func next() -> S2WEvent? + func scan(_ enabled: Bool) -> Bool + func connect(token: UInt64, address: UInt64, type: UInt32) -> Bool + func cancel(_ token: UInt64) -> Bool + func discover(_ token: UInt64) -> Bool + func notify(_ token: UInt64, index: UInt32, enabled: Bool) -> Bool + func write(_ token: UInt64, index: UInt32, data: Data) -> Bool + func close() +} +private final class NativeWindowsRadio: WindowsRadio { + private var handle: OpaquePointer? = s2w_create() + private var creationFailureReported = false + deinit { close() } + func close() { if let handle { s2w_destroy(handle); self.handle = nil }; creationFailureReported = true } + func next() -> S2WEvent? { + var value = S2WEvent() + if handle == nil, !creationFailureReported { + creationFailureReported = true; value.kind = UInt32(S2W_STATE); value.status = 2 + return value + } + return s2w_next(handle, &value) == 1 ? value : nil + } + func scan(_ enabled: Bool) -> Bool { s2w_scan(handle, enabled ? 1 : 0) == 1 } + func connect(token: UInt64, address: UInt64, type: UInt32) -> Bool { s2w_connect(handle, token, address, type) == 1 } + func cancel(_ token: UInt64) -> Bool { s2w_cancel(handle, token) == 1 } + func discover(_ token: UInt64) -> Bool { s2w_discover(handle, token) == 1 } + func notify(_ token: UInt64, index: UInt32, enabled: Bool) -> Bool { s2w_notify(handle, token, index, enabled ? 1 : 0) == 1 } + func write(_ token: UInt64, index: UInt32, data: Data) -> Bool { + data.withUnsafeBytes { s2w_write(handle, token, index, $0.bindMemory(to: UInt8.self).baseAddress, UInt32($0.count)) == 1 } + } +} +package final class WindowsPeripheral: @unchecked Sendable { + package let identifier: UUID + package let address: UInt64 + package let addressType: UInt32 + package let hostAddressBytesLE: Data + package weak var central: WindowsCentral? + package weak var delegate: CBPeripheralDelegate? + package var services: [CBService]? + package var characteristics: [UInt32: CBCharacteristic] = [:] + package var token: UInt64 = 0 + package var connected = false + package var disconnecting = false + package var writing = false + package var mtu: UInt32 = 23 + package var canSendWriteWithoutResponse: Bool { connected && !disconnecting && !writing } + package init(address: UInt64, type: UInt32, host: UInt64, central: WindowsCentral) { + self.address = address; addressType = type; self.central = central + hostAddressBytesLE = Data((0..<6).map { UInt8(truncatingIfNeeded: host >> ($0 * 8)) }) + identifier = BlueZIdentity.uuid(name: "Switch2Kit/WinRT/\(host)/\(type)/\(address)") + } + package func invalidate() { + token = 0; connected = false; disconnecting = false; writing = false + services = nil; characteristics.removeAll(); mtu = 23 + } + package func discoverServices(_ uuids: [CBUUID]?) { + guard connected, !disconnecting else { return } + if central?.driver?.discover(token) != true { central?.failed(self, code: 1) } + } + package func discoverCharacteristics(_ uuids: [CBUUID]?, for service: CBService) { + guard connected, !disconnecting, services?.contains(where: { $0 === service }) == true else { return } + delegate?.peripheral(self, didDiscoverCharacteristicsFor: service, error: nil) + } + package func maximumWriteValueLength(for type: CBCharacteristicWriteType) -> Int { max(0, min(512, Int(mtu) - 3)) } + package func setNotifyValue(_ enabled: Bool, for characteristic: CBCharacteristic) { + guard connected, !disconnecting, characteristics[characteristic.index] === characteristic else { return } + guard characteristic.flags & UInt32(S2W_NOTIFY | S2W_INDICATE) != 0, + central?.driver?.notify(token, index: characteristic.index, enabled: enabled) == true else { + delegate?.peripheral(self, didUpdateNotificationStateFor: characteristic, error: WindowsRadioError(code: 1)); return + } + } + package func writeValue(_ data: Data, for characteristic: CBCharacteristic, type: CBCharacteristicWriteType) { + guard canSendWriteWithoutResponse, characteristics[characteristic.index] === characteristic else { return } + guard characteristic.flags & UInt32(S2W_WRITE) != 0, !data.isEmpty, + data.count <= maximumWriteValueLength(for: type) else { central?.failed(self, code: 1); return } + writing = true + if central?.driver?.write(token, index: characteristic.index, data: data) != true { central?.failed(self, code: 1) } + } + // WinRT has no equivalent connected-peripheral RSSI read. Do not invent a + // fresh value from an old advertisement or expose an unrelated device metric. + package func readRSSI() {} +} +package final class WindowsCentral: @unchecked Sendable { + package weak var delegate: CBCentralManagerDelegate? + package private(set) var state: CBManagerState = .unknown + package private(set) var isScanning = false + package var driver: (any WindowsRadio)? + private let factory: () -> any WindowsRadio + private let queue: DispatchQueue + private var timer: DispatchSourceTimer? + private var nextToken: UInt64 = 0 + private var peripherals: [UUID: WindowsPeripheral] = [:] + private var connections: [UInt64: WindowsPeripheral] = [:] + private var seen = Set() + package init(delegate: CBCentralManagerDelegate, queue: DispatchQueue) { + self.delegate = delegate; self.queue = queue; factory = { NativeWindowsRadio() } + } + package init(delegate: CBCentralManagerDelegate, queue: DispatchQueue, factory: @escaping () -> any WindowsRadio) { + self.delegate = delegate; self.queue = queue; self.factory = factory + } + deinit { timer?.cancel(); driver?.close() } + package func restart() { + guard driver == nil || state == .resetting || state == .unsupported || state == .unauthorized else { return } + shutdown() + driver = factory() + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now(), repeating: .milliseconds(4), leeway: .milliseconds(1)) + timer.setEventHandler { [weak self] in self?.drain() } + self.timer = timer; timer.resume() + } + package func shutdown() { + timer?.cancel(); timer = nil + isScanning = false; seen.removeAll() + for peripheral in connections.values { peripheral.invalidate() } + connections.removeAll(); peripherals.removeAll() + driver?.close(); driver = nil; state = .unknown + } + package func scanForPeripherals(withServices services: [CBUUID]?, options: [String: Any]?) { + guard state == .poweredOn, !isScanning else { return } + seen.removeAll() + isScanning = driver?.scan(true) == true + if !isScanning { updateState(.unsupported) } + } + package func stopScan() { isScanning = false; seen.removeAll(); _ = driver?.scan(false) } + package func connect(_ peripheral: CBPeripheral, options: [String: Any]?) { + guard state == .poweredOn, peripheral.token == 0, nextToken < UInt64.max else { return } + nextToken += 1; peripheral.token = nextToken; connections[nextToken] = peripheral + if driver?.connect(token: nextToken, address: peripheral.address, type: peripheral.addressType) != true { failed(peripheral, code: 1) } + } + package func cancelPeripheralConnection(_ peripheral: CBPeripheral) { + guard peripheral.token != 0 else { return } + peripheral.disconnecting = true; peripheral.writing = false + if driver?.cancel(peripheral.token) != true { finish(peripheral, failure: nil) } + } + package func failed(_ peripheral: WindowsPeripheral, code: Int32) { + _ = driver?.cancel(peripheral.token) + finish(peripheral, failure: WindowsRadioError(code: code)) + } + private func finish(_ peripheral: WindowsPeripheral, failure: WindowsRadioError?) { + let connected = peripheral.connected, cancelled = peripheral.disconnecting + connections.removeValue(forKey: peripheral.token); peripheral.invalidate() + // A live peripheral may advertise during continuous discovery. Forget + // that scan admission when its link retires so a fresh advertisement + // can be delivered again. This neither starts scanning nor reconnects; + // the shared transport still owns retry, consent and explicit-stop policy. + seen.remove(peripheral.identifier) + if let failure, !connected, !cancelled { delegate?.centralManager(self, didFailToConnect: peripheral, error: failure) } + else { delegate?.centralManager(self, didDisconnectPeripheral: peripheral, error: failure) } + } + private func updateState(_ value: CBManagerState) { + state = value + if value != .poweredOn { + isScanning = false; seen.removeAll() + for peripheral in connections.values { peripheral.invalidate() } + connections.removeAll() + } + delegate?.centralManagerDidUpdateState(self) + } + package func drain() { + // Bound work on the controller queue even when the OS delivers a burst. + for _ in 0..<256 { + guard let event = driver?.next() else { break } + receive(event) + } + } + package func receive(_ event: S2WEvent) { + if event.kind == UInt32(S2W_STATE) { + let values: [CBManagerState] = [.unknown, .resetting, .unsupported, .unauthorized, .poweredOff, .poweredOn] + updateState(values.indices.contains(Int(event.status)) ? values[Int(event.status)] : .unknown); return + } + if event.kind == UInt32(S2W_OVERFLOW) { updateState(.resetting); return } + if event.kind == UInt32(S2W_ADVERTISEMENT) { + guard isScanning, state == .poweredOn, event.length <= 512, event.host_address != 0, + event.address != 0, event.address_type <= 1 else { return } + let data = withUnsafeBytes(of: event.bytes) { Data($0.prefix(Int(event.length))) } + guard Switch2.recognizeAdvertisement(data) != nil else { return } + let candidate = WindowsPeripheral(address: event.address, type: event.address_type, host: event.host_address, central: self) + guard seen.count < 128, !seen.contains(candidate.identifier) else { return } + if peripherals[candidate.identifier] == nil, peripherals.count >= 128 { + guard let idle = peripherals.first(where: { $0.value.token == 0 })?.key else { return } + peripherals.removeValue(forKey: idle) + } + let peripheral = peripherals[candidate.identifier] ?? candidate + peripherals[candidate.identifier] = peripheral + seen.insert(candidate.identifier) + delegate?.centralManager(self, didDiscover: peripheral, advertisementData: [CBAdvertisementDataManufacturerDataKey: data], rssi: NSNumber(value: event.rssi)); return + } + // Tokens fence late callbacks from canceled attempts and replaced links. + guard let peripheral = connections[event.token], peripheral.token == event.token else { return } + if event.kind == UInt32(S2W_DISCONNECTED) { finish(peripheral, failure: nil); return } + if event.kind == UInt32(S2W_FAILED) { finish(peripheral, failure: WindowsRadioError(code: event.status)); return } + guard !peripheral.disconnecting else { return } + switch event.kind { + case UInt32(S2W_CONNECTED): + peripheral.connected = true; peripheral.mtu = event.flags + delegate?.centralManager(self, didConnect: peripheral) + case UInt32(S2W_MTU): peripheral.mtu = event.flags + case UInt32(S2W_CHARACTERISTIC): + let text = withUnsafeBytes(of: event.uuid) { String(decoding: $0.prefix(while: { $0 != 0 }), as: UTF8.self) } + guard let uuid = UUID(uuidString: text), peripheral.characteristics.count < 128 else { failed(peripheral, code: 1); return } + peripheral.characteristics[event.characteristic] = WindowsCharacteristic(index: event.characteristic, uuid: uuid, flags: event.flags) + case UInt32(S2W_SERVICES): + peripheral.services = [WindowsService(peripheral.characteristics.sorted { $0.key < $1.key }.map(\.value))] + peripheral.delegate?.peripheral(peripheral, didDiscoverServices: nil) + case UInt32(S2W_NOTIFICATION): + guard let characteristic = peripheral.characteristics[event.characteristic] else { return } + characteristic.isNotifying = event.flags != 0 && event.status == 0 + peripheral.delegate?.peripheral(peripheral, didUpdateNotificationStateFor: characteristic, error: event.status == 0 ? nil : WindowsRadioError(code: event.status)) + case UInt32(S2W_VALUE): + guard event.length <= 512, let characteristic = peripheral.characteristics[event.characteristic] else { failed(peripheral, code: 1); return } + characteristic.value = withUnsafeBytes(of: event.bytes) { Data($0.prefix(Int(event.length))) } + peripheral.delegate?.peripheral(peripheral, didUpdateValueFor: characteristic, error: nil) + case UInt32(S2W_WRITABLE): + peripheral.writing = false; peripheral.delegate?.peripheralIsReady(toSendWriteWithoutResponse: peripheral) + default: break + } + } +} +#endif diff --git a/Sources/Switch2Kit/Public/Switch2ControllerManager.swift b/Sources/Switch2Kit/Public/Switch2ControllerManager.swift index 7726e9a..b600865 100644 --- a/Sources/Switch2Kit/Public/Switch2ControllerManager.swift +++ b/Sources/Switch2Kit/Public/Switch2ControllerManager.swift @@ -1,4 +1,4 @@ -#if canImport(CoreBluetooth) || os(Linux) +#if canImport(CoreBluetooth) || os(Linux) || os(Windows) import Foundation #if canImport(Combine) import Combine diff --git a/Sources/Switch2KitC/Exports.swift b/Sources/Switch2KitC/Exports.swift index f42b7ed..ff9ec23 100644 --- a/Sources/Switch2KitC/Exports.swift +++ b/Sources/Switch2KitC/Exports.swift @@ -23,10 +23,10 @@ public func createContext(_ config: UnsafePointer?, _ result: UnsafeM result?.pointee = 2; return nil } guard (1...256).contains(capacity), (1...64).contains(maximum) else { result?.pointee = 1; return nil } - #if canImport(CoreBluetooth) || os(Linux) + #if canImport(CoreBluetooth) || os(Linux) || os(Windows) guard Thread.isMainThread else { result?.pointee = 4; return nil } do { - let source = MainActor.assumeIsolated { ManagerSource(maximumControllers: maximum) } + let source = ManagerSource(maximumControllers: maximum) let handle = retainedHandle(try CContext(source: source, capacity: capacity)) result?.pointee = 0; return handle } catch { result?.pointee = cError(error); return nil } @@ -118,4 +118,4 @@ public func pulseRumble(_ handle: OpaquePointer?, _ id: UnsafePointer?, _ public func setPlayer(_ handle: OpaquePointer?, _ id: UnsafePointer?, _ connection: UnsafePointer?, _ number: UInt32) -> Int32 { guard (1...8).contains(number) else { return 1 } return control(handle, id, connection) { c, source in source.player(id: c.id, connection: c.connectionID, number: Int(number)); return 0 } -} \ No newline at end of file +} diff --git a/Sources/Switch2KitC/ManagerSource.swift b/Sources/Switch2KitC/ManagerSource.swift index c1b3a13..4248463 100644 --- a/Sources/Switch2KitC/ManagerSource.swift +++ b/Sources/Switch2KitC/ManagerSource.swift @@ -1,28 +1,35 @@ -#if canImport(CoreBluetooth) || os(Linux) +#if canImport(CoreBluetooth) || os(Linux) || os(Windows) import Foundation import Switch2Kit +// C/C++ hosts consume the thread-safe hub, not a SwiftUI presentation snapshot. +// Reuse the same transport directly so Qt/wxWidgets do not need to run Swift's +// main dispatch queue just to maintain an unused presentation observer. package final class ManagerSource: ControllerSource { - let manager: Switch2ControllerManager - package var hub: ControllerEventHub { manager.hub } - @MainActor init(maximumControllers: Int) { - manager = Switch2ControllerManager(configuration: .init(maximumControllers: maximumControllers)) + package let hub: ControllerEventHub + private let transport: ControllerTransport + init(maximumControllers: Int) { + let hub = ControllerEventHub() + self.hub = hub + transport = ControllerTransport(configuration: .init(maximumControllers: maximumControllers), + hub: hub, diagnostics: Switch2Diagnostics()) } - package func start() { manager.start() } - package func stop(completion: @escaping @Sendable () -> Void) { manager.stop(completion: completion) } - package func discover(seconds: Double) { try? manager.discover(for: seconds) } + deinit { transport.shutdown() } + package func start() { transport.start() } + package func stop(completion: @escaping @Sendable () -> Void) { transport.stop(completion: completion) } + package func discover(seconds: Double) { transport.requestDiscoveryWindow(seconds: seconds) } package func setAutomaticDiscovery(_ enabled: Bool) { - manager.configureDiscovery(enabled ? .automatic : .onDemand) + transport.configureDiscovery(mode: enabled ? .automatic : .onDemand, remembered: []) } package func disconnect(id: Switch2ControllerID, connection: UUID, forget: Bool) { - manager.transport.disconnect(id, forget: forget, expectedConnection: connection) + transport.disconnect(id, forget: forget, expectedConnection: connection) } package func rumble(id: Switch2ControllerID, connection: UUID, strong: Double, weak: Double, duration: Double?, feedback: Bool) { - manager.transport.submitRumble(id, strong: strong, weak: weak, duration: duration, - feedback: feedback, expectedConnection: connection) + transport.submitRumble(id, strong: strong, weak: weak, duration: duration, + feedback: feedback, expectedConnection: connection) } package func player(id: Switch2ControllerID, connection: UUID, number: Int) { - manager.transport.withSession(id, expectedConnection: connection) { $0.setPlayerNumber(number) } + transport.withSession(id, expectedConnection: connection) { $0.setPlayerNumber(number) } } } #endif diff --git a/Sources/Switch2KitC/MotionProfile.swift b/Sources/Switch2KitC/MotionProfile.swift index e7dde19..92d7c84 100644 --- a/Sources/Switch2KitC/MotionProfile.swift +++ b/Sources/Switch2KitC/MotionProfile.swift @@ -91,4 +91,4 @@ public func motionProfileCalibration(_ profile: UnsafePointer? /// C ABI entry point; same monotonic host clock used when accepting a controller input report. @_cdecl("s2k_monotonic_time") -public func monotonicTime() -> Double { ProcessInfo.processInfo.systemUptime } +public func monotonicTime() -> Double { ControllerClock.now } diff --git a/Sources/Switch2KitWinRT/BoundedQueue.hpp b/Sources/Switch2KitWinRT/BoundedQueue.hpp new file mode 100644 index 0000000..8435652 --- /dev/null +++ b/Sources/Switch2KitWinRT/BoundedQueue.hpp @@ -0,0 +1,36 @@ +#pragma once +#include +#include +#include +#include + +namespace Switch2KitWinRT { +// Both events and commands have fixed admission limits. A caller must handle a +// false push; silently losing an input release or a cancellation is not allowed. +template class BoundedQueue { + std::mutex mutex; + std::deque queue; +public: + bool push(T value) { + std::lock_guard lock(mutex); + if (queue.size() >= Capacity) return false; + try { queue.push_back(std::move(value)); return true; } + catch (...) { return false; } + } + bool pop(T& value) { + std::lock_guard lock(mutex); + if (queue.empty()) return false; + value = std::move(queue.front()); + queue.pop_front(); + return true; + } + bool empty() { + std::lock_guard lock(mutex); + return queue.empty(); + } + void clear() { + std::lock_guard lock(mutex); + queue.clear(); + } +}; +} diff --git a/Sources/Switch2KitWinRT/Radio.cpp b/Sources/Switch2KitWinRT/Radio.cpp new file mode 100644 index 0000000..171d0ae --- /dev/null +++ b/Sources/Switch2KitWinRT/Radio.cpp @@ -0,0 +1,442 @@ +#include "Switch2KitWinRT.h" +#include "BoundedQueue.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace winrt; +using namespace Windows::Foundation; +using namespace Windows::Devices::Bluetooth; +using namespace Windows::Devices::Bluetooth::Advertisement; +using namespace Windows::Devices::Bluetooth::GenericAttributeProfile; +using namespace Windows::Devices::Radios; +using namespace Windows::Storage::Streams; + +namespace { +template void quietly(F&& f) noexcept { try { f(); } catch (...) {} } +struct Characteristic { + GattCharacteristic value{nullptr}; + event_token changed{}; + bool registered = false; + bool notifying = false; +}; +struct Link { + BluetoothLEDevice device{nullptr}; + GattSession session{nullptr}; + std::vector services; + std::vector characteristics; + std::unordered_map operations; + event_token disconnected{}, invalidated{}, mtu{}; + bool deviceEvents = false, sessionEvents = false, ready = false, discovering = false, writing = false; + ~Link() { close(); } + void close() noexcept { + ready = false; + for (auto& entry : operations) quietly([&] { entry.second.Cancel(); }); + operations.clear(); + for (auto& ch : characteristics) + if (ch.registered) quietly([&] { ch.value.ValueChanged(ch.changed); }); + characteristics.clear(); + if (device && deviceEvents) { + quietly([&] { device.ConnectionStatusChanged(disconnected); }); + quietly([&] { device.GattServicesChanged(invalidated); }); + } + deviceEvents = false; + if (session) { + if (sessionEvents) quietly([&] { session.MaxPduSizeChanged(mtu); }); + quietly([&] { session.MaintainConnection(false); }); + quietly([&] { session.Close(); }); + } + sessionEvents = false; + session = nullptr; + for (auto& service : services) quietly([&] { service.Close(); }); + services.clear(); + if (device) quietly([&] { device.Close(); }); + device = nullptr; + } +}; + +// One MTA thread owns every WinRT object. WinRT callbacks only submit bounded +// work to that thread. No async operation blocks the emulator/Swift radio queue. +struct Core : std::enable_shared_from_this { + using Task = std::function; + Switch2KitWinRT::BoundedQueue tasks; + Switch2KitWinRT::BoundedQueue events; + std::mutex wakeMutex; + std::condition_variable wake; + std::atomic stopping{false}, faulted{false}; + std::thread worker; + uint64_t epoch = 1, nextOperation = 0, host = 0; + BluetoothLEAdvertisementWatcher watcher{nullptr}; + Radio radio{nullptr}; + event_token received{}, watcherStopped{}, radioChanged{}; + bool watcherEvents = false, radioEvents = false, scanning = false; + int32_t state = 0; + IAsyncInfo startup{nullptr}; + std::unordered_map> links; + + bool post(Task task) { + std::lock_guard lock(wakeMutex); + if (stopping.load()) return false; + if (!tasks.push(std::move(task))) { faulted.store(true); wake.notify_one(); return false; } + wake.notify_one(); return true; + } + void emit(S2WEvent event) { + if (!events.push(event) && event.kind != S2W_ADVERTISEMENT) { + // Failure is observable, and the worker closes all affected links. + // Never silently overwrite a button release, ACK or stop request. + std::lock_guard lock(wakeMutex); + faulted.store(true); wake.notify_one(); + } + } + S2WEvent event(uint32_t kind, uint64_t token = 0) { + S2WEvent value{}; value.kind = kind; value.token = token; return value; + } + void publishState(int32_t value) { + state = value; + auto result = event(S2W_STATE); result.status = value; result.host_address = host; emit(result); + } + void fail(uint64_t token, int32_t status) { + if (token) { + auto i = links.find(token); + if (i == links.end()) return; + links.erase(i); + auto result = event(S2W_FAILED, token); result.status = status; emit(result); + } else { + cleanup(); + publishState(status == E_ACCESSDENIED ? 3 : 2); + } + } + template void after(Operation operation, uint64_t token, F completion) { + auto operationID = ++nextOperation; + if (token) { + auto i = links.find(token); + if (i == links.end()) { quietly([&] { operation.Cancel(); }); return; } + if (i->second->operations.size() >= 16) { fail(token, E_OUTOFMEMORY); return; } + i->second->operations.emplace(operationID, operation.template as()); + } else startup = operation.template as(); + auto weak = weak_from_this(); + auto generation = epoch; + operation.Completed([weak, generation, token, operationID, completion](auto const& done, AsyncStatus) { + if (auto owner = weak.lock()) owner->post([=](Core& self) { + if (generation != self.epoch) return; + if (token) { + auto i = self.links.find(token); + if (i == self.links.end()) return; + i->second->operations.erase(operationID); + } else self.startup = nullptr; + try { completion(self, done.GetResults()); } + catch (hresult_error const& error) { self.fail(token, error.code()); } + catch (...) { self.fail(token, E_FAIL); } + }); + }); + } + void cleanup() noexcept { + ++epoch; + scanning = false; + if (startup) quietly([&] { startup.Cancel(); }); + startup = nullptr; + if (watcher) { + if (watcherEvents) { + quietly([&] { watcher.Received(received); }); + quietly([&] { watcher.Stopped(watcherStopped); }); + } + quietly([&] { watcher.Stop(); }); + } + watcherEvents = false; watcher = nullptr; + if (radio && radioEvents) quietly([&] { radio.StateChanged(radioChanged); }); + radioEvents = false; radio = nullptr; + links.clear(); + } + void run() noexcept { + try { + init_apartment(apartment_type::multi_threaded); + initialize(); + while (!stopping.load()) { + if (faulted.exchange(false)) { + cleanup(); tasks.clear(); events.clear(); + emit(event(S2W_OVERFLOW)); + publishState(1); + // Retry is explicit through SDK stop/start; do not reconnect + // automatically after losing ownership of input transitions. + continue; + } + Task task; + if (tasks.pop(task)) { + try { task(*this); } + catch (hresult_error const& error) { fail(0, error.code()); } + catch (...) { fail(0, E_FAIL); } + continue; + } + std::unique_lock lock(wakeMutex); + wake.wait(lock, [&] { + return stopping.load() || faulted.load() || !tasks.empty(); + }); + } + cleanup(); tasks.clear(); + uninit_apartment(); + } catch (...) { publishState(2); } + } + void initialize() { + after(BluetoothAdapter::GetDefaultAsync(), 0, [](Core& self, BluetoothAdapter adapter) { + if (!adapter || !adapter.IsLowEnergySupported()) { self.publishState(2); return; } + self.host = adapter.BluetoothAddress(); + self.after(adapter.GetRadioAsync(), 0, [](Core& core, Radio selected) { + if (!selected) { core.publishState(3); return; } + core.radio = selected; + auto weak = core.weak_from_this(); auto generation = core.epoch; + core.radioChanged = selected.StateChanged([weak, generation](auto const&, auto const&) { + if (auto owner = weak.lock()) owner->post([generation](Core& c) { + if (c.epoch == generation) c.updateRadio(); + }); + }); + core.radioEvents = true; + core.updateRadio(); + }); + }); + } + void updateRadio() { + if (!radio) return; + const auto value = radio.State(); + if (value != RadioState::On) { + if (watcher) quietly([&] { watcher.Stop(); }); + scanning = false; links.clear(); + } + publishState(value == RadioState::On ? 5 : value == RadioState::Disabled ? 3 : 4); + } + void scan(bool enabled) { + if (!enabled) { + scanning = false; + if (watcher) watcher.Stop(); + return; + } + if (state != 5 || scanning) return; + if (!watcher) { + watcher = BluetoothLEAdvertisementWatcher(); + watcher.ScanningMode(BluetoothLEScanningMode::Active); + auto weak = weak_from_this(); auto generation = epoch; + received = watcher.Received([weak, generation](auto const&, BluetoothLEAdvertisementReceivedEventArgs const& args) { + try { + for (auto const& manufacturer : args.Advertisement().ManufacturerData()) { + if (manufacturer.CompanyId() != 0x0553) continue; + const auto buffer = manufacturer.Data(); + if (buffer.Length() < 16 || buffer.Length() > 510) continue; + S2WEvent e{}; e.kind = S2W_ADVERTISEMENT; + e.address = args.BluetoothAddress(); e.address_type = static_cast(args.BluetoothAddressType()); + e.rssi = args.RawSignalStrengthInDBm(); e.length = buffer.Length() + 2; + e.bytes[0] = 0x53; e.bytes[1] = 0x05; + DataReader::FromBuffer(buffer).ReadBytes(array_view(e.bytes + 2, e.bytes + e.length)); + if (auto owner = weak.lock()) owner->post([generation, e](Core& c) mutable { + if (c.epoch != generation || !c.scanning) return; + e.host_address = c.host; c.emit(e); + }); + break; + } + } catch (...) { /* Ignore malformed advertisements, not link input. */ } + }); + watcherStopped = watcher.Stopped([weak, generation](auto const&, BluetoothLEAdvertisementWatcherStoppedEventArgs const& args) { + const auto error = args.Error(); + if (auto owner = weak.lock()) owner->post([generation, error](Core& c) { + if (c.epoch != generation || !c.scanning || error == BluetoothError::Success) return; + c.fail(0, error == BluetoothError::DisabledByUser || error == BluetoothError::DisabledByPolicy ? E_ACCESSDENIED : E_FAIL); + }); + }); + watcherEvents = true; + } + scanning = true; watcher.Start(); + } + void connect(uint64_t token, uint64_t address, uint32_t type) { + if (state != 5 || links.size() >= 64 || links.count(token)) { + auto e = event(S2W_FAILED, token); e.status = E_FAIL; emit(e); return; + } + links.emplace(token, std::make_unique()); + try { + after(BluetoothLEDevice::FromBluetoothAddressAsync(address, static_cast(type)), token, + [token](Core& self, BluetoothLEDevice device) { + if (!device) { self.fail(token, E_ACCESSDENIED); return; } + auto& link = *self.links.at(token); link.device = device; + auto weak = self.weak_from_this(); auto generation = self.epoch; + link.disconnected = device.ConnectionStatusChanged([weak, generation, token](auto const&, auto const&) { + if (auto owner = weak.lock()) owner->post([generation, token](Core& c) { + if (c.epoch != generation || !c.links.count(token)) return; + auto& l = *c.links.at(token); + if (l.ready && l.device.ConnectionStatus() == BluetoothConnectionStatus::Disconnected) c.cancel(token); + }); + }); + link.invalidated = device.GattServicesChanged([weak, generation, token](auto const&, auto const&) { + if (auto owner = weak.lock()) owner->post([generation, token](Core& c) { + if (c.epoch == generation && c.links.count(token) && c.links.at(token)->ready) c.fail(token, E_CHANGED_STATE); + }); + }); + link.deviceEvents = true; + self.after(GattSession::FromDeviceIdAsync(device.BluetoothDeviceId()), token, + [token](Core& core, GattSession session) { + if (!session) { core.fail(token, E_FAIL); return; } + auto& l = *core.links.at(token); l.session = session; + session.MaintainConnection(true); + auto weak = core.weak_from_this(); auto generation = core.epoch; + l.mtu = session.MaxPduSizeChanged([weak, generation, token](auto const&, auto const&) { + if (auto owner = weak.lock()) owner->post([generation, token](Core& c) { + if (c.epoch != generation || !c.links.count(token)) return; + auto e = c.event(S2W_MTU, token); e.flags = c.links.at(token)->session.MaxPduSize(); c.emit(e); + }); + }); + l.sessionEvents = true; + // A factory result is NOT a connection. Uncached discovery + // initiates GATT and must succeed before emitting CONNECTED. + core.after(l.device.GetGattServicesAsync(BluetoothCacheMode::Uncached), token, + [token](Core& c, GattDeviceServicesResult result) { + if (result.Status() != GattCommunicationStatus::Success || result.Services().Size() > 32) { + c.fail(token, result.Status() == GattCommunicationStatus::AccessDenied ? E_ACCESSDENIED : E_FAIL); return; + } + auto& link = *c.links.at(token); + for (auto const& service : result.Services()) link.services.push_back(service); + link.ready = true; + auto e = c.event(S2W_CONNECTED, token); e.flags = link.session.MaxPduSize(); c.emit(e); + }); + }); + }); + } catch (hresult_error const& e) { fail(token, e.code()); } + } + void cancel(uint64_t token) { + links.erase(token); + emit(event(S2W_DISCONNECTED, token)); + } + void discover(uint64_t token, size_t index = 0) { + if (!links.count(token)) return; + auto& link = *links.at(token); + if (!link.ready || (index == 0 && link.discovering)) return; + link.discovering = true; + if (index == link.services.size()) { emit(event(S2W_SERVICES, token)); return; } + after(link.services[index].GetCharacteristicsAsync(BluetoothCacheMode::Uncached), token, + [token, index](Core& self, GattCharacteristicsResult result) { + if (result.Status() != GattCommunicationStatus::Success) { self.fail(token, E_FAIL); return; } + auto& l = *self.links.at(token); + if (l.characteristics.size() + result.Characteristics().Size() > 128) { self.fail(token, E_OUTOFMEMORY); return; } + for (auto const& value : result.Characteristics()) { + auto e = self.event(S2W_CHARACTERISTIC, token); + e.characteristic = static_cast(l.characteristics.size()); + const auto id = to_string(to_hstring(value.Uuid())); + // winrt::to_string(guid) includes braces; Swift expects UUID text. + const auto plain = id.size() == 38 ? id.substr(1, 36) : id; + if (plain.size() != 36) { self.fail(token, E_INVALIDARG); return; } + std::memcpy(e.uuid, plain.data(), 36); + auto properties = value.CharacteristicProperties(); + if ((properties & GattCharacteristicProperties::WriteWithoutResponse) != GattCharacteristicProperties::None) e.flags |= S2W_WRITE; + if ((properties & GattCharacteristicProperties::Notify) != GattCharacteristicProperties::None) e.flags |= S2W_NOTIFY; + if ((properties & GattCharacteristicProperties::Indicate) != GattCharacteristicProperties::None) e.flags |= S2W_INDICATE; + Characteristic characteristic; characteristic.value = value; + l.characteristics.push_back(std::move(characteristic)); self.emit(e); + } + self.discover(token, index + 1); + }); + } + void notify(uint64_t token, uint32_t index, bool enabled) { + if (!links.count(token)) return; + auto& link = *links.at(token); + if (index >= link.characteristics.size()) { fail(token, E_INVALIDARG); return; } + auto& ch = link.characteristics[index]; + if (enabled && !ch.registered) { + auto weak = weak_from_this(); auto generation = epoch; + ch.changed = ch.value.ValueChanged([weak, generation, token, index](auto const&, GattValueChangedEventArgs const& args) { + if (auto owner = weak.lock()) { + try { + auto buffer = args.CharacteristicValue(); + if (buffer.Length() > 512) { + owner->post([token](Core& c) { c.fail(token, E_INVALIDARG); }); return; + } + S2WEvent e{}; e.kind = S2W_VALUE; e.token = token; e.characteristic = index; e.length = buffer.Length(); + DataReader::FromBuffer(buffer).ReadBytes(array_view(e.bytes, e.bytes + e.length)); + owner->post([generation, e](Core& c) { + if (c.epoch == generation && c.links.count(e.token)) c.emit(e); + }); + } catch (...) { owner->post([token](Core& c) { c.fail(token, E_FAIL); }); } + } + }); + ch.registered = true; + } + const auto properties = ch.value.CharacteristicProperties(); + const auto setting = !enabled ? GattClientCharacteristicConfigurationDescriptorValue::None : + (properties & GattCharacteristicProperties::Notify) != GattCharacteristicProperties::None ? + GattClientCharacteristicConfigurationDescriptorValue::Notify : GattClientCharacteristicConfigurationDescriptorValue::Indicate; + after(ch.value.WriteClientCharacteristicConfigurationDescriptorAsync(setting), token, + [token, index, enabled](Core& self, GattCommunicationStatus status) { + auto& ch = self.links.at(token)->characteristics.at(index); + ch.notifying = enabled && status == GattCommunicationStatus::Success; + if (!ch.notifying && ch.registered) { ch.value.ValueChanged(ch.changed); ch.registered = false; } + auto e = self.event(S2W_NOTIFICATION, token); e.characteristic = index; + e.flags = ch.notifying ? 1 : 0; e.status = status == GattCommunicationStatus::Success ? 0 : E_FAIL; self.emit(e); + }); + } + void write(uint64_t token, uint32_t index, std::array const& bytes, uint32_t length) { + if (!links.count(token)) return; + auto& link = *links.at(token); + if (!link.ready || link.writing || index >= link.characteristics.size() || + link.session.MaxPduSize() < 3 || length > link.session.MaxPduSize() - 3u) { fail(token, E_INVALIDARG); return; } + auto& ch = link.characteristics[index]; + if ((ch.value.CharacteristicProperties() & GattCharacteristicProperties::WriteWithoutResponse) == GattCharacteristicProperties::None) { + fail(token, E_INVALIDARG); return; + } + DataWriter writer; writer.WriteBytes(array_view(bytes.data(), bytes.data() + length)); + link.writing = true; + after(ch.value.WriteValueWithResultAsync(writer.DetachBuffer(), GattWriteOption::WriteWithoutResponse), token, + [token](Core& self, GattWriteResult result) { + if (result.Status() != GattCommunicationStatus::Success) { self.fail(token, E_FAIL); return; } + self.links.at(token)->writing = false; self.emit(self.event(S2W_WRITABLE, token)); + }); + } +}; +} +struct S2WRadio { std::shared_ptr core; }; +extern "C" S2WRadio* s2w_create() { + try { + auto radio = std::make_unique(); radio->core = std::make_shared(); + auto* core = radio->core.get(); core->worker = std::thread([core] { core->run(); }); + return radio.release(); + } catch (...) { return nullptr; } +} +extern "C" void s2w_destroy(S2WRadio* radio) { + if (!radio) return; + { std::lock_guard lock(radio->core->wakeMutex); radio->core->stopping.store(true); } + radio->core->wake.notify_one(); + if (radio->core->worker.joinable()) radio->core->worker.join(); + delete radio; +} +extern "C" int32_t s2w_scan(S2WRadio* r, uint32_t enabled) { + return r && enabled <= 1 && r->core->post([enabled](Core& c) { c.scan(enabled != 0); }); +} +extern "C" int32_t s2w_connect(S2WRadio* r, uint64_t token, uint64_t address, uint32_t type) { + return r && token && address && address <= 0xffffffffffffULL && type <= 1 && + r->core->post([=](Core& c) { c.connect(token, address, type); }); +} +extern "C" int32_t s2w_cancel(S2WRadio* r, uint64_t token) { + return r && token && r->core->post([token](Core& c) { c.cancel(token); }); +} +extern "C" int32_t s2w_discover(S2WRadio* r, uint64_t token) { + return r && token && r->core->post([token](Core& c) { c.discover(token); }); +} +extern "C" int32_t s2w_notify(S2WRadio* r, uint64_t token, uint32_t index, uint32_t enabled) { + return r && token && enabled <= 1 && r->core->post([=](Core& c) { c.notify(token, index, enabled != 0); }); +} +extern "C" int32_t s2w_write(S2WRadio* r, uint64_t token, uint32_t index, const uint8_t* data, uint32_t length) { + if (!r || !token || !data || !length || length > 512) return 0; + std::array bytes{}; std::copy_n(data, length, bytes.data()); + return r->core->post([=](Core& c) { c.write(token, index, bytes, length); }); +} +extern "C" int32_t s2w_next(S2WRadio* r, S2WEvent* event) { + return r && event && r->core->events.pop(*event); +} diff --git a/Sources/Switch2KitWinRT/include/Switch2KitWinRT.h b/Sources/Switch2KitWinRT/include/Switch2KitWinRT.h new file mode 100644 index 0000000..b8f2b57 --- /dev/null +++ b/Sources/Switch2KitWinRT/include/Switch2KitWinRT.h @@ -0,0 +1,40 @@ +#ifndef SWITCH2KIT_WINRT_H +#define SWITCH2KIT_WINRT_H +#include +#ifdef __cplusplus +// HRESULT definitions belong to the C++ implementation, not the Swift C module. +#include +extern "C" { +#endif +/* Private transport ABI. No C++ or WinRT objects cross into Swift. */ +typedef struct S2WRadio S2WRadio; +enum { S2W_STATE=1, S2W_ADVERTISEMENT, S2W_CONNECTED, S2W_CHARACTERISTIC, + S2W_SERVICES, S2W_NOTIFICATION, S2W_VALUE, S2W_WRITABLE, + S2W_DISCONNECTED, S2W_FAILED, S2W_MTU, S2W_OVERFLOW }; +enum { S2W_WRITE=1, S2W_NOTIFY=2, S2W_INDICATE=4 }; +typedef struct S2WEvent { + uint32_t kind; + int32_t status; + uint64_t token, address, host_address; + uint32_t address_type, characteristic, flags, length; + int32_t rssi; + char uuid[37]; + uint8_t bytes[512]; +} S2WEvent; +/* Create starts asynchronous adapter initialization, never scans. Destroy must + * not race other calls. All other calls copy their input and are nonblocking. + * Commands return 1 when admitted, 0 when rejected. next returns 1 for an event. + * Tokens must be nonzero and must never be reused within a radio's lifetime. */ +S2WRadio *s2w_create(void); +void s2w_destroy(S2WRadio *radio); +int32_t s2w_scan(S2WRadio *radio, uint32_t enabled); +int32_t s2w_connect(S2WRadio *radio, uint64_t token, uint64_t address, uint32_t address_type); +int32_t s2w_cancel(S2WRadio *radio, uint64_t token); +int32_t s2w_discover(S2WRadio *radio, uint64_t token); +int32_t s2w_notify(S2WRadio *radio, uint64_t token, uint32_t characteristic, uint32_t enabled); +int32_t s2w_write(S2WRadio *radio, uint64_t token, uint32_t characteristic, const uint8_t *bytes, uint32_t length); +int32_t s2w_next(S2WRadio *radio, S2WEvent *event); +#ifdef __cplusplus +} +#endif +#endif diff --git a/Tests/Switch2KitCTests/MotionProfileTests.swift b/Tests/Switch2KitCTests/MotionProfileTests.swift index c8544e1..703563f 100644 --- a/Tests/Switch2KitCTests/MotionProfileTests.swift +++ b/Tests/Switch2KitCTests/MotionProfileTests.swift @@ -101,7 +101,7 @@ final class CMotionProfileTests: XCTestCase { XCTAssertEqual(output.version, 42) } func testClockIsTheReportReceiveClockAndThreadSafe() { - let before = ProcessInfo.processInfo.systemUptime, value = monotonicTime(), after = ProcessInfo.processInfo.systemUptime + let before = ControllerClock.now, value = monotonicTime(), after = ControllerClock.now XCTAssertTrue(value.isFinite); XCTAssertGreaterThanOrEqual(value, before); XCTAssertLessThanOrEqual(value, after) DispatchQueue.concurrentPerform(iterations: 1000) { _ in XCTAssertTrue(monotonicTime().isFinite) } } diff --git a/Tests/Switch2KitTests/ControllerClockTests.swift b/Tests/Switch2KitTests/ControllerClockTests.swift new file mode 100644 index 0000000..daabbd3 --- /dev/null +++ b/Tests/Switch2KitTests/ControllerClockTests.swift @@ -0,0 +1,21 @@ +import Foundation +import XCTest +@testable import Switch2Kit + +final class ControllerClockTests: XCTestCase { + func testReceiveClockIsFiniteMonotonicAndHighResolution() { + var previous = ControllerClock.now + XCTAssertTrue(previous.isFinite) + XCTAssertGreaterThan(previous, 0) + var distinct = 0 + for _ in 0..<10_000 { + let current = ControllerClock.now + XCTAssertGreaterThanOrEqual(current, previous) + if current > previous { distinct += 1 } + previous = current + } + // Do not require every call to advance, or depend on sleep scheduling. + // A millisecond/system-tick clock cannot timestamp high-rate reports. + XCTAssertGreaterThan(distinct, 100) + } +} diff --git a/Tests/Switch2KitTests/WindowsRadioTests.swift b/Tests/Switch2KitTests/WindowsRadioTests.swift new file mode 100644 index 0000000..80b6364 --- /dev/null +++ b/Tests/Switch2KitTests/WindowsRadioTests.swift @@ -0,0 +1,220 @@ +#if os(Windows) +import Foundation +import XCTest +import Switch2KitWinRT +@testable import Switch2Kit + +private final class RadioBoundary: WindowsRadio { + var events: [S2WEvent] = [] + var scans: [Bool] = [] + var tokens: [UInt64] = [] + var cancellations: [UInt64] = [] + var writes: [Data] = [] + var acceptsCancellation = true + var closed = false + func next() -> S2WEvent? { events.isEmpty ? nil : events.removeFirst() } + func scan(_ enabled: Bool) -> Bool { scans.append(enabled); return true } + func connect(token: UInt64, address: UInt64, type: UInt32) -> Bool { tokens.append(token); return true } + func cancel(_ token: UInt64) -> Bool { cancellations.append(token); return acceptsCancellation } + func discover(_ token: UInt64) -> Bool { true } + func notify(_ token: UInt64, index: UInt32, enabled: Bool) -> Bool { true } + func write(_ token: UInt64, index: UInt32, data: Data) -> Bool { writes.append(data); return true } + func close() { closed = true } +} +private final class RadioObserver: CBCentralManagerDelegate, CBPeripheralDelegate { + var found: [WindowsPeripheral] = [] + var stateChanges = 0, connected = 0, disconnected = 0, failed = 0, values = 0 + func centralManagerDidUpdateState(_ central: CBCentralManager) { stateChanges += 1 } + func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi: NSNumber) { found.append(peripheral) } + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { connected += 1 } + func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { failed += 1 } + func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { disconnected += 1 } + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {} + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {} + func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {} + func peripheral(_ peripheral: CBPeripheral, didReadRSSI rssi: NSNumber, error: Error?) {} + func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {} + func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) {} + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { values += 1 } +} +final class WindowsRadioTests: XCTestCase { + private func event(_ kind: UInt32, token: UInt64 = 0) -> S2WEvent { + var e = S2WEvent(); e.kind = kind; e.token = token; return e + } + private func setup() -> (WindowsCentral, RadioBoundary, RadioObserver) { + let observer = RadioObserver(), radio = RadioBoundary() + let central = WindowsCentral(delegate: observer, queue: DispatchQueue(label: "test.windows-radio"), factory: { radio }) + // Drive the real adaptation layer synchronously; only the OS boundary is replaced. + central.driver = radio + var ready = event(UInt32(S2W_STATE)); ready.status = 5 + central.receive(ready) + central.scanForPeripherals(withServices: nil, options: nil) + return (central, radio, observer) + } + private func advertisement(address: UInt64 = 42, model: UInt16 = 0x2073) -> S2WEvent { + var e = event(UInt32(S2W_ADVERTISEMENT)); e.address = address + e.host_address = 0x010203040506; e.address_type = 0; e.length = 18 + var bytes = [UInt8](repeating: 0, count: 18) + bytes[0] = 0x53; bytes[1] = 0x05; bytes[5] = 0x7e; bytes[6] = 0x05 + bytes[7] = UInt8(truncatingIfNeeded: model); bytes[8] = UInt8(truncatingIfNeeded: model >> 8) + withUnsafeMutableBytes(of: &e.bytes) { $0.copyBytes(from: bytes) } + return e + } + private func connect(_ central: WindowsCentral, _ observer: RadioObserver) throws -> WindowsPeripheral { + central.receive(advertisement()) + let p = try XCTUnwrap(observer.found.last); p.delegate = observer + central.connect(p, options: nil) + var connected = event(UInt32(S2W_CONNECTED), token: p.token); connected.flags = 67 + central.receive(connected) + return p + } + func testRecognizedModelsAdmissionAndBoundedAdvertisements() { + let (central, radio, observer) = setup(); defer { central.shutdown() } + for (index, model) in [UInt16(0x2073), 0x2069, 0x2067, 0x2066].enumerated() { + central.receive(advertisement(address: UInt64(index + 1), model: model)) + } + XCTAssertEqual(observer.found.count, 4) + central.receive(advertisement(address: 5, model: 0xffff)) + XCTAssertEqual(observer.found.count, 4) + for address in 1...1000 { central.receive(advertisement(address: UInt64(address))) } + XCTAssertEqual(observer.found.count, 128) + XCTAssertEqual(radio.scans, [true]) + } + func testCancelFencesLateConnectionAndOldInput() throws { + let (central, radio, observer) = setup(); defer { central.shutdown() } + central.receive(advertisement()) + let p = try XCTUnwrap(observer.found.first) + central.connect(p, options: nil); let old = p.token + radio.acceptsCancellation = false + central.cancelPeripheralConnection(p) + XCTAssertEqual(p.token, 0); XCTAssertEqual(radio.cancellations, [old]) + central.receive(event(UInt32(S2W_CONNECTED), token: old)) + XCTAssertEqual(observer.connected, 0) + central.connect(p, options: nil) + XCTAssertGreaterThan(p.token, old) + central.receive(event(UInt32(S2W_VALUE), token: old)) + XCTAssertEqual(observer.values, 0) + } + func testWritesUseBackpressureAndRejectOversizedFrames() throws { + let (central, radio, observer) = setup(); defer { central.shutdown() } + let p = try connect(central, observer) + let ch = WindowsCharacteristic(index: 0, uuid: UUID(), flags: UInt32(S2W_WRITE)) + p.characteristics[0] = ch + p.writeValue(Data([1]), for: ch, type: .withoutResponse) + p.writeValue(Data([2]), for: ch, type: .withoutResponse) + XCTAssertEqual(radio.writes, [Data([1])]) + central.receive(event(UInt32(S2W_WRITABLE), token: p.token)) + p.writeValue(Data([3]), for: ch, type: .withoutResponse) + XCTAssertEqual(radio.writes, [Data([1]), Data([3])]) + central.receive(event(UInt32(S2W_WRITABLE), token: p.token)) + p.writeValue(Data(repeating: 0, count: 65), for: ch, type: .withoutResponse) + XCTAssertEqual(radio.writes.count, 2); XCTAssertFalse(p.connected) + XCTAssertEqual(p.token, 0) + } + func testOverflowInvalidatesConnectionsAndRejectsLateValues() throws { + let (central, _, observer) = setup(); defer { central.shutdown() } + let p = try connect(central, observer); let token = p.token + central.receive(event(UInt32(S2W_OVERFLOW))) + XCTAssertFalse(central.isScanning); XCTAssertFalse(p.connected) + XCTAssertEqual(p.token, 0) + central.receive(event(UInt32(S2W_VALUE), token: token)) + XCTAssertEqual(observer.values, 0) + } + func testUnauthorizedStateDoesNotStartScanning() { + let (central, radio, _) = setup(); defer { central.shutdown() } + var denied = event(UInt32(S2W_STATE)); denied.status = 3 + central.receive(denied) + central.scanForPeripherals(withServices: nil, options: nil) + XCTAssertEqual(radio.scans, [true]); XCTAssertFalse(central.isScanning) + } + func testPollingWorkAndShutdownAreBounded() { + let (central, radio, observer) = setup() + radio.events = Array(repeating: event(UInt32(S2W_STATE)), count: 300) + let before = observer.stateChanges + central.drain() + XCTAssertEqual(observer.stateChanges - before, 256) + XCTAssertEqual(radio.events.count, 44) + central.shutdown(); central.shutdown() + XCTAssertTrue(radio.closed); XCTAssertNil(central.driver) + } + func testPhysicalIdentityAndHostAddressByteOrder() { + let (central, _, _) = setup(); defer { central.shutdown() } + let p = WindowsPeripheral(address: 42, type: 0, host: 0x010203040506, central: central) + let same = WindowsPeripheral(address: 42, type: 0, host: 0x010203040506, central: central) + let other = WindowsPeripheral(address: 42, type: 1, host: 0x010203040506, central: central) + XCTAssertEqual(p.identifier, same.identifier); XCTAssertNotEqual(p.identifier, other.identifier) + XCTAssertEqual(p.hostAddressBytesLE, Data([6, 5, 4, 3, 2, 1])) + } + func testContinuousScanReadmitsDisconnectedIdentityAndFencesRetiredToken() throws { + let (central, radio, observer) = setup(); defer { central.shutdown() } + let p = try connect(central, observer) + let retired = p.token + // The shared transport stops discovery during a handshake and resumes + // it after readiness. A live device may advertise during that new scan. + central.stopScan() + central.scanForPeripherals(withServices: nil, options: nil) + central.receive(advertisement()) + let before = observer.found.count + central.receive(advertisement()) + XCTAssertEqual(observer.found.count, before) + central.receive(event(UInt32(S2W_DISCONNECTED), token: retired)) + XCTAssertEqual(observer.disconnected, 1) + XCTAssertFalse(p.connected); XCTAssertEqual(p.token, 0) + XCTAssertTrue(central.isScanning) + XCTAssertEqual(observer.found.count, before) // No synthetic rediscovery. + central.receive(advertisement()) + XCTAssertEqual(observer.found.count, before + 1) + XCTAssertTrue(observer.found.last === p) + central.connect(p, options: nil) + let current = p.token + XCTAssertGreaterThan(current, retired) + central.receive(event(UInt32(S2W_CONNECTED), token: retired)) + central.receive(event(UInt32(S2W_VALUE), token: retired)) + central.receive(event(UInt32(S2W_FAILED), token: retired)) + XCTAssertFalse(p.connected); XCTAssertEqual(p.token, current) + XCTAssertEqual(observer.values, 0) + XCTAssertEqual(observer.connected, 1) + var connected = event(UInt32(S2W_CONNECTED), token: current); connected.flags = 67 + central.receive(connected) + XCTAssertTrue(p.connected); XCTAssertEqual(observer.connected, 2) + XCTAssertEqual(radio.scans, [true, false, true]) + } + + func testFailureAndCancellationDoNotPermanentlySuppressFreshAdvertisements() throws { + for failure in [true, false] { + let (central, radio, observer) = setup(); defer { central.shutdown() } + central.receive(advertisement()) + let p = try XCTUnwrap(observer.found.first) + central.connect(p, options: nil) + if failure { + central.receive(event(UInt32(S2W_FAILED), token: p.token)) + XCTAssertEqual(observer.failed, 1) + } else { + radio.acceptsCancellation = false + central.cancelPeripheralConnection(p) + XCTAssertEqual(observer.disconnected, 1) + } + XCTAssertEqual(p.token, 0) + XCTAssertEqual(observer.found.count, 1) + central.receive(advertisement()) + XCTAssertEqual(observer.found.count, 2) + XCTAssertTrue(observer.found.last === p) + } + } + + func testTerminalEventsNeverRestartExplicitlyStoppedDiscovery() throws { + let (central, radio, observer) = setup(); defer { central.shutdown() } + let p = try connect(central, observer) + central.stopScan() + central.receive(event(UInt32(S2W_DISCONNECTED), token: p.token)) + let before = observer.found.count + central.receive(advertisement()) + XCTAssertFalse(central.isScanning) + XCTAssertEqual(observer.found.count, before) + XCTAssertEqual(radio.scans, [true, false]) + central.shutdown() + central.receive(advertisement()) + XCTAssertEqual(observer.found.count, before) + } +} +#endif diff --git a/docs/switch2kit/cpp.md b/docs/switch2kit/cpp.md index 5db1a78..f91200c 100644 --- a/docs/switch2kit/cpp.md +++ b/docs/switch2kit/cpp.md @@ -1,23 +1,31 @@ # C and C++ hosts -The optional `Switch2KitC` binding exposes the same controller engine through `Switch2KitC.h`. It uses caller-owned C structs and a bounded polling reader, not Objective-C objects or Swift collections. The source `Switch2Kit` product is unchanged for Swift hosts. Bluetooth requires macOS 15+ with Swift 6.2+/Xcode 26+, or the experimental [Linux/BlueZ backend](linux.md) with Swift 6.2+ and its runtime dependencies. Linux uses the same live factory, event hub and session engine; Windows and Android remain unsupported. +The optional `Switch2KitC` binding exposes the same controller engine through `Switch2KitC.h`. It uses caller-owned C structs and a bounded polling reader, not Objective-C objects or Swift collections. The source `Switch2Kit` product is unchanged for Swift hosts. Bluetooth requires macOS 15+ with Swift 6.2+/Xcode 26+, or the experimental [Linux/BlueZ backend](linux.md) with Swift 6.2+ and its runtime dependencies. Experimental [Windows x64/WinRT support](windows.md) uses the same engine with a native Bluetooth transport. Android remains unsupported. ## Build with CMake ```cmake add_subdirectory(/path/to/Switch2Kit/Integrations/CMake switch2kit) target_link_libraries(your_emulator PRIVATE Switch2Kit::C) -# For a macOS application bundle: -switch2kit_embed(your_emulator) +# Call the platform helper in the directory that creates the executable. +if(APPLE) + switch2kit_embed(your_emulator) +elseif(WIN32) + switch2kit_embed_windows(your_emulator) +elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") + switch2kit_install_linux(your_emulator) +endif() ``` Use a CMake build directory owned by your project. The integration builds SwiftPM sources in that directory, respecting `CMAKE_OSX_ARCHITECTURES`. On macOS, explicitly select a deployment target of 15.0 or newer when enabling this backend. An emulator supporting older macOS versions should keep the backend optional rather than silently changing its minimum. The macOS host supplies its Bluetooth usage description and, when sandboxed, Bluetooth entitlement. Linux uses normal BlueZ/system-bus permissions and `switch2kit_install_linux` rather than macOS bundle embedding; see [Linux installation](linux.md). `switch2kit_embed` copies the binding and required Swift runtime libraries; the host's normal final signing step signs the bundle. No signing identity or application entitlements are supplied by the binding. +Windows uses a native x64 MSVC-compatible CMake build and the x64 Swift toolchain. The imported target provides both `Switch2KitC.dll` and its import library. `switch2kit_embed_windows` copies the DLL, its compiler-selected Swift runtime dependency closure, and the required license/attribution notices beside the executable. A successfully staged application does not need the Swift installation or a Swift-specific `PATH` entry at launch; Windows, Microsoft Visual C++ runtime and driver prerequisites remain. It does not copy Windows system libraries or change global `PATH`. See [Windows build and runtime instructions](windows.md). + `bash scripts/build-switch2kit-c.sh` builds and inspects a universal `build/Switch2KitC.xcframework` and compiles a fresh C++ consumer for each architecture. The C distribution has a fixed-layout C ABI. Swift consumers use the SwiftPM source package; the standalone Swift XCFramework pipeline is retired (see [Swift distribution](xcframework.md)). Do not link both implementations into one process. The C binding already includes the controller engine. ## Lifecycle and input -Create the handle on the main thread, before starting support. Do not call the creation function from an emulator's render thread. On macOS, keep the application's main run loop active. The Linux C polling API does not require a GUI event loop. Subsequent operations are thread-safe, except that each handle has one logical event reader and the owner must stop all API calls before destruction. +Create the handle on the main thread, before starting support. Do not call the creation function from an emulator's render thread. On macOS, keep the application's main run loop active. The Linux and Windows C polling APIs do not require a GUI event loop. Subsequent operations are thread-safe, except that each handle has one logical event reader and the owner must stop all API calls before destruction. ```cpp #include @@ -52,7 +60,7 @@ This reader uses the production event hub directly. It does not poll the 10 Hz p Snapshots accompany every read, but do not overwrite ordinary historical input with a newer snapshot before processing the events. For SDL virtual devices, commit transitions at the SDL update boundary instead of staging multiple opposing changes before one update. -Stop immediately suppresses input to the C reader and requests transport teardown. It is asynchronous: `snapshot.stopping` clears when teardown completes. Starting during that interval returns `S2K_BUSY`. Destruction cancels the reader and releases ownership; it never calls host code. There is no callback userdata to retain. Keep the loaded library resident for the process lifetime so queued Swift/Dispatch teardown can complete; do not `dlclose` it. +Stop immediately suppresses input to the C reader and requests transport teardown. It is asynchronous: `snapshot.stopping` clears when teardown completes. Starting during that interval returns `S2K_BUSY`. Destruction cancels the reader and releases ownership; it never calls host code. There is no callback userdata to retain. Keep the loaded library resident for the process lifetime so queued Swift/Dispatch teardown can complete; do not `dlclose` it or call `FreeLibrary` on Windows. ## Mapping and control @@ -68,8 +76,10 @@ Malformed arguments return a synchronous `S2KResult`. Transport, radio and comma ```sh swift test -Xswiftc -warnings-as-errors -bash tests/c-consumer/run.sh +bash tests/c-consumer/run.sh # macOS and Linux bash scripts/build-switch2kit-c.sh # macOS only ``` +On Windows, use CMake directly from a native x64 developer shell: `cmake -S tests/c-consumer -B build-c -G Ninja -DCMAKE_BUILD_TYPE=Release`, `cmake --build build-c`, then `ctest --test-dir build-c --output-on-failure`. + The CMake consumer contains C11 and C++17 source. Its separately built test fixture feeds fake controller events through the real Swift event hub, C ABI and C++ program, exercising hotplug, edge order, all state fields, overflow, rumble routing and teardown. The fixture is not linked into any distribution product. Native CI also compiles the real manager creation path without starting Bluetooth. Physical controller and gameplay checks are separate from these tests. diff --git a/docs/switch2kit/linux.md b/docs/switch2kit/linux.md index 6723b68..bcbcc82 100644 --- a/docs/switch2kit/linux.md +++ b/docs/switch2kit/linux.md @@ -1,6 +1,6 @@ # Linux / BlueZ -The experimental Linux backend runs the existing Switch2Kit controller engine against BlueZ's system D-Bus GATT API. Swift hosts use `Switch2ControllerManager`; native hosts use the same `Switch2KitC` ABI and in-process SDL3 adapter as on macOS. It is a real radio implementation, not a fixture-only factory or a network bridge. Windows and Android are not implemented by this backend. +The experimental Linux backend runs the existing Switch2Kit controller engine against BlueZ's system D-Bus GATT API. Swift hosts use `Switch2ControllerManager`; native hosts use the same `Switch2KitC` ABI and in-process SDL3 adapter as on macOS. It is a real radio implementation, not a fixture-only factory or a network bridge. Windows has a separate [WinRT backend](windows.md); Android has no backend. ## Requirements and connection @@ -29,14 +29,27 @@ Writes are bounded to one outstanding D-Bus write per physical device and the ch ## Native emulators and installation -Follow the [Dolphin/Cemu source integration guide](../../Integrations/Emulators/README.md), using Linux dependencies instead of Xcode, Homebrew or MoltenVK. Both optional patches accept Linux with SDL enabled; Dolphin also requires Qt. The build helper selects Linux arguments, builds all enabled upstream installation targets, and retains the targeted macOS bundle build on Apple hosts. The emulator owns the same discovery UI and controller lifecycle; no dashboard is required. +For playing games, start with the maintained [Dolphin fork](https://github.com/jmonster/dolphin/blob/041a158aea44abb8b9625ce3359b8c231ad931da/Readme.md#linux) or [Cemu fork](https://github.com/jmonster/Cemu/blob/9a8a563c93791634bc2a5288fde885a25fbcddd4/README.md#linux). Their build helpers enable Switch2Kit and install its native library; no source patches or separate dashboard are needed. Downloads are development builds for the distribution identified by the workflow, not universal Linux binaries. + +For the SDK's separate pinned source-patch examples, follow the [Dolphin/Cemu source integration guide](../../Integrations/Emulators/README.md), using Linux dependencies instead of Xcode, Homebrew or MoltenVK. Both optional patches accept Linux with SDL enabled; Dolphin also requires Qt. The build helper selects Linux arguments, builds all enabled upstream installation targets, and retains the targeted macOS bundle build on Apple hosts. The emulator owns the same discovery UI and controller lifecycle; no dashboard is required. ```sh bash scripts/build-switch2kit-emulator.sh dolphin /path/to/patched/dolphin /path/to/build cmake --install /path/to/build --prefix /path/to/install ``` -`switch2kit_install_linux(target)` installs the C library and attribution notices and adds the relative library directory to the host's install RPATH. Host executables must be installed into `CMAKE_INSTALL_BINDIR`; the supplied integrations do so. Build-tree executables use CMake's normal build RPATH. The installation is **not a self-contained Linux app bundle**: compatible Swift runtime libraries and system dependencies must remain discoverable by the loader. A package maintainer must declare those dependencies or supply an appropriate runtime deployment; copying only the emulator executable is insufficient. `switch2kit_embed` remains the macOS bundle helper. +`switch2kit_install_linux(target)` installs the C library, its compiler-selected Swift runtime dependency closure, and complete license/attribution notices. It adds the relative library directory to the host's install RPATH and removes compiler-specific search paths from the packaged ELF copies, not from the original toolchain files. Host executables must be installed into `CMAKE_INSTALL_BINDIR`; the supplied integrations do so. Build-tree executables use CMake's normal build RPATH. `switch2kit_embed` remains the macOS bundle helper. + +Keep the entire installed directory layout when extracting or relocating a package. A successfully staged application does not require a Swift compiler installation or `LD_LIBRARY_PATH` override at launch: + +```sh +/path/to/install/bin/dolphin-emu +# Or: /path/to/install/bin/Cemu_release +``` + +This is **not a universal self-contained Linux app bundle**. Compatible glibc, C++ and desktop libraries, graphics drivers, `libsystemd.so.0`, and the BlueZ service remain system prerequisites. Use the distribution/architecture identified by the application workflow and require successful native build and extracted-package checks for that revision. A source archive is not an application, and copying only the emulator executable is insufficient. + +`swift build` alone builds the SDK rather than staging a host application. Runtime-license selection is provided for Swift 6.2.1 and 6.3.3; other distributions need their complete Swift license and ICU third-party notices supplied through `SWITCH2KIT_SWIFT_LICENSE` and `SWITCH2KIT_RUNTIME_ICU_LICENSE`. Missing libraries or notices are packaging errors to resolve, not a reason to bypass the loader or omit license texts. ## Verification and hardware boundary diff --git a/docs/switch2kit/windows.md b/docs/switch2kit/windows.md new file mode 100644 index 0000000..790205b --- /dev/null +++ b/docs/switch2kit/windows.md @@ -0,0 +1,46 @@ +# Windows x64 / WinRT + +Switch2Kit connects Switch 2 Pro, NSO GameCube, and individual Joy-Con 2 controllers through Windows' native Bluetooth LE APIs. The transport feeds the existing controller session engine, C ABI, and in-process SDL3 adapter. It does not require a system virtual-controller driver, a separate dashboard, or a network bridge. + +For games, start with the maintained [Dolphin](https://github.com/jmonster/dolphin/blob/041a158aea44abb8b9625ce3359b8c231ad931da/Readme.md#windows) or [Cemu](https://github.com/jmonster/Cemu/blob/9a8a563c93791634bc2a5288fde885a25fbcddd4/README.md#windows) fork. Their **Find Switch 2 Controllers** action owns discovery; their GameCube/Pro shortcuts apply recommended mappings. Use the controller-enabled fork build, not an ordinary upstream download. + +## Requirements + +Use x64 Windows with a working Bluetooth LE adapter and its Windows driver. Native ARM64, x86, cross-compilation, and Android are not supported by this implementation. Enable Bluetooth in Windows Settings. The application runs as a normal desktop process, not as administrator. + +Source builds require the x64 Swift 6.2+ toolchain, CMake 3.24+, Ninja, Python 3, and Visual C++ tools with a Windows SDK. The Windows CI matrix builds with Swift **6.2.1** on Windows Server 2022/Visual Studio 2022 and **6.3.3** on the Visual Studio 2026 runner. These are build/consumer test environments, not physical-controller qualifications. Each emulator also needs its own build dependencies: Dolphin's current source requires Visual Studio 2026, while Cemu's helper uses its normal MSVC-compatible build. Follow [Swift's Windows installation instructions](https://www.swift.org/install/windows/). + +Hosts using the CMake embedding helper package `Switch2KitC.dll` and its compiler-selected Swift runtime dependencies next to the executable, together with the required Swift/ICU license texts and Switch2Kit notices. Keep those files together when extracting or moving the application. A successfully staged package does **not** require the Swift compiler installation or a Swift-specific `PATH` setting to launch. + +Windows system libraries, the Microsoft Visual C++ runtime, and Bluetooth/graphics drivers remain prerequisites; this is not a promise of a fully self-contained application. The development artifacts are not signed public releases. Use only a controller-enabled application artifact whose native build and extracted-package checks succeeded for the same revision; source and diagnostic archives are not applications. Do not disable SmartScreen, antivirus, or Bluetooth security to run them. + +## Build the library or a native host + +From the Switch2Kit checkout, in an x64 Visual C++ developer shell with Swift in `PATH`: + +```powershell +swift test -Xswiftc -warnings-as-errors +swift build -c release --product Switch2KitC +``` + +For C/C++ applications, use the [CMake integration](cpp.md). Link `Switch2Kit::C`, then call `switch2kit_embed_windows(your_target)` in the CMake directory that creates that executable. CMake builds the native DLL and import library through SwiftPM. Do not link a second copy of the Swift engine into the host. + +Creation does not start Bluetooth. Call `s2k_start`, request discovery, and hold the controller's **Sync** button while scanning. Close competing controller applications first. The C polling API does not rely on a Cocoa or Win32 UI event loop; application UI updates remain the host's responsibility. Stop input and wait for API callers to finish before destroying the context. + +`swift build` alone produces the library and import library, not an application package. The embedding helper resolves the DLL dependency graph and copies only the selected runtime files into the host output directory. It stops at resolved Windows system DLLs, and fails the build for missing or ambiguous non-system dependencies; it does not hide missing DLLs by adding directories to a user's global `PATH`. + +Runtime-license selection is provided for Swift 6.2.1 and 6.3.3. For another distribution, supply its complete license and ICU third-party notices using `SWITCH2KIT_SWIFT_LICENSE` and `SWITCH2KIT_RUNTIME_ICU_LICENSE`. Offline packagers can preseed the verified license cache described in `Integrations/CMake/RuntimeNotices.cmake`. Do not omit the notices to work around a staging failure. + +## Connection and failure behavior + +The backend uses active LE advertisements, the existing Nintendo manufacturer-data recognition, GATT service discovery, notifications, and bounded writes. It obtains the selected adapter's address for the existing controller-protocol handshake. Physical IDs remain stable for the adapter/device/address-type tuple; moving to another adapter or a rotating device address can change identity. + +Connection tokens fence late callbacks and stale writes. Only one output write per controller is admitted at a time, and frames larger than the negotiated ATT payload are rejected rather than split. Overflow, service changes, notification failures, disconnects, and explicit stop invalidate affected input. The backend does not queue an unbounded stream of old rumble commands, silently alter pairing settings, or erase device bonds. + +After an unavailable/denied radio state, restore Bluetooth or application access in Windows Settings, stop the backend, and use **Find Switch 2 Controllers** again. A missing Find button means the running app was built without Switch2Kit. A missing-DLL startup error is a packaging or runtime-prerequisite problem, not a pairing problem. Re-extract the entire controller-enabled artifact and check its native CI result; copying only the executable or `Switch2KitC.dll` is insufficient. + +## Validation boundary + +The Windows workflow compiles the production WinRT backend and shared Swift engine. Package tests exercise the real Windows adaptation layer against a controlled OS boundary. Native C and real SDL consumers separately check exported ABI/type metadata, creation without starting Bluetooth, input edges, mapping, bounded queues, rumble, calibration, and shutdown. Fork workflows build the full applications, relocate them, verify that they load their own controller DLL, and test normal window launch, quit, and relaunch. + +A configured workflow is not a successful run until its checks pass. These tests do not claim physical Windows Bluetooth pairing, firmware compatibility, sleep/wake, measured motion, rumble on real hardware, or gameplay acceptance. Linux/macOS results are not substitutes for Windows hardware evidence. Record the OS, adapter/driver, controller firmware, source revision, controls, reconnect, and motor start/stop results during hardware qualification. diff --git a/scripts/verify-distribution-notices.py b/scripts/verify-distribution-notices.py index d909593..9da89b7 100644 --- a/scripts/verify-distribution-notices.py +++ b/scripts/verify-distribution-notices.py @@ -7,7 +7,7 @@ import plistlib ROOT = Path(__file__).resolve().parents[1] -NOTICES = ('CREDITS.md', 'LICENSES/README.md', 'LICENSES/MIT-trevlars.txt', 'LICENSES/SDL-zlib.txt') +NOTICES = ('CREDITS.md', 'LICENSES/MIT-trevlars.txt', 'LICENSES/SDL-zlib.txt') def verify(app, emulator, root=ROOT): diff --git a/tests/app-identity/check.py b/tests/app-identity/check.py index d99a766..dc12477 100644 --- a/tests/app-identity/check.py +++ b/tests/app-identity/check.py @@ -7,8 +7,6 @@ root = Path(__file__).resolve().parents[2] info = plistlib.loads((root / 'Resources/Info.plist').read_bytes()) assert info['CFBundleIdentifier'] == 'wabisabi.ware.gamecubed' -assert info['CFBundleDisplayName'] == 'Switch2Kit' -assert info['CFBundleName'] == 'Switch2Kit' assert info['CFBundleExecutable'] == 'Switch2KitApp' assert 'Peter Sharma' in info['NSHumanReadableCopyright'] # Automatic installation is removed, not merely disabled by a preference. diff --git a/tests/c-consumer/CMakeLists.txt b/tests/c-consumer/CMakeLists.txt index 44b3cdd..e3af046 100644 --- a/tests/c-consumer/CMakeLists.txt +++ b/tests/c-consumer/CMakeLists.txt @@ -7,6 +7,15 @@ get_filename_component(_root "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE) set(_fixture "${CMAKE_CURRENT_BINARY_DIR}/${CMAKE_SHARED_LIBRARY_PREFIX}S2KFixture${CMAKE_SHARED_LIBRARY_SUFFIX}") # Uses package-scoped fake boundaries, but compiles separately from the distribution library. set(_fixture_args) +set(_fixture_byproducts) +set(_rpath_args) +if(WIN32) + set(_fixture_import "${CMAKE_CURRENT_BINARY_DIR}/S2KFixture.lib") + list(APPEND _fixture_byproducts "${_fixture_import}") + list(APPEND _fixture_args -Xlinker "/IMPLIB:${_fixture_import}") +else() + list(APPEND _rpath_args -Xlinker -rpath -Xlinker "${SWITCH2KIT_C_BINARY_DIR}") +endif() if(APPLE) execute_process(COMMAND xcrun --sdk macosx --show-sdk-path OUTPUT_VARIABLE _sdk OUTPUT_STRIP_TRAILING_WHITESPACE COMMAND_ERROR_IS_FATAL ANY) if(CMAKE_OSX_ARCHITECTURES) @@ -21,13 +30,14 @@ if(APPLE) list(APPEND _fixture_args -sdk "${_sdk}" -target "${_arch}-apple-macosx15.0") endif() add_custom_command(OUTPUT "${_fixture}" + BYPRODUCTS ${_fixture_byproducts} COMMAND "${Python3_EXECUTABLE}" "${_root}/tests/support/compile-fixture.py" --description "${SWITCH2KIT_C_BINARY_DIR}/description.json" --compiler "${SWITCH2KIT_SWIFTC}" -- -swift-version 6 -warnings-as-errors -emit-library -module-name S2KFixture ${_fixture_args} -I "${SWITCH2KIT_C_BINARY_DIR}/Modules" -I "${_root}/Sources/Switch2KitCABI/include" -L "${SWITCH2KIT_C_BINARY_DIR}" -lSwitch2KitC - -Xlinker -rpath -Xlinker "${SWITCH2KIT_C_BINARY_DIR}" + ${_rpath_args} "${_root}/Tests/Switch2KitCTests/TestSource.swift" "${CMAKE_CURRENT_LIST_DIR}/Fixture.swift" -o "${_fixture}" DEPENDS Switch2KitCBuild "${_root}/tests/support/compile-fixture.py" "${_root}/Tests/Switch2KitCTests/TestSource.swift" "${CMAKE_CURRENT_LIST_DIR}/Fixture.swift" @@ -35,11 +45,33 @@ add_custom_command(OUTPUT "${_fixture}" add_custom_target(S2KFixtureBuild DEPENDS "${_fixture}") add_library(S2KFixture SHARED IMPORTED) set_target_properties(S2KFixture PROPERTIES IMPORTED_LOCATION "${_fixture}") +if(WIN32) + set_target_properties(S2KFixture PROPERTIES IMPORTED_IMPLIB "${_fixture_import}") +endif() add_dependencies(S2KFixture S2KFixtureBuild) add_executable(c-consumer main.cpp header.c) target_compile_features(c-consumer PRIVATE cxx_std_17 c_std_11) -target_compile_options(c-consumer PRIVATE -Wall -Wextra -Werror -UNDEBUG) +if(MSVC) + target_compile_options(c-consumer PRIVATE /W4 /UNDEBUG) +else() + target_compile_options(c-consumer PRIVATE -Wall -Wextra -Werror -UNDEBUG) +endif() target_link_libraries(c-consumer PRIVATE Switch2Kit::C S2KFixture) set_property(TARGET c-consumer APPEND PROPERTY BUILD_RPATH "${SWITCH2KIT_C_BINARY_DIR}" "${CMAKE_CURRENT_BINARY_DIR}") add_test(NAME c-consumer COMMAND c-consumer) set_tests_properties(c-consumer PROPERTIES TIMEOUT 20) + +if(WIN32) + switch2kit_embed_windows(c-consumer) + add_custom_command(TARGET c-consumer POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E copy_if_different "${_fixture}" "$" VERBATIM) +endif() + +# Compile the production settings/profile reader on every native consumer host. +add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/../host-file" host-file) + +# Native dependency graphs exercise the deployment boundary and missing-DLL +# failures independently of Swift/Bluetooth; real consumers above test the SDK. +add_test(NAME desktop-runtime-staging + COMMAND "${Python3_EXECUTABLE}" "${_root}/tests/desktop-runtime/test_staging.py") +set_tests_properties(desktop-runtime-staging PROPERTIES TIMEOUT 300) diff --git a/tests/c-consumer/main.cpp b/tests/c-consumer/main.cpp index 0a054d1..933cce8 100644 --- a/tests/c-consumer/main.cpp +++ b/tests/c-consumer/main.cpp @@ -44,7 +44,7 @@ int main() { S2KResult status{}; S2KConfig config{S2K_ABI_VERSION, sizeof(S2KConfig), 16, S2K_EVENT_CAPACITY}; auto* real = s2k_create(&config, &status); // Create only; no Bluetooth starts. -#if defined(__APPLE__) || defined(__linux__) +#if defined(__APPLE__) || defined(__linux__) || defined(_WIN32) assert(real && status == S2K_OK); #else assert(!real && status == S2K_UNSUPPORTED_PLATFORM); diff --git a/tests/c-consumer/relocate-windows.ps1 b/tests/c-consumer/relocate-windows.ps1 new file mode 100644 index 0000000..adad1de --- /dev/null +++ b/tests/c-consumer/relocate-windows.ps1 @@ -0,0 +1,5 @@ +param([string]$CBuild = "build-c", [string]$SDLBuild = "build-sdl") +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +& python (Join-Path $PSScriptRoot 'relocate-windows.py') --c-build $CBuild --sdl-build $SDLBuild +if ($LASTEXITCODE -ne 0) { throw "Extracted consumer qualification failed ($LASTEXITCODE)" } diff --git a/tests/c-consumer/relocate-windows.py b/tests/c-consumer/relocate-windows.py new file mode 100644 index 0000000..ca2f52e --- /dev/null +++ b/tests/c-consumer/relocate-windows.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Execute archived native consumers with only packaged DLLs and Windows prerequisites.""" +from __future__ import annotations + +import argparse +import ctypes +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import zipfile + +CONSUMERS = ("c/c-consumer.exe", "sdl/sdl-inprocess.exe", "sdl/sdl-motion.exe") +NOTICES = ("LICENSES/MIT-trevlars.txt", "LICENSES/SDL-zlib.txt", + "SwiftRuntime/LICENSE.txt", "SwiftRuntime/ICU.txt") + + +def require(condition: bool, message: str) -> None: + if not condition: + raise RuntimeError(message) + + +def stage(build: Path, destination: Path, executable_names: tuple[str, ...]) -> None: + require(build.is_dir(), f"Build directory does not exist: {build}") + destination.mkdir() + for name in (*executable_names, "Switch2KitC.dll", "swiftCore.dll"): + require((build / name).is_file(), f"Missing packaged consumer dependency: {build / name}") + for path in build.iterdir(): + if path.suffix.lower() in (".exe", ".dll"): + require(path.is_file() and not path.is_symlink(), f"Not a regular binary: {path}") + shutil.copy2(path, destination / path.name) + notices = build / "Switch2KitNotices" + for name in NOTICES: + path = notices / name + require(path.is_file() and path.stat().st_size > 0, f"Missing or empty license: {path}") + require(not notices.is_symlink() and all(not path.is_symlink() for path in notices.rglob("*")), + "Package notices must not reference files outside the package") + shutil.copytree(notices, destination / notices.name) + + +def child_environment(root: Path, inherited: dict[str, str]) -> dict[str, str]: + # Do not inherit the compiler, loader overrides or real user configuration. + windows = inherited.get("SystemRoot", inherited.get("SYSTEMROOT", "")) + require(bool(windows) and (Path(windows) / "System32").is_dir(), "A valid Windows SystemRoot is required") + home, temporary = root / "private profile", root / "private temporary files" + home.mkdir() + temporary.mkdir() + roaming, local = home / "AppData/Roaming", home / "AppData/Local" + roaming.mkdir(parents=True) + local.mkdir(parents=True) + return {"SystemRoot": windows, "WINDIR": windows, + "PATH": str(Path(windows) / "System32") + ";" + windows, + "COMSPEC": str(Path(windows) / "System32/cmd.exe"), + "USERPROFILE": str(home), "HOME": str(home), + "APPDATA": str(roaming), "LOCALAPPDATA": str(local), + "TEMP": str(temporary), "TMP": str(temporary)} + + +def execute(executable: Path, environment: dict[str, str], timeout: float = 30.0) -> dict: + # No command shell or string quoting: paths containing spaces stay one argument. + process = subprocess.Popen([str(executable)], cwd=executable.parent, env=environment, + stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + encoding="utf-8", errors="replace") + try: + output, _ = process.communicate(timeout=timeout) + except subprocess.TimeoutExpired: + process.kill() + output, _ = process.communicate() + raise RuntimeError(f"Consumer timed out: {executable}\n{output}") + return {"executable": str(executable), "exit_code": process.returncode, "output": output} + + +def qualify(c_build: Path, sdl_build: Path, root: Path, report: dict) -> None: + staged, extracted = root / "staged package", root / "extracted package" + staged.mkdir() + stage(c_build, staged / "c", ("c-consumer.exe",)) + stage(sdl_build, staged / "sdl", ("sdl-inprocess.exe", "sdl-motion.exe")) + manifest = {path.relative_to(staged).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest() + for path in staged.rglob("*") if path.is_file()} + archive = root / "consumers.zip" + with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as package: + for name in manifest: + package.write(staged / name, name) + report["archive_sha256"] = hashlib.sha256(archive.read_bytes()).hexdigest() + report["files"] = manifest + shutil.rmtree(staged) + # The archive was just created from regular files with relative paths above. + with zipfile.ZipFile(archive) as package: + package.extractall(extracted) + for name, digest in manifest.items(): + require(hashlib.sha256((extracted / name).read_bytes()).hexdigest() == digest, + f"Extracted file differs from the staged package: {name}") + environment = child_environment(root, dict(os.environ)) + report["runs"] = [] + for relative in CONSUMERS: + result = execute(extracted / relative, environment) + report["runs"].append(result) + require(result["exit_code"] == 0, f"Relocated consumer failed: {relative}\n{result}") + # A system or developer copy must not rescue missing application-owned DLLs. + # STATUS_DLL_NOT_FOUND is a loader failure, not an arbitrary assertion/crash. + report["negative_controls"] = [] + for name in ("Switch2KitC.dll", "swiftCore.dll"): + moved = [] + try: + for directory in ("c", "sdl"): + source = extracted / directory / name + hidden = source.with_suffix(".unavailable") + source.rename(hidden) + moved.append((source, hidden)) + for relative in CONSUMERS: + result = execute(extracted / relative, environment) + report["negative_controls"].append({"removed": name, **result}) + require(result["exit_code"] & 0xffffffff == 0xc0000135, + f"Removing {name} must fail at the loader: {relative}\n{result}") + finally: + for source, hidden in moved: + hidden.rename(source) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--c-build", type=Path, default=Path("build-c")) + parser.add_argument("--sdl-build", type=Path, default=Path("build-sdl")) + parser.add_argument("--report", type=Path, default=Path("windows-relocation.json")) + args = parser.parse_args() + report = {"status": "failed"} + try: + require(sys.platform == "win32", "This check executes native Windows consumers") + # Loader negative controls must return an exit status rather than a modal + # error dialog. This applies only to this test process and its children. + ctypes.windll.kernel32.SetErrorMode(0x0001 | 0x0002 | 0x8000) + with tempfile.TemporaryDirectory(prefix="Switch2Kit relocated consumers ") as temporary: + qualify(args.c_build.resolve(strict=True), args.sdl_build.resolve(strict=True), + Path(temporary), report) + report["status"] = "passed" + print("PASS: extracted real C/SDL consumers, OS-only PATH, isolated profiles, and missing-DLL negative controls") + return 0 + except (OSError, RuntimeError, zipfile.BadZipFile) as error: + report["error"] = str(error) + print(f"FAIL: {error}", file=sys.stderr) + return 1 + finally: + args.report.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/desktop-runtime/run.sh b/tests/desktop-runtime/run.sh new file mode 100755 index 0000000..ddc1279 --- /dev/null +++ b/tests/desktop-runtime/run.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -euo pipefail +ROOT=$(cd "$(dirname "$0")/../.." && pwd -P) +python3 "$ROOT/tests/desktop-runtime/test_staging.py" diff --git a/tests/desktop-runtime/test_staging.py b/tests/desktop-runtime/test_staging.py new file mode 100644 index 0000000..5e699de --- /dev/null +++ b/tests/desktop-runtime/test_staging.py @@ -0,0 +1,341 @@ +"""Exercise the deployment script on real native binaries, without Bluetooth or Swift. + +The tiny runtime and OS libraries are fixtures, not redistributable runtime files. +The C/SDL consumer jobs separately qualify the real Swift dependency closure. +""" +from pathlib import Path +import hashlib +import http.server +import os +import shutil +import subprocess +import sys +import tempfile +import threading +import time +import unittest + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / 'Integrations/CMake/StageDesktopRuntime.cmake' + + +@unittest.skipUnless(sys.platform in ('linux', 'win32'), 'Desktop runtime deployment is Linux/Windows only') +class RuntimeStagingTests(unittest.TestCase): + def run_command(self, *args): + return subprocess.run(args, text=True, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, timeout=60, check=False) + + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix='s2k runtime boundary ') + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.runtime = self.root / 'compiler runtime' + self.system = self.root / 'System32' + self.app = self.root / 'application' + self.destination = self.root / 'package' + for name, body in { + 'leaf': 'int leaf(void) { return 1; }', + 'runtime': 'extern int leaf(void); int runtime(void) { return leaf(); }', + 'private_os': 'int private_os(void) { return 2; }', + 'system_boundary': 'extern int private_os(void); int system_boundary(void) { return private_os(); }', + 'fixture_leaf': 'int fixture_leaf(void) { return 7; }', + 'fixture': 'extern int fixture_leaf(void); int fixture(void) { return fixture_leaf(); }', + 'consumer': 'extern int facade(void); extern int fixture(void); int main(void) { return facade() + fixture() == 10 ? 0 : 1; }', + 'facade': 'extern int runtime(void); extern int system_boundary(void); int facade(void) { return runtime() + system_boundary(); }', + }.items(): + (self.root / f'{name}.c').write_text(body + '\n') + # Use .dll names on both platforms so the same exact-file boundary is + # tested with native ELF on Linux and native PE on Windows. + (self.root / 'CMakeLists.txt').write_text('''cmake_minimum_required(VERSION 3.24) +project(RuntimeBoundary C) +foreach(name leaf runtime private_os system_boundary facade fixture_leaf fixture) + add_library(${name} SHARED ${name}.c) + set_target_properties(${name} PROPERTIES PREFIX "" SUFFIX ".dll" WINDOWS_EXPORT_ALL_SYMBOLS ON) +endforeach() +target_link_libraries(fixture PRIVATE fixture_leaf) +add_executable(consumer consumer.c) +target_link_libraries(consumer PRIVATE facade fixture) +set_target_properties(consumer PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/application") +set_target_properties(fixture PROPERTIES + LIBRARY_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/fixture libraries" + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/fixture libraries") +target_link_libraries(runtime PRIVATE leaf) +target_link_libraries(system_boundary PRIVATE private_os) +target_link_libraries(facade PRIVATE runtime system_boundary) +foreach(name leaf runtime fixture_leaf) + set_target_properties(${name} PROPERTIES + LIBRARY_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/compiler runtime" + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/compiler runtime") +endforeach() +foreach(name private_os system_boundary) + set_target_properties(${name} PROPERTIES + LIBRARY_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/System32" + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/System32") +endforeach() +set_target_properties(facade PROPERTIES + LIBRARY_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/facade libraries" + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/facade libraries") +''') + for args in [ + ('cmake', '-S', str(self.root), '-B', str(self.root / 'build'), '-G', 'Ninja', '-DCMAKE_BUILD_TYPE=Release'), + ('cmake', '--build', str(self.root / 'build'), '--parallel', '2'), + ]: + result = self.run_command(*args) + self.assertEqual(result.returncode, 0, result.stdout) + # This dependency of an OS library must not be inspected or bundled. + (self.system / 'private_os.dll').unlink() + self.library = self.root / 'facade libraries/facade.dll' + self.config = self.root / 'runtime.cmake' + self.swift_license = self.root / 'fixture-license.txt' + self.icu_license = self.root / 'fixture-icu.txt' + self.swift_license.write_text('Native test runtime fixture license\n') + self.icu_license.write_text('Native test ICU fixture notice\n') + inspector = 'dumpbin' if sys.platform == 'win32' else 'objdump' + self.assertIsNotNone(shutil.which(inspector), f'{inspector} is required') + system_dirs = [self.system] + if sys.platform == 'win32': + system_root = Path(os.environ['SystemRoot']) + system_dirs.extend([system_root / 'System32', system_root]) + values = { + 'S2K_RUNTIME_DIRS': self.runtime, + 'S2K_SYSTEM_RUNTIME_DIRS': ';'.join(path.as_posix() for path in system_dirs), + 'S2K_SWIFT_LICENSE': self.swift_license, + 'S2K_ICU_LICENSE': self.icu_license, + 'S2K_COMPILER_VERSION': 'Native fixture (not Swift)', + 'CMAKE_GET_RUNTIME_DEPENDENCIES_PLATFORM': 'windows+pe' if sys.platform == 'win32' else 'linux+elf', + 'CMAKE_GET_RUNTIME_DEPENDENCIES_TOOL': inspector, + 'CMAKE_GET_RUNTIME_DEPENDENCIES_COMMAND': Path(shutil.which(inspector)).as_posix(), + 'S2K_READELF': Path(shutil.which('readelf')).as_posix() if sys.platform == 'linux' else '', + } + self.config.write_text(''.join(f'set({key} [==[{value.as_posix() if isinstance(value, Path) else value}]==])\n' + for key, value in values.items())) + + def stage(self, destination=None, *extra): + return self.run_command('cmake', f'-DS2K_LIBRARY={self.library.as_posix()}', + f'-DS2K_DESTINATION={(destination or self.destination).as_posix()}', + f'-DS2K_NOTICES={(self.root / "notices").as_posix()}', + f'-DS2K_RUNTIME_CONFIG={self.config.as_posix()}', *extra, '-P', str(SCRIPT)) + + def test_system_boundary_preserves_transitive_runtime_and_source_files(self): + before = {path: hashlib.sha256(path.read_bytes()).digest() + for path in [self.library, *self.runtime.glob('*.dll')]} + result = self.stage() + self.assertEqual(result.returncode, 0, result.stdout) + self.assertEqual({p.name for p in self.destination.iterdir()}, {'facade.dll', 'runtime.dll', 'leaf.dll'}) + for path, digest in before.items(): + self.assertEqual(hashlib.sha256(path.read_bytes()).digest(), digest, str(path)) + self.assertEqual((self.root / 'notices/SwiftRuntime/LICENSE.txt').read_bytes(), self.swift_license.read_bytes()) + self.assertEqual((self.root / 'notices/SwiftRuntime/ICU.txt').read_bytes(), self.icu_license.read_bytes()) + + def test_missing_application_dependency_still_fails(self): + (self.runtime / 'leaf.dll').unlink() + result = self.stage() + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn('leaf.dll', result.stdout) + self.assertFalse(self.destination.exists()) + + def test_missing_system_boundary_is_not_hidden_by_name(self): + (self.system / 'system_boundary.dll').unlink() + result = self.stage() + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn('system_boundary.dll', result.stdout) + self.assertFalse(self.destination.exists()) + + def host_arguments(self): + executable = self.app / ('consumer.exe' if sys.platform == 'win32' else 'consumer') + fixture = self.root / 'fixture libraries/fixture.dll' + return (f'-DS2K_EXECUTABLE={executable.as_posix()}', + f'-DS2K_EXTRA_LIBRARIES={fixture.as_posix()}') + + def test_host_closure_includes_runtime_used_only_by_a_linked_fixture(self): + before = {path: path.read_bytes() for path in self.runtime.glob('*.dll')} + result = self.stage(None, *self.host_arguments()) + self.assertEqual(result.returncode, 0, result.stdout) + self.assertEqual({p.name for p in self.destination.iterdir()}, + {'facade.dll', 'runtime.dll', 'leaf.dll', 'fixture.dll', 'fixture_leaf.dll'}) + for path, content in before.items(): + self.assertEqual(path.read_bytes(), content, str(path)) + + def test_host_staging_is_repeatable_with_an_existing_runtime_copy(self): + for attempt in range(2): + with self.subTest(attempt=attempt): + result = self.stage(self.app, *self.host_arguments()) + self.assertEqual(result.returncode, 0, result.stdout) + self.assertEqual((self.app / 'fixture_leaf.dll').read_bytes(), + (self.runtime / 'fixture_leaf.dll').read_bytes()) + + @unittest.skipUnless(sys.platform == 'win32', 'PE loader search order is Windows-specific') + def test_stale_staged_runtime_cannot_override_the_selected_compiler(self): + result = self.stage(self.app, *self.host_arguments()) + self.assertEqual(result.returncode, 0, result.stdout) + staged = self.app / 'fixture_leaf.dll' + source = self.runtime / 'fixture_leaf.dll' + staged.write_bytes(b'X' * source.stat().st_size) + # A stale cache can retain both file size and modification time. The + # deployment copy must compare content rather than trust timestamps. + source_stat = source.stat() + os.utime(staged, ns=(source_stat.st_atime_ns, source_stat.st_mtime_ns)) + result = self.stage(self.app, *self.host_arguments()) + self.assertEqual(result.returncode, 0, result.stdout) + self.assertEqual((self.app / 'fixture_leaf.dll').read_bytes(), + (self.runtime / 'fixture_leaf.dll').read_bytes()) + (self.runtime / 'fixture_leaf.dll').unlink() + result = self.stage(self.app, *self.host_arguments()) + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn('fixture_leaf.dll', result.stdout) + + def test_missing_fixture_only_runtime_fails_before_packaging(self): + (self.runtime / 'fixture_leaf.dll').unlink() + result = self.stage(None, *self.host_arguments()) + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn('fixture_leaf.dll', result.stdout) + self.assertFalse(self.destination.exists()) + + def test_missing_host_or_linked_library_is_not_ignored(self): + for argument in ('S2K_EXECUTABLE', 'S2K_EXTRA_LIBRARIES'): + with self.subTest(argument=argument): + result = self.stage(None, f'-D{argument}={self.root.as_posix()}/missing') + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn('missing', result.stdout) + self.assertFalse(self.destination.exists()) + + @unittest.skipUnless(sys.platform == 'win32', 'Windows deployment copy lock') + def test_missing_dependency_is_diagnosed_without_waiting_for_copy_lock(self): + key = hashlib.sha256(self.destination.resolve().as_posix().lower().encode()).hexdigest() + lock = Path(str(self.config) + '.' + key + '.lock') + ready, release = self.root / 'lock ready', self.root / 'release lock' + holder_script = self.root / 'hold-lock.cmake' + holder_script.write_text(f'''cmake_minimum_required(VERSION 3.24) +file(LOCK "{lock.as_posix()}" GUARD PROCESS TIMEOUT 10) +file(WRITE "{ready.as_posix()}" "ready") +while(NOT EXISTS "{release.as_posix()}") + execute_process(COMMAND "${{CMAKE_COMMAND}}" -E sleep 0.1) +endwhile() +''') + holder = subprocess.Popen(['cmake', '-P', str(holder_script)], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + try: + deadline = time.monotonic() + 15 + while not ready.exists() and holder.poll() is None and time.monotonic() < deadline: + time.sleep(0.02) + self.assertTrue(ready.exists(), 'The independent deployment lock was not acquired') + (self.runtime / 'fixture_leaf.dll').unlink() + result = self.stage(None, *self.host_arguments()) + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn('fixture_leaf.dll', result.stdout) + self.assertNotIn('error locking file', result.stdout) + self.assertIsNone(holder.poll(), 'Input validation must finish while the copy lock is still held') + self.assertFalse(self.destination.exists()) + finally: + release.write_text('release') + try: + holder.communicate(timeout=10) + except subprocess.TimeoutExpired: + holder.kill() + holder.communicate() + + def test_deployment_into_compiler_runtime_is_rejected(self): + result = self.stage(self.runtime) + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertFalse((self.runtime / 'facade.dll').exists()) + + +class RuntimeNoticeTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix='s2k notice cache ') + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.content = b'Native test fixture license; not a redistributed runtime license.\n' + self.blob = hashlib.sha1(b'blob ' + str(len(self.content)).encode() + b'\0' + self.content).hexdigest() + self.git = shutil.which('git') + self.assertIsNotNone(self.git) + self.driver = self.root / 'notice.cmake' + self.driver.write_text(f'''cmake_minimum_required(VERSION 3.24) +set(S2K_RUNTIME_CONFIG "{self.root.as_posix()}/runtime.cmake") +set(S2K_GIT "{Path(self.git).as_posix()}") +include("{(ROOT / 'Integrations/CMake/RuntimeNotices.cmake').as_posix()}") +file(WRITE "${{READY}}" "ready") +_s2k_license("fixture.txt" "${{URL}}" "{self.blob}" acquired) +file(WRITE "${{RESULT}}" "${{acquired}}") +''') + + def launch(self, suffix, url): + return subprocess.Popen(['cmake', f'-DREADY={self.root.as_posix()}/{suffix}.ready', + f'-DRESULT={self.root.as_posix()}/{suffix}.result', + f'-DURL={url}', '-P', str(self.driver)], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + + def test_parallel_acquisition_cannot_read_a_partial_license(self): + entered, release = threading.Event(), threading.Event() + requests = [] + content = self.content + + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + requests.append(self.path) + entered.set() + if not release.wait(20): + self.send_error(503) + return + self.send_response(200) + self.send_header('Content-Length', str(len(content))) + self.end_headers() + self.wfile.write(content) + + def log_message(self, *_args): + pass + + server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + children = [] + try: + url = f'http://127.0.0.1:{server.server_port}/fixture.txt' + children.append(self.launch('first', url)) + self.assertTrue(entered.wait(15), 'The first acquisition did not reach the local fixture server') + second = self.launch('second', url) + children.append(second) + deadline = time.monotonic() + 15 + while not (self.root / 'second.ready').exists() and second.poll() is None and time.monotonic() < deadline: + time.sleep(0.02) + self.assertTrue((self.root / 'second.ready').exists()) + # The first download remains incomplete until this test releases it. + # A second caller must wait instead of hashing its partial cache file. + with self.assertRaises(subprocess.TimeoutExpired): + second.wait(timeout=0.5) + release.set() + for child in children: + output, _ = child.communicate(timeout=30) + self.assertEqual(child.returncode, 0, output) + self.assertEqual(requests, ['/fixture.txt']) + self.assertEqual((self.root / 'runtime-notices/fixture.txt').read_bytes(), self.content) + for suffix in ('first', 'second'): + self.assertEqual(Path((self.root / f'{suffix}.result').read_text()).read_bytes(), self.content) + finally: + release.set() + for child in children: + if child.poll() is None: + child.kill() + child.communicate() + server.shutdown() + server.server_close() + thread.join(timeout=5) + + def test_changed_cached_license_still_fails_hash_validation(self): + cache = self.root / 'runtime-notices' + cache.mkdir() + (cache / 'fixture.txt').write_bytes(b'altered fixture license') + child = self.launch('changed', 'http://127.0.0.1:1/not-used') + try: + output, _ = child.communicate(timeout=30) + self.assertNotEqual(child.returncode, 0, output) + self.assertIn('Altered or incorrect upstream license', output) + self.assertFalse((self.root / 'changed.result').exists()) + finally: + if child.poll() is None: + child.kill() + child.communicate() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/distribution-notices/test_notices.py b/tests/distribution-notices/test_notices.py index ec61187..eaa93f8 100644 --- a/tests/distribution-notices/test_notices.py +++ b/tests/distribution-notices/test_notices.py @@ -79,16 +79,6 @@ def test_packagers_copy_notices_before_signing_or_archiving(self): self.assertNotIn('COMMAND codesign', bundle) self.assertIn('verify-distribution-notices.py', (ROOT / 'scripts/build-switch2kit-emulator.sh').read_text()) - def test_notice_index_links_sources_and_retained_texts(self): - text = (ROOT / 'LICENSES/README.md').read_text() - self.assertIn('https://github.com/Peterksharma/switch2mac/tree/', text) - self.assertIn('[CREDITS.md](../CREDITS.md)', text) - for name in ('MIT-trevlars.txt', 'SDL-zlib.txt'): - self.assertIn('(' + name + ')', text) - self.assertIn('modified, unofficial SDL sources', text) - self.assertIn('Permission is hereby granted', (ROOT / 'LICENSES/MIT-trevlars.txt').read_text()) - self.assertIn('This notice may not be removed', (ROOT / 'LICENSES/SDL-zlib.txt').read_text()) - if __name__ == '__main__': unittest.main() diff --git a/tests/emulator-launch/README.md b/tests/emulator-launch/README.md index 23fc1cb..3e088ca 100644 --- a/tests/emulator-launch/README.md +++ b/tests/emulator-launch/README.md @@ -18,3 +18,9 @@ The application process cannot read `/Applications`, `/Library/Developer`, `/opt The test uses macOS's `sandbox-exec` as a CI-only dependency restriction, not as a product dependency or security boundary for hostile code. It fails rather than silently dropping isolation if that facility is unavailable. These fresh CI images still have preinstalled developer software. A pass is **restricted configured full-GUI CI startup and normal shutdown**, not a stock clean-Mac, pristine first-use, Gatekeeper/notarization, Bluetooth, measured-profile, or physical-controller pass. Those acceptance results require a dedicated test Mac and real controllers. Portable failure-path tests: `bash tests/emulator-launch/run.sh`. This suite is also discovered by `bash tests/run.sh`. + +## Extracted Linux applications + +`linux.py` is also used by the maintained-fork application workflows. It extracts the exact uploaded tarball, checks packaged notices/resources/runtime paths, uses a private profile on an isolated X11 display, and requires five seconds of continuous normal-window observation, normal quit, and relaunch. It first waits for both window-manager identity and its possibly empty EWMH client list; identity alone can appear before window enumeration is usable. This prerequisite has a five-second bound. Subsequent observer failures, missing windows, runtime-path violations and abnormal exits still fail. + +`python3 tests/emulator-launch/test_linux.py` covers readiness ordering, permanent failure, timeouts, early manager exit and post-readiness observer failure without X11. `python3 tests/emulator-launch/test_linux_native.py` creates its own Xvfb display, Openbox process and minimal compiled Xlib window to reproduce the missing-client-list race and exercise real window observation, normal close and relaunch. It requires a C compiler, libX11 headers, Xvfb, Openbox, wmctrl and xprop. It never changes a personal display or uses a fixture as an emulator artifact. The native Linux workflow runs it separately from the real application tests. diff --git a/tests/emulator-launch/linux.py b/tests/emulator-launch/linux.py new file mode 100644 index 0000000..6f1d302 --- /dev/null +++ b/tests/emulator-launch/linux.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Qualify an exact development archive: extracted GUI, normal quit and relaunch. + +Requires Xvfb (or another isolated X11 display), Openbox, wmctrl and xprop. +No controller, gameplay, first-use wizard or clean-distribution acceptance is implied. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import tarfile +import tempfile +import time + +from verify import LaunchFailure, seed_startup_settings, supervise + + +def command(arguments, env): + result = subprocess.run(arguments, env=env, text=True, capture_output=True, timeout=10) + if result.returncode: + raise LaunchFailure(f"Command failed: {arguments}: {result.stderr[:512]}") + return result.stdout + + +def environment(root): + env = {"PATH": "/usr/bin:/bin", "HOME": str(root / "home"), + "XDG_CONFIG_HOME": str(root / "config"), "XDG_DATA_HOME": str(root / "data"), + "XDG_CACHE_HOME": str(root / "cache"), "XDG_RUNTIME_DIR": str(root / "runtime"), + "TMPDIR": str(root / "tmp"), "LANG": "C.UTF-8", "LC_ALL": "C.UTF-8", + "QT_QPA_PLATFORM": "xcb", "GDK_BACKEND": "x11"} + for name in ("DISPLAY", "XAUTHORITY", "DBUS_SESSION_BUS_ADDRESS"): + if os.environ.get(name): + env[name] = os.environ[name] + if "DISPLAY" not in env: + raise LaunchFailure("Use xvfb-run on an isolated X11 display") + for name in ("HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "XDG_RUNTIME_DIR", "TMPDIR"): + Path(env[name]).mkdir(mode=0o700) + return env + + +def wait_window_manager(manager, env, now=time.monotonic, sleep=time.sleep): + """Wait for both EWMH registration and the (possibly empty) client list. + + Openbox can publish its identity before _NET_CLIENT_LIST. Starting the GUI + after `wmctrl -m` alone races the first `wmctrl -lp` observation. Probe the + actual observer prerequisite before launching; never turn observer errors + into successful windows or extend the application's startup deadline. + """ + deadline = now() + 5 + while now() < deadline: + if manager.poll() is not None: + raise LaunchFailure("The isolated window manager exited") + ready = True + for query in ("-m", "-lp"): + remaining = deadline - now() + if remaining <= 0: + ready = False + break + try: + probe = subprocess.run(["wmctrl", query], env=env, capture_output=True, + timeout=min(1, remaining)) + except subprocess.TimeoutExpired: + ready = False + break + if probe.returncode != 0: + ready = False + break + if ready and manager.poll() is None: + return + sleep(min(0.1, max(0, deadline - now()))) + raise LaunchFailure("The isolated X11 window manager/client list was not ready within 5 seconds") + + +def windows(pid, env): + result = [] + for line in command(["wmctrl", "-lp"], env).splitlines(): + fields = line.split(None, 4) + if len(fields) < 4 or fields[2] != str(pid): + continue + types = command(["xprop", "-id", fields[0], "_NET_WM_WINDOW_TYPE"], env) + if "_NET_WM_WINDOW_TYPE_NORMAL" in types: + result.append(fields[0]) + return result + + +def loaded_libraries(pid, prefix, forbidden): + paths = set() + for line in Path(f"/proc/{pid}/maps").read_text().splitlines(): + fields = line.split(None, 5) + if len(fields) == 6 and fields[5].startswith("/"): + paths.add(Path(fields[5]).resolve()) + for path in paths: + if any(path.is_relative_to(root) for root in forbidden): + raise LaunchFailure(f"Application loaded a build/developer dependency: {path}") + runtime = [p for p in paths if p.name.startswith(("libSwitch2KitC", "libswift", "libFoundation", + "lib_Foundation", "libdispatch", "libBlocksRuntime"))] + if not any(p.name == "libSwitch2KitC.so" for p in runtime) or not any(p.name == "libswiftCore.so" for p in runtime): + raise LaunchFailure("The GUI did not load the real controller engine and Swift runtime") + if any(not p.is_relative_to(prefix) for p in runtime): + raise LaunchFailure(f"Runtime dependency outside extracted application: {runtime}") + return sorted(str(p.relative_to(prefix)) for p in runtime) + + +def qualify(emulator, archive, report, forbidden): + record = {"version": 1, "emulator": emulator, "archive_sha256": hashlib.sha256(archive.read_bytes()).hexdigest(), + "tested_revision": os.environ.get("GITHUB_SHA"), "physical_controller_tested": False, + "pristine_first_run_tested": False, "runs": [], "status": "failed"} + report.parent.mkdir(parents=True, exist_ok=True) + manager = None + try: + with tempfile.TemporaryDirectory(prefix="s2k extracted GUI ") as directory: + root = Path(directory).resolve() + env = environment(root) + unpack = root / "unpacked" + unpack.mkdir() + with tarfile.open(archive) as package: + package.extractall(unpack, filter="data") + entries = list(unpack.iterdir()) + if len(entries) != 1 or not entries[0].is_dir(): + raise LaunchFailure("Expected one application prefix in the archive") + prefix = entries[0] + exe = prefix / "bin" / {"dolphin": "dolphin-emu", "cemu": "Cemu_release"}[emulator] + if not exe.is_file() or not os.access(exe, os.X_OK): + raise LaunchFailure("Missing executable or executable permission") + for name in ("CREDITS.md", "LICENSES/MIT-trevlars.txt", "LICENSES/SDL-zlib.txt", "SwiftRuntime/LICENSE.txt", "SwiftRuntime/ICU.txt"): + path = prefix / "share/Switch2KitNotices" / name + if not path.is_file() or not path.stat().st_size: + raise LaunchFailure(f"Missing distributed license/attribution: {name}") + resource = prefix / ("bin/Sys/Profiles/GCPad/Switch2Kit GameCube.ini" if emulator == "dolphin" else "share/Cemu/resources") + if not resource.exists() or not resource.resolve().is_relative_to(prefix): + raise LaunchFailure("Packaged resources are missing or refer outside the extracted prefix") + dependencies = command(["ldd", str(exe)], env) + if "not found" in dependencies: + raise LaunchFailure(dependencies) + record["dependencies"] = dependencies + profile = root / "user" if emulator == "dolphin" else root / "config/Cemu" + profile.mkdir() + record["startup_settings"] = seed_startup_settings(emulator, profile) + arguments = [str(exe)] + (["--user", str(profile)] if emulator == "dolphin" else []) + with (report.parent / "linux-window-manager.log").open("w") as log: + manager = subprocess.Popen(["openbox", "--sm-disable"], env=env, stdout=log, stderr=subprocess.STDOUT) + try: + wait_window_manager(manager, env) + for attempt in (1, 2): + with (report.parent / f"linux-gui-{attempt}.log").open("w") as output: + process = subprocess.Popen(arguments, env=env, cwd=root, stdout=output, stderr=subprocess.STDOUT) + observed = [] + runtime = [] + def inspect(): + observed[:] = windows(process.pid, env) + if observed: + runtime[:] = loaded_libraries(process.pid, prefix, forbidden) + return {"matched": bool(observed), "finished": bool(observed), "windows": len(observed)} + def close(): + command(["wmctrl", "-ic", observed[0]], env) + return {"quit_requested": True} + result = supervise(process, inspect, close) + result.update(attempt=attempt, runtime_libraries=runtime) + record["runs"].append(result) + if result["status"] != "passed": + raise LaunchFailure(result["reason"]) + finally: + if manager.poll() is None: + manager.terminate() + manager.wait(timeout=10) + manager = None + record["status"] = "passed" + except (OSError, ValueError, subprocess.SubprocessError, tarfile.TarError, LaunchFailure) as error: + record["reason"] = str(error) + raise + finally: + report.write_text(json.dumps(record, indent=2) + "\n") + print("PASS exact extracted archive: GUI window, packaged runtime, normal quit and relaunch; no hardware claim") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("emulator", choices=("dolphin", "cemu")) + parser.add_argument("archive", type=Path) + parser.add_argument("--report", type=Path, required=True) + parser.add_argument("--forbidden-root", type=Path, action="append", default=[]) + args = parser.parse_args() + qualify(args.emulator, args.archive.resolve(strict=True), args.report.resolve(), + [p.resolve() for p in args.forbidden_root]) diff --git a/tests/emulator-launch/run.sh b/tests/emulator-launch/run.sh index 1e012fc..b711f84 100755 --- a/tests/emulator-launch/run.sh +++ b/tests/emulator-launch/run.sh @@ -1,3 +1,4 @@ #!/bin/bash set -euo pipefail python3 "$(dirname "$0")/test_launch.py" +python3 "$(dirname "$0")/test_linux.py" diff --git a/tests/emulator-launch/test_linux.py b/tests/emulator-launch/test_linux.py new file mode 100644 index 0000000..0ecb3d8 --- /dev/null +++ b/tests/emulator-launch/test_linux.py @@ -0,0 +1,84 @@ +"""Linux observer startup prerequisites; no fixture replaces an emulator GUI.""" +import importlib.util +from pathlib import Path +import subprocess +import unittest +from unittest.mock import Mock, patch + +spec = importlib.util.spec_from_file_location("linux_launch", Path(__file__).with_name("linux.py")) +linux = importlib.util.module_from_spec(spec) +spec.loader.exec_module(linux) + + +class Clock: + def __init__(self): + self.value = 0.0 + def now(self): + return self.value + def sleep(self, seconds): + self.value += seconds + + +class WindowManagerReadiness(unittest.TestCase): + def setUp(self): + self.clock = Clock() + self.manager = Mock() + self.manager.poll.return_value = None + + def wait(self): + linux.wait_window_manager(self.manager, {}, self.clock.now, self.clock.sleep) + + def test_identity_before_client_list_waits_for_the_actual_observer(self): + queries = [] + def probe(arguments, **kwargs): + queries.append(arguments[1]) + code = int(arguments[1] == "-lp" and queries.count("-lp") < 3) + return subprocess.CompletedProcess(arguments, code, b"", b"") + with patch.object(linux.subprocess, "run", side_effect=probe): + self.wait() + self.assertEqual(queries, ["-m", "-lp"] * 3) + self.assertGreaterEqual(self.clock.value, 0.2) + + def test_an_available_empty_client_list_is_ready_without_a_dummy_window(self): + with patch.object(linux.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, b"", b"")) as probe: + self.wait() + self.assertEqual([call.args[0] for call in probe.call_args_list], [["wmctrl", "-m"], ["wmctrl", "-lp"]]) + self.assertEqual(self.clock.value, 0) + + def test_permanently_missing_client_list_is_bounded_and_fails(self): + def probe(arguments, **kwargs): + return subprocess.CompletedProcess(arguments, int(arguments[1] == "-lp")) + with patch.object(linux.subprocess, "run", side_effect=probe): + with self.assertRaises(linux.LaunchFailure): + self.wait() + self.assertEqual(self.clock.value, 5) + + def test_exited_window_manager_is_fatal_without_querying_other_processes(self): + self.manager.poll.return_value = 1 + with patch.object(linux.subprocess, "run") as probe: + with self.assertRaises(linux.LaunchFailure): + self.wait() + probe.assert_not_called() + + def test_probe_timeouts_count_against_the_same_deadline(self): + def probe(arguments, **kwargs): + self.clock.sleep(kwargs["timeout"]) + raise subprocess.TimeoutExpired(arguments, kwargs["timeout"]) + with patch.object(linux.subprocess, "run", side_effect=probe): + with self.assertRaises(linux.LaunchFailure): + self.wait() + self.assertEqual(self.clock.value, 5) + + def test_missing_observer_executable_is_not_treated_as_ready(self): + with patch.object(linux.subprocess, "run", side_effect=FileNotFoundError("wmctrl")): + with self.assertRaises(FileNotFoundError): + self.wait() + + def test_observer_failure_after_readiness_is_still_fatal(self): + with patch.object(linux.subprocess, "run", return_value=subprocess.CompletedProcess([], 1, "", "display failed")): + with self.assertRaises(linux.LaunchFailure): + linux.windows(123, {}) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/emulator-launch/test_linux_native.py b/tests/emulator-launch/test_linux_native.py new file mode 100644 index 0000000..3feea77 --- /dev/null +++ b/tests/emulator-launch/test_linux_native.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Reproduce the EWMH readiness race on an owned Xvfb/Openbox display. + +The small Xlib window is an observer fixture, not an emulator or controller. +Real extracted application qualification remains a separate required job. +""" +import importlib.util +import json +import os +from pathlib import Path +import select +import subprocess +import tempfile +import time +import threading + +spec = importlib.util.spec_from_file_location("linux_launch", Path(__file__).with_name("linux.py")) +linux = importlib.util.module_from_spec(spec) +spec.loader.exec_module(linux) + +WINDOW = r''' +#include +#include +#include +#include +#include +int main(void) { + char start; + if (read(STDIN_FILENO, &start, 1) != 1) return 4; + Display* d = XOpenDisplay(NULL); + if (!d) return 2; + Window w = XCreateSimpleWindow(d, DefaultRootWindow(d), 0, 0, 400, 300, 0, 0, 0); + unsigned long pid = (unsigned long)getpid(); + Atom type = XInternAtom(d, "_NET_WM_WINDOW_TYPE_NORMAL", False); + XChangeProperty(d, w, XInternAtom(d, "_NET_WM_PID", False), XA_CARDINAL, 32, + PropModeReplace, (unsigned char*)&pid, 1); + XChangeProperty(d, w, XInternAtom(d, "_NET_WM_WINDOW_TYPE", False), XA_ATOM, 32, + PropModeReplace, (unsigned char*)&type, 1); + Atom close = XInternAtom(d, "WM_DELETE_WINDOW", False); + XSetWMProtocols(d, w, &close, 1); + XStoreName(d, w, "Switch2Kit observer fixture - no controller input"); + char hostname[256] = {0}; + if (gethostname(hostname, sizeof(hostname) - 1) != 0) return 3; + XTextProperty machine = {(unsigned char*)hostname, XA_STRING, 8, strlen(hostname)}; + XSetWMClientMachine(d, w, &machine); + XClassHint hint = {"s2k-observer-fixture", "S2KObserverFixture"}; + XSetClassHint(d, w, &hint); + XMapWindow(d, w); + XFlush(d); + for (;;) { + XEvent e; + XNextEvent(d, &e); + if (e.type == ClientMessage && (Atom)e.xclient.data.l[0] == close) break; + } + XDestroyWindow(d, w); + XCloseDisplay(d); + return 0; +} +''' + + +def require(condition, message): + if not condition: + raise RuntimeError(message) + + +def stop(process): + if process is not None and process.poll() is None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + + +def main(): + display = manager = child = None + with tempfile.TemporaryDirectory(prefix="s2k native observer ") as directory: + root = Path(directory) + source, binary = root / "window.c", root / "window" + source.write_text(WINDOW) + subprocess.run(["cc", "-Wall", "-Wextra", "-Werror", str(source), "-lX11", "-o", str(binary)], + check=True, timeout=30) + read_fd, write_fd = os.pipe() + try: + display = subprocess.Popen(["Xvfb", "-displayfd", str(write_fd), "-screen", "0", "1024x768x24", + "-nolisten", "tcp"], pass_fds=(write_fd,), stdout=subprocess.DEVNULL) + finally: + os.close(write_fd) + try: + require(bool(select.select([read_fd], [], [], 5)[0]), "The private Xvfb did not start") + number = os.read(read_fd, 32).decode().strip() + require(number.isdecimal(), "Invalid private display number") + env = {"PATH": "/usr/bin:/bin", "HOME": str(root), "DISPLAY": ":" + number, "LANG": "C.UTF-8", "LC_ALL": "C.UTF-8"} + with (root / "openbox.log").open("w") as log: + manager = subprocess.Popen(["openbox", "--sm-disable"], env=env, stdout=log, stderr=log) + linux.wait_window_manager(manager, env) + # Reproduce the observed intermediate startup state: identity + # published, but client-list property not published yet. + linux.command(["xprop", "-root", "-remove", "_NET_CLIENT_LIST"], env) + linux.command(["wmctrl", "-m"], env) + child = subprocess.Popen([str(binary)], env=env, stdin=subprocess.PIPE) + try: + linux.windows(child.pid, env) + except linux.LaunchFailure: + print("PASS reproduced original identity-only readiness failure", flush=True) + else: + raise RuntimeError("The missing-list negative control was ineffective") + def release_window(): + child.stdin.write(b"x") + child.stdin.close() + started = time.monotonic() + release = threading.Timer(0.3, release_window) + release.start() + try: + linux.wait_window_manager(manager, env) + except Exception: + print("manager", manager.poll(), "child", child.poll(), flush=True) + for args in (["wmctrl", "-m"], ["wmctrl", "-lp"], ["xprop", "-root", "_NET_CLIENT_LIST"], ["xwininfo", "-root", "-tree"]): + print(subprocess.run(args, env=env, capture_output=True, text=True, timeout=5), flush=True) + print((root / "openbox.log").read_text(), flush=True) + raise + finally: + release.join(timeout=2) + require(time.monotonic() - started >= 0.2, "Readiness returned before the client list existed") + for attempt in (1, 2): + if attempt == 2: + # A ready empty list must not require another fixture window. + require(linux.command(["wmctrl", "-lp"], env).strip() == "", "Owned window was not removed") + linux.wait_window_manager(manager, env) + child = subprocess.Popen([str(binary)], env=env, stdin=subprocess.PIPE) + release_window() + observed = [] + def inspect(): + observed[:] = linux.windows(child.pid, env) + return {"matched": bool(observed), "finished": bool(observed), "windows": len(observed)} + def close(): + linux.command(["wmctrl", "-ic", observed[0]], env) + return {"quit_requested": True} + result = linux.supervise(child, inspect, close) + print(json.dumps({"attempt": attempt, **result}), flush=True) + require(result["status"] == "passed" and not result["forced_cleanup"], + "Native window observation/normal shutdown failed") + child = None + finally: + os.close(read_fd) + stop(child) + stop(manager) + stop(display) + print("PASS owned X11 readiness, real normal-window observation, normal close and relaunch; no emulator/hardware claim") + + +if __name__ == "__main__": + main() diff --git a/tests/emulator-launch/test_windows.py b/tests/emulator-launch/test_windows.py new file mode 100644 index 0000000..208fb74 --- /dev/null +++ b/tests/emulator-launch/test_windows.py @@ -0,0 +1,134 @@ +"""Execute the real PowerShell supervisor's preparation and failure paths. + +The tiny native executable is deliberately not a GUI or controller substitute: +qualification must fail, but only after the process verifies its private launch +environment. Maintained-fork CI separately qualifies the actual applications. +""" +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest +import zipfile + +SUPERVISOR = Path(__file__).with_name("windows.ps1") +NOTICES = ("CREDITS.md", "LICENSES/MIT-trevlars.txt", "LICENSES/SDL-zlib.txt", + "SwiftRuntime/LICENSE.txt", "SwiftRuntime/ICU.txt") +FIXTURE = r''' +#include +#include +#include +std::wstring env(const wchar_t* key) { + wchar_t buffer[32768]; + DWORD size = GetEnvironmentVariableW(key, buffer, 32768); + return size && size < 32768 ? std::wstring(buffer, size) : L""; +} +int wmain(int argc, wchar_t** argv) { + const auto home = env(L"USERPROFILE"); + const auto root = std::filesystem::path(home).parent_path(); + if (home.empty() || home.find(L"s2k extracted GUI ") == std::wstring::npos) + return 41; + if (std::filesystem::path(env(L"APPDATA")) != std::filesystem::path(home) / L"AppData" / L"Roaming" || + std::filesystem::path(env(L"LOCALAPPDATA")) != std::filesystem::path(home) / L"AppData" / L"Local" || + std::filesystem::path(env(L"TEMP")) != root / L"tmp" || env(L"TMP") != env(L"TEMP")) + return 42; + if (env(L"PATH") != env(L"SystemRoot") + L"\\System32;" + env(L"SystemRoot") || + !env(L"SWIFT_RUNTIME_PATH").empty() || !env(L"SDKROOT").empty()) + return 43; + if (argc == 3) { + if (std::wstring(argv[1]) != L"--user" || + std::filesystem::path(argv[2]) != root / L"user" || + !std::filesystem::is_regular_file(std::filesystem::path(argv[2]) / L"Config" / L"Dolphin.ini")) + return 44; + } else if (argc != 1 || !std::filesystem::is_regular_file(L"settings.xml")) { + return 45; + } + // Prove the process was reached, without allowing a console fixture to pass + // the GUI, runtime-origin, normal-quit or relaunch requirements. + return 23; +} +''' + + +@unittest.skipUnless(sys.platform == "win32", "Executes native Windows processes and PowerShell") +class WindowsPreparation(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.temporary = tempfile.TemporaryDirectory(prefix="s2k supervisor tests ") + cls.addClassCleanup(cls.temporary.cleanup) + cls.root = Path(cls.temporary.name) + cls.pwsh = shutil.which("pwsh") + if not cls.pwsh: + raise RuntimeError("PowerShell 7 is required") + (cls.root / "fixture.cpp").write_text(FIXTURE, encoding="utf-8") + (cls.root / "CMakeLists.txt").write_text('''cmake_minimum_required(VERSION 3.24) +project(SupervisorFixture CXX) +add_executable(fixture fixture.cpp) +target_compile_features(fixture PRIVATE cxx_std_17) +set_property(TARGET fixture PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded") +''', encoding="utf-8") + for command in (["cmake", "-S", str(cls.root), "-B", str(cls.root / "build"), + "-G", "Ninja", "-DCMAKE_BUILD_TYPE=Release"], + ["cmake", "--build", str(cls.root / "build")]): + result = subprocess.run(command, capture_output=True, text=True, timeout=120) + if result.returncode: + raise RuntimeError(result.stdout + result.stderr) + cls.executable = cls.root / "build/fixture.exe" + + def run_supervisor(self, emulator, missing_notice=None, existing_settings=False): + with tempfile.TemporaryDirectory(dir=self.root) as temporary: + directory = Path(temporary) + archive, report = directory / "application with spaces.zip", directory / "report.json" + name = "Dolphin.exe" if emulator == "dolphin" else "Cemu_release.exe" + with zipfile.ZipFile(archive, "w") as package: + package.write(self.executable, "application/" + name) + package.writestr("application/Switch2KitC.dll", b"not loaded by the failing console fixture") + for notice in NOTICES: + if notice != missing_notice: + package.writestr("application/Switch2KitNotices/" + notice, b"test fixture notice\n") + package.writestr("application/Sys/Profiles/GCPad/Switch2Kit GameCube.ini", b"fixture\n") + package.writestr("application/resources/fixture.txt", b"fixture\n") + if existing_settings: + package.writestr("application/settings.xml", b"do not overwrite\n") + before = hashlib.sha256(archive.read_bytes()).hexdigest() + environment = dict(os.environ, SWIFT_RUNTIME_PATH="must not reach the application", SDKROOT="must not leak") + result = subprocess.run([self.pwsh, "-NoProfile", "-NonInteractive", "-File", str(SUPERVISOR), + "-Emulator", emulator, "-Archive", str(archive), "-Report", str(report)], + env=environment, text=True, capture_output=True, timeout=30) + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertTrue(report.is_file(), result.stdout + result.stderr) + record = json.loads(report.read_text(encoding="utf-8-sig")) + self.assertEqual(record["status"], "failed") + self.assertEqual(record["runs"], []) + self.assertFalse(record["physicalControllerTested"]) + self.assertFalse(record["pristineFirstRunTested"]) + self.assertEqual(record["archiveSHA256"].lower(), before) + self.assertEqual(hashlib.sha256(archive.read_bytes()).hexdigest(), before) + return record + + def test_both_profiles_reach_native_process_with_private_environment(self): + for emulator in ("dolphin", "cemu"): + with self.subTest(emulator=emulator): + record = self.run_supervisor(emulator) + self.assertEqual(record["stage"], "launch") + self.assertEqual(record["startupExitCode"], 23) + + def test_missing_distributed_notice_is_rejected_before_launch(self): + for emulator in ("dolphin", "cemu"): + with self.subTest(emulator=emulator): + record = self.run_supervisor(emulator, missing_notice=NOTICES[0]) + self.assertEqual(record["stage"], "preparation") + self.assertNotIn("startupExitCode", record) + + def test_cemu_archive_with_user_settings_is_not_overwritten(self): + record = self.run_supervisor("cemu", existing_settings=True) + self.assertEqual(record["stage"], "preparation") + self.assertNotIn("startupExitCode", record) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/emulator-launch/windows.ps1 b/tests/emulator-launch/windows.ps1 new file mode 100644 index 0000000..997b365 --- /dev/null +++ b/tests/emulator-launch/windows.ps1 @@ -0,0 +1,108 @@ +param([Parameter(Mandatory=$true)][ValidateSet('dolphin','cemu')][string]$Emulator, + [Parameter(Mandatory=$true)][string]$Archive, + [string]$Report = 'windows-launch.json', + [string]$ForbiddenRoot = '') +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$archivePath = (Resolve-Path $Archive).Path +$reportPath = [IO.Path]::GetFullPath($Report) +$root = Join-Path ([IO.Path]::GetTempPath()) ('s2k extracted GUI ' + [guid]::NewGuid()) +$record = @{ version=1; emulator=$Emulator; archiveSHA256=(Get-FileHash $archivePath -Algorithm SHA256).Hash; + testedRevision=$env:GITHUB_SHA; physicalControllerTested=$false; pristineFirstRunTested=$false; + runs=@(); status='failed'; stage='preparation' } +New-Item -ItemType Directory $root | Out-Null +try { + $unpack = Join-Path $root 'unpacked' + Expand-Archive $archivePath $unpack + $prefixes = @(Get-ChildItem $unpack) + if ($prefixes.Count -ne 1 -or -not $prefixes[0].PSIsContainer) { throw 'Expected one application directory in the archive.' } + $directory = $prefixes[0].FullName + $name = if ($Emulator -eq 'dolphin') { 'Dolphin.exe' } else { 'Cemu_release.exe' } + $exe = Join-Path $directory $name + $expected = (Resolve-Path (Join-Path $directory 'Switch2KitC.dll')).Path + foreach ($notice in @('CREDITS.md','LICENSES/MIT-trevlars.txt','LICENSES/SDL-zlib.txt','SwiftRuntime/LICENSE.txt','SwiftRuntime/ICU.txt')) { + if (-not (Test-Path (Join-Path $directory "Switch2KitNotices/$notice"))) { throw "Missing distributed license/attribution: $notice" } + } + $profileRoot = Join-Path $root 'home' + $temp = Join-Path $root 'tmp' + New-Item -ItemType Directory $profileRoot, $temp, "$profileRoot/AppData/Roaming", "$profileRoot/AppData/Local" | Out-Null + if ($Emulator -eq 'dolphin') { + $user = Join-Path $root 'user' + New-Item -ItemType Directory "$user/Config" | Out-Null + [IO.File]::WriteAllText("$user/Config/Dolphin.ini", "[Analytics]`nEnabled=False`nPermissionAsked=True`n[AutoUpdate]`nUpdateTrack=`n") + if (-not (Test-Path "$directory/Sys/Profiles/GCPad/Switch2Kit GameCube.ini")) { throw 'Missing GameCube mapping resource.' } + } else { + # This documented compatibility path is honored even when optional + # CEMU_ALLOW_PORTABLE is disabled. Never touch the real Windows profile. + $settings = Join-Path $directory 'settings.xml' + if (Test-Path $settings) { throw 'The application archive unexpectedly contains user settings.' } + [IO.File]::WriteAllText($settings, 'falsefalse') + if (-not (Test-Path "$directory/resources")) { throw 'Missing Cemu resources.' } + } + for ($attempt = 1; $attempt -le 2; $attempt++) { + $start = [Diagnostics.ProcessStartInfo]::new($exe) + $start.UseShellExecute = $false + $start.WorkingDirectory = $directory + $start.Environment.Clear() + $values = @{ PATH="$env:SystemRoot\System32;$env:SystemRoot"; SystemRoot=$env:SystemRoot; + WINDIR=$env:SystemRoot; SystemDrive=$env:SystemDrive; USERPROFILE=$profileRoot; + APPDATA="$profileRoot/AppData/Roaming"; LOCALAPPDATA="$profileRoot/AppData/Local"; TEMP=$temp; TMP=$temp } + foreach ($entry in $values.GetEnumerator()) { $start.Environment[$entry.Key] = $entry.Value } + if ($Emulator -eq 'dolphin') { $start.ArgumentList.Add('--user'); $start.ArgumentList.Add($user) } + $record.stage = 'launch' + $process = [Diagnostics.Process]::Start($start) + try { + $deadline = [DateTime]::UtcNow.AddSeconds(60) + do { + Start-Sleep -Milliseconds 250 + $process.Refresh() + if ($process.HasExited) { + $record.startupExitCode = $process.ExitCode + throw "Application exited before opening a GUI: $($process.ExitCode)" + } + } until ($process.MainWindowHandle -ne 0 -or [DateTime]::UtcNow -gt $deadline) + if ($process.MainWindowHandle -eq 0) { throw 'No application window appeared within 60 seconds.' } + $deadline = [DateTime]::UtcNow.AddSeconds(5) + do { + Start-Sleep -Milliseconds 250 + $process.Refresh() + if ($process.HasExited -or $process.MainWindowHandle -eq 0) { throw 'The application did not retain a usable GUI.' } + } until ([DateTime]::UtcNow -ge $deadline) + $record.stage = 'runtime' + $modules = @($process.Modules) + $loaded = @($modules | Where-Object { $_.ModuleName -eq 'Switch2KitC.dll' }) + if ($loaded.Count -ne 1 -or $loaded[0].FileName -ne $expected) { throw 'The GUI did not load its packaged controller DLL.' } + $runtime = @($modules | Where-Object { $_.ModuleName -match '^(swift|Foundation|_Foundation|dispatch|BlocksRuntime)' }) + if (-not ($runtime | Where-Object { $_.ModuleName -eq 'swiftCore.dll' })) { throw 'The running GUI did not load the Swift runtime.' } + foreach ($module in $runtime) { + if (-not $module.FileName.StartsWith($directory + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { + throw "Swift runtime escaped the extracted application: $($module.FileName)" + } + } + if ($ForbiddenRoot) { + $blocked = [IO.Path]::GetFullPath($ForbiddenRoot).TrimEnd('\','/') + [IO.Path]::DirectorySeparatorChar + foreach ($module in $modules) { + if ($module.FileName.StartsWith($blocked, [StringComparison]::OrdinalIgnoreCase)) { throw "Build dependency loaded: $($module.FileName)" } + } + } + $record.stage = 'shutdown' + if (-not $process.CloseMainWindow()) { throw 'The application rejected a normal close request.' } + if (-not $process.WaitForExit(20000)) { throw 'The application did not shut down normally.' } + if ($process.ExitCode -ne 0) { throw "Application failed during shutdown: $($process.ExitCode)" } + $record.runs += @{ attempt=$attempt; visibleWindow=$true; stableSeconds=5; localControllerDLL=$true; + runtimeLibraries=@($runtime | ForEach-Object { $_.ModuleName }); exitCode=$process.ExitCode } + } finally { + if (-not $process.HasExited) { $process.Kill(); $process.WaitForExit() } + $process.Dispose() + } + } + $record.stage = 'complete' + $record.status = 'passed' +} catch { + $record.reason = $_.Exception.Message + throw +} finally { + $record | ConvertTo-Json -Depth 6 | Set-Content $reportPath + Remove-Item $root -Recurse -Force +} +Write-Output 'PASS exact extracted archive: GUI window, packaged DLL/runtime, normal quit and relaunch; no physical controller claim' diff --git a/tests/host-file/CMakeLists.txt b/tests/host-file/CMakeLists.txt new file mode 100644 index 0000000..b9b3228 --- /dev/null +++ b/tests/host-file/CMakeLists.txt @@ -0,0 +1,12 @@ +cmake_minimum_required(VERSION 3.24) +project(Switch2KitHostFileTests LANGUAGES CXX) +enable_testing() +add_executable(host-file main.cpp) +target_compile_features(host-file PRIVATE cxx_std_17) +if(MSVC) + target_compile_options(host-file PRIVATE /UNDEBUG /utf-8) +else() + target_compile_options(host-file PRIVATE -UNDEBUG -Wall -Wextra -Werror) +endif() +add_test(NAME host-file COMMAND host-file) +set_tests_properties(host-file PROPERTIES TIMEOUT 20) diff --git a/tests/host-file/main.cpp b/tests/host-file/main.cpp new file mode 100644 index 0000000..4f53cff --- /dev/null +++ b/tests/host-file/main.cpp @@ -0,0 +1,49 @@ +#include "../../Integrations/Emulators/HostFile.hpp" +#include +#include +#include +#include +#include + +int main() { + namespace fs = std::filesystem; + using Switch2Kit::HostFileResult; + using Switch2Kit::readHostFile; + const auto root = fs::temp_directory_path() / ("s2k-host-file-" + + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + assert(fs::create_directory(root)); // Never reuse or erase an existing user directory. + struct Cleanup { fs::path path; ~Cleanup() { fs::remove_all(path); } } cleanup{root}; + const auto file = root / fs::u8path(u8"controller-\u00e9-\u65e5.txt"); + const auto name = file.u8string(); + std::string output = "unchanged"; + const auto reject = [&](const std::string& path, size_t limit, HostFileResult expected) { + output = "unchanged"; + assert(readHostFile(path, limit, output) == expected); + assert(output == "unchanged"); + }; + reject(name, 4, HostFileResult::Missing); + { std::ofstream stream(file, std::ios::binary); stream << "ABCD"; assert(stream.good()); } + assert(readHostFile(name, 4, output) == HostFileResult::OK && output == "ABCD"); + reject(name, 3, HostFileResult::Invalid); + reject(name, 0, HostFileResult::Invalid); + reject(name, 524289, HostFileResult::Invalid); + reject("", 4, HostFileResult::Invalid); + reject(std::string(4097, 'x'), 4, HostFileResult::Invalid); + reject(name + std::string("\0suffix", 7), 4, HostFileResult::Invalid); + reject(name + "\n", 4, HostFileResult::Invalid); + reject(root.u8string(), 4, HostFileResult::Invalid); + { std::ofstream stream(file, std::ios::binary | std::ios::trunc); } + assert(readHostFile(name, 4, output) == HostFileResult::OK && output.empty()); +#if defined(_WIN32) + reject("NUL", 4, HostFileResult::Invalid); + reject("\\\\.\\PhysicalDrive0", 4, HostFileResult::Invalid); + reject("//./pipe/s2k-test", 4, HostFileResult::Invalid); + reject(std::string("\xff", 1), 4, HostFileResult::Invalid); +#else + const auto fifo = root / "fifo"; + assert(mkfifo(fifo.c_str(), 0600) == 0); + reject(fifo.string(), 4, HostFileResult::Invalid); + reject("/dev/null", 4, HostFileResult::Invalid); +#endif + std::cout << "PASS bounded UTF-8 regular-file reads, missing/special files and unchanged error outputs\n"; +} diff --git a/tests/linux-bluez/test_build.py b/tests/linux-bluez/test_build.py index 587f161..5824887 100644 --- a/tests/linux-bluez/test_build.py +++ b/tests/linux-bluez/test_build.py @@ -1,6 +1,8 @@ """Linux integration build guards and relocation of the native install layout.""" from pathlib import Path +import json import os +import shlex import re import shutil import subprocess @@ -113,12 +115,32 @@ def flag(b): return 'ON' if b else 'OFF' def test_installed_library_and_notices_relocate_with_host(self): with tempfile.TemporaryDirectory(prefix='Switch2Kit Linux install ') as temporary: root=Path(temporary) - (root/'lib.c').write_text('int fixture(void) { return 42; }\n') + # Model a compiler-selected shared runtime with native C fixtures. The + # real Swift facade is exercised separately by tests/linux-runtime. + runtime = root / 'compiler-runtime' + runtime.mkdir() + (root/'runtime.c').write_text('int runtime_value(void) { return 42; }\n') + (root/'lib.c').write_text('extern int runtime_value(void); int fixture(void) { return runtime_value(); }\n') + info = root / 'target.json' + info.write_text(json.dumps({'compilerVersion': 'Install fixture', + 'paths': {'runtimeLibraryPaths': [str(runtime)]}})) + compiler = root / 'swiftc' + compiler.write_text('#!/bin/sh\n[ "$#" -eq 1 ] && [ "$1" = -print-target-info ] || exit 1\n' + + 'exec cat ' + shlex.quote(str(info)) + '\n') + compiler.chmod(0o755) + for name in ('swift-license.txt', 'icu-license.txt'): + (root/name).write_text('License for the native install-test fixture: ' + name + '\n') (root/'main.c').write_text('extern int fixture(void); int main(void) { return fixture() != 42; }\n') (root/'CMakeLists.txt').write_text(f'''cmake_minimum_required(VERSION 3.24) project(InstallFixture C) include(GNUInstallDirs) +set(SWITCH2KIT_SWIFTC "{compiler}") +set(SWITCH2KIT_SWIFT_LICENSE "{root}/swift-license.txt") +set(SWITCH2KIT_RUNTIME_ICU_LICENSE "{root}/icu-license.txt") +add_library(FixtureRuntime SHARED runtime.c) +set_target_properties(FixtureRuntime PROPERTIES LIBRARY_OUTPUT_DIRECTORY "{runtime}") add_library(Switch2KitC SHARED lib.c) +target_link_libraries(Switch2KitC PRIVATE FixtureRuntime) add_library(Switch2Kit::C ALIAS Switch2KitC) add_executable(host main.c) target_link_libraries(host PRIVATE Switch2Kit::C) @@ -131,7 +153,16 @@ def test_installed_library_and_notices_relocate_with_host(self): result=run(*args); self.assertEqual(result.returncode,0,result.stdout) (root/'installed').rename(root/'relocated') shutil.rmtree(root/'build') + shutil.rmtree(runtime) result=run(str(root/'relocated/bin/host')); self.assertEqual(result.returncode,0,result.stdout) + for source, installed in [('swift-license.txt', 'LICENSE.txt'), ('icu-license.txt', 'ICU.txt')]: + self.assertEqual((root/f'relocated/share/Switch2KitNotices/SwiftRuntime/{installed}').read_bytes(), + (root/source).read_bytes()) + # A missing runtime must fail, even if the facade itself is present. + runtime_copy = next((root/'relocated').rglob('libFixtureRuntime.so')) + runtime_copy.unlink() + result=run(str(root/'relocated/bin/host')) + self.assertNotEqual(result.returncode, 0, result.stdout) self.assertEqual((root/'relocated/share/Switch2KitNotices/CREDITS.md').read_bytes(), (ROOT/'CREDITS.md').read_bytes()) for source in (ROOT/'LICENSES').glob('*'): if source.is_file(): self.assertEqual((root/'relocated/share/Switch2KitNotices/LICENSES'/source.name).read_bytes(),source.read_bytes()) diff --git a/tests/linux-runtime/inspect.py b/tests/linux-runtime/inspect.py index 49fb2b5..76e72b4 100644 --- a/tests/linux-runtime/inspect.py +++ b/tests/linux-runtime/inspect.py @@ -24,13 +24,7 @@ def main() -> None: require(executable.is_relative_to(prefix), "Executable must be inside the installed prefix") needed = [value for value in tags(executable, "NEEDED") if "Switch2KitC" in value] require(needed == ["libSwitch2KitC.so"], f"Unexpected facade DT_NEEDED: {needed}") - swift = shutil.which("swift") - require(swift is not None, "Swift is required to identify the runtime deployment paths") - assert swift is not None - info = json.loads(run([swift, "-print-target-info"])) - runtime_paths = info["paths"]["runtimeLibraryPaths"] - env = {"PATH": "/usr/bin:/bin", "LANG": "C", "LC_ALL": "C", - "LD_LIBRARY_PATH": os.pathsep.join(runtime_paths)} + env = {"PATH": "/usr/bin:/bin", "LANG": "C", "LC_ALL": "C"} linked = run(["ldd", str(executable)], env=env) print(linked, end="") require("not found" not in linked, "Installed executable has unresolved dependencies") @@ -40,6 +34,11 @@ def main() -> None: facade = Path(paths[0]).resolve(strict=True) require(facade.is_relative_to(prefix), f"Facade is outside installed prefix: {facade}") require(tags(facade, "SONAME") == ["libSwitch2KitC.so"], "Incorrect installed facade SONAME") + for name, path in re.findall(r"^\s*(\S+)\s+=>\s+(.+?)\s+\(0x[0-9a-f]+\)", linked, flags=re.MULTILINE): + if name.startswith(("libswift", "libFoundation", "lib_Foundation", "libdispatch", "libBlocksRuntime")): + require(Path(path).resolve(strict=True).is_relative_to(prefix), + f"Swift runtime dependency escapes installed prefix: {name}: {path}") + require(tags(facade, "RUNPATH") == ["$ORIGIN"], "Facade runtime search path is not relocatable") print(f"PASS: {executable} resolves installed facade {facade}") diff --git a/tests/linux-runtime/verify.py b/tests/linux-runtime/verify.py index 3fb436c..62e5e50 100644 --- a/tests/linux-runtime/verify.py +++ b/tests/linux-runtime/verify.py @@ -11,6 +11,7 @@ import subprocess import sys import tempfile +import tarfile def run(arguments: list[str], *, env: dict[str, str] | None = None, @@ -47,16 +48,8 @@ def main() -> None: for program in ("cmake", "ninja", "swift", "readelf"): require(shutil.which(program) is not None, f"Missing prerequisite: {program}") source = Path(__file__).resolve().parent - swift = shutil.which("swift") - assert swift is not None - info = json.loads(run([swift, "-print-target-info"])) - runtime_paths = info["paths"]["runtimeLibraryPaths"] - require(bool(runtime_paths) and all(Path(p).is_absolute() for p in runtime_paths), - "Swift did not identify its runtime library paths") - # Keep Swift runtime deployment explicit; never inherit a build-tree library - # path or injected preload from the developer's shell. - runtime_env = {"PATH": "/usr/bin:/bin", "LANG": "C", "LC_ALL": "C", - "LD_LIBRARY_PATH": os.pathsep.join(runtime_paths)} + # No Swift installation or developer library-search path in the runtime environment. + runtime_env = {"PATH": "/usr/bin:/bin", "LANG": "C", "LC_ALL": "C"} with tempfile.TemporaryDirectory(prefix="Switch2Kit loader ") as temporary: root = Path(temporary) build = root / "build" @@ -77,7 +70,12 @@ def main() -> None: if "Switch2KitC" in value] require(needed == ["libSwitch2KitC.so"], f"{name}: unexpected facade DT_NEEDED: {needed}") - install.rename(relocated) + archive = root / "installed.tar.gz" + with tarfile.open(archive, "w:gz") as package: + package.add(install, arcname="relocated prefix") + install.rename(root / "unavailable install tree") + with tarfile.open(archive) as package: + package.extractall(root, filter="data") # Remove the exact original build location before running either host. # All modifications are within this test's private temporary directory. build.rename(root / "unavailable original build") @@ -90,8 +88,10 @@ def main() -> None: str(relocated / "bin" / name), str(relocated)]) require((relocated / "share/Switch2KitNotices/CREDITS.md").is_file(), "Installed source attribution is missing") - require((relocated / "share/Switch2KitNotices/LICENSES/README.md").is_file(), - "Installed third-party notices are missing") + notices = relocated / "share/Switch2KitNotices" + for name in ("LICENSES/MIT-trevlars.txt", "LICENSES/SDL-zlib.txt", "SwiftRuntime/LICENSE.txt", "SwiftRuntime/ICU.txt"): + require((notices / name).is_file() and (notices / name).stat().st_size > 0, + f"Installed license text is missing: {name}") hidden = facade.with_suffix(".unavailable") facade.rename(hidden) try: @@ -103,6 +103,16 @@ def main() -> None: f"{name}: missing-library negative control did not fail at the loader") finally: hidden.rename(facade) + core = relocated / library_dir / "libswiftCore.so" + hidden_core = core.with_suffix(".unavailable") + core.rename(hidden_core) + try: + result = subprocess.run([str(relocated / "bin/imported_consumer")], env=runtime_env, + text=True, capture_output=True, timeout=10) + require(result.returncode != 0 and "libswiftCore.so" in result.stderr, + "Missing Swift runtime incorrectly resolved through the compiler installation") + finally: + hidden_core.rename(core) print(f"PASS: {library_dir}; two real C++ consumers, relocated facade, " "unavailable build tree, and two missing-library negative controls") diff --git a/tests/output-health/run.sh b/tests/output-health/run.sh old mode 100644 new mode 100755 index 1c75eae..4ab5d40 --- a/tests/output-health/run.sh +++ b/tests/output-health/run.sh @@ -18,14 +18,11 @@ assert '.disabled(!status.model.capabilities.contains(.rumble))' in dashboard assert 'engine.testRumble(serial: serial)' in dashboard assert 'engine.testRumble(serial: controller.serial)' in view assert 'engine.testRumble(player:' not in dashboard + view -assert 'No matching connected controller' in view -assert 'Test preset' in dashboard and 'Rumble is muted.' in dashboard assert 'id: "output-status"' in app -assert 'Output Status and Capabilities' in app and 'Output Status and Capabilities' in view assert '.disabled(controller == nil || !OutputCapabilities(model: model, backend: backend).directRumble)' in view for sink in ('UDPHub','WebSocketHub','NetworkGamepadSink','VirtualHIDSink'): assert f'OutputStatusStore.shared.register({sink}())' in app -print('PASS output UI wiring, serial-addressed rumble tests, mute and preset guidance') +print('PASS output UI wiring, serial-addressed rumble tests and capability guards') PY # Exercise real production sinks, sharing only existing harness declarations. diff --git a/tests/repository/test_layout.py b/tests/repository/test_layout.py index 0d7d04b..53b90bd 100644 --- a/tests/repository/test_layout.py +++ b/tests/repository/test_layout.py @@ -1,6 +1,5 @@ -"""Repository layout, documentation links, and application identity.""" +"""Repository integrity, documentation links, and executable identity.""" from pathlib import Path -import json import plistlib import re import subprocess @@ -12,22 +11,11 @@ class RepositoryTests(unittest.TestCase): def test_application_identity(self): info = plistlib.loads((ROOT / "Resources/Info.plist").read_bytes()) - self.assertEqual(info["CFBundleName"], "Switch2Kit") - self.assertEqual(info["CFBundleDisplayName"], "Switch2Kit") self.assertEqual(info["CFBundleExecutable"], "Switch2KitApp") self.assertEqual(info["CFBundleIdentifier"], "wabisabi.ware.gamecubed") - self.assertTrue((ROOT / "Sources/Switch2KitApp/Switch2KitApp.swift").is_file()) - self.assertIn('name: "Switch2KitApp"', (ROOT / "Package.swift").read_text()) - self.assertIn('APP_NAME="Switch2Kit"', (ROOT / "scripts/build-app.sh").read_text()) - self.assertIn('EXE=Switch2KitApp', (ROOT / "scripts/build-app.sh").read_text()) - - def test_browser_identity(self): - manifest = json.loads((ROOT / "browser/extension/manifest.json").read_text()) - self.assertTrue(manifest["name"].startswith("Switch2Kit")) - self.assertIn("Switch2Kit", manifest["description"]) def test_documentation_links(self): - roots = [ROOT / "docs", ROOT / "Examples", ROOT / "sdl", ROOT / "browser"] + roots = [ROOT / "docs", ROOT / "Examples", ROOT / "sdl", ROOT / "browser", ROOT / "LICENSES"] files = [ROOT / "README.md", ROOT / "CREDITS.md"] for root in roots: files.extend(root.rglob("*.md")) @@ -53,7 +41,6 @@ def test_source_only_tree(self): tracked = subprocess.check_output(["git", "ls-files", "-z"], cwd=ROOT).decode().split("\0") for path in filter(None, tracked): self.assertFalse(path.endswith((".dylib", ".xcframework.zip")), path) - self.assertLess(len((ROOT / "README.md").read_text().splitlines()), 100) if __name__ == "__main__": unittest.main() diff --git a/tests/sdl-inprocess/CMakeLists.txt b/tests/sdl-inprocess/CMakeLists.txt index 6fa48a4..b02d583 100644 --- a/tests/sdl-inprocess/CMakeLists.txt +++ b/tests/sdl-inprocess/CMakeLists.txt @@ -9,7 +9,7 @@ set(SDL_TESTS OFF CACHE BOOL "" FORCE) set(SDL_EXAMPLES OFF CACHE BOOL "" FORCE) set(SDL_SHARED OFF CACHE BOOL "" FORCE) set(SDL_STATIC ON CACHE BOOL "" FORCE) -if(NOT APPLE) +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") # These portable fixtures intentionally have no desktop video backend. set(SDL_UNIX_CONSOLE_BUILD ON CACHE BOOL "" FORCE) set(SDL_X11 OFF CACHE BOOL "" FORCE) @@ -26,31 +26,51 @@ add_custom_target(sdl-version-check ALL DEPENDS Switch2KitSDLVersion) get_filename_component(_root "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE) set(_fixture "${CMAKE_CURRENT_BINARY_DIR}/${CMAKE_SHARED_LIBRARY_PREFIX}S2KSDLFixture${CMAKE_SHARED_LIBRARY_SUFFIX}") set(_args) +set(_fixture_byproducts) +set(_rpath_args) +if(WIN32) + set(_fixture_import "${CMAKE_CURRENT_BINARY_DIR}/S2KSDLFixture.lib") + list(APPEND _fixture_byproducts "${_fixture_import}") + list(APPEND _args -Xlinker "/IMPLIB:${_fixture_import}") +else() + list(APPEND _rpath_args -Xlinker -rpath -Xlinker "${SWITCH2KIT_C_BINARY_DIR}") +endif() if(APPLE) execute_process(COMMAND xcrun --sdk macosx --show-sdk-path OUTPUT_VARIABLE _sdk OUTPUT_STRIP_TRAILING_WHITESPACE COMMAND_ERROR_IS_FATAL ANY) execute_process(COMMAND uname -m OUTPUT_VARIABLE _arch OUTPUT_STRIP_TRAILING_WHITESPACE) list(APPEND _args -sdk "${_sdk}" -target "${_arch}-apple-macosx15.0") -else() +elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") list(APPEND _args -Xlinker -soname -Xlinker "${CMAKE_SHARED_LIBRARY_PREFIX}S2KSDLFixture${CMAKE_SHARED_LIBRARY_SUFFIX}") endif() add_custom_command(OUTPUT "${_fixture}" + BYPRODUCTS ${_fixture_byproducts} COMMAND "${Python3_EXECUTABLE}" "${_root}/tests/support/compile-fixture.py" --description "${SWITCH2KIT_C_BINARY_DIR}/description.json" --compiler "${SWITCH2KIT_SWIFTC}" -- -swift-version 6 -warnings-as-errors -emit-library -module-name S2KSDLFixture ${_args} -I "${SWITCH2KIT_C_BINARY_DIR}/Modules" -I "${_root}/Sources/Switch2KitCABI/include" -L "${SWITCH2KIT_C_BINARY_DIR}" -lSwitch2KitC - -Xlinker -rpath -Xlinker "${SWITCH2KIT_C_BINARY_DIR}" + ${_rpath_args} "${CMAKE_CURRENT_LIST_DIR}/Fixture.swift" -o "${_fixture}" DEPENDS Switch2KitCBuild "${_root}/tests/support/compile-fixture.py" "${CMAKE_CURRENT_LIST_DIR}/Fixture.swift" VERBATIM) add_custom_target(S2KSDLFixtureBuild DEPENDS "${_fixture}") add_library(S2KSDLFixture SHARED IMPORTED GLOBAL) set_target_properties(S2KSDLFixture PROPERTIES IMPORTED_LOCATION "${_fixture}") +if(WIN32) + set_target_properties(S2KSDLFixture PROPERTIES IMPORTED_IMPLIB "${_fixture_import}") +endif() add_dependencies(S2KSDLFixture S2KSDLFixtureBuild) add_executable(sdl-inprocess main.cpp) target_compile_features(sdl-inprocess PRIVATE cxx_std_17) -target_compile_options(sdl-inprocess PRIVATE -Wall -Wextra -Werror -UNDEBUG) -target_compile_options(Switch2KitSDL3 PRIVATE -Wall -Wextra -Werror) +if(MSVC) + set(_test_options /W4 /UNDEBUG) + set(_force_include "/FI${CMAKE_CURRENT_LIST_DIR}/Clock.hpp") +else() + set(_test_options -Wall -Wextra -Werror -UNDEBUG) + set(_force_include -include "${CMAKE_CURRENT_LIST_DIR}/Clock.hpp") + target_compile_options(Switch2KitSDL3 PRIVATE -Wall -Wextra -Werror) +endif() +target_compile_options(sdl-inprocess PRIVATE ${_test_options}) target_link_libraries(sdl-inprocess PRIVATE Switch2Kit::SDL3 S2KSDLFixture) set_property(TARGET sdl-inprocess APPEND PROPERTY BUILD_RPATH "${SWITCH2KIT_C_BINARY_DIR}" "${CMAKE_CURRENT_BINARY_DIR}") add_test(NAME sdl-inprocess COMMAND sdl-inprocess) @@ -66,7 +86,7 @@ target_compile_features(S2KSDLClockedAdapter PUBLIC cxx_std_17) # constructor still requires its existing target-local -fexceptions override. target_compile_options(S2KSDLClockedAdapter PRIVATE "$" - -include "${CMAKE_CURRENT_LIST_DIR}/Clock.hpp") + ${_force_include}) target_compile_definitions(S2KSDLClockedAdapter PRIVATE SDL_GetTicksNS=s2k_test_ticks_ns s2k_monotonic_time=s2k_test_monotonic_time S2K_MOTION_CONTINUOUS_NS=s2k_test_continuous_ns) @@ -74,8 +94,17 @@ target_include_directories(S2KSDLClockedAdapter PUBLIC "${_root}/Integrations/SD target_link_libraries(S2KSDLClockedAdapter PUBLIC Switch2Kit::C SDL3::SDL3) add_executable(sdl-motion motion.cpp Clock.cpp) target_compile_features(sdl-motion PRIVATE cxx_std_17) -target_compile_options(sdl-motion PRIVATE -Wall -Wextra -Werror -UNDEBUG) +target_compile_options(sdl-motion PRIVATE ${_test_options}) target_link_libraries(sdl-motion PRIVATE S2KSDLClockedAdapter S2KSDLFixture) set_property(TARGET sdl-motion APPEND PROPERTY BUILD_RPATH "${SWITCH2KIT_C_BINARY_DIR}" "${CMAKE_CURRENT_BINARY_DIR}") add_test(NAME sdl-motion COMMAND sdl-motion) set_tests_properties(sdl-motion PROPERTIES TIMEOUT 45) + +if(WIN32) + target_link_libraries(S2KSDLClockedAdapter PUBLIC mincore) + foreach(_consumer sdl-inprocess sdl-motion) + switch2kit_embed_windows(${_consumer}) + add_custom_command(TARGET ${_consumer} POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E copy_if_different "${_fixture}" "$" VERBATIM) + endforeach() +endif() diff --git a/tests/sdl-inprocess/main.cpp b/tests/sdl-inprocess/main.cpp index 7f5e816..09e1f63 100644 --- a/tests/sdl-inprocess/main.cpp +++ b/tests/sdl-inprocess/main.cpp @@ -79,14 +79,11 @@ int main() { discardEvents(); double strong{}, weak{}; assert(SDL_RumbleGamepad(pads[0], 32768, 16384, 5000)); - auto calls = test_input_rumble(context, 0, &strong, &weak); + assert(test_input_rumble(context, 0, &strong, &weak) > 0); assert(strong > 0.49 && weak > 0.24); - SDL_Delay(230); assert(adapter.pump() == S2K_OK); - assert(test_input_rumble(context, 0, &strong, &weak) > calls && strong > 0.49); - calls = test_input_rumble(context, 0, &strong, &weak); - SDL_Delay(550); assert(test_input_rumble(context, 0, &strong, &weak) == calls); - assert(adapter.pump() == S2K_OK); - test_input_rumble(context, 0, &strong, &weak); assert(strong == 0 && weak == 0); + // Renewal and stall boundaries are tested with the clocked adapter in + // motion.cpp. A descheduled clock-read bracket legitimately fails closed + // in this production-clock consumer, even without a 500 ms host stall. assert(SDL_RumbleGamepad(pads[0], 30000, 20000, 20)); SDL_Delay(40); assert(adapter.pump() == S2K_OK); test_input_rumble(context, 0, &strong, &weak); assert(strong == 0 && weak == 0); @@ -97,7 +94,7 @@ int main() { test_input_rumble(context, 1, &strong, &weak); assert(strong == 0 && weak == 0); auto gc = adapter.snapshot().controllers[1]; assert(s2k_play_feedback(context, &gc.id, &gc.connection_id, 0.25) == S2K_OK); - std::puts("PASS sustained effect renewal, SDL duration expiry, stalled-host stop and distinct GameCube feedback capability"); + std::puts("PASS real-clock SDL rumble delivery, duration expiry and distinct GameCube feedback capability"); input[0].buttons = S2K_BUTTON_A; ++input[0].sequence; test_input_report(context, 0, models[0], &input[0]); assert(adapter.pump() == S2K_OK); assert(SDL_GetGamepadButton(pads[0], SDL_GAMEPAD_BUTTON_EAST)); diff --git a/tests/sdl-inprocess/motion.cpp b/tests/sdl-inprocess/motion.cpp index eeaffed..96f1ae0 100644 --- a/tests/sdl-inprocess/motion.cpp +++ b/tests/sdl-inprocess/motion.cpp @@ -126,6 +126,45 @@ int main() { // OS scheduling must not turn a consumer-only delay into an input gap. TestClock::start(); assert(adapter.pump(false) == S2K_OK); pump(); assert(events().empty()); + { + // Test the same production rumble policy without assuming that a + // wall-clock sleep returns before the host/clock safety cutoffs. + // SDL and the Swift C facade are real; only adapter clock reads are + // controlled. Production clocks remain covered by sdl-inprocess. + const auto outputPump = [&] { assert(adapter.pump() == S2K_OK); }; + double strong{}, weak{}; + assert(SDL_RumbleGamepad(pads[0], 32768, 16384, 5000)); + const auto started = test_input_rumble(context, 0, &strong, &weak); + assert(strong > 0.49 && weak > 0.24); + TestClock::advance(199999999); outputPump(); + assert(test_input_rumble(context, 0, &strong, &weak) == started); + TestClock::advance(1); outputPump(); + assert(test_input_rumble(context, 0, &strong, &weak) == started + 1); + assert(strong > 0.49 && weak > 0.24); + const auto renewed = test_input_rumble(context, 0, &strong, &weak); + // No pump means no background renewal. A stale callback must not + // revive the effect before the input loop notices its own stall. + TestClock::advance(500000001); + assert(test_input_rumble(context, 0, &strong, &weak) == renewed); + assert(!SDL_RumbleGamepad(pads[0], 30000, 20000, 5000)); + assert(test_input_rumble(context, 0, &strong, &weak) == renewed); + outputPump(); + const auto stopped = test_input_rumble(context, 0, &strong, &weak); + assert(stopped > renewed && strong == 0 && weak == 0); + TestClock::advance(200000000); outputPump(); + assert(test_input_rumble(context, 0, &strong, &weak) == stopped); + assert(strong == 0 && weak == 0); + // SDL duration expiry still uses SDL's real clock. With the adapter + // clock held steady, this stop cannot be a host-stall false positive. + assert(SDL_RumbleGamepad(pads[0], 30000, 20000, 20)); + SDL_Delay(40); outputPump(); + test_input_rumble(context, 0, &strong, &weak); + assert(strong == 0 && weak == 0); + const auto expired = test_input_rumble(context, 0, &strong, &weak); + TestClock::advance(200000000); outputPump(); + assert(test_input_rumble(context, 0, &strong, &weak) == expired); + std::puts("PASS exact rumble renewal boundary, stale callback rejection, stalled-host stop and no effect revival"); + } for (int i = 0; i < 4; ++i) submit(i); pump(); assert(events().empty()); for (int i = 0; i < 4; ++i) submit(i); diff --git a/tests/support/kit-sources.sh b/tests/support/kit-sources.sh index f306c17..fbf6d7d 100644 --- a/tests/support/kit-sources.sh +++ b/tests/support/kit-sources.sh @@ -3,6 +3,7 @@ # and the separate dashboard capability policy, never copied protocol methods. kit_flags=(-package-name Switch2Kit -D S2K_RADIO_FIXTURE) kit_sources=( + Sources/Switch2Kit/Platform/ControllerClock.swift Sources/Switch2Kit/Public/ControllerTypes.swift Sources/Switch2Kit/Public/Lifecycle.swift Sources/Switch2Kit/Protocol/Switch2Protocol.swift diff --git a/tests/windows-relocation/run.sh b/tests/windows-relocation/run.sh new file mode 100755 index 0000000..5d83d39 --- /dev/null +++ b/tests/windows-relocation/run.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -euo pipefail +ROOT=$(cd "$(dirname "$0")/../.." && pwd -P) +python3 "$ROOT/tests/windows-relocation/test_relocation.py" diff --git a/tests/windows-relocation/test_relocation.py b/tests/windows-relocation/test_relocation.py new file mode 100644 index 0000000..f742e74 --- /dev/null +++ b/tests/windows-relocation/test_relocation.py @@ -0,0 +1,121 @@ +"""Failure-oriented package-supervisor tests, without a Swift or Bluetooth substitute.""" +import importlib.util +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location('relocation', Path(__file__).resolve().parents[1] / 'c-consumer/relocate-windows.py') +relocation = importlib.util.module_from_spec(spec) +spec.loader.exec_module(relocation) + + +class Tests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix='relocation [fixture] ') + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.windows = self.root / 'Windows' + (self.windows / 'System32').mkdir(parents=True) + self.c, self.sdl, self.work = self.root / 'C build', self.root / 'SDL build', self.root / 'work' + for build, names in ((self.c, ('c-consumer.exe',)), (self.sdl, ('sdl-inprocess.exe', 'sdl-motion.exe'))): + build.mkdir() + for name in (*names, 'Switch2KitC.dll', 'swiftCore.dll'): + (build / name).write_bytes(b'package fixture only: ' + name.encode()) + for name in relocation.NOTICES: + path = build / 'Switch2KitNotices' / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('complete fixture license\n') + self.work.mkdir() + + def test_missing_binary_and_empty_notice_fail_before_launch(self): + (self.c / 'swiftCore.dll').unlink() + with self.assertRaises(RuntimeError): + relocation.stage(self.c, self.root / 'missing', ('c-consumer.exe',)) + (self.sdl / 'Switch2KitNotices' / relocation.NOTICES[0]).write_bytes(b'') + with self.assertRaises(RuntimeError): + relocation.stage(self.sdl, self.root / 'empty', ('sdl-motion.exe',)) + + def test_environment_has_only_os_paths_and_private_user_directories(self): + environment = relocation.child_environment(self.work, {'SystemRoot': str(self.windows), 'PATH': '/compiler/bin', + 'SWIFT_RUNTIME_PATH': '/compiler/runtime', 'SDKROOT': '/compiler/sdk', 'APPDATA': '/real-user/config', + 'LD_LIBRARY_PATH': '/developer/runtime', 'SDL_GAMECONTROLLERCONFIG': 'do not inherit'}) + self.assertEqual(set(environment), {'SystemRoot', 'WINDIR', 'PATH', 'COMSPEC', 'USERPROFILE', 'HOME', + 'APPDATA', 'LOCALAPPDATA', 'TEMP', 'TMP'}) + self.assertEqual(environment['PATH'], str(self.windows / 'System32') + ';' + str(self.windows)) + for key in ('USERPROFILE', 'HOME', 'APPDATA', 'LOCALAPPDATA', 'TEMP', 'TMP'): + self.assertTrue(Path(environment[key]).is_relative_to(self.work)) + self.assertTrue(Path(environment[key]).is_dir()) + + def test_invalid_system_root_fails_closed(self): + for inherited in ({}, {'SystemRoot': str(self.root / 'not Windows')}): + with self.assertRaises(RuntimeError): + relocation.child_environment(self.work, inherited) + + def invoke(self, failure=None): + report = {} + self.calls = [] + def execute(exe, env): + self.assertTrue(exe.is_relative_to(self.work / 'extracted package')) + self.assertFalse((self.work / 'staged package').exists()) + self.assertEqual(exe.read_bytes(), b'package fixture only: ' + exe.name.encode()) + self.calls.append(exe) + missing = any(not (exe.parent / name).exists() for name in ('Switch2KitC.dll', 'swiftCore.dll')) + code = (failure if failure is not None else -1073741515) if missing else 0 + return {'executable': str(exe), 'exit_code': code, 'output': ''} + with patch.dict(os.environ, {'SystemRoot': str(self.windows)}, clear=True), patch.object(relocation, 'execute', execute): + relocation.qualify(self.c, self.sdl, self.work, report) + return report + + def test_exact_archive_is_extracted_and_all_consumers_and_negative_controls_run(self): + report = self.invoke() + self.assertEqual(len(report['runs']), 3) + self.assertEqual(len(report['negative_controls']), 6) + self.assertEqual(len(self.calls), 9) + for name in ('Switch2KitC.dll', 'swiftCore.dll'): + self.assertTrue((self.work / 'extracted package/c' / name).is_file()) + self.assertTrue((self.c / name).is_file()) + self.assertIn('c/Switch2KitNotices/SwiftRuntime/ICU.txt', report['files']) + + def test_a_global_runtime_rescuing_missing_dll_cannot_pass(self): + with self.assertRaises(RuntimeError): + self.invoke(failure=0) + self.assertTrue((self.work / 'extracted package/c/Switch2KitC.dll').exists()) + + def test_arbitrary_crash_is_not_a_missing_library_pass(self): + with self.assertRaises(RuntimeError): + self.invoke(failure=-1073740791) # Fast-fail assertion, not DLL-not-found. + + def test_nonzero_consumer_execution_fails_and_keeps_diagnostics(self): + report = {} + with patch.dict(os.environ, {'SystemRoot': str(self.windows)}, clear=True), patch.object(relocation, 'execute', + return_value={'exit_code': 23, 'output': 'consumer failed'}): + with self.assertRaises(RuntimeError): + relocation.qualify(self.c, self.sdl, self.work, report) + self.assertEqual(report['runs'], [{'exit_code': 23, 'output': 'consumer failed'}]) + + def test_timeout_kills_and_reaps_only_owned_process(self): + from unittest.mock import Mock + process = Mock() + process.communicate.side_effect = [subprocess.TimeoutExpired('child', 0.1), ('timed out output', None)] + with patch.object(subprocess, 'Popen', return_value=process) as spawn: + with self.assertRaises(RuntimeError): + relocation.execute(self.c / 'c-consumer.exe', {'PATH': 'OS only'}, timeout=0.1) + process.kill.assert_called_once_with() + self.assertEqual(process.communicate.call_count, 2) + self.assertEqual(spawn.call_args.args[0], [str(self.c / 'c-consumer.exe')]) + self.assertEqual(spawn.call_args.kwargs['env'], {'PATH': 'OS only'}) + self.assertNotIn('shell', spawn.call_args.kwargs) + + def test_real_subprocess_uses_its_executable_without_a_shell(self): + # No fixture is passed off as a native consumer: this checks the supervisor only. + environment = dict(os.environ, PYTHONINSPECT='') + result = relocation.execute(Path(sys.executable), environment) + self.assertEqual(result['exit_code'], 0, result) + + +if __name__ == '__main__': + unittest.main()