diff --git a/external/sciterui b/external/sciterui
index db5a4d18..c2cdf8fa 160000
--- a/external/sciterui
+++ b/external/sciterui
@@ -1 +1 @@
-Subproject commit db5a4d1840cd4fdda67cc8ef3f37597be26190ab
+Subproject commit c2cdf8fa3a4c5807a6178a88799844e307af1101
diff --git a/lang/english/css/config.css b/lang/english/css/config.css
index 152b9894..4b125969 100644
--- a/lang/english/css/config.css
+++ b/lang/english/css/config.css
@@ -250,6 +250,185 @@ label:disabled {
margin-bottom: 0.75rem;
}
+.page_content.profiles-page
+{
+ flow: vertical;
+ width: 100%%;
+ height: 100%%;
+ padding: 1em;
+}
+
+.active-profile-label
+{
+ margin: 0 0 0.65rem 0;
+ color: #eff0f1;
+ font-size: 0.9rem;
+}
+
+.profile-grid
+{
+ flow: horizontal-wrap;
+ border: 1px solid #55575a;
+ border-radius: 5px;
+ background: #1e1e1e;
+ padding: 0.5rem;
+ width: 100%%;
+ height: *;
+ overflow-y: auto;
+ vertical-align: top;
+}
+
+.profile-tile
+{
+ position: relative;
+ width: 96px;
+ margin: 0.35rem;
+ padding: 0.25rem;
+ border: 2px solid transparent;
+ border-radius: 5px;
+ flow: vertical;
+ horizontal-align: center;
+ cursor: pointer;
+ behavior: clickable;
+}
+
+.profile-tile:hover
+{
+ background: rgba(255, 255, 255, 0.06);
+}
+
+.profile-tile.current
+{
+ background: rgba(61, 174, 233, 0.22);
+ border: 2px solid #3daee9;
+}
+
+.profile-tile.current > .profile-tile-name
+{
+ color: #ffffff;
+ font-weight: 600;
+}
+
+.profile-tile-avatar
+{
+ width: 96px;
+ height: 96px;
+ border-radius: 4px;
+ background: #111111;
+ overflow: hidden;
+ foreground-repeat: no-repeat;
+ foreground-position: 50% 50%;
+ foreground-size: 100% 100%;
+}
+
+.profile-tile-avatar img
+{
+ display: block;
+ width: 96px;
+ height: 96px;
+ background: transparent;
+ foreground-repeat: no-repeat;
+ foreground-position: 50% 50%;
+ foreground-size: 100% 100%;
+}
+
+.profile-tile-name
+{
+ width: 90px;
+ margin-top: 0.35rem;
+ text-align: center;
+ color: #eff0f1;
+ font-size: 0.85rem;
+ overflow-x: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.profile-tile-edit
+{
+ position: absolute;
+ top: 0.35rem;
+ right: 0.35rem;
+ width: 24px;
+ height: 24px;
+ border-radius: 12px;
+ background: #2b2f32;
+ border: none;
+ color: #ffffff;
+ padding: 0;
+ z-index: 2;
+ opacity: 0;
+ visibility: hidden;
+ behavior: clickable;
+ cursor: pointer;
+ overflow: hidden;
+}
+
+.profile-tile-edit svg
+{
+ position: absolute;
+ left: 5px;
+ top: 5px;
+ width: 14px;
+ height: 14px;
+}
+
+.profile-tile-edit path
+{
+ fill: none;
+ stroke: currentColor;
+}
+
+.profile-tile:hover > .profile-tile-edit
+{
+ opacity: 1;
+ visibility: visible;
+}
+
+.profile-tile-edit:hover
+{
+ background: #3a4045;
+}
+
+.profile-tile-add
+{
+ position: relative;
+ width: 96px;
+ height: 118px;
+ margin: 0.35rem;
+ border-radius: 5px;
+ background: #2b2f32;
+ cursor: pointer;
+ behavior: clickable;
+}
+
+.profile-tile-add-button
+{
+ position: absolute;
+ left: 27px;
+ top: 38px;
+ width: 42px;
+ height: 42px;
+ border-radius: 21px;
+ background: #2b2f32;
+ border: 1px solid #55575a;
+ color: #eff0f1;
+ font-size: 1.6rem;
+ font-family: var(--font-ui);
+ line-height: 40px;
+ text-align: center;
+ padding: 0;
+ overflow: hidden;
+ behavior: clickable;
+ cursor: pointer;
+}
+
+.profile-tile-add-button:hover
+{
+ background: #3a4045;
+ border: 1px solid #3cafe9;
+}
+
.hotkeys-table
{
width: 100%%;
diff --git a/lang/english/css/profile_editor_dialog.css b/lang/english/css/profile_editor_dialog.css
new file mode 100644
index 00000000..be111659
--- /dev/null
+++ b/lang/english/css/profile_editor_dialog.css
@@ -0,0 +1,126 @@
+.profile-editor-body
+{
+ padding: 0.75rem 0.5rem 0.25rem;
+ min-width: 32rem;
+ color: #eff0f1;
+ font-family: 'Segoe UI', Arial, sans-serif;
+ font-size: 0.9rem;
+}
+
+.profile-detail-layout
+{
+ flow: horizontal;
+ width: 100%;
+}
+
+.profile-detail-fields
+{
+ width: *;
+ min-width: 22rem;
+ flow: vertical;
+}
+
+.profile-field
+{
+ flow: vertical;
+ width: *;
+ margin: 0 0 0.85rem 0;
+}
+
+.profile-field > label
+{
+ margin: 0 0 0.35rem 0;
+ color: #eff0f1;
+}
+
+.profile-field > input
+{
+ display: block;
+ width: *;
+ height: 2rem;
+ min-height: 2rem;
+ max-height: 2rem;
+ box-sizing: border-box;
+ color: #ffffff;
+ background: #2f2f31;
+ border: 1px solid #6a6e73;
+ border-radius: 0.35rem;
+ padding: 0 0.6rem;
+ margin: 0;
+ font-family: 'Segoe UI', Arial, sans-serif;
+ font-size: 0.875rem;
+ line-height: 2rem;
+ vertical-align: middle;
+ font-rendering-mode: snap-pixel;
+ text-selection-caret-color: #ffffff;
+ text-selection-color: #ffffff;
+ text-selection-background-color: #3daee9;
+}
+
+.profile-field > input:focus,
+.profile-field > input:active
+{
+ border-color: #3daee9;
+ background: #3c3c3c;
+ text-selection-caret-color: #ffffff;
+}
+
+.profile-field > input:hover
+{
+ background: #3c3c3c;
+}
+
+.profile-field > input:disabled,
+.profile-field > input[readonly]
+{
+ background: #2a2a2c;
+ color: #d0d0d0;
+ border-color: #55575a;
+ text-selection-caret-color: transparent;
+}
+
+.profile-field > input:disabled:hover,
+.profile-field > input[readonly]:hover
+{
+ background: #2a2a2c;
+}
+
+.profile-detail-image
+{
+ margin-left: 1rem;
+}
+
+.profile-detail-avatar
+{
+ width: 96px;
+ height: 96px;
+ border: 1px solid #55575a;
+ border-radius: 4px;
+ background: #111111;
+ overflow: hidden;
+ foreground-repeat: no-repeat;
+ foreground-position: 50% 50%;
+ foreground-size: 100% 100%;
+}
+
+.profile-detail-avatar img
+{
+ display: block;
+ width: 96px;
+ height: 96px;
+ background: transparent;
+ foreground-repeat: no-repeat;
+ foreground-position: 50% 50%;
+ foreground-size: 100% 100%;
+}
+
+.profile-editor-footer
+{
+ flow: horizontal;
+ horizontal-align: right;
+}
+
+.profile-editor-footer > button
+{
+ margin-left: 0.4rem;
+}
diff --git a/lang/english/css/profile_firmware_avatar_dialog.css b/lang/english/css/profile_firmware_avatar_dialog.css
new file mode 100644
index 00000000..eb198f77
--- /dev/null
+++ b/lang/english/css/profile_firmware_avatar_dialog.css
@@ -0,0 +1,119 @@
+html
+{
+ overflow: hidden;
+}
+
+body
+{
+ flow: vertical;
+}
+
+.firmware-avatar-body
+{
+ padding: 0.5rem 0.25rem;
+ min-width: 32rem;
+ height: 21rem;
+ color: #eff0f1;
+ font-family: 'Segoe UI', Arial, sans-serif;
+ font-size: 0.9rem;
+}
+
+.firmware-avatar-grid
+{
+ flow: horizontal-wrap;
+ overflow-y: auto;
+ height: 100%;
+ width: 100%;
+ box-sizing: border-box;
+ padding: 0.25rem;
+ border: 1px solid #55575a;
+ background: #121212;
+ horizontal-align: center;
+}
+
+.firmware-avatar-tile
+{
+ width: 72px;
+ height: 72px;
+ margin: 5px;
+ border: 2px solid transparent;
+ border-radius: 4px;
+ background: #ffffff;
+ overflow: hidden;
+ cursor: pointer;
+}
+
+.firmware-avatar-tile:hover
+{
+ border-color: #3cafe9;
+}
+
+.firmware-avatar-tile.selected
+{
+ border-color: #3cafe9;
+}
+
+.firmware-avatar-tile img
+{
+ width: 72px;
+ height: 72px;
+ display: block;
+ foreground-repeat: no-repeat;
+ foreground-position: 50% 50%;
+ foreground-size: contain;
+}
+
+.firmware-avatar-empty
+{
+ padding: 2rem 1rem;
+ text-align: center;
+ color: #b0b3b5;
+}
+
+.firmware-avatar-footer
+{
+ flow: horizontal;
+ vertical-align: middle;
+}
+
+.firmware-avatar-footer > #firmwareAvatarBack
+{
+ margin-right: *;
+}
+
+.firmware-avatar-actions
+{
+ flow: horizontal;
+ vertical-align: middle;
+}
+
+.firmware-avatar-actions > *
+{
+ margin-left: 0.4rem;
+}
+
+.firmware-avatar-colors
+{
+ flow: horizontal;
+ vertical-align: middle;
+}
+
+.firmware-avatar-swatch
+{
+ width: 1.35rem;
+ height: 1.35rem;
+ margin-right: 0.25rem;
+ border: 2px solid #55575a;
+ border-radius: 3px;
+ cursor: pointer;
+}
+
+.firmware-avatar-swatch:hover
+{
+ border-color: #3cafe9;
+}
+
+.firmware-avatar-swatch.selected
+{
+ border-color: #3cafe9;
+}
diff --git a/lang/english/css/profile_image_selector_dialog.css b/lang/english/css/profile_image_selector_dialog.css
new file mode 100644
index 00000000..d41f9566
--- /dev/null
+++ b/lang/english/css/profile_image_selector_dialog.css
@@ -0,0 +1,41 @@
+.profile-image-selector-body
+{
+ padding: 0.75rem 0.25rem 0.25rem;
+ min-width: 28rem;
+ color: #eff0f1;
+ font-family: 'Segoe UI', Arial, sans-serif;
+ font-size: 0.9rem;
+}
+
+.profile-image-selector-body p
+{
+ margin: 0;
+ line-height: 1.4;
+ white-space: normal;
+}
+
+.profile-image-selector-footer
+{
+ flow: horizontal;
+ vertical-align: middle;
+}
+
+.profile-image-selector-footer > #profileImageBack
+{
+ margin-right: *;
+}
+
+.profile-image-selector-actions
+{
+ flow: horizontal;
+}
+
+.profile-image-selector-actions > button
+{
+ margin-left: 0.4rem;
+}
+
+#profileImageFirmware:disabled
+{
+ opacity: 0.45;
+}
diff --git a/lang/english/html/profile_editor_dialog.html b/lang/english/html/profile_editor_dialog.html
new file mode 100644
index 00000000..f7263a04
--- /dev/null
+++ b/lang/english/html/profile_editor_dialog.html
@@ -0,0 +1,40 @@
+
+
+
+
+
+ Edit Profile
+
+
+
Edit Profile
+
+
+
+
+
+
+
+
+
+
+
![]()
+
+
+
+
+
+
+
diff --git a/lang/english/html/profile_firmware_avatar_dialog.html b/lang/english/html/profile_firmware_avatar_dialog.html
new file mode 100644
index 00000000..98bd7a99
--- /dev/null
+++ b/lang/english/html/profile_firmware_avatar_dialog.html
@@ -0,0 +1,42 @@
+
+
+
+
+
+ Select Firmware Avatar
+
+
+
Select Firmware Avatar
+
+
+
+
+
+
+
+
+ No firmware avatars were found. Ensure a complete system firmware install that includes AvatarImage (010000000000080A).
+
+
+
+
+
diff --git a/lang/english/html/profile_image_selector_dialog.html b/lang/english/html/profile_image_selector_dialog.html
new file mode 100644
index 00000000..abd03089
--- /dev/null
+++ b/lang/english/html/profile_image_selector_dialog.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+ Profile Image Selection
+
+
+
Choose a profile Image
+
+
+
+
+
+
+
You may import a custom profile image, or select an avatar from system firmware
+
+
+
+
diff --git a/lang/english/html/system_config.html b/lang/english/html/system_config.html
index 000e7e45..5d774476 100644
--- a/lang/english/html/system_config.html
+++ b/lang/english/html/system_config.html
@@ -146,6 +146,7 @@ PageNav.page_tabs page.active {
+
diff --git a/lang/english/html/system_config_profiles.html b/lang/english/html/system_config_profiles.html
new file mode 100644
index 00000000..f82ab00e
--- /dev/null
+++ b/lang/english/html/system_config_profiles.html
@@ -0,0 +1,7 @@
+
+
+ Active profile:
+
+
+
+
diff --git a/src/nxemu-module-spec/base.h b/src/nxemu-module-spec/base.h
index 174b5b17..45c1d32f 100644
--- a/src/nxemu-module-spec/base.h
+++ b/src/nxemu-module-spec/base.h
@@ -29,7 +29,7 @@ enum
MODULE_LOADER_SPECS_VERSION = 0x0123,
MODULE_VIDEO_SPECS_VERSION = 0x011A,
MODULE_CPU_SPECS_VERSION = 0x0111,
- MODULE_OPERATING_SYSTEM_SPECS_VERSION = 0x0118,
+ MODULE_OPERATING_SYSTEM_SPECS_VERSION = 0x0119,
};
enum MODULE_TYPE : uint16_t
diff --git a/src/nxemu-module-spec/operating_system.h b/src/nxemu-module-spec/operating_system.h
index 549391f0..016a56c2 100644
--- a/src/nxemu-module-spec/operating_system.h
+++ b/src/nxemu-module-spec/operating_system.h
@@ -646,6 +646,19 @@ nxinterface IButtonMappingList
virtual void Release() = 0;
};
+enum
+{
+ HOST_PROFILE_UUID_SIZE = 16,
+ HOST_PROFILE_USERNAME_SIZE = 32,
+ HOST_PROFILE_MAX_USERS = 8,
+};
+
+typedef struct HostProfileInfo
+{
+ uint8_t uuid[HOST_PROFILE_UUID_SIZE];
+ char username[HOST_PROFILE_USERNAME_SIZE + 1];
+} HostProfileInfo;
+
nxinterface IOperatingSystem
{
virtual bool Initialize() = 0;
@@ -685,6 +698,13 @@ nxinterface IOperatingSystem
virtual void SetFrontendApplets(ICabinetFrontendApplet * cabinet, IControllerFrontendApplet * controller, IErrorFrontendApplet * error, IMiiEditFrontendApplet * mii_edit, IParentalControlsFrontendApplet * parental_controls, IPhotoViewerFrontendApplet * photo_viewer, IProfileSelectFrontendApplet * profile_select, ISoftwareKeyboardFrontendApplet * software_keyboard, IWebBrowserFrontendApplet * web_browser) = 0;
virtual void SetPlayerButtonState(uint32_t player_index, uint32_t button_ordinal, bool pressed) = 0;
virtual void SetPlayerAnalogState(uint32_t player_index, uint32_t stick_index, float x, float y) = 0;
+ virtual uint32_t GetProfileCount() const = 0;
+ virtual bool GetProfile(uint32_t index, HostProfileInfo * profile) const = 0;
+ virtual bool CreateProfile(const uint8_t uuid[HOST_PROFILE_UUID_SIZE], const char * username_utf8, HostProfileInfo * out_profile) = 0;
+ virtual bool RenameProfile(const uint8_t uuid[HOST_PROFILE_UUID_SIZE], const char * username_utf8) = 0;
+ virtual bool RemoveProfile(const uint8_t uuid[HOST_PROFILE_UUID_SIZE]) = 0;
+ virtual bool SetProfileImage(const uint8_t uuid[HOST_PROFILE_UUID_SIZE], const uint8_t * image_data, uint32_t image_size) = 0;
+ virtual bool GetProfileImagePath(const uint8_t uuid[HOST_PROFILE_UUID_SIZE], char * out_path, uint32_t out_path_size) const = 0;
};
EXPORT IOperatingSystem * CALL CreateOperatingSystem(ISystemModules & modules);
diff --git a/src/nxemu-os/nxemu-os.vcxproj b/src/nxemu-os/nxemu-os.vcxproj
index 559b77d0..8d415405 100644
--- a/src/nxemu-os/nxemu-os.vcxproj
+++ b/src/nxemu-os/nxemu-os.vcxproj
@@ -482,6 +482,7 @@
+
@@ -992,6 +993,7 @@
+
diff --git a/src/nxemu-os/nxemu-os.vcxproj.filters b/src/nxemu-os/nxemu-os.vcxproj.filters
index d2c12992..a168ce88 100644
--- a/src/nxemu-os/nxemu-os.vcxproj.filters
+++ b/src/nxemu-os/nxemu-os.vcxproj.filters
@@ -2043,6 +2043,9 @@
Header Files\core\hle\service\ldn
+
+ Header Files
+
@@ -3409,5 +3412,8 @@
Source Files\core\hle\service\ldn
+
+ Source Files
+
\ No newline at end of file
diff --git a/src/nxemu-os/os_manager.cpp b/src/nxemu-os/os_manager.cpp
index 23cfbc19..757c70eb 100644
--- a/src/nxemu-os/os_manager.cpp
+++ b/src/nxemu-os/os_manager.cpp
@@ -1,7 +1,9 @@
#include "os_manager.h"
+#include "profile_image_writer.h"
#include "core/core_timing.h"
#include "core/cpu_manager.h"
#include "core/hle/kernel/k_process.h"
+#include "core/hle/service/acc/profile_manager.h"
#include "core/hle/service/am/applet_manager.h"
#include "core/hle/service/am/frontend/applets.h"
#include "core/hle/service/filesystem/filesystem.h"
@@ -9,16 +11,21 @@
#include "os_settings.h"
#include "os_settings_identifiers.h"
#include "yuzu_audio_core/sink/sink_details.h"
+#include "yuzu_common/fs/path_util.h"
#include "yuzu_common/settings.h"
+#include "yuzu_common/string_util.h"
#include "yuzu_hid_core/frontend/emulated_controller.h"
#include "yuzu_hid_core/hid_core.h"
#include "yuzu_input_common/drivers/keyboard.h"
#include "yuzu_input_common/drivers/virtual_gamepad.h"
#include "yuzu_input_common/main.h"
#include
+#include
namespace
{
+ constexpr char ACC_SAVE_AVATORS_BASE_PATH[] = "system/save/8000000000000010/su/avators";
+
class IButtonMappingListImpl : public IButtonMappingList
{
public:
@@ -88,6 +95,37 @@ namespace
std::vector m_indices;
std::vector m_params;
};
+
+ bool FillHostProfileInfo(const Service::Account::ProfileManager & manager, std::size_t index, HostProfileInfo * out_profile)
+ {
+ if (out_profile == nullptr)
+ {
+ return false;
+ }
+
+ const auto uuid = manager.GetUser(index);
+ if (!uuid)
+ {
+ return false;
+ }
+
+ Service::Account::ProfileBase profile{};
+ if (!manager.GetProfileBase(*uuid, profile))
+ {
+ return false;
+ }
+
+ std::memcpy(out_profile->uuid, profile.user_uuid.uuid.data(), HOST_PROFILE_UUID_SIZE);
+ const std::string username = Common::StringFromFixedZeroTerminatedBuffer((const char *)profile.username.data(), profile.username.size());
+ std::memset(out_profile->username, 0, sizeof(out_profile->username));
+ std::strncpy(out_profile->username, username.c_str(), HOST_PROFILE_USERNAME_SIZE);
+ return true;
+ }
+
+ std::filesystem::path ProfileImageFilesystemPath(const Common::UUID & uuid)
+ {
+ return Common::FS::GetYuzuPath(Common::FS::YuzuPath::NANDDir) / ACC_SAVE_AVATORS_BASE_PATH / (uuid.FormattedString() + ".jpg");
+ }
}
extern IModuleSettings * g_settings;
@@ -416,4 +454,159 @@ void OSManager::SetPlayerAnalogState(uint32_t player_index, uint32_t stick_index
return;
}
virtual_gamepad->SetStickPosition(player_index, static_cast(stick_index), x, y);
-}
\ No newline at end of file
+}
+
+uint32_t OSManager::GetProfileCount() const
+{
+ Service::Account::ProfileManager manager;
+ return (uint32_t)manager.GetUserCount();
+}
+
+bool OSManager::GetProfile(uint32_t index, HostProfileInfo * out_profile) const
+{
+ Service::Account::ProfileManager manager;
+ return FillHostProfileInfo(manager, index, out_profile);
+}
+
+bool OSManager::CreateProfile(const uint8_t uuid_bytes[HOST_PROFILE_UUID_SIZE], const char * username_utf8, HostProfileInfo * out_profile)
+{
+ if (uuid_bytes == nullptr || username_utf8 == nullptr || username_utf8[0] == '\0')
+ {
+ return false;
+ }
+
+ Service::Account::ProfileManager manager;
+ if (manager.GetUserCount() >= Service::Account::MAX_USERS)
+ {
+ return false;
+ }
+
+ std::array uuid_array{};
+ std::memcpy(uuid_array.data(), uuid_bytes, HOST_PROFILE_UUID_SIZE);
+ const Common::UUID uuid{uuid_array};
+ if (uuid.IsInvalid() || manager.UserExists(uuid))
+ {
+ return false;
+ }
+
+ if (manager.CreateNewUser(uuid, std::string(username_utf8)).IsError())
+ {
+ return false;
+ }
+
+ manager.WriteUserSaveFile();
+ if (out_profile == nullptr)
+ {
+ return true;
+ }
+
+ const auto index = manager.GetUserIndex(uuid);
+ return index && FillHostProfileInfo(manager, *index, out_profile);
+}
+
+bool OSManager::RenameProfile(const uint8_t uuid_bytes[HOST_PROFILE_UUID_SIZE], const char * username_utf8)
+{
+ if (uuid_bytes == nullptr || username_utf8 == nullptr || username_utf8[0] == '\0')
+ {
+ return false;
+ }
+
+ std::array uuid_array{};
+ std::memcpy(uuid_array.data(), uuid_bytes, HOST_PROFILE_UUID_SIZE);
+ const Common::UUID uuid{uuid_array};
+
+ Service::Account::ProfileManager manager;
+ Service::Account::ProfileBase profile{};
+ if (!manager.GetProfileBase(uuid, profile))
+ {
+ return false;
+ }
+
+ const std::string username(username_utf8);
+ profile.username.fill(0);
+ std::copy_n(username.begin(), std::min(username.size(), profile.username.size()), profile.username.begin());
+
+ if (!manager.SetProfileBase(uuid, profile))
+ {
+ return false;
+ }
+
+ manager.WriteUserSaveFile();
+ return true;
+}
+
+bool OSManager::RemoveProfile(const uint8_t uuid_bytes[HOST_PROFILE_UUID_SIZE])
+{
+ if (uuid_bytes == nullptr)
+ {
+ return false;
+ }
+
+ std::array uuid_array{};
+ std::memcpy(uuid_array.data(), uuid_bytes, HOST_PROFILE_UUID_SIZE);
+ const Common::UUID uuid{uuid_array};
+
+ Service::Account::ProfileManager manager;
+ if (manager.GetUserCount() < 2 || !manager.RemoveUser(uuid))
+ {
+ return false;
+ }
+
+ manager.WriteUserSaveFile();
+ return true;
+}
+
+bool OSManager::SetProfileImage(const uint8_t uuid_bytes[HOST_PROFILE_UUID_SIZE], const uint8_t * image_data, uint32_t image_size)
+{
+ if (uuid_bytes == nullptr || image_data == nullptr || image_size == 0)
+ {
+ return false;
+ }
+
+ std::array uuid_array{};
+ std::memcpy(uuid_array.data(), uuid_bytes, HOST_PROFILE_UUID_SIZE);
+ const Common::UUID uuid{uuid_array};
+
+ Service::Account::ProfileManager manager;
+ if (!manager.UserExists(uuid))
+ {
+ return false;
+ }
+
+ return WriteProfileJpegFromMemory(image_data, image_size, ProfileImageFilesystemPath(uuid));
+}
+
+bool OSManager::GetProfileImagePath(const uint8_t uuid_bytes[HOST_PROFILE_UUID_SIZE], char * out_path, uint32_t out_path_size) const
+{
+ if (uuid_bytes == nullptr || out_path == nullptr || out_path_size == 0)
+ {
+ return false;
+ }
+
+ std::array uuid_array{};
+ std::memcpy(uuid_array.data(), uuid_bytes, HOST_PROFILE_UUID_SIZE);
+ const Common::UUID uuid{uuid_array};
+
+ Service::Account::ProfileManager manager;
+ if (!manager.UserExists(uuid))
+ {
+ return false;
+ }
+
+ const std::filesystem::path imagePath = ProfileImageFilesystemPath(uuid);
+ std::error_code ec;
+ if (!std::filesystem::exists(imagePath, ec) || ec)
+ {
+ out_path[0] = '\0';
+ return true;
+ }
+
+ const std::string path = Common::FS::PathToUTF8String(imagePath);
+ if (path.size() + 1 > out_path_size)
+ {
+ return false;
+ }
+
+ std::memcpy(out_path, path.c_str(), path.size() + 1);
+ return true;
+}
diff --git a/src/nxemu-os/os_manager.h b/src/nxemu-os/os_manager.h
index 00a5b76a..a1ed7ae3 100644
--- a/src/nxemu-os/os_manager.h
+++ b/src/nxemu-os/os_manager.h
@@ -51,6 +51,13 @@ public:
void SetFrontendApplets(ICabinetFrontendApplet * cabinet, IControllerFrontendApplet * controller, IErrorFrontendApplet * error, IMiiEditFrontendApplet * mii_edit, IParentalControlsFrontendApplet * parental_controls, IPhotoViewerFrontendApplet * photo_viewer, IProfileSelectFrontendApplet * profile_select, ISoftwareKeyboardFrontendApplet * software_keyboard, IWebBrowserFrontendApplet * web_browser) override;
void SetPlayerButtonState(uint32_t player_index, uint32_t button_ordinal, bool pressed) override;
void SetPlayerAnalogState(uint32_t player_index, uint32_t stick_index, float x, float y) override;
+ uint32_t GetProfileCount() const override;
+ bool GetProfile(uint32_t index, HostProfileInfo * out_profile) const override;
+ bool CreateProfile(const uint8_t uuid[HOST_PROFILE_UUID_SIZE], const char * username_utf8, HostProfileInfo * out_profile) override;
+ bool RenameProfile(const uint8_t uuid[HOST_PROFILE_UUID_SIZE], const char * username_utf8) override;
+ bool RemoveProfile(const uint8_t uuid[HOST_PROFILE_UUID_SIZE]) override;
+ bool SetProfileImage(const uint8_t uuid[HOST_PROFILE_UUID_SIZE], const uint8_t * image_data, uint32_t image_size) override;
+ bool GetProfileImagePath(const uint8_t uuid[HOST_PROFILE_UUID_SIZE], char * out_path, uint32_t out_path_size) const override;
private:
OSManager() = delete;
diff --git a/src/nxemu-os/profile_image_writer.cpp b/src/nxemu-os/profile_image_writer.cpp
new file mode 100644
index 00000000..6db4cecc
--- /dev/null
+++ b/src/nxemu-os/profile_image_writer.cpp
@@ -0,0 +1,86 @@
+#include "profile_image_writer.h"
+
+#include "yuzu_common/fs/file.h"
+#include "yuzu_common/fs/fs.h"
+#include "yuzu_common/fs/path_util.h"
+#include "yuzu_common/stb.h"
+namespace
+{
+constexpr int ProfileDimension = 256;
+constexpr std::size_t ProfilePixelCount = static_cast(ProfileDimension) * ProfileDimension;
+
+void JpgToMemory(void * context, void * data, int len)
+{
+ std::vector * jpg = (std::vector *)context;
+ const unsigned char * bytes = (const unsigned char *)data;
+ jpg->insert(jpg->end(), bytes, bytes + len);
+}
+
+bool WriteJpegRgb(const uint8_t * rgb, const std::filesystem::path & destination)
+{
+ if (!Common::FS::CreateParentDirs(destination))
+ {
+ return false;
+ }
+
+ std::error_code ec;
+ if (std::filesystem::exists(destination, ec))
+ {
+ if (!Common::FS::RemoveFile(destination))
+ {
+ return false;
+ }
+ }
+
+ std::vector jpeg;
+ if (!stbi_write_jpg_to_func(JpgToMemory, &jpeg, ProfileDimension, ProfileDimension, STBI_rgb, rgb, 100))
+ {
+ return false;
+ }
+
+ Common::FS::IOFile file(destination, Common::FS::FileAccessMode::Write, Common::FS::FileType::BinaryFile);
+ if (!file.IsOpen() || !file.SetSize(jpeg.size()) || file.Write(jpeg) != jpeg.size())
+ {
+ return false;
+ }
+ return true;
+}
+} // namespace
+
+bool WriteProfileJpegFromMemory(const uint8_t * data, size_t size, const std::filesystem::path & destination)
+{
+ if (data == nullptr || size == 0)
+ {
+ return false;
+ }
+
+ int32_t width = 0, height = 0, channels = 0;
+ unsigned char * pixels = stbi_load_from_memory(data, static_cast(size), &width, &height, &channels, STBI_rgb);
+ if (pixels == nullptr || width <= 0 || height <= 0)
+ {
+ if (pixels != nullptr)
+ {
+ stbi_image_free(pixels);
+ }
+ return false;
+ }
+
+ std::vector resized(ProfilePixelCount * 3);
+ if (width != ProfileDimension || height != ProfileDimension)
+ {
+ if (!stbir_resize_uint8(pixels, width, height, 0, resized.data(), ProfileDimension, ProfileDimension, 0, STBI_rgb))
+ {
+ stbi_image_free(pixels);
+ return false;
+ }
+ stbi_image_free(pixels);
+ pixels = nullptr;
+ }
+ else
+ {
+ std::memcpy(resized.data(), pixels, resized.size());
+ stbi_image_free(pixels);
+ pixels = nullptr;
+ }
+ return WriteJpegRgb(resized.data(), destination);
+}
diff --git a/src/nxemu-os/profile_image_writer.h b/src/nxemu-os/profile_image_writer.h
new file mode 100644
index 00000000..d3e5f27e
--- /dev/null
+++ b/src/nxemu-os/profile_image_writer.h
@@ -0,0 +1,6 @@
+#pragma once
+
+#include
+#include
+
+bool WriteProfileJpegFromMemory(const uint8_t * data, size_t size, const std::filesystem::path & destination);
diff --git a/src/nxemu/nxemu.vcxproj b/src/nxemu/nxemu.vcxproj
index 40d4b1ab..27ac847f 100644
--- a/src/nxemu/nxemu.vcxproj
+++ b/src/nxemu/nxemu.vcxproj
@@ -24,7 +24,7 @@
- $(SolutionDir)external\fmt\include;$(SolutionDir)external\vulkan-headers\include;$(SolutionDir)external\sciterui\include;$(SolutionDir)src\3rd_party\discord-rpc\include;%(AdditionalIncludeDirectories)
+ $(SolutionDir)src\3rd_party\stb;$(SolutionDir)external\fmt\include;$(SolutionDir)external\vulkan-headers\include;$(SolutionDir)external\sciterui\include;$(SolutionDir)src\3rd_party\discord-rpc\include;%(AdditionalIncludeDirectories)
true
NOMINMAX;%(PreprocessorDefinitions)
@@ -43,6 +43,7 @@
+
@@ -50,12 +51,16 @@
+
+
+
+
@@ -69,6 +74,7 @@
+
@@ -77,6 +83,9 @@
+
+
+
@@ -84,6 +93,7 @@
+
diff --git a/src/nxemu/nxemu.vcxproj.filters b/src/nxemu/nxemu.vcxproj.filters
index 2b187790..bd141406 100644
--- a/src/nxemu/nxemu.vcxproj.filters
+++ b/src/nxemu/nxemu.vcxproj.filters
@@ -111,6 +111,21 @@
Source Files\applets
+
+ Source Files\user_interface\settings
+
+
+ Source Files\user_interface
+
+
+ Source Files\user_interface\settings
+
+
+ Source Files\user_interface\settings
+
+
+ Source Files\user_interface\settings
+
Source Files\user_interface
@@ -191,6 +206,21 @@
Header Files\applets
+
+ Header Files\user_interface\settings
+
+
+ Header Files\user_interface
+
+
+ Header Files\user_interface\settings
+
+
+ Header Files\user_interface\settings
+
+
+ Header Files\user_interface\settings
+
Header Files\user_interface
diff --git a/src/nxemu/user_interface/about_dialog.cpp b/src/nxemu/user_interface/about_dialog.cpp
index 74c36765..bc20593f 100644
--- a/src/nxemu/user_interface/about_dialog.cpp
+++ b/src/nxemu/user_interface/about_dialog.cpp
@@ -1,4 +1,5 @@
#include "about_dialog.h"
+#include "user_interface/html_utils.h"
#include
#include
@@ -8,19 +9,6 @@
#include
-namespace
-{
-
-void AttachClickHandler(ISciterUI & sciterUI, const SciterElement & element, IClickSink * sink)
-{
- if (element.IsValid())
- {
- sciterUI.AttachHandler(element, IID_ICLICKSINK, sink);
- }
-}
-
-} // namespace
-
AboutDialog::AboutDialog(ISciterUI & sciterUI) :
m_sciterUI(sciterUI),
m_window(nullptr)
diff --git a/src/nxemu/user_interface/html_utils.cpp b/src/nxemu/user_interface/html_utils.cpp
new file mode 100644
index 00000000..64aa0dd1
--- /dev/null
+++ b/src/nxemu/user_interface/html_utils.cpp
@@ -0,0 +1,108 @@
+#include "html_utils.h"
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace
+{
+const char * SniffImageMime(const uint8_t * data, size_t size)
+{
+ if (size >= 8 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47)
+ {
+ return "image/png";
+ }
+ if (size >= 2 && data[0] == 0xff && data[1] == 0xd8)
+ {
+ return "image/jpeg";
+ }
+ if (size >= 3 && data[0] == 'G' && data[1] == 'I' && data[2] == 'F')
+ {
+ return "image/gif";
+ }
+ return "image/jpeg";
+}
+
+} // namespace
+
+std::string HtmlEscape(const std::string & text)
+{
+ std::string out;
+ out.reserve(text.size());
+ for (const char ch : text)
+ {
+ switch (ch)
+ {
+ case '&': out += "&"; break;
+ case '<': out += "<"; break;
+ case '>': out += ">"; break;
+ case '"': out += """; break;
+ case '\'': out += "'"; break;
+ default: out.push_back(ch); break;
+ }
+ }
+ return out;
+}
+
+std::string ImageDataUri(const uint8_t * data, size_t size)
+{
+ if (data == nullptr || size == 0)
+ {
+ return {};
+ }
+ return stdstr_f("data:%s;base64,%s", SniffImageMime(data, size), base64_encode(data, size).c_str());
+}
+
+std::string ImageDataUriFromFile(const char * path)
+{
+ if (path == nullptr || path[0] == '\0')
+ {
+ return {};
+ }
+
+ std::ifstream file(path, std::ios::binary);
+ if (!file)
+ {
+ return {};
+ }
+
+ const std::vector data((std::istreambuf_iterator(file)), std::istreambuf_iterator());
+ return ImageDataUri(data.data(), data.size());
+}
+
+void AttachClickHandler(ISciterUI & sciterUI, const SciterElement & element, IClickSink * sink)
+{
+ if (element.IsValid())
+ {
+ sciterUI.AttachHandler(element, IID_ICLICKSINK, sink);
+ }
+}
+
+void SetElementEnabled(const SciterElement & root, const char * id, bool enabled)
+{
+ SciterElement el(root.GetElementByID(id));
+ if (!el.IsValid())
+ {
+ return;
+ }
+ if (enabled)
+ {
+ el.SetState(0, SciterElement::STATE_DISABLED, true);
+ }
+ else
+ {
+ el.SetState(SciterElement::STATE_DISABLED, 0, true);
+ }
+}
+
+void SetElementVisible(const SciterElement & root, const char * id, bool visible)
+{
+ SciterElement el(root.GetElementByID(id));
+ if (!el.IsValid())
+ {
+ return;
+ }
+ el.SetStyleAttribute("display", visible ? "" : "none");
+}
diff --git a/src/nxemu/user_interface/html_utils.h b/src/nxemu/user_interface/html_utils.h
new file mode 100644
index 00000000..2c7b34af
--- /dev/null
+++ b/src/nxemu/user_interface/html_utils.h
@@ -0,0 +1,14 @@
+#pragma once
+
+#include
+
+__interface ISciterUI;
+__interface IClickSink;
+class SciterElement;
+
+std::string HtmlEscape(const std::string & text);
+std::string ImageDataUri(const uint8_t * data, size_t size);
+std::string ImageDataUriFromFile(const char * path);
+void AttachClickHandler(ISciterUI & sciterUI, const SciterElement & element, IClickSink * sink);
+void SetElementEnabled(const SciterElement & root, const char * id, bool enabled);
+void SetElementVisible(const SciterElement & root, const char * id, bool visible);
diff --git a/src/nxemu/user_interface/sciter_main_window.cpp b/src/nxemu/user_interface/sciter_main_window.cpp
index 0a2a2235..da16ec28 100644
--- a/src/nxemu/user_interface/sciter_main_window.cpp
+++ b/src/nxemu/user_interface/sciter_main_window.cpp
@@ -3,9 +3,9 @@
#include "settings/system_config.h"
#include "settings/ui_settings.h"
#include "user_interface/about_dialog.h"
+#include "user_interface/html_utils.h"
#include "user_interface/key_mappings.h"
#include "user_interface/notification.h"
-#include
#include
#include
#include
@@ -111,17 +111,7 @@ void LoadImageToElement(SciterElement elem, const std::vector & data)
}
if (!data.empty())
{
- const char * mime = "image/png";
- if (data.size() >= 2 && data[0] == 0xff && data[1] == 0xd8)
- {
- mime = "image/jpeg";
- }
- else if (data.size() >= 4 && data[0] == 'G' && data[1] == 'I' && data[2] == 'F')
- {
- mime = "image/gif";
- }
- const std::string uri = stdstr_f("data:%s;base64,%s", mime, base64_encode(data.data(), data.size()).c_str());
- elem.SetAttribute("src", uri.c_str());
+ elem.SetAttribute("src", ImageDataUri(data.data(), data.size()).c_str());
}
elem.SetStyleAttribute("display", data.empty() ? "none" : "block");
}
@@ -165,34 +155,6 @@ void UpdateLoadingProgressBar(SciterElement & fillEl, bool indeterminate, int wi
}
}
-std::string HtmlEscapeForHtmlContent(const std::string & s)
-{
- std::string out;
- out.reserve(s.size());
- for (const char c : s)
- {
- switch (c)
- {
- case '&':
- out += "&";
- break;
- case '<':
- out += "<";
- break;
- case '>':
- out += ">";
- break;
- case '"':
- out += """;
- break;
- default:
- out += c;
- break;
- }
- }
- return out;
-}
-
std::string GetGameTitleLoadingHtml(ISystemloader & loader, const char * verb)
{
IRomInfoPtr info(loader.LoadedRomInfo());
@@ -210,7 +172,7 @@ std::string GetGameTitleLoadingHtml(ISystemloader & loader, const char * verb)
{
return stdstr_f("%s ...", verb);
}
- const std::string titleEsc = HtmlEscapeForHtmlContent(std::string(buf.data()));
+ const std::string titleEsc = HtmlEscape(std::string(buf.data()));
return stdstr_f("%s %s", verb, titleEsc.c_str());
}
@@ -1055,7 +1017,7 @@ void SciterMainWindow::RefreshFirmwareInstallLoading()
if (status.IsValid())
{
- const std::string text = stdstr_f("Installing firmware %s", HtmlEscapeForHtmlContent(detail).c_str());
+ const std::string text = stdstr_f("Installing firmware %s", HtmlEscape(detail).c_str());
status.SetHTML(reinterpret_cast(text.c_str()), text.size());
}
m_sciterUI.UpdateWindow(m_rootElement.GetElementHwnd(true));
diff --git a/src/nxemu/user_interface/settings/profile_editor_dialog.cpp b/src/nxemu/user_interface/settings/profile_editor_dialog.cpp
new file mode 100644
index 00000000..609acc33
--- /dev/null
+++ b/src/nxemu/user_interface/settings/profile_editor_dialog.cpp
@@ -0,0 +1,361 @@
+#include "profile_editor_dialog.h"
+#include "profile_image_selector_dialog.h"
+#include "user_interface/html_utils.h"
+#include "user_interface/notification.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace
+{
+std::string FormatUuid(const HostProfileInfo & profile)
+{
+ std::string uuid_text;
+ uuid_text.reserve(36);
+ static const char * hex = "0123456789ABCDEF";
+ for (uint32_t i = 0; i < HOST_PROFILE_UUID_SIZE; ++i)
+ {
+ if (i == 4 || i == 6 || i == 8 || i == 10)
+ {
+ uuid_text.push_back('-');
+ }
+ uuid_text.push_back(hex[(profile.uuid[i] >> 4) & 0xF]);
+ uuid_text.push_back(hex[profile.uuid[i] & 0xF]);
+ }
+ return uuid_text;
+}
+}
+
+ProfileEditorDialog::ProfileEditorDialog(ISciterUI & sciterUI, SystemModules & modules) :
+ m_sciterUI(sciterUI),
+ m_modules(modules),
+ m_window(nullptr),
+ m_mode(ProfileEditorMode::EditExistingProfile),
+ m_editingIndex(-1),
+ m_editingProfile{},
+ m_pendingImage{},
+ m_changed(false)
+{
+}
+
+ProfileEditorDialog::~ProfileEditorDialog() = default;
+
+bool ProfileEditorDialog::Display(void * parentWindow, ProfileEditorMode mode, int32_t profileIndex)
+{
+ enum
+ {
+ WINDOW_WIDTH = 520,
+ };
+
+ m_mode = mode;
+ m_editingIndex = profileIndex;
+ m_editingProfile = {};
+ m_pendingImage = {};
+ m_changed = false;
+ m_window = nullptr;
+
+ if (!m_modules.IsValid())
+ {
+ return false;
+ }
+
+ IOperatingSystem & os = m_modules.Modules().OperatingSystem();
+ if (m_mode == ProfileEditorMode::CreateNewProfile)
+ {
+ if (os.GetProfileCount() >= HOST_PROFILE_MAX_USERS)
+ {
+ Notification::GetInstance().DisplayError("Unable to create profile. A maximum of 8 users is supported.", "Profiles");
+ return false;
+ }
+ const Common::UUID uuid = Common::UUID::MakeRandom();
+ std::memcpy(m_editingProfile.uuid, uuid.uuid.data(), HOST_PROFILE_UUID_SIZE);
+ m_editingProfile.username[0] = '\0';
+ m_editingIndex = -1;
+ }
+ else if (m_mode == ProfileEditorMode::EditExistingProfile && !os.GetProfile(static_cast(profileIndex), &m_editingProfile))
+ {
+ return false;
+ }
+
+ if (!m_sciterUI.WindowCreate(parentWindow, "profile_editor_dialog.html", 0, 0, WINDOW_WIDTH, 0, SUIW_CHILD, m_window))
+ {
+ return false;
+ }
+
+ SciterElement root(m_window->GetRootElement());
+ if (root.IsValid())
+ {
+ SciterElement titleEl(root.GetElementByID("ProfileEditorTitle"));
+ if (titleEl.IsValid())
+ {
+ titleEl.SetText(m_mode == ProfileEditorMode::CreateNewProfile ? "Create Profile" : "Edit Profile");
+ }
+
+ SciterElement usernameInput(root.GetElementByID("ProfileUsername"));
+ SciterElement userIdInput(root.GetElementByID("ProfileUserId"));
+ if (usernameInput.IsValid())
+ {
+ usernameInput.SetValue(SciterValue(std::string(m_editingProfile.username)));
+ }
+ if (userIdInput.IsValid())
+ {
+ userIdInput.SetValue(SciterValue(FormatUuid(m_editingProfile)));
+ }
+
+ SetDetailImage(m_mode == ProfileEditorMode::CreateNewProfile ? std::string() : GetImageFileUrl(m_editingProfile));
+ UpdateControls();
+
+ AttachClickHandler(m_sciterUI, root.GetElementByID("profileRemove"), this);
+ AttachClickHandler(m_sciterUI, root.GetElementByID("profileSetImage"), this);
+ AttachClickHandler(m_sciterUI, root.GetElementByID("profileSave"), this);
+ }
+
+ m_window->FixMinSize();
+ m_window->CenterWindow();
+ m_window->RunModal();
+ return m_changed;
+}
+
+bool ProfileEditorDialog::OnClick(SCITER_ELEMENT element, SCITER_ELEMENT /*source*/, uint32_t /*reason*/)
+{
+ SciterElement clickElem(element);
+ const std::string elementID = clickElem.GetAttributeByName("id");
+ if (!m_modules.IsValid())
+ {
+ return true;
+ }
+
+ IOperatingSystem & os = m_modules.Modules().OperatingSystem();
+
+ if (elementID == "profileSave")
+ {
+ const std::string username = GetUsernameInput();
+ if (username.empty())
+ {
+ Notification::GetInstance().DisplayError("Enter a username for the profile.", "Profiles");
+ return true;
+ }
+
+ if (m_mode == ProfileEditorMode::CreateNewProfile)
+ {
+ HostProfileInfo created{};
+ if (!os.CreateProfile(m_editingProfile.uuid, username.c_str(), &created))
+ {
+ Notification::GetInstance().DisplayError("Unable to create profile. A maximum of 8 users is supported.", "Profiles");
+ return true;
+ }
+
+ m_editingProfile = created;
+ m_editingIndex = (int32_t)(os.GetProfileCount() - 1);
+
+ if (m_pendingImage.HasImage() && !CommitPendingImage())
+ {
+ return true;
+ }
+
+ m_mode = ProfileEditorMode::EditExistingProfile;
+ m_changed = true;
+ Close();
+ return true;
+ }
+
+ if (!os.RenameProfile(m_editingProfile.uuid, username.c_str()))
+ {
+ Notification::GetInstance().DisplayError("Unable to rename profile.", "Profiles");
+ return true;
+ }
+
+ if (m_pendingImage.HasImage() && !CommitPendingImage())
+ {
+ return true;
+ }
+
+ m_changed = true;
+ Close();
+ return true;
+ }
+
+ if (elementID == "profileRemove")
+ {
+ if (m_mode == ProfileEditorMode::CreateNewProfile)
+ {
+ return true;
+ }
+
+ const std::string message = stdstr_f("Delete user \"%s\"?\n\nSave data for this user will remain on disk.", m_editingProfile.username);
+ if (Notification::GetInstance().Query(message.c_str(), "Confirm Delete") != NotificationResponse::Yes)
+ {
+ return true;
+ }
+
+ if (!os.RemoveProfile(m_editingProfile.uuid))
+ {
+ Notification::GetInstance().DisplayError("Unable to delete profile. At least one user must remain.", "Profiles");
+ return true;
+ }
+
+ SettingsStore & settings = SettingsStore::GetInstance();
+ if (settings.GetInt(NXOsSetting::CurrentUser) == m_editingIndex)
+ {
+ settings.SetInt(NXOsSetting::CurrentUser, 0);
+ }
+ else if (settings.GetInt(NXOsSetting::CurrentUser) > m_editingIndex)
+ {
+ settings.SetInt(NXOsSetting::CurrentUser, settings.GetInt(NXOsSetting::CurrentUser) - 1);
+ }
+
+ m_pendingImage = {};
+ m_changed = true;
+ Close();
+ return true;
+ }
+
+ if (elementID == "profileSetImage")
+ {
+ ProfileImageSelectorDialog selector(m_sciterUI, m_modules);
+ PendingProfileImage selection{};
+ if (selector.Display((void *)(m_window != nullptr ? m_window->GetHandle() : nullptr), selection))
+ {
+ m_pendingImage = selection;
+ SetDetailImage(m_pendingImage.HasImage() ? ImageDataUri(m_pendingImage.data.data(), m_pendingImage.data.size()) : std::string{});
+ }
+ return true;
+ }
+
+ return true;
+}
+
+void ProfileEditorDialog::Close()
+{
+ if (m_window == nullptr || m_window->IsClosed())
+ {
+ return;
+ }
+ m_window->Destroy();
+}
+
+void ProfileEditorDialog::UpdateControls()
+{
+ if (m_window == nullptr)
+ {
+ return;
+ }
+
+ SciterElement root(m_window->GetRootElement());
+ const uint32_t count = m_modules.IsValid() ? m_modules.Modules().OperatingSystem().GetProfileCount() : 0;
+ const bool editing = m_mode == ProfileEditorMode::EditExistingProfile;
+
+ SetElementEnabled(root, "profileSave", true);
+ SetElementEnabled(root, "profileSetImage", true);
+ SetElementVisible(root, "profileRemove", editing);
+ SetElementEnabled(root, "profileRemove", editing && count >= 2);
+}
+
+void ProfileEditorDialog::SetDetailImage(const std::string & imageUri)
+{
+ if (m_window == nullptr)
+ {
+ return;
+ }
+
+ SciterElement image(SciterElement(m_window->GetRootElement()).GetElementByID("ProfileDetailImage"));
+ if (!image.IsValid())
+ {
+ return;
+ }
+
+ image.SetAttribute("src", "");
+ image.SetStyleAttribute("foreground-image", "none");
+
+ SciterElement avatar = image.GetParent();
+ if (avatar.IsValid())
+ {
+ avatar.SetStyleAttribute("foreground-image", "none");
+ avatar.SetStyleAttribute("background", "");
+ }
+
+ if (imageUri.empty())
+ {
+ image.SetStyleAttribute("display", "none");
+ }
+ else
+ {
+ const std::string foreground = "url(" + imageUri + ")";
+ image.SetAttribute("src", imageUri.c_str());
+ image.SetStyleAttribute("foreground-image", foreground.c_str());
+ image.SetStyleAttribute("foreground-size", "100% 100%");
+ image.SetStyleAttribute("background", "transparent");
+ image.SetStyleAttribute("display", "block");
+
+ if (avatar.IsValid())
+ {
+ avatar.SetStyleAttribute("foreground-image", foreground.c_str());
+ avatar.SetStyleAttribute("foreground-size", "100% 100%");
+ }
+ }
+}
+
+bool ProfileEditorDialog::CommitPendingImage()
+{
+ if (!m_modules.IsValid() || !m_pendingImage.HasImage())
+ {
+ return true;
+ }
+
+ IOperatingSystem & os = m_modules.Modules().OperatingSystem();
+ if (!os.SetProfileImage(m_editingProfile.uuid, m_pendingImage.data.data(), (uint32_t)m_pendingImage.data.size()))
+ {
+ Notification::GetInstance().DisplayError("Unable to set profile image.", "Profiles");
+ return false;
+ }
+
+ m_pendingImage = {};
+ return true;
+}
+
+std::string ProfileEditorDialog::GetUsernameInput() const
+{
+ if (m_window == nullptr)
+ {
+ return {};
+ }
+
+ SciterElement input(SciterElement(m_window->GetRootElement()).GetElementByID("ProfileUsername"));
+ if (!input.IsValid())
+ {
+ return {};
+ }
+
+ SciterValue value = input.GetValue();
+ if (!value.isString())
+ {
+ return {};
+ }
+
+ std::string username = value.GetValueStr();
+ if (username.size() > HOST_PROFILE_USERNAME_SIZE)
+ {
+ username.resize(HOST_PROFILE_USERNAME_SIZE);
+ }
+ return username;
+}
+
+std::string ProfileEditorDialog::GetImageFileUrl(const HostProfileInfo & profile) const
+{
+ if (!m_modules.IsValid())
+ {
+ return {};
+ }
+
+ char path[1024] = {};
+ if (!m_modules.Modules().OperatingSystem().GetProfileImagePath(profile.uuid, path, sizeof(path)) || path[0] == '\0')
+ {
+ return {};
+ }
+
+ return ImageDataUriFromFile(path);
+}
diff --git a/src/nxemu/user_interface/settings/profile_editor_dialog.h b/src/nxemu/user_interface/settings/profile_editor_dialog.h
new file mode 100644
index 00000000..c8d4d454
--- /dev/null
+++ b/src/nxemu/user_interface/settings/profile_editor_dialog.h
@@ -0,0 +1,49 @@
+#pragma once
+#include "profile_image_selector_dialog.h"
+#include
+#include
+#include
+
+__interface ISciterUI;
+__interface ISciterWindow;
+class SystemModules;
+
+enum class ProfileEditorMode
+{
+ CreateNewProfile,
+ EditExistingProfile,
+};
+
+class ProfileEditorDialog :
+ public IClickSink
+{
+public:
+ ProfileEditorDialog(ISciterUI & sciterUI, SystemModules & modules);
+ ~ProfileEditorDialog();
+
+ bool Display(void * parentWindow, ProfileEditorMode mode, int32_t profileIndex = -1);
+
+ // IClickSink
+ bool OnClick(SCITER_ELEMENT element, SCITER_ELEMENT source, uint32_t reason) override;
+
+private:
+ ProfileEditorDialog() = delete;
+ ProfileEditorDialog(const ProfileEditorDialog &) = delete;
+ ProfileEditorDialog & operator=(const ProfileEditorDialog &) = delete;
+
+ void Close();
+ void UpdateControls();
+ void SetDetailImage(const std::string & imageUri);
+ bool CommitPendingImage();
+ std::string GetUsernameInput() const;
+ std::string GetImageFileUrl(const HostProfileInfo & profile) const;
+
+ ISciterUI & m_sciterUI;
+ SystemModules & m_modules;
+ ISciterWindow * m_window;
+ ProfileEditorMode m_mode;
+ int32_t m_editingIndex;
+ HostProfileInfo m_editingProfile;
+ PendingProfileImage m_pendingImage;
+ bool m_changed;
+};
diff --git a/src/nxemu/user_interface/settings/profile_firmware_avatar_dialog.cpp b/src/nxemu/user_interface/settings/profile_firmware_avatar_dialog.cpp
new file mode 100644
index 00000000..546ab4dc
--- /dev/null
+++ b/src/nxemu/user_interface/settings/profile_firmware_avatar_dialog.cpp
@@ -0,0 +1,1039 @@
+#include "profile_firmware_avatar_dialog.h"
+#include "user_interface/html_utils.h"
+#include "user_interface/notification.h"
+#include "yuzu_common/fs/fs.h"
+#include "yuzu_common/fs/path_util.h"
+#include "yuzu_common/logging/log.h"
+#include "yuzu_common/stb.h"
+#include "yuzu_common/string_util.h"
+#include "yuzu_common/swap.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace
+{
+enum
+{
+ TIMER_WAIT_LOAD = 1,
+ TIMER_LOAD_IMAGES = 2,
+ IMAGE_BATCH_SIZE = 16,
+};
+
+std::mutex g_uriCacheMutex;
+std::vector g_uriCache;
+
+const std::string & CachedDataUri(uint32_t index, const std::string & path)
+{
+ std::lock_guard lock(g_uriCacheMutex);
+ if (index >= g_uriCache.size())
+ {
+ g_uriCache.resize(index + 1);
+ }
+ if (g_uriCache[index].empty())
+ {
+ g_uriCache[index] = ImageDataUriFromFile(path.c_str());
+ }
+ return g_uriCache[index];
+}
+
+int HexDigit(char c)
+{
+ if (c >= '0' && c <= '9')
+ {
+ return c - '0';
+ }
+ if (c >= 'a' && c <= 'f')
+ {
+ return 10 + (c - 'a');
+ }
+ if (c >= 'A' && c <= 'F')
+ {
+ return 10 + (c - 'A');
+ }
+ return -1;
+}
+
+constexpr uint64_t AvatarImageTitleId = 0x010000000000080AULL;
+constexpr int ProfileDimension = 256;
+constexpr int ThumbDimension = 72;
+constexpr std::size_t ProfilePixelCount = (std::size_t)ProfileDimension * ProfileDimension;
+constexpr std::size_t ProfileRgbaSize = ProfilePixelCount * 4;
+
+std::filesystem::path AvatarCacheDir()
+{
+ return Common::FS::GetYuzuPath(Common::FS::YuzuPath::CacheDir) / "firmware_avatars";
+}
+
+std::string AvatarCacheFileName(uint32_t index, int dim)
+{
+ return std::to_string(index) + "_t." + std::to_string(dim) + ".png";
+}
+
+uint32_t ReadBe32(const uint8_t * data)
+{
+ u32_be value;
+ std::memcpy(&value, data, sizeof(value));
+ return value;
+}
+
+std::vector DecompressYaz0(const std::vector & input)
+{
+ if (input.size() < 16 || std::memcmp(input.data(), "Yaz0", 4) != 0)
+ {
+ return {};
+ }
+
+ const uint32_t decoded_length = ReadBe32(input.data() + 4);
+ if (decoded_length == 0 || decoded_length > (16 * 1024 * 1024))
+ {
+ return {};
+ }
+
+ std::size_t input_offset = 16;
+ std::vector output(decoded_length);
+ std::size_t output_offset = 0;
+
+ uint16_t mask = 0;
+ uint8_t header = 0;
+
+ while (output_offset < decoded_length)
+ {
+ mask >>= 1;
+ if (mask == 0)
+ {
+ if (input_offset >= input.size())
+ {
+ break;
+ }
+ header = input[input_offset++];
+ mask = 0x80;
+ }
+
+ if ((header & mask) != 0)
+ {
+ if (input_offset >= input.size() || output_offset >= output.size())
+ {
+ break;
+ }
+ output[output_offset++] = input[input_offset++];
+ }
+ else
+ {
+ if (input_offset + 1 >= input.size())
+ {
+ break;
+ }
+
+ const uint8_t byte1 = input[input_offset++];
+ const uint8_t byte2 = input[input_offset++];
+ const uint32_t dist = ((uint32_t)(byte1 & 0xF) << 8) | byte2;
+ std::size_t position = output_offset - (dist + 1);
+
+ uint32_t length = (uint32_t)byte1 >> 4;
+ if (length == 0)
+ {
+ if (input_offset >= input.size())
+ {
+ break;
+ }
+ length = (uint32_t)input[input_offset++] + 0x12;
+ }
+ else
+ {
+ length += 2;
+ }
+
+ if (position >= output.size())
+ {
+ break;
+ }
+
+ for (uint32_t i = 0; i < length && output_offset < decoded_length; ++i)
+ {
+ output[output_offset++] = output[position++];
+ }
+ }
+ }
+
+ if (output_offset != decoded_length)
+ {
+ return {};
+ }
+ return output;
+}
+
+bool PathLooksLikeAvatarSzs(const std::string & path)
+{
+ // Firmware stores avatars as chara/00000001.szs (directory name + numbered szs).
+ // Match the full path so "chara" in a parent directory is recognized.
+ const std::string lower = Common::ToLower(path);
+ return lower.find("chara") != std::string::npos && lower.find("szs") != std::string::npos;
+}
+
+std::vector UnpremultiplyToStraightRgba(const std::vector & premul)
+{
+ std::vector straight = premul;
+ for (std::size_t i = 0; i + 3 < straight.size(); i += 4)
+ {
+ const uint8_t a = straight[i + 3];
+ if (a == 0)
+ {
+ straight[i] = 0;
+ straight[i + 1] = 0;
+ straight[i + 2] = 0;
+ continue;
+ }
+ if (a == 255)
+ {
+ continue;
+ }
+ straight[i] = (uint8_t)(std::min(255, (straight[i] * 255) / a));
+ straight[i + 1] = (uint8_t)(std::min(255, (straight[i + 1] * 255) / a));
+ straight[i + 2] = (uint8_t)(std::min(255, (straight[i + 2] * 255) / a));
+ }
+ return straight;
+}
+
+std::vector DownsamplePremulRgba(const std::vector & premul_rgba, int src_dim, int dst_dim)
+{
+ std::vector out((std::size_t)(dst_dim) * (std::size_t)(dst_dim) * 4);
+ for (int y = 0; y < dst_dim; ++y)
+ {
+ const int sy = y * src_dim / dst_dim;
+ for (int x = 0; x < dst_dim; ++x)
+ {
+ const int sx = x * src_dim / dst_dim;
+ const uint8_t * src = &premul_rgba[((std::size_t)(sy) * src_dim + sx) * 4];
+ uint8_t * dst = &out[((std::size_t)(y) * dst_dim + x) * 4];
+ dst[0] = src[0];
+ dst[1] = src[1];
+ dst[2] = src[2];
+ dst[3] = src[3];
+ }
+ }
+ return out;
+}
+
+bool WriteStraightRgbaPng(const std::vector & straight_rgba, int dim, const std::filesystem::path & path)
+{
+ if (!Common::FS::CreateParentDirs(path))
+ {
+ return false;
+ }
+
+ const std::string path_utf8 = Common::FS::PathToUTF8String(path);
+ return stbi_write_png(path_utf8.c_str(), dim, dim, 4, straight_rgba.data(), dim * 4) != 0;
+}
+
+std::vector LoadStraightRgba256(const std::string & path_utf8)
+{
+ std::ifstream file(path_utf8.c_str(), std::ios::binary);
+ if (!file)
+ {
+ return {};
+ }
+
+ const std::vector encoded((std::istreambuf_iterator(file)), std::istreambuf_iterator());
+ if (encoded.empty())
+ {
+ return {};
+ }
+
+ int width = 0;
+ int height = 0;
+ int channels = 0;
+ unsigned char * pixels = stbi_load_from_memory(encoded.data(), (int)(encoded.size()), &width, &height, &channels, STBI_rgb_alpha);
+ if (pixels == nullptr)
+ {
+ return {};
+ }
+
+ std::vector rgba;
+ if (width == ProfileDimension && height == ProfileDimension)
+ {
+ rgba.assign(pixels, pixels + ProfileRgbaSize);
+ }
+ stbi_image_free(pixels);
+ return rgba;
+}
+
+bool CacheFileReady(const std::filesystem::path & path)
+{
+ std::error_code ec;
+ return std::filesystem::exists(path, ec) && !ec && std::filesystem::file_size(path, ec) > 0 && !ec;
+}
+
+void CollectAvatarFiles(IVirtualDirectory * directory, const std::string & path_prefix,
+ std::vector>> & out_files)
+{
+ if (directory == nullptr)
+ {
+ return;
+ }
+
+ {
+ IVirtualFileListPtr files(directory->GetFiles());
+ if (files)
+ {
+ const uint32_t count = files->GetSize();
+ for (uint32_t i = 0; i < count; ++i)
+ {
+ IVirtualFilePtr file(files->GetItem(i));
+ if (!file)
+ {
+ continue;
+ }
+
+ const char * name_cstr = file->GetName();
+ if (name_cstr == nullptr)
+ {
+ continue;
+ }
+
+ const std::string name(name_cstr);
+ const std::string full_path = path_prefix + name;
+ if (!PathLooksLikeAvatarSzs(full_path))
+ {
+ continue;
+ }
+
+ std::vector data = file.ReadAllBytes();
+ if (!data.empty())
+ {
+ out_files.emplace_back(full_path, std::move(data));
+ }
+ }
+ }
+ }
+
+ {
+ IVirtualDirectoryListPtr subdirs(directory->GetSubdirectories());
+ if (!subdirs)
+ {
+ return;
+ }
+
+ const uint32_t count = subdirs->GetSize();
+ for (uint32_t i = 0; i < count; ++i)
+ {
+ IVirtualDirectoryPtr subdir(subdirs->GetItem(i));
+ if (!subdir)
+ {
+ continue;
+ }
+ const char * name_cstr = subdir->GetName();
+ const std::string name = name_cstr != nullptr ? name_cstr : "";
+ CollectAvatarFiles(subdir.Get(), path_prefix + name + "/", out_files);
+ }
+ }
+}
+
+std::vector EnumerateCachedAvatars()
+{
+ std::vector avatars;
+ const std::filesystem::path cache_dir = AvatarCacheDir();
+ for (uint32_t index = 0;; ++index)
+ {
+ const std::filesystem::path full = cache_dir / AvatarCacheFileName(index, ProfileDimension);
+ const std::filesystem::path thumb = cache_dir / AvatarCacheFileName(index, ThumbDimension);
+ if (!CacheFileReady(full) || !CacheFileReady(thumb))
+ {
+ break;
+ }
+
+ FirmwareProfileAvatar avatar;
+ avatar.full_path = Common::FS::PathToUTF8String(full);
+ avatar.thumb_path = Common::FS::PathToUTF8String(thumb);
+ avatars.push_back(std::move(avatar));
+ }
+ return avatars;
+}
+
+void BuildAvatarCacheFromFirmware(ISystemModules & modules, FirmwareAvatarLoadState & state)
+{
+ ISystemloader & loader = modules.Systemloader();
+ FileSysNCAPtr nca(loader.GetContentProviderEntry(AvatarImageTitleId, LoaderContentRecordType::Data));
+ if (!nca || nca->GetStatus() != LoaderResultStatus::Success)
+ {
+ LOG_WARNING(Service_ACC, "AvatarImage NCA 010000000000080A not found or failed to open");
+ state.done.store(true);
+ return;
+ }
+
+ IVirtualFilePtr romfs_file(nca->GetRomFS());
+ if (!romfs_file)
+ {
+ LOG_WARNING(Service_ACC, "AvatarImage NCA has no RomFS section");
+ state.done.store(true);
+ return;
+ }
+
+ IVirtualDirectoryPtr romfs(romfs_file->ExtractRomFS());
+ if (!romfs)
+ {
+ LOG_WARNING(Service_ACC, "AvatarImage RomFS extract failed");
+ state.done.store(true);
+ return;
+ }
+
+ std::vector>> compressed_files;
+ CollectAvatarFiles(romfs.Get(), "", compressed_files);
+ if (compressed_files.empty())
+ {
+ LOG_WARNING(Service_ACC, "AvatarImage RomFS contained no chara*.szs avatars");
+ state.done.store(true);
+ return;
+ }
+
+ std::sort(compressed_files.begin(), compressed_files.end(),
+ [](const auto & a, const auto & b) { return a.first < b.first; });
+
+ const std::filesystem::path cache_dir = AvatarCacheDir();
+ void(Common::FS::CreateDir(cache_dir));
+
+ uint32_t index = 0;
+ for (auto & [name, compressed] : compressed_files)
+ {
+ const std::vector rgba = DecompressYaz0(compressed);
+ if (rgba.size() != ProfileRgbaSize)
+ {
+ LOG_WARNING(Service_ACC, "Skipping firmware avatar '{}' (unexpected size {})", name, rgba.size());
+ continue;
+ }
+
+ const std::vector straight = UnpremultiplyToStraightRgba(rgba);
+ const std::vector thumb = DownsamplePremulRgba(straight, ProfileDimension, ThumbDimension);
+
+ const std::filesystem::path full_path = cache_dir / AvatarCacheFileName(index, ProfileDimension);
+ const std::filesystem::path thumb_path = cache_dir / AvatarCacheFileName(index, ThumbDimension);
+ if (!WriteStraightRgbaPng(straight, ProfileDimension, full_path) ||
+ !WriteStraightRgbaPng(thumb, ThumbDimension, thumb_path))
+ {
+ LOG_WARNING(Service_ACC, "Failed to write firmware avatar cache for '{}'", name);
+ continue;
+ }
+
+ FirmwareProfileAvatar avatar;
+ avatar.full_path = Common::FS::PathToUTF8String(full_path);
+ avatar.thumb_path = Common::FS::PathToUTF8String(thumb_path);
+ {
+ std::lock_guard lock(state.mutex);
+ state.avatars.push_back(std::move(avatar));
+ }
+ ++index;
+ }
+
+ state.done.store(true);
+}
+
+void PngToMemory(void * context, void * data, int len)
+{
+ auto * png = (std::vector *)context;
+ const auto * bytes = (unsigned char *)data;
+ png->insert(png->end(), bytes, bytes + len);
+}
+
+std::vector BuildFirmwareAvatarProfileImage(const std::vector & straight, uint8_t background_r, uint8_t background_g, uint8_t background_b)
+{
+ if (straight.size() != ProfileRgbaSize)
+ {
+ return {};
+ }
+
+ std::vector rgb(ProfilePixelCount * 3);
+ for (std::size_t i = 0; i < ProfilePixelCount; ++i)
+ {
+ rgb[i * 3 + 0] = background_r;
+ rgb[i * 3 + 1] = background_g;
+ rgb[i * 3 + 2] = background_b;
+ }
+
+ for (std::size_t i = 0; i < ProfilePixelCount; ++i)
+ {
+ const uint8_t * px = &straight[i * 4];
+ const uint32_t a = px[3];
+ if (a == 0)
+ {
+ continue;
+ }
+ if (a == 255)
+ {
+ rgb[i * 3 + 0] = px[0];
+ rgb[i * 3 + 1] = px[1];
+ rgb[i * 3 + 2] = px[2];
+ continue;
+ }
+
+ const uint32_t inv_a = 255 - a;
+ rgb[i * 3 + 0] = (uint8_t)((px[0] * a + background_r * inv_a) / 255);
+ rgb[i * 3 + 1] = (uint8_t)((px[1] * a + background_g * inv_a) / 255);
+ rgb[i * 3 + 2] = (uint8_t)((px[2] * a + background_b * inv_a) / 255);
+ }
+
+ std::vector png;
+ if (stbi_write_png_to_func(PngToMemory, &png, ProfileDimension, ProfileDimension, 3, rgb.data(), ProfileDimension * 3) == 0)
+ {
+ return {};
+ }
+ return png;
+}
+}
+
+ProfileFirmwareAvatarDialog::ProfileFirmwareAvatarDialog(ISciterUI & sciterUI, SystemModules & modules) :
+ m_sciterUI(sciterUI),
+ m_modules(modules),
+ m_window(nullptr),
+ m_selectedIndex(-1),
+ m_backgroundColor("#FFFFFF"),
+ m_selection{},
+ m_changed(false),
+ m_nextImageIndex(0)
+{
+}
+
+ProfileFirmwareAvatarDialog::~ProfileFirmwareAvatarDialog() = default;
+
+bool ProfileFirmwareAvatarDialog::Display(void * parentWindow, PendingProfileImage & outSelection)
+{
+ enum
+ {
+ WINDOW_WIDTH = 560,
+ };
+
+ m_selectedIndex = -1;
+ m_backgroundColor = "#FFFFFF";
+ m_selection = {};
+ m_changed = false;
+ m_window = nullptr;
+ m_avatars.clear();
+ m_pngPaths.clear();
+ m_nextImageIndex = 0;
+ m_loadState.reset();
+ outSelection = {};
+
+ if (!m_sciterUI.WindowCreate(parentWindow, "profile_firmware_avatar_dialog.html", 0, 0, WINDOW_WIDTH, 0, SUIW_CHILD, m_window))
+ {
+ return false;
+ }
+ SciterElement root(m_window->GetRootElement());
+ if (root.IsValid())
+ {
+ SciterElement empty(root.GetElementByID("FirmwareAvatarEmpty"));
+ SciterElement grid(root.GetElementByID("FirmwareAvatarGrid"));
+ if (empty.IsValid())
+ {
+ empty.SetText("Loading firmware avatars...");
+ empty.SetStyleAttribute("display", "block");
+ }
+ if (grid.IsValid())
+ {
+ grid.SetStyleAttribute("display", "none");
+ }
+
+ AttachClickHandler(m_sciterUI, root.GetElementByID("firmwareAvatarChoose"), this);
+ AttachClickHandler(m_sciterUI, root.GetElementByID("FirmwareAvatarColors"), this);
+ AttachClickHandler(m_sciterUI, root.GetElementByID("FirmwareAvatarGrid"), this);
+
+ m_sciterUI.AttachHandler(root, IID_ITIMERSINK, (ITimerSink *)this);
+
+ m_avatars = EnumerateCachedAvatars();
+ if (!m_avatars.empty())
+ {
+ PopulateGridStructure();
+ if (!m_pngPaths.empty())
+ {
+ root.SetTimer(1, (uint32_t *)TIMER_LOAD_IMAGES);
+ }
+ }
+ else
+ {
+ StartAvatarLoad();
+ root.SetTimer(16, (uint32_t *)TIMER_WAIT_LOAD);
+ }
+ }
+
+ m_window->FixMinSize();
+ m_window->CenterWindow();
+ m_window->RunModal();
+
+ if (m_changed)
+ {
+ outSelection = m_selection;
+ }
+ return m_changed;
+}
+
+void ProfileFirmwareAvatarDialog::StartAvatarLoad()
+{
+ m_loadState = std::make_shared();
+ std::shared_ptr state = m_loadState;
+ SystemModules * modules = &m_modules;
+ std::thread([state, modules]() {
+ if (modules != nullptr && modules->IsValid())
+ {
+ BuildAvatarCacheFromFirmware(modules->Modules(), *state);
+ }
+ else
+ {
+ state->done.store(true);
+ }
+ }).detach();
+}
+
+bool ProfileFirmwareAvatarDialog::OnTimer(SCITER_ELEMENT /*element*/, uint32_t * timerId)
+{
+ if (timerId == (uint32_t *)TIMER_WAIT_LOAD)
+ {
+ if (m_loadState == nullptr)
+ {
+ return false;
+ }
+
+ SyncAvatarsFromLoad();
+ if (m_nextImageIndex < m_pngPaths.size())
+ {
+ ApplyNextImageBatch();
+ }
+
+ if (m_loadState->done.load())
+ {
+ SyncAvatarsFromLoad();
+ if (m_avatars.empty())
+ {
+ ShowEmptyAvatarsMessage();
+ }
+ m_loadState.reset();
+ if (m_window != nullptr && m_nextImageIndex < m_pngPaths.size())
+ {
+ SciterElement(m_window->GetRootElement()).SetTimer(1, (uint32_t *)TIMER_LOAD_IMAGES);
+ }
+ return false;
+ }
+
+ return true;
+ }
+
+ if (timerId == (uint32_t *)TIMER_LOAD_IMAGES)
+ {
+ ApplyNextImageBatch();
+ return m_nextImageIndex < m_pngPaths.size();
+ }
+
+ return false;
+}
+
+bool ProfileFirmwareAvatarDialog::OnClick(SCITER_ELEMENT element, SCITER_ELEMENT source, uint32_t /*reason*/)
+{
+ SciterElement clickElem(element);
+ SciterElement sourceElem(source);
+ const std::string elementID = clickElem.GetAttributeByName("id");
+
+ if (elementID == "firmwareAvatarChoose")
+ {
+ if (ChooseSelected())
+ {
+ m_changed = true;
+ Close();
+ }
+ return true;
+ }
+
+ SciterElement walk = sourceElem.IsValid() ? sourceElem : clickElem;
+ while (walk.IsValid())
+ {
+ const std::string walkRole = walk.GetAttribute("role");
+ if (walkRole == "firmware-color")
+ {
+ const std::string color = walk.GetAttribute("data-color");
+ if (!color.empty())
+ {
+ SelectBackgroundColor(color);
+ }
+ return true;
+ }
+
+ if (walkRole == "firmware-avatar")
+ {
+ const std::string indexAttr = walk.GetAttribute("data-index");
+ if (!indexAttr.empty())
+ {
+ const int32_t newIndex = std::stoi(indexAttr);
+ if (newIndex != m_selectedIndex)
+ {
+ m_selectedIndex = newIndex;
+ UpdateSelectionHighlight();
+ }
+ }
+ return true;
+ }
+
+ if (walk.GetAttributeByName("id") == "FirmwareAvatarGrid" ||
+ walk.GetAttributeByName("id") == "FirmwareAvatarColors")
+ {
+ break;
+ }
+ walk = walk.GetParent();
+ }
+
+ return true;
+}
+
+void ProfileFirmwareAvatarDialog::Close()
+{
+ if (m_window == nullptr || m_window->IsClosed())
+ {
+ return;
+ }
+ m_window->Destroy();
+}
+
+void ProfileFirmwareAvatarDialog::PopulateGridStructure()
+{
+ if (m_window == nullptr || !m_modules.IsValid())
+ {
+ return;
+ }
+
+ SciterElement root(m_window->GetRootElement());
+ SciterElement grid(root.GetElementByID("FirmwareAvatarGrid"));
+ SciterElement empty(root.GetElementByID("FirmwareAvatarEmpty"));
+ if (!grid.IsValid())
+ {
+ return;
+ }
+
+ const uint32_t count = (uint32_t)(m_avatars.size());
+
+ if (empty.IsValid())
+ {
+ if (count == 0)
+ {
+ ShowEmptyAvatarsMessage();
+ }
+ else
+ {
+ empty.SetStyleAttribute("display", "none");
+ }
+ }
+ grid.SetStyleAttribute("display", count == 0 ? "none" : "block");
+
+ m_pngPaths.clear();
+ m_nextImageIndex = 0;
+
+ const std::vector & avatars = m_avatars;
+ {
+ std::lock_guard lock(g_uriCacheMutex);
+ if (g_uriCache.size() != count)
+ {
+ g_uriCache.clear();
+ }
+ }
+
+ std::string html;
+ m_pngPaths.reserve(count);
+ for (uint32_t i = 0; i < count && i < avatars.size(); ++i)
+ {
+ if (avatars[i].thumb_path.empty())
+ {
+ continue;
+ }
+
+ m_pngPaths.emplace_back(avatars[i].thumb_path);
+ html += stdstr_f(
+ ""
+ "
![]()
"
+ "
",
+ static_cast(i) == m_selectedIndex ? " selected" : "",
+ i);
+ }
+
+ grid.SetHTML(reinterpret_cast(html.c_str()), html.size());
+ m_sciterUI.AttachHandler(grid, IID_ICLICKSINK, (IClickSink *)this);
+ ApplyBackgroundColorPreview();
+}
+
+void ProfileFirmwareAvatarDialog::SyncAvatarsFromLoad()
+{
+ if (m_loadState == nullptr || m_window == nullptr)
+ {
+ return;
+ }
+
+ std::vector pending;
+ {
+ std::lock_guard lock(m_loadState->mutex);
+ if (m_loadState->avatars.size() <= m_avatars.size())
+ {
+ return;
+ }
+ pending.assign(m_loadState->avatars.begin() + m_avatars.size(), m_loadState->avatars.end());
+ }
+
+ SciterElement root(m_window->GetRootElement());
+ SciterElement grid(root.GetElementByID("FirmwareAvatarGrid"));
+ SciterElement empty(root.GetElementByID("FirmwareAvatarEmpty"));
+ if (!grid.IsValid())
+ {
+ return;
+ }
+
+ const uint32_t start_index = (uint32_t)(m_avatars.size());
+ m_avatars.insert(m_avatars.end(), pending.begin(), pending.end());
+
+ if (empty.IsValid())
+ {
+ empty.SetStyleAttribute("display", "none");
+ }
+ grid.SetStyleAttribute("display", "block");
+
+ std::string html;
+ html.reserve(pending.size() * 128);
+ for (uint32_t i = start_index; i < (uint32_t)(m_avatars.size()); ++i)
+ {
+ if (m_avatars[i].thumb_path.empty())
+ {
+ continue;
+ }
+
+ m_pngPaths.emplace_back(m_avatars[i].thumb_path);
+ html += stdstr_f(
+ ""
+ "
![]()
"
+ "
",
+ (int32_t)(i) == m_selectedIndex ? " selected" : "",
+ i);
+ }
+
+ if (!html.empty())
+ {
+ grid.SetHTML(reinterpret_cast(html.c_str()), html.size(),
+ SciterElement::SIH_APPEND_AFTER_LAST);
+ m_sciterUI.AttachHandler(grid, IID_ICLICKSINK, (IClickSink *)this);
+ ApplyBackgroundColorPreview();
+ }
+}
+
+void ProfileFirmwareAvatarDialog::ShowEmptyAvatarsMessage()
+{
+ if (m_window == nullptr)
+ {
+ return;
+ }
+
+ SciterElement root(m_window->GetRootElement());
+ SciterElement grid(root.GetElementByID("FirmwareAvatarGrid"));
+ SciterElement empty(root.GetElementByID("FirmwareAvatarEmpty"));
+ if (empty.IsValid())
+ {
+ empty.SetText("No firmware avatars were found. Ensure a complete system firmware install that includes AvatarImage (010000000000080A).");
+ empty.SetStyleAttribute("display", "block");
+ }
+ if (grid.IsValid())
+ {
+ grid.SetStyleAttribute("display", "none");
+ }
+}
+
+void ProfileFirmwareAvatarDialog::ApplyNextImageBatch()
+{
+ if (m_window == nullptr)
+ {
+ m_nextImageIndex = static_cast(m_pngPaths.size());
+ return;
+ }
+
+ SciterElement grid(SciterElement(m_window->GetRootElement()).GetElementByID("FirmwareAvatarGrid"));
+ if (!grid.IsValid())
+ {
+ m_nextImageIndex = static_cast(m_pngPaths.size());
+ return;
+ }
+
+ const uint32_t tileCount = (std::min)(grid.GetChildCount(), static_cast(m_pngPaths.size()));
+ const uint32_t end = (std::min)(m_nextImageIndex + IMAGE_BATCH_SIZE, tileCount);
+ for (; m_nextImageIndex < end; ++m_nextImageIndex)
+ {
+ SciterElement tile = grid.GetChild(m_nextImageIndex);
+ if (!tile.IsValid())
+ {
+ continue;
+ }
+
+ SciterElement img = tile.FindFirst("img");
+ if (!img.IsValid())
+ {
+ continue;
+ }
+
+ const std::string & dataUri = CachedDataUri(m_nextImageIndex, m_pngPaths[m_nextImageIndex]);
+ if (!dataUri.empty())
+ {
+ img.SetAttribute("src", dataUri.c_str());
+ }
+ }
+}
+
+void ProfileFirmwareAvatarDialog::UpdateSelectionHighlight()
+{
+ if (m_window == nullptr)
+ {
+ return;
+ }
+
+ SciterElement grid(SciterElement(m_window->GetRootElement()).GetElementByID("FirmwareAvatarGrid"));
+ if (!grid.IsValid())
+ {
+ return;
+ }
+
+ for (uint32_t i = 0; i < grid.GetChildCount(); ++i)
+ {
+ SciterElement tile = grid.GetChild(i);
+ if (!tile.IsValid())
+ {
+ continue;
+ }
+
+ const std::string indexAttr = tile.GetAttribute("data-index");
+ if (indexAttr.empty())
+ {
+ continue;
+ }
+
+ if (std::stoi(indexAttr) == m_selectedIndex)
+ {
+ tile.AddClassName("selected");
+ }
+ else
+ {
+ tile.RemoveClassName("selected");
+ }
+ }
+}
+
+void ProfileFirmwareAvatarDialog::ApplyBackgroundColorPreview()
+{
+ if (m_window == nullptr)
+ {
+ return;
+ }
+
+ SciterElement grid(SciterElement(m_window->GetRootElement()).GetElementByID("FirmwareAvatarGrid"));
+ if (!grid.IsValid())
+ {
+ return;
+ }
+
+ for (uint32_t i = 0; i < grid.GetChildCount(); ++i)
+ {
+ SciterElement tile = grid.GetChild(i);
+ if (tile.IsValid())
+ {
+ tile.SetStyleAttribute("background-color", m_backgroundColor.c_str());
+ }
+ }
+}
+
+void ProfileFirmwareAvatarDialog::SelectBackgroundColor(const std::string & colorCss)
+{
+ m_backgroundColor = colorCss;
+ ApplyBackgroundColorPreview();
+
+ if (m_window == nullptr)
+ {
+ return;
+ }
+
+ SciterElement colors(SciterElement(m_window->GetRootElement()).GetElementByID("FirmwareAvatarColors"));
+ if (!colors.IsValid())
+ {
+ return;
+ }
+
+ for (uint32_t i = 0; i < colors.GetChildCount(); ++i)
+ {
+ SciterElement swatch = colors.GetChild(i);
+ if (!swatch.IsValid())
+ {
+ continue;
+ }
+
+ if (swatch.GetAttribute("data-color") == m_backgroundColor)
+ {
+ swatch.AddClassName("selected");
+ }
+ else
+ {
+ swatch.RemoveClassName("selected");
+ }
+ }
+}
+
+bool ProfileFirmwareAvatarDialog::ParseSelectedColor(uint8_t & r, uint8_t & g, uint8_t & b) const
+{
+ r = 255;
+ g = 255;
+ b = 255;
+
+ if (m_backgroundColor.size() != 7 || m_backgroundColor[0] != '#')
+ {
+ return false;
+ }
+
+ const int rh = HexDigit(m_backgroundColor[1]);
+ const int rl = HexDigit(m_backgroundColor[2]);
+ const int gh = HexDigit(m_backgroundColor[3]);
+ const int gl = HexDigit(m_backgroundColor[4]);
+ const int bh = HexDigit(m_backgroundColor[5]);
+ const int bl = HexDigit(m_backgroundColor[6]);
+ if (rh < 0 || rl < 0 || gh < 0 || gl < 0 || bh < 0 || bl < 0)
+ {
+ return false;
+ }
+
+ r = static_cast((rh << 4) | rl);
+ g = static_cast((gh << 4) | gl);
+ b = static_cast((bh << 4) | bl);
+ return true;
+}
+
+bool ProfileFirmwareAvatarDialog::ChooseSelected()
+{
+ if (m_selectedIndex < 0)
+ {
+ Notification::GetInstance().DisplayError("Select a firmware avatar first.", "Profiles");
+ return false;
+ }
+
+ uint8_t r = 255;
+ uint8_t g = 255;
+ uint8_t b = 255;
+ ParseSelectedColor(r, g, b);
+
+ const std::vector & avatars = m_avatars;
+ if (static_cast(m_selectedIndex) >= avatars.size())
+ {
+ Notification::GetInstance().DisplayError("Select a firmware avatar first.", "Profiles");
+ return false;
+ }
+
+ const std::vector straight = LoadStraightRgba256(avatars[m_selectedIndex].full_path);
+ std::vector data = BuildFirmwareAvatarProfileImage(straight, r, g, b);
+ if (data.empty())
+ {
+ Notification::GetInstance().DisplayError("Unable to build the selected avatar image.", "Profiles");
+ return false;
+ }
+
+ m_selection = {};
+ m_selection.data = std::move(data);
+ return true;
+}
diff --git a/src/nxemu/user_interface/settings/profile_firmware_avatar_dialog.h b/src/nxemu/user_interface/settings/profile_firmware_avatar_dialog.h
new file mode 100644
index 00000000..5cf76d4b
--- /dev/null
+++ b/src/nxemu/user_interface/settings/profile_firmware_avatar_dialog.h
@@ -0,0 +1,72 @@
+#pragma once
+#include "profile_image_selector_dialog.h"
+#include
+#include
+#include
+#include
+#include
+#include
+
+__interface ISciterUI;
+class SystemModules;
+
+struct FirmwareProfileAvatar
+{
+ std::string thumb_path;
+ std::string full_path;
+};
+
+struct FirmwareAvatarLoadState
+{
+ std::atomic_bool done{false};
+ std::mutex mutex;
+ std::vector avatars;
+};
+
+class ProfileFirmwareAvatarDialog :
+ public IClickSink,
+ public ITimerSink
+{
+public:
+ ProfileFirmwareAvatarDialog(ISciterUI & sciterUI, SystemModules & modules);
+ ~ProfileFirmwareAvatarDialog();
+
+ bool Display(void * parentWindow, PendingProfileImage & outSelection);
+
+ // IClickSink
+ bool OnClick(SCITER_ELEMENT element, SCITER_ELEMENT source, uint32_t reason) override;
+
+ // ITimerSink
+ bool OnTimer(SCITER_ELEMENT element, uint32_t * timerId) override;
+
+private:
+ ProfileFirmwareAvatarDialog() = delete;
+ ProfileFirmwareAvatarDialog(const ProfileFirmwareAvatarDialog &) = delete;
+ ProfileFirmwareAvatarDialog & operator=(const ProfileFirmwareAvatarDialog &) = delete;
+
+ void Close();
+ void StartAvatarLoad();
+ void PopulateGridStructure();
+ void SyncAvatarsFromLoad();
+ void ShowEmptyAvatarsMessage();
+ void ApplyNextImageBatch();
+ void UpdateSelectionHighlight();
+ void ApplyBackgroundColorPreview();
+ void SelectBackgroundColor(const std::string & colorCss);
+ bool ParseSelectedColor(uint8_t & r, uint8_t & g, uint8_t & b) const;
+ bool ChooseSelected();
+
+ ISciterUI & m_sciterUI;
+ SystemModules & m_modules;
+ ISciterWindow * m_window;
+ int32_t m_selectedIndex;
+ std::string m_backgroundColor;
+ PendingProfileImage m_selection;
+ bool m_changed;
+
+ std::vector m_avatars;
+ std::vector m_pngPaths;
+ uint32_t m_nextImageIndex;
+
+ std::shared_ptr m_loadState;
+};
diff --git a/src/nxemu/user_interface/settings/profile_image_selector_dialog.cpp b/src/nxemu/user_interface/settings/profile_image_selector_dialog.cpp
new file mode 100644
index 00000000..890d78ed
--- /dev/null
+++ b/src/nxemu/user_interface/settings/profile_image_selector_dialog.cpp
@@ -0,0 +1,156 @@
+#include "profile_image_selector_dialog.h"
+#include "profile_firmware_avatar_dialog.h"
+#include "user_interface/html_utils.h"
+#include
+#include
+#include
+#include
+#include
+#include
+
+ProfileImageSelectorDialog::ProfileImageSelectorDialog(ISciterUI & sciterUI, SystemModules & modules) :
+ m_sciterUI(sciterUI),
+ m_modules(modules),
+ m_window(nullptr),
+ m_selection{},
+ m_changed(false)
+{
+}
+
+ProfileImageSelectorDialog::~ProfileImageSelectorDialog() = default;
+
+bool ProfileImageSelectorDialog::Display(void * parentWindow, PendingProfileImage & outSelection)
+{
+ enum
+ {
+ WINDOW_WIDTH = 500,
+ };
+
+ m_selection = {};
+ m_changed = false;
+ m_window = nullptr;
+ outSelection = {};
+
+ if (!m_sciterUI.WindowCreate(parentWindow, "profile_image_selector_dialog.html", 0, 0, WINDOW_WIDTH, 0, SUIW_CHILD, m_window))
+ {
+ return false;
+ }
+
+ SciterElement root(m_window->GetRootElement());
+ if (root.IsValid())
+ {
+ SciterElement firmwareButton(root.GetElementByID("profileImageFirmware"));
+ if (firmwareButton.IsValid())
+ {
+ if (HasInstalledFirmware())
+ {
+ firmwareButton.SetState(0, SciterElement::STATE_DISABLED, true);
+ }
+ else
+ {
+ firmwareButton.SetState(SciterElement::STATE_DISABLED, 0, true);
+ }
+ }
+
+ AttachClickHandler(m_sciterUI, root.GetElementByID("profileImageImport"), this);
+ AttachClickHandler(m_sciterUI, root.GetElementByID("profileImageFirmware"), this);
+ }
+
+ m_window->FixMinSize();
+ m_window->CenterWindow();
+ m_window->RunModal();
+
+ if (m_changed)
+ {
+ outSelection = m_selection;
+ }
+ return m_changed;
+}
+
+bool ProfileImageSelectorDialog::OnClick(SCITER_ELEMENT element, SCITER_ELEMENT /*source*/, uint32_t /*reason*/)
+{
+ SciterElement clickElem(element);
+ const std::string elementID = clickElem.GetAttributeByName("id");
+
+ if (elementID == "profileImageImport")
+ {
+ if (ImportImageFile())
+ {
+ m_changed = true;
+ Close();
+ }
+ return true;
+ }
+
+ if (elementID == "profileImageFirmware")
+ {
+ if (SelectFirmwareAvatar())
+ {
+ m_changed = true;
+ Close();
+ }
+ return true;
+ }
+
+ return true;
+}
+
+void ProfileImageSelectorDialog::Close()
+{
+ if (m_window == nullptr || m_window->IsClosed())
+ {
+ return;
+ }
+ m_window->Destroy();
+}
+
+bool ProfileImageSelectorDialog::HasInstalledFirmware() const
+{
+ if (!m_modules.IsValid())
+ {
+ return false;
+ }
+
+ char buffer[32]{};
+ return m_modules.Modules().Systemloader().GetInstalledFirmwareDisplayVersion(buffer, sizeof(buffer)) > 0;
+}
+
+bool ProfileImageSelectorDialog::ImportImageFile()
+{
+ Path file;
+ const char * filter =
+ "Image Files (*.jpg;*.jpeg;*.png;*.bmp)\0*.jpg;*.jpeg;*.png;*.bmp\0"
+ "All files (*.*)\0*.*\0";
+ if (!file.FileSelect((void *)(m_window != nullptr ? m_window->GetHandle() : nullptr), Path(Path::CURRENT_DIRECTORY), filter, true))
+ {
+ return false;
+ }
+
+ std::ifstream stream(static_cast(file), std::ios::binary);
+ if (!stream)
+ {
+ return false;
+ }
+
+ m_selection = {};
+ m_selection.data.assign(std::istreambuf_iterator(stream), std::istreambuf_iterator());
+ return m_selection.HasImage();
+}
+
+bool ProfileImageSelectorDialog::SelectFirmwareAvatar()
+{
+ if (!HasInstalledFirmware())
+ {
+ return false;
+ }
+
+ ProfileFirmwareAvatarDialog dialog(m_sciterUI, m_modules);
+ PendingProfileImage selection{};
+ if (!dialog.Display((void *)(m_window != nullptr ? m_window->GetHandle() : nullptr), selection))
+ {
+ return false;
+ }
+
+ m_selection = selection;
+ return true;
+}
diff --git a/src/nxemu/user_interface/settings/profile_image_selector_dialog.h b/src/nxemu/user_interface/settings/profile_image_selector_dialog.h
new file mode 100644
index 00000000..c33fd217
--- /dev/null
+++ b/src/nxemu/user_interface/settings/profile_image_selector_dialog.h
@@ -0,0 +1,43 @@
+#pragma once
+#include
+#include
+
+__interface ISciterUI;
+__interface ISciterWindow;
+class SystemModules;
+
+struct PendingProfileImage
+{
+ std::vector data;
+
+ bool HasImage() const { return !data.empty(); }
+};
+
+class ProfileImageSelectorDialog :
+ public IClickSink
+{
+public:
+ ProfileImageSelectorDialog(ISciterUI & sciterUI, SystemModules & modules);
+ ~ProfileImageSelectorDialog();
+
+ bool Display(void * parentWindow, PendingProfileImage & outSelection);
+
+ // IClickSink
+ bool OnClick(SCITER_ELEMENT element, SCITER_ELEMENT source, uint32_t reason) override;
+
+private:
+ ProfileImageSelectorDialog() = delete;
+ ProfileImageSelectorDialog(const ProfileImageSelectorDialog &) = delete;
+ ProfileImageSelectorDialog & operator=(const ProfileImageSelectorDialog &) = delete;
+
+ void Close();
+ bool ImportImageFile();
+ bool SelectFirmwareAvatar();
+ bool HasInstalledFirmware() const;
+
+ ISciterUI & m_sciterUI;
+ SystemModules & m_modules;
+ ISciterWindow * m_window;
+ PendingProfileImage m_selection;
+ bool m_changed;
+};
diff --git a/src/nxemu/user_interface/settings/system_config.cpp b/src/nxemu/user_interface/settings/system_config.cpp
index e2d0015f..e11096b3 100644
--- a/src/nxemu/user_interface/settings/system_config.cpp
+++ b/src/nxemu/user_interface/settings/system_config.cpp
@@ -4,6 +4,7 @@
#include "system_config_audio.h"
#include "system_config_general.h"
#include "system_config_graphics.h"
+#include "system_config_profiles.h"
#include "system_config_system.h"
#include
#include
@@ -330,6 +331,10 @@ void SystemConfig::PageNavCreatedPage(const std::string & pageName, SCITER_ELEME
{
m_systemConfigGeneral.reset(new SystemConfigGeneral(m_sciterUI, *this, *m_window, page));
}
+ else if (pageName == "Profiles")
+ {
+ m_systemConfigProfiles.reset(new SystemConfigProfiles(m_sciterUI, *this, m_modules, *m_window, page));
+ }
}
void SystemConfig::PageNavPageChanged(const std::string & /*pageName*/, SCITER_ELEMENT /*pageNav*/)
@@ -353,6 +358,10 @@ bool SystemConfig::OnClick(SCITER_ELEMENT element, SCITER_ELEMENT /*source*/, ui
{
m_systemConfigGeneral->SaveSetting();
}
+ if (m_systemConfigProfiles)
+ {
+ m_systemConfigProfiles->SaveSetting();
+ }
if (m_modules.IsValid())
{
m_modules.FlushSettings();
diff --git a/src/nxemu/user_interface/settings/system_config.h b/src/nxemu/user_interface/settings/system_config.h
index adf39529..cce81d10 100644
--- a/src/nxemu/user_interface/settings/system_config.h
+++ b/src/nxemu/user_interface/settings/system_config.h
@@ -13,6 +13,7 @@ class SciterElement;
class SystemConfigAudio;
class SystemConfigGraphics;
class SystemConfigGeneral;
+class SystemConfigProfiles;
class SystemConfigSystem;
class ConfigSetting;
class SystemModules;
@@ -86,4 +87,5 @@ private:
std::unique_ptr m_systemConfigGraphics;
std::unique_ptr m_systemConfigAudio;
std::unique_ptr m_systemConfigGeneral;
+ std::unique_ptr m_systemConfigProfiles;
};
diff --git a/src/nxemu/user_interface/settings/system_config_profiles.cpp b/src/nxemu/user_interface/settings/system_config_profiles.cpp
new file mode 100644
index 00000000..94beb26f
--- /dev/null
+++ b/src/nxemu/user_interface/settings/system_config_profiles.cpp
@@ -0,0 +1,291 @@
+#include "system_config_profiles.h"
+#include "profile_editor_dialog.h"
+#include "user_interface/html_utils.h"
+#include "user_interface/notification.h"
+#include
+#include
+#include
+#include
+#include
+
+SystemConfigProfiles::SystemConfigProfiles(ISciterUI & sciterUI, SystemConfig & config, SystemModules & modules, ISciterWindow & window, SciterElement page) :
+ m_sciterUI(sciterUI),
+ m_config(config),
+ m_modules(modules),
+ m_window(window),
+ m_page(page),
+ m_enabled(!SettingsStore::GetInstance().GetBool(NXCoreSetting::EmulationRunning)),
+ m_currentUser(SettingsStore::GetInstance().GetInt(NXOsSetting::CurrentUser))
+{
+ SciterElement pageNav = page.GetElementByID("ProfilesTabNav");
+ std::shared_ptr interfacePtr = pageNav.IsValid() ? m_sciterUI.GetElementInterface(pageNav, IID_IPAGENAV) : nullptr;
+ if (interfacePtr)
+ {
+ m_pageNav = std::static_pointer_cast(interfacePtr);
+ m_pageNav->AddSink(this);
+ }
+}
+
+void SystemConfigProfiles::SaveSetting(void)
+{
+ SettingsStore::GetInstance().SetInt(NXOsSetting::CurrentUser, m_currentUser);
+}
+
+bool SystemConfigProfiles::PageNavChangeFrom(const std::string & /*pageName*/, SCITER_ELEMENT /*pageNav*/)
+{
+ return true;
+}
+
+bool SystemConfigProfiles::PageNavChangeTo(const std::string & /*pageName*/, SCITER_ELEMENT /*pageNav*/)
+{
+ return true;
+}
+
+void SystemConfigProfiles::PageNavCreatedPage(const std::string & pageName, SCITER_ELEMENT page)
+{
+ if (pageName == "ManageProfiles")
+ {
+ SetupProfilesPage(page);
+ }
+}
+
+void SystemConfigProfiles::PageNavPageChanged(const std::string & /*pageName*/, SCITER_ELEMENT /*pageNav*/)
+{
+}
+
+void SystemConfigProfiles::SetupProfilesPage(SciterElement page)
+{
+ m_profilesPage = page;
+ PopulateProfiles();
+}
+
+bool SystemConfigProfiles::OnClick(SCITER_ELEMENT /*element*/, SCITER_ELEMENT source, uint32_t /*reason*/)
+{
+ SciterElement clickElem(source);
+ std::string role;
+ std::string indexAttr;
+
+ SciterElement walk = clickElem;
+ while (walk.IsValid() && walk != m_page)
+ {
+ const std::string walkRole = walk.GetAttribute("role");
+ const std::string walkIndex = walk.GetAttribute("data-index");
+
+ if (walkRole == "profile-edit" || walkRole == "profile-add" || walkRole == "profile-tile")
+ {
+ role = walkRole;
+ if (!walkIndex.empty())
+ {
+ indexAttr = walkIndex;
+ }
+ break;
+ }
+
+ if (indexAttr.empty() && !walkIndex.empty())
+ {
+ indexAttr = walkIndex;
+ }
+ walk = walk.GetParent();
+ }
+
+ if (role == "profile-add")
+ {
+ if (m_enabled)
+ {
+ AddProfile();
+ }
+ return true;
+ }
+
+ if (role == "profile-edit")
+ {
+ if (m_enabled && !indexAttr.empty())
+ {
+ OpenEditor(ProfileEditorMode::EditExistingProfile, std::stoi(indexAttr));
+ }
+ return true;
+ }
+
+ if (role == "profile-tile")
+ {
+ if (!indexAttr.empty())
+ {
+ SelectCurrentUser(std::stoi(indexAttr));
+ PopulateProfiles();
+ }
+ return true;
+ }
+
+ return true;
+}
+
+void SystemConfigProfiles::AddProfile()
+{
+ if (!m_modules.IsValid())
+ {
+ return;
+ }
+
+ if (m_modules.Modules().OperatingSystem().GetProfileCount() >= HOST_PROFILE_MAX_USERS)
+ {
+ Notification::GetInstance().DisplayError("Unable to create profile. A maximum of 8 users is supported.", "Profiles");
+ return;
+ }
+
+ OpenEditor(ProfileEditorMode::CreateNewProfile);
+}
+
+void SystemConfigProfiles::OpenEditor(ProfileEditorMode mode, int32_t index)
+{
+ if (!m_modules.IsValid())
+ {
+ return;
+ }
+
+ IOperatingSystem & os = m_modules.Modules().OperatingSystem();
+ const uint32_t countBefore = os.GetProfileCount();
+
+ ProfileEditorDialog dialog(m_sciterUI, m_modules);
+ if (!dialog.Display((void *)m_window.GetHandle(), mode, index))
+ {
+ return;
+ }
+
+ const uint32_t count = os.GetProfileCount();
+ if (mode == ProfileEditorMode::CreateNewProfile && count > countBefore)
+ {
+ m_currentUser = static_cast(count - 1);
+ }
+ else if (count < countBefore)
+ {
+ m_currentUser = SettingsStore::GetInstance().GetInt(NXOsSetting::CurrentUser);
+ }
+ PopulateProfiles();
+}
+
+void SystemConfigProfiles::PopulateProfiles()
+{
+ SciterElement grid = m_profilesPage.IsValid() ? m_profilesPage.GetElementByID("profileGrid") : nullptr;
+ if (!m_profilesPage.IsValid() || !grid.IsValid() || !m_modules.IsValid())
+ {
+ return;
+ }
+
+ grid.Clear();
+
+ IOperatingSystem & os = m_modules.Modules().OperatingSystem();
+ const uint32_t count = os.GetProfileCount();
+ if (count == 0)
+ {
+ m_currentUser = 0;
+ }
+ else if (m_currentUser < 0 || (uint32_t)m_currentUser >= count)
+ {
+ m_currentUser = 0;
+ }
+
+ const int32_t current = m_currentUser;
+ std::string activeName;
+
+ std::string html;
+ std::vector imageUris;
+ imageUris.reserve(count);
+ for (uint32_t i = 0; i < count; ++i)
+ {
+ HostProfileInfo profile{};
+ if (!os.GetProfile(i, &profile))
+ {
+ continue;
+ }
+
+ const bool isCurrent = (int32_t)i == current;
+ const std::string imageUri = GetImageFileUrl(profile);
+ const std::string name = HtmlEscape(profile.username);
+ imageUris.push_back(imageUri);
+
+ if (isCurrent)
+ {
+ activeName = profile.username;
+ }
+
+ html += stdstr_f(
+ "",
+ isCurrent ? " current" : "",
+ i,
+ name.c_str(),
+ i,
+ m_enabled ? "" : "disabled");
+ }
+
+ SciterElement activeLabel = m_profilesPage.GetElementByID("ActiveProfileLabel");
+ if (activeLabel.IsValid())
+ {
+ activeLabel.SetText(stdstr_f("Active profile: %s", !activeName.empty() ? activeName.c_str() : "").c_str());
+ }
+
+ if (m_enabled && count < HOST_PROFILE_MAX_USERS)
+ {
+ html +=
+ "";
+ }
+
+ grid.SetHTML((const uint8_t *)html.c_str(), html.size());
+ m_sciterUI.AttachHandler(grid, IID_ICLICKSINK, (IClickSink *)this);
+
+ for (uint32_t i = 0; i < grid.GetChildCount() && i < imageUris.size(); ++i)
+ {
+ if (imageUris[i].empty())
+ {
+ continue;
+ }
+
+ SciterElement tile = grid.GetChild(i);
+ if (!tile.IsValid())
+ {
+ continue;
+ }
+
+ SciterElement avatar = tile.FindFirst(".profile-tile-avatar");
+ if (avatar.IsValid())
+ {
+ avatar.SetStyleAttribute("foreground-image", ("url(" + imageUris[i] + ")").c_str());
+ avatar.SetStyleAttribute("foreground-size", "100% 100%");
+ avatar.SetStyleAttribute("foreground-repeat", "no-repeat");
+ avatar.SetStyleAttribute("foreground-position", "50% 50%");
+ }
+ }
+}
+
+void SystemConfigProfiles::SelectCurrentUser(int32_t index)
+{
+ if (index < 0)
+ {
+ return;
+ }
+ m_currentUser = index;
+}
+
+std::string SystemConfigProfiles::GetImageFileUrl(const HostProfileInfo & profile) const
+{
+ if (!m_modules.IsValid())
+ {
+ return {};
+ }
+
+ char path[1024] = {};
+ if (!m_modules.Modules().OperatingSystem().GetProfileImagePath(profile.uuid, path, sizeof(path)) || path[0] == '\0')
+ {
+ return {};
+ }
+ return ImageDataUriFromFile(path);
+}
diff --git a/src/nxemu/user_interface/settings/system_config_profiles.h b/src/nxemu/user_interface/settings/system_config_profiles.h
new file mode 100644
index 00000000..bb364086
--- /dev/null
+++ b/src/nxemu/user_interface/settings/system_config_profiles.h
@@ -0,0 +1,53 @@
+#pragma once
+#include
+#include
+#include
+#include
+#include
+#include
+
+class SystemConfig;
+class SystemModules;
+enum class ProfileEditorMode;
+
+class SystemConfigProfiles :
+ public IPagesSink,
+ public IClickSink
+{
+public:
+ SystemConfigProfiles(ISciterUI & sciterUI, SystemConfig & config, SystemModules & modules, ISciterWindow & window, SciterElement page);
+ ~SystemConfigProfiles() = default;
+
+ void SaveSetting(void);
+
+ // IPagesSink
+ bool PageNavChangeFrom(const std::string & pageName, SCITER_ELEMENT pageNav) override;
+ bool PageNavChangeTo(const std::string & pageName, SCITER_ELEMENT pageNav) override;
+ void PageNavCreatedPage(const std::string & pageName, SCITER_ELEMENT page) override;
+ void PageNavPageChanged(const std::string & pageName, SCITER_ELEMENT pageNav) override;
+
+ // IClickSink
+ bool OnClick(SCITER_ELEMENT element, SCITER_ELEMENT source, uint32_t reason) override;
+
+private:
+ SystemConfigProfiles() = delete;
+ SystemConfigProfiles(const SystemConfigProfiles &) = delete;
+ SystemConfigProfiles & operator=(const SystemConfigProfiles &) = delete;
+
+ void SetupProfilesPage(SciterElement page);
+ void PopulateProfiles();
+ void SelectCurrentUser(int32_t index);
+ void AddProfile();
+ void OpenEditor(ProfileEditorMode mode, int32_t index = -1);
+ std::string GetImageFileUrl(const HostProfileInfo & profile) const;
+
+ ISciterUI & m_sciterUI;
+ SystemConfig & m_config;
+ SystemModules & m_modules;
+ ISciterWindow & m_window;
+ SciterElement m_page;
+ SciterElement m_profilesPage;
+ std::shared_ptr m_pageNav;
+ bool m_enabled;
+ int32_t m_currentUser;
+};
diff --git a/src/nxemu/user_interface/widgets/rom_browser.cpp b/src/nxemu/user_interface/widgets/rom_browser.cpp
index 7f8589d0..e08b1c43 100644
--- a/src/nxemu/user_interface/widgets/rom_browser.cpp
+++ b/src/nxemu/user_interface/widgets/rom_browser.cpp
@@ -1,8 +1,8 @@
#include "rom_browser.h"
+#include "user_interface/html_utils.h"
#include "user_interface/sciter_main_window.h"
#include "settings/ui_settings.h"
#include