diff --git a/lang/system/en.xml b/lang/system/en.xml index 948e7ed48..2a136b11f 100644 --- a/lang/system/en.xml +++ b/lang/system/en.xml @@ -125,6 +125,8 @@ depending on its size and your hardware. An error occurred. Error code: {} Cancel + Connecting... +Please wait. Close Could not load the file. Could not save the file. @@ -140,6 +142,10 @@ Error code: {} Submit Yes + Could not connect to the server. + You must sign in to Vita3K Network to use this feature. + Could not connect to the Internet. + The connection to Vita3K Network has been lost. Sunday Monday @@ -558,6 +564,27 @@ Check vita3k.log to see console output for details. 3. If you want to install or boot Vitamin, it is not supported. + + Saved Data Auto-Upload + Copying... + Download + Eligible + Ineligible + Location + Memory Card + {} Minutes Left + Online Storage + Do you want to overwrite the saved data? + PS Vita System + PS TV System + {} Seconds Left + Size + Trophy Earning + Updated + Upload + Uploaded + + Gamepad Overlay Show gamepad overlay ingame diff --git a/lang/system/fr.xml b/lang/system/fr.xml index 43c91aeb4..ea3231ae1 100644 --- a/lang/system/fr.xml +++ b/lang/system/fr.xml @@ -93,6 +93,8 @@ Une erreur est survenue. Code d'erreur: {} Annuler + Connexion en cours... +Merci de patienter. Fermer Le chargement du fichier a échoué. La sauvegarde du fichier a échoué. @@ -106,6 +108,10 @@ Code d'erreur: {} Sélectionner tout Oui + La connexion au serveur a échoué. + Vous devez vous connecter à Vita3K Network pour utiliser cette fonctionnalité. + La connexion à Internet a échoué. + La connexion à Vita3K Network a été interrompue. dimanche lundi @@ -372,6 +378,27 @@ Appuyez sur 'Autoriser l'accès' pour continuer. + + Téléchargement en amont automatique des données sauvegardées + Copie en cours... + Télécharger + Autorisé + Non autorisé + Emplacement + Carte mémoire + {} minutes restantes + Stockage en ligne + Écraser les données sauvegardées ? + Système PS Vita + Système PS TV + {} secondes restantes + Taille + Obtention de trophées + Mis à jour + Téléchargement en amont + Téléchargée en amont + + Musique système diff --git a/vita3k/CMakeLists.txt b/vita3k/CMakeLists.txt index e8aa40a75..4a41d7113 100644 --- a/vita3k/CMakeLists.txt +++ b/vita3k/CMakeLists.txt @@ -130,6 +130,7 @@ add_subdirectory(touch) add_subdirectory(util) add_subdirectory(gdbstub) add_subdirectory(packages) +add_subdirectory(v3kn) add_subdirectory(vkutil) if(ANDROID) diff --git a/vita3k/emuenv/CMakeLists.txt b/vita3k/emuenv/CMakeLists.txt index 473e9522e..d848d7235 100644 --- a/vita3k/emuenv/CMakeLists.txt +++ b/vita3k/emuenv/CMakeLists.txt @@ -5,4 +5,4 @@ add_library( target_include_directories(emuenv INTERFACE include) target_link_libraries(emuenv PUBLIC mem) -target_link_libraries(emuenv PRIVATE audio camera config ctrl dialog display ime io kernel motion net ngs nids np regmgr renderer touch gdbstub packages http) +target_link_libraries(emuenv PRIVATE audio camera config ctrl dialog display ime io kernel motion net ngs nids np regmgr renderer touch gdbstub packages http v3kn) diff --git a/vita3k/emuenv/include/emuenv/state.h b/vita3k/emuenv/include/emuenv/state.h index 77f3b1f28..d3e23a120 100644 --- a/vita3k/emuenv/include/emuenv/state.h +++ b/vita3k/emuenv/include/emuenv/state.h @@ -55,6 +55,7 @@ struct DialogState; struct Ime; struct License; struct RegMgrState; +struct V3KNState; struct SfoFile; struct GDBState; struct HTTPState; @@ -101,6 +102,7 @@ private: std::unique_ptr _ime; std::unique_ptr _license; std::unique_ptr _regmgr; + std::unique_ptr _v3kn; std::unique_ptr _sfo_handle; std::unique_ptr _gdb; std::unique_ptr _http; @@ -167,6 +169,7 @@ public: Ime &ime; License &license; RegMgrState ®mgr; + V3KNState &v3kn; SfoFile &sfo_handle; NIDSet missing_nids; float system_dpi_scale = 1.f; diff --git a/vita3k/emuenv/src/emuenv.cpp b/vita3k/emuenv/src/emuenv.cpp index a45f43c12..bd03e3986 100644 --- a/vita3k/emuenv/src/emuenv.cpp +++ b/vita3k/emuenv/src/emuenv.cpp @@ -38,6 +38,7 @@ #include #include #include +#include #include @@ -85,6 +86,8 @@ EmuEnvState::EmuEnvState() , license(*_license) , _regmgr(new RegMgrState) , regmgr(*_regmgr) + , _v3kn(new V3KNState) + , v3kn(*_v3kn) , _sfo_handle(new SfoFile) , sfo_handle(*_sfo_handle) , _gdb(new GDBState) diff --git a/vita3k/gui/CMakeLists.txt b/vita3k/gui/CMakeLists.txt index 86c77bd5d..10e109b03 100644 --- a/vita3k/gui/CMakeLists.txt +++ b/vita3k/gui/CMakeLists.txt @@ -26,6 +26,7 @@ set(GUI_SOURCES src/main_menubar.cpp src/manual.cpp src/mutexes_dialog.cpp + src/online_storage.cpp src/perf_overlay.cpp src/pkg_install_dialog.cpp src/private.h @@ -44,6 +45,7 @@ set(GUI_SOURCES src/disassembly_dialog.cpp src/trophy_unlocked.cpp src/about_dialog.cpp + src/v3kn_dialog.cpp src/vita3k_update.cpp src/welcome_dialog.cpp ) @@ -59,7 +61,7 @@ endif() add_library(gui STATIC ${GUI_SOURCES}) target_include_directories(gui PUBLIC include ${CMAKE_SOURCE_DIR}/vita3k) -target_link_libraries(gui PUBLIC app bgm_player compat config dialog emuenv ime imgui lang regmgr np) +target_link_libraries(gui PUBLIC app bgm_player compat config dialog emuenv ime imgui lang regmgr np v3kn) target_link_libraries(gui PRIVATE audio camera cppcommon ctrl kernel miniz motion psvpfsparser pugixml::pugixml stb renderer packages SDL3::SDL3 touch vkutil host::dialog concurrentqueue) # Link macOS frameworks for Objective-C++ code if(APPLE) diff --git a/vita3k/gui/include/gui/state.h b/vita3k/gui/include/gui/state.h index 99070c4dc..2eaec30ba 100644 --- a/vita3k/gui/include/gui/state.h +++ b/vita3k/gui/include/gui/state.h @@ -120,10 +120,13 @@ struct VitaAreaState { bool information_bar = false; bool live_area_screen = false; bool manual = false; + bool online_storage = false; bool settings = false; bool start_screen = false; bool trophy_collection = false; bool user_management = false; + bool connecting_please_wait = false; + bool please_wait = false; }; struct FileMenuState { @@ -150,6 +153,7 @@ struct DebugMenuState { struct ConfigurationMenuState { bool custom_settings_dialog = false; bool settings_dialog = false; + bool v3kn_dialog = false; }; struct ControlMenuState { @@ -181,6 +185,18 @@ enum DateTime { HOUR, }; +enum class AvatarSize : uint32_t { + SMALL = 34, + MEDIUM = 130, + LARGE = 160, + V3KN = 80 +}; + +struct AvatarInfo { + ImVec2 pos; + ImVec2 size; +}; + struct User { std::string id; std::string name = "Vita3K"; @@ -287,6 +303,10 @@ struct GuiState { std::map users; std::map users_avatar; + std::map> users_avatar_infos; + + ImGui_Texture v3kn_avatar; + ImGui_Texture v3kn_panel; MemoryEditor memory_editor; MemoryEditor gxp_shader_editor; diff --git a/vita3k/gui/src/app_context_menu.cpp b/vita3k/gui/src/app_context_menu.cpp index a571309c9..32d0bf5a3 100644 --- a/vita3k/gui/src/app_context_menu.cpp +++ b/vita3k/gui/src/app_context_menu.cpp @@ -33,6 +33,8 @@ #include #include +#include + #include #include #undef main @@ -879,6 +881,8 @@ void draw_app_context_menu(GuiState &gui, EmuEnvState &emuenv, const std::string open_manual(gui, emuenv, app_path); if (ImGui::MenuItem(gui.lang.home_screen["refresh"].c_str())) refresh_app(gui, emuenv, app_path); + if (title_id.starts_with("PCS") && ImGui::MenuItem(gui.lang.online_storage["online_storage"].c_str())) + v3kn::open_online_storage(gui, emuenv, title_id); ImGui::EndMenu(); } if (ImGui::BeginMenu(common["delete"].c_str())) { diff --git a/vita3k/gui/src/gui.cpp b/vita3k/gui/src/gui.cpp index 45602782d..94435c6d5 100644 --- a/vita3k/gui/src/gui.cpp +++ b/vita3k/gui/src/gui.cpp @@ -463,6 +463,54 @@ void init_app_background(GuiState &gui, EmuEnvState &emuenv, const std::string & stbi_image_free(data); } +static void draw_connecting_please_wait(GuiState &gui, EmuEnvState &emuenv) { + const ImVec2 VIEWPORT_SIZE(emuenv.logical_viewport_size.x, emuenv.logical_viewport_size.y); + const ImVec2 VIEWPORT_POS(emuenv.logical_viewport_pos.x, emuenv.logical_viewport_pos.y); + const auto RES_SCALE = ImVec2(emuenv.gui_scale.x, emuenv.gui_scale.y); + const auto SCALE = ImVec2(RES_SCALE.x * emuenv.manual_dpi_scale, RES_SCALE.y * emuenv.manual_dpi_scale); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.f); + ImGui::SetNextWindowPos(VIEWPORT_POS); + ImGui::SetNextWindowSize(VIEWPORT_SIZE); + ImGui::Begin("please_wait", &gui.vita_area.connecting_please_wait, ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoDecoration); + const ImVec2 WINDOW_SIZE(520.f * SCALE.x, 120.f * SCALE.y); + ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, 15.f * SCALE.x); + ImGui::SetNextWindowPos(ImVec2(VIEWPORT_POS.x + ((VIEWPORT_SIZE.x - WINDOW_SIZE.x) / 2.f), VIEWPORT_POS.y + ((VIEWPORT_SIZE.y - WINDOW_SIZE.y) / 2.f))); + ImGui::BeginChild("please_wait_child", WINDOW_SIZE, ImGuiChildFlags_Borders, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings); + const auto text = emuenv.common_dialog.lang.common["connecting_please_wait"]; + const auto text_size = ImGui::CalcTextSize(text.c_str()); + ImGui::SetCursorPos(ImVec2((WINDOW_SIZE.x - text_size.x) / 2.f, (WINDOW_SIZE.y - text_size.y) / 2.f)); + ImGui::Text("%s", text.c_str()); + ImGui::EndChild(); + ImGui::PopStyleVar(); + ImGui::End(); + ImGui::PopStyleVar(2); +} + +static void draw_please_wait(GuiState &gui, EmuEnvState &emuenv) { + const ImVec2 VIEWPORT_SIZE(emuenv.logical_viewport_size.x, emuenv.logical_viewport_size.y); + const ImVec2 VIEWPORT_POS(emuenv.logical_viewport_pos.x, emuenv.logical_viewport_pos.y); + const auto RES_SCALE = ImVec2(emuenv.gui_scale.x, emuenv.gui_scale.y); + const auto SCALE = ImVec2(RES_SCALE.x * emuenv.manual_dpi_scale, RES_SCALE.y * emuenv.manual_dpi_scale); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.f); + ImGui::SetNextWindowPos(VIEWPORT_POS); + ImGui::SetNextWindowSize(VIEWPORT_SIZE); + ImGui::Begin("please_wait", &gui.vita_area.please_wait, ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoDecoration); + const ImVec2 WINDOW_SIZE(520.f * SCALE.x, 120.f * SCALE.y); + ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, 15.f * SCALE.x); + ImGui::SetNextWindowPos(ImVec2(VIEWPORT_POS.x + ((VIEWPORT_SIZE.x - WINDOW_SIZE.x) / 2.f), VIEWPORT_POS.y + ((VIEWPORT_SIZE.y - WINDOW_SIZE.y) / 2.f))); + ImGui::BeginChild("please_wait_child", WINDOW_SIZE, ImGuiChildFlags_Borders, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings); + const auto text = emuenv.common_dialog.lang.common["please_wait"]; + const auto text_size = ImGui::CalcTextSize(text.c_str()); + ImGui::SetCursorPos(ImVec2((WINDOW_SIZE.x - text_size.x) / 2.f, (WINDOW_SIZE.y - text_size.y) / 2.f)); + ImGui::Text("%s", text.c_str()); + ImGui::EndChild(); + ImGui::PopStyleVar(); + ImGui::End(); + ImGui::PopStyleVar(2); +} + std::string get_sys_lang_name(uint32_t lang_id) { const auto current_sys_lang = std::find_if(LIST_SYS_LANG.begin(), LIST_SYS_LANG.end(), [&](const auto &l) { return l.first == lang_id; @@ -927,14 +975,21 @@ void draw_vita_area(GuiState &gui, EmuEnvState &emuenv) { if (gui.vita_area.app_close) draw_app_close(gui, emuenv); + if (gui.vita_area.connecting_please_wait) + draw_connecting_please_wait(gui, emuenv); + if (gui.vita_area.home_screen) draw_home_screen(gui, emuenv); if (gui.vita_area.live_area_screen) draw_live_area_screen(gui, emuenv); + if (gui.vita_area.manual) draw_manual(gui, emuenv); + if (gui.vita_area.please_wait) + draw_please_wait(gui, emuenv); + // Draw install dialogs if (gui.file_menu.archive_install_dialog) draw_archive_install_dialog(gui, emuenv); @@ -961,6 +1016,9 @@ void draw_vita_area(GuiState &gui, EmuEnvState &emuenv) { if (gui.vita_area.content_manager) draw_content_manager(gui, emuenv); + if (gui.vita_area.online_storage) + draw_online_storage(gui, emuenv); + if (gui.vita_area.settings) draw_settings(gui, emuenv); @@ -985,6 +1043,9 @@ void draw_ui(GuiState &gui, EmuEnvState &emuenv) { if ((gui.vita_area.home_screen || !emuenv.io.app_path.empty()) && get_sys_apps_state(gui) && !gui.vita_area.live_area_screen && !gui.vita_area.user_management && (!emuenv.cfg.show_info_bar || !gui.vita_area.information_bar)) draw_main_menu_bar(gui, emuenv); + if (gui.configuration_menu.v3kn_dialog) + draw_v3kn_dialog(gui, emuenv); + if (gui.configuration_menu.custom_settings_dialog || gui.configuration_menu.settings_dialog) draw_settings_dialog(gui, emuenv); diff --git a/vita3k/gui/src/live_area.cpp b/vita3k/gui/src/live_area.cpp index dad8c2b4c..3efd5b399 100644 --- a/vita3k/gui/src/live_area.cpp +++ b/vita3k/gui/src/live_area.cpp @@ -35,9 +35,12 @@ #include +#include + #include #include +#include #include #include @@ -66,7 +69,7 @@ void GateAnimation::update() { namespace gui { bool get_sys_apps_state(GuiState &gui) { - return !gui.vita_area.content_manager && !gui.vita_area.settings && !gui.vita_area.trophy_collection && !gui.vita_area.manual; + return !gui.vita_area.content_manager && !gui.vita_area.online_storage && !gui.vita_area.settings && !gui.vita_area.trophy_collection && !gui.vita_area.manual; } struct FRAME { @@ -577,7 +580,8 @@ enum LiveAreaType { GATE, SEARCH, MANUAL, - REFRESH + REFRESH, + CLOUD }; struct ButtonEntry { @@ -912,6 +916,7 @@ void draw_live_area_screen(GuiState &gui, EmuEnvState &emuenv) { const ImVec2 background_pos_max(background_pos_min.x + bg_scal_size.x, background_pos_min.y + bg_scal_size.y); DRAW_LIST->AddImage(gui.live_items[app_path][frame.id]["background"][current_item[app_path][frame.id]], background_pos_min, background_pos_max); } + if (gui.live_items[app_path][frame.id].contains("image")) { const auto image_pos_min = set_screen_pos(img_pos); img_scal_size = apply_zoom_size(img_scal_size); @@ -1238,6 +1243,155 @@ void draw_live_area_screen(GuiState &gui, EmuEnvState &emuenv) { } if (app_device == VitaIoDevice::ux0) { + const auto rotate_point = [](const ImVec2 &point, const ImVec2 ¢er, float cos_angle, float sin_angle) -> ImVec2 { + const ImVec2 offset(point.x - center.x, point.y - center.y); + return ImVec2( + center.x + (offset.x * cos_angle) - (offset.y * sin_angle), + center.y + (offset.x * sin_angle) + (offset.y * cos_angle)); + }; + + const auto draw_cloud_icon = [&](ImDrawList *draw_list, const ImVec2 &pos_min, const ImVec2 &size) { + const float BG_ROUNDING = 12.0f * SCALE.x; + const float BORDER_THICKNESS = 1.5f * SCALE.x; + const float ANGLE = -0.24f; + const float COS_ANGLE = std::cos(ANGLE); + const float SIN_ANGLE = std::sin(ANGLE); + const float BRANCH_SQUARE = size.x * 0.225f; + const float HALF_SQUARE = BRANCH_SQUARE * 0.50f; + const float HALF_EXTENT = BRANCH_SQUARE * 1.50f; + const float SYMBOL_STROKE = 2.0f * SCALE.x; + const float SYMBOL_TRIANGLE_RADIUS = size.x * 0.053f; + const float SYMBOL_SQUARE_RADIUS = size.x * 0.051f; + const float SYMBOL_CIRCLE_RADIUS = size.x * 0.052f; + const float SYMBOL_CROSS_RADIUS = size.x * 0.047f; + const ImVec2 POS_MAX(pos_min.x + size.x, pos_min.y + size.y); + const ImVec2 CENTER(pos_min.x + size.x * 0.53f, pos_min.y + size.y * 0.44f); + const ImVec2 DEPTH(-5.6f * SCALE.x, 6.4f * SCALE.y); + const ImU32 BG_COLOR = IM_COL32(221, 221, 221, 255); + const ImU32 BORDER_COLOR = IM_COL32(192, 193, 196, 255); + const ImU32 FRONT_COLOR = IM_COL32(255, 226, 86, 255); + const ImU32 OUTLINE_COLOR = IM_COL32(220, 164, 20, 255); + const ImU32 SYMBOL_COLOR = IM_COL32(158, 103, 6, 255); + const ImVec2 RAW[12] = { + ImVec2(-HALF_SQUARE, -HALF_EXTENT), ImVec2(HALF_SQUARE, -HALF_EXTENT), + ImVec2(HALF_SQUARE, -HALF_SQUARE), ImVec2(HALF_EXTENT, -HALF_SQUARE), + ImVec2(HALF_EXTENT, HALF_SQUARE), ImVec2(HALF_SQUARE, HALF_SQUARE), + ImVec2(HALF_SQUARE, HALF_EXTENT), ImVec2(-HALF_SQUARE, HALF_EXTENT), + ImVec2(-HALF_SQUARE, HALF_SQUARE), ImVec2(-HALF_EXTENT, HALF_SQUARE), + ImVec2(-HALF_EXTENT, -HALF_SQUARE), ImVec2(-HALF_SQUARE, -HALF_SQUARE) + }; + const ImVec2 FRONT_SQUARES[5] = { + ImVec2(0.0f, 0.0f), + ImVec2(0.0f, -BRANCH_SQUARE), + ImVec2(-BRANCH_SQUARE, 0.0f), + ImVec2(BRANCH_SQUARE, 0.0f), + ImVec2(0.0f, BRANCH_SQUARE) + }; + struct FaceDef { + int first; + int second; + ImU32 color; + ImVec2 offset_scale; + }; + const FaceDef FACES[] = { + { 4, 5, IM_COL32(220, 160, 52, 255), ImVec2(0.22f, 0.88f) }, + { 6, 7, IM_COL32(205, 145, 36, 255), ImVec2(1.00f, 1.00f) }, + { 7, 8, IM_COL32(212, 151, 42, 255), ImVec2(1.00f, 1.00f) }, + { 8, 9, IM_COL32(219, 158, 48, 255), ImVec2(1.00f, 1.00f) }, + { 9, 10, IM_COL32(214, 153, 44, 255), ImVec2(1.00f, 1.00f) }, + { 10, 11, IM_COL32(208, 148, 40, 255), ImVec2(0.92f, 0.92f) }, + { 11, 0, IM_COL32(198, 140, 34, 255), ImVec2(0.70f, 0.78f) } + }; + + draw_list->AddRectFilled(pos_min, POS_MAX, BG_COLOR, BG_ROUNDING, ImDrawFlags_RoundCornersBottom); + draw_list->AddRect(pos_min, POS_MAX, BORDER_COLOR, BG_ROUNDING, ImDrawFlags_RoundCornersBottom, BORDER_THICKNESS); + + const auto to_world = [&](const ImVec2 &point) -> ImVec2 { + return ImVec2(CENTER.x + point.x * COS_ANGLE - point.y * SIN_ANGLE, CENTER.y + point.x * SIN_ANGLE + point.y * COS_ANGLE); + }; + const auto rotate_symbol_point = [&](const ImVec2 &point, const ImVec2 &pivot) -> ImVec2 { + return rotate_point(point, pivot, COS_ANGLE, SIN_ANGLE); + }; + const auto make_vec2 = [](float x, float y) -> ImVec2 { + ImVec2 value; + value.x = x; + value.y = y; + return value; + }; + const auto draw_face = [&](int first, int second, ImU32 color, const ImVec2 &offset_scale) { + const ImVec2 offset = make_vec2(DEPTH.x * offset_scale.x, DEPTH.y * offset_scale.y); + const ImVec2 first_world = to_world(RAW[first]); + const ImVec2 second_world = to_world(RAW[second]); + const ImVec2 quad[4] = { + make_vec2(first_world.x, first_world.y), + make_vec2(second_world.x, second_world.y), + make_vec2(second_world.x + offset.x, second_world.y + offset.y), + make_vec2(first_world.x + offset.x, first_world.y + offset.y) + }; + draw_list->AddConvexPolyFilled(quad, IM_ARRAYSIZE(quad), color); + }; + const auto draw_square = [&](const ImVec2 &square_center, ImU32 color) { + const ImVec2 quad[4] = { + to_world(ImVec2(square_center.x - HALF_SQUARE, square_center.y - HALF_SQUARE)), + to_world(ImVec2(square_center.x + HALF_SQUARE, square_center.y - HALF_SQUARE)), + to_world(ImVec2(square_center.x + HALF_SQUARE, square_center.y + HALF_SQUARE)), + to_world(ImVec2(square_center.x - HALF_SQUARE, square_center.y + HALF_SQUARE)) + }; + draw_list->AddConvexPolyFilled(quad, IM_ARRAYSIZE(quad), color); + }; + const auto symbol_center = [&](const ImVec2 &offset) -> ImVec2 { + return to_world(offset); + }; + + for (const auto &face : FACES) + draw_face(face.first, face.second, face.color, face.offset_scale); + + for (const auto &square_center : FRONT_SQUARES) + draw_square(square_center, FRONT_COLOR); + + ImVec2 TOP[12]; + for (int i = 0; i < IM_ARRAYSIZE(RAW); ++i) + TOP[i] = to_world(RAW[i]); + draw_list->AddPolyline(TOP, IM_ARRAYSIZE(TOP), OUTLINE_COLOR, ImDrawFlags_Closed, 1.8f * SCALE.x); + + { + const ImVec2 TRI_CENTER = symbol_center(ImVec2(0.0f, -BRANCH_SQUARE)); + ImVec2 tri[3] = { + ImVec2(TRI_CENTER.x, TRI_CENTER.y - SYMBOL_TRIANGLE_RADIUS), + ImVec2(TRI_CENTER.x - (SYMBOL_TRIANGLE_RADIUS * 0.87f), TRI_CENTER.y + (SYMBOL_TRIANGLE_RADIUS * 0.50f)), + ImVec2(TRI_CENTER.x + (SYMBOL_TRIANGLE_RADIUS * 0.87f), TRI_CENTER.y + (SYMBOL_TRIANGLE_RADIUS * 0.50f)) + }; + for (auto &point : tri) + point = rotate_symbol_point(point, TRI_CENTER); + draw_list->AddPolyline(tri, IM_ARRAYSIZE(tri), SYMBOL_COLOR, ImDrawFlags_Closed, SYMBOL_STROKE); + } + + { + const ImVec2 SQUARE_CENTER = symbol_center(ImVec2(-BRANCH_SQUARE, 0.0f)); + ImVec2 square[4] = { + ImVec2(SQUARE_CENTER.x - SYMBOL_SQUARE_RADIUS, SQUARE_CENTER.y - SYMBOL_SQUARE_RADIUS), + ImVec2(SQUARE_CENTER.x + SYMBOL_SQUARE_RADIUS, SQUARE_CENTER.y - SYMBOL_SQUARE_RADIUS), + ImVec2(SQUARE_CENTER.x + SYMBOL_SQUARE_RADIUS, SQUARE_CENTER.y + SYMBOL_SQUARE_RADIUS), + ImVec2(SQUARE_CENTER.x - SYMBOL_SQUARE_RADIUS, SQUARE_CENTER.y + SYMBOL_SQUARE_RADIUS) + }; + for (auto &point : square) + point = rotate_symbol_point(point, SQUARE_CENTER); + draw_list->AddPolyline(square, IM_ARRAYSIZE(square), SYMBOL_COLOR, ImDrawFlags_Closed, SYMBOL_STROKE); + } + + draw_list->AddCircle(symbol_center(ImVec2(BRANCH_SQUARE, 0.0f)), SYMBOL_CIRCLE_RADIUS, SYMBOL_COLOR, 0, SYMBOL_STROKE); + + { + const ImVec2 CROSS_CENTER = symbol_center(ImVec2(0.0f, BRANCH_SQUARE)); + const ImVec2 LINE_A = rotate_symbol_point(ImVec2(CROSS_CENTER.x - SYMBOL_CROSS_RADIUS, CROSS_CENTER.y - SYMBOL_CROSS_RADIUS), CROSS_CENTER); + const ImVec2 LINE_B = rotate_symbol_point(ImVec2(CROSS_CENTER.x + SYMBOL_CROSS_RADIUS, CROSS_CENTER.y + SYMBOL_CROSS_RADIUS), CROSS_CENTER); + const ImVec2 LINE_C = rotate_symbol_point(ImVec2(CROSS_CENTER.x - SYMBOL_CROSS_RADIUS, CROSS_CENTER.y + SYMBOL_CROSS_RADIUS), CROSS_CENTER); + const ImVec2 LINE_D = rotate_symbol_point(ImVec2(CROSS_CENTER.x + SYMBOL_CROSS_RADIUS, CROSS_CENTER.y - SYMBOL_CROSS_RADIUS), CROSS_CENTER); + draw_list->AddLine(LINE_A, LINE_B, SYMBOL_COLOR, SYMBOL_STROKE); + draw_list->AddLine(LINE_C, LINE_D, SYMBOL_COLOR, SYMBOL_STROKE); + } + }; + auto APP_INDEX = get_app_index(gui, app_path); const auto widget_scal_size = apply_zoom_size(ImVec2(70.0f * SCALE.x, 70.f * SCALE.y)); @@ -1251,7 +1405,9 @@ void draw_live_area_screen(GuiState &gui, EmuEnvState &emuenv) { if (manual_exist) buttons.emplace_back("Manual", MANUAL, [&]() { open_manual(gui, emuenv, app_path); }); buttons.emplace_back("Refresh", REFRESH, [&]() { refresh_app(gui, emuenv, app_path); }); - + if (app_path.find("PCS") != std::string::npos) { + buttons.emplace_back("Cloud", CLOUD, [&]() { v3kn::open_online_storage(gui, emuenv, APP_INDEX->title_id); }); + } const auto BUTTONS_COUNT = static_cast(buttons.size()); const float spacing = 42.0f * SCALE.x; const ImVec2 BUTTONS_POS = ImVec2( @@ -1273,8 +1429,12 @@ void draw_live_area_screen(GuiState &gui, EmuEnvState &emuenv) { if (TYPE == MANUAL) WIDGET_COLOR = IM_COL32(202, 0, 106, 255); - DRAW_LIST->AddRectFilled(WIDGET_POS_MIN, ImVec2(WIDGET_POS_MIN.x + widget_scal_size.x, WIDGET_POS_MIN.y + widget_scal_size.y), WIDGET_COLOR, 12.0f * SCALE.x, ImDrawFlags_RoundCornersAll); - DRAW_LIST->AddText(gui.vita_font[emuenv.current_font_level], widget_font_size, WIDGET_STR_POS, IM_COL32(255, 255, 255, 255), WIDGET_STR); + if (TYPE == CLOUD) + draw_cloud_icon(DRAW_LIST, WIDGET_POS_MIN, widget_scal_size); + else { + DRAW_LIST->AddRectFilled(WIDGET_POS_MIN, ImVec2(WIDGET_POS_MIN.x + widget_scal_size.x, WIDGET_POS_MIN.y + widget_scal_size.y), WIDGET_COLOR, 12.0f * SCALE.x, ImDrawFlags_RoundCornersAll); + DRAW_LIST->AddText(gui.vita_font[emuenv.current_font_level], widget_font_size, WIDGET_STR_POS, IM_COL32(255, 255, 255, 255), WIDGET_STR); + } ImGui::SetCursorPos(button_pos_scal); if (!is_current_animated) { if (ImGui::Selectable(("##" + TITLE).c_str(), gui.is_nav_button && (live_area_type_selected == TYPE), ImGuiSelectableFlags_None, widget_scal_size)) diff --git a/vita3k/gui/src/main_menubar.cpp b/vita3k/gui/src/main_menubar.cpp index 9d3872b7e..067bb0a17 100644 --- a/vita3k/gui/src/main_menubar.cpp +++ b/vita3k/gui/src/main_menubar.cpp @@ -79,7 +79,7 @@ static void draw_emulation_menu(GuiState &gui, EmuEnvState &emuenv) { } ImGui::PushStyleVar(ImGuiStyleVar_SelectableTextAlign, ImVec2(0.5f, 0.5f)); if (ImGui::Selectable(app.title.c_str(), false, ImGuiSelectableFlags_SpanAllColumns, ImVec2(0, ICON_SIZE.y))) - pre_load_app(gui, emuenv, emuenv.cfg.show_live_area_screen, app.title_id); + pre_load_app(gui, emuenv, emuenv.cfg.show_live_area_screen, app.path); ImGui::PopStyleVar(); ImGui::PopID(); ImGui::PopStyleColor(); @@ -135,6 +135,7 @@ static void draw_config_menu(GuiState &gui, EmuEnvState &emuenv) { init_config(gui, emuenv, emuenv.io.app_path); if (ImGui::MenuItem(lang["user_management"].c_str(), nullptr, &gui.vita_area.user_management, (!gui.vita_area.user_management && emuenv.io.title_id.empty()))) init_user_management(gui, emuenv); + ImGui::MenuItem("Vita3K Network", nullptr, &gui.configuration_menu.v3kn_dialog); ImGui::EndMenu(); } } diff --git a/vita3k/gui/src/online_storage.cpp b/vita3k/gui/src/online_storage.cpp new file mode 100644 index 000000000..7d0bfe5af --- /dev/null +++ b/vita3k/gui/src/online_storage.cpp @@ -0,0 +1,366 @@ +// Vita3K emulator project +// Copyright (C) 2026 Vita3K team +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, write to the Free Software Foundation, Inc., +// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +#include "private.h" + +#include +#include +#include + +#include +#include + +#include +#include + +namespace gui { + +static std::string get_remaining_str(LangState &lang, const uint64_t remaining) { + if (remaining > 60) + return fmt::format(fmt::runtime(lang.online_storage["minutes_left"]), remaining / 60); + else + return fmt::format(fmt::runtime(lang.online_storage["seconds_left"]), remaining); +} + +void draw_online_storage(GuiState &gui, EmuEnvState &emuenv) { + const ImVec2 VIEWPORT_SIZE(emuenv.logical_viewport_size.x, emuenv.logical_viewport_size.y); + const ImVec2 VIEWPORT_POS(emuenv.logical_viewport_pos.x, emuenv.logical_viewport_pos.y); + const auto RES_SCALE = ImVec2(emuenv.gui_scale.x, emuenv.gui_scale.y); + const auto SCALE = ImVec2(RES_SCALE.x * emuenv.manual_dpi_scale, RES_SCALE.y * emuenv.manual_dpi_scale); + const auto INDICATOR_SIZE = ImVec2(VIEWPORT_SIZE.x, 32.f * SCALE.y); + const ImVec2 WINDOW_SIZE(VIEWPORT_SIZE.x, VIEWPORT_SIZE.y - INDICATOR_SIZE.y); + const ImVec2 WINDOW_POS(VIEWPORT_POS.x, VIEWPORT_POS.y + INDICATOR_SIZE.y); + + const auto &app_path = emuenv.cfg.show_live_area_screen && (gui.live_area_app_current_open >= 0) ? gui.live_area_current_open_apps_list[gui.live_area_app_current_open] : emuenv.app_path; + const auto APP_INDEX = get_app_index(gui, app_path); + const auto &app_titleid = APP_INDEX->title_id; + const auto &app_title = APP_INDEX->title; + const auto &app_icon = gui.app_selector.user_apps_icon[app_path]; + + auto &storage_state = emuenv.v3kn.storage_state; + auto &lang = gui.lang.online_storage; + auto &common_lang = emuenv.common_dialog.lang.common; + const auto is_12_hour_format = emuenv.cfg.sys_time_format == SCE_SYSTEM_PARAM_TIME_FORMAT_12HOUR; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.f); + ImGui::SetNextWindowPos(WINDOW_POS, ImGuiCond_Always); + ImGui::SetNextWindowSize(WINDOW_SIZE, ImGuiCond_Always); + ImGui::Begin("##cloud_save", &gui.vita_area.online_storage, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoSavedSettings); + + const auto &draw_list = ImGui::GetWindowDrawList(); + draw_list->AddRectFilled(ImVec2(VIEWPORT_POS.x, VIEWPORT_POS.y + INDICATOR_SIZE.y), ImVec2(VIEWPORT_POS.x + VIEWPORT_SIZE.x, VIEWPORT_POS.y + VIEWPORT_SIZE.y + INDICATOR_SIZE.y), IM_COL32(0, 55, 145, 255)); + + const ImVec2 CLOSE_BUTTON_SIZE(46.f * SCALE.x, 46.f * SCALE.y); + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 5.f * SCALE.x); + + ImGui::SetCursorPos(ImVec2(10.f * SCALE.x, 10.f * SCALE.y)); + if (ImGui::Button("X", CLOSE_BUTTON_SIZE)) { + gui.vita_area.online_storage = false; + if (gui.live_area_app_current_open >= 0) + gui.vita_area.live_area_screen = true; + else + gui.vita_area.home_screen = true; + } + ImGui::PopStyleVar(); + + ImGui::SetCursorPosX((WINDOW_SIZE.x - CLOSE_BUTTON_SIZE.x) / 2.f); + ImGui::SetWindowFontScale(1.85f * RES_SCALE.y); + const std::string window_title = lang["online_storage"]; + const auto window_title_width = ImGui::CalcTextSize(window_title.c_str()).x; + + ImGui::SetCursorPos(ImVec2((WINDOW_SIZE.x - window_title_width) / 2.f, ((64.f * SCALE.y) - ImGui::GetFontSize()) / 2.f)); + ImGui::Text("%s", window_title.c_str()); + const auto SEPARATOR_CLOUD_POS_Y = 64.f * SCALE.y; + const auto LOCATION_SECTION_SIZE_Y = 92.f * SCALE.y; + ImGui::SetCursorPos(ImVec2(0.f, SEPARATOR_CLOUD_POS_Y)); + ImGui::Separator(); + ImGui::SetCursorPos(ImVec2(54.f * SCALE.x, SEPARATOR_CLOUD_POS_Y + ((LOCATION_SECTION_SIZE_Y - ImGui::GetFontSize()) / 2.f))); + + ImGui::Text("Cloud"); + + const auto has_cloud_save = storage_state.savedata_info.contains(SAVE_DATA_TYPE_CLOUD); + const auto has_local_save = storage_state.savedata_info.contains(SAVE_DATA_TYPE_LOCAL); + + const auto begin_text_pos_x = 182.f * SCALE.x; + + ImGui::SetWindowFontScale(1.68f * RES_SCALE.y); + const auto app_title_height_size = ImGui::CalcTextSize(app_title.c_str()).y; + if (has_cloud_save) { + ImGui::SetCursorPos(ImVec2(begin_text_pos_x, SEPARATOR_CLOUD_POS_Y + (LOCATION_SECTION_SIZE_Y / 2.f) - app_title_height_size)); + ImGui::Text("%s", app_title.c_str()); + const auto &cloud_info = storage_state.savedata_info[SAVE_DATA_TYPE_CLOUD]; + ImGui::SetCursorPos(ImVec2(begin_text_pos_x, SEPARATOR_CLOUD_POS_Y + (LOCATION_SECTION_SIZE_Y / 2.f) + (10.f * SCALE.y))); + tm updated_at_tm = {}; + SAFE_LOCALTIME(&cloud_info.last_updated, &updated_at_tm); + auto UPDATED_AT = get_date_time(gui, emuenv, updated_at_tm); + ImGui::SetWindowFontScale(1.12f * RES_SCALE.y); + ImGui::TextColored(GUI_COLOR_TEXT, "%s", fmt::format("{} {} {} {}", UPDATED_AT[DateTime::DATE_MINI], UPDATED_AT[DateTime::CLOCK], is_12_hour_format ? UPDATED_AT[DateTime::DAY_MOMENT] : "", get_unit_size(cloud_info.total_size)).c_str()); + } else { + ImGui::SetCursorPos(ImVec2(begin_text_pos_x, SEPARATOR_CLOUD_POS_Y + ((LOCATION_SECTION_SIZE_Y - ImGui::GetFontSize()) / 2.f))); + ImGui::Text("-"); + } + + const auto SEPARATOR_BUTTON_POS_Y = SEPARATOR_CLOUD_POS_Y + LOCATION_SECTION_SIZE_Y; + + ImGui::SetCursorPos(ImVec2(0.f, SEPARATOR_BUTTON_POS_Y)); + ImGui::Separator(); + + const auto BUTTONS_SECTION_SIZE_Y = 184.f * SCALE.y; + const auto BUTTONS_SEPARATION = 40.f * SCALE.x; + + ImGui::SetWindowFontScale(1.06f * RES_SCALE.y); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(1, 1, 1, 0.08f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(1, 1, 1, 0.12f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(1, 1, 1, 0.18f)); + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 15.f * SCALE.x); + const ImVec2 CLOUD_BUTTON_SIZE(284.f * SCALE.x, 116.f * SCALE.y); + const auto BUTTONS_POS_Y = SEPARATOR_BUTTON_POS_Y + ((BUTTONS_SECTION_SIZE_Y - CLOUD_BUTTON_SIZE.y) / 2.f); + ImGui::SetCursorPos(ImVec2((WINDOW_SIZE.x / 2.f) - CLOUD_BUTTON_SIZE.x - (BUTTONS_SEPARATION / 2.f), BUTTONS_POS_Y)); + ImGui::BeginDisabled(!has_local_save); + if (ImGui::Button(lang["upload"].c_str(), CLOUD_BUTTON_SIZE)) { + if (has_cloud_save) { + storage_state.online_storage_state = ONLINE_STORAGE_UPLOAD; + } else { + storage_state.online_storage_state = ONLINE_STORAGE_COPYING; + v3kn::upload_savedata(gui, emuenv, app_titleid); + } + } + ImGui::EndDisabled(); + ImGui::SetCursorPos(ImVec2((WINDOW_SIZE.x / 2.f) + (BUTTONS_SEPARATION / 2.f), BUTTONS_POS_Y)); + ImGui::BeginDisabled(!has_cloud_save); + if (ImGui::Button(lang["download"].c_str(), CLOUD_BUTTON_SIZE)) { + if (has_local_save) { + storage_state.online_storage_state = ONLINE_STORAGE_DOWNLOAD; + } else { + storage_state.online_storage_state = ONLINE_STORAGE_COPYING; + v3kn::download_savedata(gui, emuenv, app_titleid); + } + } + ImGui::EndDisabled(); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(3); + + static const auto get_timestamp_ms = []() { + return duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); + }; + + if (storage_state.online_storage_state != ONLINE_STORAGE_SELECT) { + const ImVec2 OVERLAY_SIZE(VIEWPORT_SIZE.x, VIEWPORT_SIZE.y - INDICATOR_SIZE.y); + const ImVec2 OVERLAY_POS(VIEWPORT_POS.x, VIEWPORT_POS.y + INDICATOR_SIZE.y); + ImGui::SetNextWindowPos(OVERLAY_POS, ImGuiCond_Always); + ImGui::SetNextWindowSize(OVERLAY_SIZE, ImGuiCond_Always); + ImGui::Begin("##cloud_save_overlay", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoSavedSettings); + const auto &window_draw_list = ImGui::GetWindowDrawList(); + + switch (storage_state.online_storage_state) { + case ONLINE_STORAGE_UPLOAD: + case ONLINE_STORAGE_DOWNLOAD: { + const ImVec2 POPUP_SIZE(868.f * SCALE.x, 478.f * SCALE.y); + const ImVec2 POPUP_POS(VIEWPORT_POS.x + ((VIEWPORT_SIZE.x - POPUP_SIZE.x) / 2.f), VIEWPORT_POS.y + ((VIEWPORT_SIZE.y - POPUP_SIZE.y) / 2.f)); + ImGui::SetNextWindowPos(POPUP_POS, ImGuiCond_Always); + ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, 15.f * SCALE.x); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.f, 0.f)); + ImGui::BeginChild("##confirm_popup", POPUP_SIZE, ImGuiChildFlags_Borders, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoSavedSettings); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0.f, 0.f)); + + const ImVec2 ICON_SIZE(76.f * SCALE.x, 76.f * SCALE.y); + const ImVec2 ICON_POS(50.f * SCALE.x, 26.f * SCALE.y); + if (app_icon) { + const ImVec2 ICON_POS_MIN(POPUP_POS.x + ICON_POS.x, POPUP_POS.y + ICON_POS.y); + const ImVec2 ICON_POS_MAX(ICON_POS_MIN.x + ICON_SIZE.x, ICON_POS_MIN.y + ICON_SIZE.y); + window_draw_list->AddImageRounded(app_icon, ICON_POS_MIN, ICON_POS_MAX, ImVec2(0, 0), ImVec2(1, 1), IM_COL32_WHITE, ICON_SIZE.x); + } + ImGui::SetWindowFontScale(1.52f * RES_SCALE.y); + ImGui::SetCursorPos(ImVec2(ICON_POS.x + ICON_SIZE.x + (22.f * SCALE.x), ICON_POS.y + ((ICON_SIZE.y - app_title_height_size) / 2.f))); + ImGui::Text("%s", app_title.c_str()); + + const bool is_upload = (storage_state.online_storage_state == ONLINE_STORAGE_UPLOAD); + + const auto &src_save = is_upload ? storage_state.savedata_info[SAVE_DATA_TYPE_LOCAL] : storage_state.savedata_info[SAVE_DATA_TYPE_CLOUD]; + const auto &dst_save = is_upload ? storage_state.savedata_info[SAVE_DATA_TYPE_CLOUD] : storage_state.savedata_info[SAVE_DATA_TYPE_LOCAL]; + + const auto ¤t_ps_system = emuenv.cfg.pstv_mode ? lang["ps_tv_system"] : lang["ps_vita_system"]; + const auto &src_title = is_upload ? current_ps_system : lang["online_storage"]; + const auto &dst_title = is_upload ? lang["online_storage"] : current_ps_system; + + const auto draw_save_info = [&](const ImVec2 &begin_pos, const std::string &title, const SaveDataInfo &save_info) { + ImGui::SetCursorPos(begin_pos); + ImGui::SetWindowFontScale(1.1f * RES_SCALE.y); + ImGui::Text("%s", title.c_str()); + ImGui::SetCursorPos(ImVec2(begin_pos.x, ImGui::GetCursorPosY() + (15.f * SCALE.y))); + tm updated_at_tm = {}; + SAFE_LOCALTIME(&save_info.last_updated, &updated_at_tm); + auto UPDATED_AT = get_date_time(gui, emuenv, updated_at_tm); + ImGui::TextColored(GUI_COLOR_TEXT, "%s", fmt::format("{} {} {}", UPDATED_AT[DateTime::DATE_MINI], UPDATED_AT[DateTime::CLOCK], is_12_hour_format ? UPDATED_AT[DateTime::DAY_MOMENT] : "").c_str()); + ImGui::SetCursorPos(ImVec2(begin_pos.x, ImGui::GetCursorPosY() + (5.f * SCALE.y))); + ImGui::TextColored(GUI_COLOR_TEXT, "%s", get_unit_size(save_info.total_size).c_str()); + }; + + const float INFO_START_Y = ICON_POS.y + ICON_SIZE.y + (22.f * SCALE.y); + + // Draw source save info + const auto SRC_POS_X = 50.f * SCALE.x; + draw_save_info(ImVec2(SRC_POS_X, INFO_START_Y), src_title, src_save); + + // Draw arrow + const auto ARROW_COLOR = IM_COL32(161, 162, 165, 255); + const auto ARROW_BAR_SIZE = ImVec2(42.f * SCALE.x, 8.f * SCALE.y); + const auto ARROW_HEAD_SIZE = ImVec2(12.f * SCALE.x, 18.f * SCALE.y); + const ImVec2 ARROW_BAR_POS_MIN(POPUP_POS.x + (POPUP_SIZE.x / 2.f) - (5.f * SCALE.x) - ARROW_BAR_SIZE.x - ARROW_HEAD_SIZE.x, POPUP_POS.y + (132.f * SCALE.y)); + const ImVec2 ARROW_BAR_POS_MAX(ARROW_BAR_POS_MIN.x + ARROW_BAR_SIZE.x, ARROW_BAR_POS_MIN.y + ARROW_BAR_SIZE.y); + window_draw_list->AddRectFilled(ARROW_BAR_POS_MIN, ARROW_BAR_POS_MAX, ARROW_COLOR); + const ImVec2 ARROW_HEAD_POS1(ARROW_BAR_POS_MAX.x, ARROW_BAR_POS_MIN.y - ((ARROW_HEAD_SIZE.y - ARROW_BAR_SIZE.y) / 2.f)); + const ImVec2 ARROW_HEAD_POS2(ARROW_BAR_POS_MAX.x + ARROW_HEAD_SIZE.x, ARROW_BAR_POS_MIN.y + (ARROW_BAR_SIZE.y / 2.f)); + const ImVec2 ARROW_HEAD_POS3(ARROW_BAR_POS_MAX.x, ARROW_BAR_POS_MAX.y + ((ARROW_HEAD_SIZE.y - ARROW_BAR_SIZE.y) / 2.f)); + window_draw_list->AddTriangleFilled(ARROW_HEAD_POS1, ARROW_HEAD_POS2, ARROW_HEAD_POS3, ARROW_COLOR); + + // Draw destination save info + const auto DST_POS_X = (POPUP_SIZE.x / 2.f) + 42.f * SCALE.x; + draw_save_info(ImVec2(DST_POS_X, INFO_START_Y), dst_title, dst_save); + + ImGui::SetCursorPosY(215.f * SCALE.y); + ImGui::Separator(); + ImGui::SetWindowFontScale(1.38f * RES_SCALE.y); + ImGui::SetCursorPos(ImVec2(60.f * SCALE.x, ImGui::GetCursorPosY() + (14.f * SCALE.y))); + ImGui::Text("%s", lang["overwrite_saved_data"].c_str()); + const auto CONFIRM_BUTTON_SIZE = ImVec2(310.f * SCALE.x, 46.f * SCALE.y); + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 15.f * SCALE.x); + const auto BUTTON_SPACING = 40.f * SCALE.x; + ImGui::SetCursorPos(ImVec2((POPUP_SIZE.x / 2.f) - CONFIRM_BUTTON_SIZE.x - (BUTTON_SPACING / 2.f), POPUP_SIZE.y - CONFIRM_BUTTON_SIZE.y - (24.f * SCALE.y))); + if (ImGui::Button(common_lang["no"].c_str(), CONFIRM_BUTTON_SIZE)) { + storage_state.online_storage_state = ONLINE_STORAGE_SELECT; + } + ImGui::SetCursorPos(ImVec2((POPUP_SIZE.x / 2.f) + (BUTTON_SPACING / 2.f), POPUP_SIZE.y - CONFIRM_BUTTON_SIZE.y - (24.f * SCALE.y))); + if (ImGui::Button(common_lang["yes"].c_str(), CONFIRM_BUTTON_SIZE)) { + if (storage_state.online_storage_state == ONLINE_STORAGE_UPLOAD) + v3kn::upload_savedata(gui, emuenv, app_titleid); + else + v3kn::download_savedata(gui, emuenv, app_titleid); + storage_state.online_storage_state = ONLINE_STORAGE_COPYING; + } + ImGui::PopStyleVar(2); + ImGui::EndChild(); + ImGui::PopStyleVar(2); + break; + } + case ONLINE_STORAGE_COPYING: { + const ImVec2 ICON_SIZE(64.f * SCALE.x, 64.f * SCALE.y); + const ImVec2 POPUP_SIZE(760 * SCALE.x, 436.f * SCALE.y); + ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, 15.f * SCALE.x); + const auto PUPUP_POS = ImVec2(VIEWPORT_POS.x + ((VIEWPORT_SIZE.x - POPUP_SIZE.x) / 2.f), VIEWPORT_POS.y + ((VIEWPORT_SIZE.y - POPUP_SIZE.y) / 2.f)); + ImGui::SetNextWindowPos(PUPUP_POS, ImGuiCond_Always); + ImGui::BeginChild("##copying_popup", POPUP_SIZE, ImGuiChildFlags_Borders, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoSavedSettings); + const auto ©_str = lang["copying"]; + ImGui::SetCursorPos(ImVec2((POPUP_SIZE.x - ImGui::CalcTextSize(copy_str.c_str()).x) / 2.f, 82.f * SCALE.y)); + ImGui::Text("%s", copy_str.c_str()); + const auto ICON_POS = ImVec2(100.f * SCALE.x, 132.f * SCALE.y); + if (app_icon) { + const ImVec2 ICON_POS_MIN(PUPUP_POS.x + ICON_POS.x, PUPUP_POS.y + ICON_POS.y); + const ImVec2 ICON_POS_MAX(ICON_POS_MIN.x + ICON_SIZE.x, ICON_POS_MIN.y + ICON_SIZE.y); + window_draw_list->AddImageRounded(app_icon, ICON_POS_MIN, ICON_POS_MAX, ImVec2(0, 0), ImVec2(1, 1), IM_COL32_WHITE, ICON_SIZE.x); + } + ImGui::SetCursorPos(ImVec2(ICON_POS.x + ICON_SIZE.x + (12.f * SCALE.x), ICON_POS.y + ((ICON_SIZE.y - ImGui::GetFontSize()) / 2.f))); + ImGui::Text("%s", app_title.c_str()); + const auto remaining_str = get_remaining_str(gui.lang, storage_state.remaining.load()); + ImGui::SetCursorPos(ImVec2(POPUP_SIZE.x - (100 * SCALE.x) - (ImGui::CalcTextSize(remaining_str.c_str()).x), 214.f * SCALE.y)); + ImGui::Text("%s", remaining_str.c_str()); + const float PROGRESS_BAR_WIDTH = 560.f * SCALE.x; + ImGui::SetCursorPos(ImVec2((ImGui::GetWindowWidth() / 2) - (PROGRESS_BAR_WIDTH / 2.f), 236.f * SCALE.y)); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, GUI_PROGRESS_BAR); + ImGui::ProgressBar(storage_state.progress.load(), ImVec2(PROGRESS_BAR_WIDTH, 14.f * SCALE.y), ""); + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + (16.f * SCALE.y)); + TextColoredCentered(GUI_COLOR_TEXT, std::to_string(static_cast(storage_state.progress.load() * 100.f)).append("%").c_str()); + ImGui::PopStyleColor(); + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 15.f * SCALE.x); + const ImVec2 CANCEL_BUTTON_SIZE(320.f * SCALE.x, 46.f * SCALE.y); + ImGui::SetCursorPos(ImVec2((POPUP_SIZE.x - CANCEL_BUTTON_SIZE.x) / 2.f, POPUP_SIZE.y - CANCEL_BUTTON_SIZE.y - (24.f * SCALE.y))); + ImGui::BeginDisabled(storage_state.progress.load() >= 1.f); + if (ImGui::Button(emuenv.common_dialog.lang.common["cancel"].c_str(), CANCEL_BUTTON_SIZE)) { + std::lock_guard lock(storage_state.progress_state.mutex); + storage_state.progress_state.canceled = true; + storage_state.online_storage_state = ONLINE_STORAGE_SELECT; + } + ImGui::EndDisabled(); + + // Hold the dialog at 100% for a short moment to ensure the user perceives the completion state + if (storage_state.progress.load() >= 1.f) { + const auto now = get_timestamp_ms(); + + if (storage_state.progress_done_timestamp == 0) + storage_state.progress_done_timestamp = now; + + if (now - storage_state.progress_done_timestamp >= 900) { + gui.vita_area.please_wait = true; + storage_state.please_wait_timestamp = now; + storage_state.online_storage_state = ONLINE_STORAGE_SELECT; + } + } + + ImGui::PopStyleVar(); + ImGui::EndChild(); + ImGui::PopStyleVar(); + break; + } + default: + break; + } + ImGui::End(); + } + + // Hide please wait after a short delay to improve UX + if (gui.vita_area.please_wait && !storage_state.please_wait_done.load()) { + const auto now = get_timestamp_ms(); + + if (now - storage_state.please_wait_timestamp >= 900) { + storage_state.please_wait_done.store(true); + { + std::lock_guard lck(storage_state.mutex_progress); + storage_state.cv_progress.notify_all(); + } + } + } + + const auto END_SEPARATOR_POS_Y = SEPARATOR_BUTTON_POS_Y + BUTTONS_SECTION_SIZE_Y; + ImGui::SetCursorPos(ImVec2(0.f, END_SEPARATOR_POS_Y)); + ImGui::Separator(); + + ImGui::SetWindowFontScale(1.85f * RES_SCALE.y); + ImGui::SetCursorPos(ImVec2(54.f * SCALE.x, END_SEPARATOR_POS_Y + ((LOCATION_SECTION_SIZE_Y - ImGui::GetFontSize()) / 2.f))); + ImGui::Text("Local"); + + ImGui::SetWindowFontScale(1.68f * RES_SCALE.y); + if (has_local_save) { + const auto &local_info = storage_state.savedata_info[SAVE_DATA_TYPE_LOCAL]; + ImGui::SetCursorPos(ImVec2(begin_text_pos_x, END_SEPARATOR_POS_Y + (LOCATION_SECTION_SIZE_Y / 2.f) - app_title_height_size)); + ImGui::Text("%s", app_title.c_str()); + ImGui::SetCursorPos(ImVec2(begin_text_pos_x, END_SEPARATOR_POS_Y + (LOCATION_SECTION_SIZE_Y / 2.f) + (10.f * SCALE.y))); + tm updated_at_tm = {}; + SAFE_LOCALTIME(&local_info.last_updated, &updated_at_tm); + auto UPDATED_AT = get_date_time(gui, emuenv, updated_at_tm); + ImGui::SetWindowFontScale(1.12f * RES_SCALE.y); + ImGui::TextColored(GUI_COLOR_TEXT, "%s", fmt::format("{} {} {} {}", UPDATED_AT[DateTime::DATE_MINI], UPDATED_AT[DateTime::CLOCK], is_12_hour_format ? UPDATED_AT[DateTime::DAY_MOMENT] : "", get_unit_size(local_info.total_size)).c_str()); + } else { + ImGui::SetCursorPos(ImVec2(begin_text_pos_x, END_SEPARATOR_POS_Y + (LOCATION_SECTION_SIZE_Y - ImGui::GetFontSize()) / 2.f)); + ImGui::Text("-"); + } + + ImGui::End(); + ImGui::PopStyleVar(2); +} + +} // namespace gui diff --git a/vita3k/gui/src/private.h b/vita3k/gui/src/private.h index 72725d8a2..d8432dc12 100644 --- a/vita3k/gui/src/private.h +++ b/vita3k/gui/src/private.h @@ -58,6 +58,7 @@ void draw_event_flags_dialog(GuiState &gui, EmuEnvState &emuenv); void draw_allocations_dialog(GuiState &gui, EmuEnvState &emuenv); void draw_disassembly_dialog(GuiState &gui, EmuEnvState &emuenv); void draw_settings_dialog(GuiState &gui, EmuEnvState &emuenv); +void draw_v3kn_dialog(GuiState &gui, EmuEnvState &emuenv); void draw_overlay_dialog(GuiState &gui, EmuEnvState &emuenv); void draw_controls_dialog(GuiState &gui, EmuEnvState &emuenv); void draw_controllers_dialog(GuiState &gui, EmuEnvState &emuenv); @@ -71,6 +72,7 @@ void draw_home_screen(GuiState &gui, EmuEnvState &emuenv); void draw_information_bar(GuiState &gui, EmuEnvState &emuenv); void draw_live_area_screen(GuiState &gui, EmuEnvState &emuenv); void draw_manual(GuiState &gui, EmuEnvState &emuenv); +void draw_online_storage(GuiState &gui, EmuEnvState &emuenv); void draw_settings(GuiState &gui, EmuEnvState &emuenv); void draw_start_screen(GuiState &gui, EmuEnvState &emuenv); void draw_trophy_collection(GuiState &gui, EmuEnvState &emuenv); diff --git a/vita3k/gui/src/user_management.cpp b/vita3k/gui/src/user_management.cpp index a910d3602..d1875763c 100644 --- a/vita3k/gui/src/user_management.cpp +++ b/vita3k/gui/src/user_management.cpp @@ -31,6 +31,8 @@ #include #include +#include + #include #include @@ -38,22 +40,10 @@ namespace gui { -enum AvatarSize { - SMALL = 34, - MEDIUM = 130, - LARGE = 160 -}; - static ImVec2 get_avatar_size(AvatarSize size, const ImVec2 scale = { 1, 1 }) { return ImVec2(static_cast(size) * scale.x, static_cast(size) * scale.y); } -struct AvatarInfo { - ImVec2 pos; - ImVec2 size; -}; - -static std::map> users_avatar_infos; static bool init_avatar(GuiState &gui, EmuEnvState &emuenv, const std::string &user_id, const std::string &avatar_path) { const auto avatar_path_path = avatar_path == "default" ? emuenv.static_assets_path / "data/image/icon.png" : fs_utils::utf8_to_path(avatar_path); @@ -78,11 +68,11 @@ static bool init_avatar(GuiState &gui, EmuEnvState &emuenv, const std::string &u // Calculate avatar size and position based of aspect ratio // Resize for all size of avatar - constexpr std::array sizes = { SMALL, MEDIUM, LARGE }; + constexpr std::array sizes = { AvatarSize::SMALL, AvatarSize::MEDIUM, AvatarSize::LARGE, AvatarSize::V3KN }; for (const auto size : sizes) { const auto avatar_size = get_avatar_size(size); const auto ratio = std::min(avatar_size.x / static_cast(width), avatar_size.y / static_cast(height)); - auto &avatar = users_avatar_infos[user_id][size]; + auto &avatar = gui.users_avatar_infos[user_id][size]; avatar.size = ImVec2(width * ratio, height * ratio); avatar.pos = ImVec2((avatar_size.x / 2.f) - (avatar.size.x / 2.f), (avatar_size.y / 2.f) - (avatar.size.y / 2.f)); } @@ -240,6 +230,11 @@ void init_user(GuiState &gui, EmuEnvState &emuenv, const std::string &user_id) { init_theme(gui, emuenv, gui.users[user_id].theme_id); init_notice_info(gui, emuenv); init_last_time_apps(gui, emuenv); + + // Init V3KN user info asynchronously to avoid blocking the GUI + std::thread([&gui, &emuenv]() { + v3kn::init_v3kn_user_info(gui, emuenv); + }).detach(); } void open_user(GuiState &gui, EmuEnvState &emuenv) { @@ -316,7 +311,7 @@ static void create_and_save_user(GuiState &gui, EmuEnvState &emuenv) { // Move avatar texture + infos gui.users_avatar[user_id_selected] = std::move(gui.users_avatar["temp"]); - users_avatar_infos[user_id_selected] = users_avatar_infos["temp"]; + gui.users_avatar_infos[user_id_selected] = gui.users_avatar_infos["temp"]; current_user_id_selected = string_utils::stoi_def(user_id_selected, 0, "selected user id"); save_user(gui, emuenv, user_id_selected); @@ -517,9 +512,9 @@ void draw_user_management(GuiState &gui, EmuEnvState &emuenv) { const auto user_path{ emuenv.pref_path / "ux0/user" }; - const auto SMALL_AVATAR_SIZE = get_avatar_size(SMALL, SCALE); - const auto MED_AVATAR_SIZE = get_avatar_size(MEDIUM, SCALE); - const auto LARGE_AVATAR_SIZE = get_avatar_size(LARGE, SCALE); + const auto SMALL_AVATAR_SIZE = get_avatar_size(AvatarSize::SMALL, SCALE); + const auto MED_AVATAR_SIZE = get_avatar_size(AvatarSize::MEDIUM, SCALE); + const auto LARGE_AVATAR_SIZE = get_avatar_size(AvatarSize::LARGE, SCALE); const auto USER_NAME_BG_SIZE = ImVec2(MED_AVATAR_SIZE.x, 60.f * SCALE.y); ImGui::SetWindowFontScale(1.4f * RES_SCALE.x); @@ -549,7 +544,7 @@ void draw_user_management(GuiState &gui, EmuEnvState &emuenv) { const auto BG_AVATAR_SIZE = get_avatar_size(size, SCALE); const auto &DRAW_LIST = ImGui::GetWindowDrawList(); DRAW_LIST->AddRectFilled(SCREEN_POS, ImVec2(SCREEN_POS.x + BG_AVATAR_SIZE.x, SCREEN_POS.y + BG_AVATAR_SIZE.y), IM_COL32(19.f, 69.f, 167.f, 150.f)); - if (size == MEDIUM) { + if (size == AvatarSize::MEDIUM) { ImGui::SetCursorPos(ImVec2(origin_pos.x, origin_pos.y + MED_AVATAR_SIZE.y)); DRAW_LIST->AddRectFilled(SCREEN_POS, ImVec2(SCREEN_POS.x + BG_AVATAR_SIZE.x, SCREEN_POS.y + USER_NAME_BG_SIZE.y), IM_COL32(19.f, 69.f, 167.f, 255.f)); } @@ -559,7 +554,7 @@ void draw_user_management(GuiState &gui, EmuEnvState &emuenv) { const auto draw_avatar = [&](const std::string &user_id, const AvatarSize size, const ImVec2 origin_pos) { draw_user_bg(size, origin_pos); if (gui.users_avatar.contains(user_id)) { - const auto &user_avatar_infos = users_avatar_infos[user_id][size]; + const auto &user_avatar_infos = gui.users_avatar_infos[user_id][size]; ImVec2 AVATAR_POS = ImVec2(origin_pos.x + (user_avatar_infos.pos.x * SCALE.x), origin_pos.y + (user_avatar_infos.pos.y * SCALE.y)); ImVec2 AVATAR_SIZE = ImVec2(user_avatar_infos.size.x * SCALE.x, user_avatar_infos.size.y * SCALE.y); ImGui::SetCursorPos(AVATAR_POS); @@ -637,7 +632,7 @@ void draw_user_management(GuiState &gui, EmuEnvState &emuenv) { // Users list title = lang["select_user"]; ImGui::SetWindowFontScale(1.6f); - draw_user_bg(MEDIUM, CREATE_USER_POS); + draw_user_bg(AvatarSize::MEDIUM, CREATE_USER_POS); ImGui::SetCursorPos(CREATE_USER_POS); if (ImGui::InvisibleButton("+", SELECTABLE_USER_SIZE)) { if (menu_selected == CREATE) @@ -685,7 +680,7 @@ void draw_user_management(GuiState &gui, EmuEnvState &emuenv) { if (is_edit_user_selected) draw_frame(EDIT_USER_POS_SEL, EDIT_USER_SIZE); trigger_scroll_to_item(is_current_user_id_selected); - draw_avatar(user.first, MEDIUM, USER_POS); + draw_avatar(user.first, AvatarSize::MEDIUM, USER_POS); ImGui::SetCursorPos(USER_POS); ImGui::PushStyleColor(ImGuiCol_Text, GUI_COLOR_TEXT_TITLE); if (ImGui::InvisibleButton("##avatar", SELECTABLE_USER_SIZE)) { @@ -712,7 +707,7 @@ void draw_user_management(GuiState &gui, EmuEnvState &emuenv) { ImGui::SetCursorPos(ImVec2(USER_POS.x + SPACE_AVATAR, USER_POS.y)); } if (!gui.users.empty()) { - draw_user_bg(MEDIUM, DELETE_USER_POS); + draw_user_bg(AvatarSize::MEDIUM, DELETE_USER_POS); ImGui::SetCursorPos(DELETE_USER_POS); ImGui::SetWindowFontScale(1.6f); if (ImGui::InvisibleButton("-", SELECTABLE_USER_SIZE)) { @@ -745,7 +740,7 @@ void draw_user_management(GuiState &gui, EmuEnvState &emuenv) { temp.avatar = "default"; init_avatar(gui, emuenv, "temp", "default"); } - draw_avatar("temp", LARGE, AVATAR_POS); + draw_avatar("temp", AvatarSize::LARGE, AVATAR_POS); const ImVec2 CHANGE_AVATAR_BTN_SIZE(LARGE_AVATAR_SIZE.x, 36.f * SCALE.y); ImGui::SetCursorPos(ImVec2(AVATAR_POS.x + (LARGE_AVATAR_SIZE.x / 2.f) - (CHANGE_AVATAR_BTN_SIZE.x / 2.f), AVATAR_POS.y + LARGE_AVATAR_SIZE.y + (5.f * SCALE.y))); if (ImGui::Button(lang["choose_avatar"].c_str(), CHANGE_AVATAR_BTN_SIZE)) { @@ -788,7 +783,7 @@ void draw_user_management(GuiState &gui, EmuEnvState &emuenv) { ImGui::SetCursorPosY(44.f * SCALE.y); TextColoredCentered(GUI_COLOR_TEXT, lang["user_created"].c_str()); const auto AVATAR_CONFIRM_POS = ImVec2((SIZE_USER.x / 2) - (MED_AVATAR_SIZE.x / 2.f), 96.f * SCALE.y); - draw_avatar(user_id_selected, MEDIUM, AVATAR_CONFIRM_POS); + draw_avatar(user_id_selected, AvatarSize::MEDIUM, AVATAR_CONFIRM_POS); ImGui::SetWindowFontScale(0.7f); ImGui::SetCursorPos(ImVec2(AVATAR_CONFIRM_POS.x + USER_NAME_PADDING, AVATAR_CONFIRM_POS.y + MED_AVATAR_SIZE.y + (5.f * SCALE.y))); ImGui::PushTextWrapPos(AVATAR_CONFIRM_POS.x + MED_AVATAR_SIZE.x - USER_NAME_PADDING); @@ -822,7 +817,7 @@ void draw_user_management(GuiState &gui, EmuEnvState &emuenv) { ImGui::Separator(); for (const auto &user : gui.users) { users_list_available.push_back(string_utils::stoi_def(user.first, 0, "gui user id")); - draw_avatar(user.first, SMALL, ImVec2(ImGui::GetCursorPosX(), ImGui::GetCursorPosY() + (SELECT_SIZE.y / 2.f) - (SMALL_AVATAR_SIZE.y / 2.f))); + draw_avatar(user.first, AvatarSize::SMALL, ImVec2(ImGui::GetCursorPosX(), ImGui::GetCursorPosY() + (SELECT_SIZE.y / 2.f) - (SMALL_AVATAR_SIZE.y / 2.f))); ImGui::NextColumn(); ImGui::PushStyleVar(ImGuiStyleVar_SelectableTextAlign, ImVec2(0.0f, 0.5f)); if (ImGui::Selectable(user.second.name.c_str(), gui.is_nav_button && (current_user_id_selected == string_utils::stoi_def(user.first, 0, "gui user id")), ImGuiSelectableFlags_SpanAllColumns, SELECT_SIZE)) @@ -913,7 +908,7 @@ void draw_user_management(GuiState &gui, EmuEnvState &emuenv) { config::serialize_config(emuenv.cfg, emuenv.cfg.config_path); } if (!gui.users.empty() && gui.users.contains(emuenv.cfg.user_id)) { - draw_avatar(emuenv.cfg.user_id, SMALL, ImVec2(WINDOW_SIZE.x - (220.f * SCALE.x), WINDOW_SIZE.y - (POS_SEPARATOR / 2.f) - (SMALL_AVATAR_SIZE.y / 2.f))); + draw_avatar(emuenv.cfg.user_id, AvatarSize::SMALL, ImVec2(WINDOW_SIZE.x - (220.f * SCALE.x), WINDOW_SIZE.y - (POS_SEPARATOR / 2.f) - (SMALL_AVATAR_SIZE.y / 2.f))); ImGui::SetCursorPos(ImVec2(WINDOW_SIZE.x - 180.f * SCALE.x, WINDOW_SIZE.y - (POS_SEPARATOR / 2.f) - (ImGui::CalcTextSize(gui.users[emuenv.cfg.user_id].name.c_str()).y / 2.f))); ImGui::TextColored(GUI_COLOR_TEXT, "%s", gui.users[emuenv.cfg.user_id].name.c_str()); } diff --git a/vita3k/gui/src/v3kn_dialog.cpp b/vita3k/gui/src/v3kn_dialog.cpp new file mode 100644 index 000000000..b718e8670 --- /dev/null +++ b/vita3k/gui/src/v3kn_dialog.cpp @@ -0,0 +1,1295 @@ +// Vita3K emulator project +// Copyright (C) 2026 Vita3K team +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, write to the Free Software Foundation, Inc., +// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +#include "private.h" + +#include + +#include + +#include +#include + +#include + +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include + +#define STB_IMAGE_RESIZE_IMPLEMENTATION +#include + +#undef ERROR + +struct PanelUploadState { + std::string *result_message; + bool *upload_success; + bool *is_uploading; +}; + +void upload_v3kn_panel(GuiState *gui, EmuEnvState *emuenv, UserInfo *user_info, std::vector panel_source, + int source_w, int source_h, float crop_scroll_x, float crop_scroll_y, float crop_scale, float crop_zoom, PanelUploadState state) { + constexpr int panel_width = 400; + constexpr int panel_height = 80; + + if (panel_source.empty() || source_w <= 0 || source_h <= 0) { + *state.result_message = "Failed to load image for processing."; + *state.is_uploading = false; + return; + } + + constexpr float panel_ratio = static_cast(panel_width) / static_cast(panel_height); + const float max_crop_w = static_cast(source_w) / std::max(crop_zoom, 1.f); + const float max_crop_h = static_cast(source_h) / std::max(crop_zoom, 1.f); + int crop_w = static_cast(std::round(max_crop_w)); + int crop_h = static_cast(std::round(static_cast(crop_w) / panel_ratio)); + if (crop_h > static_cast(std::round(max_crop_h))) { + crop_h = static_cast(std::round(max_crop_h)); + crop_w = static_cast(std::round(static_cast(crop_h) * panel_ratio)); + } + + int crop_x = 0; + int crop_y = 0; + if (crop_w < source_w) { + crop_x = static_cast(std::round(crop_scroll_x / crop_scale)); + crop_x = std::clamp(crop_x, 0, std::max(0, source_w - crop_w)); + } + if (crop_h < source_h) { + crop_y = static_cast(std::round(crop_scroll_y / crop_scale)); + crop_y = std::clamp(crop_y, 0, std::max(0, source_h - crop_h)); + } + + std::vector cropped(crop_w * crop_h * 4); + for (int y = 0; y < crop_h; ++y) { + const auto src_offset = ((crop_y + y) * source_w + crop_x) * 4; + const auto dst_offset = y * crop_w * 4; + std::copy_n(panel_source.data() + src_offset, crop_w * 4, cropped.data() + dst_offset); + } + + std::vector resized(panel_width * panel_height * 4); + stbir_resize_uint8_linear(cropped.data(), crop_w, crop_h, 0, resized.data(), panel_width, panel_height, 0, STBIR_RGBA); + + std::vector png_data; + stbi_write_png_to_func( + [](void *context, void *data, int size) { + auto *vec = static_cast *>(context); + const auto ptr = static_cast(data); + vec->insert(vec->end(), ptr, ptr + size); + }, + &png_data, panel_width, panel_height, 4, resized.data(), panel_width * 4); + + const auto res = v3kn::v3kn_panel_upload(*user_info, png_data); + v3kn::handle_v3kn_status(*emuenv, res); + + if (res.body.starts_with("OK")) { + int w = 0; + int h = 0; + auto *data = stbi_load_from_memory(reinterpret_cast(png_data.data()), static_cast(png_data.size()), &w, &h, nullptr, 4); + if (data) + gui->v3kn_panel = ImGui_Texture(gui->imgui_state.get(), data, w, h); + *state.upload_success = true; + *state.result_message = "Panel uploaded successfully."; + } else { + *state.result_message = v3kn::get_v3kn_error_message(*emuenv, res); + } + *state.is_uploading = false; +} + +enum class V3knPendingAction { + NONE, + CREATE, + LOGIN, + REFRESH_QUOTA, + DELETE_ACCOUNT, + CHANGE_ONLINE_ID, + CHANGE_PASSWORD, + CHANGE_ABOUT_ME, +}; + +struct V3knPendingResult { + V3knPendingAction action = V3knPendingAction::NONE; + net_utils::WebResponse response; + std::string online_id; + std::string password; + std::string token; + std::string message; + uint64_t quota_used = 0; + uint64_t quota_total = 0; + bool has_quota = false; +}; + +namespace gui { + +void draw_v3kn_dialog(GuiState &gui, EmuEnvState &emuenv) { + static std::mutex result_mutex; + static std::optional pending_result; + static std::atomic_bool is_result_ready{ false }; + static std::atomic_bool is_waiting_result{ false }; + + enum class V3knResultState { + NONE, + PENDING_ACK, + CLOSE_PARENT, + }; + + static V3knResultState result_state = V3knResultState::NONE; + + const ImVec2 VIEWPORT_POS(emuenv.logical_viewport_pos.x, emuenv.logical_viewport_pos.y); + const ImVec2 VIEWPORT_SIZE(emuenv.logical_viewport_size.x, emuenv.logical_viewport_size.y); + const auto RES_SCALE = ImVec2(emuenv.gui_scale.x, emuenv.gui_scale.y); + const auto SCALE = ImVec2(RES_SCALE.x * emuenv.manual_dpi_scale, RES_SCALE.y * emuenv.manual_dpi_scale); + const float INDICATOR_SIZE(32.f * SCALE.y); + const ImVec2 WINDOW_SIZE(VIEWPORT_SIZE.x / 1.6f, 0.f); + const ImVec2 WINDOW_POS(VIEWPORT_POS.x + (VIEWPORT_SIZE.x - WINDOW_SIZE.x) / 2.f, VIEWPORT_POS.y + (VIEWPORT_SIZE.y - WINDOW_SIZE.y) / 2.f); + + ImGui::SetNextWindowPos(WINDOW_POS, ImGuiCond_Always, ImVec2(0.f, 0.5f)); + ImGui::SetNextWindowSize(WINDOW_SIZE, ImGuiCond_Always); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.f); + ImGui::Begin("##v3kn_dialog", &gui.configuration_menu.v3kn_dialog, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoMove); + + const ImVec2 BUTTON_SIZE(200.f * SCALE.x, 40.f * SCALE.y); + const ImVec2 SMALL_BUTTON_SIZE(100.f * SCALE.x, 40.f * SCALE.y); + + ImGui::SetWindowFontScale(RES_SCALE.y); + const ImVec2 POPUP_SIZE = ImGui::GetWindowSize(); + const ImVec2 POPUP_POS = ImGui::GetWindowPos(); + + const auto title = "V3KN Account Management"; + const auto FONT_SCALE = 0.81f; + const auto title_size = ImGui::CalcTextSize(title); + const auto BLOCK_TITLE_SIZE_HEIGHT = 40.f * SCALE.y; + const ImVec2 title_pos((POPUP_SIZE.x - title_size.x) / 2.f, (BLOCK_TITLE_SIZE_HEIGHT - (title_size.y * FONT_SCALE)) / 2.f); + ImGui::SetCursorPos(title_pos); + ImGui::TextColored(GUI_COLOR_TEXT_TITLE, "%s", title); + ImGui::SetCursorPosY(BLOCK_TITLE_SIZE_HEIGHT); + ImGui::Separator(); + + static std::string result_message; + static char username[16] = ""; + static char password[64] = ""; + static char confirm[64] = ""; + static char about_me[22] = ""; + + auto &common_lang = emuenv.common_dialog.lang.common; + auto &account_state = emuenv.v3kn.account_state; + auto &user_info = account_state.user_info; + + if (is_result_ready.exchange(false)) { + std::optional result; + { + std::lock_guard lock(result_mutex); + result = std::exchange(pending_result, std::nullopt); + } + + if (result) { + v3kn::handle_v3kn_status(emuenv, result->response); + result_state = V3knResultState::NONE; + + const auto rename_avatar_cache = [&](const std::string &old_online_id, const std::string &new_online_id) { + if (old_online_id.empty() || new_online_id.empty() || old_online_id == new_online_id) + return; + + const fs::path server_cache_dir = emuenv.cache_path / "v3kn" / user_info.host; + const fs::path old_avatar_cache_dir = server_cache_dir / old_online_id; + const fs::path new_avatar_cache_dir = server_cache_dir / new_online_id; + + if (!fs::exists(old_avatar_cache_dir)) + return; + + fs::create_directories(server_cache_dir); + fs::rename(old_avatar_cache_dir, new_avatar_cache_dir); + }; + + const auto complete_sign_in = [&]() { + user_info.online_id = result->online_id; + user_info.password = result->password; + user_info.token = result->token; + v3kn::save_v3kn_user_info(emuenv); + std::thread([&gui, &emuenv]() { + v3kn::init_v3kn_user_info(gui, emuenv); + }).detach(); + }; + + switch (result->action) { + case V3knPendingAction::CREATE: + case V3knPendingAction::LOGIN: + if (result->response.body.starts_with("OK:") && !result->token.empty()) { + complete_sign_in(); + result_state = V3knResultState::PENDING_ACK; + } + break; + case V3knPendingAction::REFRESH_QUOTA: + if (result->has_quota) { + user_info.quota_used = result->quota_used; + user_info.quota_total = result->quota_total; + } + break; + case V3knPendingAction::DELETE_ACCOUNT: + if (result->response.body.starts_with("OK:")) { + user_info = {}; + v3kn::save_v3kn_user_info(emuenv); + result_state = V3knResultState::PENDING_ACK; + std::thread([&gui, &emuenv]() { + v3kn::init_v3kn_user_info(gui, emuenv); + }).detach(); + } + break; + case V3knPendingAction::CHANGE_ONLINE_ID: + if (result->response.body.starts_with("OK:")) { + const std::string old_online_id = user_info.online_id; + user_info.online_id = result->online_id; + rename_avatar_cache(old_online_id, user_info.online_id); + v3kn::save_v3kn_user_info(emuenv); + result_state = V3knResultState::PENDING_ACK; + } + break; + case V3knPendingAction::CHANGE_PASSWORD: + if (result->response.body.starts_with("OK:") && !result->token.empty()) { + user_info.password = result->password; + user_info.token = result->token; + v3kn::save_v3kn_user_info(emuenv); + result_state = V3knResultState::PENDING_ACK; + } + break; + case V3knPendingAction::CHANGE_ABOUT_ME: + if (result->response.body.starts_with("OK:")) { + result_state = V3knResultState::PENDING_ACK; + } + break; + case V3knPendingAction::NONE: + break; + } + + result_message = result->message; + is_waiting_result = false; + } + } + + const std::string OK_STR = common_lang["ok"]; + const std::string CANCEL_STR = common_lang["cancel"]; + + const auto popup_flags = ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings; + + static const auto result_popup = [&]() { + const ImVec2 POPUP_SIZE(620.f * SCALE.x, 200.f * SCALE.y); + const ImVec2 POPUP_POS((VIEWPORT_SIZE.x - POPUP_SIZE.x) / 2.f, (VIEWPORT_SIZE.y - POPUP_SIZE.y) / 2.f); + ImGui::SetNextWindowSize(POPUP_SIZE, ImGuiCond_Always); + ImGui::SetNextWindowPos(POPUP_POS, ImGuiCond_Always); + if (ImGui::BeginPopupModal("Result", nullptr, popup_flags)) { + const auto popup_width_size = ImGui::GetWindowWidth(); + const auto text = result_message.empty() ? emuenv.common_dialog.lang.common["please_wait"] : result_message; + const auto text_size = ImGui::CalcTextSize(text.c_str()); + ImGui::SetCursorPosY(((POPUP_SIZE.y - text_size.y) / 2.f) - SMALL_BUTTON_SIZE.y); + TextColoredCentered(GUI_COLOR_TEXT, text.c_str(), 20.f * SCALE.x); + ImGui::SetCursorPos(ImVec2((popup_width_size - SMALL_BUTTON_SIZE.x) / 2.f, POPUP_SIZE.y - SMALL_BUTTON_SIZE.y - (16.f * SCALE.y))); + ImGui::BeginDisabled(is_waiting_result); + if (ImGui::Button("OK", SMALL_BUTTON_SIZE)) { + ImGui::CloseCurrentPopup(); + if (result_state == V3knResultState::PENDING_ACK) { + result_state = V3knResultState::CLOSE_PARENT; + username[0] = '\0'; + password[0] = '\0'; + confirm[0] = '\0'; + about_me[0] = '\0'; + } else { + result_state = V3knResultState::NONE; + result_message.clear(); + } + } + ImGui::EndDisabled(); + ImGui::EndPopup(); + } + }; + + const auto AVATAR_SIZE = ImVec2(static_cast(AvatarSize::V3KN) * SCALE.x, static_cast(AvatarSize::V3KN) * SCALE.y); + const auto BLOCK_INFO_SIZE_HEIGHT = AVATAR_SIZE.y + (16.f * SCALE.y); + const auto INFO_SIZE_HEIGHT = BLOCK_INFO_SIZE_HEIGHT / 4.f; + const auto BEGIN_INFO_POS_Y = BLOCK_TITLE_SIZE_HEIGHT + 1.f; + const auto FIRST_INFO_POS_Y = BEGIN_INFO_POS_Y + ((INFO_SIZE_HEIGHT - (ImGui::GetFontSize() * FONT_SCALE)) / 2.f); + + ImGui::SetCursorPosY(FIRST_INFO_POS_Y); + const auto selected_server = account_state.selected_server_index; + if (selected_server < V3KN_SERVER_LIST_NAME.size()) + ImGui::Text("Server: %s", V3KN_SERVER_LIST_NAME[selected_server]); + + const auto draw_list = ImGui::GetWindowDrawList(); + const auto STATUS_CIRCLE_RADIUS = ImGui::GetFontSize() / 2.f; + const auto circle_pos = ImVec2(POPUP_POS.x + ImGui::GetCursorPosX() + STATUS_CIRCLE_RADIUS, POPUP_POS.y + ImGui::GetCursorPosY() + (INFO_SIZE_HEIGHT / 3.f)); + ImU32 status_color; + std::string status_text; + + if (!user_info.token.empty()) { + if (v3kn::is_v3kn_logged_in()) { + status_color = IM_COL32(14, 138, 22, 255); + status_text = fmt::format("Signed In - ID: {}", user_info.online_id); + } else { + status_color = IM_COL32(224, 138, 30, 255); + status_text = fmt::format("Offline - ID: {}", user_info.online_id); + } + } else { + status_color = IM_COL32(255, 0, 0, 255); + status_text = "Not Signed In - ID: " + (user_info.online_id.empty() ? std::string("-") : user_info.online_id); + } + + draw_list->AddCircleFilled(circle_pos, STATUS_CIRCLE_RADIUS, status_color); + ImGui::SetCursorPos(ImVec2(ImGui::GetCursorPosX() + (STATUS_CIRCLE_RADIUS * 2.f) + (8.f * SCALE.x), FIRST_INFO_POS_Y + INFO_SIZE_HEIGHT)); + ImGui::Text("%s", status_text.c_str()); + + std::string created_at = "-"; + if (user_info.created_at > 0) { + tm created_tm = {}; + SAFE_LOCALTIME(&user_info.created_at, &created_tm); + auto CREATED_AT = get_date_time(gui, emuenv, created_tm); + created_at = fmt::format("{} {} {}", CREATED_AT[DateTime::DATE_MINI], CREATED_AT[DateTime::CLOCK], emuenv.cfg.sys_time_format == SCE_SYSTEM_PARAM_TIME_FORMAT_12HOUR ? CREATED_AT[DateTime::DAY_MOMENT] : ""); + } + ImGui::SetCursorPosY(FIRST_INFO_POS_Y + INFO_SIZE_HEIGHT * 2); + ImGui::Text("Created At: %s", created_at.c_str()); + ImGui::SetCursorPosY(FIRST_INFO_POS_Y + (INFO_SIZE_HEIGHT * 3)); + ImGui::Text("Quota Used: %s / %s", + user_info.quota_used == 0 ? "-" : get_unit_size(user_info.quota_used).c_str(), + user_info.quota_total == 0 ? "-" : get_unit_size(user_info.quota_total).c_str()); + + const auto END_INFO_POS_Y = BEGIN_INFO_POS_Y + BLOCK_INFO_SIZE_HEIGHT; + + // Display V3KN avatar if available, otherwise fall back to local user avatar + if (gui.v3kn_avatar) { + const ImVec2 V3KN_AVATAR_SIZE(static_cast(AvatarSize::V3KN) * SCALE.x, static_cast(AvatarSize::V3KN) * SCALE.y); + const ImVec2 V3KN_AVATAR_POS( + POPUP_SIZE.x - V3KN_AVATAR_SIZE.x - (10.f * SCALE.x), + BEGIN_INFO_POS_Y + ((BLOCK_INFO_SIZE_HEIGHT - V3KN_AVATAR_SIZE.y) / 2.f)); + const float BORDER_THICKNESS = 2.f * SCALE.x; + ImGui::GetWindowDrawList()->AddRect( + ImVec2(POPUP_POS.x + V3KN_AVATAR_POS.x - BORDER_THICKNESS, POPUP_POS.y + V3KN_AVATAR_POS.y - BORDER_THICKNESS), + ImVec2(POPUP_POS.x + V3KN_AVATAR_POS.x + V3KN_AVATAR_SIZE.x + BORDER_THICKNESS, POPUP_POS.y + V3KN_AVATAR_POS.y + V3KN_AVATAR_SIZE.y + BORDER_THICKNESS), + IM_COL32(100, 149, 237, 255), 6.f * SCALE.x, 0, BORDER_THICKNESS); + ImGui::SetCursorPos(V3KN_AVATAR_POS); + ImGui::Image(gui.v3kn_avatar, V3KN_AVATAR_SIZE); + } else if (gui.users_avatar[emuenv.io.user_id]) { + const auto &user_avatar_infos = gui.users_avatar_infos[emuenv.io.user_id][AvatarSize::V3KN]; + const ImVec2 AVATAR_FINAL_SIZE = ImVec2(user_avatar_infos.size.x * SCALE.x, user_avatar_infos.size.y * SCALE.y); + const ImVec2 AVATAR_POS = ImVec2( + POPUP_SIZE.x - AVATAR_SIZE.x - (10.f * SCALE.x) + (user_avatar_infos.pos.x * SCALE.x), + BEGIN_INFO_POS_Y + ((BLOCK_INFO_SIZE_HEIGHT - AVATAR_SIZE.y) / 2.f) + (user_avatar_infos.pos.y * SCALE.y)); + const float BORDER_THICKNESS = 2.f * SCALE.x; + ImGui::GetWindowDrawList()->AddRect( + ImVec2(POPUP_POS.x + AVATAR_POS.x - BORDER_THICKNESS, POPUP_POS.y + AVATAR_POS.y - BORDER_THICKNESS), + ImVec2(POPUP_POS.x + AVATAR_POS.x + AVATAR_FINAL_SIZE.x + BORDER_THICKNESS, POPUP_POS.y + AVATAR_POS.y + AVATAR_FINAL_SIZE.y + BORDER_THICKNESS), + IM_COL32(100, 149, 237, 255), 0.f, 0, BORDER_THICKNESS); + ImGui::SetCursorPos(AVATAR_POS); + ImGui::Image(gui.users_avatar[emuenv.io.user_id], AVATAR_FINAL_SIZE); + } + + ImGui::SetCursorPosY(END_INFO_POS_Y); + ImGui::Separator(); + ImGui::Spacing(); + + const auto AFTER_INFO_POS = ImGui::GetCursorPosY(); + + ImGui::BeginDisabled(v3kn::is_v3kn_logged_in() && result_state == V3knResultState::NONE); + + // Create Account Button + ImGui::SetCursorPosX((WINDOW_SIZE.x / 2.f) - BUTTON_SIZE.x - (20.f * SCALE.x)); + if (ImGui::Button("Create Account", BUTTON_SIZE)) { + result_state = V3knResultState::NONE; + ImGui::OpenPopup("V3KN Create"); + } + + if (ImGui::BeginPopupModal("V3KN Create", nullptr, popup_flags)) { + const auto title_popup = "Create V3KN Account"; + ImGui::SetCursorPosX((ImGui::GetWindowWidth() - ImGui::CalcTextSize(title_popup).x) / 2.f); + ImGui::TextColored(GUI_COLOR_TEXT_TITLE, "%s", title_popup); + ImGui::Separator(); + + ImGui::TextColored(GUI_COLOR_TEXT_TITLE, "Please enter a username and password to create your V3KN account."); + ImGui::TextColored(GUI_COLOR_TEXT, "Username must start with a letter and contain 3–16 characters (letters, numbers, _ or -)."); + ImGui::TextColored(GUI_COLOR_TEXT, "Password must be at least 8 characters long."); + + ImGui::InputText("Username", username, sizeof(username), ImGuiInputTextFlags_CharsNoBlank); + ImGui::InputText("Password", password, sizeof(password), ImGuiInputTextFlags_Password); + const auto res = ImGui::InputText("Confirm Password", confirm, sizeof(confirm), ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_Password); + + const std::string username_str(username); + const std::string password_str(password); + const std::string confirm_str(confirm); + ImGui::BeginDisabled(is_waiting_result || username_str.empty() || (username_str.size() < 3) || password_str.empty() || confirm_str.empty() || (password_str.size() < 8)); + ImGui::SetCursorPosX((ImGui::GetWindowWidth() / 2.f) - SMALL_BUTTON_SIZE.x - (20.f * SCALE.x)); + if (ImGui::Button(OK_STR.c_str(), SMALL_BUTTON_SIZE) || res) { + ImGui::OpenPopup("Result"); + if (std::string(password) != std::string(confirm)) { + result_state = V3knResultState::NONE; + result_message = "Password and confirmation do not match."; + } else { + const std::string derived_password = derive_password(password); + const std::string base64_password = base64_encode(derived_password); + const std::string requested_username = username; + const UserInfo current_user_info = user_info; + is_waiting_result = true; + std::thread([&emuenv, requested_username, base64_password, current_user_info]() { + const auto res = v3kn::v3kn_create(current_user_info, requested_username, base64_password); + V3knPendingResult result; + result.action = V3knPendingAction::CREATE; + result.response = res; + result.online_id = requested_username; + result.password = base64_password; + if (res.body.starts_with("OK:")) { + std::smatch match; + std::regex regex_pattern(R"(OK:([^:]+))"); + if (std::regex_search(res.body, match, regex_pattern) && (match.size() == 2)) { + result.token = match[1]; + result.message = fmt::format("Account created successfully for user {}", requested_username); + } else { + result.message = fmt::format("Unexpected response format from server: {}", res.body); + } + } else { + result.message = v3kn::get_v3kn_error_message(emuenv, res); + } + { + std::lock_guard lock(result_mutex); + pending_result = std::move(result); + } + is_result_ready = true; + }).detach(); + } + } + ImGui::EndDisabled(); + ImGui::SameLine(0, 40.f * SCALE.x); + if (ImGui::Button(CANCEL_STR.c_str(), SMALL_BUTTON_SIZE)) { + result_state = V3knResultState::NONE; + username[0] = '\0'; + password[0] = '\0'; + confirm[0] = '\0'; + ImGui::CloseCurrentPopup(); + } + result_popup(); + if (result_state == V3knResultState::CLOSE_PARENT) { + ImGui::CloseCurrentPopup(); + result_message.clear(); + result_state = V3knResultState::NONE; + } + ImGui::EndPopup(); + } + + // Login Button + ImGui::SetCursorPosX((WINDOW_SIZE.x / 2.f) - BUTTON_SIZE.x - (20.f * SCALE.x)); + if (ImGui::Button("Login", BUTTON_SIZE)) { + result_state = V3knResultState::NONE; + ImGui::OpenPopup("V3KN Login"); + } + + if (ImGui::BeginPopupModal("V3KN Login", nullptr, popup_flags)) { + const auto title_popup = "V3KN Login"; + ImGui::SetCursorPosX((ImGui::GetWindowWidth() - ImGui::CalcTextSize(title_popup).x) / 2.f); + ImGui::TextColored(GUI_COLOR_TEXT_TITLE, "%s", title_popup); + ImGui::Separator(); + + ImGui::InputText("Username", username, sizeof(username)); + auto res = ImGui::InputText("Password", password, sizeof(password), ImGuiInputTextFlags_Password | ImGuiInputTextFlags_EnterReturnsTrue); + + ImGui::BeginDisabled(is_waiting_result || std::string(password).empty() || std::string(username).empty()); + ImGui::SetCursorPosX((ImGui::GetWindowWidth() / 2.f) - SMALL_BUTTON_SIZE.x - (20.f * SCALE.x)); + if (ImGui::Button(OK_STR.c_str(), SMALL_BUTTON_SIZE) || res) { + const std::string derived_password = derive_password(password); + const std::string base64_password = base64_encode(derived_password); + const std::string requested_username = username; + const UserInfo current_user_info = user_info; + result_state = V3knResultState::NONE; + is_waiting_result = true; + ImGui::OpenPopup("Result"); + std::thread([&emuenv, requested_username, base64_password, current_user_info]() { + const auto res = v3kn::v3kn_login(current_user_info, requested_username, base64_password); + V3knPendingResult result; + result.action = V3knPendingAction::LOGIN; + result.response = res; + result.password = base64_password; + if (res.body.starts_with("OK:")) { + std::smatch match; + std::regex regex_pattern(R"(OK:([^:]+):([^:]+))"); + if (std::regex_search(res.body, match, regex_pattern) && (match.size() == 3)) { + result.online_id = match[1]; + result.token = match[2]; + result.message = "Login on V3KN successfully for user " + result.online_id; + } else { + result.message = v3kn::get_v3kn_error_message(emuenv, res); + } + } else { + result.message = v3kn::get_v3kn_error_message(emuenv, res); + } + { + std::lock_guard lock(result_mutex); + pending_result = std::move(result); + } + is_result_ready = true; + }).detach(); + } + ImGui::EndDisabled(); + ImGui::SameLine(0, 40.f * SCALE.x); + if (ImGui::Button(CANCEL_STR.c_str(), SMALL_BUTTON_SIZE)) { + result_state = V3knResultState::NONE; + username[0] = '\0'; + password[0] = '\0'; + ImGui::CloseCurrentPopup(); + } + result_popup(); + if (result_state == V3knResultState::CLOSE_PARENT) { + ImGui::CloseCurrentPopup(); + result_message.clear(); + result_state = V3knResultState::NONE; + } + ImGui::EndPopup(); + } + ImGui::EndDisabled(); + + if (!v3kn::is_v3kn_logged_in() && !user_info.token.empty()) { + ImGui::SetCursorPosX((WINDOW_SIZE.x / 2.f) - BUTTON_SIZE.x - (20.f * SCALE.x)); + static bool is_testing_connection = false; + ImGui::BeginDisabled(is_waiting_result || is_testing_connection); + if (ImGui::Button(is_testing_connection ? "Testing..." : "Test Connection", BUTTON_SIZE)) { + is_testing_connection = true; + std::thread([&gui, &emuenv]() { + v3kn::init_v3kn_user_info(gui, emuenv); + is_testing_connection = false; + }).detach(); + } + ImGui::EndDisabled(); + SetTooltipEx("Test connection to V3KN server with saved credentials"); + } + + ImGui::BeginDisabled(!v3kn::is_v3kn_logged_in() && result_state == V3knResultState::NONE); + + // Refresh quota button + ImGui::SetCursorPosX((WINDOW_SIZE.x / 2.f) - BUTTON_SIZE.x - (20.f * SCALE.x)); + ImGui::BeginDisabled(is_waiting_result); + if (ImGui::Button("Refresh Quota", BUTTON_SIZE)) { + const UserInfo current_user_info = user_info; + is_waiting_result = true; + std::thread([&emuenv, current_user_info]() { + const std::string url = v3kn::get_v3kn_server_url(current_user_info.host, "v3kn/quota"); + const auto res = net_utils::get_web_response_ex(url, current_user_info.token); + V3knPendingResult result; + result.action = V3knPendingAction::REFRESH_QUOTA; + result.response = res; + if (res.body.starts_with("OK:")) { + std::smatch match; + std::regex regex_pattern(R"(OK:([^:]+):([^:]+))"); + if (std::regex_search(res.body, match, regex_pattern) && (match.size() == 3)) { + result.quota_used = std::strtoull(match[1].str().c_str(), nullptr, 10); + result.quota_total = std::strtoull(match[2].str().c_str(), nullptr, 10); + result.has_quota = true; + result.message = fmt::format("Quota refreshed: {} / {}", get_unit_size(result.quota_used), get_unit_size(result.quota_total)); + } else { + result.message = v3kn::get_v3kn_error_message(emuenv, res); + } + } else { + result.message = v3kn::get_v3kn_error_message(emuenv, res); + } + { + std::lock_guard lock(result_mutex); + pending_result = std::move(result); + } + is_result_ready = true; + }).detach(); + ImGui::OpenPopup("Result"); + } + ImGui::EndDisabled(); + SetTooltipEx("Refresh quota"); + result_popup(); + + // Delete account button + ImGui::SetCursorPosX((WINDOW_SIZE.x / 2.f) - BUTTON_SIZE.x - (20.f * SCALE.x)); + if (ImGui::Button("Delete Account", BUTTON_SIZE)) + ImGui::OpenPopup("V3KN Delete"); + + if (ImGui::BeginPopupModal("V3KN Delete", nullptr, popup_flags)) { + const auto title_popup = "Delete V3KN Account"; + ImGui::SetCursorPosX((ImGui::GetWindowWidth() - ImGui::CalcTextSize(title_popup).x) / 2.f); + ImGui::TextColored(GUI_COLOR_TEXT_TITLE, "%s", title_popup); + ImGui::Separator(); + + ImGui::InputText("Password", password, sizeof(password), ImGuiInputTextFlags_Password); + ImGui::BeginDisabled(is_waiting_result || std::string(password).empty()); + ImGui::SetCursorPosX((ImGui::GetWindowWidth() / 2.f) - SMALL_BUTTON_SIZE.x - (20.f * SCALE.x)); + if (ImGui::Button(OK_STR.c_str(), SMALL_BUTTON_SIZE)) { + const std::string derived_password = derive_password(password); + const std::string base64_password = base64_encode(derived_password); + const UserInfo current_user_info = user_info; + const std::string current_online_id = user_info.online_id; + is_waiting_result = true; + ImGui::OpenPopup("Result"); + std::thread([&emuenv, current_online_id, base64_password, current_user_info]() { + const auto res = v3kn::v3kn_delete(current_user_info, base64_password); + V3knPendingResult result; + result.action = V3knPendingAction::DELETE_ACCOUNT; + result.response = res; + if (res.body.starts_with("OK:")) + result.message = "Account V3KN deleted successfully for user " + current_online_id; + else + result.message = v3kn::get_v3kn_error_message(emuenv, res); + { + std::lock_guard lock(result_mutex); + pending_result = std::move(result); + } + is_result_ready = true; + }).detach(); + } + ImGui::EndDisabled(); + ImGui::SameLine(0, 40.f * SCALE.x); + if (ImGui::Button(CANCEL_STR.c_str(), SMALL_BUTTON_SIZE)) { + password[0] = '\0'; + ImGui::CloseCurrentPopup(); + } + result_popup(); + if (result_state == V3knResultState::CLOSE_PARENT) { + ImGui::CloseCurrentPopup(); + result_message.clear(); + result_state = V3knResultState::NONE; + } + ImGui::EndPopup(); + } + + ImGui::SetCursorPosX((WINDOW_SIZE.x / 2.f) - BUTTON_SIZE.x - (20.f * SCALE.x)); + if (ImGui::Button("Logout", BUTTON_SIZE)) + ImGui::OpenPopup("Confirm Logout"); + + if (ImGui::BeginPopupModal("Confirm Logout", nullptr, popup_flags)) { + ImGui::Text("Are you sure you want to logout from V3KN?"); + ImGui::SetCursorPosX((ImGui::GetWindowWidth() - (SMALL_BUTTON_SIZE.x * 2.f) - (40.f * SCALE.x)) / 2.f); + if (ImGui::Button(common_lang["no"].c_str(), SMALL_BUTTON_SIZE)) + ImGui::CloseCurrentPopup(); + ImGui::SameLine(0, 40.f * SCALE.x); + if (ImGui::Button(common_lang["yes"].c_str(), SMALL_BUTTON_SIZE)) { + user_info = {}; + v3kn::set_v3kn_logged_in(false); + v3kn::save_v3kn_user_info(emuenv); + gui.v3kn_avatar = {}; + std::thread([&gui, &emuenv]() { + v3kn::init_v3kn_user_info(gui, emuenv); + }).detach(); + result_message = "Logged out successfully."; + ImGui::CloseCurrentPopup(); + } + result_popup(); + if (result_state == V3knResultState::CLOSE_PARENT) { + ImGui::CloseCurrentPopup(); + result_message.clear(); + result_state = V3knResultState::NONE; + } + ImGui::EndPopup(); + } + + ImGui::SetCursorPosY(AFTER_INFO_POS); + ImGui::SetCursorPosX((WINDOW_SIZE.x / 2.f) + (20.f * SCALE.x)); + if (ImGui::Button("Change Online ID", BUTTON_SIZE)) + ImGui::OpenPopup("V3KN Change Online ID"); + + if (ImGui::BeginPopupModal("V3KN Change Online ID", nullptr, popup_flags)) { + const auto title_popup = "Change V3KN Online ID"; + ImGui::SetCursorPosX((ImGui::GetWindowWidth() - ImGui::CalcTextSize(title_popup).x) / 2.f); + ImGui::TextColored(GUI_COLOR_TEXT_TITLE, "%s", title_popup); + ImGui::Separator(); + + ImGui::InputText("New Username", username, sizeof(username), ImGuiInputTextFlags_CharsNoBlank); + ImGui::BeginDisabled(is_waiting_result || std::string(username).empty()); + ImGui::SetCursorPosX((ImGui::GetWindowWidth() / 2.f) - SMALL_BUTTON_SIZE.x - (20.f * SCALE.x)); + if (ImGui::Button(OK_STR.c_str(), SMALL_BUTTON_SIZE)) { + const std::string new_online_id = username; + const UserInfo current_user_info = user_info; + is_waiting_result = true; + ImGui::OpenPopup("Result"); + std::thread([&emuenv, new_online_id, current_user_info]() { + const auto res = v3kn::v3kn_change_online_id(current_user_info, new_online_id); + V3knPendingResult result; + result.action = V3knPendingAction::CHANGE_ONLINE_ID; + result.response = res; + result.online_id = new_online_id; + if (res.body.starts_with("OK:")) + result.message = "Online ID successfully changed to " + new_online_id; + else + result.message = v3kn::get_v3kn_error_message(emuenv, res); + { + std::lock_guard lock(result_mutex); + pending_result = std::move(result); + } + is_result_ready = true; + }).detach(); + } + ImGui::EndDisabled(); + ImGui::SameLine(0, 40.f * SCALE.x); + if (ImGui::Button(CANCEL_STR.c_str(), SMALL_BUTTON_SIZE)) { + username[0] = '\0'; + ImGui::CloseCurrentPopup(); + } + result_popup(); + if (result_state == V3knResultState::CLOSE_PARENT) { + ImGui::CloseCurrentPopup(); + result_message.clear(); + result_state = V3knResultState::NONE; + } + ImGui::EndPopup(); + } + + ImGui::SetCursorPosX((WINDOW_SIZE.x / 2.f) + (20.f * SCALE.x)); + if (ImGui::Button("Change Password", BUTTON_SIZE)) + ImGui::OpenPopup("V3KN Change Password"); + + if (ImGui::BeginPopupModal("V3KN Change Password", nullptr, popup_flags)) { + const auto title_popup = "Change V3KN Password"; + ImGui::SetCursorPosX((ImGui::GetWindowWidth() - ImGui::CalcTextSize(title_popup).x) / 2.f); + ImGui::TextColored(GUI_COLOR_TEXT_TITLE, "%s", title_popup); + ImGui::Separator(); + + ImGui::InputText("Current Password", password, sizeof(password), ImGuiInputTextFlags_Password); + ImGui::InputText("New Password", confirm, sizeof(confirm), ImGuiInputTextFlags_Password); + ImGui::BeginDisabled(is_waiting_result || std::string(password).empty() || std::string(confirm).empty()); + ImGui::SetCursorPosX((ImGui::GetWindowWidth() / 2.f) - SMALL_BUTTON_SIZE.x - (20.f * SCALE.x)); + if (ImGui::Button(OK_STR.c_str(), SMALL_BUTTON_SIZE)) { + const std::string derived_current_password = derive_password(password); + const std::string base64_current_password = base64_encode(derived_current_password); + if (base64_current_password != user_info.password) { + result_message = "Current password is incorrect."; + ImGui::OpenPopup("Result"); + } else { + const std::string derived_new_password = derive_password(confirm); + const std::string base64_new_password = base64_encode(derived_new_password); + const UserInfo current_user_info = user_info; + is_waiting_result = true; + ImGui::OpenPopup("Result"); + std::thread([&emuenv, base64_current_password, base64_new_password, current_user_info]() { + const auto res = v3kn::v3kn_change_password(current_user_info, base64_current_password, base64_new_password); + V3knPendingResult result; + result.action = V3knPendingAction::CHANGE_PASSWORD; + result.response = res; + result.password = base64_new_password; + if (res.body.starts_with("OK:")) { + std::smatch match; + std::regex regex_pattern(R"(OK:([^:]+))"); + if (std::regex_search(res.body, match, regex_pattern) && (match.size() == 2)) { + result.token = match[1]; + result.message = "Password changed successfully for user " + current_user_info.online_id; + } else { + result.message = v3kn::get_v3kn_error_message(emuenv, res); + } + } else { + result.message = v3kn::get_v3kn_error_message(emuenv, res); + } + { + std::lock_guard lock(result_mutex); + pending_result = std::move(result); + } + is_result_ready = true; + }).detach(); + } + } + ImGui::EndDisabled(); + ImGui::SameLine(0, 40.f * SCALE.x); + if (ImGui::Button(CANCEL_STR.c_str(), SMALL_BUTTON_SIZE)) { + password[0] = '\0'; + confirm[0] = '\0'; + ImGui::CloseCurrentPopup(); + } + result_popup(); + if (result_state == V3knResultState::CLOSE_PARENT) { + ImGui::CloseCurrentPopup(); + result_message.clear(); + result_state = V3knResultState::NONE; + } + ImGui::EndPopup(); + } + + // Change About Me Button + ImGui::SetCursorPosX((WINDOW_SIZE.x / 2.f) + (20.f * SCALE.x)); + if (ImGui::Button("Change About Me", BUTTON_SIZE)) + ImGui::OpenPopup("V3KN Change About Me"); + + if (ImGui::BeginPopupModal("V3KN Change About Me", nullptr, popup_flags)) { + const auto title_popup = "Change V3KN About Me"; + ImGui::SetCursorPosX((ImGui::GetWindowWidth() - ImGui::CalcTextSize(title_popup).x) / 2.f); + ImGui::TextColored(GUI_COLOR_TEXT_TITLE, "%s", title_popup); + ImGui::Separator(); + const ImVec2 BOX_TEXT_SIZE = ImVec2(368.f * SCALE.x, 50.f * SCALE.y); + ImGui::SetNextItemWidth(BOX_TEXT_SIZE.x); + const auto res = ImGui::InputText("About Me", about_me, sizeof(about_me), ImGuiInputTextFlags_EnterReturnsTrue); + ImGui::BeginDisabled(is_waiting_result || std::string(about_me).empty()); + if (ImGui::Button(OK_STR.c_str(), SMALL_BUTTON_SIZE) || res) { + const std::string about_me_str(about_me); + const UserInfo current_user_info = user_info; + is_waiting_result = true; + ImGui::OpenPopup("Result"); + std::thread([&emuenv, about_me_str, current_user_info]() { + const auto res = v3kn::v3kn_change_about_me(current_user_info, about_me_str); + V3knPendingResult result; + result.action = V3knPendingAction::CHANGE_ABOUT_ME; + result.response = res; + if (res.body.starts_with("OK:")) + result.message = "About Me changed successfully for user " + current_user_info.online_id; + else + result.message = v3kn::get_v3kn_error_message(emuenv, res); + { + std::lock_guard lock(result_mutex); + pending_result = std::move(result); + } + is_result_ready = true; + }).detach(); + } + ImGui::EndDisabled(); + ImGui::SameLine(0, 40.f * SCALE.x); + if (ImGui::Button(CANCEL_STR.c_str(), SMALL_BUTTON_SIZE)) { + about_me[0] = '\0'; + ImGui::CloseCurrentPopup(); + } + result_popup(); + if (result_state == V3knResultState::CLOSE_PARENT) { + ImGui::CloseCurrentPopup(); + result_message.clear(); + result_state = V3knResultState::NONE; + } + ImGui::EndPopup(); + } + + // Change avatar + ImGui::SetCursorPosX((WINDOW_SIZE.x / 2.f) + (20.f * SCALE.x)); + if (ImGui::Button("Change Avatar", BUTTON_SIZE)) + ImGui::OpenPopup("V3KN Change Avatar"); + + if (ImGui::BeginPopupModal("V3KN Change Avatar", nullptr, popup_flags)) { + const auto title_popup = "Change V3KN Avatar"; + ImGui::SetCursorPosX((ImGui::GetWindowWidth() - ImGui::CalcTextSize(title_popup).x) / 2.f); + ImGui::TextColored(GUI_COLOR_TEXT_TITLE, "%s", title_popup); + ImGui::Separator(); + + static std::string selected_avatar_path; + static ImGui_Texture avatar_preview; + static int preview_w = 0, preview_h = 0; + static bool is_uploading = false; + static std::string avatar_result_message; + static bool avatar_upload_success = false; + + if (avatar_upload_success) { + avatar_upload_success = false; + selected_avatar_path.clear(); + avatar_preview = {}; + preview_w = 0; + preview_h = 0; + avatar_result_message.clear(); + ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + // skip rest of popup this frame + goto avatar_popup_end; + } + + if (ImGui::Button("Browse...", BUTTON_SIZE)) { + fs::path avatar_path{}; + const auto result = host::dialog::filesystem::open_file(avatar_path, { { "Image file", { "bmp", "gif", "jpg", "jpeg", "png", "tif" } } }); + if (result == host::dialog::filesystem::Result::SUCCESS && fs::exists(avatar_path)) { + selected_avatar_path = avatar_path.string(); + avatar_result_message.clear(); + + int w, h; + stbi_uc *data = stbi_load(selected_avatar_path.c_str(), &w, &h, nullptr, STBI_rgb_alpha); + if (data) { + avatar_preview = ImGui_Texture(gui.imgui_state.get(), data, w, h); + preview_w = w; + preview_h = h; + stbi_image_free(data); + } else { + avatar_result_message = "Failed to load image."; + selected_avatar_path.clear(); + } + } else if (result == host::dialog::filesystem::Result::ERROR) { + avatar_result_message = fmt::format("File dialog error: {}", host::dialog::filesystem::get_error()); + } + } + + if (!selected_avatar_path.empty()) { + ImGui::Text("Selected: %s", fs::path(selected_avatar_path).filename().string().c_str()); + if (avatar_preview && preview_w > 0 && preview_h > 0) { + const float max_preview = 128.f * SCALE.x; + const float ratio = std::min(max_preview / static_cast(preview_w), max_preview / static_cast(preview_h)); + const ImVec2 preview_size(preview_w * ratio, preview_h * ratio); + ImGui::SetCursorPosX((ImGui::GetWindowWidth() - preview_size.x) / 2.f); + ImGui::Image(avatar_preview, preview_size); + } + } + + if (!avatar_result_message.empty()) { + const auto msg_color = avatar_result_message.find("successfully") != std::string::npos + ? ImVec4(0.3f, 1.f, 0.3f, 1.f) + : ImVec4(1.f, 0.3f, 0.3f, 1.f); + ImGui::TextColored(msg_color, "%s", avatar_result_message.c_str()); + } + + ImGui::BeginDisabled(selected_avatar_path.empty() || is_uploading); + ImGui::SetCursorPosX((ImGui::GetWindowWidth() / 2.f) - SMALL_BUTTON_SIZE.x - (20.f * SCALE.x)); + if (ImGui::Button(is_uploading ? "Uploading..." : OK_STR.c_str(), SMALL_BUTTON_SIZE)) { + is_uploading = true; + avatar_result_message.clear(); + std::thread([&gui, &emuenv, &user_info]() { + constexpr int canvas_size = 128; + + int w, h; + stbi_uc *img = stbi_load(selected_avatar_path.c_str(), &w, &h, nullptr, STBI_rgb_alpha); + if (!img) { + avatar_result_message = "Failed to load image for processing."; + is_uploading = false; + return; + } + + const float scale_factor = static_cast(canvas_size) / static_cast(std::max(w, h)); + const int new_w = static_cast(w * scale_factor); + const int new_h = static_cast(h * scale_factor); + + std::vector resized(new_w * new_h * 4); + stbir_resize_uint8_linear(img, w, h, 0, resized.data(), new_w, new_h, 0, STBIR_RGBA); + stbi_image_free(img); + + std::vector canvas(canvas_size * canvas_size * 4, 0); + const int offset_x = (canvas_size - new_w) / 2; + const int offset_y = (canvas_size - new_h) / 2; + + for (int y = 0; y < new_h; y++) { + for (int x = 0; x < new_w; x++) { + const int src = (y * new_w + x) * 4; + const int dst = ((y + offset_y) * canvas_size + (x + offset_x)) * 4; + canvas[dst + 0] = resized[src + 0]; + canvas[dst + 1] = resized[src + 1]; + canvas[dst + 2] = resized[src + 2]; + canvas[dst + 3] = resized[src + 3]; + } + } + + std::vector png_data; + stbi_write_png_to_func( + [](void *context, void *data, int size) { + auto *vec = static_cast *>(context); + const auto ptr = static_cast(data); + vec->insert(vec->end(), ptr, ptr + size); + }, + &png_data, canvas_size, canvas_size, 4, canvas.data(), canvas_size * 4); + + const auto res = v3kn::v3kn_avatar_upload(user_info, png_data); + v3kn::handle_v3kn_status(emuenv, res); + + if (res.body.starts_with("OK")) { + gui.v3kn_avatar = ImGui_Texture(gui.imgui_state.get(), canvas.data(), canvas_size, canvas_size); + const fs::path avatar_cache_path = emuenv.cache_path / "v3kn" / user_info.host / fmt::format("{}.png", user_info.online_id); + fs::create_directories(avatar_cache_path.parent_path()); + fs_utils::dump_data(avatar_cache_path, png_data.data(), static_cast(png_data.size())); + avatar_upload_success = true; + } else { + avatar_result_message = v3kn::get_v3kn_error_message(emuenv, res); + } + is_uploading = false; + }).detach(); + } + ImGui::EndDisabled(); + ImGui::SameLine(0, 40.f * SCALE.x); + if (ImGui::Button(CANCEL_STR.c_str(), SMALL_BUTTON_SIZE)) { + selected_avatar_path.clear(); + avatar_preview = {}; + preview_w = 0; + preview_h = 0; + avatar_result_message.clear(); + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } +avatar_popup_end: + + // Change Panel + ImGui::SetCursorPosX((WINDOW_SIZE.x / 2.f) + (20.f * SCALE.x)); + if (ImGui::Button("Change Panel", BUTTON_SIZE)) + ImGui::OpenPopup("V3KN Change Panel"); + ImGui::EndDisabled(); + + static std::string selected_panel_path; + static ImGui_Texture panel_preview; + static int panel_preview_w = 0; + static int panel_preview_h = 0; + static std::vector panel_source_data; + static bool is_panel_uploading = false; + static std::string panel_result_message; + static bool panel_upload_success = false; + static float panel_crop_scroll_x = 0.f; + static float panel_crop_scroll_y = 0.f; + static float panel_crop_scale = 1.f; + static float panel_crop_zoom = 1.f; + static bool show_panel_crop_popup = false; + + if (ImGui::BeginPopupModal("V3KN Change Panel", nullptr, popup_flags)) { + const auto title_popup = "Change V3KN Panel"; + ImGui::SetCursorPosX((ImGui::GetWindowWidth() - ImGui::CalcTextSize(title_popup).x) / 2.f); + ImGui::TextColored(GUI_COLOR_TEXT_TITLE, "%s", title_popup); + ImGui::Separator(); + + if (ImGui::Button("Browse...", BUTTON_SIZE)) { + fs::path panel_path{}; + const auto result = host::dialog::filesystem::open_file(panel_path, { { "Image file", { "bmp", "gif", "jpg", "jpeg", "png", "tif" } } }); + if (result == host::dialog::filesystem::Result::SUCCESS && fs::exists(panel_path)) { + selected_panel_path = panel_path.string(); + panel_result_message.clear(); + + int w = 0; + int h = 0; + stbi_uc *data = stbi_load(selected_panel_path.c_str(), &w, &h, nullptr, STBI_rgb_alpha); + if (data) { + panel_preview = ImGui_Texture(gui.imgui_state.get(), data, w, h); + panel_preview_w = w; + panel_preview_h = h; + panel_source_data.assign(data, data + (w * h * 4)); + stbi_image_free(data); + panel_crop_scroll_x = 0.f; + panel_crop_scroll_y = 0.f; + panel_crop_zoom = 1.f; + show_panel_crop_popup = true; + ImGui::CloseCurrentPopup(); + } else { + panel_result_message = "Failed to load image."; + selected_panel_path.clear(); + } + } else if (result == host::dialog::filesystem::Result::ERROR) { + panel_result_message = fmt::format("File dialog error: {}", host::dialog::filesystem::get_error()); + } + } + ImGui::SetCursorPosX((ImGui::GetWindowWidth() - SMALL_BUTTON_SIZE.x) / 2.f); + if (ImGui::Button(CANCEL_STR.c_str(), SMALL_BUTTON_SIZE)) { + selected_panel_path.clear(); + panel_preview = {}; + panel_preview_w = 0; + panel_preview_h = 0; + panel_source_data.clear(); + panel_result_message.clear(); + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + + if (show_panel_crop_popup && !selected_panel_path.empty() && panel_preview && panel_preview_w > 0 && panel_preview_h > 0) { + constexpr float panel_ratio = 360.f / 72.f; + const float panel_width = 720.f * SCALE.x; + const float panel_height = panel_width / panel_ratio; + const ImVec2 crop_popup_size(panel_width + (80.f * SCALE.x), panel_height + (160.f * SCALE.y)); + ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + ImGui::SetNextWindowSize(crop_popup_size, ImGuiCond_Appearing); + ImGui::OpenPopup("V3KN Panel Crop"); + show_panel_crop_popup = false; + } + + if (ImGui::BeginPopupModal("V3KN Panel Crop", nullptr, popup_flags)) { + const auto title_popup = "Crop V3KN Panel"; + ImGui::SetCursorPosX((ImGui::GetWindowWidth() - ImGui::CalcTextSize(title_popup).x) / 2.f); + ImGui::TextColored(GUI_COLOR_TEXT_TITLE, "%s", title_popup); + ImGui::Separator(); + + if (panel_upload_success) { + panel_upload_success = false; + selected_panel_path.clear(); + panel_preview = {}; + panel_preview_w = 0; + panel_preview_h = 0; + panel_source_data.clear(); + panel_result_message.clear(); + ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + } else { + const ImVec2 PANEL_SIZE(720.f * SCALE.x, 144.f * SCALE.y); + const float panel_base_scale = PANEL_SIZE.x / static_cast(panel_preview_w); + panel_crop_scale = panel_base_scale * panel_crop_zoom; + float panel_image_height = panel_preview_h * panel_crop_scale; + const bool needs_scroll_y_for_size = panel_image_height > (PANEL_SIZE.y + 0.5f); + const float panel_extra_width = needs_scroll_y_for_size ? ImGui::GetStyle().ScrollbarSize : 0.f; + const ImVec2 panel_crop_size(PANEL_SIZE.x + panel_extra_width, PANEL_SIZE.y); + static float panel_view_width = 0.f; + static float panel_view_height = 0.f; + if (panel_view_width <= 0.f) + panel_view_width = PANEL_SIZE.x; + if (panel_view_height <= 0.f) + panel_view_height = PANEL_SIZE.y; + + ImGui::Text("Selected: %s", fs::path(selected_panel_path).filename().string().c_str()); + const ImVec2 zoom_button_size(40.f * SCALE.x, 40.f * SCALE.y); + const float panel_crop_start_x = (ImGui::GetWindowWidth() - panel_crop_size.x) / 2.f; + const float panel_crop_start_y = ImGui::GetCursorPosY(); + const float zoom_button_y = panel_crop_start_y + ((panel_crop_size.y - zoom_button_size.y) / 2.f); + + float next_scroll_x = panel_crop_scroll_x; + float next_scroll_y = panel_crop_scroll_y; + bool zoom_changed = false; + + const auto update_zoom = [&](float new_zoom) { + new_zoom = std::clamp(new_zoom, 1.f, 4.f); + if (std::abs(new_zoom - panel_crop_zoom) < 0.001f) + return; + + const float old_scale = panel_base_scale * panel_crop_zoom; + const float new_scale = panel_base_scale * new_zoom; + const float center_x = (panel_crop_scroll_x + (panel_view_width * 0.5f)) / old_scale; + const float center_y = (panel_crop_scroll_y + (panel_view_height * 0.5f)) / old_scale; + const float max_scroll_x = std::max(0.f, (panel_preview_w * new_scale) - panel_view_width); + const float max_scroll_y = std::max(0.f, (panel_preview_h * new_scale) - panel_view_height); + + next_scroll_x = std::clamp((center_x * new_scale) - (panel_view_width * 0.5f), 0.f, max_scroll_x); + next_scroll_y = std::clamp((center_y * new_scale) - (panel_view_height * 0.5f), 0.f, max_scroll_y); + panel_crop_zoom = new_zoom; + zoom_changed = true; + }; + + ImGui::SetCursorPos(ImVec2(panel_crop_start_x - zoom_button_size.x - (10.f * SCALE.x), zoom_button_y)); + if (ImGui::Button("-", zoom_button_size)) + update_zoom(panel_crop_zoom - 0.05f); + + ImGui::SetCursorPos(ImVec2(panel_crop_start_x + panel_crop_size.x + (10.f * SCALE.x), zoom_button_y)); + if (ImGui::Button("+", zoom_button_size)) + update_zoom(panel_crop_zoom + 0.05f); + + if (zoom_changed) { + panel_crop_scale = panel_base_scale * panel_crop_zoom; + panel_image_height = panel_preview_h * panel_crop_scale; + } + + ImGui::SetCursorPos(ImVec2(panel_crop_start_x, panel_crop_start_y)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.f, 0.f)); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0.f, 0.f)); + + const float panel_image_width = panel_preview_w * panel_crop_scale; + const bool needs_scroll_x = panel_image_width > (PANEL_SIZE.x + 0.5f); + const bool needs_scroll_y = panel_image_height > (PANEL_SIZE.y + 0.5f); + ImGuiWindowFlags child_flags = 0; + if (!needs_scroll_x && !needs_scroll_y) + child_flags |= ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings; + else if (needs_scroll_x) + child_flags |= ImGuiWindowFlags_HorizontalScrollbar; + + ImGui::BeginChild("V3KN Panel Crop", panel_crop_size, ImGuiChildFlags_Borders, child_flags); + if (zoom_changed) { + ImGui::SetScrollX(next_scroll_x); + ImGui::SetScrollY(next_scroll_y); + } + const ImVec2 content_min = ImGui::GetWindowContentRegionMin(); + const ImVec2 content_max = ImGui::GetWindowContentRegionMax(); + panel_view_width = std::max(0.f, content_max.x - content_min.x); + panel_view_height = std::max(0.f, content_max.y - content_min.y); + ImGui::Image(panel_preview, ImVec2(panel_preview_w * panel_crop_scale, panel_preview_h * panel_crop_scale)); + panel_crop_scroll_x = ImGui::GetScrollX(); + panel_crop_scroll_y = ImGui::GetScrollY(); + ImGui::EndChild(); + ImGui::PopStyleVar(2); + + ImGui::SetCursorPosY(panel_crop_start_y + panel_crop_size.y + ImGui::GetStyle().ItemSpacing.y); + ImGui::SetCursorPosX(ImGui::GetStyle().WindowPadding.x); + + if (!panel_result_message.empty()) { + const auto msg_color = panel_result_message.find("successfully") != std::string::npos + ? ImVec4(0.3f, 1.f, 0.3f, 1.f) + : ImVec4(1.f, 0.3f, 0.3f, 1.f); + ImGui::TextColored(msg_color, "%s", panel_result_message.c_str()); + } + + ImGui::BeginDisabled(is_panel_uploading); + ImGui::SetCursorPosX((ImGui::GetWindowWidth() / 2.f) - SMALL_BUTTON_SIZE.x - (20.f * SCALE.x)); + if (ImGui::Button(is_panel_uploading ? "Uploading..." : OK_STR.c_str(), SMALL_BUTTON_SIZE)) { + is_panel_uploading = true; + panel_result_message.clear(); + + const auto panel_source = panel_source_data; + const auto source_w = panel_preview_w; + const auto source_h = panel_preview_h; + const auto crop_scroll_x = panel_crop_scroll_x; + const auto crop_scroll_y = panel_crop_scroll_y; + const auto crop_scale = panel_crop_scale; + const auto crop_zoom = panel_crop_zoom; + PanelUploadState panel_state{ &panel_result_message, &panel_upload_success, &is_panel_uploading }; + std::thread(upload_v3kn_panel, &gui, &emuenv, &user_info, panel_source, source_w, source_h, crop_scroll_x, crop_scroll_y, crop_scale, crop_zoom, panel_state).detach(); + } + ImGui::EndDisabled(); + ImGui::SameLine(0, 40.f * SCALE.x); + if (ImGui::Button(CANCEL_STR.c_str(), SMALL_BUTTON_SIZE)) { + selected_panel_path.clear(); + panel_preview = {}; + panel_preview_w = 0; + panel_preview_h = 0; + panel_source_data.clear(); + panel_result_message.clear(); + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + } + + // Change server + static int selected_index = static_cast(account_state.selected_server_index); + ImGui::SetCursorPosX((WINDOW_SIZE.x / 2.f) + (20.f * SCALE.x)); + if (ImGui::Button("Change Server", BUTTON_SIZE)) { + selected_index = static_cast(account_state.selected_server_index); + ImGui::OpenPopup("V3KN Change Server"); + } + + if (ImGui::BeginPopupModal("V3KN Change Server", nullptr, popup_flags)) { + const auto title_popup = "Change V3KN Server"; + ImGui::SetCursorPosX((ImGui::GetWindowWidth() - ImGui::CalcTextSize(title_popup).x) / 2.f); + ImGui::TextColored(GUI_COLOR_TEXT_TITLE, "%s", title_popup); + ImGui::Separator(); + ImGui::Combo("Server", &selected_index, V3KN_SERVER_LIST_NAME.data(), static_cast(V3KN_SERVER_LIST_NAME.size())); + ImGui::SetCursorPosX((ImGui::GetWindowWidth() / 2.f) - SMALL_BUTTON_SIZE.x - (20.f * SCALE.x)); + if (ImGui::Button(OK_STR.c_str(), SMALL_BUTTON_SIZE)) { + if (selected_index != static_cast(account_state.selected_server_index)) { + account_state.selected_server_index = static_cast(selected_index); + user_info = {}; + const auto new_index = account_state.selected_server_index; + if (new_index < V3KN_SERVER_LIST_URL.size()) + user_info.host = V3KN_SERVER_LIST_URL[new_index]; + v3kn::set_v3kn_logged_in(false); + v3kn::save_v3kn_user_info(emuenv); + std::thread([&gui, &emuenv]() { + v3kn::init_v3kn_user_info(gui, emuenv); + }).detach(); + } + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(0, 40.f * SCALE.x); + if (ImGui::Button(CANCEL_STR.c_str(), SMALL_BUTTON_SIZE)) + ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + } + + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + const float close_x_pos = (WINDOW_SIZE.x - BUTTON_SIZE.x) / 2.f; + ImGui::SetCursorPosX(close_x_pos); + if (ImGui::Button("Close", BUTTON_SIZE)) { + gui.configuration_menu.v3kn_dialog = false; + } + + ImGui::End(); + ImGui::PopStyleVar(); +} + +} // namespace gui diff --git a/vita3k/gui/src/vita3k_update.cpp b/vita3k/gui/src/vita3k_update.cpp index 3b62679dc..be8b3bb80 100644 --- a/vita3k/gui/src/vita3k_update.cpp +++ b/vita3k/gui/src/vita3k_update.cpp @@ -194,10 +194,11 @@ bool init_vita3k_update(GuiState &gui) { static std::atomic progress(0); static std::atomic remaining(0); +static std::atomic downloaded(0); static net_utils::ProgressState progress_state{}; static void download_update(const fs::path &base_path) { - progress_state.download = true; + progress_state.canceled = false; progress_state.pause = false; std::thread download([base_path]() { std::string download_continuous_link = "https://github.com/Vita3K/Vita3K/releases/download/continuous/"; @@ -272,9 +273,10 @@ static void download_update(const fs::path &base_path) { // Download latest Vita3K version LOG_INFO("Attempting to download and extract the latest Vita3K version {} in progress...", git_version); - static const auto progress_callback = [](float updated_progress, uint64_t updated_remaining) { + static const auto progress_callback = [](float updated_progress, uint64_t updated_remaining, uint64_t updated_downloaded) -> net_utils::ProgressState * { progress = updated_progress; remaining = updated_remaining; + downloaded = updated_downloaded; return &progress_state; }; @@ -322,7 +324,7 @@ static void download_update(const fs::path &base_path) { std::system(vita3K_batch.c_str()); #endif } else { - if (progress_state.download) { + if (!progress_state.canceled) { LOG_WARN("Download failed, please try again later."); state = NOT_COMPLETE_UPDATE; } else @@ -513,7 +515,7 @@ void draw_vita3k_update(GuiState &gui, EmuEnvState &emuenv) { ImGui::SameLine(0, 40.f * SCALE.x); if (ImGui::Button(common["yes"].c_str(), LARGE_BUTTON_SIZE)) { std::unique_lock lock(progress_state.mutex); - progress_state.download = false; + progress_state.canceled = true; progress_state.pause = false; // Unpause so it gets handled by the callback progress_state.cv.notify_one(); diff --git a/vita3k/lang/include/lang/state.h b/vita3k/lang/include/lang/state.h index 2eaa8526a..7e3672d9b 100644 --- a/vita3k/lang/include/lang/state.h +++ b/vita3k/lang/include/lang/state.h @@ -33,6 +33,7 @@ struct DialogLangState { std::map common = { { "an_error_occurred", "An error occurred.\nError code: {}" }, { "cancel", "Cancel" }, + { "connecting_please_wait", "Connecting...\nPlease wait." }, { "close", "Close" }, { "could_not_load", "Could not load the file." }, { "could_not_save", "Could not save the file." }, @@ -47,7 +48,11 @@ struct DialogLangState { { "select_all", "Select All" }, { "select", "Select" }, { "submit", "Submit" }, - { "yes", "Yes" } + { "yes", "Yes" }, + { "could_not_connect_server", "Could not connect to the server." }, + { "must_sign_in_vita3k_network", "You must sign in to Vita3K Network to use this feature." }, + { "could_not_connect_internet", "Could not connect to the Internet." }, + { "vita3k_network_connection_lost", "The connection to Vita3K Network has been lost." } }; std::map message = { { "load_app_failed", "Failed to load \"{}\".\nCheck vita3k.log to see console output for details.\n1. Have you installed the firmware?\n2. Re-dump your own PS Vita app/game and install it on Vita3K.\n3. If you want to install or boot Vitamin, it is not supported." } }; std::map trophy = { { "preparing_start_app", "Preparing to start the application..." } }; @@ -469,6 +474,26 @@ struct LangState { }; }; LiveArea live_area; + std::map online_storage = { + { "save_data_auto_upload", "Saved Data Auto - Upload" }, + { "copying", "Copying..." }, + { "download", "Download" }, + { "eligible", "Eligible" }, + { "ineligible", "Ineligible" }, + { "location", "Location" }, + { "memory_card", "Memory Card" }, + { "minutes_left", "{} Minutes Left" }, + { "online_storage", "Online Storage" }, + { "overwrite_saved_data", "Do you want to overwrite the saved data ?" }, + { "ps_vita_system", "PS Vita System" }, + { "ps_tv_system", "PS TV System" }, + { "seconds_left", "{} Seconds Left" }, + { "size", "Size" }, + { "trophy_earning", "Trophy Earning" }, + { "updated", "Updated" }, + { "upload", "Upload" }, + { "uploaded", "Uploaded" } + }; std::map overlay = { { "title", "Overlay" }, { "gamepad_overlay", "Gamepad Overlay" }, diff --git a/vita3k/lang/src/lang.cpp b/vita3k/lang/src/lang.cpp index 1efcf8327..f2d3f766e 100644 --- a/vita3k/lang/src/lang.cpp +++ b/vita3k/lang/src/lang.cpp @@ -313,6 +313,9 @@ void init_lang(LangState &lang, EmuEnvState &emuenv) { // Message set_lang_string(emuenv.common_dialog.lang.message, lang_child.child("message")); + // Online Storage + set_lang_string(lang.online_storage, lang_child.child("online_storage")); + // Overlay set_lang_string(lang.overlay, lang_child.child("overlay")); diff --git a/vita3k/util/include/util/hash.h b/vita3k/util/include/util/hash.h index 08ac280e2..cd207bf91 100644 --- a/vita3k/util/include/util/hash.h +++ b/vita3k/util/include/util/hash.h @@ -23,6 +23,8 @@ using Sha256Hash = std::array; +std::string base64_encode(const std::string &input); +std::string derive_password(const std::string_view &password); Sha256Hash sha256(const void *data, size_t size); typedef std::array Sha256HashText; diff --git a/vita3k/util/include/util/net_utils.h b/vita3k/util/include/util/net_utils.h index ee9e8be81..a2665b3cd 100644 --- a/vita3k/util/include/util/net_utils.h +++ b/vita3k/util/include/util/net_utils.h @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -36,7 +37,7 @@ struct parsedUrl { struct ProgressState { std::condition_variable cv; std::mutex mutex; - bool download = true; + bool canceled = false; bool pause = false; }; @@ -51,12 +52,31 @@ struct AssignedAddr { std::string netMask; // Network mask }; -typedef const std::function &ProgressCallback; +typedef const std::function &ProgressCallback; typedef std::pair CallbackData; +struct WebResponse { + int curl_res = 0; + std::string body; +}; + +struct CurlSession { + void *handle = nullptr; + void *headers = nullptr; +}; + bool download_file(const std::string &url, const std::string &output_file_path, ProgressCallback progress_callback = nullptr); +WebResponse download_file_ex(const std::string &url, const std::string &output_file_path, ProgressCallback progress_callback = nullptr, const std::string &token = "", CurlSession *session = nullptr); std::string get_web_response(const std::string &url); +WebResponse get_web_response_ex(const std::string &url, const std::string &token = "", const std::string &post_data = "", CurlSession *session = nullptr, const std::atomic *cancel_flag = nullptr); std::string get_web_regex_result(const std::string &url, const std::regex ®ex); +std::vector get_web_regex_results(const std::string &url, const std::regex ®ex); +WebResponse upload_file(const std::string &url, const std::string &input_file_path, const std::string &token, const std::string &metadata = "", ProgressCallback progress_callback = nullptr, CurlSession *session = nullptr); +WebResponse upload_data(const std::string &url, const std::vector &data, const std::string &filename, const std::string &token, CurlSession *session = nullptr); + +CurlSession init_curl_download_session(const std::string &token, bool is_keep_alive = true); +CurlSession init_curl_upload_session(const std::string &token, bool is_keep_alive = true); +void cleanup_curl_session(CurlSession &session); SceHttpErrorCode parse_url(const std::string &url, parsedUrl &out); const char *int_method_to_char(const int n); diff --git a/vita3k/util/src/hash.cpp b/vita3k/util/src/hash.cpp index 8c66de2c2..cca793d8a 100644 --- a/vita3k/util/src/hash.cpp +++ b/vita3k/util/src/hash.cpp @@ -15,10 +15,67 @@ // with this program; if not, write to the Free Software Foundation, Inc., // 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +#include + #include #include +static const char b64_table[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +std::string base64_encode(const std::string &input) { + const unsigned char *data = reinterpret_cast(input.data()); + size_t len = input.size(); + std::string out; + out.reserve(((len + 2) / 3) * 4); + + for (size_t i = 0; i < len; i += 3) { + unsigned int val = data[i] << 16; + if (i + 1 < len) + val |= data[i + 1] << 8; + if (i + 2 < len) + val |= data[i + 2]; + + out.push_back(b64_table[(val >> 18) & 0x3F]); + out.push_back(b64_table[(val >> 12) & 0x3F]); + out.push_back((i + 1 < len) ? b64_table[(val >> 6) & 0x3F] : '='); + out.push_back((i + 2 < len) ? b64_table[val & 0x3F] : '='); + } + + return out; +} + +std::string derive_password(const std::string_view &password) { + const std::string_view salt_str = "No matter where you store, everyone has access somewhere."; + + // Size of SHA3-256 digest + constexpr int digest_len = 32; // 256 bits + + unsigned char derived_password_digest[digest_len]; + + // PBKDF2-HMAC-SHA3-256 + if (!PKCS5_PBKDF2_HMAC( + password.data(), + static_cast(password.size()), + reinterpret_cast(salt_str.data()), + static_cast(salt_str.size()), + 200000, // number of iterations + EVP_sha3_256(), // SHA3-256 algorithm + digest_len, + derived_password_digest)) { + throw std::runtime_error("PBKDF2 failed"); + } + + // Conversion to hex + std::string derived_password; + derived_password.reserve(digest_len * 2); + for (int i = 0; i < digest_len; i++) { + derived_password += fmt::format("{:02X}", derived_password_digest[i]); + } + + return derived_password; +} + Sha256Hash sha256(const void *data, size_t size) { Sha256Hash hash; diff --git a/vita3k/util/src/net_utils.cpp b/vita3k/util/src/net_utils.cpp index ac3b9c33b..3b1730e79 100644 --- a/vita3k/util/src/net_utils.cpp +++ b/vita3k/util/src/net_utils.cpp @@ -334,41 +334,89 @@ bool socketSetBlocking(int sockfd, bool blocking) { return true; } -std::string get_web_response(const std::string &url) { - auto curl = curl_easy_init(); - if (!curl) - return {}; +static int curl_cancel_callback(void *user_data, curl_off_t, curl_off_t, curl_off_t, curl_off_t) { + const auto *cancel_flag = static_cast *>(user_data); + return (cancel_flag && cancel_flag->load()) ? 1 : 0; +} + +WebResponse get_web_response_ex(const std::string &url, const std::string &token, const std::string &post_data, CurlSession *session, const std::atomic *cancel_flag) { + WebResponse web_response{}; + + const bool has_session_given = session; + + CurlSession local_session; + + if (!has_session_given) { + local_session = init_curl_download_session(token, false); + if (!local_session.handle) { + web_response.curl_res = CURLE_FAILED_INIT; + return web_response; + } + session = &local_session; + } + + CURL *curl = static_cast(session->handle); + curl_slist *headers = static_cast(session->headers); curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); - curl_easy_setopt(curl, CURLOPT_USERAGENT, "Vita3K Emulator"); - curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, true); // Follow redirects - curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 1L); + if (cancel_flag) { + curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L); + curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, curl_cancel_callback); + curl_easy_setopt(curl, CURLOPT_XFERINFODATA, cancel_flag); + } else { + curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 1L); + } #ifdef __ANDROID__ curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L); #endif - std::string response_string; + if (!post_data.empty()) { + curl_easy_setopt(curl, CURLOPT_POST, 1L); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, post_data.c_str()); + curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, post_data.size()); + } + const auto writeFunc = +[](void *ptr, size_t size, size_t nmemb, std::string *data) { data->append((char *)ptr, size * nmemb); return size * nmemb; }; curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeFunc); - curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response_string); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &web_response.body); + + if (headers) + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + + web_response.curl_res = curl_easy_perform(curl); + long response_code; - curl_easy_perform(curl); curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &response_code); - curl_easy_cleanup(curl); + if (!has_session_given) + cleanup_curl_session(local_session); if (response_code / 100 == 2) - return response_string; + return web_response; - return {}; + web_response.body.clear(); + return web_response; +} + +std::string get_web_response(const std::string &url) { + const auto response = get_web_response_ex(url, ""); + return response.body; } std::string get_web_regex_result(const std::string &url, const std::regex ®ex) { - std::string result; + const auto result = get_web_regex_results(url, regex); + if (result.empty()) + return {}; + + return result.back(); +} + +std::vector get_web_regex_results(const std::string &url, const std::regex ®ex) { + std::vector results; // Get the response of the web const auto response = get_web_response(url); @@ -378,12 +426,13 @@ std::string get_web_regex_result(const std::string &url, const std::regex ®ex std::smatch match; // Check if the response matches the regex if (std::regex_search(response, match, regex)) { - result = match[1]; + for (size_t i = 1; i < match.size(); ++i) + results.push_back(match[i].str()); } else LOG_ERROR("No success found regex: {}", response); } - return result; + return results; } static uint64_t get_current_time_ms() { @@ -395,56 +444,85 @@ static size_t write_data(void *ptr, size_t size, size_t nmemb, void *stream) { return written; } -typedef int (*CurlDownloadCallback)(void *c, long t, long d); +static int curl_callback(void *user_data, curl_off_t dltotal, curl_off_t dlnow, curl_off_t ultotal, curl_off_t ulnow) { + auto *data = (CallbackData *)user_data; -bool download_file(const std::string &url, const std::string &output_file_path, ProgressCallback progress_callback) { - CURL *curl_download = curl_easy_init(); - if (!curl_download) - return false; + // Detect mode (download/upload) + const bool is_download = (dltotal > 0); + const bool is_upload = (ultotal > 0); - CurlDownloadCallback curl_callback = +[](void *user_data, long total_bytes, long downloaded_bytes) { - const auto data = (CallbackData *)user_data; - - if ((total_bytes == 0) || (downloaded_bytes == 0)) - return 0; // Ignore if we dont have the total yet or if we haven't downloaded anything - - if (!data->second) - return 0; - - // Calculate progress percentage - const auto total_bytes_downloaded = static_cast(downloaded_bytes + data->first.bytes_already_downloaded); - const auto file_size = total_bytes + data->first.bytes_already_downloaded; - const auto progress_percent = static_cast(total_bytes_downloaded) / static_cast(file_size) * 100.0f; - - // Calculate elapsed time - const auto current_time = get_current_time_ms(); - const auto elapsed_time_ms = std::difftime(current_time, data->first.time); - - // Calculate remaining time in seconds - const auto remaining_bytes = static_cast(total_bytes - downloaded_bytes); - const auto remaining_time = static_cast((remaining_bytes / downloaded_bytes) * elapsed_time_ms) / 1000; - - ProgressState *callback_result = data->second(progress_percent, remaining_time); - - std::unique_lock lock(callback_result->mutex); - - // Store the current pause state - const auto pause = callback_result->pause; - - // Wait until the pause state becomes false (unpaused) - callback_result->cv.wait(lock, [&]() { - return !callback_result->pause; - }); - - // When coming out of pause, add the time spent to the start time to keep the time consistent - if (pause) - data->first.time += std::difftime(get_current_time_ms(), current_time); - - if (!callback_result->download) { - return 1; // Returning anything that's not 0 aborts the request - } + // Skip if curl doesn't have info yet + if (!is_download && !is_upload) return 0; - }; + + if (!data->second) + return 0; + + float progress = 0.f; + double bytes_done = 0; + double bytes_total = 0; + + if (is_download) { + bytes_done = static_cast(dlnow); + bytes_total = static_cast(dltotal); + const auto total_bytes_done = static_cast(dlnow + data->first.bytes_already_downloaded); + const auto file_size = static_cast(dltotal + data->first.bytes_already_downloaded); + progress = static_cast(total_bytes_done) / (static_cast(file_size)); + } else if (is_upload) { + bytes_done = static_cast(ulnow); + bytes_total = static_cast(ultotal); + progress = static_cast(bytes_done) / static_cast(bytes_total); + } + + if (bytes_done == 0) + return 0; + + // Calculate elapsed time + const auto current_time = get_current_time_ms(); + const auto elapsed_time_ms = std::difftime(current_time, data->first.time); + + // Calculate remaining time in seconds + const auto remaining_bytes = static_cast(bytes_total - bytes_done); + const uint64_t remaining_time = static_cast((remaining_bytes / (double)bytes_done) * elapsed_time_ms) / 1000; + + // Callback UI + ProgressState *callback_result = data->second(progress, remaining_time, bytes_done); + + std::unique_lock lock(callback_result->mutex); + + const bool was_paused = callback_result->pause; + + callback_result->cv.wait(lock, [&]() { + return !callback_result->pause; + }); + + if (was_paused) + data->first.time += std::difftime(get_current_time_ms(), current_time); + + if (callback_result->canceled) + return 1; + + return 0; +} + +WebResponse download_file_ex(const std::string &url, const std::string &output_file_path, ProgressCallback progress_callback, const std::string &token, CurlSession *session) { + WebResponse web_response{}; + + CurlSession local_session; + + const bool has_session_given = session; + + if (!has_session_given) { + local_session = init_curl_download_session(token); + if (!local_session.handle) { + web_response.curl_res = CURLE_FAILED_INIT; + return web_response; + } + session = &local_session; + } + + CURL *curl_download = static_cast(session->handle); + curl_slist *headers = static_cast(session->headers); const auto start_time = get_current_time_ms(); const uint64_t bytes_already_downloaded = fs::exists(output_file_path) ? fs::file_size(output_file_path) : 0; @@ -456,33 +534,232 @@ bool download_file(const std::string &url, const std::string &output_file_path, curl_easy_setopt(curl_download, CURLOPT_RESUME_FROM_LARGE, bytes_already_downloaded); curl_easy_setopt(curl_download, CURLOPT_XFERINFODATA, &callbackData); curl_easy_setopt(curl_download, CURLOPT_XFERINFOFUNCTION, curl_callback); - curl_easy_setopt(curl_download, CURLOPT_FOLLOWLOCATION, true); // Follow redirects -#ifdef __ANDROID__ - curl_easy_setopt(curl_download, CURLOPT_SSL_VERIFYPEER, 0L); -#endif + if (headers) + curl_easy_setopt(curl_download, CURLOPT_HTTPHEADER, headers); auto fp = fopen(output_file_path.c_str(), "ab"); if (!fp) { LOG_CRITICAL("Could not fopen file {}", output_file_path); - curl_easy_cleanup(curl_download); + if (!has_session_given) + cleanup_curl_session(local_session); if (fs::exists(output_file_path)) fs::remove(output_file_path); - return false; + web_response.curl_res = CURLE_FAILED_INIT; + return web_response; } curl_easy_setopt(curl_download, CURLOPT_WRITEDATA, fp); - int res = curl_easy_perform(curl_download); + web_response.curl_res = curl_easy_perform(curl_download); + + long http_code = 0; + curl_easy_getinfo(curl_download, CURLINFO_RESPONSE_CODE, &http_code); + if ((http_code != 200) && (http_code != 206)) { + LOG_WARN("Download failed with HTTP code: {}", http_code); + fclose(fp); + if (!has_session_given) + cleanup_curl_session(local_session); + if (fs::exists(output_file_path)) + fs::remove(output_file_path); + web_response.curl_res = CURLE_HTTP_RETURNED_ERROR; + return web_response; + } fclose(fp); - curl_easy_cleanup(curl_download); - if (progress_callback) - progress_callback(0, 0); - if (res == CURLE_ABORTED_BY_CALLBACK) - LOG_CRITICAL("Aborted update by user"); + if (!has_session_given) + cleanup_curl_session(local_session); - return res == CURLE_OK; + if (web_response.curl_res == CURLE_ABORTED_BY_CALLBACK) + LOG_WARN("Aborted by user"); + + return web_response; +} + +bool download_file(const std::string &url, const std::string &output_file_path, ProgressCallback progress_callback) { + return download_file_ex(url, output_file_path, progress_callback).curl_res == CURLE_OK; +} + +static size_t write_string(void *ptr, size_t size, size_t nmemb, void *userdata) { + std::stringstream *ss = static_cast(userdata); + ss->write(static_cast(ptr), size * nmemb); + return size * nmemb; +} + +static WebResponse upload_impl(const std::string &url, const char *data, size_t data_size, const std::string &filename, const std::string &token, const std::string &metadata, ProgressCallback progress_callback, CurlSession *session) { + WebResponse response{}; + + const bool has_session_given = session; + + CurlSession local_session; + + if (!has_session_given) { + local_session = init_curl_upload_session(token, false); + if (!local_session.handle) { + response.curl_res = CURLE_FAILED_INIT; + return response; + } + session = &local_session; + } + + CURL *curl_upload = static_cast(session->handle); + curl_slist *headers = static_cast(session->headers); + + curl_easy_setopt(curl_upload, CURLOPT_URL, url.c_str()); + + const auto start_time = get_current_time_ms(); + const auto callbackData = CallbackData({ start_time }, progress_callback); + curl_easy_setopt(curl_upload, CURLOPT_NOPROGRESS, progress_callback ? false : true); + if (progress_callback) { + curl_easy_setopt(curl_upload, CURLOPT_XFERINFODATA, &callbackData); + curl_easy_setopt(curl_upload, CURLOPT_XFERINFOFUNCTION, curl_callback); + } + + std::stringstream response_stream; + curl_easy_setopt(curl_upload, CURLOPT_WRITEFUNCTION, write_string); + curl_easy_setopt(curl_upload, CURLOPT_WRITEDATA, &response_stream); + + if (headers) + curl_easy_setopt(curl_upload, CURLOPT_HTTPHEADER, headers); + + curl_mime *mime = curl_mime_init(curl_upload); + curl_mimepart *part = nullptr; + + part = curl_mime_addpart(mime); + curl_mime_name(part, "file"); + curl_mime_filename(part, filename.c_str()); + curl_mime_data(part, data, data_size); + + if (!metadata.empty()) { + part = curl_mime_addpart(mime); + curl_mime_name(part, "xml"); + curl_mime_data(part, metadata.c_str(), CURL_ZERO_TERMINATED); + } + + curl_easy_setopt(curl_upload, CURLOPT_MIMEPOST, mime); + + response.curl_res = curl_easy_perform(curl_upload); + response.body = response_stream.str(); + + curl_mime_free(mime); + + if (!has_session_given) + cleanup_curl_session(local_session); + + return response; +} + +WebResponse upload_file(const std::string &url, const std::string &input_file_path, const std::string &token, const std::string &metadata, ProgressCallback progress_callback, CurlSession *session) { + WebResponse response{}; + + std::ifstream file(input_file_path, std::ios::binary); + if (!file) { + LOG_CRITICAL("Could not open file {}", input_file_path); + response.curl_res = CURLE_READ_ERROR; + return response; + } + + std::vector file_buffer((std::istreambuf_iterator(file)), + std::istreambuf_iterator()); + + return upload_impl(url, file_buffer.data(), file_buffer.size(), fs::path(input_file_path).filename().string(), token, metadata, progress_callback, session); +} + +WebResponse upload_data(const std::string &url, const std::vector &data, const std::string &filename, const std::string &token, CurlSession *session) { + WebResponse response{}; + + if (data.empty()) { + response.curl_res = CURLE_READ_ERROR; + return response; + } + + return upload_impl(url, reinterpret_cast(data.data()), data.size(), filename, token, "", nullptr, session); +} + +CurlSession init_curl_download_session(const std::string &token, bool is_keep_alive) { + CurlSession session; + session.handle = curl_easy_init(); + + if (!session.handle) + return session; + + CURL *curl = static_cast(session.handle); + + std::string os; +#if defined(_WIN32) + os = "Windows"; +#elif defined(__ANDROID__) + os = "Android"; +#elif defined(__linux__) + os = "Linux"; +#elif defined(__APPLE__) + os = "macOS"; +#else + os = "Unknown OS"; +#endif + + curl_easy_setopt(curl, CURLOPT_USERAGENT, ("Vita3K Emulator on (" + os + ")").c_str()); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, true); + if (is_keep_alive) { + curl_easy_setopt(curl, CURLOPT_TCP_KEEPALIVE, 1L); + curl_easy_setopt(curl, CURLOPT_TCP_KEEPIDLE, 120L); + curl_easy_setopt(curl, CURLOPT_TCP_KEEPINTVL, 60L); + } + +#ifdef __ANDROID__ + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L); +#endif + + if (!token.empty()) { + curl_slist *headers = nullptr; + std::string auth_header = "Authorization: Bearer " + token; + headers = curl_slist_append(headers, auth_header.c_str()); + session.headers = headers; + } + + return session; +} + +CurlSession init_curl_upload_session(const std::string &token, bool is_keep_alive) { + CurlSession session; + session.handle = curl_easy_init(); + + if (!session.handle) + return session; + + CURL *curl = static_cast(session.handle); + + curl_easy_setopt(curl, CURLOPT_USERAGENT, "Vita3K Emulator"); + + if (is_keep_alive) { + curl_easy_setopt(curl, CURLOPT_TCP_KEEPALIVE, 1L); + curl_easy_setopt(curl, CURLOPT_TCP_KEEPIDLE, 120L); + curl_easy_setopt(curl, CURLOPT_TCP_KEEPINTVL, 60L); + } + +#ifdef __ANDROID__ + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L); +#endif + + if (!token.empty()) { + curl_slist *headers = nullptr; + std::string auth_header = "Authorization: Bearer " + token; + headers = curl_slist_append(headers, auth_header.c_str()); + session.headers = headers; + } + + return session; +} + +void cleanup_curl_session(CurlSession &session) { + if (session.headers) { + curl_slist_free_all(static_cast(session.headers)); + session.headers = nullptr; + } + if (session.handle) { + curl_easy_cleanup(static_cast(session.handle)); + session.handle = nullptr; + } } std::vector get_all_assigned_addrs() { diff --git a/vita3k/v3kn/CMakeLists.txt b/vita3k/v3kn/CMakeLists.txt new file mode 100644 index 000000000..c80bf86ac --- /dev/null +++ b/vita3k/v3kn/CMakeLists.txt @@ -0,0 +1,13 @@ +add_library( + v3kn + STATIC + include/v3kn/account.h + include/v3kn/state.h + include/v3kn/storage.h + src/account.cpp + src/storage.cpp +) + +target_include_directories(v3kn PUBLIC include) +target_link_libraries(v3kn PUBLIC gui emuenv util packages) +target_link_libraries(v3kn PRIVATE pugixml::pugixml miniz stb) diff --git a/vita3k/v3kn/include/v3kn/account.h b/vita3k/v3kn/include/v3kn/account.h new file mode 100644 index 000000000..b6a0888bb --- /dev/null +++ b/vita3k/v3kn/include/v3kn/account.h @@ -0,0 +1,71 @@ +// Vita3K emulator project +// Copyright (C) 2026 Vita3K team +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, write to the Free Software Foundation, Inc., +// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +#pragma once + +#include + +#include +#include +#include +#include +#include + +struct EmuEnvState; +struct GuiState; + +inline const std::vector V3KN_SERVER_LIST_NAME = { "Official V3KN Server", "Local V3KN Test Server" }; +inline const std::vector V3KN_SERVER_LIST_URL = { "np.vita3k.org", "localhost:3000" }; +inline const std::map V3KN_SERVER_PROTOCOL = { + { "np.vita3k.org", "https://" }, + { "localhost:3000", "http://" }, +}; + +struct UserInfo { + std::string host = V3KN_SERVER_LIST_URL[0]; + std::string online_id; + std::string password; + std::string token; + time_t created_at = 0; + uint64_t quota_used = 0; + uint64_t quota_total = 0; +}; + +namespace v3kn { + +void set_v3kn_logged_in(bool logged_in); +bool is_v3kn_logged_in(); +bool wait_for_v3kn_login(uint32_t timeout_ms); +void handle_v3kn_status(EmuEnvState &emuenv, const net_utils::WebResponse &response); +std::string get_v3kn_error_message(EmuEnvState &emuenv, const net_utils::WebResponse &response); +std::string get_v3kn_server_url(const std::string &host, const std::string &path); + +net_utils::WebResponse v3kn_create(const UserInfo &user_info, const std::string &online_id, const std::string &password); +net_utils::WebResponse v3kn_login(const UserInfo &user_info, const std::string &online_id, const std::string &password); +net_utils::WebResponse v3kn_delete(const UserInfo &user_info, const std::string &password); +net_utils::WebResponse v3kn_change_online_id(const UserInfo &user_info, const std::string &new_online_id); +net_utils::WebResponse v3kn_change_password(const UserInfo &user_info, const std::string &old_password, const std::string &new_password); +net_utils::WebResponse v3kn_change_about_me(const UserInfo &user_info, const std::string &about_me); +net_utils::WebResponse v3kn_avatar_upload(const UserInfo &user_info, const std::string &file_path); +net_utils::WebResponse v3kn_avatar_upload(const UserInfo &user_info, const std::vector &png_data); +net_utils::WebResponse v3kn_avatar_download(const UserInfo &user_info, const std::string &target_online_id, net_utils::CurlSession *session = nullptr); +net_utils::WebResponse v3kn_panel_upload(const UserInfo &user_info, const std::vector &png_data); +net_utils::WebResponse v3kn_panel_download(const UserInfo &user_info, const std::string &target_online_id, net_utils::CurlSession *session = nullptr); +void save_v3kn_user_info(EmuEnvState &emuenv); +void init_v3kn_user_info(GuiState &gui, EmuEnvState &emuenv); + +} // namespace v3kn diff --git a/vita3k/v3kn/include/v3kn/state.h b/vita3k/v3kn/include/v3kn/state.h new file mode 100644 index 000000000..908f85853 --- /dev/null +++ b/vita3k/v3kn/include/v3kn/state.h @@ -0,0 +1,59 @@ +// Vita3K emulator project +// Copyright (C) 2026 Vita3K team +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, write to the Free Software Foundation, Inc., +// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +#pragma once + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +struct AccountState { + UserInfo user_info; + std::atomic is_logged_in{ false }; + uint32_t selected_server_index = 0; +}; + +struct StorageState { + OnlineStorageState online_storage_state = ONLINE_STORAGE_SELECT; + std::map savedata_info; + std::atomic progress{ 0 }; + std::atomic remaining{ 0 }; + std::atomic bytes_done{ 0 }; + net_utils::ProgressState progress_state{}; + + uint64_t progress_done_timestamp = 0; + uint64_t please_wait_timestamp = 0; + std::atomic please_wait_done{ false }; + std::mutex mutex_progress; + std::condition_variable cv_progress; +}; + +struct V3KNState { + AccountState account_state; + StorageState storage_state; +}; diff --git a/vita3k/v3kn/include/v3kn/storage.h b/vita3k/v3kn/include/v3kn/storage.h new file mode 100644 index 000000000..f7c3ec354 --- /dev/null +++ b/vita3k/v3kn/include/v3kn/storage.h @@ -0,0 +1,83 @@ +// Vita3K emulator project +// Copyright (C) 2026 Vita3K team +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, write to the Free Software Foundation, Inc., +// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +#pragma once + +#include + +#include +#include +#include +#include +#include + +struct EmuEnvState; +struct GuiState; + +struct SaveEntry { + std::string rel_path; + time_t last_updated; +}; + +struct SaveDataInfo { + time_t last_updated; + time_t last_uploaded; + uint64_t total_size; + std::vector files; +}; + +struct TrophySync { + time_t last_updated; + std::vector unlocked_ids; + uint32_t platinum = 0; + uint32_t gold = 0; + uint32_t silver = 0; + uint32_t bronze = 0; + uint32_t progress = 0; +}; + +struct TrophyProgressInfo { + std::map progress = {}; + std::map> unlocked_ids = {}; + std::map> unlocked_type_count = {}; + time_t last_updated; +}; + +enum SaveDataType { + SAVE_DATA_TYPE_CLOUD = 0, + SAVE_DATA_TYPE_LOCAL = 1, +}; + +enum OnlineStorageState { + ONLINE_STORAGE_SELECT, + ONLINE_STORAGE_UPLOAD, + ONLINE_STORAGE_DOWNLOAD, + ONLINE_STORAGE_COPYING, +}; + +bool load_and_compute_trophy_stats(EmuEnvState &emuenv, np::trophy::Context &context, const std::string &np_com_id, TrophyProgressInfo &trophy_progress_info); + +namespace v3kn { +void start_trophy_sync(GuiState &gui, EmuEnvState &emuenv, std::map &trophy_progress, std::atomic &sync_progress, std::atomic &sync_cancel, std::vector &sync_downloaded); +void start_trophy_auto_sync(GuiState &gui, EmuEnvState &emuenv); +void add_pending_trophy_sync(EmuEnvState &emuenv, const std::string &np_com_id); +void download_savedata(GuiState &gui, EmuEnvState &emuenv, const std::string &titleid); +void upload_savedata(GuiState &gui, EmuEnvState &emuenv, const std::string &titleid); +void open_online_storage(GuiState &gui, EmuEnvState &emuenv, const std::string &titleid); +void sync_game_stitle(EmuEnvState &emuenv); + +} // namespace v3kn diff --git a/vita3k/v3kn/src/account.cpp b/vita3k/v3kn/src/account.cpp new file mode 100644 index 000000000..188aad661 --- /dev/null +++ b/vita3k/v3kn/src/account.cpp @@ -0,0 +1,386 @@ +// Vita3K emulator project +// Copyright (C) 2026 Vita3K team +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, write to the Free Software Foundation, Inc., +// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +namespace v3kn { + +static std::atomic is_v3kn_user_logged_in(false); +static std::condition_variable v3kn_login_cv; +static std::mutex v3kn_login_mutex; +static bool v3kn_login_init_completed = false; +constexpr char V3KN_VERSION[] = "0.01"; + +static fs::path get_v3kn_avatar_cache_path(const EmuEnvState &emuenv, const UserInfo &user_info, const std::string &online_id) { + return emuenv.cache_path / "v3kn" / user_info.host / online_id / "avatar.png"; +} + +static bool load_v3kn_avatar_texture(GuiState &gui, const fs::path &avatar_path) { + if (!fs::exists(avatar_path)) + return false; + + int width = 0; + int height = 0; + stbi_uc *data = stbi_load(avatar_path.string().c_str(), &width, &height, nullptr, STBI_rgb_alpha); + if (!data) + return false; + + gui.v3kn_avatar = ImGui_Texture(gui.imgui_state.get(), data, width, height); + stbi_image_free(data); + return true; +} + +static void init_v3kn_avatar(GuiState &gui, EmuEnvState &emuenv, const UserInfo &user_info) { + gui.v3kn_avatar = {}; + + if (user_info.online_id.empty() || user_info.token.empty()) + return; + + const fs::path avatar_cache_path = get_v3kn_avatar_cache_path(emuenv, user_info, user_info.online_id); + if (load_v3kn_avatar_texture(gui, avatar_cache_path)) + return; + + fs::create_directories(avatar_cache_path.parent_path()); + + const auto res = v3kn_avatar_download(user_info, user_info.online_id); + handle_v3kn_status(emuenv, res); + if (!res.body.starts_with("OK") || res.body.empty()) + return; + + fs_utils::dump_data(avatar_cache_path, res.body.data(), static_cast(res.body.size())); + load_v3kn_avatar_texture(gui, avatar_cache_path); +} + +static void set_v3kn_login_init_completed(bool completed) { + { + std::lock_guard lock(v3kn_login_mutex); + v3kn_login_init_completed = completed; + } + + v3kn_login_cv.notify_all(); +} + +void set_v3kn_logged_in(bool logged_in) { + is_v3kn_user_logged_in.store(logged_in); + v3kn_login_cv.notify_all(); +} + +bool is_v3kn_logged_in() { + return is_v3kn_user_logged_in.load(); +} + +bool wait_for_v3kn_login(uint32_t timeout_ms) { + std::unique_lock lock(v3kn_login_mutex); + v3kn_login_cv.wait_for(lock, std::chrono::milliseconds(timeout_ms), [] { + return is_v3kn_user_logged_in.load() || v3kn_login_init_completed; + }); + + return is_v3kn_user_logged_in.load(); +} + +std::string get_v3kn_error_message(EmuEnvState &emuenv, const net_utils::WebResponse &response) { + std::string error_msg; + if (response.body.starts_with("ERR:") || !response.body.starts_with("OK:")) { + if (response.body == "ERR:MissingToken") + error_msg = emuenv.common_dialog.lang.common["must_sign_in_vita3k_network"]; + else if (response.body == "ERR:InvalidToken") + error_msg = "Your session on Vita3K Network has expired. Please sign in again."; + else if (response.body == "ERR:MissingTitleID") + error_msg = "V3KN get error Missing title ID."; + else if (response.body == "ERR:UserExists") + error_msg = "V3KN create account error User already exists."; + else if (response.body == "ERR:MissingPassword") + error_msg = "V3KN get error Missing password."; + else if (response.body == "ERR:InvalidOnlineID") + error_msg = "Invalid Online ID for V3KN.\nMake sure it follows the required format."; + else if (response.body == "ERR:InvalidPassword") + error_msg = "V3KN login/change password/delete error Invalid password."; + else if (response.body == "ERR:ServerError") + error_msg = "V3KN server error."; + else if (response.body == "ERR:QuotaExceeded") + error_msg = "V3KN error Quota exceeded."; + else if (response.body == "ERR:UserNotFound") + error_msg = "V3KN login error User not found."; + else if (response.body == "ERR:InvalidType") + error_msg = "V3KN error Invalid type of download file."; + else if (response.body == "ERR:InvalidID") + error_msg = "V3KN error Invalid ID of App."; + else if (response.body == "ERR:FileNotFound") + error_msg = "V3KN error File not found."; + else if (response.body == "ERR:MissingNewPassword") + error_msg = "V3KN change password error Missing new password."; + else if (response.body == "ERR:MissingOldPassword") + error_msg = "V3KN change password error Missing old password."; + else if (response.body == "ERR:SamePassword") + error_msg = "V3KN change password error New password cannot be same as the old password."; + else if (response.body == "ERR:MissingTargetOnlineId") + error_msg = "V3KN error Missing target online ID."; + else if (response.body == "ERR:AlreadyFriends") + error_msg = "V3KN error Already friends with this user."; + else if (response.body == "ERR:RequestAlreadySent") + error_msg = "V3KN error Friend request already sent to this user."; + else if (response.body == "ERR:NoRequestFound") + error_msg = "V3KN error No friend request found from this user."; + else if (response.body == "ERR:CannotAddYourself") + error_msg = "V3KN error Cannot add yourself as a friend."; + else if (response.body == "ERR:AboutMeTooLong") + error_msg = "V3KN error 'About Me' text is too long."; + else if (response.body == "ERR:OutdatedClient") + error_msg = "Outdated client version. Please update Vita3K to the latest version."; + else if (response.body.empty()) { + if (response.curl_res == 3) + error_msg = "V3KN get error Malformed URL, check server URL."; + else if (response.curl_res == 6) + error_msg = emuenv.common_dialog.lang.common["could_not_connect_internet"]; + else if (response.curl_res == 7) + error_msg = emuenv.common_dialog.lang.common["could_not_connect_server"]; + else if ((response.curl_res == 55) || (response.curl_res == 56)) + error_msg = emuenv.common_dialog.lang.common["vita3k_network_connection_lost"]; + else + error_msg = fmt::format("V3KN get Unexpected server error: {}", response.curl_res); + } else { + error_msg = fmt::format("V3KN: Unexpected server response: {}", response.body); + } + } + + return error_msg; +} + +std::string get_v3kn_server_url(const std::string &host, const std::string &path) { + if (V3KN_SERVER_PROTOCOL.find(host) == V3KN_SERVER_PROTOCOL.end()) { + LOG_WARN("V3KN: Unknown server host {}, defaulting to https", host); + return fmt::format("https://{}/{}", host, path); + } + + return V3KN_SERVER_PROTOCOL.at(host) + host + "/" + path; +} + +void handle_v3kn_status(EmuEnvState &emuenv, const net_utils::WebResponse &response) { + if (response.body.starts_with("OK:")) { + set_v3kn_logged_in(true); + } else if (response.body.starts_with("ERR:")) { + if (response.body == "ERR:InvalidToken") { + set_v3kn_logged_in(false); + emuenv.v3kn.account_state.user_info.token.clear(); + save_v3kn_user_info(emuenv); + } + } else if (!response.body.empty() && response.curl_res == 0) { + set_v3kn_logged_in(true); + } +} + +net_utils::WebResponse v3kn_create(const UserInfo &user_info, const std::string &online_id, const std::string &password) { + const std::string url = get_v3kn_server_url(user_info.host, "v3kn/create"); + const std::string post_data = fmt::format("version={}&online_id={}&password={}", V3KN_VERSION, online_id, password); + + const auto res = net_utils::get_web_response_ex(url, "", post_data); + + return res; +} + +net_utils::WebResponse v3kn_delete(const UserInfo &user_info, const std::string &password) { + const std::string url = get_v3kn_server_url(user_info.host, "v3kn/delete"); + const std::string post_data = fmt::format("password={}", password); + const auto res = net_utils::get_web_response_ex(url, user_info.token, post_data); + + return res; +} + +net_utils::WebResponse v3kn_login(const UserInfo &user_info, const std::string &online_id, const std::string &password) { + const std::string url = get_v3kn_server_url(user_info.host, "v3kn/login"); + const std::string post_data = fmt::format("version={}&online_id={}&password={}", V3KN_VERSION, online_id, password); + + const auto res = net_utils::get_web_response_ex(url, "", post_data); + + return res; +} + +net_utils::WebResponse v3kn_change_online_id(const UserInfo &user_info, const std::string &new_online_id) { + const std::string url = get_v3kn_server_url(user_info.host, "v3kn/change_online_id"); + const std::string post_data = fmt::format("new_online_id={}", new_online_id); + const auto res = net_utils::get_web_response_ex(url, user_info.token, post_data); + + return res; +} + +net_utils::WebResponse v3kn_change_password(const UserInfo &user_info, const std::string &old_password, const std::string &new_password) { + const std::string url = get_v3kn_server_url(user_info.host, "v3kn/change_password"); + const std::string post_data = fmt::format("old_password={}&new_password={}", old_password, new_password); + const auto res = net_utils::get_web_response_ex(url, user_info.token, post_data); + + return res; +} + +net_utils::WebResponse v3kn_change_about_me(const UserInfo &user_info, const std::string &about_me) { + const std::string url = get_v3kn_server_url(user_info.host, "v3kn/change_about_me"); + const std::string post_data = fmt::format("about_me={}", about_me); + const auto res = net_utils::get_web_response_ex(url, user_info.token, post_data); + + return res; +} + +net_utils::WebResponse v3kn_avatar_upload(const UserInfo &user_info, const std::string &file_path) { + const std::string url = get_v3kn_server_url(user_info.host, "v3kn/avatar"); + const auto res = net_utils::upload_file(url, file_path, user_info.token, ""); + + return res; +} + +net_utils::WebResponse v3kn_avatar_upload(const UserInfo &user_info, const std::vector &png_data) { + const std::string url = get_v3kn_server_url(user_info.host, "v3kn/avatar"); + const auto res = net_utils::upload_data(url, png_data, "Avatar.png", user_info.token); + return res; +} + +net_utils::WebResponse v3kn_avatar_download(const UserInfo &user_info, const std::string &target_online_id, net_utils::CurlSession *session) { + const std::string path = fmt::format("v3kn/avatar?target_online_id={}", target_online_id); + const std::string url = get_v3kn_server_url(user_info.host, path); + const auto res = net_utils::get_web_response_ex(url, user_info.token, "", session); + return res; +} + +net_utils::WebResponse v3kn_panel_upload(const UserInfo &user_info, const std::vector &png_data) { + const std::string url = get_v3kn_server_url(user_info.host, "v3kn/panel"); + const auto res = net_utils::upload_data(url, png_data, "Panel.png", user_info.token); + return res; +} + +net_utils::WebResponse v3kn_panel_download(const UserInfo &user_info, const std::string &target_online_id, net_utils::CurlSession *session) { + const std::string path = fmt::format("v3kn/panel?target_online_id={}", target_online_id); + const std::string url = get_v3kn_server_url(user_info.host, path); + const auto res = net_utils::get_web_response_ex(url, user_info.token, "", session); + return res; +} + +void save_v3kn_user_info(EmuEnvState &emuenv) { + const fs::path user_info_path{ emuenv.pref_path / "ux0/user" / emuenv.io.user_id / "v3kn.xml" }; + auto &user_info = emuenv.v3kn.account_state.user_info; + pugi::xml_document doc; + pugi::xml_node user_node = doc.append_child("user"); + user_node.append_attribute("version").set_value(1); + user_node.append_child("host").text().set(user_info.host.c_str()); + user_node.append_child("online_id").text().set(user_info.online_id.c_str()); + user_node.append_child("password").text().set(user_info.password.c_str()); + user_node.append_child("token").text().set(user_info.token.c_str()); + doc.save_file(user_info_path.string().c_str()); +} + +void init_v3kn_user_info(GuiState &gui, EmuEnvState &emuenv) { + auto &account_state = emuenv.v3kn.account_state; + auto &user_info = account_state.user_info; + user_info = {}; + account_state.selected_server_index = 0; + set_v3kn_logged_in(false); + set_v3kn_login_init_completed(false); + + const fs::path user_info_path{ emuenv.pref_path / "ux0/user" / emuenv.io.user_id / "v3kn.xml" }; + pugi::xml_document doc; + pugi::xml_parse_result result = doc.load_file(user_info_path.string().c_str()); + if (!result) { + set_v3kn_login_init_completed(true); + return; + } + pugi::xml_node user_node = doc.child("user"); + if (!user_node) { + LOG_WARN("V3KN user info: missing node"); + set_v3kn_login_init_completed(true); + return; + } + const auto version = user_node.attribute("version").as_uint(); + if (version != 1) { + LOG_WARN("V3KN user info: unsupported version {}", version); + set_v3kn_login_init_completed(true); + return; + } + + user_info.host = user_node.child("host").text().as_string(V3KN_SERVER_LIST_URL[0]); + user_info.online_id = user_node.child("online_id").text().as_string(); + user_info.password = user_node.child("password").text().as_string(); + user_info.token = user_node.child("token").text().as_string(); + + account_state.selected_server_index = static_cast(vector_utils::find_index(V3KN_SERVER_LIST_URL, user_info.host)); + if (account_state.selected_server_index == static_cast(-1)) + account_state.selected_server_index = 0; + + if (user_info.token.empty()) { + set_v3kn_login_init_completed(true); + return; + } + + const std::string url = get_v3kn_server_url(user_info.host, "v3kn/check?version=" + std::string(V3KN_VERSION)); + const auto res = net_utils::get_web_response_ex(url, user_info.token, ""); + + handle_v3kn_status(emuenv, res); + + if (!res.body.starts_with("OK:")) { + gui.info_message.function = "v3kn_check"; + gui.info_message.title = emuenv.common_dialog.lang.common["error"]; + gui.info_message.level = spdlog::level::err; + gui.info_message.msg = get_v3kn_error_message(emuenv, res); + + set_v3kn_login_init_completed(true); + return; + } + + std::smatch match; + std::regex regex_pattern(R"(OK:Connected:([^:]+):([^:]+):([^:]+):([^]+))"); + if (std::regex_search(res.body, match, regex_pattern) && (match.size() == 5)) { + const std::string current_online_id = match[1].str(); + user_info.created_at = std::strtoull(match[2].str().c_str(), nullptr, 10); + user_info.quota_used = std::strtoull(match[3].str().c_str(), nullptr, 10); + user_info.quota_total = std::strtoull(match[4].str().c_str(), nullptr, 10); + if (current_online_id != user_info.online_id) { + LOG_INFO("V3KN user info: online ID on server ({}) does not match local info ({}), updating local info", current_online_id, user_info.online_id); + user_info.online_id = current_online_id; + save_v3kn_user_info(emuenv); + } + } else { + LOG_WARN("V3KN user info: unexpected server response, connecting again will be required"); + set_v3kn_login_init_completed(true); + return; + } + + set_v3kn_logged_in(true); + init_v3kn_avatar(gui, emuenv, user_info); + + set_v3kn_login_init_completed(true); + LOG_INFO("V3KN user info: successfully logged in as {}", user_info.online_id); +} + +} // namespace v3kn diff --git a/vita3k/v3kn/src/storage.cpp b/vita3k/v3kn/src/storage.cpp new file mode 100644 index 000000000..86d3cb492 --- /dev/null +++ b/vita3k/v3kn/src/storage.cpp @@ -0,0 +1,408 @@ +// Vita3K emulator project +// Copyright (C) 2026 Vita3K team +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 2 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, write to the Free Software Foundation, Inc., +// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace v3kn { + +// Online Storage +static bool get_savedata_info(GuiState &gui, EmuEnvState &emuenv, const std::string &titleid) { + emuenv.v3kn.storage_state.savedata_info.clear(); + + // Get local savedata info + const fs::path save_data_path = { emuenv.pref_path / "ux0/user" / emuenv.io.user_id / "savedata" / titleid }; + if (fs::exists(save_data_path) && !fs::is_empty(save_data_path)) { + auto &save_data_info_local = emuenv.v3kn.storage_state.savedata_info[SAVE_DATA_TYPE_LOCAL]; + uint64_t total_size_local = 0; + time_t global_last_updated = 0; + for (auto &file : fs::recursive_directory_iterator(save_data_path)) { + if (!file.is_regular_file()) + continue; + + const auto file_size = fs::file_size(file.path()); + const auto last_updated = fs::last_write_time(file.path()); + SaveEntry entry{ + .rel_path = fs::relative(file.path(), save_data_path).generic_string(), + .last_updated = last_updated + }; + save_data_info_local.files.push_back(entry); + global_last_updated = std::max(global_last_updated, last_updated); + total_size_local += file_size; + } + save_data_info_local.last_updated = global_last_updated; + save_data_info_local.total_size = total_size_local; + } + + const std::string url = get_v3kn_server_url(emuenv.v3kn.account_state.user_info.host, fmt::format("v3kn/save_info?titleid={}", titleid)); + const auto response = net_utils::get_web_response_ex(url, emuenv.v3kn.account_state.user_info.token); + + // Handle response and update connection status + handle_v3kn_status(emuenv, response); + + if (response.body.starts_with("WARN:")) { + LOG_WARN("V3KN get savedata info: server warning: {}", response.body); + return true; + } else if (response.body.starts_with("ERR:")) { + gui.info_message.title = emuenv.common_dialog.lang.common["error"]; + gui.info_message.level = spdlog::level::err; + gui.info_message.msg = get_v3kn_error_message(emuenv, response); + return false; + } + + pugi::xml_document doc; + pugi::xml_parse_result result = doc.load_string(response.body.c_str()); + if (!result) { + LOG_WARN("V3KN get savedata info: failed to parse XML: {}", result.description()); + return false; + } + + pugi::xml_node savedata_node = doc.child("savedata"); + if (!savedata_node) { + LOG_WARN("V3KN get savedata info: missing node"); + return false; + } + + auto &save_data_info_cloud = emuenv.v3kn.storage_state.savedata_info[SAVE_DATA_TYPE_CLOUD]; + save_data_info_cloud.last_updated = static_cast(savedata_node.attribute("last_updated").as_ullong()); + save_data_info_cloud.last_uploaded = static_cast(savedata_node.attribute("last_uploaded").as_ullong()); + save_data_info_cloud.total_size = savedata_node.attribute("total_size").as_ullong(); + for (pugi::xml_node file_node : savedata_node.children("file")) { + SaveEntry entry{ + .rel_path = file_node.attribute("path").as_string(), + .last_updated = static_cast(file_node.attribute("last_updated").as_ullong()) + }; + save_data_info_cloud.files.push_back(entry); + } + + return true; +} + +static bool create_zip_from_directory(const fs::path &directory, const fs::path &zip_path) { + mz_zip_archive zip; + memset(&zip, 0, sizeof(zip)); + + if (!mz_zip_writer_init_file(&zip, zip_path.string().c_str(), 0)) { + LOG_ERROR("Failed to init zip writer"); + return false; + } + + for (auto &entry : fs::recursive_directory_iterator(directory)) { + if (!entry.is_regular_file()) + continue; + + fs::path file_path = entry.path(); + fs::path rel_path = fs::relative(file_path, directory); + + if (!mz_zip_writer_add_file( + &zip, + rel_path.generic_string().c_str(), // path in the zip + file_path.string().c_str(), // source file + nullptr, + 0, + MZ_BEST_SPEED)) { // fast compression + LOG_ERROR("Failed to add file {}", file_path.string()); + mz_zip_writer_end(&zip); + return false; + } + } + + if (!mz_zip_writer_finalize_archive(&zip)) { + LOG_ERROR("Failed to finalize zip"); + mz_zip_writer_end(&zip); + return false; + } + + mz_zip_writer_end(&zip); + return true; +} + +static bool extract_zip_to_directory(const fs::path &zip_path, const fs::path &output_dir) { + mz_zip_archive zip; + memset(&zip, 0, sizeof(zip)); + + if (!mz_zip_reader_init_file(&zip, zip_path.string().c_str(), 0)) { + LOG_ERROR("Failed to init zip reader"); + return false; + } + + int file_count = (int)mz_zip_reader_get_num_files(&zip); + + for (int i = 0; i < file_count; i++) { + mz_zip_archive_file_stat stat; + if (!mz_zip_reader_file_stat(&zip, i, &stat)) { + LOG_ERROR("Failed to stat file {}", i); + continue; + } + + fs::path out_path = output_dir / stat.m_filename; + + if (stat.m_is_directory) { + fs::create_directories(out_path); + continue; + } + + fs::create_directories(out_path.parent_path()); + + if (!mz_zip_reader_extract_to_file(&zip, i, out_path.string().c_str(), 0)) { + LOG_ERROR("Failed to extract {}", stat.m_filename); + continue; + } + } + + mz_zip_reader_end(&zip); + return true; +} + +static void reset_progress(StorageState &storage_state) { + storage_state.progress = 0.f; + storage_state.bytes_done = 0; + storage_state.remaining = 0; + storage_state.progress_done_timestamp = 0; + storage_state.please_wait_timestamp = 0; + storage_state.please_wait_done.store(false); + storage_state.progress_state.canceled = false; +} + +void download_savedata(GuiState &gui, EmuEnvState &emuenv, const std::string &titleid) { + const fs::path savedata_path = emuenv.pref_path / "ux0/user" / emuenv.io.user_id / "savedata" / titleid; + const fs::path savedata_cache_path = emuenv.cache_path / fmt::format("{}.psvimg", titleid); + + const auto &savedata_info_cloud = emuenv.v3kn.storage_state.savedata_info[SAVE_DATA_TYPE_CLOUD]; + + reset_progress(emuenv.v3kn.storage_state); + + std::thread([&gui, &emuenv, titleid, savedata_path, savedata_cache_path, savedata_info_cloud]() mutable { + auto &storage_state = emuenv.v3kn.storage_state; + + auto progress_callback = [&storage_state](float updated_progress, uint64_t updated_remaining, uint64_t updated_bytes_done) { + storage_state.progress = updated_progress; + storage_state.bytes_done = updated_bytes_done; + storage_state.remaining = updated_remaining; + return &storage_state.progress_state; + }; + + const std::string url = get_v3kn_server_url(emuenv.v3kn.account_state.user_info.host, fmt::format("v3kn/download_file?type=savedata&id={}", titleid)); + + if (fs::exists(savedata_cache_path)) + fs::remove(savedata_cache_path); + + const auto savedata_cache_str = fs_utils::path_to_utf8(savedata_cache_path); + const auto res = net_utils::download_file_ex(url, savedata_cache_str, progress_callback, emuenv.v3kn.account_state.user_info.token); + + // Handle response and update connection status + handle_v3kn_status(emuenv, res); + + // Wait for progress to reach 100% and display please wait dialog a minimum time before proceeding for a better UX + { + std::unique_lock lck(storage_state.mutex_progress); + storage_state.cv_progress.wait(lck, [&] { + return storage_state.please_wait_done.load(); + }); + } + + if (res.body.starts_with("ERR:") || (res.curl_res != 0)) { + gui.info_message.function = spdlog::level::err; + gui.info_message.title = emuenv.common_dialog.lang.common["error"]; + gui.info_message.msg = get_v3kn_error_message(emuenv, res); + gui.vita_area.please_wait = false; + LOG_ERROR("Failed to download {}", savedata_cache_str); + return; + } + + // Verify downloaded file size + const auto file_size = fs::file_size(savedata_cache_path); + if ((file_size == 0) || (file_size != savedata_info_cloud.total_size)) { + fs::remove(savedata_cache_path); + gui.info_message.function = spdlog::level::err; + gui.info_message.title = emuenv.common_dialog.lang.common["error"]; + gui.info_message.msg = "Downloaded savedata size mismatch, please try again."; + gui.vita_area.please_wait = false; + LOG_ERROR("Downloaded savedata size mismatch: expected {}, got {}", savedata_info_cloud.total_size, file_size); + return; + } + + // Extract savedata zip in a temp folder first to avoid partial overwrite if extraction fails + const auto save_data_temp_path = fs::path{ savedata_path.parent_path() / (titleid + "_v3kn") }; + if (fs::exists(save_data_temp_path)) + fs::remove_all(save_data_temp_path); + const auto ret = extract_zip_to_directory(savedata_cache_path, save_data_temp_path); + fs::remove(savedata_cache_path); + if (!ret) { + fs::remove_all(save_data_temp_path); + gui.info_message.function = spdlog::level::err; + gui.info_message.title = emuenv.common_dialog.lang.common["error"]; + gui.info_message.msg = "Failed to extract savedata zip, please try again."; + gui.vita_area.please_wait = false; + LOG_ERROR("Failed to extract savedata zip for titleid {}", titleid); + return; + } + + // Remove existing savedata if extracted successfully + if (fs::exists(savedata_path)) + fs::remove_all(savedata_path); + + // Rename temp folder to actual savedata folder + fs::rename(save_data_temp_path, savedata_path); + + auto &savedata_info_local = emuenv.v3kn.storage_state.savedata_info[SAVE_DATA_TYPE_LOCAL]; + savedata_info_local = savedata_info_cloud; + uint64_t total_size_local = 0; + for (auto &entry : savedata_info_cloud.files) { + const fs::path local_file_path = savedata_path / entry.rel_path; + total_size_local += fs::file_size(local_file_path); + fs::last_write_time(local_file_path, entry.last_updated); + } + + savedata_info_local.total_size = total_size_local; + fs::last_write_time(savedata_path, savedata_info_cloud.last_updated); + gui.vita_area.please_wait = false; + LOG_INFO("Download successful for titleid {}", titleid); + }).detach(); +} + +void upload_savedata(GuiState &gui, EmuEnvState &emuenv, const std::string &titleid) { + const fs::path savedata_path = { emuenv.pref_path / "ux0/user" / emuenv.io.user_id / "savedata" / titleid }; + if (!fs::exists(savedata_path) || fs::is_empty(savedata_path)) { + LOG_WARN("No savedata to upload for titleid {}", titleid); + return; + } + + const fs::path savedata_cache_path{ emuenv.cache_path / fmt::format("{}.psvimg", titleid) }; + + reset_progress(emuenv.v3kn.storage_state); + + std::thread([&gui, &emuenv, titleid, savedata_path, savedata_cache_path]() mutable { + auto &storage_state = emuenv.v3kn.storage_state; + + auto progress_callback = [&storage_state](float updated_progress, uint64_t updated_remaining, uint64_t updated_bytes_done) { + storage_state.progress = updated_progress; + storage_state.bytes_done = updated_bytes_done; + storage_state.remaining = updated_remaining; + return &storage_state.progress_state; + }; + + auto &savedata_info_local = emuenv.v3kn.storage_state.savedata_info[SAVE_DATA_TYPE_LOCAL]; + + if (!create_zip_from_directory(savedata_path, savedata_cache_path)) { + LOG_ERROR("Failed to create savedata zip"); + return; + } + + const auto total_size = fs::file_size(savedata_cache_path); + const auto last_uploaded = std::time(0); + + std::stringstream savedata_info_xml; + pugi::xml_document doc; + pugi::xml_node savedata_node = doc.append_child("savedata"); + for (const auto &entry : savedata_info_local.files) { + auto file_node = savedata_node.append_child("file"); + file_node.append_attribute("path").set_value(entry.rel_path.c_str()); + file_node.append_attribute("last_updated").set_value(static_cast(entry.last_updated)); + } + savedata_node.append_attribute("last_uploaded").set_value(static_cast(last_uploaded)); + savedata_node.append_attribute("last_updated").set_value(static_cast(savedata_info_local.last_updated)); + savedata_node.append_attribute("total_size").set_value(total_size); + doc.save(savedata_info_xml); + + const std::string url = get_v3kn_server_url(emuenv.v3kn.account_state.user_info.host, fmt::format("v3kn/upload_file?type=savedata&id={}", titleid)); + + const std::string filepath_str = fs_utils::path_to_utf8(savedata_cache_path); + const auto res = net_utils::upload_file(url, filepath_str, emuenv.v3kn.account_state.user_info.token, savedata_info_xml.str(), progress_callback); + // Handle response and update connection status + handle_v3kn_status(emuenv, res); + + // Wait for progress to reach 100% and display please wait dialog a minimum time before proceeding for a better UX + { + std::unique_lock lck(storage_state.mutex_progress); + storage_state.cv_progress.wait(lck, [&] { + return storage_state.please_wait_done.load(); + }); + } + + fs::remove(savedata_cache_path); + + if (!res.body.starts_with("OK:")) { + gui.info_message.function = spdlog::level::err; + gui.info_message.title = emuenv.common_dialog.lang.common["error"]; + gui.info_message.msg = get_v3kn_error_message(emuenv, res); + gui.vita_area.please_wait = false; + LOG_ERROR("Upload failed for {}", filepath_str); + return; + } + + std::smatch match; + // Expected format: "OK:quota_used:quota_total" + std::regex regex_pattern(R"(OK:(\d+):(\d+))"); + if (std::regex_search(res.body, match, regex_pattern) && match.size() == 3) { + // Update user info of quota + emuenv.v3kn.account_state.user_info.quota_used = std::strtoull(match[1].str().c_str(), nullptr, 10); + emuenv.v3kn.account_state.user_info.quota_total = std::strtoull(match[2].str().c_str(), nullptr, 10); + + // Update cloud savedata info + savedata_info_local.last_uploaded = last_uploaded; + auto &savedata_info_cloud = emuenv.v3kn.storage_state.savedata_info[SAVE_DATA_TYPE_CLOUD]; + savedata_info_cloud = savedata_info_local; + savedata_info_cloud.total_size = total_size; + LOG_INFO("Upload successful for titleid {}", titleid); + } else + LOG_WARN("Unexpected upload response format: {}", res.body); + + gui.vita_area.please_wait = false; + }).detach(); +} + +void open_online_storage(GuiState &gui, EmuEnvState &emuenv, const std::string &titleid) { + gui.vita_area.connecting_please_wait = true; + std::thread([&gui, &emuenv, titleid]() mutable { + if (get_savedata_info(gui, emuenv, titleid)) { + gui.vita_area.online_storage = true; + gui.vita_area.home_screen = false; + gui.vita_area.live_area_screen = false; + } + gui.vita_area.connecting_please_wait = false; + }).detach(); +} + +} // namespace v3kn