[XAM] Implement XamShowSigninUI
This commit is contained in:
parent
842161ca9b
commit
060954f0c3
|
@ -95,9 +95,6 @@ class EmulatorWindow {
|
||||||
|
|
||||||
void ToggleProfilesConfigDialog();
|
void ToggleProfilesConfigDialog();
|
||||||
void SetHotkeysState(bool enabled) { disable_hotkeys_ = !enabled; }
|
void SetHotkeysState(bool enabled) { disable_hotkeys_ = !enabled; }
|
||||||
// We need to store it somewhere so there will be no situation when there are
|
|
||||||
// multiple instances opened.
|
|
||||||
std::unique_ptr<CreateProfileDialog> profile_creation_dialog_;
|
|
||||||
|
|
||||||
// Types of button functions for hotkeys.
|
// Types of button functions for hotkeys.
|
||||||
enum class ButtonFunctions {
|
enum class ButtonFunctions {
|
||||||
|
|
|
@ -11,29 +11,36 @@
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include "xenia/app/emulator_window.h"
|
#include "xenia/app/emulator_window.h"
|
||||||
#include "xenia/base/system.h"
|
#include "xenia/base/system.h"
|
||||||
|
#include "xenia/kernel/util/shim_utils.h"
|
||||||
|
|
||||||
namespace xe {
|
namespace xe {
|
||||||
|
namespace kernel {
|
||||||
|
namespace xam {
|
||||||
|
extern bool xeDrawProfileContent(ui::ImGuiDrawer* imgui_drawer,
|
||||||
|
const uint64_t xuid, const uint8_t user_index,
|
||||||
|
const X_XAMACCOUNTINFO* account,
|
||||||
|
uint64_t* selected_xuid);
|
||||||
|
}
|
||||||
|
} // namespace kernel
|
||||||
namespace app {
|
namespace app {
|
||||||
|
|
||||||
void CreateProfileDialog::OnDraw(ImGuiIO& io) {
|
void CreateProfileDialog::OnDraw(ImGuiIO& io) {
|
||||||
|
if (!has_opened_) {
|
||||||
|
ImGui::OpenPopup("Create Profile");
|
||||||
|
has_opened_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
auto profile_manager = emulator_window_->emulator()
|
auto profile_manager = emulator_window_->emulator()
|
||||||
->kernel_state()
|
->kernel_state()
|
||||||
->xam_state()
|
->xam_state()
|
||||||
->profile_manager();
|
->profile_manager();
|
||||||
|
|
||||||
const auto window_position =
|
|
||||||
ImVec2(GetIO().DisplaySize.x * 0.40f, GetIO().DisplaySize.y * 0.44f);
|
|
||||||
|
|
||||||
ImGui::SetNextWindowPos(window_position, ImGuiCond_FirstUseEver);
|
|
||||||
ImGui::SetNextWindowBgAlpha(1.0f);
|
|
||||||
|
|
||||||
bool dialog_open = true;
|
bool dialog_open = true;
|
||||||
if (!ImGui::Begin("Create Profile", &dialog_open,
|
if (!ImGui::BeginPopupModal("Create Profile", &dialog_open,
|
||||||
ImGuiWindowFlags_NoCollapse |
|
ImGuiWindowFlags_NoCollapse |
|
||||||
ImGuiWindowFlags_AlwaysAutoResize |
|
ImGuiWindowFlags_AlwaysAutoResize |
|
||||||
ImGuiWindowFlags_HorizontalScrollbar)) {
|
ImGuiWindowFlags_HorizontalScrollbar)) {
|
||||||
ImGui::End();
|
Close();
|
||||||
emulator_window_->profile_creation_dialog_.reset();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -43,33 +50,37 @@ void CreateProfileDialog::OnDraw(ImGuiIO& io) {
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::TextUnformatted("Gamertag:");
|
ImGui::TextUnformatted("Gamertag:");
|
||||||
ImGui::InputText("##Gamertag", gamertag, sizeof(gamertag));
|
ImGui::InputText("##Gamertag", gamertag_, sizeof(gamertag_));
|
||||||
|
|
||||||
const std::string gamertag_string = std::string(gamertag);
|
const std::string gamertag_string = std::string(gamertag_);
|
||||||
|
bool valid = profile_manager->IsGamertagValid(gamertag_string);
|
||||||
|
|
||||||
if (profile_manager->IsGamertagValid(gamertag_string)) {
|
ImGui::BeginDisabled(!valid);
|
||||||
if (ImGui::Button("Create")) {
|
if (ImGui::Button("Create")) {
|
||||||
if (profile_manager->CreateProfile(gamertag_string, migration) &&
|
bool autologin = (profile_manager->GetProfilesCount() == 0);
|
||||||
migration) {
|
if (profile_manager->CreateProfile(gamertag_string, autologin,
|
||||||
emulator_window_->emulator()->DataMigration(0xB13EBABEBABEBABE);
|
migration_) &&
|
||||||
}
|
migration_) {
|
||||||
std::fill(std::begin(gamertag), std::end(gamertag), '\0');
|
emulator_window_->emulator()->DataMigration(0xB13EBABEBABEBABE);
|
||||||
dialog_open = false;
|
|
||||||
}
|
}
|
||||||
ImGui::SameLine();
|
std::fill(std::begin(gamertag_), std::end(gamertag_), '\0');
|
||||||
|
dialog_open = false;
|
||||||
}
|
}
|
||||||
|
ImGui::EndDisabled();
|
||||||
|
ImGui::SameLine();
|
||||||
|
|
||||||
if (ImGui::Button("Cancel")) {
|
if (ImGui::Button("Cancel")) {
|
||||||
std::fill(std::begin(gamertag), std::end(gamertag), '\0');
|
std::fill(std::begin(gamertag_), std::end(gamertag_), '\0');
|
||||||
dialog_open = false;
|
dialog_open = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!dialog_open) {
|
if (!dialog_open) {
|
||||||
ImGui::End();
|
ImGui::CloseCurrentPopup();
|
||||||
emulator_window_->profile_creation_dialog_.reset();
|
Close();
|
||||||
|
ImGui::EndPopup();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ImGui::End();
|
ImGui::EndPopup();
|
||||||
}
|
}
|
||||||
|
|
||||||
void NoProfileDialog::OnDraw(ImGuiIO& io) {
|
void NoProfileDialog::OnDraw(ImGuiIO& io) {
|
||||||
|
@ -112,18 +123,14 @@ void NoProfileDialog::OnDraw(ImGuiIO& io) {
|
||||||
emulator_window_->emulator()->content_root());
|
emulator_window_->emulator()->content_root());
|
||||||
|
|
||||||
if (content_files.empty()) {
|
if (content_files.empty()) {
|
||||||
if (ImGui::Button("Create Profile") &&
|
if (ImGui::Button("Create Profile")) {
|
||||||
!emulator_window_->profile_creation_dialog_) {
|
new CreateProfileDialog(emulator_window_->imgui_drawer(),
|
||||||
emulator_window_->profile_creation_dialog_ =
|
emulator_window_);
|
||||||
std::make_unique<CreateProfileDialog>(
|
|
||||||
emulator_window_->imgui_drawer(), emulator_window_);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (ImGui::Button("Create profile & migrate data") &&
|
if (ImGui::Button("Create profile & migrate data")) {
|
||||||
!emulator_window_->profile_creation_dialog_) {
|
new CreateProfileDialog(emulator_window_->imgui_drawer(),
|
||||||
emulator_window_->profile_creation_dialog_ =
|
emulator_window_, true);
|
||||||
std::make_unique<CreateProfileDialog>(
|
|
||||||
emulator_window_->imgui_drawer(), emulator_window_, true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -183,7 +190,8 @@ void ProfileConfigDialog::OnDraw(ImGuiIO& io) {
|
||||||
const uint8_t user_index =
|
const uint8_t user_index =
|
||||||
profile_manager->GetUserIndexAssignedToProfile(xuid);
|
profile_manager->GetUserIndexAssignedToProfile(xuid);
|
||||||
|
|
||||||
if (!DrawProfileContent(xuid, user_index, &account)) {
|
if (!kernel::xam::xeDrawProfileContent(imgui_drawer(), xuid, user_index,
|
||||||
|
&account, &selected_xuid_)) {
|
||||||
ImGui::PopID();
|
ImGui::PopID();
|
||||||
ImGui::End();
|
ImGui::End();
|
||||||
return;
|
return;
|
||||||
|
@ -196,11 +204,8 @@ void ProfileConfigDialog::OnDraw(ImGuiIO& io) {
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
|
|
||||||
if (ImGui::Button("Create Profile") &&
|
if (ImGui::Button("Create Profile")) {
|
||||||
!emulator_window_->profile_creation_dialog_) {
|
new CreateProfileDialog(emulator_window_->imgui_drawer(), emulator_window_);
|
||||||
emulator_window_->profile_creation_dialog_ =
|
|
||||||
std::make_unique<CreateProfileDialog>(emulator_window_->imgui_drawer(),
|
|
||||||
emulator_window_);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::End();
|
ImGui::End();
|
||||||
|
@ -211,131 +216,5 @@ void ProfileConfigDialog::OnDraw(ImGuiIO& io) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ProfileConfigDialog::DrawProfileContent(const uint64_t xuid,
|
|
||||||
const uint8_t user_index,
|
|
||||||
const X_XAMACCOUNTINFO* account) {
|
|
||||||
auto profile_manager = emulator_window_->emulator()
|
|
||||||
->kernel_state()
|
|
||||||
->xam_state()
|
|
||||||
->profile_manager();
|
|
||||||
|
|
||||||
const float default_image_size = 75.0f;
|
|
||||||
auto position = ImGui::GetCursorPos();
|
|
||||||
const float selectable_height =
|
|
||||||
ImGui::GetTextLineHeight() *
|
|
||||||
5; // 3 is for amount of lines of text behind image/object.
|
|
||||||
const auto font = emulator_window_->imgui_drawer()->GetIO().Fonts->Fonts[0];
|
|
||||||
|
|
||||||
const auto text_size = font->CalcTextSizeA(
|
|
||||||
font->FontSize, FLT_MAX, -1.0f,
|
|
||||||
fmt::format("XUID: {:016X}\n", 0xB13EBABEBABEBABE).c_str());
|
|
||||||
|
|
||||||
const auto image_scale = selectable_height / default_image_size;
|
|
||||||
const auto image_size = ImVec2(default_image_size * image_scale,
|
|
||||||
default_image_size * image_scale);
|
|
||||||
// This includes 10% to include empty spaces between border and elements.
|
|
||||||
auto selectable_region_size =
|
|
||||||
ImVec2((image_size.x + text_size.x) * 1.10f, selectable_height);
|
|
||||||
|
|
||||||
if (ImGui::Selectable("##Selectable", selected_xuid_ == xuid,
|
|
||||||
ImGuiSelectableFlags_SpanAllColumns,
|
|
||||||
selectable_region_size)) {
|
|
||||||
selected_xuid_ = xuid;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ImGui::BeginPopupContextItem("Profile Menu")) {
|
|
||||||
if (user_index == static_cast<uint8_t>(-1)) {
|
|
||||||
if (ImGui::MenuItem("Login")) {
|
|
||||||
profile_manager->Login(xuid);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ImGui::BeginMenu("Login to slot:")) {
|
|
||||||
for (uint8_t i = 0; i < XUserMaxUserCount; i++) {
|
|
||||||
if (ImGui::MenuItem(fmt::format("slot {}", i).c_str())) {
|
|
||||||
profile_manager->Login(xuid, i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ImGui::EndMenu();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (ImGui::MenuItem("Logout")) {
|
|
||||||
profile_manager->Logout(user_index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui::MenuItem("Modify (unsupported)");
|
|
||||||
ImGui::MenuItem("Show Achievements (unsupported)");
|
|
||||||
|
|
||||||
if (ImGui::MenuItem("Show Content Directory")) {
|
|
||||||
const auto path = profile_manager->GetProfileContentPath(
|
|
||||||
xuid, emulator_window_->emulator()->kernel_state()->title_id());
|
|
||||||
|
|
||||||
if (!std::filesystem::exists(path)) {
|
|
||||||
std::filesystem::create_directories(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
std::thread path_open(LaunchFileExplorer, path);
|
|
||||||
path_open.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!emulator_window_->emulator()->is_title_open()) {
|
|
||||||
ImGui::Separator();
|
|
||||||
if (ImGui::BeginMenu("Delete Profile")) {
|
|
||||||
ImGui::BeginTooltip();
|
|
||||||
ImGui::TextUnformatted(
|
|
||||||
fmt::format("You're about to delete profile: {} (XUID: {:016X}). "
|
|
||||||
"This will remove all data assigned to this profile "
|
|
||||||
"including savefiles. Are you sure?",
|
|
||||||
account->GetGamertagString(), xuid)
|
|
||||||
.c_str());
|
|
||||||
ImGui::EndTooltip();
|
|
||||||
|
|
||||||
if (ImGui::MenuItem("Yes, delete it!")) {
|
|
||||||
profile_manager->DeleteProfile(xuid);
|
|
||||||
ImGui::EndMenu();
|
|
||||||
ImGui::EndPopup();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui::EndMenu();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ImGui::EndPopup();
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui::SameLine();
|
|
||||||
ImGui::SetCursorPos(position);
|
|
||||||
|
|
||||||
// In the future it can be replaced with profile icon.
|
|
||||||
ImGui::Image(user_index < XUserMaxUserCount
|
|
||||||
? imgui_drawer()->GetNotificationIcon(user_index)
|
|
||||||
: nullptr,
|
|
||||||
ImVec2(default_image_size * image_scale,
|
|
||||||
default_image_size * image_scale));
|
|
||||||
|
|
||||||
ImGui::SameLine();
|
|
||||||
position = ImGui::GetCursorPos();
|
|
||||||
ImGui::TextUnformatted(
|
|
||||||
fmt::format("User: {}\n", account->GetGamertagString()).c_str());
|
|
||||||
|
|
||||||
ImGui::SameLine();
|
|
||||||
ImGui::SetCursorPos(position);
|
|
||||||
ImGui::SetCursorPosY(position.y + ImGui::GetTextLineHeight());
|
|
||||||
ImGui::TextUnformatted(fmt::format("XUID: {:016X}\n", xuid).c_str());
|
|
||||||
|
|
||||||
ImGui::SameLine();
|
|
||||||
ImGui::SetCursorPos(position);
|
|
||||||
ImGui::SetCursorPosY(position.y + 2 * ImGui::GetTextLineHeight());
|
|
||||||
|
|
||||||
if (user_index != static_cast<uint8_t>(-1)) {
|
|
||||||
ImGui::TextUnformatted(
|
|
||||||
fmt::format("Assigned to slot: {}\n", user_index + 1).c_str());
|
|
||||||
} else {
|
|
||||||
ImGui::TextUnformatted(fmt::format("Profile is not signed in").c_str());
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace app
|
} // namespace app
|
||||||
} // namespace xe
|
} // namespace xe
|
|
@ -26,15 +26,16 @@ class CreateProfileDialog final : public ui::ImGuiDialog {
|
||||||
bool with_migration = false)
|
bool with_migration = false)
|
||||||
: ui::ImGuiDialog(imgui_drawer),
|
: ui::ImGuiDialog(imgui_drawer),
|
||||||
emulator_window_(emulator_window),
|
emulator_window_(emulator_window),
|
||||||
migration(with_migration) {
|
migration_(with_migration) {
|
||||||
memset(gamertag, 0, sizeof(gamertag));
|
memset(gamertag_, 0, sizeof(gamertag_));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
void OnDraw(ImGuiIO& io) override;
|
void OnDraw(ImGuiIO& io) override;
|
||||||
|
|
||||||
bool migration = false;
|
bool has_opened_ = false;
|
||||||
char gamertag[16] = "";
|
bool migration_ = false;
|
||||||
|
char gamertag_[16] = "";
|
||||||
EmulatorWindow* emulator_window_;
|
EmulatorWindow* emulator_window_;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -54,19 +55,13 @@ class ProfileConfigDialog final : public ui::ImGuiDialog {
|
||||||
public:
|
public:
|
||||||
ProfileConfigDialog(ui::ImGuiDrawer* imgui_drawer,
|
ProfileConfigDialog(ui::ImGuiDrawer* imgui_drawer,
|
||||||
EmulatorWindow* emulator_window)
|
EmulatorWindow* emulator_window)
|
||||||
: ui::ImGuiDialog(imgui_drawer), emulator_window_(emulator_window) {
|
: ui::ImGuiDialog(imgui_drawer), emulator_window_(emulator_window) {}
|
||||||
memset(gamertag, 0, sizeof(gamertag));
|
|
||||||
}
|
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
void OnDraw(ImGuiIO& io) override;
|
void OnDraw(ImGuiIO& io) override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
bool DrawProfileContent(const uint64_t xuid, const uint8_t user_index,
|
|
||||||
const X_XAMACCOUNTINFO* account);
|
|
||||||
|
|
||||||
uint64_t selected_xuid_ = 0;
|
uint64_t selected_xuid_ = 0;
|
||||||
char gamertag[16] = "";
|
|
||||||
EmulatorWindow* emulator_window_;
|
EmulatorWindow* emulator_window_;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -254,7 +254,8 @@ bool ProfileManager::DismountProfile(const uint64_t xuid) {
|
||||||
fmt::format("{:016X}", xuid) + ':');
|
fmt::format("{:016X}", xuid) + ':');
|
||||||
}
|
}
|
||||||
|
|
||||||
void ProfileManager::Login(const uint64_t xuid, const uint8_t user_index) {
|
void ProfileManager::Login(const uint64_t xuid, const uint8_t user_index,
|
||||||
|
bool notify) {
|
||||||
if (logged_profiles_.size() >= 4 && user_index >= XUserMaxUserCount) {
|
if (logged_profiles_.size() >= 4 && user_index >= XUserMaxUserCount) {
|
||||||
XELOGE(
|
XELOGE(
|
||||||
"Cannot login account with XUID: {:016X} due to lack of free slots "
|
"Cannot login account with XUID: {:016X} due to lack of free slots "
|
||||||
|
@ -293,23 +294,39 @@ void ProfileManager::Login(const uint64_t xuid, const uint8_t user_index) {
|
||||||
|
|
||||||
logged_profiles_[assigned_user_slot] =
|
logged_profiles_[assigned_user_slot] =
|
||||||
std::make_unique<UserProfile>(xuid, &profile);
|
std::make_unique<UserProfile>(xuid, &profile);
|
||||||
kernel_state_->BroadcastNotification(kXNotificationIDSystemSignInChanged,
|
if (notify) {
|
||||||
GetUsedUserSlots().to_ulong());
|
kernel_state_->BroadcastNotification(kXNotificationIDSystemSignInChanged,
|
||||||
|
GetUsedUserSlots().to_ulong());
|
||||||
|
}
|
||||||
UpdateConfig(xuid, assigned_user_slot);
|
UpdateConfig(xuid, assigned_user_slot);
|
||||||
}
|
}
|
||||||
|
|
||||||
void ProfileManager::Logout(const uint8_t user_index) {
|
void ProfileManager::Logout(const uint8_t user_index, bool notify) {
|
||||||
auto profile = logged_profiles_.find(user_index);
|
auto profile = logged_profiles_.find(user_index);
|
||||||
if (profile == logged_profiles_.cend()) {
|
if (profile == logged_profiles_.cend()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
DismountProfile(profile->second->xuid());
|
DismountProfile(profile->second->xuid());
|
||||||
logged_profiles_.erase(profile);
|
logged_profiles_.erase(profile);
|
||||||
kernel_state_->BroadcastNotification(kXNotificationIDSystemSignInChanged,
|
if (notify) {
|
||||||
GetUsedUserSlots().to_ulong());
|
kernel_state_->BroadcastNotification(kXNotificationIDSystemSignInChanged,
|
||||||
|
GetUsedUserSlots().to_ulong());
|
||||||
|
}
|
||||||
UpdateConfig(0, user_index);
|
UpdateConfig(0, user_index);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ProfileManager::LoginMultiple(
|
||||||
|
const std::map<uint8_t, uint64_t>& profiles) {
|
||||||
|
int slots_mask = 0;
|
||||||
|
for (auto [slot, xuid] : profiles) {
|
||||||
|
Login(xuid, slot, false);
|
||||||
|
slots_mask |= (1 << slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
kernel_state_->BroadcastNotification(kXNotificationIDSystemSignInChanged,
|
||||||
|
slots_mask);
|
||||||
|
}
|
||||||
|
|
||||||
std::vector<uint64_t> ProfileManager::FindProfiles() const {
|
std::vector<uint64_t> ProfileManager::FindProfiles() const {
|
||||||
// Info: Profile directory name is also it's offline xuid
|
// Info: Profile directory name is also it's offline xuid
|
||||||
std::vector<uint64_t> profiles_xuids;
|
std::vector<uint64_t> profiles_xuids;
|
||||||
|
@ -410,7 +427,7 @@ std::filesystem::path ProfileManager::GetProfilePath(
|
||||||
fmt::format("{:08X}", XContentType::kProfile) / xuid;
|
fmt::format("{:08X}", XContentType::kProfile) / xuid;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ProfileManager::CreateProfile(const std::string gamertag,
|
bool ProfileManager::CreateProfile(const std::string gamertag, bool autologin,
|
||||||
bool default_xuid) {
|
bool default_xuid) {
|
||||||
const auto xuid = !default_xuid ? GenerateXuid() : 0xB13EBABEBABEBABE;
|
const auto xuid = !default_xuid ? GenerateXuid() : 0xB13EBABEBABEBABE;
|
||||||
|
|
||||||
|
@ -423,7 +440,7 @@ bool ProfileManager::CreateProfile(const std::string gamertag,
|
||||||
}
|
}
|
||||||
|
|
||||||
const bool is_account_created = CreateAccount(xuid, gamertag);
|
const bool is_account_created = CreateAccount(xuid, gamertag);
|
||||||
if (is_account_created) {
|
if (is_account_created && autologin) {
|
||||||
Login(xuid);
|
Login(xuid);
|
||||||
}
|
}
|
||||||
return is_account_created;
|
return is_account_created;
|
||||||
|
|
|
@ -79,15 +79,18 @@ class ProfileManager {
|
||||||
|
|
||||||
~ProfileManager();
|
~ProfileManager();
|
||||||
|
|
||||||
bool CreateProfile(const std::string gamertag, bool default_xuid = false);
|
bool CreateProfile(const std::string gamertag, bool autologin,
|
||||||
|
bool default_xuid = false);
|
||||||
// bool CreateProfile(const X_XAMACCOUNTINFO* account_info);
|
// bool CreateProfile(const X_XAMACCOUNTINFO* account_info);
|
||||||
bool DeleteProfile(const uint64_t xuid);
|
bool DeleteProfile(const uint64_t xuid);
|
||||||
|
|
||||||
bool MountProfile(const uint64_t xuid);
|
bool MountProfile(const uint64_t xuid);
|
||||||
bool DismountProfile(const uint64_t xuid);
|
bool DismountProfile(const uint64_t xuid);
|
||||||
|
|
||||||
void Login(const uint64_t xuid, const uint8_t user_index = -1);
|
void Login(const uint64_t xuid, const uint8_t user_index = -1,
|
||||||
void Logout(const uint8_t user_index);
|
bool notify = true);
|
||||||
|
void Logout(const uint8_t user_index, bool notify = true);
|
||||||
|
void LoginMultiple(const std::map<uint8_t, uint64_t>& profiles);
|
||||||
|
|
||||||
bool LoadAccount(const uint64_t xuid);
|
bool LoadAccount(const uint64_t xuid);
|
||||||
void LoadAccounts(const std::vector<uint64_t> profiles_xuids);
|
void LoadAccounts(const std::vector<uint64_t> profiles_xuids);
|
||||||
|
|
|
@ -10,7 +10,9 @@
|
||||||
#include "third_party/imgui/imgui.h"
|
#include "third_party/imgui/imgui.h"
|
||||||
#include "xenia/base/logging.h"
|
#include "xenia/base/logging.h"
|
||||||
#include "xenia/base/string_util.h"
|
#include "xenia/base/string_util.h"
|
||||||
|
#include "xenia/base/system.h"
|
||||||
#include "xenia/emulator.h"
|
#include "xenia/emulator.h"
|
||||||
|
#include "xenia/hid/input_system.h"
|
||||||
#include "xenia/kernel/kernel_flags.h"
|
#include "xenia/kernel/kernel_flags.h"
|
||||||
#include "xenia/kernel/kernel_state.h"
|
#include "xenia/kernel/kernel_state.h"
|
||||||
#include "xenia/kernel/util/shim_utils.h"
|
#include "xenia/kernel/util/shim_utils.h"
|
||||||
|
@ -819,38 +821,415 @@ dword_result_t XamShowMarketplaceDownloadItemsUI_entry(
|
||||||
}
|
}
|
||||||
DECLARE_XAM_EXPORT1(XamShowMarketplaceDownloadItemsUI, kUI, kSketchy);
|
DECLARE_XAM_EXPORT1(XamShowMarketplaceDownloadItemsUI, kUI, kSketchy);
|
||||||
|
|
||||||
dword_result_t XamShowSigninUI_entry(dword_t users_needed, dword_t unk_mask) {
|
bool xeDrawProfileContent(ui::ImGuiDrawer* imgui_drawer, const uint64_t xuid,
|
||||||
// XN_SYS_UI (on)
|
const uint8_t user_index,
|
||||||
kernel_state()->BroadcastNotification(kXNotificationIDSystemUI, 1);
|
const X_XAMACCOUNTINFO* account,
|
||||||
// Mask values vary. Probably matching user types? Local/remote?
|
uint64_t* selected_xuid) {
|
||||||
// Games seem to sit and loop until we trigger this notification:
|
auto profile_manager = kernel_state()->xam_state()->profile_manager();
|
||||||
|
|
||||||
auto run = [users_needed]() -> void {
|
const float default_image_size = 75.0f;
|
||||||
uint32_t user_mask = 0;
|
auto position = ImGui::GetCursorPos();
|
||||||
uint32_t active_users = 0;
|
const float selectable_height =
|
||||||
|
ImGui::GetTextLineHeight() *
|
||||||
|
5; // 3 is for amount of lines of text behind image/object.
|
||||||
|
const auto font = imgui_drawer->GetIO().Fonts->Fonts[0];
|
||||||
|
|
||||||
for (uint32_t i = 0; i < XUserMaxUserCount; i++) {
|
const auto text_size = font->CalcTextSizeA(
|
||||||
if (kernel_state()->xam_state()->IsUserSignedIn(i)) {
|
font->FontSize, FLT_MAX, -1.0f,
|
||||||
user_mask |= (1 << i);
|
fmt::format("XUID: {:016X}\n", 0xB13EBABEBABEBABE).c_str());
|
||||||
active_users++;
|
|
||||||
if (active_users >= users_needed) break;
|
const auto image_scale = selectable_height / default_image_size;
|
||||||
}
|
const auto image_size = ImVec2(default_image_size * image_scale,
|
||||||
|
default_image_size * image_scale);
|
||||||
|
|
||||||
|
if (xuid && selected_xuid) {
|
||||||
|
// This includes 10% to include empty spaces between border and elements.
|
||||||
|
auto selectable_region_size =
|
||||||
|
ImVec2((image_size.x + text_size.x) * 1.10f, selectable_height);
|
||||||
|
|
||||||
|
if (ImGui::Selectable("##Selectable", *selected_xuid == xuid,
|
||||||
|
ImGuiSelectableFlags_SpanAllColumns,
|
||||||
|
selectable_region_size)) {
|
||||||
|
*selected_xuid = xuid;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::this_thread::sleep_for(std::chrono::milliseconds(150));
|
if (ImGui::BeginPopupContextItem("Profile Menu")) {
|
||||||
// XN_SYS_SIGNINCHANGED (players)
|
if (user_index == static_cast<uint8_t>(-1)) {
|
||||||
kernel_state()->BroadcastNotification(kXNotificationIDSystemSignInChanged,
|
if (ImGui::MenuItem("Login")) {
|
||||||
user_mask);
|
profile_manager->Login(xuid);
|
||||||
// XN_SYS_UI (off)
|
}
|
||||||
kernel_state()->BroadcastNotification(kXNotificationIDSystemUI, 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
std::thread thread(run);
|
if (ImGui::BeginMenu("Login to slot:")) {
|
||||||
thread.detach();
|
for (uint8_t i = 0; i < XUserMaxUserCount; i++) {
|
||||||
|
if (ImGui::MenuItem(fmt::format("slot {}", i).c_str())) {
|
||||||
|
profile_manager->Login(xuid, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImGui::EndMenu();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (ImGui::MenuItem("Logout")) {
|
||||||
|
profile_manager->Logout(user_index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return X_ERROR_SUCCESS;
|
ImGui::MenuItem("Modify (unsupported)");
|
||||||
|
ImGui::MenuItem("Show Achievements (unsupported)");
|
||||||
|
|
||||||
|
if (ImGui::MenuItem("Show Content Directory")) {
|
||||||
|
const auto path = profile_manager->GetProfileContentPath(
|
||||||
|
xuid, kernel_state()->title_id());
|
||||||
|
|
||||||
|
if (!std::filesystem::exists(path)) {
|
||||||
|
std::filesystem::create_directories(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::thread path_open(LaunchFileExplorer, path);
|
||||||
|
path_open.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!kernel_state()->emulator()->is_title_open()) {
|
||||||
|
ImGui::Separator();
|
||||||
|
if (ImGui::BeginMenu("Delete Profile")) {
|
||||||
|
ImGui::BeginTooltip();
|
||||||
|
ImGui::TextUnformatted(
|
||||||
|
fmt::format("You're about to delete profile: {} (XUID: {:016X}). "
|
||||||
|
"This will remove all data assigned to this profile "
|
||||||
|
"including savefiles. Are you sure?",
|
||||||
|
account->GetGamertagString(), xuid)
|
||||||
|
.c_str());
|
||||||
|
ImGui::EndTooltip();
|
||||||
|
|
||||||
|
if (ImGui::MenuItem("Yes, delete it!")) {
|
||||||
|
profile_manager->DeleteProfile(xuid);
|
||||||
|
ImGui::EndMenu();
|
||||||
|
ImGui::EndPopup();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::EndMenu();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImGui::EndPopup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::SameLine();
|
||||||
|
ImGui::SetCursorPos(position);
|
||||||
|
|
||||||
|
// In the future it can be replaced with profile icon.
|
||||||
|
ImGui::Image(user_index < XUserMaxUserCount
|
||||||
|
? imgui_drawer->GetNotificationIcon(user_index)
|
||||||
|
: nullptr,
|
||||||
|
ImVec2(default_image_size * image_scale,
|
||||||
|
default_image_size * image_scale));
|
||||||
|
|
||||||
|
ImGui::SameLine();
|
||||||
|
position = ImGui::GetCursorPos();
|
||||||
|
ImGui::TextUnformatted(
|
||||||
|
fmt::format("User: {}\n", account->GetGamertagString()).c_str());
|
||||||
|
|
||||||
|
ImGui::SameLine();
|
||||||
|
ImGui::SetCursorPos(position);
|
||||||
|
ImGui::SetCursorPosY(position.y + ImGui::GetTextLineHeight());
|
||||||
|
ImGui::TextUnformatted(fmt::format("XUID: {:016X}\n", xuid).c_str());
|
||||||
|
|
||||||
|
ImGui::SameLine();
|
||||||
|
ImGui::SetCursorPos(position);
|
||||||
|
ImGui::SetCursorPosY(position.y + 2 * ImGui::GetTextLineHeight());
|
||||||
|
|
||||||
|
if (user_index != static_cast<uint8_t>(-1)) {
|
||||||
|
ImGui::TextUnformatted(
|
||||||
|
fmt::format("Assigned to slot: {}\n", user_index + 1).c_str());
|
||||||
|
} else {
|
||||||
|
ImGui::TextUnformatted(fmt::format("Profile is not signed in").c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
DECLARE_XAM_EXPORT1(XamShowSigninUI, kUserProfiles, kStub);
|
|
||||||
|
class SigninDialog : public XamDialog {
|
||||||
|
public:
|
||||||
|
SigninDialog(xe::ui::ImGuiDrawer* imgui_drawer, uint32_t users_needed)
|
||||||
|
: XamDialog(imgui_drawer),
|
||||||
|
users_needed_(users_needed),
|
||||||
|
title_("Sign In") {
|
||||||
|
last_user_ = kernel_state()->emulator()->input_system()->GetLastUsedSlot();
|
||||||
|
|
||||||
|
for (uint8_t slot = 0; slot < XUserMaxUserCount; slot++) {
|
||||||
|
std::string name = fmt::format("Slot {:d}", slot + 1);
|
||||||
|
slot_data_.push_back({slot, name});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual ~SigninDialog() {}
|
||||||
|
|
||||||
|
void OnDraw(ImGuiIO& io) override {
|
||||||
|
bool first_draw = false;
|
||||||
|
if (!has_opened_) {
|
||||||
|
ImGui::OpenPopup(title_.c_str());
|
||||||
|
has_opened_ = true;
|
||||||
|
first_draw = true;
|
||||||
|
ReloadProfiles(true);
|
||||||
|
}
|
||||||
|
if (ImGui::BeginPopupModal(title_.c_str(), nullptr,
|
||||||
|
ImGuiWindowFlags_AlwaysAutoResize)) {
|
||||||
|
auto profile_manager = kernel_state()->xam_state()->profile_manager();
|
||||||
|
auto profiles = profile_manager->GetProfiles();
|
||||||
|
|
||||||
|
for (uint32_t i = 0; i < users_needed_; i++) {
|
||||||
|
ImGui::BeginGroup();
|
||||||
|
|
||||||
|
std::vector<const char*> combo_items;
|
||||||
|
int items_count = 0;
|
||||||
|
int current_item = 0;
|
||||||
|
|
||||||
|
// Fill slot list.
|
||||||
|
std::vector<uint8_t> slots;
|
||||||
|
slots.push_back(0xFF);
|
||||||
|
combo_items.push_back("---");
|
||||||
|
for (auto& elem : slot_data_) {
|
||||||
|
// Select the slot or skip it if it's already used.
|
||||||
|
bool already_taken = false;
|
||||||
|
for (uint32_t j = 0; j < users_needed_; j++) {
|
||||||
|
if (chosen_slots_[j] == elem.first) {
|
||||||
|
if (i == j) {
|
||||||
|
current_item = static_cast<int>(combo_items.size());
|
||||||
|
} else {
|
||||||
|
already_taken = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (already_taken) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
slots.push_back(elem.first);
|
||||||
|
combo_items.push_back(elem.second.c_str());
|
||||||
|
}
|
||||||
|
items_count = static_cast<int>(combo_items.size());
|
||||||
|
|
||||||
|
ImGui::BeginDisabled(users_needed_ == 1);
|
||||||
|
ImGui::Combo(fmt::format("##Slot{:d}", i).c_str(), ¤t_item,
|
||||||
|
combo_items.data(), items_count);
|
||||||
|
chosen_slots_[i] = slots[current_item];
|
||||||
|
ImGui::EndDisabled();
|
||||||
|
ImGui::Spacing();
|
||||||
|
|
||||||
|
combo_items.clear();
|
||||||
|
current_item = 0;
|
||||||
|
|
||||||
|
// Fill profile list.
|
||||||
|
std::vector<uint64_t> xuids;
|
||||||
|
xuids.push_back(0);
|
||||||
|
combo_items.push_back("---");
|
||||||
|
if (chosen_slots_[i] != 0xFF) {
|
||||||
|
for (auto& elem : profile_data_) {
|
||||||
|
// Select the profile or skip it if it's already used.
|
||||||
|
bool already_taken = false;
|
||||||
|
for (uint32_t j = 0; j < users_needed_; j++) {
|
||||||
|
if (chosen_xuids_[j] == elem.first) {
|
||||||
|
if (i == j) {
|
||||||
|
current_item = static_cast<int>(combo_items.size());
|
||||||
|
} else {
|
||||||
|
already_taken = true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (already_taken) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
xuids.push_back(elem.first);
|
||||||
|
combo_items.push_back(elem.second.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items_count = static_cast<int>(combo_items.size());
|
||||||
|
|
||||||
|
ImGui::BeginDisabled(chosen_slots_[i] == 0xFF);
|
||||||
|
ImGui::Combo(fmt::format("##Profile{:d}", i).c_str(), ¤t_item,
|
||||||
|
combo_items.data(), items_count);
|
||||||
|
chosen_xuids_[i] = xuids[current_item];
|
||||||
|
ImGui::EndDisabled();
|
||||||
|
ImGui::Spacing();
|
||||||
|
|
||||||
|
// Draw profile badge.
|
||||||
|
uint8_t slot = chosen_slots_[i];
|
||||||
|
uint64_t xuid = chosen_xuids_[i];
|
||||||
|
|
||||||
|
if (slot == 0xFF || xuid == 0 || profiles->count(xuid) == 0) {
|
||||||
|
float ypos = ImGui::GetCursorPosY();
|
||||||
|
ImGui::SetCursorPosY(ypos + ImGui::GetTextLineHeight() * 5);
|
||||||
|
} else {
|
||||||
|
const X_XAMACCOUNTINFO* account = &profiles->at(xuid);
|
||||||
|
xeDrawProfileContent(imgui_drawer(), xuid, slot, account, nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::EndGroup();
|
||||||
|
if (i != (users_needed_ - 1) && (i == 0 || i == 2)) {
|
||||||
|
ImGui::SameLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::Spacing();
|
||||||
|
|
||||||
|
if (ImGui::Button("Create Profile")) {
|
||||||
|
creating_profile_ = true;
|
||||||
|
ImGui::OpenPopup("Create Profile");
|
||||||
|
first_draw = true;
|
||||||
|
}
|
||||||
|
ImGui::Spacing();
|
||||||
|
|
||||||
|
if (creating_profile_) {
|
||||||
|
if (ImGui::BeginPopupModal("Create Profile", nullptr,
|
||||||
|
ImGuiWindowFlags_NoCollapse |
|
||||||
|
ImGuiWindowFlags_AlwaysAutoResize |
|
||||||
|
ImGuiWindowFlags_HorizontalScrollbar)) {
|
||||||
|
if (first_draw) {
|
||||||
|
ImGui::SetKeyboardFocusHere();
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::TextUnformatted("Gamertag:");
|
||||||
|
ImGui::InputText("##Gamertag", gamertag_, sizeof(gamertag_));
|
||||||
|
|
||||||
|
const std::string gamertag_string = gamertag_;
|
||||||
|
bool valid = profile_manager->IsGamertagValid(gamertag_string);
|
||||||
|
|
||||||
|
ImGui::BeginDisabled(!valid);
|
||||||
|
if (ImGui::Button("Create")) {
|
||||||
|
profile_manager->CreateProfile(gamertag_string, false);
|
||||||
|
std::fill(std::begin(gamertag_), std::end(gamertag_), '\0');
|
||||||
|
ImGui::CloseCurrentPopup();
|
||||||
|
creating_profile_ = false;
|
||||||
|
ReloadProfiles(false);
|
||||||
|
}
|
||||||
|
ImGui::EndDisabled();
|
||||||
|
ImGui::SameLine();
|
||||||
|
|
||||||
|
if (ImGui::Button("Cancel")) {
|
||||||
|
std::fill(std::begin(gamertag_), std::end(gamertag_), '\0');
|
||||||
|
ImGui::CloseCurrentPopup();
|
||||||
|
creating_profile_ = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::EndPopup();
|
||||||
|
} else {
|
||||||
|
creating_profile_ = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ImGui::Button("OK")) {
|
||||||
|
std::map<uint8_t, uint64_t> profile_map;
|
||||||
|
for (uint32_t i = 0; i < users_needed_; i++) {
|
||||||
|
uint8_t slot = chosen_slots_[i];
|
||||||
|
uint64_t xuid = chosen_xuids_[i];
|
||||||
|
if (slot != 0xFF && xuid != 0) {
|
||||||
|
profile_map[slot] = xuid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
profile_manager->LoginMultiple(profile_map);
|
||||||
|
|
||||||
|
ImGui::CloseCurrentPopup();
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
ImGui::SameLine();
|
||||||
|
|
||||||
|
if (ImGui::Button("Cancel")) {
|
||||||
|
ImGui::CloseCurrentPopup();
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::Spacing();
|
||||||
|
ImGui::Spacing();
|
||||||
|
ImGui::EndPopup();
|
||||||
|
} else {
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReloadProfiles(bool first_draw) {
|
||||||
|
auto profile_manager = kernel_state()->xam_state()->profile_manager();
|
||||||
|
auto profiles = profile_manager->GetProfiles();
|
||||||
|
|
||||||
|
profile_data_.clear();
|
||||||
|
for (auto& [xuid, account] : *profiles) {
|
||||||
|
profile_data_.push_back({xuid, account.GetGamertagString()});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (first_draw) {
|
||||||
|
// If only one user is requested, request last used controller to sign in.
|
||||||
|
if (users_needed_ == 1) {
|
||||||
|
chosen_slots_[0] = last_user_;
|
||||||
|
} else {
|
||||||
|
for (uint32_t i = 0; i < users_needed_; i++) {
|
||||||
|
// TODO: Not sure about this, needs testing on real hardware.
|
||||||
|
chosen_slots_[i] = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default profile selection to profile that is already signed in.
|
||||||
|
for (auto& elem : profile_data_) {
|
||||||
|
uint64_t xuid = elem.first;
|
||||||
|
uint8_t slot = profile_manager->GetUserIndexAssignedToProfile(xuid);
|
||||||
|
for (uint32_t j = 0; j < users_needed_; j++) {
|
||||||
|
if (chosen_slots_[j] != 0xFF && slot == chosen_slots_[j]) {
|
||||||
|
chosen_xuids_[j] = xuid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool has_opened_ = false;
|
||||||
|
std::string title_;
|
||||||
|
uint32_t users_needed_ = 1;
|
||||||
|
uint32_t last_user_ = 0;
|
||||||
|
|
||||||
|
std::vector<std::pair<uint8_t, std::string>> slot_data_;
|
||||||
|
std::vector<std::pair<uint64_t, std::string>> profile_data_;
|
||||||
|
uint8_t chosen_slots_[XUserMaxUserCount] = {};
|
||||||
|
uint64_t chosen_xuids_[XUserMaxUserCount] = {};
|
||||||
|
|
||||||
|
bool creating_profile_ = false;
|
||||||
|
char gamertag_[16] = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
dword_result_t XamShowSigninUI_entry(dword_t users_needed, dword_t unk_mask) {
|
||||||
|
// Mask values vary. Probably matching user types? Local/remote?
|
||||||
|
// Games seem to sit and loop until we trigger sign in notification.
|
||||||
|
if (users_needed != 1 && users_needed != 2 && users_needed != 4) {
|
||||||
|
return X_ERROR_INVALID_PARAMETER;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cvars::headless) {
|
||||||
|
return xeXamDispatchHeadlessAsync([users_needed]() {
|
||||||
|
std::map<uint8_t, uint64_t> xuids;
|
||||||
|
|
||||||
|
for (uint32_t i = 0; i < XUserMaxUserCount; i++) {
|
||||||
|
UserProfile* profile = kernel_state()->xam_state()->GetUserProfile(i);
|
||||||
|
if (profile) {
|
||||||
|
xuids[i] = profile->xuid();
|
||||||
|
if (xuids.size() >= users_needed) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kernel_state()->xam_state()->profile_manager()->LoginMultiple(xuids);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
auto close = [](SigninDialog* dialog) -> void {};
|
||||||
|
|
||||||
|
const Emulator* emulator = kernel_state()->emulator();
|
||||||
|
ui::ImGuiDrawer* imgui_drawer = emulator->imgui_drawer();
|
||||||
|
return xeXamDispatchDialogAsync<SigninDialog>(
|
||||||
|
new SigninDialog(imgui_drawer, users_needed), close);
|
||||||
|
}
|
||||||
|
DECLARE_XAM_EXPORT1(XamShowSigninUI, kUserProfiles, kImplemented);
|
||||||
|
|
||||||
} // namespace xam
|
} // namespace xam
|
||||||
} // namespace kernel
|
} // namespace kernel
|
||||||
|
|
Loading…
Reference in New Issue