[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 <crueter@eden-emu.dev>
Co-authored-by: DraVee <dravee@eden-emu.dev>
Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/3308
Reviewed-by: MaranBr <maranbr@eden-emu.dev>
Reviewed-by: DraVee <dravee@eden-emu.dev>
Reviewed-by: Lizzie <lizzie@eden-emu.dev>
Co-authored-by: Maufeat <sahyno1996@gmail.com>
Co-committed-by: Maufeat <sahyno1996@gmail.com>
This commit is contained in:
Maufeat 2026-01-17 01:48:15 +01:00 committed by crueter
parent b7417f68ce
commit ae501e256e
No known key found for this signature in database
GPG Key ID: 425ACD2D4830EBC6
35 changed files with 2978 additions and 315 deletions

View File

@ -14,8 +14,7 @@ else()
pkg_search_module(ZSTD QUIET IMPORTED_TARGET libzstd) pkg_search_module(ZSTD QUIET IMPORTED_TARGET libzstd)
find_package_handle_standard_args(zstd find_package_handle_standard_args(zstd
REQUIRED_VARS ZSTD_LINK_LIBRARIES REQUIRED_VARS ZSTD_LINK_LIBRARIES
VERSION_VAR ZSTD_VERSION VERSION_VAR ZSTD_VERSION)
)
endif() endif()
if (zstd_FOUND AND NOT TARGET zstd::zstd) if (zstd_FOUND AND NOT TARGET zstd::zstd)
@ -36,4 +35,7 @@ if (NOT TARGET zstd::libzstd)
else() else()
add_library(zstd::libzstd ALIAS zstd::zstd) add_library(zstd::libzstd ALIAS zstd::zstd)
endif() 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() endif()

View File

@ -26,6 +26,7 @@ import org.yuzu.yuzu_emu.model.InstallResult
import org.yuzu.yuzu_emu.model.Patch import org.yuzu.yuzu_emu.model.Patch
import org.yuzu.yuzu_emu.model.GameVerificationResult import org.yuzu.yuzu_emu.model.GameVerificationResult
import org.yuzu.yuzu_emu.network.NetPlayManager import org.yuzu.yuzu_emu.network.NetPlayManager
import org.yuzu.yuzu_emu.applets.web.WebBrowser
/** /**
* Class which contains methods that interact * Class which contains methods that interact
@ -457,6 +458,17 @@ object NativeLibrary {
*/ */
external fun setCurrentAppletId(appletId: Int) 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. * Sets the cabinet mode for launching the cabinet applet.
* *

View File

@ -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}")
}
}
}
}

View File

@ -66,6 +66,7 @@
#include "core/frontend/applets/profile_select.h" #include "core/frontend/applets/profile_select.h"
#include "core/frontend/applets/software_keyboard.h" #include "core/frontend/applets/software_keyboard.h"
#include "core/frontend/applets/web_browser.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/applet_manager.h"
#include "core/hle/service/am/frontend/applets.h" #include "core/hle/service/am/frontend/applets.h"
#include "core/hle/service/filesystem/filesystem.h" #include "core/hle/service/filesystem/filesystem.h"
@ -275,6 +276,7 @@ Core::SystemResultStatus EmulationSession::InitializeEmulation(const std::string
// Initialize system. // Initialize system.
jauto android_keyboard = std::make_unique<Common::Android::SoftwareKeyboard::AndroidKeyboard>(); jauto android_keyboard = std::make_unique<Common::Android::SoftwareKeyboard::AndroidKeyboard>();
jauto android_webapplet = std::make_unique<Common::Android::WebBrowser::AndroidWebBrowser>();
m_software_keyboard = android_keyboard.get(); m_software_keyboard = android_keyboard.get();
m_system.SetShuttingDown(false); m_system.SetShuttingDown(false);
m_system.ApplySettings(); m_system.ApplySettings();
@ -289,7 +291,7 @@ Core::SystemResultStatus EmulationSession::InitializeEmulation(const std::string
nullptr, // Photo Viewer nullptr, // Photo Viewer
nullptr, // Profile Selector nullptr, // Profile Selector
std::move(android_keyboard), // Software Keyboard std::move(android_keyboard), // Software Keyboard
nullptr, // Web Browser std::move(android_webapplet),// Web Browser
nullptr, // Net Connect nullptr, // Net Connect
}); });

View File

@ -152,8 +152,7 @@ add_library(
zstd_compression.cpp zstd_compression.cpp
zstd_compression.h zstd_compression.h
fs/ryujinx_compat.h fs/ryujinx_compat.cpp fs/ryujinx_compat.h fs/ryujinx_compat.cpp
fs/symlink.h fs/symlink.cpp fs/symlink.h fs/symlink.cpp)
)
if(WIN32) if(WIN32)
target_sources(common PRIVATE windows/timer_resolution.cpp target_sources(common PRIVATE windows/timer_resolution.cpp
@ -177,7 +176,9 @@ if(ANDROID)
android/multiplayer/multiplayer.cpp android/multiplayer/multiplayer.cpp
android/multiplayer/multiplayer.h android/multiplayer/multiplayer.h
android/applets/software_keyboard.cpp android/applets/software_keyboard.cpp
android/applets/software_keyboard.h) android/applets/software_keyboard.h
android/applets/web_browser.cpp
android/applets/web_browser.h)
endif() endif()
if(ARCHITECTURE_x86_64) if(ARCHITECTURE_x86_64)
@ -220,8 +221,7 @@ else()
stb.cpp stb.cpp
PROPERTIES PROPERTIES
COMPILE_OPTIONS COMPILE_OPTIONS
"-Wno-implicit-fallthrough;-Wno-missing-declarations;-Wno-missing-field-initializers" "-Wno-implicit-fallthrough;-Wno-missing-declarations;-Wno-missing-field-initializers")
)
# Get around GCC failing with intrinsics in Debug # Get around GCC failing with intrinsics in Debug
if(CXX_GCC AND CMAKE_BUILD_TYPE MATCHES "Debug") if(CXX_GCC AND CMAKE_BUILD_TYPE MATCHES "Debug")
@ -240,8 +240,7 @@ if(CXX_CLANG)
PRIVATE PRIVATE
# Clang 14 and earlier have errors when explicitly instantiating # Clang 14 and earlier have errors when explicitly instantiating
# Settings::Setting # Settings::Setting
$<$<VERSION_LESS:$<CXX_COMPILER_VERSION>,15>:CANNOT_EXPLICITLY_INSTANTIATE> $<$<VERSION_LESS:$<CXX_COMPILER_VERSION>,15>:CANNOT_EXPLICITLY_INSTANTIATE>)
)
endif() endif()
if (BOOST_NO_HEADERS) if (BOOST_NO_HEADERS)

View File

@ -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<jclass>(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<void>([&](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

View File

@ -0,0 +1,30 @@
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <jni.h>
#include <string>
#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

View File

@ -4,6 +4,7 @@
#include <jni.h> #include <jni.h>
#include "applets/software_keyboard.h" #include "applets/software_keyboard.h"
#include "applets/web_browser.h"
#include "common/android/id_cache.h" #include "common/android/id_cache.h"
#include "common/assert.h" #include "common/assert.h"
#include "common/fs/fs_android.h" #include "common/fs/fs_android.h"
@ -602,6 +603,7 @@ namespace Common::Android {
// Initialize applets // Initialize applets
Common::Android::SoftwareKeyboard::InitJNI(env); Common::Android::SoftwareKeyboard::InitJNI(env);
Common::Android::WebBrowser::InitJNI(env);
return JNI_VERSION; return JNI_VERSION;
} }
@ -631,6 +633,7 @@ namespace Common::Android {
// UnInitialize applets // UnInitialize applets
SoftwareKeyboard::CleanupJNI(env); SoftwareKeyboard::CleanupJNI(env);
WebBrowser::CleanupJNI(env);
AndroidMultiplayer::NetworkShutdown(); AndroidMultiplayer::NetworkShutdown();
} }

View File

@ -563,8 +563,14 @@ add_library(core STATIC
hle/service/bcat/delivery_cache_storage_service.h 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.cpp
hle/service/bcat/news/newly_arrived_event_holder.h 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.cpp
hle/service/bcat/news/news_data_service.h 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.cpp
hle/service/bcat/news/news_database_service.h hle/service/bcat/news/news_database_service.h
hle/service/bcat/news/news_service.cpp hle/service/bcat/news/news_service.cpp
@ -1125,6 +1131,8 @@ add_library(core STATIC
internal_network/sockets.h internal_network/sockets.h
internal_network/wifi_scanner.cpp internal_network/wifi_scanner.cpp
internal_network/wifi_scanner.h internal_network/wifi_scanner.h
launch_timestamp_cache.cpp
launch_timestamp_cache.h
loader/deconstructed_rom_directory.cpp loader/deconstructed_rom_directory.cpp
loader/deconstructed_rom_directory.h loader/deconstructed_rom_directory.h
loader/kip.cpp loader/kip.cpp
@ -1157,8 +1165,7 @@ add_library(core STATIC
tools/freezer.cpp tools/freezer.cpp
tools/freezer.h tools/freezer.h
tools/renderdoc.cpp tools/renderdoc.cpp
tools/renderdoc.h tools/renderdoc.h)
)
if (ENABLE_WIFI_SCAN) if (ENABLE_WIFI_SCAN)
# find_package(libiw REQUIRED) # find_package(libiw REQUIRED)
@ -1188,8 +1195,7 @@ else()
-Werror=conversion -Werror=conversion
-Wno-sign-conversion -Wno-sign-conversion
-Wno-cast-function-type -Wno-cast-function-type
$<$<CXX_COMPILER_ID:Clang>:-fsized-deallocation> $<$<CXX_COMPILER_ID:Clang>:-fsized-deallocation>)
)
# pre-clang19 will spam with "OH DID YOU MEAN THIS?" otherwise... # pre-clang19 will spam with "OH DID YOU MEAN THIS?" otherwise...
if (CXX_CLANG AND CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL 19) if (CXX_CLANG AND CMAKE_CXX_COMPILER_VERSION VERSION_GREATER_EQUAL 19)
target_compile_options(core PRIVATE -Wno-cast-function-type-mismatch) target_compile_options(core PRIVATE -Wno-cast-function-type-mismatch)
@ -1205,10 +1211,13 @@ else()
target_link_libraries(core PUBLIC Boost::headers) target_link_libraries(core PUBLIC Boost::headers)
endif() endif()
target_link_libraries(core PRIVATE fmt::fmt nlohmann_json::nlohmann_json RenderDoc::API MbedTLS::mbedcrypto${MBEDTLS_LIB_SUFFIX} MbedTLS::mbedtls${MBEDTLS_LIB_SUFFIX}) target_link_libraries(core PRIVATE
# if (MINGW) fmt::fmt
# target_link_libraries(core PRIVATE ws2_32 mswsock wlanapi) nlohmann_json::nlohmann_json
# endif() RenderDoc::API
MbedTLS::mbedcrypto${MBEDTLS_LIB_SUFFIX}
MbedTLS::mbedtls${MBEDTLS_LIB_SUFFIX}
httplib::httplib)
if (ENABLE_WEB_SERVICE) if (ENABLE_WEB_SERVICE)
target_compile_definitions(core PUBLIC 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/interpreter_visitor.h
arm/nce/patcher.cpp arm/nce/patcher.cpp
arm/nce/patcher.h arm/nce/patcher.h
arm/nce/visitor_base.h arm/nce/visitor_base.h)
)
target_link_libraries(core PRIVATE merry::oaknut) target_link_libraries(core PRIVATE merry::oaknut)
endif() endif()
@ -1251,8 +1259,7 @@ if (ARCHITECTURE_x86_64 OR ARCHITECTURE_arm64)
hle/service/jit/jit_context.cpp hle/service/jit/jit_context.cpp
hle/service/jit/jit_context.h hle/service/jit/jit_context.h
hle/service/jit/jit.cpp hle/service/jit/jit.cpp
hle/service/jit/jit.h hle/service/jit/jit.h)
)
target_link_libraries(core PRIVATE dynarmic::dynarmic) target_link_libraries(core PRIVATE dynarmic::dynarmic)
endif() endif()
@ -1263,6 +1270,7 @@ if(ENABLE_OPENSSL)
find_package(OpenSSL REQUIRED) find_package(OpenSSL REQUIRED)
target_link_libraries(core PRIVATE OpenSSL::SSL OpenSSL::Crypto) target_link_libraries(core PRIVATE OpenSSL::SSL OpenSSL::Crypto)
target_compile_definitions(core PRIVATE CPPHTTPLIB_OPENSSL_SUPPORT)
elseif (APPLE) elseif (APPLE)
target_sources(core PRIVATE target_sources(core PRIVATE
hle/service/ssl/ssl_backend_securetransport.cpp) hle/service/ssl/ssl_backend_securetransport.cpp)

View File

@ -15,6 +15,8 @@
#include "common/string_util.h" #include "common/string_util.h"
#include "core/arm/exclusive_monitor.h" #include "core/arm/exclusive_monitor.h"
#include "core/core.h" #include "core/core.h"
#include "launch_timestamp_cache.h"
#include "core/core_timing.h" #include "core/core_timing.h"
#include "core/cpu_manager.h" #include "core/cpu_manager.h"
#include "core/debugger/debugger.h" #include "core/debugger/debugger.h"
@ -323,6 +325,9 @@ struct System::Impl {
LOG_INFO(Core, "Loading {} ({:016X}) ...", name, params.program_id); 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 // Make the process created be the application
kernel.MakeApplicationProcess(process->GetHandle()); kernel.MakeApplicationProcess(process->GetHandle());

View File

@ -94,6 +94,7 @@ enum class AppletId : u32 {
LoginShare = 0x18, LoginShare = 0x18,
WebAuth = 0x19, WebAuth = 0x19,
MyPage = 0x1A, MyPage = 0x1A,
Lhub = 0x35
}; };
enum class AppletProgramId : u64 { enum class AppletProgramId : u64 {

View File

@ -236,10 +236,6 @@ WebBrowser::WebBrowser(Core::System& system_, std::shared_ptr<Applet> applet_,
WebBrowser::~WebBrowser() = default; WebBrowser::~WebBrowser() = default;
void WebBrowser::Initialize() { void WebBrowser::Initialize() {
if (Settings::values.disable_web_applet) {
return;
}
FrontendApplet::Initialize(); FrontendApplet::Initialize();
LOG_INFO(Service_AM, "Initializing Web Browser Applet."); 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={}", LOG_DEBUG(Service_AM, "WebArgHeader: total_tlv_entries={}, shim_kind={}",
web_arg_header.total_tlv_entries, web_arg_header.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); ExtractSharedFonts(system);
switch (web_arg_header.shim_kind) { switch (web_arg_header.shim_kind) {
@ -288,6 +290,9 @@ void WebBrowser::Initialize() {
case ShimKind::Lobby: case ShimKind::Lobby:
InitializeLobby(); InitializeLobby();
break; break;
case ShimKind::Lhub:
InitializeLhub();
break;
default: default:
ASSERT_MSG(false, "Invalid ShimKind={}", web_arg_header.shim_kind); ASSERT_MSG(false, "Invalid ShimKind={}", web_arg_header.shim_kind);
break; break;
@ -303,8 +308,19 @@ void WebBrowser::ExecuteInteractive() {
} }
void WebBrowser::Execute() { 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) { 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); WebBrowserExit(WebExitReason::EndButtonPressed);
return; return;
} }
@ -331,6 +347,9 @@ void WebBrowser::Execute() {
case ShimKind::Lobby: case ShimKind::Lobby:
ExecuteLobby(); ExecuteLobby();
break; break;
case ShimKind::Lhub:
ExecuteLhub();
break;
default: default:
ASSERT_MSG(false, "Invalid ShimKind={}", web_arg_header.shim_kind); ASSERT_MSG(false, "Invalid ShimKind={}", web_arg_header.shim_kind);
WebBrowserExit(WebExitReason::EndButtonPressed); WebBrowserExit(WebExitReason::EndButtonPressed);
@ -351,17 +370,99 @@ void WebBrowser::ExtractOfflineRomFS() {
} }
void WebBrowser::WebBrowserExit(WebExitReason exit_reason, std::string last_url) { 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_applet_version >= WebAppletVersion::Version196608) ||
(web_arg_header.shim_kind == ShimKind::Web && (web_arg_header.shim_kind == ShimKind::Web &&
web_applet_version >= WebAppletVersion::Version524288)) { web_applet_version >= WebAppletVersion::Version524288) ||
// TODO: Push Output TLVs instead of a WebCommonReturnValue (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<u8> 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<size_t>(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<u32>(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++;
} }
WebCommonReturnValue web_common_return_value; // 0x2 LastUrl
{
WebArgOutputTLV tlv{};
tlv.output_tlv_type = WebArgOutputTLVType::LastURL;
const u16 url_data_size = static_cast<u16>(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<IStorage>(system, std::move(out_data)));
Exit();
return;
}
// for old browser, keep old return, use WebCommonReturnValue
WebCommonReturnValue web_common_return_value{};
web_common_return_value.exit_reason = exit_reason; 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(); web_common_return_value.last_url_size = last_url.size();
LOG_DEBUG(Service_AM, "WebCommonReturnValue: exit_reason={}, last_url={}, 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"); LOG_WARNING(Service_AM, "(STUBBED) called, Lobby Applet is not implemented");
WebBrowserExit(WebExitReason::EndButtonPressed); 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 } // namespace Service::AM::Frontend

View File

@ -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-FileCopyrightText: Copyright 2020 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
@ -53,6 +56,7 @@ private:
void InitializeWeb(); void InitializeWeb();
void InitializeWifi(); void InitializeWifi();
void InitializeLobby(); void InitializeLobby();
void InitializeLhub();
// Executors for the various types of browser applets // Executors for the various types of browser applets
void ExecuteShop(); void ExecuteShop();
@ -62,6 +66,7 @@ private:
void ExecuteWeb(); void ExecuteWeb();
void ExecuteWifi(); void ExecuteWifi();
void ExecuteLobby(); void ExecuteLobby();
void ExecuteLhub();
const Core::Frontend::WebBrowserApplet& frontend; const Core::Frontend::WebBrowserApplet& frontend;

View File

@ -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-FileCopyrightText: Copyright 2020 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
@ -30,6 +33,7 @@ enum class ShimKind : u32 {
Web = 5, Web = 5,
Wifi = 6, Wifi = 6,
Lobby = 7, Lobby = 7,
Lhub = 8,
}; };
enum class WebExitReason : u32 { enum class WebExitReason : u32 {

View File

@ -237,13 +237,15 @@ std::shared_ptr<FrontendApplet> FrontendAppletHolder::GetApplet(std::shared_ptr<
case AppletId::OfflineWeb: case AppletId::OfflineWeb:
case AppletId::LoginShare: case AppletId::LoginShare:
case AppletId::WebAuth: case AppletId::WebAuth:
case AppletId::Lhub:
return std::make_shared<WebBrowser>(system, applet, mode, *frontend.web_browser); return std::make_shared<WebBrowser>(system, applet, mode, *frontend.web_browser);
case AppletId::PhotoViewer: case AppletId::PhotoViewer:
return std::make_shared<PhotoViewer>(system, applet, mode, *frontend.photo_viewer); return std::make_shared<PhotoViewer>(system, applet, mode, *frontend.photo_viewer);
case AppletId::NetConnect: case AppletId::NetConnect:
return std::make_shared<NetConnect>(system, applet, mode, *frontend.net_connect); return std::make_shared<NetConnect>(system, applet, mode, *frontend.net_connect);
default: default:
LOG_ERROR(Service_AM, "No backend implementation exists for applet_id={:02X}. Falling back to stub applet", static_cast<u8>(id)); LOG_ERROR(Service_AM, "No backend implementation exists for applet_id={:02X} program_id={:016X}"
"Falling back to stub applet", static_cast<u8>(id), applet->program_id);
return std::make_shared<StubApplet>(system, applet, id, mode); return std::make_shared<StubApplet>(system, applet, id, mode);
} }
} }

View File

@ -15,6 +15,7 @@
#include "core/hle/service/am/window_system.h" #include "core/hle/service/am/window_system.h"
#include "core/hle/service/cmif_serialization.h" #include "core/hle/service/cmif_serialization.h"
#include "core/loader/loader.h" #include "core/loader/loader.h"
#include "core/launch_timestamp_cache.h"
namespace Service::AM { namespace Service::AM {
@ -72,6 +73,7 @@ IApplicationCreator::~IApplicationCreator() = default;
Result IApplicationCreator::CreateApplication( Result IApplicationCreator::CreateApplication(
Out<SharedPointer<IApplicationAccessor>> out_application_accessor, u64 application_id) { Out<SharedPointer<IApplicationAccessor>> out_application_accessor, u64 application_id) {
LOG_INFO(Service_NS, "called, application_id={:016X}", application_id); LOG_INFO(Service_NS, "called, application_id={:016X}", application_id);
Core::LaunchTimestampCache::SaveLaunchTimestamp(application_id);
R_RETURN( R_RETURN(
CreateGuestApplication(out_application_accessor, system, m_window_system, application_id)); CreateGuestApplication(out_application_accessor, system, m_window_system, application_id));
} }
@ -103,6 +105,7 @@ Result IApplicationCreator::CreateSystemApplication(
*out_application_accessor = *out_application_accessor =
std::make_shared<IApplicationAccessor>(system, applet, m_window_system); std::make_shared<IApplicationAccessor>(system, applet, m_window_system);
Core::LaunchTimestampCache::SaveLaunchTimestamp(application_id);
R_SUCCEED(); R_SUCCEED();
} }

View File

@ -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 <boost/algorithm/string/replace.hpp>
#include <boost/regex.hpp>
#include <boost/regex/v5/regex_replace.hpp>
#include <fmt/format.h>
#include <httplib.h>
#include <nlohmann/json.hpp>
#ifdef CPPHTTPLIB_OPENSSL_SUPPORT
#include <httplib.h>
#endif
#include <chrono>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <future>
#include <iomanip>
#include <mutex>
#include <optional>
#include <sstream>
#include <thread>
#ifdef YUZU_BUNDLED_OPENSSL
#include <openssl/cert.h>
#endif
namespace Service::News {
namespace {
constexpr const char* GitHubAPI_EdenReleases = "/repos/eden-emulator/Releases/releases";
// Cached logo data
std::vector<u8> default_logo_small;
std::vector<u8> default_logo_large;
bool default_logos_loaded = false;
std::unordered_map<std::string, std::vector<u8>> news_images_small;
std::unordered_map<std::string, std::vector<u8>> 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<u32>(std::hash<std::string_view>{}(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<u64>(_mkgmtime(&tm));
#else
return static_cast<u64>(timegm(&tm));
#endif
}
std::vector<u8> 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<std::streamsize>(f.tellg());
if (file_size <= 0 || file_size > 10 * 1024 * 1024) return {};
f.seekg(0);
std::vector<u8> data(static_cast<size_t>(file_size));
if (!f.read(reinterpret_cast<char*>(data.data()), file_size)) return {};
return data;
}
std::vector<u8> 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<u8> 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<std::streamsize>(res->body.size()));
}
return data;
}
} catch (...) {
LOG_WARNING(Service_BCAT, "Failed to download: {}", url_path);
}
#endif
return {};
}
std::vector<u8> 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<u8> 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<u32>& news_ids) {
std::vector<std::future<void>> 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<std::string> 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<std::string> 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<u32> 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<u32>(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<u32>(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<u8> 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<std::string>& languages,
const std::string& author,
const std::vector<std::pair<std::string, std::string>>& /*assets*/,
const std::string& html_url,
std::optional<u32> 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

View File

@ -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 <mutex>
#include <optional>
#include <string_view>
#include <vector>
namespace Service::News {
void EnsureBuiltinNewsLoaded();
std::vector<u8> 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<std::string>& languages,
const std::string& author_name,
const std::vector<std::pair<std::string, std::string>>& assets,
const std::string& html_url,
std::optional<u32> override_news_id = std::nullopt);
} // namespace Service::News

View File

@ -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 <algorithm>
#include <cstring>
// 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<const u8> bytes) {
out.insert(out.end(), bytes.begin(), bytes.end());
}
void MsgPack::Writer::WriteFixMap(size_t count) {
if (count <= 15) {
out.push_back(static_cast<u8>(0x80 | count));
} else if (count <= 0xFFFF) {
out.push_back(0xDE);
out.push_back(static_cast<u8>((count >> 8) & 0xFF));
out.push_back(static_cast<u8>(count & 0xFF));
} else {
WriteMap32(count);
}
}
void MsgPack::Writer::WriteMap32(size_t count) {
out.push_back(0xDF);
out.push_back(static_cast<u8>((count >> 24) & 0xFF));
out.push_back(static_cast<u8>((count >> 16) & 0xFF));
out.push_back(static_cast<u8>((count >> 8) & 0xFF));
out.push_back(static_cast<u8>(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<u8>(0xA0 | s.size()));
} else if (s.size() <= 0xFF) {
out.push_back(0xD9);
out.push_back(static_cast<u8>(s.size()));
} else if (s.size() <= 0xFFFF) {
out.push_back(0xDA);
out.push_back(static_cast<u8>((s.size() >> 8) & 0xFF));
out.push_back(static_cast<u8>(s.size() & 0xFF));
} else {
out.push_back(0xDB);
out.push_back(static_cast<u8>((s.size() >> 24) & 0xFF));
out.push_back(static_cast<u8>((s.size() >> 16) & 0xFF));
out.push_back(static_cast<u8>((s.size() >> 8) & 0xFF));
out.push_back(static_cast<u8>(s.size() & 0xFF));
}
WriteBytes({reinterpret_cast<const u8*>(s.data()), s.size()});
}
void MsgPack::Writer::WriteInt64(s64 v) {
if (v >= 0) {
WriteUInt(static_cast<u64>(v));
return;
}
if (v >= -32) {
out.push_back(static_cast<u8>(0xE0 | (v + 32)));
} else if (v >= -128) {
out.push_back(0xD0);
out.push_back(static_cast<u8>(v & 0xFF));
} else if (v >= -32768) {
out.push_back(0xD1);
out.push_back(static_cast<u8>((v >> 8) & 0xFF));
out.push_back(static_cast<u8>(v & 0xFF));
} else if (v >= INT32_MIN) {
out.push_back(0xD2);
out.push_back(static_cast<u8>((v >> 24) & 0xFF));
out.push_back(static_cast<u8>((v >> 16) & 0xFF));
out.push_back(static_cast<u8>((v >> 8) & 0xFF));
out.push_back(static_cast<u8>(v & 0xFF));
} else {
out.push_back(0xD3);
for (int i = 7; i >= 0; --i) {
out.push_back(static_cast<u8>((static_cast<u64>(v) >> (8 * i)) & 0xFF));
}
}
}
void MsgPack::Writer::WriteUInt(u64 v) {
if (v < 0x80) {
out.push_back(static_cast<u8>(v));
} else if (v <= 0xFF) {
out.push_back(0xCC);
out.push_back(static_cast<u8>(v));
} else if (v <= 0xFFFF) {
out.push_back(0xCD);
out.push_back(static_cast<u8>((v >> 8) & 0xFF));
out.push_back(static_cast<u8>(v & 0xFF));
} else if (v <= 0xFFFFFFFFULL) {
out.push_back(0xCE);
out.push_back(static_cast<u8>((v >> 24) & 0xFF));
out.push_back(static_cast<u8>((v >> 16) & 0xFF));
out.push_back(static_cast<u8>((v >> 8) & 0xFF));
out.push_back(static_cast<u8>(v & 0xFF));
} else {
out.push_back(0xCF);
for (int i = 7; i >= 0; --i) {
out.push_back(static_cast<u8>((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<u8>(0x90 | count));
} else if (count <= 0xFFFF) {
out.push_back(0xDC);
out.push_back(static_cast<u8>((count >> 8) & 0xFF));
out.push_back(static_cast<u8>(count & 0xFF));
} else {
out.push_back(0xDD);
out.push_back(static_cast<u8>((count >> 24) & 0xFF));
out.push_back(static_cast<u8>((count >> 16) & 0xFF));
out.push_back(static_cast<u8>((count >> 8) & 0xFF));
out.push_back(static_cast<u8>(count & 0xFF));
}
}
void MsgPack::Writer::WriteBinary(const std::vector<u8>& data) {
if (data.size() <= 0xFF) {
out.push_back(0xC4);
out.push_back(static_cast<u8>(data.size()));
} else if (data.size() <= 0xFFFF) {
out.push_back(0xC5);
out.push_back(static_cast<u8>((data.size() >> 8) & 0xFF));
out.push_back(static_cast<u8>(data.size() & 0xFF));
} else {
out.push_back(0xC6);
out.push_back(static_cast<u8>((data.size() >> 24) & 0xFF));
out.push_back(static_cast<u8>((data.size() >> 16) & 0xFF));
out.push_back(static_cast<u8>((data.size() >> 8) & 0xFF));
out.push_back(static_cast<u8>(data.size() & 0xFF));
}
WriteBytes(data);
}
void MsgPack::Writer::WriteBool(bool v) {
out.push_back(v ? 0xC3 : 0xC2);
}
std::vector<u8> MsgPack::Writer::Take() {
return std::move(out);
}
MsgPack::Reader::Reader(std::span<const u8> 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<size_t>(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<int8_t>(byte);
offset++;
return true;
}
if (byte == 0xD0) {
ReadByte();
if (!Ensure(1)) {
return Fail("int8 truncated");
}
value = static_cast<int8_t>(data[offset]);
offset += 1;
return true;
}
if (byte == 0xD1) {
ReadByte();
if (!Ensure(2)) {
return Fail("int16 truncated");
}
value = static_cast<int16_t>((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<int32_t>((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<const char*>(data.data() + offset), len);
offset += len;
return true;
}
bool MsgPack::Reader::ReadBinary(std::vector<u8>& 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<u8>& value) {
if (!ReadBinary(value)) {
value.clear();
return false;
}
return true;
}
bool MsgPack::Reader::ReadStringArray(std::vector<std::string>& 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<u8>& 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

View File

@ -0,0 +1,149 @@
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <span>
#include <string>
#include <string_view>
#include <type_traits>
#include <vector>
#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<u8> 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<std::string> 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<u8> 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<u8>& data);
void WriteBool(bool value);
std::vector<u8> Take();
const std::vector<u8>& Buffer() const { return out; }
void Clear() { out.clear(); }
private:
void WriteBytes(std::span<const u8> bytes);
std::vector<u8> out;
};
class Reader {
public:
explicit Reader(std::span<const u8> buffer);
template <typename T>
bool Read(T& out) {
if constexpr (std::is_same_v<T, NewsStruct>) {
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<u8>& 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<std::string>& out);
bool ReadBinaryCompat(std::vector<u8>& out);
bool ReadByteArray(std::vector<u8>& out);
std::span<const u8> data;
size_t offset{0};
std::string error;
};
};
} // namespace Service::News

View File

@ -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-FileCopyrightText: Copyright 2024 yuzu Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
#include "core/hle/service/bcat/news/news_data_service.h" #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 <cstring>
namespace Service::News { namespace Service::News {
namespace {
std::string_view ToStringView(std::span<const char> 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_) INewsDataService::INewsDataService(Core::System& system_)
: ServiceFramework{system_, "INewsDataService"} { : ServiceFramework{system_, "INewsDataService"} {
// clang-format off
static const FunctionInfo functions[] = { static const FunctionInfo functions[] = {
{0, nullptr, "Open"}, {0, D<&INewsDataService::Open>, "Open"},
{1, nullptr, "OpenWithNewsRecordV1"}, {1, D<&INewsDataService::OpenWithNewsRecordV1>, "OpenWithNewsRecordV1"},
{2, nullptr, "Read"}, {2, D<&INewsDataService::Read>, "Read"},
{3, nullptr, "GetSize"}, {3, D<&INewsDataService::GetSize>, "GetSize"},
{1001, nullptr, "OpenWithNewsRecord"}, {1001, D<&INewsDataService::OpenWithNewsRecord>, "OpenWithNewsRecord"},
}; };
// clang-format on
RegisterHandlers(functions); RegisterHandlers(functions);
} }
INewsDataService::~INewsDataService() = default; 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<BufferAttr_HipcMapAlias> name) {
EnsureBuiltinNewsLoaded();
const auto key = ToStringView({reinterpret_cast<const char*>(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<u64> out_size, s64 offset,
OutBuffer<BufferAttr_HipcMapAlias> out_buffer) {
const auto off = static_cast<size_t>(std::max<s64>(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<s64> out_size) {
*out_size = static_cast<s64>(opened_payload.size());
R_SUCCEED();
}
} // namespace Service::News } // namespace Service::News

View File

@ -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-FileCopyrightText: Copyright 2024 yuzu Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
#pragma once #pragma once
#include "core/hle/service/cmif_types.h"
#include "core/hle/service/service.h" #include "core/hle/service/service.h"
#include "core/hle/service/bcat/news/news_storage.h"
namespace Core { namespace Core {
class System; class System;
} }
@ -15,6 +21,17 @@ class INewsDataService final : public ServiceFramework<INewsDataService> {
public: public:
explicit INewsDataService(Core::System& system_); explicit INewsDataService(Core::System& system_);
~INewsDataService() override; ~INewsDataService() override;
private:
bool TryOpen(std::string_view key, std::string_view user);
Result Open(InBuffer<BufferAttr_HipcMapAlias> name);
Result OpenWithNewsRecordV1(NewsRecordV1 record_buffer);
Result OpenWithNewsRecord(NewsRecord record_buffer);
Result Read(Out<u64> out_size, s64 offset, OutBuffer<BufferAttr_HipcMapAlias> out_buffer);
Result GetSize(Out<s64> out_size);
std::vector<u8> opened_payload;
}; };
} // namespace Service::News } // namespace Service::News

View File

@ -1,53 +1,195 @@
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: Copyright 2024 yuzu Emulator Project // SPDX-FileCopyrightText: Copyright 2024 yuzu Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
#include "core/hle/service/bcat/news/news_database_service.h" #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 "core/hle/service/cmif_serialization.h"
#include <algorithm>
#include <cstring>
namespace Service::News { namespace Service::News {
namespace {
std::string_view ToStringView(std::span<const u8> buf) {
if (buf.empty()) return {};
auto data = reinterpret_cast<const char*>(buf.data());
return {data, strnlen(data, buf.size())};
}
std::string_view ToStringView(std::span<const char> 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_) INewsDatabaseService::INewsDatabaseService(Core::System& system_)
: ServiceFramework{system_, "INewsDatabaseService"} { : ServiceFramework{system_, "INewsDatabaseService"} {
// clang-format off
static const FunctionInfo functions[] = { static const FunctionInfo functions[] = {
{0, nullptr, "GetListV1"}, {0, D<&INewsDatabaseService::GetListV1>, "GetListV1"},
{1, D<&INewsDatabaseService::Count>, "Count"}, {1, D<&INewsDatabaseService::Count>, "Count"},
{2, nullptr, "CountWithKey"}, {2, D<&INewsDatabaseService::CountWithKey>, "CountWithKey"},
{3, nullptr, "UpdateIntegerValue"}, {3, D<&INewsDatabaseService::UpdateIntegerValue>, "UpdateIntegerValue"},
{4, D<&INewsDatabaseService::UpdateIntegerValueWithAddition>, "UpdateIntegerValueWithAddition"}, {4, D<&INewsDatabaseService::UpdateIntegerValueWithAddition>, "UpdateIntegerValueWithAddition"},
{5, nullptr, "UpdateStringValue"}, {5, D<&INewsDatabaseService::UpdateStringValue>, "UpdateStringValue"},
{1000, D<&INewsDatabaseService::GetList>, "GetList"}, {1000, D<&INewsDatabaseService::GetList>, "GetList"},
}; };
// clang-format on
RegisterHandlers(functions); RegisterHandlers(functions);
} }
INewsDatabaseService::~INewsDatabaseService() = default; INewsDatabaseService::~INewsDatabaseService() = default;
Result INewsDatabaseService::Count(Out<s32> out_count, Result INewsDatabaseService::Count(Out<s32> out_count, InBuffer<BufferAttr_HipcPointer> where) {
InBuffer<BufferAttr_HipcPointer> buffer_data) { EnsureBuiltinNewsLoaded();
LOG_WARNING(Service_BCAT, "(STUBBED) called, buffer_size={}", buffer_data.size()); *out_count = static_cast<s32>(NewsStorage::Instance().ListAll().size());
*out_count = 0;
R_SUCCEED(); R_SUCCEED();
} }
Result INewsDatabaseService::UpdateIntegerValueWithAddition( Result INewsDatabaseService::CountWithKey(Out<s32> out_count,
u32 value, InBuffer<BufferAttr_HipcPointer> buffer_data_1, InBuffer<BufferAttr_HipcPointer> key,
InBuffer<BufferAttr_HipcPointer> buffer_data_2) { InBuffer<BufferAttr_HipcPointer> where) {
LOG_WARNING(Service_BCAT, "(STUBBED) called, value={}, buffer_size_1={}, buffer_data_2={}", EnsureBuiltinNewsLoaded();
value, buffer_data_1.size(), buffer_data_2.size()); *out_count = static_cast<s32>(NewsStorage::Instance().ListAll().size());
R_SUCCEED(); R_SUCCEED();
} }
Result INewsDatabaseService::GetList(Out<s32> out_count, u32 value, Result INewsDatabaseService::UpdateIntegerValue(u32 value,
OutBuffer<BufferAttr_HipcMapAlias> out_buffer_data, InBuffer<BufferAttr_HipcPointer> key,
InBuffer<BufferAttr_HipcPointer> buffer_data_1, InBuffer<BufferAttr_HipcPointer> where) {
InBuffer<BufferAttr_HipcPointer> buffer_data_2) { const auto column = ToStringView(key);
LOG_WARNING(Service_BCAT, "(STUBBED) called, value={}, buffer_size_1={}, buffer_data_2={}", for (const auto& rec : NewsStorage::Instance().ListAll()) {
value, buffer_data_1.size(), buffer_data_2.size()); NewsStorage::Instance().UpdateRecord(
ToStringView(rec.news_id), {},
[&](NewsRecord& r) { UpdateField(r, column, static_cast<s32>(value), false); });
}
R_SUCCEED();
}
Result INewsDatabaseService::UpdateIntegerValueWithAddition(u32 value,
InBuffer<BufferAttr_HipcPointer> key,
InBuffer<BufferAttr_HipcPointer> 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<s32>(value), true); });
}
R_SUCCEED();
}
Result INewsDatabaseService::UpdateStringValue(InBuffer<BufferAttr_HipcPointer> key,
InBuffer<BufferAttr_HipcPointer> value,
InBuffer<BufferAttr_HipcPointer> where) {
LOG_WARNING(Service_BCAT, "(STUBBED) UpdateStringValue");
R_SUCCEED();
}
Result INewsDatabaseService::GetListV1(Out<s32> out_count,
OutBuffer<BufferAttr_HipcMapAlias> out_buffer,
InBuffer<BufferAttr_HipcPointer> where,
InBuffer<BufferAttr_HipcPointer> order,
s32 offset) {
EnsureBuiltinNewsLoaded();
auto record_size = sizeof(NewsRecordV1);
if (out_buffer.size() < record_size) {
*out_count = 0; *out_count = 0;
R_SUCCEED(); R_SUCCEED();
}
std::memset(out_buffer.data(), 0, out_buffer.size());
const auto list = NewsStorage::Instance().ListAll();
const size_t start = static_cast<size_t>(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<s32>(count);
R_SUCCEED();
}
Result INewsDatabaseService::GetList(Out<s32> out_count,
OutBuffer<BufferAttr_HipcMapAlias> out_buffer,
InBuffer<BufferAttr_HipcPointer> where,
InBuffer<BufferAttr_HipcPointer> 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<size_t>(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<s32>(count);
R_SUCCEED();
} }
} // namespace Service::News } // namespace Service::News

View File

@ -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-FileCopyrightText: Copyright 2024 yuzu Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
@ -6,6 +9,8 @@
#include "core/hle/service/cmif_types.h" #include "core/hle/service/cmif_types.h"
#include "core/hle/service/service.h" #include "core/hle/service/service.h"
#include "core/hle/service/bcat/news/news_storage.h"
namespace Core { namespace Core {
class System; class System;
} }
@ -20,13 +25,30 @@ public:
private: private:
Result Count(Out<s32> out_count, InBuffer<BufferAttr_HipcPointer> buffer_data); Result Count(Out<s32> out_count, InBuffer<BufferAttr_HipcPointer> buffer_data);
Result CountWithKey(Out<s32> out_count, InBuffer<BufferAttr_HipcPointer> key,
InBuffer<BufferAttr_HipcPointer> where);
Result UpdateIntegerValue(u32 value, InBuffer<BufferAttr_HipcPointer> key,
InBuffer<BufferAttr_HipcPointer> where);
Result UpdateIntegerValueWithAddition(u32 value, InBuffer<BufferAttr_HipcPointer> buffer_data_1, Result UpdateIntegerValueWithAddition(u32 value, InBuffer<BufferAttr_HipcPointer> buffer_data_1,
InBuffer<BufferAttr_HipcPointer> buffer_data_2); InBuffer<BufferAttr_HipcPointer> buffer_data_2);
Result GetList(Out<s32> out_count, u32 value, Result UpdateStringValue(InBuffer<BufferAttr_HipcPointer> key,
InBuffer<BufferAttr_HipcPointer> value,
InBuffer<BufferAttr_HipcPointer> where);
Result GetListV1(Out<s32> out_count,
OutBuffer<BufferAttr_HipcMapAlias> out_buffer_data, OutBuffer<BufferAttr_HipcMapAlias> out_buffer_data,
InBuffer<BufferAttr_HipcPointer> buffer_data_1, InBuffer<BufferAttr_HipcPointer> where_phrase,
InBuffer<BufferAttr_HipcPointer> buffer_data_2); InBuffer<BufferAttr_HipcPointer> order_by_phrase,
s32 offset);
Result GetList(Out<s32> out_count,
OutBuffer<BufferAttr_HipcMapAlias> out_buffer_data,
InBuffer<BufferAttr_HipcPointer> where_phrase,
InBuffer<BufferAttr_HipcPointer> order_by_phrase,
s32 offset);
}; };
} // namespace Service::News } // namespace Service::News

View File

@ -5,32 +5,35 @@
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
#include "core/hle/service/bcat/news/news_service.h" #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 "core/hle/service/cmif_serialization.h"
#include <cstring>
namespace Service::News { namespace Service::News {
INewsService::INewsService(Core::System& system_) : ServiceFramework{system_, "INewsService"} { INewsService::INewsService(Core::System& system_) : ServiceFramework{system_, "INewsService"} {
// clang-format off // clang-format off
static const FunctionInfo functions[] = { static const FunctionInfo functions[] = {
{10100, D<&INewsService::PostLocalNews>, "PostLocalNews"}, {10100, D<&INewsService::PostLocalNews>, "PostLocalNews"},
{20100, nullptr, "SetPassphrase"}, {20100, D<&INewsService::SetPassphrase>, "SetPassphrase"},
{30100, D<&INewsService::GetSubscriptionStatus>, "GetSubscriptionStatus"}, {30100, D<&INewsService::GetSubscriptionStatus>, "GetSubscriptionStatus"},
{30101, nullptr, "GetTopicList"}, //3.0.0+ {30101, D<&INewsService::GetTopicList>, "GetTopicList"}, //3.0.0+
{30110, nullptr, "Unknown30110"}, //6.0.0+ {30110, D<&INewsService::GetTopicList>, "Unknown30110"}, //6.0.0+ (stub)
{30200, D<&INewsService::IsSystemUpdateRequired>, "IsSystemUpdateRequired"}, {30200, D<&INewsService::IsSystemUpdateRequired>, "IsSystemUpdateRequired"},
{30201, nullptr, "Unknown30201"}, //8.0.0+ {30201, D<&INewsService::IsSystemUpdateRequired>, "Unknown30201"}, //8.0.0+ (stub)
{30210, nullptr, "Unknown30210"}, //10.0.0+ {30210, D<&INewsService::IsSystemUpdateRequired>, "Unknown30210"}, //10.0.0+ (stub)
{30300, nullptr, "RequestImmediateReception"}, {30300, nullptr, "RequestImmediateReception"},
{30400, nullptr, "DecodeArchiveFile"}, //3.0.0-18.1.0 {30400, nullptr, "DecodeArchiveFile"}, //3.0.0-18.1.0 (stub)
{30500, nullptr, "Unknown30500"}, //8.0.0+ {30500, nullptr, "Unknown30500"}, //8.0.0+ (stub)
{30900, nullptr, "Unknown30900"}, //1.0.0 {30900, nullptr, "Unknown30900"}, //1.0.0 (stub)
{30901, nullptr, "Unknown30901"}, //1.0.0 {30901, nullptr, "Unknown30901"}, //1.0.0 (stub)
{30902, nullptr, "Unknown30902"}, //1.0.0 {30902, nullptr, "Unknown30902"}, //1.0.0 (stub)
{40100, nullptr, "SetSubscriptionStatus"}, {40100, nullptr, "SetSubscriptionStatus"},
{40101, D<&INewsService::RequestAutoSubscription>, "RequestAutoSubscription"}, //3.0.0+ {40101, D<&INewsService::RequestAutoSubscription>, "RequestAutoSubscription"}, //3.0.0+
{40200, nullptr, "ClearStorage"}, {40200, D<&INewsService::ClearStorage>, "ClearStorage"},
{40201, nullptr, "ClearSubscriptionStatusAll"}, {40201, D<&INewsService::ClearSubscriptionStatusAll>, "ClearSubscriptionStatusAll"},
{90100, nullptr, "GetNewsDatabaseDump"}, {90100, D<&INewsService::GetNewsDatabaseDump>, "GetNewsDatabaseDump"},
}; };
// clang-format on // clang-format on
@ -40,8 +43,7 @@ INewsService::INewsService(Core::System& system_) : ServiceFramework{system_, "I
INewsService::~INewsService() = default; INewsService::~INewsService() = default;
Result INewsService::PostLocalNews(InBuffer<BufferAttr_HipcAutoSelect> buffer_data) { Result INewsService::PostLocalNews(InBuffer<BufferAttr_HipcAutoSelect> 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(); R_SUCCEED();
} }
@ -63,4 +65,44 @@ Result INewsService::RequestAutoSubscription(u64 value) {
R_SUCCEED(); R_SUCCEED();
} }
Result INewsService::SetPassphrase(InBuffer<BufferAttr_HipcPointer> buffer_data) {
LOG_WARNING(Service_BCAT, "(STUBBED) SetPassphrase called size={}", buffer_data.size());
R_SUCCEED();
}
Result INewsService::GetTopicList(Out<s32> out_count, OutBuffer<BufferAttr_HipcMapAlias> 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 } // namespace Service::News

View File

@ -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-FileCopyrightText: Copyright 2024 yuzu Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
@ -20,11 +23,21 @@ public:
private: private:
Result PostLocalNews(InBuffer<BufferAttr_HipcAutoSelect> buffer_data); Result PostLocalNews(InBuffer<BufferAttr_HipcAutoSelect> buffer_data);
Result SetPassphrase(InBuffer<BufferAttr_HipcPointer> buffer_data);
Result GetSubscriptionStatus(Out<u32> out_status, InBuffer<BufferAttr_HipcPointer> buffer_data); Result GetSubscriptionStatus(Out<u32> out_status, InBuffer<BufferAttr_HipcPointer> buffer_data);
Result GetTopicList(Out<s32> out_count, OutBuffer<BufferAttr_HipcMapAlias> out_topics, s32 filter);
Result IsSystemUpdateRequired(Out<bool> out_is_system_update_required); Result IsSystemUpdateRequired(Out<bool> out_is_system_update_required);
Result RequestAutoSubscription(u64 value); Result RequestAutoSubscription(u64 value);
Result ClearStorage();
Result ClearSubscriptionStatusAll();
Result GetNewsDatabaseDump();
}; };
} // namespace Service::News } // namespace Service::News

View File

@ -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 <algorithm>
#include <chrono>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <set>
namespace Service::News {
namespace {
std::filesystem::path GetReadCachePath() {
return Common::FS::GetEdenPath(Common::FS::EdenPath::CacheDir) / "news" / "news_read";
}
std::set<std::string> LoadReadIds() {
std::set<std::string> 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<std::string>& 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<char> 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<seconds>(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<u8> 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<u8> payload) {
return Upsert(meta.news_id, "", meta.topic_id, static_cast<s64>(meta.published_at), std::move(payload));
}
std::vector<NewsRecord> NewsStorage::ListAll() const {
std::scoped_lock lk{mtx};
std::vector<NewsRecord> 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<StoredNews> 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<void(NewsRecord&)>& 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

View File

@ -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 <array>
#include <functional>
#include <mutex>
#include <optional>
#include <span>
#include <string>
#include <unordered_map>
#include <vector>
#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<char, 24> news_id{};
std::array<char, 24> user_id{};
s64 received_time{};
s32 read{};
s32 newly{};
s32 displayed{};
s32 extra1{};
};
static_assert(sizeof(NewsRecordV1) == 72);
struct NewsRecord {
std::array<char, 24> news_id{};
std::array<char, 24> user_id{};
std::array<char, 32> topic_id{};
s64 received_time{};
std::array<u8, 12> _pad1{};
s32 read{};
s32 newly{};
s32 displayed{};
std::array<u8, 8> _pad2{};
s32 extra1{};
s32 extra2{};
};
static_assert(sizeof(NewsRecord) == 128);
struct StoredNews {
NewsRecord record{};
std::vector<u8> 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<u8> payload);
StoredNews& UpsertRaw(const GithubNewsMeta& meta, std::vector<u8> payload);
std::vector<NewsRecord> ListAll() const;
std::optional<StoredNews> 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<void(NewsRecord&)>& 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<char> dst, std::string_view src);
static s64 Now();
mutable std::mutex mtx;
std::unordered_map<std::string, StoredNews> items;
size_t open_counter{};
};
} // namespace Service::News

View File

@ -16,6 +16,10 @@
#include "core/hle/service/ns/read_only_application_control_data_interface.h" #include "core/hle/service/ns/read_only_application_control_data_interface.h"
#include "core/file_sys/patch_manager.h" #include "core/file_sys/patch_manager.h"
#include "frontend_common/firmware_manager.h" #include "frontend_common/firmware_manager.h"
#include "core/launch_timestamp_cache.h"
#include <algorithm>
#include <vector>
namespace Service::NS { namespace Service::NS {
@ -560,33 +564,42 @@ Result IApplicationManagerInterface::ListApplicationRecord(
s32 offset) { s32 offset) {
const auto limit = out_records.size(); const auto limit = out_records.size();
LOG_WARNING(Service_NS, "(STUBBED) called"); LOG_DEBUG(Service_NS, "called");
const auto& cache = system.GetContentProviderUnion(); const auto& cache = system.GetContentProviderUnion();
const auto installed_games = cache.ListEntriesFilterOrigin( const auto installed_games = cache.ListEntriesFilterOrigin(
std::nullopt, FileSys::TitleType::Application, FileSys::ContentRecordType::Program); std::nullopt, FileSys::TitleType::Application, FileSys::ContentRecordType::Program);
size_t i = 0; std::vector<ApplicationRecord> records;
u8 ii = 24; records.reserve(installed_games.size());
for (const auto& [slot, game] : installed_games) { for (const auto& [slot, game] : installed_games) {
if (i >= limit) {
break;
}
if (game.title_id == 0 || game.title_id < 0x0100000000001FFFull) { if (game.title_id == 0 || game.title_id < 0x0100000000001FFFull) {
continue; continue;
} }
if (offset > 0) { if ((game.title_id & 0xFFF) != 0) {
offset--; continue; // skip sub-programs (e.g., 001)
continue;
} }
ApplicationRecord record{}; ApplicationRecord record{};
record.application_id = game.title_id; record.application_id = game.title_id;
record.type = ApplicationRecordType::Installed; record.last_event = ApplicationEvent::Installed;
record.unknown = 0; // 2 = needs update record.attributes = 0;
record.unknown2 = ii++; 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<size_t>(std::max(0, offset));
for (size_t idx = start; idx < records.size() && i < limit; ++idx) {
out_records[i++] = records[idx];
} }
*out_count = static_cast<s32>(i); *out_count = static_cast<s32>(i);

View File

@ -12,7 +12,7 @@
namespace Service::NS { namespace Service::NS {
enum class ApplicationRecordType : u8 { enum class ApplicationEvent : u8 {
Installing = 2, Installing = 2,
Installed = 3, Installed = 3,
GameCardNotInserted = 5, GameCardNotInserted = 5,
@ -34,11 +34,10 @@ enum class BackgroundNetworkUpdateState : u8 {
struct ApplicationRecord { struct ApplicationRecord {
u64 application_id; u64 application_id;
ApplicationRecordType type; ApplicationEvent last_event;
u8 unknown; u8 attributes;
INSERT_PADDING_BYTES_NOINIT(0x6); INSERT_PADDING_BYTES_NOINIT(0x6);
u8 unknown2; s64 last_updated;
INSERT_PADDING_BYTES_NOINIT(0x7);
}; };
static_assert(sizeof(ApplicationRecord) == 0x18, "ApplicationRecord has incorrect size."); static_assert(sizeof(ApplicationRecord) == 0x18, "ApplicationRecord has incorrect size.");

View File

@ -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 <filesystem>
#include <fstream>
#include <mutex>
#include <sstream>
#include <string>
#include <unordered_map>
#include <fmt/format.h>
#include <nlohmann/json.hpp>
#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<u64, s64>;
std::mutex mutex;
CacheMap cache;
bool loaded = false;
std::filesystem::path GetCachePath() {
return Common::FS::GetEdenPath(Common::FS::EdenPath::CacheDir) / "launched.json";
}
std::optional<std::string> 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<std::streamsize>(data.size()));
return static_cast<bool>(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<s64>();
}
}
} 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

View File

@ -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

View File

@ -12,16 +12,14 @@ add_library(frontend_common STATIC
firmware_manager.cpp firmware_manager.cpp
data_manager.h data_manager.cpp data_manager.h data_manager.cpp
play_time_manager.cpp play_time_manager.cpp
play_time_manager.h play_time_manager.h)
)
if (ENABLE_UPDATE_CHECKER) if (ENABLE_UPDATE_CHECKER)
target_link_libraries(frontend_common PRIVATE httplib::httplib) target_link_libraries(frontend_common PRIVATE httplib::httplib)
target_link_libraries(frontend_common PRIVATE nlohmann_json::nlohmann_json) target_link_libraries(frontend_common PRIVATE nlohmann_json::nlohmann_json)
target_sources(frontend_common PRIVATE target_sources(frontend_common PRIVATE
update_checker.cpp update_checker.cpp
update_checker.h update_checker.h)
)
if (ENABLE_OPENSSL) if (ENABLE_OPENSSL)
target_compile_definitions(frontend_common PRIVATE CPPHTTPLIB_OPENSSL_SUPPORT) target_compile_definitions(frontend_common PRIVATE CPPHTTPLIB_OPENSSL_SUPPORT)

View File

@ -4,6 +4,11 @@
// SPDX-FileCopyrightText: Copyright 2020 yuzu Emulator Project // SPDX-FileCopyrightText: Copyright 2020 yuzu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
#include <QDesktopServices>
#include <QUrl>
#include "common/logging/log.h"
#ifdef YUZU_USE_QT_WEB_ENGINE #ifdef YUZU_USE_QT_WEB_ENGINE
#include <bit> #include <bit>
@ -429,15 +434,17 @@ void QtWebBrowser::OpenLocalWebPage(const std::string& local_url,
void QtWebBrowser::OpenExternalWebPage(const std::string& external_url, void QtWebBrowser::OpenExternalWebPage(const std::string& external_url,
OpenWebPageCallback callback_) const { 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) { if (success) {
emit MainWindowOpenWebPage(external_url, "", false); LOG_INFO(Service_AM, "Successfully opened URL in host browser");
callback_(Service::AM::Frontend::WebExitReason::EndButtonPressed, external_url);
} else { } else {
emit MainWindowOpenWebPage(external_url.substr(0, index), external_url.substr(index), LOG_ERROR(Service_AM, "Failed to open URL in host browser");
false); callback_(Service::AM::Frontend::WebExitReason::WindowClosed, external_url);
} }
} }

View File

@ -400,6 +400,11 @@ void GameListWorker::ScanFileSystem(ScanTarget target, const std::string& dir_pa
if (res2 == Loader::ResultStatus::Success && program_ids.size() > 1 && if (res2 == Loader::ResultStatus::Success && program_ids.size() > 1 &&
(file_type == Loader::FileType::XCI || file_type == Loader::FileType::NSP)) { (file_type == Loader::FileType::XCI || file_type == Loader::FileType::NSP)) {
for (const auto id : program_ids) { 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); loader = Loader::GetLoader(system, file, id);
if (!loader) { if (!loader) {
continue; continue;