From 2903f3524e7b9d802da4d23ae6d25d07f7eba8f5 Mon Sep 17 00:00:00 2001 From: Zach Hilman Date: Sun, 28 Apr 2019 18:47:58 -0400 Subject: [PATCH] bcat: Add BCAT backend for Boxcat service Downloads content from yuzu servers and unpacks it into the temporary directory provided. Fully supports all Backend features except passphrase. --- src/core/hle/service/bcat/backend/boxcat.cpp | 351 +++++++++++++++++++ src/core/hle/service/bcat/backend/boxcat.h | 56 +++ 2 files changed, 407 insertions(+) create mode 100644 src/core/hle/service/bcat/backend/boxcat.cpp create mode 100644 src/core/hle/service/bcat/backend/boxcat.h diff --git a/src/core/hle/service/bcat/backend/boxcat.cpp b/src/core/hle/service/bcat/backend/boxcat.cpp new file mode 100644 index 000000000..539140f30 --- /dev/null +++ b/src/core/hle/service/bcat/backend/boxcat.cpp @@ -0,0 +1,351 @@ +// Copyright 2019 yuzu emulator team +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include +#include +#include +#include +#include "common/hex_util.h" +#include "common/logging/backend.h" +#include "common/logging/log.h" +#include "core/core.h" +#include "core/file_sys/vfs.h" +#include "core/file_sys/vfs_libzip.h" +#include "core/file_sys/vfs_vector.h" +#include "core/frontend/applets/error.h" +#include "core/hle/lock.h" +#include "core/hle/service/am/applets/applets.h" +#include "core/hle/service/bcat/backend/boxcat.h" +#include "core/settings.h" + +namespace Service::BCAT { + +constexpr char BOXCAT_HOSTNAME[] = "api.yuzu-emu.org"; + +// Formatted using fmt with arg[0] = hex title id +constexpr char BOXCAT_PATHNAME_DATA[] = "/boxcat/titles/{:016X}/data"; + +constexpr char BOXCAT_PATHNAME_EVENTS[] = "/boxcat/events"; + +constexpr char BOXCAT_API_VERSION[] = "1"; + +// HTTP status codes for Boxcat +enum class ResponseStatus { + BadClientVersion = 301, ///< The Boxcat-Client-Version doesn't match the server. + NoUpdate = 304, ///< The digest provided would match the new data, no need to update. + NoMatchTitleId = 404, ///< The title ID provided doesn't have a boxcat implementation. + NoMatchBuildId = 406, ///< The build ID provided is blacklisted (potentially because of format + ///< issues or whatnot) and has no data. +}; + +enum class DownloadResult { + Success = 0, + NoResponse, + GeneralWebError, + NoMatchTitleId, + NoMatchBuildId, + InvalidContentType, + GeneralFSError, + BadClientVersion, +}; + +constexpr std::array DOWNLOAD_RESULT_LOG_MESSAGES{ + "Success", + "There was no response from the server.", + "There was a general web error code returned from the server.", + "The title ID of the current game doesn't have a boxcat implementation. If you believe an " + "implementation should be added, contact yuzu support.", + "The build ID of the current version of the game is marked as incompatible with the current " + "BCAT distribution. Try upgrading or downgrading your game version or contacting yuzu support.", + "The content type of the web response was invalid.", + "There was a general filesystem error while saving the zip file.", + "The server is either too new or too old to serve the request. Try using the latest version of " + "an official release of yuzu.", +}; + +std::ostream& operator<<(std::ostream& os, DownloadResult result) { + return os << DOWNLOAD_RESULT_LOG_MESSAGES.at(static_cast(result)); +} + +constexpr u32 PORT = 443; +constexpr u32 TIMEOUT_SECONDS = 30; +constexpr u64 VFS_COPY_BLOCK_SIZE = 1ull << 24; // 4MB + +namespace { + +std::string GetZIPFilePath(u64 title_id) { + return fmt::format("{}bcat/{:016X}/data.zip", + FileUtil::GetUserPath(FileUtil::UserPath::CacheDir), title_id); +} + +// If the error is something the user should know about (build ID mismatch, bad client version), +// display an error. +void HandleDownloadDisplayResult(DownloadResult res) { + if (res == DownloadResult::Success || res == DownloadResult::NoResponse || + res == DownloadResult::GeneralWebError || res == DownloadResult::GeneralFSError || + res == DownloadResult::NoMatchTitleId || res == DownloadResult::InvalidContentType) { + return; + } + + const auto& frontend{Core::System::GetInstance().GetAppletManager().GetAppletFrontendSet()}; + frontend.error->ShowCustomErrorText( + ResultCode(-1), "There was an error while attempting to use Boxcat.", + DOWNLOAD_RESULT_LOG_MESSAGES[static_cast(res)], [] {}); +} + +} // namespace + +class Boxcat::Client { +public: + Client(std::string zip_path, u64 title_id, u64 build_id) + : zip_path(std::move(zip_path)), title_id(title_id), build_id(build_id) {} + + DownloadResult Download() { + const auto resolved_path = fmt::format(BOXCAT_PATHNAME_DATA, title_id); + if (client == nullptr) { + client = std::make_unique(BOXCAT_HOSTNAME, PORT, TIMEOUT_SECONDS); + } + + httplib::Headers headers{ + {std::string("Boxcat-Client-Version"), std::string(BOXCAT_API_VERSION)}, + {std::string("Boxcat-Build-Id"), fmt::format("{:016X}", build_id)}, + }; + + if (FileUtil::Exists(zip_path)) { + FileUtil::IOFile file{zip_path, "rb"}; + std::vector bytes(file.GetSize()); + file.ReadBytes(bytes.data(), bytes.size()); + const auto digest = DigestFile(bytes); + headers.insert({std::string("Boxcat-Current-Zip-Digest"), + Common::HexArrayToString(digest, false)}); + } + + const auto response = client->Get(resolved_path.c_str(), headers); + if (response == nullptr) + return DownloadResult::NoResponse; + + if (response->status == static_cast(ResponseStatus::NoUpdate)) + return DownloadResult::Success; + if (response->status == static_cast(ResponseStatus::BadClientVersion)) + return DownloadResult::BadClientVersion; + if (response->status == static_cast(ResponseStatus::NoMatchTitleId)) + return DownloadResult::NoMatchTitleId; + if (response->status == static_cast(ResponseStatus::NoMatchBuildId)) + return DownloadResult::NoMatchBuildId; + if (response->status >= 400) + return DownloadResult::GeneralWebError; + + const auto content_type = response->headers.find("content-type"); + if (content_type == response->headers.end() || + content_type->second.find("application/zip") == std::string::npos) { + return DownloadResult::InvalidContentType; + } + + FileUtil::CreateFullPath(zip_path); + FileUtil::IOFile file{zip_path, "wb"}; + if (!file.IsOpen()) + return DownloadResult::GeneralFSError; + if (!file.Resize(response->body.size())) + return DownloadResult::GeneralFSError; + if (file.WriteBytes(response->body.data(), response->body.size()) != response->body.size()) + return DownloadResult::GeneralFSError; + + return DownloadResult::Success; + } + +private: + using Digest = std::array; + static Digest DigestFile(std::vector bytes) { + Digest out{}; + mbedtls_sha256(bytes.data(), bytes.size(), out.data(), 0); + return out; + } + + std::unique_ptr client; + std::string zip_path; + u64 title_id; + u64 build_id; +}; + +Boxcat::Boxcat(DirectoryGetter getter) : Backend(std::move(getter)) {} + +Boxcat::~Boxcat() = default; + +void SynchronizeInternal(DirectoryGetter dir_getter, TitleIDVersion title, + CompletionCallback callback, std::optional dir_name = {}) { + const auto failure = [&callback] { + // Acquire the HLE mutex + std::lock_guard lock{HLE::g_hle_lock}; + callback(false); + }; + + if (Settings::values.bcat_boxcat_local) { + LOG_INFO(Service_BCAT, "Boxcat using local data by override, skipping download."); + // Acquire the HLE mutex + std::lock_guard lock{HLE::g_hle_lock}; + callback(true); + return; + } + + const auto zip_path{GetZIPFilePath(title.title_id)}; + Boxcat::Client client{zip_path, title.title_id, title.build_id}; + + const auto res = client.Download(); + if (res != DownloadResult::Success) { + LOG_ERROR(Service_BCAT, "Boxcat synchronization failed with error '{}'!", res); + HandleDownloadDisplayResult(res); + failure(); + return; + } + + FileUtil::IOFile zip{zip_path, "rb"}; + const auto size = zip.GetSize(); + std::vector bytes(size); + if (size == 0 || zip.ReadBytes(bytes.data(), bytes.size()) != bytes.size()) { + LOG_ERROR(Service_BCAT, "Boxcat failed to read ZIP file at path '{}'!", zip_path); + failure(); + return; + } + + const auto extracted = FileSys::ExtractZIP(std::make_shared(bytes)); + if (extracted == nullptr) { + LOG_ERROR(Service_BCAT, "Boxcat failed to extract ZIP file!"); + failure(); + return; + } + + if (dir_name == std::nullopt) { + const auto target_dir = dir_getter(title.title_id); + if (target_dir == nullptr || + !FileSys::VfsRawCopyD(extracted, target_dir, VFS_COPY_BLOCK_SIZE)) { + LOG_ERROR(Service_BCAT, "Boxcat failed to copy extracted ZIP to target directory!"); + failure(); + return; + } + } else { + const auto target_dir = dir_getter(title.title_id); + if (target_dir == nullptr) { + LOG_ERROR(Service_BCAT, "Boxcat failed to get directory for title ID!"); + failure(); + return; + } + + const auto target_sub = target_dir->GetSubdirectory(*dir_name); + const auto source_sub = extracted->GetSubdirectory(*dir_name); + + if (target_sub == nullptr || source_sub == nullptr || + !FileSys::VfsRawCopyD(source_sub, target_sub, VFS_COPY_BLOCK_SIZE)) { + LOG_ERROR(Service_BCAT, "Boxcat failed to copy extracted ZIP to target directory!"); + failure(); + return; + } + } + + // Acquire the HLE mutex + std::lock_guard lock{HLE::g_hle_lock}; + callback(true); +} + +bool Boxcat::Synchronize(TitleIDVersion title, CompletionCallback callback) { + is_syncing.exchange(true); + std::thread(&SynchronizeInternal, dir_getter, title, callback, std::nullopt).detach(); + return true; +} + +bool Boxcat::SynchronizeDirectory(TitleIDVersion title, std::string name, + CompletionCallback callback) { + is_syncing.exchange(true); + std::thread(&SynchronizeInternal, dir_getter, title, callback, name).detach(); + return true; +} + +bool Boxcat::Clear(u64 title_id) { + if (Settings::values.bcat_boxcat_local) { + LOG_INFO(Service_BCAT, "Boxcat using local data by override, skipping clear."); + return true; + } + + const auto dir = dir_getter(title_id); + + std::vector dirnames; + + for (const auto& subdir : dir->GetSubdirectories()) + dirnames.push_back(subdir->GetName()); + + for (const auto& subdir : dirnames) { + if (!dir->DeleteSubdirectoryRecursive(subdir)) + return false; + } + + return true; +} + +void Boxcat::SetPassphrase(u64 title_id, const Passphrase& passphrase) { + LOG_DEBUG(Service_BCAT, "called, title_id={:016X}, passphrase={}", title_id, + Common::HexArrayToString(passphrase)); +} + +Boxcat::StatusResult Boxcat::GetStatus(std::optional& global, + std::map& games) { + httplib::SSLClient client{BOXCAT_HOSTNAME, static_cast(PORT), + static_cast(TIMEOUT_SECONDS)}; + + httplib::Headers headers{ + {std::string("Boxcat-Client-Version"), std::string(BOXCAT_API_VERSION)}, + }; + + const auto response = client.Get(BOXCAT_PATHNAME_EVENTS, headers); + if (response == nullptr) + return StatusResult::Offline; + + if (response->status == static_cast(ResponseStatus::BadClientVersion)) + return StatusResult::BadClientVersion; + + try { + nlohmann::json json = nlohmann::json::parse(response->body); + + if (!json["online"].get()) + return StatusResult::Offline; + + if (json["global"].is_null()) + global = std::nullopt; + else + global = json["global"].get(); + + if (json["games"].is_array()) { + for (const auto object : json["games"]) { + if (object.is_object() && object.find("name") != object.end()) { + EventStatus detail{}; + if (object["header"].is_string()) { + detail.header = object["header"].get(); + } else { + detail.header = std::nullopt; + } + + if (object["footer"].is_string()) { + detail.footer = object["footer"].get(); + } else { + detail.footer = std::nullopt; + } + + if (object["events"].is_array()) { + for (const auto& event : object["events"]) { + if (!event.is_string()) + continue; + detail.events.push_back(event.get()); + } + } + + games.insert_or_assign(object["name"], std::move(detail)); + } + } + } + + return StatusResult::Success; + } catch (const nlohmann::json::parse_error& e) { + return StatusResult::ParseError; + } +} + +} // namespace Service::BCAT diff --git a/src/core/hle/service/bcat/backend/boxcat.h b/src/core/hle/service/bcat/backend/boxcat.h new file mode 100644 index 000000000..f4e60f264 --- /dev/null +++ b/src/core/hle/service/bcat/backend/boxcat.h @@ -0,0 +1,56 @@ +// Copyright 2019 yuzu emulator team +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#include +#include +#include +#include "core/hle/service/bcat/backend/backend.h" + +namespace Service::BCAT { + +struct EventStatus { + std::optional header; + std::optional footer; + std::vector events; +}; + +/// Boxcat is yuzu's custom backend implementation of Nintendo's BCAT service. It is free to use and +/// doesn't require a switch or nintendo account. The content is controlled by the yuzu team. +class Boxcat final : public Backend { + friend void SynchronizeInternal(DirectoryGetter dir_getter, TitleIDVersion title, + CompletionCallback callback, + std::optional dir_name); + +public: + explicit Boxcat(DirectoryGetter getter); + ~Boxcat() override; + + bool Synchronize(TitleIDVersion title, CompletionCallback callback) override; + bool SynchronizeDirectory(TitleIDVersion title, std::string name, + CompletionCallback callback) override; + + bool Clear(u64 title_id) override; + + void SetPassphrase(u64 title_id, const Passphrase& passphrase) override; + + enum class StatusResult { + Success, + Offline, + ParseError, + BadClientVersion, + }; + + static StatusResult GetStatus(std::optional& global, + std::map& games); + +private: + std::atomic_bool is_syncing{false}; + + class Client; + std::unique_ptr client; +}; + +} // namespace Service::BCAT