diff --git a/README.md b/README.md index d0e3c96..b6d866b 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,9 @@ The repository now includes `.run/Run xemu.run.xml`, which launches `scripts\run If you create a local CLion run configuration that sets the working directory to a build output such as `$CMakeCurrentBuildDir$/xbox`, the Windows wrapper also treats that caller working directory as the xemu target path when no explicit launcher arguments or `MOONLIGHT_XEMU_*` overrides are provided. -The setup script downloads xemu and the emulator support files into `.local/xemu`, then refreshes launcher manifests used by `scripts/run-xemu.sh`. The launcher accepts `MOONLIGHT_XEMU_BUILD_DIR`, `MOONLIGHT_XEMU_ISO_PATH`, `--build-dir `, `--iso `, or a single positional path that can point at either a build directory or an ISO file. If you do not pass a path, it falls back across available `cmake-build-*` outputs and prefers the newest built ISO. +The setup script downloads xemu and the emulator support files into `.local/xemu`, then refreshes launcher manifests used by `scripts/run-xemu.sh`. Existing files under `.local/xemu` are preserved by default so a local xemu install or support bundle is not overwritten unless you pass `--force`. The launcher accepts `MOONLIGHT_XEMU_BUILD_DIR`, `MOONLIGHT_XEMU_ISO_PATH`, `--build-dir `, `--iso `, or a single positional path that can point at either a build directory or an ISO file. If you do not pass a path, it falls back across available `cmake-build-*` outputs and prefers the newest built ISO. + +When xemu runs with its default user-mode network, multicast mDNS traffic is not forwarded reliably, so automatic host discovery may not find your PC. For reliable discovery, launch xemu with `--network tap --tap-ifname ` or add the host manually from the Xbox UI. If you only want the emulator without the ROM/HDD support bundle, run: @@ -241,6 +243,7 @@ scripts\setup-xemu.cmd --skip-support-files - Misc. - [x] Save config and pairing states - [x] Host pairing + - [x] Auto host discovery - [ ] Possibly, GPU overclocking, see https://github.com/GXTX/XboxOverclock
diff --git a/cmake/moonlight-dependencies.cmake b/cmake/moonlight-dependencies.cmake index 5ab1553..dcc5024 100644 --- a/cmake/moonlight-dependencies.cmake +++ b/cmake/moonlight-dependencies.cmake @@ -55,6 +55,11 @@ macro(MOONLIGHT_PREPARE_COMMON_DEPENDENCIES) set(MOONLIGHT_NXDK_NET_INCLUDE_DIR "${NXDK_DIR}/lib/net") set(MOONLIGHT_NXDK_LIBC_EXTENSIONS_DIR "${NXDK_DIR}/lib/xboxrt/libc_extensions") set(MOONLIGHT_NXDK_LWIP_POSIX_COMPAT_DIR "${NXDK_DIR}/lib/net/lwip/src/include/compat/posix") + set(MOONLIGHT_NXDK_LWIP_MDNS_SOURCES + "${NXDK_DIR}/lib/net/lwip/src/apps/mdns/mdns.c" + "${NXDK_DIR}/lib/net/lwip/src/apps/mdns/mdns_domain.c" + "${NXDK_DIR}/lib/net/lwip/src/apps/mdns/mdns_out.c" + ) if(TARGET enet) target_link_libraries(enet PUBLIC NXDK::NXDK NXDK::Net) diff --git a/cmake/xbox-build.cmake b/cmake/xbox-build.cmake index 73043d1..3b100ca 100644 --- a/cmake/xbox-build.cmake +++ b/cmake/xbox-build.cmake @@ -56,7 +56,9 @@ add_executable(${CMAKE_PROJECT_NAME} ) target_sources(${CMAKE_PROJECT_NAME} PRIVATE - "${CMAKE_SOURCE_DIR}/src/_nxdk_compat/stat_compat.cpp") + "${CMAKE_SOURCE_DIR}/src/_nxdk_compat/stat_compat.cpp" + ${MOONLIGHT_NXDK_LWIP_MDNS_SOURCES} +) target_include_directories(${CMAKE_PROJECT_NAME} SYSTEM PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}" diff --git a/scripts/setup-xemu.sh b/scripts/setup-xemu.sh index b99c6a5..da38f01 100644 --- a/scripts/setup-xemu.sh +++ b/scripts/setup-xemu.sh @@ -7,6 +7,7 @@ usage() { Usage: setup-xemu.sh [--force] [--skip-support-files] Downloads a portable xemu build into .local/xemu and refreshes launcher manifests. +Existing .local/xemu files are preserved unless --force is passed. EOF return 0 } @@ -68,6 +69,33 @@ extract_archive() { return 1 } +copy_tree_preserving_existing() { + local source_root="$1" + local destination_root="$2" + local source_path relative_path destination_path + + mkdir -p "$destination_root" + + while IFS= read -r -d '' source_path; do + relative_path="${source_path#"$source_root"/}" + destination_path="$destination_root/$relative_path" + + if [[ -d "$source_path" ]]; then + mkdir -p "$destination_path" + continue + fi + + if [[ -e "$destination_path" ]]; then + continue + fi + + mkdir -p "$(dirname "$destination_path")" + cp -p "$source_path" "$destination_path" + done < <(find "$source_root" -mindepth 1 -print0) + + return 0 +} + latest_xemu_tag() { curl -fsSL -A 'Moonlight-XboxOG setup-xemu' \ 'https://api.github.com/repos/xemu-project/xemu/releases/latest' | @@ -147,6 +175,22 @@ find_first_file() { return 1 } +find_existing_xemu_executable() { + local root="$1" + + find_first_file "$root" 'xemu.exe' 'xemu' 'xemu.AppImage' + return $? +} + +support_assets_ready() { + local root="$1" + + [[ -n "$(find_first_file "$root" 'mcpx*.bin' 2>/dev/null || true)" ]] || return 1 + [[ -n "$(find_first_file "$root" 'Complex*.bin' '*4627*.bin' 2>/dev/null || true)" ]] || return 1 + [[ -n "$(find_first_file "$root" 'xbox_hdd.qcow2' '*.qcow2' 2>/dev/null || true)" ]] || return 1 + return 0 +} + write_shell_manifest() { local manifest_path="$1" shift @@ -179,6 +223,8 @@ write_cmd_manifest() { force_download=0 skip_support_files=0 +reused_existing_xemu=0 +reused_existing_support=0 while [[ $# -gt 0 ]]; do case "$1" in @@ -214,56 +260,79 @@ manifest_cmd="$xemu_root/paths.cmd" mkdir -p "$downloads_dir" "$app_dir" "$support_dir" "$temp_dir" "$portable_root" -mapfile -t platform_info < <(detect_platform) -os_name="${platform_info[0]}" -arch_name="${platform_info[1]}" -tag="$(latest_xemu_tag)" -if [[ -z "$tag" ]]; then - echo 'Could not determine the latest xemu release tag.' >&2 - exit 1 -fi - -asset_name="$(select_xemu_asset "$os_name" "$arch_name" "$tag")" -asset_url="https://github.com/xemu-project/xemu/releases/latest/download/${asset_name}" -asset_path="$downloads_dir/$asset_name" - -download_file "$asset_url" "$asset_path" "$force_download" - -rm -rf "$app_dir" -mkdir -p "$app_dir" +xemu_exe="$(find_existing_xemu_executable "$app_dir" || true)" +if [[ -n "$xemu_exe" && "$force_download" -eq 0 ]]; then + reused_existing_xemu=1 +else + mapfile -t platform_info < <(detect_platform) + os_name="${platform_info[0]}" + arch_name="${platform_info[1]}" + tag="$(latest_xemu_tag)" + if [[ -z "$tag" ]]; then + echo 'Could not determine the latest xemu release tag.' >&2 + exit 1 + fi -case "$asset_name" in - *.zip) - extract_archive "$asset_path" "$temp_dir/xemu-extract" - xemu_exe="$(find_first_file "$temp_dir/xemu-extract" 'xemu.exe' 'xemu')" - if [[ -z "$xemu_exe" ]]; then - echo 'Could not find xemu executable in the downloaded archive.' >&2 + asset_name="$(select_xemu_asset "$os_name" "$arch_name" "$tag")" + asset_url="https://github.com/xemu-project/xemu/releases/latest/download/${asset_name}" + asset_path="$downloads_dir/$asset_name" + + download_file "$asset_url" "$asset_path" "$force_download" + + case "$asset_name" in + *.zip) + extract_archive "$asset_path" "$temp_dir/xemu-extract" + if [[ -z "$(find_existing_xemu_executable "$temp_dir/xemu-extract" || true)" ]]; then + echo 'Could not find xemu executable in the downloaded archive.' >&2 + exit 1 + fi + + if [[ "$force_download" -eq 1 ]]; then + rm -rf "$app_dir" + mkdir -p "$app_dir" + cp -R "$temp_dir/xemu-extract"/. "$app_dir"/ + else + copy_tree_preserving_existing "$temp_dir/xemu-extract" "$app_dir" + fi + xemu_exe="$(find_existing_xemu_executable "$app_dir" || true)" + ;; + *.AppImage) + if [[ "$force_download" -eq 1 || ! -f "$app_dir/xemu.AppImage" ]]; then + cp "$asset_path" "$app_dir/xemu.AppImage" + fi + chmod +x "$app_dir/xemu.AppImage" + xemu_exe="$app_dir/xemu.AppImage" + ;; + *) + echo "Unsupported xemu asset type: $asset_name" >&2 exit 1 - fi - cp -R "$temp_dir/xemu-extract"/. "$app_dir"/ - xemu_exe="$(find_first_file "$app_dir" 'xemu.exe' 'xemu')" - ;; - *.AppImage) - cp "$asset_path" "$app_dir/xemu.AppImage" - chmod +x "$app_dir/xemu.AppImage" - xemu_exe="$app_dir/xemu.AppImage" - ;; - *) - echo "Unsupported xemu asset type: $asset_name" >&2 + ;; + esac + + if [[ -z "$xemu_exe" ]]; then + echo 'Could not locate the xemu executable after installation.' >&2 exit 1 - ;; - esac + fi +fi support_zip="$downloads_dir/Xbox-Emulator-Files.zip" if [[ "$skip_support_files" -eq 0 ]]; then - download_file \ - 'https://github.com/K3V1991/Xbox-Emulator-Files/releases/download/v1/Xbox-Emulator-Files.zip' \ - "$support_zip" \ - "$force_download" - rm -rf "$support_dir" - mkdir -p "$support_dir" - extract_archive "$support_zip" "$temp_dir/support-extract" - cp -R "$temp_dir/support-extract"/. "$support_dir"/ + if [[ "$force_download" -eq 0 ]] && support_assets_ready "$support_dir"; then + reused_existing_support=1 + else + download_file \ + 'https://github.com/K3V1991/Xbox-Emulator-Files/releases/download/v1/Xbox-Emulator-Files.zip' \ + "$support_zip" \ + "$force_download" + extract_archive "$support_zip" "$temp_dir/support-extract" + if [[ "$force_download" -eq 1 ]]; then + rm -rf "$support_dir" + mkdir -p "$support_dir" + cp -R "$temp_dir/support-extract"/. "$support_dir"/ + else + copy_tree_preserving_existing "$temp_dir/support-extract" "$support_dir" + fi + fi fi bootrom_path='' @@ -352,6 +421,12 @@ fi chmod +x "$manifest_sh" printf 'Portable xemu files are ready in %s\n' "$xemu_root" +if [[ "$reused_existing_xemu" -eq 1 ]]; then + echo 'Reused the existing xemu application files in .local/xemu/app.' +fi +if [[ "$reused_existing_support" -eq 1 ]]; then + echo 'Reused the existing xemu support files in .local/xemu/support.' +fi if [[ "$skip_support_files" -eq 1 ]]; then echo 'Support files were skipped. Run setup-xemu.sh again without --skip-support-files to fetch them.' elif [[ -z "$bootrom_path" || -z "$flashrom_path" || -z "$hdd_path" ]]; then diff --git a/src/app/client_state.cpp b/src/app/client_state.cpp index b8aee00..1d5c3ec 100644 --- a/src/app/client_state.cpp +++ b/src/app/client_state.cpp @@ -944,7 +944,7 @@ namespace { * @param update Update structure that receives the pairing request details. */ void request_host_pairing(app::ClientState &state, const app::HostRecord &host, app::AppUpdate *update) { - if (!enter_pair_host_screen(state, host.address, host.port)) { + if (const uint16_t pairingPort = host.resolvedHttpPort == 0U ? host.port : host.resolvedHttpPort; !enter_pair_host_screen(state, host.address, pairingPort)) { return; } @@ -1323,6 +1323,51 @@ namespace app { } } + bool merge_discovered_host(ClientState &state, std::string displayName, const std::string &address, uint16_t port) { + const std::string normalizedAddress = normalize_ipv4_address(address); + if (normalizedAddress.empty()) { + return false; + } + + const uint16_t effectivePort = effective_host_port(port); + const uint16_t storedPort = effectivePort == DEFAULT_HOST_PORT ? 0 : effectivePort; + if (HostRecord *host = find_host_by_endpoint(state.hosts.items, normalizedAddress, effectivePort); host != nullptr) { + bool persistedMetadataChanged = false; + if (const std::string defaultDisplayName = build_default_host_display_name(host->address); !displayName.empty() && (host->displayName.empty() || host->displayName == defaultDisplayName)) { + persistedMetadataChanged = host->displayName != displayName; + host->displayName = std::move(displayName); + } + host->reachability = HostReachability::online; + if (host->manualAddress.empty()) { + host->manualAddress = normalizedAddress; + } + if (host->resolvedHttpPort == 0U || host->resolvedHttpPort == DEFAULT_HOST_PORT) { + host->resolvedHttpPort = effectivePort; + } + state.hosts.dirty = state.hosts.dirty || persistedMetadataChanged; + return persistedMetadataChanged; + } + + HostRecord discoveredHost = make_host_record(normalizedAddress, storedPort); + if (!displayName.empty()) { + discoveredHost.displayName = std::move(displayName); + } + discoveredHost.reachability = HostReachability::online; + discoveredHost.manualAddress = normalizedAddress; + discoveredHost.resolvedHttpPort = effectivePort; + + const bool wasEmpty = state.hosts.items.empty(); + state.hosts.items.push_back(std::move(discoveredHost)); + state.hosts.loaded = true; + state.hosts.dirty = true; + if (wasEmpty && state.shell.activeScreen == ScreenId::hosts) { + state.hosts.focusArea = HostsFocusArea::grid; + state.hosts.selectedHostIndex = 0U; + } + clamp_selected_host_index(state); + return true; + } + void replace_saved_files(ClientState &state, std::vector savedFiles) { state.settings.savedFiles = std::move(savedFiles); state.settings.savedFilesDirty = false; diff --git a/src/app/client_state.h b/src/app/client_state.h index 532142a..d33c340 100644 --- a/src/app/client_state.h +++ b/src/app/client_state.h @@ -413,6 +413,23 @@ namespace app { */ void replace_hosts(ClientState &state, std::vector hosts, std::string statusMessage = {}); + /** + * @brief Add or refresh one auto-discovered host in the current host list. + * + * Discovery results are normalized to the same saved-host conventions used by + * manual host entry. When a matching host already exists, transient runtime + * fields such as reachability are refreshed without overwriting a custom saved + * name. When no host matches, a new host record is appended and marked dirty so + * it can be persisted. + * + * @param state Mutable app state. + * @param displayName Discovered host name, or an empty string to use the default label. + * @param address Discovered IPv4 address. + * @param port Discovered host HTTP port. + * @return true when persisted host metadata changed or a new host was added. + */ + bool merge_discovered_host(ClientState &state, std::string displayName, const std::string &address, uint16_t port); + /** * @brief Replace the in-memory saved-file inventory shown on the settings page. * diff --git a/src/network/host_discovery.cpp b/src/network/host_discovery.cpp new file mode 100644 index 0000000..b75c263 --- /dev/null +++ b/src/network/host_discovery.cpp @@ -0,0 +1,518 @@ +/** + * @file src/network/host_discovery.cpp + * @brief Implements host auto-discovery helpers. + */ +// class header include +#include "src/network/host_discovery.h" + +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// local includes +#include "src/app/host_records.h" +#include "src/logging/logger.h" +#include "src/network/runtime_network.h" + +#ifdef NXDK + // nxdk includes + #include + #include + #include + #include + #include + #include + #include + #include + + // platform includes + #include // NOSONAR(cpp:S3806) nxdk requires lowercase header names + +/** + * @brief nxdk-provided pointer to the active lwIP network interface. + */ +extern "C" struct netif *g_pnetif; // NOSONAR(cpp:S5421) external symbol declared by nxdk; cannot be const +#endif + +namespace { + + constexpr uint32_t DEFAULT_DISCOVERY_TIMEOUT_MILLISECONDS = 1500U; + +#ifdef NXDK + constexpr char kMdnsDiscoveryHostName[] = "moonlight-xbox"; +#endif + + constexpr std::string_view kMdnsDiscoveryMethodName = "mDNS"; + + network::testing::HostDiscoveryTestHandler &host_discovery_test_handler() { + static network::testing::HostDiscoveryTestHandler handler; ///< Optional scripted discovery handler used by host-native unit tests. + return handler; + } + + std::string trim_ascii_whitespace(std::string_view text) { + while (!text.empty() && std::isspace(static_cast(text.front())) != 0) { + text.remove_prefix(1); + } + while (!text.empty() && std::isspace(static_cast(text.back())) != 0) { + text.remove_suffix(1); + } + return std::string(text); + } + + std::string first_dns_label(std::string_view domainText) { + const std::size_t separator = domainText.find('.'); + return trim_ascii_whitespace(domainText.substr(0, separator)); + } + + /** + * @brief Format one host endpoint for discovery diagnostics. + * + * @param address Canonical IPv4 address for the host. + * @param port Host HTTP port, or zero for the default port. + * @return Human-readable endpoint text. + */ + std::string format_discovered_host_endpoint(const std::string &address, uint16_t port) { + const uint16_t effectivePort = app::effective_host_port(port); + return address + ":" + std::to_string(effectivePort); + } + + /** + * @brief Append one discovered host when its endpoint is not already present. + * + * @param result Discovery result to update. + * @param displayName User-facing label for the discovered host. + * @param address Reachable IPv4 address for the host. + * @param port Host HTTP port, or zero for the default Moonlight port. + * @return True when a new host was appended. + */ + bool append_discovered_host(network::DiscoverHostsResult *result, std::string displayName, const std::string &address, uint16_t port) { + if (result == nullptr) { + return false; + } + + const std::string normalizedAddress = app::normalize_ipv4_address(address); + if (normalizedAddress.empty()) { + return false; + } + + const uint16_t storedPort = app::effective_host_port(port) == app::DEFAULT_HOST_PORT ? 0 : port; + if (const auto duplicate = std::find_if(result->hosts.begin(), result->hosts.end(), [&](const network::DiscoveredHost &host) { + return host.address == normalizedAddress && app::effective_host_port(host.port) == app::effective_host_port(storedPort); + }); + duplicate != result->hosts.end()) { + logging::debug( + "hosts", + "Ignoring duplicate host from " + std::string(kMdnsDiscoveryMethodName) + ": " + format_discovered_host_endpoint(normalizedAddress, storedPort) + ); + if (duplicate->displayName.empty() && !displayName.empty()) { + duplicate->displayName = std::move(displayName); + } + return false; + } + + if (displayName.empty()) { + displayName = "Host " + normalizedAddress; + } + + result->hosts.push_back({ + std::move(displayName), + normalizedAddress, + storedPort, + }); + const network::DiscoveredHost &host = result->hosts.back(); + logging::debug( + "hosts", + "Discovered host via " + std::string(kMdnsDiscoveryMethodName) + ": " + host.displayName + " (" + format_discovered_host_endpoint(host.address, host.port) + ')' + ); + return true; + } + + bool discovered_host_less(const network::DiscoveredHost &left, const network::DiscoveredHost &right) { + if (left.displayName != right.displayName) { + return left.displayName < right.displayName; + } + if (left.address != right.address) { + return left.address < right.address; + } + return app::effective_host_port(left.port) < app::effective_host_port(right.port); + } + + void sort_discovered_hosts_in_place(std::vector *hosts) { + if (hosts == nullptr) { + return; + } + + std::sort(hosts->begin(), hosts->end(), discovered_host_less); + } + +#ifdef NXDK + + constexpr char kGameStreamServiceType[] = "_nvstream"; + + /** + * @brief Cached status of the one-time lwIP mDNS bootstrap used by discovery. + */ + enum class MdnsDiscoveryBootstrapState { + uninitialized, + ready, + failed, + }; + + MdnsDiscoveryBootstrapState g_mdnsBootstrapState = MdnsDiscoveryBootstrapState::uninitialized; // NOSONAR(cpp:S5421) one-time NXDK runtime state used to avoid repeated mdns setup attempts + err_t g_mdnsBootstrapError = ERR_OK; // NOSONAR(cpp:S5421) cached lwIP error from the one-time NXDK mDNS bootstrap + + /** + * @brief Discovery-side aggregation of one service instance while mDNS answers arrive. + */ + struct PendingDiscoveredService { + std::string instanceDomain; ///< Full service-instance domain reported by PTR/SRV records. + std::string displayName; ///< Friendly host label derived from the service instance name. + std::string targetDomain; ///< DNS host target reported by the SRV record. + uint16_t port = 0; ///< Host port reported by the SRV record. + }; + + /** + * @brief Thread-safe collector updated by the lwIP mDNS search callback. + */ + struct DiscoveryCollector { + mutable std::mutex mutex; ///< Guards the partial discovery state shared with the callback. + std::unordered_map servicesByInstance; ///< Partial records keyed by instance domain. + std::unordered_map ipv4ByDomain; ///< Resolved IPv4 addresses keyed by target host domain. + }; + + std::string encoded_domain_to_string(const char *encodedDomain, std::size_t encodedLength) { + if (encodedDomain == nullptr || encodedLength == 0U) { + return {}; + } + + std::string domainText; + std::size_t offset = 0U; + while (offset < encodedLength) { + const auto labelLength = static_cast(static_cast(encodedDomain[offset])); + if (labelLength == 0U) { + break; + } + ++offset; + if (labelLength > encodedLength - offset) { + return {}; + } + if (!domainText.empty()) { + domainText.push_back('.'); + } + domainText.append(encodedDomain + offset, labelLength); + offset += labelLength; + } + + return domainText; + } + + std::string mdns_domain_to_string(const mdns_domain &domain) { + return encoded_domain_to_string(reinterpret_cast(domain.name), domain.length); + } + + std::string ipv4_address_from_rdata(const char *rdata, int rdataLength) { + if (rdata == nullptr || rdataLength != 4) { + return {}; + } + + return std::to_string(static_cast(static_cast(rdata[0]))) + '.' + std::to_string(static_cast(static_cast(rdata[1]))) + '.' + + std::to_string(static_cast(static_cast(rdata[2]))) + '.' + std::to_string(static_cast(static_cast(rdata[3]))); + } + + uint16_t read_u16_be(const char *bytes) { + return static_cast((static_cast(static_cast(bytes[0])) << 8U) | static_cast(static_cast(bytes[1]))); + } + + void remember_ptr_service_instance(DiscoveryCollector *collector, std::string instanceDomain) { + if (collector == nullptr || instanceDomain.empty()) { + return; + } + + const std::scoped_lock lock(collector->mutex); + PendingDiscoveredService &service = collector->servicesByInstance[instanceDomain]; + service.instanceDomain = std::move(instanceDomain); + if (service.displayName.empty()) { + service.displayName = first_dns_label(service.instanceDomain); + } + } + + void remember_srv_service_target(DiscoveryCollector *collector, std::string instanceDomain, std::string targetDomain, uint16_t port) { + if (collector == nullptr || instanceDomain.empty() || targetDomain.empty() || port == 0) { + return; + } + + const std::scoped_lock lock(collector->mutex); + PendingDiscoveredService &service = collector->servicesByInstance[instanceDomain]; + service.instanceDomain = std::move(instanceDomain); + if (service.displayName.empty()) { + service.displayName = first_dns_label(service.instanceDomain); + } + service.targetDomain = std::move(targetDomain); + service.port = port; + } + + void remember_a_record(DiscoveryCollector *collector, std::string domain, std::string ipv4Address) { + if (collector == nullptr || domain.empty() || ipv4Address.empty()) { + return; + } + + const std::scoped_lock lock(collector->mutex); + collector->ipv4ByDomain[std::move(domain)] = std::move(ipv4Address); + } + + /** + * @brief Consume one lwIP mDNS answer and collect enough data to resolve hosts. + * + * @param answer Parsed mDNS answer supplied by lwIP. + * @param varpart Variable-length answer payload supplied by lwIP. + * @param varlen Length of @p varpart in bytes. + * @param flags Frame-boundary flags supplied by lwIP. + * @param arg Pointer to the shared discovery collector. + */ + void collect_mdns_search_result(struct mdns_answer *answer, const char *varpart, int varlen, int flags, void *arg) { // NOSONAR(cpp:S5008) lwIP callback signature is fixed + (void) flags; + auto *collector = static_cast(arg); + if (collector == nullptr || answer == nullptr) { + return; + } + + switch (answer->info.type) { + case DNS_RRTYPE_PTR: + { + const std::string instanceDomain = encoded_domain_to_string(varpart, static_cast(std::max(varlen, 0))); + remember_ptr_service_instance(collector, instanceDomain); + return; + } + case DNS_RRTYPE_SRV: + { + if (varpart == nullptr || varlen < 7) { + return; + } + const std::string instanceDomain = mdns_domain_to_string(answer->info.domain); + const std::string targetDomain = encoded_domain_to_string(varpart + 6, static_cast(varlen - 6)); + remember_srv_service_target(collector, instanceDomain, targetDomain, read_u16_be(varpart + 4)); + return; + } + case DNS_RRTYPE_A: + { + const std::string domain = mdns_domain_to_string(answer->info.domain); + const std::string ipv4Address = ipv4_address_from_rdata(varpart, varlen); + remember_a_record(collector, domain, ipv4Address); + return; + } + default: + return; + } + } + + network::DiscoverHostsResult build_discover_hosts_result(const DiscoveryCollector &collector) { + network::DiscoverHostsResult result {}; + + const std::scoped_lock lock(collector.mutex); + for (const auto &[instanceDomain, service] : collector.servicesByInstance) { + (void) instanceDomain; + if (service.targetDomain.empty() || service.port == 0) { + continue; + } + const auto addressIterator = collector.ipv4ByDomain.find(service.targetDomain); + if (addressIterator == collector.ipv4ByDomain.end()) { + continue; + } + + append_discovered_host( + &result, + service.displayName.empty() ? first_dns_label(service.targetDomain) : service.displayName, + addressIterator->second, + service.port + ); + } + + sort_discovered_hosts_in_place(&result.hosts); + return result; + } + + /** + * @brief Ensure the active lwIP interface exposes the multicast features mDNS needs. + * + * nxdk's netif is sufficient for normal IPv4 unicast traffic, but discovery + * also needs the Ethernet and IGMP capability bits that lwIP checks before + * joining the IPv4 mDNS multicast group on the original Xbox runtime. + * + * @param netif Active lwIP network interface used by discovery. + * @return lwIP status for the multicast-capability preparation. + */ + err_t prepare_netif_for_mdns_discovery(struct netif *netif) { + if (netif == nullptr) { + return ERR_ARG; + } + + const std::byte multicastFlags = static_cast(NETIF_FLAG_ETHERNET) | static_cast(NETIF_FLAG_IGMP); + const std::byte preparedFlags = static_cast(netif->flags) | multicastFlags; + netif->flags = static_castflags)>(std::to_integer(preparedFlags)); + if (netif_igmp_data(netif) == nullptr) { + const err_t igmpStartResult = igmp_start(netif); + if (igmpStartResult != ERR_OK) { + return igmpStartResult; + } + } + return ERR_OK; + } + + /** + * @brief Initialize lwIP mDNS once before issuing discovery queries. + * + * lwIP search requests use a dedicated mDNS UDP PCB that is created by + * `mdns_resp_init()`. Without that one-time initialization, discovery sends + * queries through a null PCB and lwIP emits `udp_sendto_if: invalid pcb`. + * The receive path also expects the active netif to be registered with mDNS, + * so the helper adds the runtime interface once using a stable local host + * name before the first search starts. + * + * @param netif Active lwIP network interface that should participate in mDNS. + * @return lwIP status for the bootstrap attempt. + * This helper must run while the TCP/IP core lock is held. + */ + err_t ensure_mdns_initialized_for_discovery(struct netif *netif) { + if (g_mdnsBootstrapState == MdnsDiscoveryBootstrapState::ready) { + return ERR_OK; + } + if (netif == nullptr) { + g_mdnsBootstrapError = ERR_ARG; + return g_mdnsBootstrapError; + } + if (g_mdnsBootstrapState == MdnsDiscoveryBootstrapState::failed) { + return g_mdnsBootstrapError; + } + + g_mdnsBootstrapError = prepare_netif_for_mdns_discovery(netif); + if (g_mdnsBootstrapError != ERR_OK) { + g_mdnsBootstrapState = MdnsDiscoveryBootstrapState::failed; + return g_mdnsBootstrapError; + } + mdns_resp_init(); + g_mdnsBootstrapError = mdns_resp_add_netif(netif, kMdnsDiscoveryHostName); + if (g_mdnsBootstrapError != ERR_OK) { + g_mdnsBootstrapState = MdnsDiscoveryBootstrapState::failed; + return g_mdnsBootstrapError; + } + + g_mdnsBootstrapState = MdnsDiscoveryBootstrapState::ready; + g_mdnsBootstrapError = ERR_OK; + return ERR_OK; + } + +#endif + +} // namespace + +namespace network { + + DiscoverHostsResult discover_hosts(uint32_t timeoutMilliseconds) { + if (timeoutMilliseconds == 0U) { + timeoutMilliseconds = DEFAULT_DISCOVERY_TIMEOUT_MILLISECONDS; + } + + if (const testing::HostDiscoveryTestHandler &handler = host_discovery_test_handler(); handler) { + return handler(timeoutMilliseconds); + } + +#ifdef NXDK + DiscoverHostsResult result {}; + if (!runtime_network_ready()) { + result.errorMessage = runtime_network_status().summary; + return result; + } + if (g_pnetif == nullptr) { + result.errorMessage = "nxdk networking initialized without publishing an lwIP network interface"; + return result; + } + + DiscoveryCollector collector {}; + u8_t requestId = 0; + LOCK_TCPIP_CORE(); + if (const err_t mdnsBootstrapResult = ensure_mdns_initialized_for_discovery(g_pnetif); mdnsBootstrapResult != ERR_OK) { + UNLOCK_TCPIP_CORE(); + result.errorMessage = "Failed to initialize host auto discovery mDNS support (lwIP error " + std::to_string(mdnsBootstrapResult) + ")"; + return result; + } + const err_t searchResult = mdns_search_service(nullptr, kGameStreamServiceType, DNSSD_PROTO_TCP, g_pnetif, collect_mdns_search_result, &collector, &requestId); + UNLOCK_TCPIP_CORE(); + if (searchResult != ERR_OK) { + result.errorMessage = "Failed to start host auto discovery (lwIP error " + std::to_string(searchResult) + ")"; + return result; + } + + Sleep(timeoutMilliseconds); + LOCK_TCPIP_CORE(); + mdns_search_stop(requestId); + UNLOCK_TCPIP_CORE(); + + return build_discover_hosts_result(collector); +#else + (void) timeoutMilliseconds; + return {}; +#endif + } + + namespace testing { + + void set_host_discovery_test_handler(HostDiscoveryTestHandler handler) { + host_discovery_test_handler() = std::move(handler); + } + + void clear_host_discovery_test_handler() { + host_discovery_test_handler() = {}; + } + + /** + * @brief Expose the internal whitespace trimmer to host-native unit tests. + * + * @param text Raw discovery text. + * @return Discovery text with surrounding ASCII whitespace removed. + */ + std::string trim_host_discovery_text(std::string_view text) { + return trim_ascii_whitespace(text); + } + + /** + * @brief Expose the internal DNS-label parser to host-native unit tests. + * + * @param domainText Service instance or host domain text. + * @return The first DNS label after discovery-style trimming. + */ + std::string first_host_discovery_label(std::string_view domainText) { + return first_dns_label(domainText); + } + + /** + * @brief Expose host normalization and deduplication to host-native unit tests. + * + * @param result Discovery result to update. + * @param displayName Candidate user-facing label. + * @param address Candidate IPv4 address. + * @param port Candidate HTTP port, or zero for the default port. + * @return True when a new host was appended. + */ + bool append_mdns_discovered_host(DiscoverHostsResult *result, std::string displayName, const std::string &address, uint16_t port) { + return append_discovered_host(result, std::move(displayName), address, port); + } + + /** + * @brief Expose the runtime host ordering rules to host-native unit tests. + * + * @param hosts Hosts to sort in place. + */ + void sort_discovered_hosts(std::vector *hosts) { + sort_discovered_hosts_in_place(hosts); + } + + } // namespace testing + +} // namespace network diff --git a/src/network/host_discovery.h b/src/network/host_discovery.h new file mode 100644 index 0000000..5036d66 --- /dev/null +++ b/src/network/host_discovery.h @@ -0,0 +1,65 @@ +/** + * @file src/network/host_discovery.h + * @brief Declares host auto-discovery helpers. + */ +#pragma once + +// standard includes +#include +#include +#include +#include + +namespace network { + + /** + * @brief One Moonlight-compatible host discovered through mDNS/DNS-SD. + */ + struct DiscoveredHost { + std::string displayName; ///< User-facing host name discovered from the service instance. + std::string address; ///< Canonical IPv4 address resolved for the host. + uint16_t port = 0; ///< Host HTTP port advertised by the discovered service. + }; + + /** + * @brief Result of one host auto-discovery sweep. + */ + struct DiscoverHostsResult { + std::vector hosts; ///< Hosts discovered during the sweep. + std::string errorMessage; ///< Non-empty when discovery could not run successfully. + }; + + /** + * @brief Discover Moonlight-compatible hosts on the local network. + * + * The Xbox build uses lwIP mDNS/DNS-SD search support to look for compatible + * GameStream services. Host-native test builds return scripted results when a + * test handler is installed, or an empty successful result otherwise. + * + * @param timeoutMilliseconds Maximum discovery window in milliseconds. + * @return Discovered hosts plus an optional error message. + */ + DiscoverHostsResult discover_hosts(uint32_t timeoutMilliseconds); + + namespace testing { + + /** + * @brief Callback used by unit tests to replace runtime host discovery. + */ + using HostDiscoveryTestHandler = std::function; + + /** + * @brief Install a scripted host-discovery handler for unit tests. + * + * @param handler Callback that should service subsequent discovery requests. + */ + void set_host_discovery_test_handler(HostDiscoveryTestHandler handler); + + /** + * @brief Remove any scripted host-discovery handler installed for tests. + */ + void clear_host_discovery_test_handler(); + + } // namespace testing + +} // namespace network diff --git a/src/ui/shell_screen.cpp b/src/ui/shell_screen.cpp index e45fcf6..1115a43 100644 --- a/src/ui/shell_screen.cpp +++ b/src/ui/shell_screen.cpp @@ -32,6 +32,7 @@ #include "src/input/navigation_input.h" #include "src/logging/log_file.h" #include "src/logging/logger.h" +#include "src/network/host_discovery.h" #include "src/network/host_pairing.h" #include "src/network/runtime_network.h" #include "src/os.h" @@ -48,6 +49,8 @@ namespace { constexpr std::size_t PAIRING_THREAD_STACK_SIZE = 1024U * 1024U; + constexpr Uint32 HOST_DISCOVERY_REFRESH_INTERVAL_MILLISECONDS = 10000U; + constexpr uint32_t HOST_DISCOVERY_TIMEOUT_MILLISECONDS = 1500U; constexpr Uint32 HOST_PROBE_REFRESH_INTERVAL_MILLISECONDS = 10000U; constexpr Uint32 APP_LIST_REFRESH_INTERVAL_MILLISECONDS = 30000U; @@ -2497,6 +2500,13 @@ namespace { std::size_t failureCount = 0; }; + struct HostDiscoveryTaskState { + SDL_Thread *thread = nullptr; + std::atomic completed = false; + std::vector hosts; + std::string errorMessage; + }; + struct HostProbeTaskState { struct ProbeWorkerState { SDL_Thread *thread = nullptr; @@ -2658,6 +2668,21 @@ namespace { return task.thread != nullptr && !task.completed.load(); } + void reset_host_discovery_task(HostDiscoveryTaskState *task) { + if (task == nullptr) { + return; + } + + task->thread = nullptr; + task->completed.store(false); + task->hosts.clear(); + task->errorMessage.clear(); + } + + bool host_discovery_task_is_active(const HostDiscoveryTaskState &task) { + return task.thread != nullptr && !task.completed.load(); + } + void reset_host_probe_task(HostProbeTaskState *task) { if (task == nullptr) { return; @@ -2867,6 +2892,62 @@ namespace { return 0; } + int run_host_discovery_task(void *context) { // NOSONAR(cpp:S5008) SDL_CreateThread requires void* callback signature + auto *task = static_cast(context); + if (task == nullptr) { + return -1; + } + + const network::DiscoverHostsResult result = network::discover_hosts(HOST_DISCOVERY_TIMEOUT_MILLISECONDS); + task->hosts = result.hosts; + task->errorMessage = result.errorMessage; + task->completed.store(true); + return 0; + } + + void finish_host_discovery_task_if_ready(app::ClientState &state, HostDiscoveryTaskState *task, Uint32 *nextHostProbeTick) { + if (task == nullptr || task->thread == nullptr || !task->completed.load()) { + return; + } + + SDL_Thread *thread = task->thread; + task->thread = nullptr; + int threadResult = 0; + SDL_WaitThread(thread, &threadResult); + (void) threadResult; + + std::vector discoveredHosts = std::move(task->hosts); + const std::string errorMessage = task->errorMessage; + reset_host_discovery_task(task); + + if (state.shell.activeScreen != app::ScreenId::hosts || !state.hosts.loaded) { + return; + } + if (!errorMessage.empty()) { + logging::warn("hosts", errorMessage); + return; + } + + std::size_t persistedHostCount = 0U; + for (const network::DiscoveredHost &host : discoveredHosts) { + if (app::merge_discovered_host(state, host.displayName, host.address, host.port)) { + ++persistedHostCount; + } + } + + if (!discoveredHosts.empty()) { + logging::debug("hosts", "Auto discovery found " + std::to_string(discoveredHosts.size()) + " Moonlight-compatible host(s)"); + if (nextHostProbeTick != nullptr) { + *nextHostProbeTick = 0U; + } + } else { + logging::info("hosts", "Auto discovery completed without finding any Moonlight-compatible hosts. If you are using xemu's default user-mode network, use TAP networking or add the host manually."); + } + if (persistedHostCount > 0U && state.hosts.dirty) { + persist_hosts(state); + } + } + void apply_published_host_probe_results(app::ClientState &state, HostProbeTaskState *task) { if (task == nullptr) { return; @@ -2967,6 +3048,31 @@ namespace { } } + void start_host_discovery_task_if_needed(const app::ClientState &state, HostDiscoveryTaskState *task, Uint32 now, Uint32 *nextHostDiscoveryTick) { + if (task == nullptr || host_discovery_task_is_active(*task) || state.shell.activeScreen != app::ScreenId::hosts || !state.hosts.loaded || !network::runtime_network_ready()) { + return; + } + if (nextHostDiscoveryTick != nullptr && *nextHostDiscoveryTick != 0U && now < *nextHostDiscoveryTick) { + return; + } + + reset_host_discovery_task(task); + logging::debug("hosts", "Starting host auto discovery"); + task->thread = SDL_CreateThread(run_host_discovery_task, "discover-hosts", task); + if (task->thread == nullptr) { + logging::error("hosts", std::string("Failed to start the host auto-discovery task: ") + SDL_GetError()); + reset_host_discovery_task(task); + if (nextHostDiscoveryTick != nullptr) { + *nextHostDiscoveryTick = now + HOST_DISCOVERY_REFRESH_INTERVAL_MILLISECONDS; + } + return; + } + + if (nextHostDiscoveryTick != nullptr) { + *nextHostDiscoveryTick = now + HOST_DISCOVERY_REFRESH_INTERVAL_MILLISECONDS; + } + } + void pair_host_if_requested(app::ClientState &state, const app::AppUpdate &update, PairingTaskState *task) { if (!update.requests.pairingRequested || task == nullptr) { return; @@ -4187,10 +4293,12 @@ namespace { bool running = true; ///< False when the shell should stop processing frames. bool keypadRedrawRequested = true; ///< True when the add-host keypad must redraw immediately. ShellInputState inputState {}; ///< Current controller and trigger input state. + Uint32 nextHostDiscoveryTick = 0; ///< Next scheduled host auto-discovery time. Uint32 nextHostProbeTick = 0; ///< Next scheduled host probe time. PairingTaskState pairingTask {}; ///< Background pairing workflow state. AppListTaskState appListTask {}; ///< Background app-list fetch state. AppArtTaskState appArtTask {}; ///< Background box-art download state. + HostDiscoveryTaskState hostDiscoveryTask {}; ///< Background host auto-discovery state. HostProbeTaskState hostProbeTask {}; ///< Background host probe state. }; @@ -4351,6 +4459,7 @@ namespace { reset_pairing_task(&runtime->pairingTask); reset_app_list_task(&runtime->appListTask); reset_app_art_task(&runtime->appArtTask); + reset_host_discovery_task(&runtime->hostDiscoveryTask); reset_host_probe_task(&runtime->hostProbeTask); logging::set_minimum_level(logging::LogLevel::trace); logging::set_file_minimum_level(state.settings.loggingLevel); @@ -4400,6 +4509,7 @@ namespace { finish_pairing_task_if_ready(state, &runtime->pairingTask); finish_app_list_task_if_ready(state, &runtime->appListTask); finish_app_art_task_if_ready(state, &runtime->appArtTask, &resources->coverArtTextureCache); + finish_host_discovery_task_if_ready(state, &runtime->hostDiscoveryTask, &runtime->nextHostProbeTick); finish_host_probe_task_if_ready(state, &runtime->hostProbeTask); } @@ -4415,6 +4525,7 @@ namespace { return; } + start_host_discovery_task_if_needed(state, &runtime->hostDiscoveryTask, now, &runtime->nextHostDiscoveryTick); start_host_probe_task_if_needed(state, &runtime->hostProbeTask, now, &runtime->nextHostProbeTick); start_app_list_task_if_needed(state, &runtime->appListTask, now); start_app_art_task_if_needed(state, &runtime->appArtTask); @@ -4537,6 +4648,7 @@ namespace { wait_for_thread(runtime->appListTask.thread); wait_for_thread(runtime->appArtTask.thread); + wait_for_thread(runtime->hostDiscoveryTask.thread); for (const std::unique_ptr &worker : runtime->hostProbeTask.workers) { if (worker == nullptr) { continue; diff --git a/src/ui/shell_view.cpp b/src/ui/shell_view.cpp index c71e379..e5cfb34 100644 --- a/src/ui/shell_view.cpp +++ b/src/ui/shell_view.cpp @@ -229,8 +229,8 @@ namespace { if (state.hosts.items.empty()) { return { "No PCs have been added yet.", - "Use Add Host to save a host manually.", - "A Moonlight-style discovery grid now owns the home screen.", + "Moonlight-compatible PCs discovered over the local network will appear here automatically.", + "Use Add Host to save a host manually when auto discovery is unavailable.", }; } diff --git a/tests/unit/app/client_state_test.cpp b/tests/unit/app/client_state_test.cpp index e60544d..afe3c56 100644 --- a/tests/unit/app/client_state_test.cpp +++ b/tests/unit/app/client_state_test.cpp @@ -42,6 +42,38 @@ namespace { EXPECT_EQ(state.hosts.selectedHostIndex, 0U); } + TEST(ClientStateTest, MergeDiscoveredHostAddsANewReachableHostToTheGrid) { + app::ClientState state = app::create_initial_state(); + + EXPECT_TRUE(app::merge_discovered_host(state, "Living Room PC", test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], 0)); + + ASSERT_EQ(state.hosts.size(), 1U); + EXPECT_TRUE(state.hosts.dirty); + EXPECT_EQ(state.hosts.front().displayName, "Living Room PC"); + EXPECT_EQ(state.hosts.front().address, test_support::kTestIpv4Addresses[test_support::kIpLivingRoom]); + EXPECT_EQ(state.hosts.front().reachability, app::HostReachability::online); + EXPECT_EQ(state.hosts.focusArea, app::HostsFocusArea::grid); + EXPECT_EQ(state.hosts.selectedHostIndex, 0U); + } + + TEST(ClientStateTest, MergeDiscoveredHostPrefersDiscoveredNamesOnlyForDefaultNamedHosts) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Host 10.0.0.25", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], app::PairingState::not_paired, app::HostReachability::unknown}, + }); + + EXPECT_TRUE(app::merge_discovered_host(state, "Office PC", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost])); + ASSERT_EQ(state.hosts.size(), 1U); + EXPECT_EQ(state.hosts.front().displayName, "Office PC"); + EXPECT_EQ(state.hosts.front().reachability, app::HostReachability::online); + + state.hosts.dirty = false; + state.hosts.front().displayName = "Custom Office"; + EXPECT_FALSE(app::merge_discovered_host(state, "Discovered Office", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost])); + EXPECT_EQ(state.hosts.front().displayName, "Custom Office"); + EXPECT_FALSE(state.hosts.dirty); + } + TEST(ClientStateTest, HostsToolbarCanOpenSettingsAndAddHost) { app::ClientState state = app::create_initial_state(); @@ -199,6 +231,21 @@ namespace { EXPECT_TRUE(state.hosts.activeLoaded); } + TEST(ClientStateTest, SelectingAnUnpairedHostPrefersTheResolvedHttpPortForPairing) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Living Room PC", test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], 0, app::PairingState::not_paired}, + }); + state.hosts.front().resolvedHttpPort = test_support::kTestPorts[test_support::kPortResolvedHttp]; + + app::handle_command(state, input::UiCommand::move_down); + const app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); + + EXPECT_TRUE(update.requests.pairingRequested); + EXPECT_EQ(update.requests.pairingPort, test_support::kTestPorts[test_support::kPortResolvedHttp]); + EXPECT_EQ(state.pairingDraft.targetPort, test_support::kTestPorts[test_support::kPortResolvedHttp]); + } + TEST(ClientStateTest, SelectingAnOfflineUnpairedHostDoesNotOpenThePairingScreen) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { diff --git a/tests/unit/network/host_discovery_test.cpp b/tests/unit/network/host_discovery_test.cpp new file mode 100644 index 0000000..def13cd --- /dev/null +++ b/tests/unit/network/host_discovery_test.cpp @@ -0,0 +1,165 @@ +/** + * @file tests/unit/network/host_discovery_test.cpp + * @brief Verifies host auto-discovery helpers. + */ +// test header include +#include "src/network/host_discovery.h" + +// standard includes +#include + +// lib includes +#include + +// test includes +#include "tests/support/network_test_constants.h" + +namespace network::testing { + + std::string trim_host_discovery_text(std::string_view text); + std::string first_host_discovery_label(std::string_view domainText); + bool append_mdns_discovered_host(::network::DiscoverHostsResult *result, std::string displayName, const std::string &address, uint16_t port); + void sort_discovered_hosts(std::vector<::network::DiscoveredHost> *hosts); + +} // namespace network::testing + +namespace { + + namespace host_discovery_testing = network::testing; + + class ScopedHostDiscoveryTestHandler { + public: + explicit ScopedHostDiscoveryTestHandler(network::testing::HostDiscoveryTestHandler handler) { + network::testing::set_host_discovery_test_handler(std::move(handler)); + } + + ~ScopedHostDiscoveryTestHandler() { + network::testing::clear_host_discovery_test_handler(); + } + + ScopedHostDiscoveryTestHandler(const ScopedHostDiscoveryTestHandler &) = delete; + ScopedHostDiscoveryTestHandler &operator=(const ScopedHostDiscoveryTestHandler &) = delete; + }; + + TEST(HostDiscoveryTest, ReturnsAnEmptySuccessfulResultWithoutATestHandler) { + const network::DiscoverHostsResult result = network::discover_hosts(250U); + + EXPECT_TRUE(result.hosts.empty()); + EXPECT_TRUE(result.errorMessage.empty()); + } + + TEST(HostDiscoveryTest, UsesTheScriptedTestHandlerWhenInstalled) { + ScopedHostDiscoveryTestHandler guard([](uint32_t timeoutMilliseconds) { + EXPECT_EQ(timeoutMilliseconds, 750U); + return network::DiscoverHostsResult { + { + {"Living Room PC", test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], 0}, + {"Office PC", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost]}, + }, + {}, + }; + }); + + const network::DiscoverHostsResult result = network::discover_hosts(750U); + + ASSERT_EQ(result.hosts.size(), 2U); + EXPECT_EQ(result.hosts[0].displayName, "Living Room PC"); + EXPECT_EQ(result.hosts[0].address, test_support::kTestIpv4Addresses[test_support::kIpLivingRoom]); + EXPECT_EQ(result.hosts[1].displayName, "Office PC"); + EXPECT_EQ(result.hosts[1].address, test_support::kTestIpv4Addresses[test_support::kIpOffice]); + EXPECT_EQ(result.hosts[1].port, test_support::kTestPorts[test_support::kPortDefaultHost]); + EXPECT_TRUE(result.errorMessage.empty()); + } + + TEST(HostDiscoveryTest, UsesTheDefaultTimeoutWhenTheCallerPassesZero) { + ScopedHostDiscoveryTestHandler guard([](uint32_t timeoutMilliseconds) { + EXPECT_EQ(timeoutMilliseconds, 1500U); + return network::DiscoverHostsResult {}; + }); + + const network::DiscoverHostsResult result = network::discover_hosts(0U); + + EXPECT_TRUE(result.hosts.empty()); + EXPECT_TRUE(result.errorMessage.empty()); + } + + TEST(HostDiscoveryTest, TrimHostDiscoveryTextRemovesAsciiWhitespace) { + EXPECT_EQ(host_discovery_testing::trim_host_discovery_text(" \tLiving Room PC\r\n"), "Living Room PC"); + EXPECT_TRUE(host_discovery_testing::trim_host_discovery_text("\t \r\n").empty()); + } + + TEST(HostDiscoveryTest, FirstHostDiscoveryLabelReturnsTheTrimmedLeadingDnsLabel) { + EXPECT_EQ(host_discovery_testing::first_host_discovery_label(" Office-PC._nvstream._tcp.local "), "Office-PC"); + EXPECT_EQ(host_discovery_testing::first_host_discovery_label(" no-dot-label "), "no-dot-label"); + } + + TEST(HostDiscoveryTest, AppendMdnsDiscoveredHostRejectsNullResultsAndInvalidAddresses) { + EXPECT_FALSE(host_discovery_testing::append_mdns_discovered_host(nullptr, "Office PC", test_support::kTestIpv4Addresses[test_support::kIpOffice], 0)); + + network::DiscoverHostsResult result {}; + EXPECT_FALSE(host_discovery_testing::append_mdns_discovered_host(&result, "Office PC", "not-an-ip", 0)); + EXPECT_TRUE(result.hosts.empty()); + } + + TEST(HostDiscoveryTest, AppendMdnsDiscoveredHostNormalizesDefaultPortsAndAddsFallbackNames) { + network::DiscoverHostsResult result {}; + + EXPECT_TRUE(host_discovery_testing::append_mdns_discovered_host(&result, {}, test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], test_support::kTestPorts[test_support::kPortResolvedHttp])); + + ASSERT_EQ(result.hosts.size(), 1U); + EXPECT_EQ(result.hosts.front().displayName, std::string("Host ") + test_support::kTestIpv4Addresses[test_support::kIpLivingRoom]); + EXPECT_EQ(result.hosts.front().address, test_support::kTestIpv4Addresses[test_support::kIpLivingRoom]); + EXPECT_EQ(result.hosts.front().port, 0U); + } + + TEST(HostDiscoveryTest, AppendMdnsDiscoveredHostTreatsDefaultAndExplicitDefaultPortsAsDuplicates) { + network::DiscoverHostsResult result { + { + {"", test_support::kTestIpv4Addresses[test_support::kIpOffice], 0}, + }, + {}, + }; + + EXPECT_FALSE(host_discovery_testing::append_mdns_discovered_host(&result, "Office PC", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortResolvedHttp])); + + ASSERT_EQ(result.hosts.size(), 1U); + EXPECT_EQ(result.hosts.front().displayName, "Office PC"); + EXPECT_EQ(result.hosts.front().port, 0U); + } + + TEST(HostDiscoveryTest, AppendMdnsDiscoveredHostKeepsDistinctCustomPorts) { + network::DiscoverHostsResult result {}; + + EXPECT_TRUE(host_discovery_testing::append_mdns_discovered_host(&result, "Office PC", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortPairing])); + EXPECT_TRUE(host_discovery_testing::append_mdns_discovered_host(&result, "Office PC HTTPS", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortResolvedHttps])); + + ASSERT_EQ(result.hosts.size(), 2U); + EXPECT_EQ(result.hosts[0].port, test_support::kTestPorts[test_support::kPortPairing]); + EXPECT_EQ(result.hosts[1].port, test_support::kTestPorts[test_support::kPortResolvedHttps]); + } + + TEST(HostDiscoveryTest, SortDiscoveredHostsUsesTheRuntimeOrderingRules) { + std::vector hosts { + {"Office PC", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortResolvedHttps]}, + {"Living Room PC", test_support::kTestIpv4Addresses[test_support::kIpLivingRoomNeighbor], 0}, + {"Office PC", test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], test_support::kTestPorts[test_support::kPortPairing]}, + {"Office PC", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortPairing]}, + }; + + host_discovery_testing::sort_discovered_hosts(&hosts); + + ASSERT_EQ(hosts.size(), 4U); + EXPECT_EQ(hosts[0].displayName, "Living Room PC"); + EXPECT_EQ(hosts[1].address, test_support::kTestIpv4Addresses[test_support::kIpOffice]); + EXPECT_EQ(hosts[1].port, test_support::kTestPorts[test_support::kPortPairing]); + EXPECT_EQ(hosts[2].address, test_support::kTestIpv4Addresses[test_support::kIpOffice]); + EXPECT_EQ(hosts[2].port, test_support::kTestPorts[test_support::kPortResolvedHttps]); + EXPECT_EQ(hosts[3].address, test_support::kTestIpv4Addresses[test_support::kIpLivingRoom]); + EXPECT_EQ(hosts[3].port, test_support::kTestPorts[test_support::kPortPairing]); + } + + TEST(HostDiscoveryTest, SortDiscoveredHostsAcceptsANullListPointer) { + host_discovery_testing::sort_discovered_hosts(nullptr); + } + +} // namespace diff --git a/tests/unit/ui/shell_view_test.cpp b/tests/unit/ui/shell_view_test.cpp index d2296d0..39d171e 100644 --- a/tests/unit/ui/shell_view_test.cpp +++ b/tests/unit/ui/shell_view_test.cpp @@ -30,6 +30,8 @@ namespace { EXPECT_EQ(viewModel.content.toolbarButtons[2].iconAssetPath, "icons\\add-host.svg"); ASSERT_FALSE(viewModel.content.bodyLines.empty()); EXPECT_EQ(viewModel.content.bodyLines.front(), "No PCs have been added yet."); + ASSERT_GE(viewModel.content.bodyLines.size(), 3U); + EXPECT_EQ(viewModel.content.bodyLines[1], "Moonlight-compatible PCs discovered over the local network will appear here automatically."); ASSERT_EQ(viewModel.frame.footerActions.size(), 2U); EXPECT_EQ(viewModel.frame.footerActions[0].label, "Select"); EXPECT_EQ(viewModel.frame.footerActions[0].iconAssetPath, "icons\\button-a.svg"); diff --git a/third-party/nxdk b/third-party/nxdk index acd2f9b..e7cc20b 160000 --- a/third-party/nxdk +++ b/third-party/nxdk @@ -1 +1 @@ -Subproject commit acd2f9bf2c04486501a2392153da1e1d650d4935 +Subproject commit e7cc20be2e9f6f87fda06655e752ef62afa92313