From ae501e256ea462268dbae8109ad07ef457c4fd8e Mon Sep 17 00:00:00 2001 From: Maufeat Date: Sat, 17 Jan 2026 01:48:15 +0100 Subject: [PATCH] [bcat/news/web/am] Implement news applet, proper TLV return, external web browser URL and qlaunch app sorting (#3308) This pulls eden releases changelog & text from our github releases. We don't store the msgpack file but rather generate them in-memory for the News Applet. Uses cache folder. Files generated are: - cache/news/github_releases.json - cache/news/eden_logo.jpg - cache/news/news_read Additional changes: - Proper TLV returning for online web applet, to open external URL - Add applet type `LHub` to properly close, as it also uses TLV return - qlaunch app sorting, adds another cached .json to track last launched app timestamps and sort them accordingly Co-authored-by: crueter Co-authored-by: DraVee Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/3308 Reviewed-by: MaranBr Reviewed-by: DraVee Reviewed-by: Lizzie Co-authored-by: Maufeat Co-committed-by: Maufeat --- CMakeModules/Findzstd.cmake | 6 +- .../java/org/yuzu/yuzu_emu/NativeLibrary.kt | 12 + .../yuzu/yuzu_emu/applets/web/WebBrowser.kt | 35 + src/android/app/src/main/jni/native.cpp | 4 +- src/common/CMakeLists.txt | 411 ++++---- src/common/android/applets/web_browser.cpp | 50 + src/common/android/applets/web_browser.h | 30 + src/common/android/id_cache.cpp | 3 + src/core/CMakeLists.txt | 32 +- src/core/core.cpp | 5 + src/core/hle/service/am/am_types.h | 1 + .../am/frontend/applet_web_browser.cpp | 130 ++- .../service/am/frontend/applet_web_browser.h | 5 + .../am/frontend/applet_web_browser_types.h | 4 + src/core/hle/service/am/frontend/applets.cpp | 4 +- .../am/service/application_creator.cpp | 3 + .../hle/service/bcat/news/builtin_news.cpp | 555 +++++++++++ src/core/hle/service/bcat/news/builtin_news.h | 27 + src/core/hle/service/bcat/news/msgpack.cpp | 886 ++++++++++++++++++ src/core/hle/service/bcat/news/msgpack.h | 149 +++ .../service/bcat/news/news_data_service.cpp | 116 ++- .../hle/service/bcat/news/news_data_service.h | 17 + .../bcat/news/news_database_service.cpp | 188 +++- .../service/bcat/news/news_database_service.h | 28 +- .../hle/service/bcat/news/news_service.cpp | 72 +- src/core/hle/service/bcat/news/news_service.h | 13 + .../hle/service/bcat/news/news_storage.cpp | 169 ++++ src/core/hle/service/bcat/news/news_storage.h | 100 ++ .../ns/application_manager_interface.cpp | 51 +- src/core/hle/service/ns/ns_types.h | 9 +- src/core/launch_timestamp_cache.cpp | 135 +++ src/core/launch_timestamp_cache.h | 13 + src/frontend_common/CMakeLists.txt | 6 +- src/yuzu/applets/qt_web_browser.cpp | 19 +- src/yuzu/game_list_worker.cpp | 5 + 35 files changed, 2978 insertions(+), 315 deletions(-) create mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/web/WebBrowser.kt create mode 100644 src/common/android/applets/web_browser.cpp create mode 100644 src/common/android/applets/web_browser.h create mode 100644 src/core/hle/service/bcat/news/builtin_news.cpp create mode 100644 src/core/hle/service/bcat/news/builtin_news.h create mode 100644 src/core/hle/service/bcat/news/msgpack.cpp create mode 100644 src/core/hle/service/bcat/news/msgpack.h create mode 100644 src/core/hle/service/bcat/news/news_storage.cpp create mode 100644 src/core/hle/service/bcat/news/news_storage.h create mode 100644 src/core/launch_timestamp_cache.cpp create mode 100644 src/core/launch_timestamp_cache.h diff --git a/CMakeModules/Findzstd.cmake b/CMakeModules/Findzstd.cmake index 2afdb56a8c..8e44b7a892 100644 --- a/CMakeModules/Findzstd.cmake +++ b/CMakeModules/Findzstd.cmake @@ -14,8 +14,7 @@ else() pkg_search_module(ZSTD QUIET IMPORTED_TARGET libzstd) find_package_handle_standard_args(zstd REQUIRED_VARS ZSTD_LINK_LIBRARIES - VERSION_VAR ZSTD_VERSION - ) + VERSION_VAR ZSTD_VERSION) endif() if (zstd_FOUND AND NOT TARGET zstd::zstd) @@ -36,4 +35,7 @@ if (NOT TARGET zstd::libzstd) else() add_library(zstd::libzstd ALIAS zstd::zstd) endif() +elseif(YUZU_STATIC_BUILD AND TARGET zstd::libzstd_static) + # zstd::libzstd links to shared zstd by default + set_target_properties(zstd::libzstd PROPERTIES INTERFACE_LINK_LIBRARIES zstd::libzstd_static) endif() diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt index 65d13ba2ba..8919f25fd6 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt @@ -26,6 +26,7 @@ import org.yuzu.yuzu_emu.model.InstallResult import org.yuzu.yuzu_emu.model.Patch import org.yuzu.yuzu_emu.model.GameVerificationResult import org.yuzu.yuzu_emu.network.NetPlayManager +import org.yuzu.yuzu_emu.applets.web.WebBrowser /** * Class which contains methods that interact @@ -457,6 +458,17 @@ object NativeLibrary { */ external fun setCurrentAppletId(appletId: Int) + /** + * Launch external URL when Web applet and opens browser + * + * @param String URL + */ + @Keep + @JvmStatic + fun openExternalUrl(url: String) { + WebBrowser.openExternal(url) + } + /** * Sets the cabinet mode for launching the cabinet applet. * diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/web/WebBrowser.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/web/WebBrowser.kt new file mode 100644 index 0000000000..898c88f4ac --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/web/WebBrowser.kt @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.applets.web + +import android.content.Intent +import android.net.Uri +import androidx.annotation.Keep +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.utils.Log + +/** + Should run WebBrowser as a new intent. +*/ + +@Keep +object WebBrowser { + @JvmStatic + fun openExternal(url: String) { + val activity = NativeLibrary.sEmulationActivity.get() ?: run { + return + } + + activity.runOnUiThread { + try { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + activity.startActivity(intent) + } catch (e: Exception) { + Log.error("WebBrowser failed to launch $url: ${e.message}") + } + } + } +} diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp index e656c2edad..5746659b68 100644 --- a/src/android/app/src/main/jni/native.cpp +++ b/src/android/app/src/main/jni/native.cpp @@ -66,6 +66,7 @@ #include "core/frontend/applets/profile_select.h" #include "core/frontend/applets/software_keyboard.h" #include "core/frontend/applets/web_browser.h" +#include "common/android/applets/web_browser.h" #include "core/hle/service/am/applet_manager.h" #include "core/hle/service/am/frontend/applets.h" #include "core/hle/service/filesystem/filesystem.h" @@ -275,6 +276,7 @@ Core::SystemResultStatus EmulationSession::InitializeEmulation(const std::string // Initialize system. jauto android_keyboard = std::make_unique(); + jauto android_webapplet = std::make_unique(); m_software_keyboard = android_keyboard.get(); m_system.SetShuttingDown(false); m_system.ApplySettings(); @@ -289,7 +291,7 @@ Core::SystemResultStatus EmulationSession::InitializeEmulation(const std::string nullptr, // Photo Viewer nullptr, // Profile Selector std::move(android_keyboard), // Software Keyboard - nullptr, // Web Browser + std::move(android_webapplet),// Web Browser nullptr, // Net Connect }); diff --git a/src/common/CMakeLists.txt b/src/common/CMakeLists.txt index a3d57ffce4..3d09c1caea 100644 --- a/src/common/CMakeLists.txt +++ b/src/common/CMakeLists.txt @@ -5,243 +5,242 @@ # GPL-2.0-or-later if(DEFINED ENV{AZURECIREPO}) - set(BUILD_REPOSITORY $ENV{AZURECIREPO}) + set(BUILD_REPOSITORY $ENV{AZURECIREPO}) endif() if(DEFINED ENV{TITLEBARFORMATIDLE}) - set(TITLE_BAR_FORMAT_IDLE $ENV{TITLEBARFORMATIDLE}) + set(TITLE_BAR_FORMAT_IDLE $ENV{TITLEBARFORMATIDLE}) endif() if(DEFINED ENV{TITLEBARFORMATRUNNING}) - set(TITLE_BAR_FORMAT_RUNNING $ENV{TITLEBARFORMATRUNNING}) + set(TITLE_BAR_FORMAT_RUNNING $ENV{TITLEBARFORMATRUNNING}) endif() if(DEFINED ENV{DISPLAYVERSION}) - set(DISPLAY_VERSION $ENV{DISPLAYVERSION}) + set(DISPLAY_VERSION $ENV{DISPLAYVERSION}) endif() include(GenerateSCMRev) add_library( - common STATIC - address_space.cpp - address_space.h - algorithm.h - alignment.h - announce_multiplayer_room.h - assert.cpp - assert.h - atomic_ops.h - bit_field.h - bit_util.h - bounded_threadsafe_queue.h - cityhash.cpp - cityhash.h - common_funcs.h - common_types.h - concepts.h - container_hash.h - demangle.cpp - demangle.h - detached_tasks.cpp - detached_tasks.h - device_power_state.cpp - device_power_state.h - div_ceil.h - dynamic_library.cpp - dynamic_library.h - elf.h - error.cpp - error.h - expected.h - fiber.cpp - fiber.h - fixed_point.h - free_region_manager.h - fs/file.cpp - fs/file.h - fs/fs.cpp - fs/fs.h - fs/fs_paths.h - fs/fs_types.h - fs/fs_util.cpp - fs/fs_util.h - fs/path_util.cpp - fs/path_util.h - hash.h - heap_tracker.cpp - heap_tracker.h - hex_util.cpp - hex_util.h - host_memory.cpp - host_memory.h - input.h - intrusive_red_black_tree.h - literals.h - logging/backend.cpp - logging/backend.h - logging/filter.cpp - logging/filter.h - logging/formatter.h - logging/log.h - logging/log_entry.h - logging/text_formatter.cpp - logging/text_formatter.h - logging/types.h - lz4_compression.cpp - lz4_compression.h - make_unique_for_overwrite.h - math_util.h - memory_detect.cpp - memory_detect.h - multi_level_page_table.cpp - multi_level_page_table.h - overflow.h - page_table.cpp - page_table.h - param_package.cpp - param_package.h - parent_of_member.h - point.h - quaternion.h - range_map.h - range_mutex.h - range_sets.h - range_sets.inc - ring_buffer.h - ${CMAKE_CURRENT_BINARY_DIR}/scm_rev.cpp - scm_rev.h - scope_exit.h - scratch_buffer.h - settings.cpp - settings.h - settings_common.cpp - settings_common.h - settings_enums.h - settings_input.cpp - settings_input.h - settings_setting.h - slot_vector.h - socket_types.h - spin_lock.h - stb.cpp - stb.h - steady_clock.cpp - steady_clock.h - stream.cpp - stream.h - string_util.cpp - string_util.h - swap.h - thread.cpp - thread.h - thread_queue_list.h - thread_worker.h - threadsafe_queue.h - time_zone.cpp - time_zone.h - tiny_mt.h - tree.h - typed_address.h - uint128.h - unique_function.h - uuid.cpp - uuid.h - vector_math.h - virtual_buffer.cpp - virtual_buffer.h - wall_clock.cpp - wall_clock.h - zstd_compression.cpp - zstd_compression.h - fs/ryujinx_compat.h fs/ryujinx_compat.cpp - fs/symlink.h fs/symlink.cpp -) + common STATIC + address_space.cpp + address_space.h + algorithm.h + alignment.h + announce_multiplayer_room.h + assert.cpp + assert.h + atomic_ops.h + bit_field.h + bit_util.h + bounded_threadsafe_queue.h + cityhash.cpp + cityhash.h + common_funcs.h + common_types.h + concepts.h + container_hash.h + demangle.cpp + demangle.h + detached_tasks.cpp + detached_tasks.h + device_power_state.cpp + device_power_state.h + div_ceil.h + dynamic_library.cpp + dynamic_library.h + elf.h + error.cpp + error.h + expected.h + fiber.cpp + fiber.h + fixed_point.h + free_region_manager.h + fs/file.cpp + fs/file.h + fs/fs.cpp + fs/fs.h + fs/fs_paths.h + fs/fs_types.h + fs/fs_util.cpp + fs/fs_util.h + fs/path_util.cpp + fs/path_util.h + hash.h + heap_tracker.cpp + heap_tracker.h + hex_util.cpp + hex_util.h + host_memory.cpp + host_memory.h + input.h + intrusive_red_black_tree.h + literals.h + logging/backend.cpp + logging/backend.h + logging/filter.cpp + logging/filter.h + logging/formatter.h + logging/log.h + logging/log_entry.h + logging/text_formatter.cpp + logging/text_formatter.h + logging/types.h + lz4_compression.cpp + lz4_compression.h + make_unique_for_overwrite.h + math_util.h + memory_detect.cpp + memory_detect.h + multi_level_page_table.cpp + multi_level_page_table.h + overflow.h + page_table.cpp + page_table.h + param_package.cpp + param_package.h + parent_of_member.h + point.h + quaternion.h + range_map.h + range_mutex.h + range_sets.h + range_sets.inc + ring_buffer.h + ${CMAKE_CURRENT_BINARY_DIR}/scm_rev.cpp + scm_rev.h + scope_exit.h + scratch_buffer.h + settings.cpp + settings.h + settings_common.cpp + settings_common.h + settings_enums.h + settings_input.cpp + settings_input.h + settings_setting.h + slot_vector.h + socket_types.h + spin_lock.h + stb.cpp + stb.h + steady_clock.cpp + steady_clock.h + stream.cpp + stream.h + string_util.cpp + string_util.h + swap.h + thread.cpp + thread.h + thread_queue_list.h + thread_worker.h + threadsafe_queue.h + time_zone.cpp + time_zone.h + tiny_mt.h + tree.h + typed_address.h + uint128.h + unique_function.h + uuid.cpp + uuid.h + vector_math.h + virtual_buffer.cpp + virtual_buffer.h + wall_clock.cpp + wall_clock.h + zstd_compression.cpp + zstd_compression.h + fs/ryujinx_compat.h fs/ryujinx_compat.cpp + fs/symlink.h fs/symlink.cpp) if(WIN32) - target_sources(common PRIVATE windows/timer_resolution.cpp - windows/timer_resolution.h) - target_link_libraries(common PRIVATE ntdll) + target_sources(common PRIVATE windows/timer_resolution.cpp + windows/timer_resolution.h) + target_link_libraries(common PRIVATE ntdll) endif() if(NOT WIN32) - target_sources(common PRIVATE signal_chain.cpp signal_chain.h) + target_sources(common PRIVATE signal_chain.cpp signal_chain.h) endif() if(ANDROID) - target_sources( - common - PUBLIC fs/fs_android.cpp - fs/fs_android.h - android/android_common.cpp - android/android_common.h - android/id_cache.cpp - android/id_cache.h - android/multiplayer/multiplayer.cpp - android/multiplayer/multiplayer.h - android/applets/software_keyboard.cpp - android/applets/software_keyboard.h) + target_sources( + common + PUBLIC fs/fs_android.cpp + fs/fs_android.h + android/android_common.cpp + android/android_common.h + android/id_cache.cpp + android/id_cache.h + android/multiplayer/multiplayer.cpp + android/multiplayer/multiplayer.h + android/applets/software_keyboard.cpp + android/applets/software_keyboard.h + android/applets/web_browser.cpp + android/applets/web_browser.h) endif() if(ARCHITECTURE_x86_64) - target_sources( - common - PRIVATE x64/cpu_detect.cpp - x64/cpu_detect.h - x64/cpu_wait.cpp - x64/cpu_wait.h - x64/native_clock.cpp - x64/native_clock.h - x64/rdtsc.cpp - x64/rdtsc.h - x64/xbyak_abi.h - x64/xbyak_util.h) - target_link_libraries(common PRIVATE xbyak::xbyak) + target_sources( + common + PRIVATE x64/cpu_detect.cpp + x64/cpu_detect.h + x64/cpu_wait.cpp + x64/cpu_wait.h + x64/native_clock.cpp + x64/native_clock.h + x64/rdtsc.cpp + x64/rdtsc.h + x64/xbyak_abi.h + x64/xbyak_util.h) + target_link_libraries(common PRIVATE xbyak::xbyak) endif() if(HAS_NCE) - target_sources(common PRIVATE arm64/native_clock.cpp arm64/native_clock.h) + target_sources(common PRIVATE arm64/native_clock.cpp arm64/native_clock.h) endif() if(MSVC) - target_compile_definitions( - common - PRIVATE # The standard library doesn't provide any replacement for codecvt - # yet so we can disable this deprecation warning for the time being. - _SILENCE_CXX17_CODECVT_HEADER_DEPRECATION_WARNING) - target_compile_options( - common - PRIVATE /we4242 # 'identifier': conversion from 'type1' to 'type2', possible - # loss of data - /we4254 # 'operator': conversion from 'type1:field_bits' to - # 'type2:field_bits', possible loss of data - /we4800 # Implicit conversion from 'type' to bool. Possible - # information loss - ) + target_compile_definitions( + common + PRIVATE # The standard library doesn't provide any replacement for codecvt + # yet so we can disable this deprecation warning for the time being. + _SILENCE_CXX17_CODECVT_HEADER_DEPRECATION_WARNING) + target_compile_options( + common + PRIVATE /we4242 # 'identifier': conversion from 'type1' to 'type2', possible + # loss of data + /we4254 # 'operator': conversion from 'type1:field_bits' to + # 'type2:field_bits', possible loss of data + /we4800 # Implicit conversion from 'type' to bool. Possible + # information loss + ) else() - set_source_files_properties( - stb.cpp - PROPERTIES - COMPILE_OPTIONS - "-Wno-implicit-fallthrough;-Wno-missing-declarations;-Wno-missing-field-initializers" - ) + set_source_files_properties( + stb.cpp + PROPERTIES + COMPILE_OPTIONS + "-Wno-implicit-fallthrough;-Wno-missing-declarations;-Wno-missing-field-initializers") - # Get around GCC failing with intrinsics in Debug - if(CXX_GCC AND CMAKE_BUILD_TYPE MATCHES "Debug") - set_property( - SOURCE stb.cpp - APPEND - PROPERTY COMPILE_OPTIONS ";-O2") - endif() + # Get around GCC failing with intrinsics in Debug + if(CXX_GCC AND CMAKE_BUILD_TYPE MATCHES "Debug") + set_property( + SOURCE stb.cpp + APPEND + PROPERTY COMPILE_OPTIONS ";-O2") + endif() endif() if(CXX_CLANG) - target_compile_options(common PRIVATE -fsized-deallocation - -Werror=unreachable-code-aggressive) - target_compile_definitions( - common - PRIVATE - # Clang 14 and earlier have errors when explicitly instantiating - # Settings::Setting - $<$,15>:CANNOT_EXPLICITLY_INSTANTIATE> - ) + target_compile_options(common PRIVATE -fsized-deallocation + -Werror=unreachable-code-aggressive) + target_compile_definitions( + common + PRIVATE + # Clang 14 and earlier have errors when explicitly instantiating + # Settings::Setting + $<$,15>:CANNOT_EXPLICITLY_INSTANTIATE>) endif() if (BOOST_NO_HEADERS) @@ -260,8 +259,8 @@ target_link_libraries(common PUBLIC fmt::fmt stb::headers Threads::Threads) target_link_libraries(common PRIVATE lz4::lz4 LLVM::Demangle zstd::zstd) if(ANDROID) - # For ASharedMemory_create - target_link_libraries(common PRIVATE android) + # For ASharedMemory_create + target_link_libraries(common PRIVATE android) endif() create_target_directory_groups(common) diff --git a/src/common/android/applets/web_browser.cpp b/src/common/android/applets/web_browser.cpp new file mode 100644 index 0000000000..cf844ff5fc --- /dev/null +++ b/src/common/android/applets/web_browser.cpp @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "common/android/android_common.h" +#include "common/android/id_cache.h" +#include "common/android/applets/web_browser.h" +#include "common/logging/log.h" + +static jclass s_native_library_class = nullptr; +static jmethodID s_open_external_url = nullptr; + +namespace Common::Android::WebBrowser { + +void InitJNI(JNIEnv* env) { + const jclass local = env->FindClass("org/yuzu/yuzu_emu/NativeLibrary"); + s_native_library_class = static_cast(env->NewGlobalRef(local)); + env->DeleteLocalRef(local); + s_open_external_url = env->GetStaticMethodID(s_native_library_class, "openExternalUrl", "(Ljava/lang/String;)V"); +} + +void CleanupJNI(JNIEnv* env) { + if (s_native_library_class != nullptr) { + env->DeleteGlobalRef(s_native_library_class); + s_native_library_class = nullptr; + } + s_open_external_url = nullptr; +} + +void AndroidWebBrowser::OpenLocalWebPage(const std::string& local_url, ExtractROMFSCallback extract_romfs_callback, OpenWebPageCallback callback) const { + LOG_WARNING(Frontend, "(STUBBED)"); + callback(Service::AM::Frontend::WebExitReason::WindowClosed, ""); +} + +void AndroidWebBrowser::OpenExternalWebPage(const std::string& external_url, OpenWebPageCallback callback) const { + // do a dedicated thread, calling from the this thread crashed CPU fiber. + Common::Android::RunJNIOnFiber([&](JNIEnv* env) { + if (env != nullptr && s_native_library_class != nullptr && s_open_external_url != nullptr) { + const jstring j_url = Common::Android::ToJString(env, external_url); + env->CallStaticVoidMethod(s_native_library_class, s_open_external_url, j_url); + env->DeleteLocalRef(j_url); + } else { + LOG_ERROR(Frontend, "JNI not initialized, cannot open {}", external_url); + } + return; + }); + + callback(Service::AM::Frontend::WebExitReason::WindowClosed, external_url); +} + +} // namespace Common::Android::WebBrowser diff --git a/src/common/android/applets/web_browser.h b/src/common/android/applets/web_browser.h new file mode 100644 index 0000000000..a70903e3be --- /dev/null +++ b/src/common/android/applets/web_browser.h @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +#include "core/frontend/applets/web_browser.h" + +namespace Common::Android::WebBrowser { + +class AndroidWebBrowser final : public Core::Frontend::WebBrowserApplet { +public: + ~AndroidWebBrowser() override = default; + + void Close() const override {} + + void OpenLocalWebPage(const std::string& local_url, + ExtractROMFSCallback extract_romfs_callback, + OpenWebPageCallback callback) const override; + + void OpenExternalWebPage(const std::string& external_url, + OpenWebPageCallback callback) const override; +}; + +void InitJNI(JNIEnv* env); +void CleanupJNI(JNIEnv* env); + +} // namespace Common::Android::WebBrowser diff --git a/src/common/android/id_cache.cpp b/src/common/android/id_cache.cpp index 1198833996..eb43f4e213 100644 --- a/src/common/android/id_cache.cpp +++ b/src/common/android/id_cache.cpp @@ -4,6 +4,7 @@ #include #include "applets/software_keyboard.h" +#include "applets/web_browser.h" #include "common/android/id_cache.h" #include "common/assert.h" #include "common/fs/fs_android.h" @@ -602,6 +603,7 @@ namespace Common::Android { // Initialize applets Common::Android::SoftwareKeyboard::InitJNI(env); + Common::Android::WebBrowser::InitJNI(env); return JNI_VERSION; } @@ -631,6 +633,7 @@ namespace Common::Android { // UnInitialize applets SoftwareKeyboard::CleanupJNI(env); + WebBrowser::CleanupJNI(env); AndroidMultiplayer::NetworkShutdown(); } diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index a961eff8bf..ec7ce42a1b 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -563,8 +563,14 @@ add_library(core STATIC hle/service/bcat/delivery_cache_storage_service.h hle/service/bcat/news/newly_arrived_event_holder.cpp hle/service/bcat/news/newly_arrived_event_holder.h + hle/service/bcat/news/msgpack.cpp + hle/service/bcat/news/msgpack.h hle/service/bcat/news/news_data_service.cpp hle/service/bcat/news/news_data_service.h + hle/service/bcat/news/builtin_news.cpp + hle/service/bcat/news/builtin_news.h + hle/service/bcat/news/news_storage.cpp + hle/service/bcat/news/news_storage.h hle/service/bcat/news/news_database_service.cpp hle/service/bcat/news/news_database_service.h hle/service/bcat/news/news_service.cpp @@ -1125,6 +1131,8 @@ add_library(core STATIC internal_network/sockets.h internal_network/wifi_scanner.cpp internal_network/wifi_scanner.h + launch_timestamp_cache.cpp + launch_timestamp_cache.h loader/deconstructed_rom_directory.cpp loader/deconstructed_rom_directory.h loader/kip.cpp @@ -1157,8 +1165,7 @@ add_library(core STATIC tools/freezer.cpp tools/freezer.h tools/renderdoc.cpp - tools/renderdoc.h -) + tools/renderdoc.h) if (ENABLE_WIFI_SCAN) # find_package(libiw REQUIRED) @@ -1188,8 +1195,7 @@ else() -Werror=conversion -Wno-sign-conversion -Wno-cast-function-type - $<$:-fsized-deallocation> - ) + $<$:-fsized-deallocation>) # pre-clang19 will spam with "OH DID YOU MEAN THIS?" otherwise... if (CXX_CLANG AND CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL 19) target_compile_options(core PRIVATE -Wno-cast-function-type-mismatch) @@ -1205,10 +1211,13 @@ else() target_link_libraries(core PUBLIC Boost::headers) endif() -target_link_libraries(core PRIVATE fmt::fmt nlohmann_json::nlohmann_json RenderDoc::API MbedTLS::mbedcrypto${MBEDTLS_LIB_SUFFIX} MbedTLS::mbedtls${MBEDTLS_LIB_SUFFIX}) -# if (MINGW) -# target_link_libraries(core PRIVATE ws2_32 mswsock wlanapi) -# endif() +target_link_libraries(core PRIVATE + fmt::fmt + nlohmann_json::nlohmann_json + RenderDoc::API + MbedTLS::mbedcrypto${MBEDTLS_LIB_SUFFIX} + MbedTLS::mbedtls${MBEDTLS_LIB_SUFFIX} + httplib::httplib) if (ENABLE_WEB_SERVICE) target_compile_definitions(core PUBLIC ENABLE_WEB_SERVICE) @@ -1230,8 +1239,7 @@ if (HAS_NCE) arm/nce/interpreter_visitor.h arm/nce/patcher.cpp arm/nce/patcher.h - arm/nce/visitor_base.h - ) + arm/nce/visitor_base.h) target_link_libraries(core PRIVATE merry::oaknut) endif() @@ -1251,8 +1259,7 @@ if (ARCHITECTURE_x86_64 OR ARCHITECTURE_arm64) hle/service/jit/jit_context.cpp hle/service/jit/jit_context.h hle/service/jit/jit.cpp - hle/service/jit/jit.h - ) + hle/service/jit/jit.h) target_link_libraries(core PRIVATE dynarmic::dynarmic) endif() @@ -1263,6 +1270,7 @@ if(ENABLE_OPENSSL) find_package(OpenSSL REQUIRED) target_link_libraries(core PRIVATE OpenSSL::SSL OpenSSL::Crypto) + target_compile_definitions(core PRIVATE CPPHTTPLIB_OPENSSL_SUPPORT) elseif (APPLE) target_sources(core PRIVATE hle/service/ssl/ssl_backend_securetransport.cpp) diff --git a/src/core/core.cpp b/src/core/core.cpp index aea2b2b060..bada8ef2c1 100644 --- a/src/core/core.cpp +++ b/src/core/core.cpp @@ -15,6 +15,8 @@ #include "common/string_util.h" #include "core/arm/exclusive_monitor.h" #include "core/core.h" + +#include "launch_timestamp_cache.h" #include "core/core_timing.h" #include "core/cpu_manager.h" #include "core/debugger/debugger.h" @@ -323,6 +325,9 @@ struct System::Impl { LOG_INFO(Core, "Loading {} ({:016X}) ...", name, params.program_id); + // Track launch time for frontend launches + LaunchTimestampCache::SaveLaunchTimestamp(params.program_id); + // Make the process created be the application kernel.MakeApplicationProcess(process->GetHandle()); diff --git a/src/core/hle/service/am/am_types.h b/src/core/hle/service/am/am_types.h index 621a7b4921..beb52b74dd 100644 --- a/src/core/hle/service/am/am_types.h +++ b/src/core/hle/service/am/am_types.h @@ -94,6 +94,7 @@ enum class AppletId : u32 { LoginShare = 0x18, WebAuth = 0x19, MyPage = 0x1A, + Lhub = 0x35 }; enum class AppletProgramId : u64 { diff --git a/src/core/hle/service/am/frontend/applet_web_browser.cpp b/src/core/hle/service/am/frontend/applet_web_browser.cpp index 8246b3e88e..e7cbdf6361 100644 --- a/src/core/hle/service/am/frontend/applet_web_browser.cpp +++ b/src/core/hle/service/am/frontend/applet_web_browser.cpp @@ -236,10 +236,6 @@ WebBrowser::WebBrowser(Core::System& system_, std::shared_ptr applet_, WebBrowser::~WebBrowser() = default; void WebBrowser::Initialize() { - if (Settings::values.disable_web_applet) { - return; - } - FrontendApplet::Initialize(); LOG_INFO(Service_AM, "Initializing Web Browser Applet."); @@ -264,6 +260,12 @@ void WebBrowser::Initialize() { LOG_DEBUG(Service_AM, "WebArgHeader: total_tlv_entries={}, shim_kind={}", web_arg_header.total_tlv_entries, web_arg_header.shim_kind); + if (Settings::values.disable_web_applet && + web_arg_header.shim_kind != ShimKind::Web && + web_arg_header.shim_kind != ShimKind::Lhub) { + return; + } + ExtractSharedFonts(system); switch (web_arg_header.shim_kind) { @@ -288,6 +290,9 @@ void WebBrowser::Initialize() { case ShimKind::Lobby: InitializeLobby(); break; + case ShimKind::Lhub: + InitializeLhub(); + break; default: ASSERT_MSG(false, "Invalid ShimKind={}", web_arg_header.shim_kind); break; @@ -303,8 +308,19 @@ void WebBrowser::ExecuteInteractive() { } void WebBrowser::Execute() { + if (web_arg_header.shim_kind == ShimKind::Web) { + ExecuteWeb(); + return; + } + + if (web_arg_header.shim_kind == ShimKind::Lhub) { + ExecuteLhub(); + return; + } + if (Settings::values.disable_web_applet) { - LOG_WARNING(Service_AM, "(STUBBED) called, Web Browser Applet is disabled"); + LOG_WARNING(Service_AM, "(STUBBED) called, Web Browser Applet is disabled. shim_kind={}", + web_arg_header.shim_kind); WebBrowserExit(WebExitReason::EndButtonPressed); return; } @@ -331,6 +347,9 @@ void WebBrowser::Execute() { case ShimKind::Lobby: ExecuteLobby(); break; + case ShimKind::Lhub: + ExecuteLhub(); + break; default: ASSERT_MSG(false, "Invalid ShimKind={}", web_arg_header.shim_kind); WebBrowserExit(WebExitReason::EndButtonPressed); @@ -351,17 +370,99 @@ void WebBrowser::ExtractOfflineRomFS() { } void WebBrowser::WebBrowserExit(WebExitReason exit_reason, std::string last_url) { - if ((web_arg_header.shim_kind == ShimKind::Share && + const bool use_tlv_output = + (web_arg_header.shim_kind == ShimKind::Share && web_applet_version >= WebAppletVersion::Version196608) || (web_arg_header.shim_kind == ShimKind::Web && - web_applet_version >= WebAppletVersion::Version524288)) { - // TODO: Push Output TLVs instead of a WebCommonReturnValue + web_applet_version >= WebAppletVersion::Version524288) || + (web_arg_header.shim_kind == ShimKind::Lhub); + + // https://switchbrew.org/wiki/Internet_Browser#TLVs + if (use_tlv_output) { + LOG_DEBUG(Service_AM, "Using TLV output: exit_reason={}, last_url={}, last_url_size={}", + exit_reason, last_url, last_url.size()); + + // storage size for TLVs is 0x2000 bytes (as per switchbrew documentation) + constexpr size_t TLV_STORAGE_SIZE = 0x2000; + std::vector out_data(TLV_STORAGE_SIZE, 0); + + size_t current_offset = sizeof(WebArgHeader); + u16 tlv_count = 0; + + // align and matchng TLV struct alignment + auto align_offset = [](size_t offset) -> size_t { + return (offset + 7) & ~static_cast(7); + }; + + // 0x1 ShareExitReason + { + WebArgOutputTLV tlv{}; + tlv.output_tlv_type = WebArgOutputTLVType::ShareExitReason; + tlv.arg_data_size = sizeof(u32); + + std::memcpy(out_data.data() + current_offset, &tlv, sizeof(WebArgOutputTLV)); + current_offset += sizeof(WebArgOutputTLV); + + const u32 exit_reason_value = static_cast(exit_reason); + std::memcpy(out_data.data() + current_offset, &exit_reason_value, sizeof(u32)); + current_offset += sizeof(u32); + + current_offset = align_offset(current_offset); + tlv_count++; + } + + // 0x2 LastUrl + { + WebArgOutputTLV tlv{}; + tlv.output_tlv_type = WebArgOutputTLVType::LastURL; + const u16 url_data_size = static_cast(last_url.size() + 1); + tlv.arg_data_size = url_data_size; + + std::memcpy(out_data.data() + current_offset, &tlv, sizeof(WebArgOutputTLV)); + current_offset += sizeof(WebArgOutputTLV); + + // null terminator + std::memcpy(out_data.data() + current_offset, last_url.c_str(), last_url.size() + 1); + current_offset += url_data_size; + current_offset = align_offset(current_offset); + tlv_count++; + } + + // 0x3 LastUrlSize + { + WebArgOutputTLV tlv{}; + tlv.output_tlv_type = WebArgOutputTLVType::LastURLSize; + tlv.arg_data_size = sizeof(u64); + + std::memcpy(out_data.data() + current_offset, &tlv, sizeof(WebArgOutputTLV)); + current_offset += sizeof(WebArgOutputTLV); + + const u64 url_size = last_url.size(); + std::memcpy(out_data.data() + current_offset, &url_size, sizeof(u64)); + current_offset += sizeof(u64); + tlv_count++; + } + + WebArgHeader out_header{}; + out_header.total_tlv_entries = tlv_count; + out_header.shim_kind = web_arg_header.shim_kind; + std::memcpy(out_data.data(), &out_header, sizeof(WebArgHeader)); + + LOG_DEBUG(Service_AM, "TLV output: total_size={}, tlv_count={}, used_offset={}", + out_data.size(), tlv_count, current_offset); + + complete = true; + PushOutData(std::make_shared(system, std::move(out_data))); + Exit(); + return; } - WebCommonReturnValue web_common_return_value; + // for old browser, keep old return, use WebCommonReturnValue + WebCommonReturnValue web_common_return_value{}; web_common_return_value.exit_reason = exit_reason; - std::memcpy(&web_common_return_value.last_url, last_url.data(), last_url.size()); + std::memcpy(&web_common_return_value.last_url, last_url.data(), + std::min(last_url.size(), web_common_return_value.last_url.size())); web_common_return_value.last_url_size = last_url.size(); LOG_DEBUG(Service_AM, "WebCommonReturnValue: exit_reason={}, last_url={}, last_url_size={}", @@ -516,4 +617,13 @@ void WebBrowser::ExecuteLobby() { LOG_WARNING(Service_AM, "(STUBBED) called, Lobby Applet is not implemented"); WebBrowserExit(WebExitReason::EndButtonPressed); } + +void WebBrowser::InitializeLhub() {} + +void WebBrowser::ExecuteLhub() { + LOG_INFO(Service_AM, "(STUBBED) called, Lhub Applet is not implemented"); + WebBrowserExit(WebExitReason::EndButtonPressed); +} + + } // namespace Service::AM::Frontend diff --git a/src/core/hle/service/am/frontend/applet_web_browser.h b/src/core/hle/service/am/frontend/applet_web_browser.h index ba20b7a4cf..ae62389f13 100644 --- a/src/core/hle/service/am/frontend/applet_web_browser.h +++ b/src/core/hle/service/am/frontend/applet_web_browser.h @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: Copyright 2020 yuzu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later @@ -53,6 +56,7 @@ private: void InitializeWeb(); void InitializeWifi(); void InitializeLobby(); + void InitializeLhub(); // Executors for the various types of browser applets void ExecuteShop(); @@ -62,6 +66,7 @@ private: void ExecuteWeb(); void ExecuteWifi(); void ExecuteLobby(); + void ExecuteLhub(); const Core::Frontend::WebBrowserApplet& frontend; diff --git a/src/core/hle/service/am/frontend/applet_web_browser_types.h b/src/core/hle/service/am/frontend/applet_web_browser_types.h index 2f7c05c243..0892a6a716 100644 --- a/src/core/hle/service/am/frontend/applet_web_browser_types.h +++ b/src/core/hle/service/am/frontend/applet_web_browser_types.h @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: Copyright 2020 yuzu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later @@ -30,6 +33,7 @@ enum class ShimKind : u32 { Web = 5, Wifi = 6, Lobby = 7, + Lhub = 8, }; enum class WebExitReason : u32 { diff --git a/src/core/hle/service/am/frontend/applets.cpp b/src/core/hle/service/am/frontend/applets.cpp index a25b7e3aa2..2ff2bcdbb6 100644 --- a/src/core/hle/service/am/frontend/applets.cpp +++ b/src/core/hle/service/am/frontend/applets.cpp @@ -237,13 +237,15 @@ std::shared_ptr FrontendAppletHolder::GetApplet(std::shared_ptr< case AppletId::OfflineWeb: case AppletId::LoginShare: case AppletId::WebAuth: + case AppletId::Lhub: return std::make_shared(system, applet, mode, *frontend.web_browser); case AppletId::PhotoViewer: return std::make_shared(system, applet, mode, *frontend.photo_viewer); case AppletId::NetConnect: return std::make_shared(system, applet, mode, *frontend.net_connect); default: - LOG_ERROR(Service_AM, "No backend implementation exists for applet_id={:02X}. Falling back to stub applet", static_cast(id)); + LOG_ERROR(Service_AM, "No backend implementation exists for applet_id={:02X} program_id={:016X}" + "Falling back to stub applet", static_cast(id), applet->program_id); return std::make_shared(system, applet, id, mode); } } diff --git a/src/core/hle/service/am/service/application_creator.cpp b/src/core/hle/service/am/service/application_creator.cpp index 2fc33a303c..d16fd7dd84 100644 --- a/src/core/hle/service/am/service/application_creator.cpp +++ b/src/core/hle/service/am/service/application_creator.cpp @@ -15,6 +15,7 @@ #include "core/hle/service/am/window_system.h" #include "core/hle/service/cmif_serialization.h" #include "core/loader/loader.h" +#include "core/launch_timestamp_cache.h" namespace Service::AM { @@ -72,6 +73,7 @@ IApplicationCreator::~IApplicationCreator() = default; Result IApplicationCreator::CreateApplication( Out> out_application_accessor, u64 application_id) { LOG_INFO(Service_NS, "called, application_id={:016X}", application_id); + Core::LaunchTimestampCache::SaveLaunchTimestamp(application_id); R_RETURN( CreateGuestApplication(out_application_accessor, system, m_window_system, application_id)); } @@ -103,6 +105,7 @@ Result IApplicationCreator::CreateSystemApplication( *out_application_accessor = std::make_shared(system, applet, m_window_system); + Core::LaunchTimestampCache::SaveLaunchTimestamp(application_id); R_SUCCEED(); } diff --git a/src/core/hle/service/bcat/news/builtin_news.cpp b/src/core/hle/service/bcat/news/builtin_news.cpp new file mode 100644 index 0000000000..440a5d1f51 --- /dev/null +++ b/src/core/hle/service/bcat/news/builtin_news.cpp @@ -0,0 +1,555 @@ +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "core/hle/service/bcat/news/builtin_news.h" +#include "core/hle/service/bcat/news/msgpack.h" +#include "core/hle/service/bcat/news/news_storage.h" + +#include "common/fs/file.h" +#include "common/fs/path_util.h" +#include "common/logging/log.h" + +#include +#include +#include +#include +#include +#include + +#ifdef CPPHTTPLIB_OPENSSL_SUPPORT +#include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef YUZU_BUNDLED_OPENSSL +#include +#endif + +namespace Service::News { +namespace { + +constexpr const char* GitHubAPI_EdenReleases = "/repos/eden-emulator/Releases/releases"; + +// Cached logo data +std::vector default_logo_small; +std::vector default_logo_large; +bool default_logos_loaded = false; + +std::unordered_map> news_images_small; +std::unordered_map> news_images_large; +std::mutex images_mutex; + + +std::filesystem::path GetCachePath() { + return Common::FS::GetEdenPath(Common::FS::EdenPath::CacheDir) / "news" / "github_releases.json"; +} + +std::filesystem::path GetDefaultLogoPath(bool large) { + return Common::FS::GetEdenPath(Common::FS::EdenPath::CacheDir) / "news" / + (large ? "eden_logo_large.jpg" : "eden_logo_small.jpg"); +} + +std::filesystem::path GetNewsImagePath(std::string_view news_id, bool large) { + const std::string filename = fmt::format("{}_{}.jpg", news_id, large ? "large" : "small"); + return Common::FS::GetEdenPath(Common::FS::EdenPath::CacheDir) / "news" / "images" / filename; +} + +u32 HashToNewsId(std::string_view key) { + return static_cast(std::hash{}(key) & 0x7FFFFFFF); +} + +u64 ParseIsoTimestamp(const std::string& iso) { + if (iso.empty()) return 0; + + std::string buf = iso; + if (buf.back() == 'Z') buf.pop_back(); + + std::tm tm{}; + std::istringstream ss(buf); + ss >> std::get_time(&tm, "%Y-%m-%dT%H:%M:%S"); + if (ss.fail()) return 0; + +#ifdef _WIN32 + return static_cast(_mkgmtime(&tm)); +#else + return static_cast(timegm(&tm)); +#endif +} + +std::vector TryLoadFromDisk(const std::filesystem::path& path) { + if (!std::filesystem::exists(path)) return {}; + + std::ifstream f(path, std::ios::binary | std::ios::ate); + if (!f) return {}; + + const auto file_size = static_cast(f.tellg()); + if (file_size <= 0 || file_size > 10 * 1024 * 1024) return {}; + + f.seekg(0); + std::vector data(static_cast(file_size)); + if (!f.read(reinterpret_cast(data.data()), file_size)) return {}; + + return data; +} + +std::vector DownloadImage(const std::string& url_path, const std::filesystem::path& cache_path) { + LOG_INFO(Service_BCAT, "Downloading image: https://eden-emu.dev{}", url_path); + +#ifdef CPPHTTPLIB_OPENSSL_SUPPORT + try { + httplib::Client cli("https://eden-emu.dev"); + cli.set_follow_location(true); + cli.set_connection_timeout(std::chrono::seconds(10)); + cli.set_read_timeout(std::chrono::seconds(30)); + +#ifdef YUZU_BUNDLED_OPENSSL + cli.load_ca_cert_store(kCert, sizeof(kCert)); +#endif + + if (auto res = cli.Get(url_path); res && res->status == 200 && !res->body.empty()) { + std::vector data(res->body.begin(), res->body.end()); + + std::error_code ec; + std::filesystem::create_directories(cache_path.parent_path(), ec); + if (std::ofstream out(cache_path, std::ios::binary); out) { + out.write(res->body.data(), static_cast(res->body.size())); + } + return data; + } + } catch (...) { + LOG_WARNING(Service_BCAT, "Failed to download: {}", url_path); + } +#endif + + return {}; +} + +std::vector LoadDefaultLogo(bool large) { + const auto path = GetDefaultLogoPath(large); + const std::string url = large ? "/news/eden_logo_large.jpg" : "/news/eden_logo_small.jpg"; + + auto data = TryLoadFromDisk(path); + if (!data.empty()) return data; + + return DownloadImage(url, path); +} + +void LoadDefaultLogos() { + if (default_logos_loaded) return; + default_logos_loaded = true; + + default_logo_small = LoadDefaultLogo(false); + default_logo_large = LoadDefaultLogo(true); +} + +std::vector GetNewsImage(std::string_view news_id, bool large) { + const std::string id_str{news_id}; + + { + std::lock_guard lock{images_mutex}; + auto& cache = large ? news_images_large : news_images_small; + if (auto it = cache.find(id_str); it != cache.end()) { + return it->second; + } + } + + const auto cache_path = GetNewsImagePath(news_id, large); + auto data = TryLoadFromDisk(cache_path); + + if (data.empty()) { + const std::string url = fmt::format("/news/{}_{}.jpg", id_str, large ? "large" : "small"); + data = DownloadImage(url, cache_path); + } + + if (data.empty()) { + data = large ? default_logo_large : default_logo_small; + } + + { + std::lock_guard lock{images_mutex}; + auto& cache = large ? news_images_large : news_images_small; + cache[id_str] = data; + } + + return data; +} + +void PreloadNewsImages(const std::vector& news_ids) { + std::vector> futures; + futures.reserve(news_ids.size() * 2); + + for (const u32 id : news_ids) { + const std::string id_str = fmt::format("{}", id); + + { + std::lock_guard lock{images_mutex}; + if (news_images_small.contains(id_str) && news_images_large.contains(id_str)) { + continue; + } + } + + const auto path_small = GetNewsImagePath(id_str, false); + const auto path_large = GetNewsImagePath(id_str, true); + if (std::filesystem::exists(path_small) && std::filesystem::exists(path_large)) { + continue; + } + + futures.push_back(std::async(std::launch::async, [id_str]() { + GetNewsImage(id_str, false); + })); + futures.push_back(std::async(std::launch::async, [id_str]() { + GetNewsImage(id_str, true); + })); + } + + for (auto& f : futures) { + f.wait(); + } +} + +std::optional ReadCachedJson() { + const auto path = GetCachePath(); + if (!std::filesystem::exists(path)) return std::nullopt; + + auto content = Common::FS::ReadStringFromFile(path, Common::FS::FileType::TextFile); + return content.empty() ? std::nullopt : std::optional{std::move(content)}; +} + +void WriteCachedJson(std::string_view json) { + const auto path = GetCachePath(); + std::error_code ec; + std::filesystem::create_directories(path.parent_path(), ec); + (void)Common::FS::WriteStringToFile(path, Common::FS::FileType::TextFile, json); +} + +std::optional DownloadReleasesJson() { + +#ifdef CPPHTTPLIB_OPENSSL_SUPPORT + try { + httplib::SSLClient cli{"api.github.com", 443}; + cli.set_connection_timeout(10); + cli.set_read_timeout(10); + + httplib::Headers headers{ + {"User-Agent", "Eden"}, + {"Accept", "application/vnd.github+json"}, + }; + + // TODO(crueter): automate this in some way... +#ifdef YUZU_BUNDLED_OPENSSL + cli.load_ca_cert_store(kCert, sizeof(kCert)); +#endif + + if (auto res = cli.Get(GitHubAPI_EdenReleases, headers); res && res->status < 400) { + return res->body; + } + } catch (...) { + LOG_WARNING(Service_BCAT, " failed to download releases"); + } +#endif + return std::nullopt; +} + +// idk but News App does not render Markdown or HTML, so remove some formatting. +std::string SanitizeMarkdown(std::string_view markdown) { + std::string result; + result.reserve(markdown.size()); + + // our current structure for markdown is after "# Packages" remove everything. + std::string text{markdown}; + if (auto pos = text.find("# Packages"); pos != std::string::npos) { + text = text.substr(0, pos); + } + + // Fix line endings + boost::replace_all(text, "\r", ""); + + // Remove backticks + boost::replace_all(text, "`", ""); + + // Remove excessive newlines + static const boost::regex newlines(R"(\n\n\n+)"); + text = boost::regex_replace(text, newlines, "\n\n"); + + // Remove markdown headers + static const boost::regex headers(R"(^#+ )"); + text = boost::regex_replace(text, headers, ""); + + // Convert bullet points to something nicer + static const boost::regex list1(R"(^- )"); + text = boost::regex_replace(text, list1, "• "); + + static const boost::regex list2(R"(^ \* )"); + text = boost::regex_replace(text, list2, " • "); + + // what + static const boost::regex list2_dash(R"(^ - )"); + text = boost::regex_replace(text, list2_dash, " • "); + + // Convert bold/italic text into normal text + static const boost::regex bold(R"(\*\*(.*?)\*\*)"); + text = boost::regex_replace(text, bold, "$1"); + + static const boost::regex italic(R"(\*(.*?)\*)"); + text = boost::regex_replace(text, italic, "$1"); + + // Remove links and convert to normal text + static const boost::regex link(R"(\[([^\]]+)\]\([^)]*\))"); + text = boost::regex_replace(text, link, "$1"); + + // Trim trailing whitespace/newlines + while (!text.empty() && (text.back() == '\n' || text.back() == ' ')) { + text.pop_back(); + } + + return text; +} + +std::string FormatBody(const nlohmann::json& release, std::string_view title) { + std::string body = release.value("body", std::string{}); + + if (body.empty()) { + return std::string(title); + } + + // Sanitize markdown + body = SanitizeMarkdown(body); + + // Limit body length - News app has character limits + size_t max_body_length = 4000; + if (body.size() > max_body_length) { + size_t cut_pos = body.rfind('\n', max_body_length); + if (cut_pos == std::string::npos || cut_pos < max_body_length / 2) { + cut_pos = body.rfind(". ", max_body_length); + } + if (cut_pos == std::string::npos || cut_pos < max_body_length / 2) { + cut_pos = max_body_length; + } + body = body.substr(0, cut_pos); + + // Trim trailing whitespace + while (!body.empty() && (body.back() == '\n' || body.back() == ' ')) { + body.pop_back(); + } + + body += "\n\n... View more on GitHub"; + } + + return body; +} + +void ImportReleases(std::string_view json_text) { + nlohmann::json root; + try { + root = nlohmann::json::parse(json_text); + } catch (...) { + LOG_WARNING(Service_BCAT, "failed to parse JSON"); + return; + } + + if (!root.is_array()) return; + + std::vector news_ids; + for (const auto& rel : root) { + if (!rel.is_object()) continue; + std::string title = rel.value("name", rel.value("tag_name", std::string{})); + if (title.empty()) continue; + + const u64 release_id = rel.value("id", 0); + const u32 news_id = release_id ? static_cast(release_id & 0x7FFFFFFF) : HashToNewsId(title); + news_ids.push_back(news_id); + } + + PreloadNewsImages(news_ids); + + for (const auto& rel : root) { + if (!rel.is_object()) continue; + + std::string title = rel.value("name", rel.value("tag_name", std::string{})); + if (title.empty()) continue; + + const u64 release_id = rel.value("id", 0); + const u32 news_id = release_id ? static_cast(release_id & 0x7FFFFFFF) : HashToNewsId(title); + const u64 published = ParseIsoTimestamp(rel.value("published_at", std::string{})); + const u64 pickup_limit = published + 600000000; + const u32 priority = rel.value("prerelease", false) ? 1500 : 2500; + + std::string author = "eden"; + if (rel.contains("author") && rel["author"].is_object()) { + author = rel["author"].value("login", "eden"); + } + + auto payload = BuildMsgpack(title, FormatBody(rel, title), title, published, + pickup_limit, priority, {"en"}, author, {}, + rel.value("html_url", std::string{}), news_id); + + const std::string news_id_str = fmt::format("LA{:020}", news_id); + + GithubNewsMeta meta{ + .news_id = news_id_str, + .topic_id = "1", + .published_at = published, + .pickup_limit = pickup_limit, + .essential_pickup_limit = pickup_limit, + .expire_at = 0, + .priority = priority, + .deletion_priority = 100, + .decoration_type = 1, + .opted_in = 1, + .essential_pickup_limit_flag = 1, + .category = 0, + .language_mask = 1, + }; + + NewsStorage::Instance().UpsertRaw(meta, std::move(payload)); + } +} + +} // anonymous namespace + +std::vector BuildMsgpack(std::string_view title, std::string_view body, + std::string_view topic_name, u64 published_at, + u64 pickup_limit, u32 priority, + const std::vector& languages, + const std::string& author, + const std::vector>& /*assets*/, + const std::string& html_url, + std::optional override_id) { + MsgPack::Writer w; + + const u32 news_id = override_id.value_or(HashToNewsId(title.empty() ? "eden" : title)); + const std::string news_id_str = fmt::format("{}", news_id); + + const auto img_small = GetNewsImage(news_id_str, false); + const auto img_large = GetNewsImage(news_id_str, true); + + w.WriteFixMap(23); + + // Version infos, could exist a 2? + w.WriteKey("version"); + w.WriteFixMap(2); + w.WriteKey("format"); + w.WriteUInt(1); + w.WriteKey("semantics"); + w.WriteUInt(1); + + // Metadata + w.WriteKey("news_id"); + w.WriteUInt(news_id); + w.WriteKey("published_at"); + w.WriteUInt(published_at); + w.WriteKey("pickup_limit"); + w.WriteUInt(pickup_limit); + w.WriteKey("priority"); + w.WriteUInt(priority); + w.WriteKey("deletion_priority"); + w.WriteUInt(100); + + // Language + w.WriteKey("language"); + w.WriteString(languages.empty() ? "en" : languages.front()); + w.WriteKey("supported_languages"); + w.WriteFixArray(languages.size()); + for (const auto& lang : languages) w.WriteString(lang); + + // Display settings + w.WriteKey("display_type"); + w.WriteString("NORMAL"); + w.WriteKey("topic_id"); + w.WriteString("eden"); + + w.WriteKey("no_photography"); // still show image + w.WriteUInt(0); + w.WriteKey("surprise"); // no idea + w.WriteUInt(0); + w.WriteKey("bashotorya"); // no idea + w.WriteUInt(0); + w.WriteKey("movie"); + w.WriteUInt(0); // 1 = has video, movie_url must be set but we don't support it yet + + // News Subject (Title) + w.WriteKey("subject"); + w.WriteFixMap(2); + w.WriteKey("caption"); + w.WriteUInt(1); + w.WriteKey("text"); + w.WriteString(title.empty() ? "No title" : title); + + // Topic name = who wrote it + w.WriteKey("topic_name"); + w.WriteString("Eden"); + + w.WriteKey("list_image"); + w.WriteBinary(img_small); + + // Footer + w.WriteKey("footer"); + w.WriteFixMap(1); + w.WriteKey("text"); + w.WriteString(""); + + w.WriteKey("allow_domains"); + w.WriteString("^https?://github.com(/|$)"); + + // More link + w.WriteKey("more"); + w.WriteFixMap(1); + w.WriteKey("browser"); + w.WriteFixMap(2); + w.WriteKey("url"); + w.WriteString(html_url); + w.WriteKey("text"); + w.WriteString("Open GitHub"); + + // Body + w.WriteKey("body"); + w.WriteFixMap(4); + w.WriteKey("text"); + w.WriteString(body); + w.WriteKey("main_image_height"); + w.WriteUInt(450); + w.WriteKey("movie_url"); + w.WriteString(""); + w.WriteKey("main_image"); + w.WriteBinary(img_large); + + // no clue + w.WriteKey("contents_descriptors"); + w.WriteString(""); + w.WriteKey("interactive_elements"); + w.WriteString(""); + + return w.Take(); +} + +void EnsureBuiltinNewsLoaded() { + static std::once_flag once; + std::call_once(once, [] { + LoadDefaultLogos(); + + if (const auto cached = ReadCachedJson()) { + ImportReleases(*cached); + LOG_DEBUG(Service_BCAT, "news: {} entries loaded from cache", NewsStorage::Instance().ListAll().size()); + } + + std::thread([] { + if (const auto fresh = DownloadReleasesJson()) { + WriteCachedJson(*fresh); + ImportReleases(*fresh); + LOG_DEBUG(Service_BCAT, "news: {} entries updated from GitHub", NewsStorage::Instance().ListAll().size()); + } + }).detach(); + }); +} + +} // namespace Service::News diff --git a/src/core/hle/service/bcat/news/builtin_news.h b/src/core/hle/service/bcat/news/builtin_news.h new file mode 100644 index 0000000000..cb06b2d4e3 --- /dev/null +++ b/src/core/hle/service/bcat/news/builtin_news.h @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "common/common_types.h" +#include +#include +#include +#include + +namespace Service::News { + +void EnsureBuiltinNewsLoaded(); + + std::vector BuildMsgpack(std::string_view title, std::string_view body, + std::string_view topic_name, + u64 published_at, + u64 pickup_limit, + u32 priority, + const std::vector& languages, + const std::string& author_name, + const std::vector>& assets, + const std::string& html_url, + std::optional override_news_id = std::nullopt); + +} // namespace Service::News diff --git a/src/core/hle/service/bcat/news/msgpack.cpp b/src/core/hle/service/bcat/news/msgpack.cpp new file mode 100644 index 0000000000..b67b75ea9d --- /dev/null +++ b/src/core/hle/service/bcat/news/msgpack.cpp @@ -0,0 +1,886 @@ +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "core/hle/service/bcat/news/msgpack.h" + +#include +#include + +// This file is a partial MsgPack implementation, only implementing the features +// needed for the News service. Can be extended but enough for the news use case. + +namespace Service::News { + +void MsgPack::Writer::WriteBytes(std::span bytes) { + out.insert(out.end(), bytes.begin(), bytes.end()); +} + +void MsgPack::Writer::WriteFixMap(size_t count) { + if (count <= 15) { + out.push_back(static_cast(0x80 | count)); + } else if (count <= 0xFFFF) { + out.push_back(0xDE); + out.push_back(static_cast((count >> 8) & 0xFF)); + out.push_back(static_cast(count & 0xFF)); + } else { + WriteMap32(count); + } +} + +void MsgPack::Writer::WriteMap32(size_t count) { + out.push_back(0xDF); + out.push_back(static_cast((count >> 24) & 0xFF)); + out.push_back(static_cast((count >> 16) & 0xFF)); + out.push_back(static_cast((count >> 8) & 0xFF)); + out.push_back(static_cast(count & 0xFF)); +} + +void MsgPack::Writer::WriteKey(std::string_view s) { + WriteString(s); +} + +void MsgPack::Writer::WriteString(std::string_view s) { + if (s.size() <= 31) { + out.push_back(static_cast(0xA0 | s.size())); + } else if (s.size() <= 0xFF) { + out.push_back(0xD9); + out.push_back(static_cast(s.size())); + } else if (s.size() <= 0xFFFF) { + out.push_back(0xDA); + out.push_back(static_cast((s.size() >> 8) & 0xFF)); + out.push_back(static_cast(s.size() & 0xFF)); + } else { + out.push_back(0xDB); + out.push_back(static_cast((s.size() >> 24) & 0xFF)); + out.push_back(static_cast((s.size() >> 16) & 0xFF)); + out.push_back(static_cast((s.size() >> 8) & 0xFF)); + out.push_back(static_cast(s.size() & 0xFF)); + } + WriteBytes({reinterpret_cast(s.data()), s.size()}); +} + +void MsgPack::Writer::WriteInt64(s64 v) { + if (v >= 0) { + WriteUInt(static_cast(v)); + return; + } + if (v >= -32) { + out.push_back(static_cast(0xE0 | (v + 32))); + } else if (v >= -128) { + out.push_back(0xD0); + out.push_back(static_cast(v & 0xFF)); + } else if (v >= -32768) { + out.push_back(0xD1); + out.push_back(static_cast((v >> 8) & 0xFF)); + out.push_back(static_cast(v & 0xFF)); + } else if (v >= INT32_MIN) { + out.push_back(0xD2); + out.push_back(static_cast((v >> 24) & 0xFF)); + out.push_back(static_cast((v >> 16) & 0xFF)); + out.push_back(static_cast((v >> 8) & 0xFF)); + out.push_back(static_cast(v & 0xFF)); + } else { + out.push_back(0xD3); + for (int i = 7; i >= 0; --i) { + out.push_back(static_cast((static_cast(v) >> (8 * i)) & 0xFF)); + } + } +} + +void MsgPack::Writer::WriteUInt(u64 v) { + if (v < 0x80) { + out.push_back(static_cast(v)); + } else if (v <= 0xFF) { + out.push_back(0xCC); + out.push_back(static_cast(v)); + } else if (v <= 0xFFFF) { + out.push_back(0xCD); + out.push_back(static_cast((v >> 8) & 0xFF)); + out.push_back(static_cast(v & 0xFF)); + } else if (v <= 0xFFFFFFFFULL) { + out.push_back(0xCE); + out.push_back(static_cast((v >> 24) & 0xFF)); + out.push_back(static_cast((v >> 16) & 0xFF)); + out.push_back(static_cast((v >> 8) & 0xFF)); + out.push_back(static_cast(v & 0xFF)); + } else { + out.push_back(0xCF); + for (int i = 7; i >= 0; --i) { + out.push_back(static_cast((v >> (8 * i)) & 0xFF)); + } + } +} + +void MsgPack::Writer::WriteNil() { + out.push_back(0xC0); +} + +void MsgPack::Writer::WriteFixArray(size_t count) { + if (count <= 15) { + out.push_back(static_cast(0x90 | count)); + } else if (count <= 0xFFFF) { + out.push_back(0xDC); + out.push_back(static_cast((count >> 8) & 0xFF)); + out.push_back(static_cast(count & 0xFF)); + } else { + out.push_back(0xDD); + out.push_back(static_cast((count >> 24) & 0xFF)); + out.push_back(static_cast((count >> 16) & 0xFF)); + out.push_back(static_cast((count >> 8) & 0xFF)); + out.push_back(static_cast(count & 0xFF)); + } +} + +void MsgPack::Writer::WriteBinary(const std::vector& data) { + if (data.size() <= 0xFF) { + out.push_back(0xC4); + out.push_back(static_cast(data.size())); + } else if (data.size() <= 0xFFFF) { + out.push_back(0xC5); + out.push_back(static_cast((data.size() >> 8) & 0xFF)); + out.push_back(static_cast(data.size() & 0xFF)); + } else { + out.push_back(0xC6); + out.push_back(static_cast((data.size() >> 24) & 0xFF)); + out.push_back(static_cast((data.size() >> 16) & 0xFF)); + out.push_back(static_cast((data.size() >> 8) & 0xFF)); + out.push_back(static_cast(data.size() & 0xFF)); + } + WriteBytes(data); +} + +void MsgPack::Writer::WriteBool(bool v) { + out.push_back(v ? 0xC3 : 0xC2); +} + +std::vector MsgPack::Writer::Take() { + return std::move(out); +} + +MsgPack::Reader::Reader(std::span buffer) : data(buffer) {} + +bool MsgPack::Reader::Fail(const char* msg) { + error = msg; + return false; +} + +bool MsgPack::Reader::Ensure(size_t n) const { + return offset + n <= data.size(); +} + +u8 MsgPack::Reader::Peek() const { + return Ensure(1) ? data[offset] : 0; +} + +u8 MsgPack::Reader::ReadByte() { + if (!Ensure(1)) { + return 0; + } + return data[offset++]; +} + +bool MsgPack::Reader::ReadSize(size_t byte_count, size_t& out_size) { + if (!Ensure(byte_count)) { + return Fail("size out of range"); + } + size_t value = 0; + for (size_t i = 0; i < byte_count; ++i) { + value = (value << 8) | data[offset + i]; + } + offset += byte_count; + out_size = value; + return true; +} + +bool MsgPack::Reader::SkipBytes(size_t n) { + if (!Ensure(n)) { + return Fail("skip out of range"); + } + offset += n; + return true; +} + +bool MsgPack::Reader::SkipValue() { + if (End()) { + return Fail("unexpected end"); + } + const u8 byte = ReadByte(); + + if (byte <= 0x7F || (byte >= 0xE0)) { + return true; + } + if ((byte & 0xE0) == 0xA0) { + const size_t len = byte & 0x1F; + return SkipBytes(len); + } + if ((byte & 0xF0) == 0x80) { + const size_t count = byte & 0x0F; + return SkipContainer(count * 2, true); + } + if ((byte & 0xF0) == 0x90) { + const size_t count = byte & 0x0F; + return SkipContainer(count, false); + } + + switch (byte) { + case 0xC0: + case 0xC2: + case 0xC3: + return true; + case 0xC4: + case 0xC5: + case 0xC6: { + size_t size = 0; + if (!ReadSize(static_cast(1) << (byte - 0xC4), size)) { + return false; + } + return SkipBytes(size); + } + case 0xC7: + case 0xC8: + case 0xC9: + return Fail("ext not supported"); + case 0xCA: + return SkipBytes(4); + case 0xCB: + return SkipBytes(8); + case 0xCC: + return SkipBytes(1); + case 0xCD: + return SkipBytes(2); + case 0xCE: + return SkipBytes(4); + case 0xCF: + return SkipBytes(8); + case 0xD0: + return SkipBytes(1); + case 0xD1: + return SkipBytes(2); + case 0xD2: + return SkipBytes(4); + case 0xD3: + return SkipBytes(8); + case 0xD4: + case 0xD5: + case 0xD6: + case 0xD7: + case 0xD8: + return Fail("fixext not supported"); + case 0xD9: { + size_t size = 0; + if (!ReadSize(1, size)) { + return false; + } + return SkipBytes(size); + } + case 0xDA: { + size_t size = 0; + if (!ReadSize(2, size)) { + return false; + } + return SkipBytes(size); + } + case 0xDB: { + size_t size = 0; + if (!ReadSize(4, size)) { + return false; + } + return SkipBytes(size); + } + case 0xDC: { + size_t count = 0; + if (!ReadSize(2, count)) { + return false; + } + return SkipContainer(count, false); + } + case 0xDD: { + size_t count = 0; + if (!ReadSize(4, count)) { + return false; + } + return SkipContainer(count, false); + } + case 0xDE: { + size_t count = 0; + if (!ReadSize(2, count)) { + return false; + } + return SkipContainer(count * 2, true); + } + case 0xDF: { + size_t count = 0; + if (!ReadSize(4, count)) { + return false; + } + return SkipContainer(count * 2, true); + } + default: + return Fail("unknown type"); + } +} + +bool MsgPack::Reader::SkipContainer(size_t count, bool /*map_mode*/) { + for (size_t i = 0; i < count; ++i) { + if (!SkipValue()) { + return false; + } + } + return true; +} + +bool MsgPack::Reader::SkipAll() { + while (!End()) { + if (!SkipValue()) { + return false; + } + } + return true; +} + +bool MsgPack::Reader::ReadMapHeader(size_t& count) { + if (End()) { + return Fail("unexpected end"); + } + const u8 byte = Peek(); + if ((byte & 0xF0) == 0x80) { + count = byte & 0x0F; + offset++; + return true; + } + if (byte == 0xDE) { + ReadByte(); + return ReadSize(2, count); + } + if (byte == 0xDF) { + ReadByte(); + return ReadSize(4, count); + } + return Fail("not a map"); +} + +bool MsgPack::Reader::ReadArrayHeader(size_t& count) { + if (End()) { + return Fail("unexpected end"); + } + const u8 byte = Peek(); + if ((byte & 0xF0) == 0x90) { + count = byte & 0x0F; + offset++; + return true; + } + if (byte == 0xDC) { + ReadByte(); + return ReadSize(2, count); + } + if (byte == 0xDD) { + ReadByte(); + return ReadSize(4, count); + } + return Fail("not an array"); +} + +bool MsgPack::Reader::ReadUInt(u64& value) { + if (End()) { + return Fail("unexpected end"); + } + const u8 byte = Peek(); + if (byte <= 0x7F) { + value = byte; + offset++; + return true; + } + if (byte == 0xCC) { + ReadByte(); + if (!Ensure(1)) { + return Fail("uint8 truncated"); + } + value = data[offset++]; + return true; + } + if (byte == 0xCD) { + ReadByte(); + size_t tmp = 0; + if (!ReadSize(2, tmp)) { + return false; + } + value = tmp; + return true; + } + if (byte == 0xCE) { + ReadByte(); + size_t tmp = 0; + if (!ReadSize(4, tmp)) { + return false; + } + value = tmp; + return true; + } + if (byte == 0xCF) { + ReadByte(); + if (!Ensure(8)) { + return Fail("uint64 truncated"); + } + u64 tmp = 0; + for (int i = 0; i < 8; ++i) { + tmp = (tmp << 8) | data[offset + i]; + } + offset += 8; + value = tmp; + return true; + } + return Fail("not uint"); +} + +bool MsgPack::Reader::ReadInt(s64& value) { + if (End()) { + return Fail("unexpected end"); + } + const u8 byte = Peek(); + if (byte <= 0x7F) { + value = byte; + offset++; + return true; + } + if (byte >= 0xE0) { + value = static_cast(byte); + offset++; + return true; + } + if (byte == 0xD0) { + ReadByte(); + if (!Ensure(1)) { + return Fail("int8 truncated"); + } + value = static_cast(data[offset]); + offset += 1; + return true; + } + if (byte == 0xD1) { + ReadByte(); + if (!Ensure(2)) { + return Fail("int16 truncated"); + } + value = static_cast((data[offset] << 8) | data[offset + 1]); + offset += 2; + return true; + } + if (byte == 0xD2) { + ReadByte(); + if (!Ensure(4)) { + return Fail("int32 truncated"); + } + const s32 tmp = static_cast((data[offset] << 24) | (data[offset + 1] << 16) | + (data[offset + 2] << 8) | data[offset + 3]); + offset += 4; + value = tmp; + return true; + } + if (byte == 0xD3) { + ReadByte(); + if (!Ensure(8)) { + return Fail("int64 truncated"); + } + s64 tmp = 0; + for (int i = 0; i < 8; ++i) { + tmp = (tmp << 8) | data[offset + i]; + } + offset += 8; + value = tmp; + return true; + } + return Fail("not int"); +} + +bool MsgPack::Reader::ReadBool(bool& value) { + if (End()) { + return Fail("unexpected end"); + } + const u8 byte = ReadByte(); + if (byte == 0xC2) { + value = false; + return true; + } + if (byte == 0xC3) { + value = true; + return true; + } + return Fail("not bool"); +} + +bool MsgPack::Reader::ReadString(std::string& value) { + if (End()) { + return Fail("unexpected end"); + } + const u8 byte = ReadByte(); + size_t len = 0; + if ((byte & 0xE0) == 0xA0) { + len = byte & 0x1F; + } else if (byte == 0xD9) { + if (!ReadSize(1, len)) { + return false; + } + } else if (byte == 0xDA) { + if (!ReadSize(2, len)) { + return false; + } + } else if (byte == 0xDB) { + if (!ReadSize(4, len)) { + return false; + } + } else { + return Fail("not string"); + } + + if (!Ensure(len)) { + return Fail("string truncated"); + } + value.assign(reinterpret_cast(data.data() + offset), len); + offset += len; + return true; +} + +bool MsgPack::Reader::ReadBinary(std::vector& value) { + if (End()) { + return Fail("unexpected end"); + } + const u8 byte = ReadByte(); + size_t len = 0; + if (byte == 0xC4) { + if (!ReadSize(1, len)) { + return false; + } + } else if (byte == 0xC5) { + if (!ReadSize(2, len)) { + return false; + } + } else if (byte == 0xC6) { + if (!ReadSize(4, len)) { + return false; + } + } else { + return Fail("not binary"); + } + if (!Ensure(len)) { + return Fail("binary truncated"); + } + value.assign(data.begin() + offset, data.begin() + offset + len); + offset += len; + return true; +} + +bool MsgPack::Reader::ReadBinaryCompat(std::vector& value) { + if (!ReadBinary(value)) { + value.clear(); + return false; + } + return true; +} + +bool MsgPack::Reader::ReadStringArray(std::vector& out) { + size_t count = 0; + if (!ReadArrayHeader(count)) { + return false; + } + out.clear(); + out.reserve(count); + for (size_t i = 0; i < count; ++i) { + std::string value; + if (!ReadString(value)) { + return false; + } + out.push_back(std::move(value)); + } + return true; +} + +bool MsgPack::Reader::ReadNewsVersion(NewsStruct::Version& out) { + size_t count = 0; + if (!ReadMapHeader(count)) { + return false; + } + for (size_t i = 0; i < count; ++i) { + std::string key; + if (!ReadString(key)) { + return false; + } + if (key == "format") { + if (!ReadUInt(out.format)) { + return false; + } + } else if (key == "semantics") { + if (!ReadUInt(out.semantics)) { + return false; + } + } else { + if (!SkipValue()) { + return false; + } + } + } + return true; +} + +bool MsgPack::Reader::ReadNewsSubject(NewsStruct::Subject& out) { + size_t count = 0; + if (!ReadMapHeader(count)) { + return false; + } + for (size_t i = 0; i < count; ++i) { + std::string key; + if (!ReadString(key)) { + return false; + } + if (key == "caption") { + if (!ReadUInt(out.caption)) { + return false; + } + } else if (key == "text") { + if (!ReadString(out.text)) { + return false; + } + } else { + if (!SkipValue()) { + return false; + } + } + } + return true; +} + +bool MsgPack::Reader::ReadNewsFooter(NewsStruct::Footer& out) { + size_t count = 0; + if (!ReadMapHeader(count)) { + return false; + } + for (size_t i = 0; i < count; ++i) { + std::string key; + if (!ReadString(key)) { + return false; + } + if (key == "text") { + if (!ReadString(out.text)) { + return false; + } + } else { + if (!SkipValue()) { + return false; + } + } + } + return true; +} + +bool MsgPack::Reader::ReadByteArray(std::vector& out) { + if (!ReadBinary(out)) { + out.clear(); + return false; + } + return true; +} + +bool MsgPack::Reader::ReadNewsBody(NewsStruct::Body& out) { + size_t count = 0; + if (!ReadMapHeader(count)) { + return false; + } + for (size_t i = 0; i < count; ++i) { + std::string key; + if (!ReadString(key)) { + return false; + } + if (key == "text") { + if (!ReadString(out.text)) { + return false; + } + } else if (key == "main_image_height") { + if (!ReadUInt(out.main_image_height)) { + return false; + } + } else if (key == "movie_url") { + if (!ReadString(out.movie_url)) { + return false; + } + } else if (key == "main_image") { + if (!ReadByteArray(out.main_image)) { + return false; + } + } else { + if (!SkipValue()) { + return false; + } + } + } + return true; +} + +bool MsgPack::Reader::ReadNewsBrowser(NewsStruct::More::Browser& out) { + size_t count = 0; + if (!ReadMapHeader(count)) { + return false; + } + out.present = true; + for (size_t i = 0; i < count; ++i) { + std::string key; + if (!ReadString(key)) { + return false; + } + if (key == "url") { + if (!ReadString(out.url)) { + return false; + } + } else if (key == "text") { + if (!ReadString(out.text)) { + return false; + } + } else { + if (!SkipValue()) { + return false; + } + } + } + return true; +} + +bool MsgPack::Reader::ReadNewsMore(NewsStruct::More& out) { + size_t count = 0; + if (!ReadMapHeader(count)) { + return false; + } + out.has_browser = false; + for (size_t i = 0; i < count; ++i) { + std::string key; + if (!ReadString(key)) { + return false; + } + if (key == "browser") { + out.has_browser = true; + if (!ReadNewsBrowser(out.browser)) { + return false; + } + } else { + if (!SkipValue()) { + return false; + } + } + } + return true; +} + +bool MsgPack::Reader::ReadNewsStruct(NewsStruct& out) { + size_t count = 0; + if (!ReadMapHeader(count)) { + return false; + } + for (size_t i = 0; i < count; ++i) { + std::string key; + if (!ReadString(key)) { + return false; + } + + auto read_u64 = [&](u64& target) -> bool { + return ReadUInt(target); + }; + + if (key == "version") { + if (!ReadNewsVersion(out.version)) { + return false; + } + } else if (key == "news_id") { + if (!read_u64(out.news_id)) { + return false; + } + } else if (key == "published_at") { + if (!read_u64(out.published_at)) { + return false; + } + } else if (key == "pickup_limit") { + if (!read_u64(out.pickup_limit)) { + return false; + } + } else if (key == "priority") { + if (!read_u64(out.priority)) { + return false; + } + } else if (key == "deletion_priority") { + if (!read_u64(out.deletion_priority)) { + return false; + } + } else if (key == "language") { + if (!ReadString(out.language)) { + return false; + } + } else if (key == "supported_languages") { + if (!ReadStringArray(out.supported_languages)) { + return false; + } + } else if (key == "display_type") { + if (!ReadString(out.display_type)) { + return false; + } + } else if (key == "topic_id") { + if (!ReadString(out.topic_id)) { + return false; + } + } else if (key == "no_photography") { + if (!read_u64(out.no_photography)) { + return false; + } + } else if (key == "surprise") { + if (!read_u64(out.surprise)) { + return false; + } + } else if (key == "bashotorya") { + if (!read_u64(out.bashotorya)) { + return false; + } + } else if (key == "movie") { + if (!read_u64(out.movie)) { + return false; + } + } else if (key == "subject") { + if (!ReadNewsSubject(out.subject)) { + return false; + } + } else if (key == "topic_name") { + if (!ReadString(out.topic_name)) { + return false; + } + } else if (key == "list_image") { + if (!ReadByteArray(out.list_image)) { + return false; + } + } else if (key == "footer") { + if (!ReadNewsFooter(out.footer)) { + return false; + } + } else if (key == "allow_domains") { + if (!ReadString(out.allow_domains)) { + return false; + } + } else if (key == "more") { + if (!ReadNewsMore(out.more)) { + return false; + } + } else if (key == "body") { + if (!ReadNewsBody(out.body)) { + return false; + } + } else if (key == "contents_descriptors") { + if (!ReadString(out.contents_descriptors)) { + return false; + } + } else if (key == "interactive_elements") { + if (!ReadString(out.interactive_elements)) { + return false; + } + } else { + if (!SkipValue()) { + return false; + } + } + } + return true; +} + +} // namespace Service::News diff --git a/src/core/hle/service/bcat/news/msgpack.h b/src/core/hle/service/bcat/news/msgpack.h new file mode 100644 index 0000000000..a7a2aa40ba --- /dev/null +++ b/src/core/hle/service/bcat/news/msgpack.h @@ -0,0 +1,149 @@ +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include + +#include "common/common_types.h" + +namespace Service::News { + +struct NewsStruct { + struct Version { + u64 format{}; + u64 semantics{}; + }; + + struct Subject { + u64 caption{}; + std::string text; + }; + + struct Footer { + std::string text; + }; + + struct Body { + std::string text; + u64 main_image_height{}; + std::string movie_url; + std::vector main_image; + }; + + struct More { + struct Browser { + std::string url; + std::string text; + bool present{}; + } browser; + bool has_browser{}; + }; + + Version version; + u64 news_id{}; + u64 published_at{}; + u64 pickup_limit{}; + u64 priority{}; + u64 deletion_priority{}; + std::string language; + std::vector supported_languages; + std::string display_type; + std::string topic_id; + u64 no_photography{}; + u64 surprise{}; + u64 bashotorya{}; + u64 movie{}; + Subject subject; + std::string topic_name; + std::vector list_image; + Footer footer; + std::string allow_domains; + More more; + Body body; + std::string contents_descriptors; + std::string interactive_elements; +}; + +class MsgPack { +public: + class Writer { + public: + void WriteFixMap(size_t count); + void WriteMap32(size_t count); + void WriteKey(std::string_view key); + void WriteString(std::string_view value); + void WriteInt64(s64 value); + void WriteUInt(u64 value); + void WriteNil(); + void WriteFixArray(size_t count); + void WriteBinary(const std::vector& data); + void WriteBool(bool value); + + std::vector Take(); + const std::vector& Buffer() const { return out; } + void Clear() { out.clear(); } + + private: + void WriteBytes(std::span bytes); + + std::vector out; + }; + + class Reader { + public: + explicit Reader(std::span buffer); + + template + bool Read(T& out) { + if constexpr (std::is_same_v) { + return ReadNewsStruct(out); + } else { + static_assert(sizeof(T) == 0, "Unsupported MsgPack::Reader::Read type"); + } + } + + bool SkipValue(); + bool SkipAll(); + + bool ReadMapHeader(size_t& count); + bool ReadArrayHeader(size_t& count); + bool ReadUInt(u64& value); + bool ReadInt(s64& value); + bool ReadBool(bool& value); + bool ReadString(std::string& value); + bool ReadBinary(std::vector& value); + + bool End() const { return offset >= data.size(); } + std::string_view Error() const { return error; } + + private: + bool Fail(const char* msg); + bool Ensure(size_t n) const; + u8 Peek() const; + u8 ReadByte(); + bool ReadSize(size_t byte_count, size_t& out_size); + bool SkipBytes(size_t n); + bool SkipContainer(size_t count, bool map_mode); + bool ReadNewsStruct(NewsStruct& out); + bool ReadNewsVersion(NewsStruct::Version& out); + bool ReadNewsSubject(NewsStruct::Subject& out); + bool ReadNewsFooter(NewsStruct::Footer& out); + bool ReadNewsBody(NewsStruct::Body& out); + bool ReadNewsMore(NewsStruct::More& out); + bool ReadNewsBrowser(NewsStruct::More::Browser& out); + bool ReadStringArray(std::vector& out); + bool ReadBinaryCompat(std::vector& out); + bool ReadByteArray(std::vector& out); + + std::span data; + size_t offset{0}; + std::string error; + }; +}; + +} // namespace Service::News diff --git a/src/core/hle/service/bcat/news/news_data_service.cpp b/src/core/hle/service/bcat/news/news_data_service.cpp index 08103c9c3b..72055fc66a 100644 --- a/src/core/hle/service/bcat/news/news_data_service.cpp +++ b/src/core/hle/service/bcat/news/news_data_service.cpp @@ -1,25 +1,125 @@ +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: Copyright 2024 yuzu Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later #include "core/hle/service/bcat/news/news_data_service.h" +#include "core/hle/service/bcat/news/builtin_news.h" +#include "core/hle/service/bcat/news/news_storage.h" +#include "core/hle/service/cmif_serialization.h" + +#include "common/logging/log.h" + +#include namespace Service::News { +namespace { + +std::string_view ToStringView(std::span buf) { + const std::string_view sv{buf.data(), buf.size()}; + const auto nul = sv.find('\0'); + return nul == std::string_view::npos ? sv : sv.substr(0, nul); +} + +} // namespace INewsDataService::INewsDataService(Core::System& system_) : ServiceFramework{system_, "INewsDataService"} { - // clang-format off static const FunctionInfo functions[] = { - {0, nullptr, "Open"}, - {1, nullptr, "OpenWithNewsRecordV1"}, - {2, nullptr, "Read"}, - {3, nullptr, "GetSize"}, - {1001, nullptr, "OpenWithNewsRecord"}, + {0, D<&INewsDataService::Open>, "Open"}, + {1, D<&INewsDataService::OpenWithNewsRecordV1>, "OpenWithNewsRecordV1"}, + {2, D<&INewsDataService::Read>, "Read"}, + {3, D<&INewsDataService::GetSize>, "GetSize"}, + {1001, D<&INewsDataService::OpenWithNewsRecord>, "OpenWithNewsRecord"}, }; - // clang-format on - RegisterHandlers(functions); } INewsDataService::~INewsDataService() = default; +bool INewsDataService::TryOpen(std::string_view key, std::string_view user) { + opened_payload.clear(); + + if (auto found = NewsStorage::Instance().FindByNewsId(key, user)) { + opened_payload = std::move(found->payload); + return true; + } + + if (!user.empty()) { + if (auto found = NewsStorage::Instance().FindByNewsId(key)) { + opened_payload = std::move(found->payload); + return true; + } + } + + const auto list = NewsStorage::Instance().ListAll(); + if (!list.empty()) { + if (auto found = NewsStorage::Instance().FindByNewsId(ToStringView(list.front().news_id))) { + opened_payload = std::move(found->payload); + return true; + } + } + + return false; +} + +Result INewsDataService::Open(InBuffer name) { + EnsureBuiltinNewsLoaded(); + + const auto key = ToStringView({reinterpret_cast(name.data()), name.size()}); + + if (TryOpen(key, {})) { + R_SUCCEED(); + } + + R_RETURN(ResultUnknown); +} + +Result INewsDataService::OpenWithNewsRecordV1(NewsRecordV1 record) { + EnsureBuiltinNewsLoaded(); + + const auto key = ToStringView(record.news_id); + const auto user = ToStringView(record.user_id); + + if (TryOpen(key, user)) { + R_SUCCEED(); + } + + R_RETURN(ResultUnknown); +} + +Result INewsDataService::OpenWithNewsRecord(NewsRecord record) { + EnsureBuiltinNewsLoaded(); + + const auto key = ToStringView(record.news_id); + const auto user = ToStringView(record.user_id); + + if (TryOpen(key, user)) { + R_SUCCEED(); + } + + R_RETURN(ResultUnknown); +} + +Result INewsDataService::Read(Out out_size, s64 offset, + OutBuffer out_buffer) { + const auto off = static_cast(std::max(0, offset)); + + if (off >= opened_payload.size()) { + *out_size = 0; + R_SUCCEED(); + } + + const size_t len = std::min(out_buffer.size(), opened_payload.size() - off); + std::memcpy(out_buffer.data(), opened_payload.data() + off, len); + *out_size = len; + R_SUCCEED(); +} + +Result INewsDataService::GetSize(Out out_size) { + *out_size = static_cast(opened_payload.size()); + R_SUCCEED(); +} + } // namespace Service::News diff --git a/src/core/hle/service/bcat/news/news_data_service.h b/src/core/hle/service/bcat/news/news_data_service.h index 12082ada41..8f5e031701 100644 --- a/src/core/hle/service/bcat/news/news_data_service.h +++ b/src/core/hle/service/bcat/news/news_data_service.h @@ -1,10 +1,16 @@ +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: Copyright 2024 yuzu Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later #pragma once +#include "core/hle/service/cmif_types.h" #include "core/hle/service/service.h" +#include "core/hle/service/bcat/news/news_storage.h" + namespace Core { class System; } @@ -15,6 +21,17 @@ class INewsDataService final : public ServiceFramework { public: explicit INewsDataService(Core::System& system_); ~INewsDataService() override; + +private: + bool TryOpen(std::string_view key, std::string_view user); + + Result Open(InBuffer name); + Result OpenWithNewsRecordV1(NewsRecordV1 record_buffer); + Result OpenWithNewsRecord(NewsRecord record_buffer); + Result Read(Out out_size, s64 offset, OutBuffer out_buffer); + Result GetSize(Out out_size); + + std::vector opened_payload; }; } // namespace Service::News diff --git a/src/core/hle/service/bcat/news/news_database_service.cpp b/src/core/hle/service/bcat/news/news_database_service.cpp index b94ef0636f..f2f8a3286a 100644 --- a/src/core/hle/service/bcat/news/news_database_service.cpp +++ b/src/core/hle/service/bcat/news/news_database_service.cpp @@ -1,52 +1,194 @@ +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: Copyright 2024 yuzu Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later #include "core/hle/service/bcat/news/news_database_service.h" +#include "core/hle/service/bcat/news/builtin_news.h" +#include "core/hle/service/bcat/news/news_storage.h" #include "core/hle/service/cmif_serialization.h" +#include +#include + namespace Service::News { +namespace { + +std::string_view ToStringView(std::span buf) { + if (buf.empty()) return {}; + auto data = reinterpret_cast(buf.data()); + return {data, strnlen(data, buf.size())}; +} + +std::string_view ToStringView(std::span buf) { + if (buf.empty()) return {}; + return {buf.data(), strnlen(buf.data(), buf.size())}; +} + +bool UpdateField(NewsRecord& rec, std::string_view column, s32 value, bool additive) { + auto apply = [&](s32& field) { + field = additive ? field + value : value; + return true; + }; + + if (column == "read") return apply(rec.read); + if (column == "newly") return apply(rec.newly); + if (column == "displayed") return apply(rec.displayed); + if (column == "extra1" || column == "extra_1") return apply(rec.extra1); + if (column == "extra2" || column == "extra_2") return apply(rec.extra2); + + // Accept but ignore fields that don't exist in our struct + return column == "priority" || column == "decoration_type" || + column == "feedback" || column == "category"; +} + +} // namespace INewsDatabaseService::INewsDatabaseService(Core::System& system_) : ServiceFramework{system_, "INewsDatabaseService"} { - // clang-format off static const FunctionInfo functions[] = { - {0, nullptr, "GetListV1"}, + {0, D<&INewsDatabaseService::GetListV1>, "GetListV1"}, {1, D<&INewsDatabaseService::Count>, "Count"}, - {2, nullptr, "CountWithKey"}, - {3, nullptr, "UpdateIntegerValue"}, + {2, D<&INewsDatabaseService::CountWithKey>, "CountWithKey"}, + {3, D<&INewsDatabaseService::UpdateIntegerValue>, "UpdateIntegerValue"}, {4, D<&INewsDatabaseService::UpdateIntegerValueWithAddition>, "UpdateIntegerValueWithAddition"}, - {5, nullptr, "UpdateStringValue"}, + {5, D<&INewsDatabaseService::UpdateStringValue>, "UpdateStringValue"}, {1000, D<&INewsDatabaseService::GetList>, "GetList"}, }; - // clang-format on - RegisterHandlers(functions); } INewsDatabaseService::~INewsDatabaseService() = default; -Result INewsDatabaseService::Count(Out out_count, - InBuffer buffer_data) { - LOG_WARNING(Service_BCAT, "(STUBBED) called, buffer_size={}", buffer_data.size()); - *out_count = 0; +Result INewsDatabaseService::Count(Out out_count, InBuffer where) { + EnsureBuiltinNewsLoaded(); + *out_count = static_cast(NewsStorage::Instance().ListAll().size()); R_SUCCEED(); } -Result INewsDatabaseService::UpdateIntegerValueWithAddition( - u32 value, InBuffer buffer_data_1, - InBuffer buffer_data_2) { - LOG_WARNING(Service_BCAT, "(STUBBED) called, value={}, buffer_size_1={}, buffer_data_2={}", - value, buffer_data_1.size(), buffer_data_2.size()); +Result INewsDatabaseService::CountWithKey(Out out_count, + InBuffer key, + InBuffer where) { + EnsureBuiltinNewsLoaded(); + *out_count = static_cast(NewsStorage::Instance().ListAll().size()); R_SUCCEED(); } -Result INewsDatabaseService::GetList(Out out_count, u32 value, - OutBuffer out_buffer_data, - InBuffer buffer_data_1, - InBuffer buffer_data_2) { - LOG_WARNING(Service_BCAT, "(STUBBED) called, value={}, buffer_size_1={}, buffer_data_2={}", - value, buffer_data_1.size(), buffer_data_2.size()); - *out_count = 0; +Result INewsDatabaseService::UpdateIntegerValue(u32 value, + InBuffer key, + InBuffer where) { + const auto column = ToStringView(key); + for (const auto& rec : NewsStorage::Instance().ListAll()) { + NewsStorage::Instance().UpdateRecord( + ToStringView(rec.news_id), {}, + [&](NewsRecord& r) { UpdateField(r, column, static_cast(value), false); }); + } + R_SUCCEED(); +} + +Result INewsDatabaseService::UpdateIntegerValueWithAddition(u32 value, + InBuffer key, + InBuffer where) { + const auto column = ToStringView(key); + const auto where_str = ToStringView(where); + + // Extract news_id from where clause like "N_SWITCH(news_id,'LA00000000000123456',1,0)=1" + auto extract_news_id = [](std::string_view w) -> std::string { + auto pos = w.find("'LA"); + if (pos == std::string_view::npos) return {}; + pos++; // skip the ' + auto end = w.find("'", pos); + if (end == std::string_view::npos) return {}; + return std::string(w.substr(pos, end - pos)); + }; + + const auto news_id = extract_news_id(where_str); + + if (column == "read" && value > 0 && !news_id.empty()) { + NewsStorage::Instance().MarkAsRead(news_id); + } else if (!news_id.empty()) { + NewsStorage::Instance().UpdateRecord(news_id, {}, + [&](NewsRecord& r) { UpdateField(r, column, static_cast(value), true); }); + } + + R_SUCCEED(); +} + +Result INewsDatabaseService::UpdateStringValue(InBuffer key, + InBuffer value, + InBuffer where) { + LOG_WARNING(Service_BCAT, "(STUBBED) UpdateStringValue"); + R_SUCCEED(); +} + +Result INewsDatabaseService::GetListV1(Out out_count, + OutBuffer out_buffer, + InBuffer where, + InBuffer order, + s32 offset) { + EnsureBuiltinNewsLoaded(); + + auto record_size = sizeof(NewsRecordV1); + + if (out_buffer.size() < record_size) { + *out_count = 0; + R_SUCCEED(); + } + + std::memset(out_buffer.data(), 0, out_buffer.size()); + + const auto list = NewsStorage::Instance().ListAll(); + const size_t start = static_cast(std::max(0, offset)); + const size_t max_records = out_buffer.size() / record_size; + const size_t available = start < list.size() ? list.size() - start : 0; + const size_t count = std::min(max_records, available); + + for (size_t i = 0; i < count; ++i) { + const auto& src = list[start + i]; + NewsRecordV1 dst{}; + std::memcpy(dst.news_id.data(), src.news_id.data(), dst.news_id.size()); + std::memcpy(dst.user_id.data(), src.user_id.data(), dst.user_id.size()); + dst.received_time = src.received_time; + dst.read = src.read; + dst.newly = src.newly; + dst.displayed = src.displayed; + dst.extra1 = src.extra1; + std::memcpy(out_buffer.data() + i * record_size, &dst, record_size); + } + + *out_count = static_cast(count); + R_SUCCEED(); +} + +Result INewsDatabaseService::GetList(Out out_count, + OutBuffer out_buffer, + InBuffer where, + InBuffer order, + s32 offset) { + EnsureBuiltinNewsLoaded(); + NewsStorage::Instance().ResetOpenCounter(); + + auto record_size = sizeof(NewsRecord); + + if (out_buffer.size() < record_size) { + *out_count = 0; + R_SUCCEED(); + } + + std::memset(out_buffer.data(), 0, out_buffer.size()); + + const auto list = NewsStorage::Instance().ListAll(); + const size_t start = static_cast(std::max(0, offset)); + const size_t max_records = out_buffer.size() / record_size; + const size_t available = start < list.size() ? list.size() - start : 0; + const size_t count = std::min(max_records, available); + + if (count > 0) { + std::memcpy(out_buffer.data(), list.data() + start, count * record_size); + } + + *out_count = static_cast(count); R_SUCCEED(); } diff --git a/src/core/hle/service/bcat/news/news_database_service.h b/src/core/hle/service/bcat/news/news_database_service.h index 860b7074ca..143864e80e 100644 --- a/src/core/hle/service/bcat/news/news_database_service.h +++ b/src/core/hle/service/bcat/news/news_database_service.h @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: Copyright 2024 yuzu Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later @@ -6,6 +9,8 @@ #include "core/hle/service/cmif_types.h" #include "core/hle/service/service.h" +#include "core/hle/service/bcat/news/news_storage.h" + namespace Core { class System; } @@ -20,13 +25,30 @@ public: private: Result Count(Out out_count, InBuffer buffer_data); + Result CountWithKey(Out out_count, InBuffer key, + InBuffer where); + + Result UpdateIntegerValue(u32 value, InBuffer key, + InBuffer where); + Result UpdateIntegerValueWithAddition(u32 value, InBuffer buffer_data_1, InBuffer buffer_data_2); - Result GetList(Out out_count, u32 value, + Result UpdateStringValue(InBuffer key, + InBuffer value, + InBuffer where); + + Result GetListV1(Out out_count, + OutBuffer out_buffer_data, + InBuffer where_phrase, + InBuffer order_by_phrase, + s32 offset); + + Result GetList(Out out_count, OutBuffer out_buffer_data, - InBuffer buffer_data_1, - InBuffer buffer_data_2); + InBuffer where_phrase, + InBuffer order_by_phrase, + s32 offset); }; } // namespace Service::News diff --git a/src/core/hle/service/bcat/news/news_service.cpp b/src/core/hle/service/bcat/news/news_service.cpp index 09f7e67aff..aed1536bfe 100644 --- a/src/core/hle/service/bcat/news/news_service.cpp +++ b/src/core/hle/service/bcat/news/news_service.cpp @@ -5,32 +5,35 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "core/hle/service/bcat/news/news_service.h" +#include "core/hle/service/bcat/news/news_storage.h" #include "core/hle/service/cmif_serialization.h" +#include + namespace Service::News { INewsService::INewsService(Core::System& system_) : ServiceFramework{system_, "INewsService"} { // clang-format off static const FunctionInfo functions[] = { {10100, D<&INewsService::PostLocalNews>, "PostLocalNews"}, - {20100, nullptr, "SetPassphrase"}, + {20100, D<&INewsService::SetPassphrase>, "SetPassphrase"}, {30100, D<&INewsService::GetSubscriptionStatus>, "GetSubscriptionStatus"}, - {30101, nullptr, "GetTopicList"}, //3.0.0+ - {30110, nullptr, "Unknown30110"}, //6.0.0+ + {30101, D<&INewsService::GetTopicList>, "GetTopicList"}, //3.0.0+ + {30110, D<&INewsService::GetTopicList>, "Unknown30110"}, //6.0.0+ (stub) {30200, D<&INewsService::IsSystemUpdateRequired>, "IsSystemUpdateRequired"}, - {30201, nullptr, "Unknown30201"}, //8.0.0+ - {30210, nullptr, "Unknown30210"}, //10.0.0+ + {30201, D<&INewsService::IsSystemUpdateRequired>, "Unknown30201"}, //8.0.0+ (stub) + {30210, D<&INewsService::IsSystemUpdateRequired>, "Unknown30210"}, //10.0.0+ (stub) {30300, nullptr, "RequestImmediateReception"}, - {30400, nullptr, "DecodeArchiveFile"}, //3.0.0-18.1.0 - {30500, nullptr, "Unknown30500"}, //8.0.0+ - {30900, nullptr, "Unknown30900"}, //1.0.0 - {30901, nullptr, "Unknown30901"}, //1.0.0 - {30902, nullptr, "Unknown30902"}, //1.0.0 + {30400, nullptr, "DecodeArchiveFile"}, //3.0.0-18.1.0 (stub) + {30500, nullptr, "Unknown30500"}, //8.0.0+ (stub) + {30900, nullptr, "Unknown30900"}, //1.0.0 (stub) + {30901, nullptr, "Unknown30901"}, //1.0.0 (stub) + {30902, nullptr, "Unknown30902"}, //1.0.0 (stub) {40100, nullptr, "SetSubscriptionStatus"}, {40101, D<&INewsService::RequestAutoSubscription>, "RequestAutoSubscription"}, //3.0.0+ - {40200, nullptr, "ClearStorage"}, - {40201, nullptr, "ClearSubscriptionStatusAll"}, - {90100, nullptr, "GetNewsDatabaseDump"}, + {40200, D<&INewsService::ClearStorage>, "ClearStorage"}, + {40201, D<&INewsService::ClearSubscriptionStatusAll>, "ClearSubscriptionStatusAll"}, + {90100, D<&INewsService::GetNewsDatabaseDump>, "GetNewsDatabaseDump"}, }; // clang-format on @@ -40,8 +43,7 @@ INewsService::INewsService(Core::System& system_) : ServiceFramework{system_, "I INewsService::~INewsService() = default; Result INewsService::PostLocalNews(InBuffer buffer_data) { - LOG_WARNING(Service_BCAT, "(STUBBED) called, buffer_size={}", buffer_data.size()); - + LOG_WARNING(Service_BCAT, "(STUBBED) PostLocalNews size={}", buffer_data.size()); R_SUCCEED(); } @@ -63,4 +65,44 @@ Result INewsService::RequestAutoSubscription(u64 value) { R_SUCCEED(); } +Result INewsService::SetPassphrase(InBuffer buffer_data) { + LOG_WARNING(Service_BCAT, "(STUBBED) SetPassphrase called size={}", buffer_data.size()); + R_SUCCEED(); +} + +Result INewsService::GetTopicList(Out out_count, OutBuffer out_topics, s32 filter) { + constexpr size_t TopicIdSize = 32; + constexpr auto EdenTopicId = "eden"; + + const size_t max_topics = out_topics.size() / TopicIdSize; + + if (max_topics == 0) { + *out_count = 0; + R_SUCCEED(); + } + + std::memset(out_topics.data(), 0, out_topics.size()); + + std::memcpy(out_topics.data(), EdenTopicId, std::strlen(EdenTopicId)); + *out_count = 1; + + R_SUCCEED(); +} + +Result INewsService::ClearStorage() { + LOG_WARNING(Service_BCAT, "(STUBBED) called"); + NewsStorage::Instance().Clear(); + R_SUCCEED(); +} + +Result INewsService::ClearSubscriptionStatusAll() { + LOG_WARNING(Service_BCAT, "(STUBBED) called"); + R_SUCCEED(); +} + +Result INewsService::GetNewsDatabaseDump() { + LOG_WARNING(Service_BCAT, "(STUBBED) called"); + R_SUCCEED(); +} + } // namespace Service::News diff --git a/src/core/hle/service/bcat/news/news_service.h b/src/core/hle/service/bcat/news/news_service.h index 245f08afc3..1a320df02f 100644 --- a/src/core/hle/service/bcat/news/news_service.h +++ b/src/core/hle/service/bcat/news/news_service.h @@ -1,3 +1,6 @@ +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + // SPDX-FileCopyrightText: Copyright 2024 yuzu Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later @@ -20,11 +23,21 @@ public: private: Result PostLocalNews(InBuffer buffer_data); + Result SetPassphrase(InBuffer buffer_data); + Result GetSubscriptionStatus(Out out_status, InBuffer buffer_data); + Result GetTopicList(Out out_count, OutBuffer out_topics, s32 filter); + Result IsSystemUpdateRequired(Out out_is_system_update_required); Result RequestAutoSubscription(u64 value); + + Result ClearStorage(); + + Result ClearSubscriptionStatusAll(); + + Result GetNewsDatabaseDump(); }; } // namespace Service::News diff --git a/src/core/hle/service/bcat/news/news_storage.cpp b/src/core/hle/service/bcat/news/news_storage.cpp new file mode 100644 index 0000000000..3590f6a91b --- /dev/null +++ b/src/core/hle/service/bcat/news/news_storage.cpp @@ -0,0 +1,169 @@ +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "core/hle/service/bcat/news/news_storage.h" + +#include "common/fs/path_util.h" + +#include +#include +#include +#include +#include +#include + +namespace Service::News { +namespace { + +std::filesystem::path GetReadCachePath() { + return Common::FS::GetEdenPath(Common::FS::EdenPath::CacheDir) / "news" / "news_read"; +} + +std::set LoadReadIds() { + std::set ids; + std::ifstream f(GetReadCachePath()); + std::string line; + while (std::getline(f, line)) { + if (!line.empty()) ids.insert(line); + } + return ids; +} + +void SaveReadIds(const std::set& ids) { + const auto path = GetReadCachePath(); + std::error_code ec; + std::filesystem::create_directories(path.parent_path(), ec); + std::ofstream f(path); + for (const auto& id : ids) { + f << id << '\n'; + } +} + +} // namespace + +NewsStorage& NewsStorage::Instance() { + static NewsStorage s; + return s; +} + +void NewsStorage::Clear() { + std::scoped_lock lk{mtx}; + items.clear(); +} + +void NewsStorage::CopyZ(std::span dst, std::string_view src) { + std::memset(dst.data(), 0, dst.size()); + std::memcpy(dst.data(), src.data(), std::min(dst.size() - 1, src.size())); +} + +std::string NewsStorage::MakeKey(std::string_view news_id, std::string_view user_id) { + return std::string(news_id) + "|" + std::string(user_id); +} + +s64 NewsStorage::Now() { + using namespace std::chrono; + return duration_cast(system_clock::now().time_since_epoch()).count(); +} + +StoredNews& NewsStorage::Upsert(std::string_view news_id, std::string_view user_id, + std::string_view topic_id, s64 time, std::vector payload) { + std::scoped_lock lk{mtx}; + + const auto key = MakeKey(news_id, user_id); + auto it = items.find(key); + const bool exists = it != items.end(); + + // implemented a little "read" file. All in cache. + const auto read_ids = LoadReadIds(); + const bool was_read = read_ids.contains(std::string(news_id)); + + NewsRecord rec{}; + CopyZ(rec.news_id, news_id); + CopyZ(rec.user_id, user_id); + // there is nx_notice and nx_promotion for tags pre-existing + CopyZ(rec.topic_id, topic_id.empty() ? "nx_notice" : topic_id); + + rec.received_time = exists ? it->second.record.received_time : (time ? time : Now()); + rec.read = was_read ? 1 : (exists ? it->second.record.read : 0); + rec.newly = was_read ? 0 : 1; + rec.displayed = exists ? it->second.record.displayed : 0; + rec.extra1 = exists ? it->second.record.extra1 : 0; + rec.extra2 = exists ? it->second.record.extra2 : 0; + + auto& entry = items[key]; + entry.record = rec; + entry.payload = std::move(payload); + return entry; +} + +StoredNews& NewsStorage::UpsertRaw(const GithubNewsMeta& meta, std::vector payload) { + return Upsert(meta.news_id, "", meta.topic_id, static_cast(meta.published_at), std::move(payload)); +} + +std::vector NewsStorage::ListAll() const { + std::scoped_lock lk{mtx}; + + std::vector out; + out.reserve(items.size()); + for (const auto& [_, v] : items) { + out.push_back(v.record); + } + + std::sort(out.begin(), out.end(), [](const auto& a, const auto& b) { + return a.received_time > b.received_time; + }); + return out; +} + +std::optional NewsStorage::FindByNewsId(std::string_view news_id, + std::string_view user_id) const { + std::scoped_lock lk{mtx}; + + if (auto it = items.find(MakeKey(news_id, user_id)); it != items.end()) { + return it->second; + } + if (!user_id.empty()) { + if (auto it = items.find(MakeKey(news_id, "")); it != items.end()) { + return it->second; + } + } + return std::nullopt; +} + +bool NewsStorage::UpdateRecord(std::string_view news_id, std::string_view user_id, + const std::function& updater) { + std::scoped_lock lk{mtx}; + + if (auto it = items.find(MakeKey(news_id, user_id)); it != items.end()) { + updater(it->second.record); + return true; + } + return false; +} + +void NewsStorage::MarkAsRead(std::string_view news_id) { + std::scoped_lock lk{mtx}; + for (auto& [_, entry] : items) { + if (std::string_view(entry.record.news_id.data()) == news_id) { + entry.record.read = 1; + entry.record.newly = 0; + break; + } + } + auto ids = LoadReadIds(); + ids.insert(std::string(news_id)); + SaveReadIds(ids); +} + +size_t NewsStorage::GetAndIncrementOpenCounter() { + std::scoped_lock lk{mtx}; + return open_counter++; +} + +void NewsStorage::ResetOpenCounter() { + std::scoped_lock lk{mtx}; + open_counter = 0; +} + + +} // namespace Service::News diff --git a/src/core/hle/service/bcat/news/news_storage.h b/src/core/hle/service/bcat/news/news_storage.h new file mode 100644 index 0000000000..ad84e4080c --- /dev/null +++ b/src/core/hle/service/bcat/news/news_storage.h @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +// SPDX-FileCopyrightText: Copyright 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "common/common_types.h" + +namespace Service::News { + +struct GithubNewsMeta { + std::string news_id; + std::string topic_id; + u64 published_at{}; + u64 pickup_limit{}; + u64 essential_pickup_limit{}; + u64 expire_at{}; + u32 priority{}; + u32 deletion_priority{100}; + u32 decoration_type{1}; + u32 opted_in{1}; + u32 essential_pickup_limit_flag{1}; + u32 category{}; + u32 language_mask{1}; +}; + +struct NewsRecordV1 { + std::array news_id{}; + std::array user_id{}; + s64 received_time{}; + s32 read{}; + s32 newly{}; + s32 displayed{}; + s32 extra1{}; +}; +static_assert(sizeof(NewsRecordV1) == 72); + +struct NewsRecord { + std::array news_id{}; + std::array user_id{}; + std::array topic_id{}; + s64 received_time{}; + std::array _pad1{}; + s32 read{}; + s32 newly{}; + s32 displayed{}; + std::array _pad2{}; + s32 extra1{}; + s32 extra2{}; +}; +static_assert(sizeof(NewsRecord) == 128); + +struct StoredNews { + NewsRecord record{}; + std::vector payload; +}; + +class NewsStorage { +public: + static NewsStorage& Instance(); + + void Clear(); + StoredNews& Upsert(std::string_view news_id, std::string_view user_id, + std::string_view topic_id, s64 time, std::vector payload); + StoredNews& UpsertRaw(const GithubNewsMeta& meta, std::vector payload); + + std::vector ListAll() const; + std::optional FindByNewsId(std::string_view news_id, + std::string_view user_id = {}) const; + bool UpdateRecord(std::string_view news_id, std::string_view user_id, + const std::function& updater); + void MarkAsRead(std::string_view news_id); + + size_t GetAndIncrementOpenCounter(); + void ResetOpenCounter(); + +private: + NewsStorage() = default; + + static std::string MakeKey(std::string_view news_id, std::string_view user_id); + static void CopyZ(std::span dst, std::string_view src); + static s64 Now(); + + mutable std::mutex mtx; + std::unordered_map items; + size_t open_counter{}; +}; + +} // namespace Service::News diff --git a/src/core/hle/service/ns/application_manager_interface.cpp b/src/core/hle/service/ns/application_manager_interface.cpp index 77481a0d98..f989bdc0a8 100644 --- a/src/core/hle/service/ns/application_manager_interface.cpp +++ b/src/core/hle/service/ns/application_manager_interface.cpp @@ -16,6 +16,10 @@ #include "core/hle/service/ns/read_only_application_control_data_interface.h" #include "core/file_sys/patch_manager.h" #include "frontend_common/firmware_manager.h" +#include "core/launch_timestamp_cache.h" + +#include +#include namespace Service::NS { @@ -560,33 +564,42 @@ Result IApplicationManagerInterface::ListApplicationRecord( s32 offset) { const auto limit = out_records.size(); - LOG_WARNING(Service_NS, "(STUBBED) called"); + LOG_DEBUG(Service_NS, "called"); const auto& cache = system.GetContentProviderUnion(); const auto installed_games = cache.ListEntriesFilterOrigin( std::nullopt, FileSys::TitleType::Application, FileSys::ContentRecordType::Program); - size_t i = 0; - u8 ii = 24; + std::vector records; + records.reserve(installed_games.size()); for (const auto& [slot, game] : installed_games) { - if (i >= limit) { - break; - } - if (game.title_id == 0 || game.title_id < 0x0100000000001FFFull) { - continue; - } - if (offset > 0) { - offset--; - continue; - } + if (game.title_id == 0 || game.title_id < 0x0100000000001FFFull) { + continue; + } + if ((game.title_id & 0xFFF) != 0) { + continue; // skip sub-programs (e.g., 001) + } - ApplicationRecord record{}; - record.application_id = game.title_id; - record.type = ApplicationRecordType::Installed; - record.unknown = 0; // 2 = needs update - record.unknown2 = ii++; + ApplicationRecord record{}; + record.application_id = game.title_id; + record.last_event = ApplicationEvent::Installed; + record.attributes = 0; + record.last_updated = Core::LaunchTimestampCache::GetLaunchTimestamp(game.title_id); - out_records[i++] = record; + records.push_back(record); + } + + std::sort(records.begin(), records.end(), [](const ApplicationRecord& lhs, const ApplicationRecord& rhs) { + if (lhs.last_updated == rhs.last_updated) { + return lhs.application_id < rhs.application_id; + } + return lhs.last_updated > rhs.last_updated; + }); + + size_t i = 0; + const size_t start = static_cast(std::max(0, offset)); + for (size_t idx = start; idx < records.size() && i < limit; ++idx) { + out_records[i++] = records[idx]; } *out_count = static_cast(i); diff --git a/src/core/hle/service/ns/ns_types.h b/src/core/hle/service/ns/ns_types.h index 247f7063af..c581e8d6c3 100644 --- a/src/core/hle/service/ns/ns_types.h +++ b/src/core/hle/service/ns/ns_types.h @@ -12,7 +12,7 @@ namespace Service::NS { -enum class ApplicationRecordType : u8 { +enum class ApplicationEvent : u8 { Installing = 2, Installed = 3, GameCardNotInserted = 5, @@ -34,11 +34,10 @@ enum class BackgroundNetworkUpdateState : u8 { struct ApplicationRecord { u64 application_id; - ApplicationRecordType type; - u8 unknown; + ApplicationEvent last_event; + u8 attributes; INSERT_PADDING_BYTES_NOINIT(0x6); - u8 unknown2; - INSERT_PADDING_BYTES_NOINIT(0x7); + s64 last_updated; }; static_assert(sizeof(ApplicationRecord) == 0x18, "ApplicationRecord has incorrect size."); diff --git a/src/core/launch_timestamp_cache.cpp b/src/core/launch_timestamp_cache.cpp new file mode 100644 index 0000000000..5a154a5c2f --- /dev/null +++ b/src/core/launch_timestamp_cache.cpp @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "core/launch_timestamp_cache.h" + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "common/fs/fs.h" +#include "common/fs/file.h" +#include "common/fs/path_util.h" +#include "common/logging/log.h" + +namespace Core::LaunchTimestampCache { +namespace { + +using CacheMap = std::unordered_map; + +std::mutex mutex; +CacheMap cache; +bool loaded = false; + +std::filesystem::path GetCachePath() { + return Common::FS::GetEdenPath(Common::FS::EdenPath::CacheDir) / "launched.json"; +} + +std::optional ReadFileToString(const std::filesystem::path& path) { + const std::ifstream file{path, std::ios::in | std::ios::binary}; + if (!file) { + return std::nullopt; + } + std::ostringstream ss; + ss << file.rdbuf(); + return ss.str(); +} + +bool WriteStringToFile(const std::filesystem::path& path, const std::string& data) { + if (!Common::FS::CreateParentDirs(path)) { + return false; + } + std::ofstream file{path, std::ios::out | std::ios::binary | std::ios::trunc}; + if (!file) { + return false; + } + file.write(data.data(), static_cast(data.size())); + return static_cast(file); +} + +void Load() { + if (loaded) { + return; + } + + loaded = true; + + const auto path = GetCachePath(); + if (!std::filesystem::exists(path)) { + return; + } + + const auto data = ReadFileToString(path); + if (!data) { + LOG_WARNING(Core, "Failed to read launch timestamp cache: {}", + Common::FS::PathToUTF8String(path)); + return; + } + + try { + const auto json = nlohmann::json::parse(data->data(), data->data() + data->size()); + if (!json.is_object()) { + return; + } + for (auto it = json.begin(); it != json.end(); ++it) { + const auto key_str = it.key(); + const auto value = it.value(); + u64 key{}; + try { + key = std::stoull(key_str, nullptr, 16); + } catch (...) { + continue; + } + if (value.is_number_integer()) { + cache[key] = value.get(); + } + } + } catch (const std::exception& e) { + LOG_WARNING(Core, "Failed to parse launch timestamp cache"); + } +} + +void Save() { + nlohmann::json json = nlohmann::json::object(); + for (const auto& [key, value] : cache) { + json[fmt::format("{:016X}", key)] = value; + } + + const auto path = GetCachePath(); + if (!WriteStringToFile(path, json.dump(4))) { + LOG_WARNING(Core, "Failed to write launch timestamp cache: {}", + Common::FS::PathToUTF8String(path)); + } +} + +s64 NowSeconds() { + return std::time(nullptr); +} + +} // namespace + +void SaveLaunchTimestamp(u64 title_id) { + std::scoped_lock lk{mutex}; + Load(); + cache[title_id] = NowSeconds(); + Save(); +} + +s64 GetLaunchTimestamp(u64 title_id) { + std::scoped_lock lk{mutex}; + Load(); + const auto it = cache.find(title_id); + if (it != cache.end()) { + return it->second; + } + // we need a timestamp, i decided on 01/01/2026 00:00 + return 1767225600; +} + +} // namespace Core::LaunchTimestampCache diff --git a/src/core/launch_timestamp_cache.h b/src/core/launch_timestamp_cache.h new file mode 100644 index 0000000000..b3722033cb --- /dev/null +++ b/src/core/launch_timestamp_cache.h @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "common/common_types.h" + +namespace Core::LaunchTimestampCache { + +void SaveLaunchTimestamp(u64 title_id); +s64 GetLaunchTimestamp(u64 title_id); + +} // namespace Core::LaunchTimestampCache diff --git a/src/frontend_common/CMakeLists.txt b/src/frontend_common/CMakeLists.txt index 911f3ebdca..b8a282b234 100644 --- a/src/frontend_common/CMakeLists.txt +++ b/src/frontend_common/CMakeLists.txt @@ -12,16 +12,14 @@ add_library(frontend_common STATIC firmware_manager.cpp data_manager.h data_manager.cpp play_time_manager.cpp - play_time_manager.h -) + play_time_manager.h) if (ENABLE_UPDATE_CHECKER) target_link_libraries(frontend_common PRIVATE httplib::httplib) target_link_libraries(frontend_common PRIVATE nlohmann_json::nlohmann_json) target_sources(frontend_common PRIVATE update_checker.cpp - update_checker.h - ) + update_checker.h) if (ENABLE_OPENSSL) target_compile_definitions(frontend_common PRIVATE CPPHTTPLIB_OPENSSL_SUPPORT) diff --git a/src/yuzu/applets/qt_web_browser.cpp b/src/yuzu/applets/qt_web_browser.cpp index cab8ef190e..3d9d851ab6 100644 --- a/src/yuzu/applets/qt_web_browser.cpp +++ b/src/yuzu/applets/qt_web_browser.cpp @@ -4,6 +4,11 @@ // SPDX-FileCopyrightText: Copyright 2020 yuzu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later +#include +#include + +#include "common/logging/log.h" + #ifdef YUZU_USE_QT_WEB_ENGINE #include @@ -429,15 +434,17 @@ void QtWebBrowser::OpenLocalWebPage(const std::string& local_url, void QtWebBrowser::OpenExternalWebPage(const std::string& external_url, OpenWebPageCallback callback_) const { - callback = std::move(callback_); + LOG_INFO(Service_AM, "Opening external URL in host browser: {}", external_url); - const auto index = external_url.find('?'); + const QUrl url(QString::fromStdString(external_url)); + const bool success = QDesktopServices::openUrl(url); - if (index == std::string::npos) { - emit MainWindowOpenWebPage(external_url, "", false); + if (success) { + LOG_INFO(Service_AM, "Successfully opened URL in host browser"); + callback_(Service::AM::Frontend::WebExitReason::EndButtonPressed, external_url); } else { - emit MainWindowOpenWebPage(external_url.substr(0, index), external_url.substr(index), - false); + LOG_ERROR(Service_AM, "Failed to open URL in host browser"); + callback_(Service::AM::Frontend::WebExitReason::WindowClosed, external_url); } } diff --git a/src/yuzu/game_list_worker.cpp b/src/yuzu/game_list_worker.cpp index 4542b63100..a694da8d84 100644 --- a/src/yuzu/game_list_worker.cpp +++ b/src/yuzu/game_list_worker.cpp @@ -400,6 +400,11 @@ void GameListWorker::ScanFileSystem(ScanTarget target, const std::string& dir_pa if (res2 == Loader::ResultStatus::Success && program_ids.size() > 1 && (file_type == Loader::FileType::XCI || file_type == Loader::FileType::NSP)) { for (const auto id : program_ids) { + // dravee suggested this, only viable way to + // not show sub-games in qlaunch for now. + if ((id & 0xFFF) != 0) { + continue; + } loader = Loader::GetLoader(system, file, id); if (!loader) { continue;