From c46f6879ff9171a1e024965618242e8bad373b6b Mon Sep 17 00:00:00 2001 From: Ac_K Date: Fri, 23 Apr 2021 22:26:31 +0200 Subject: [PATCH] account: add Custom User Profiles support (#2227) * Initial Impl * Fix names * remove useless ContentManager * Support backgrounds and improve avatar loading * Fix firmware checks * Addresses gdkchan feedback --- .../Services/Account/Acc/AccountManager.cs | 172 ++++++++- .../Account/Acc/AccountSaveDataManager.cs | 87 +++++ .../Services/Account/Acc/Types/UserProfile.cs | 73 +++- .../HOS/Services/Caps/CaptureManager.cs | 6 - .../Friend/ServiceCreator/IFriendService.cs | 16 +- Ryujinx/Program.cs | 7 + Ryujinx/Ui/MainWindow.cs | 17 +- Ryujinx/Ui/MainWindow.glade | 10 + Ryujinx/Ui/Widgets/GameTableContextMenu.cs | 2 +- Ryujinx/Ui/Widgets/GtkDialog.cs | 29 ++ Ryujinx/Ui/Widgets/GtkInputDialog.cs | 37 ++ Ryujinx/Ui/Windows/AvatarWindow.cs | 289 ++++++++++++++++ .../UserProfilesManagerWindow.Designer.cs | 255 ++++++++++++++ .../Ui/Windows/UserProfilesManagerWindow.cs | 327 ++++++++++++++++++ 14 files changed, 1286 insertions(+), 41 deletions(-) create mode 100644 Ryujinx.HLE/HOS/Services/Account/Acc/AccountSaveDataManager.cs create mode 100644 Ryujinx/Ui/Widgets/GtkInputDialog.cs create mode 100644 Ryujinx/Ui/Windows/AvatarWindow.cs create mode 100644 Ryujinx/Ui/Windows/UserProfilesManagerWindow.Designer.cs create mode 100644 Ryujinx/Ui/Windows/UserProfilesManagerWindow.cs diff --git a/Ryujinx.HLE/HOS/Services/Account/Acc/AccountManager.cs b/Ryujinx.HLE/HOS/Services/Account/Acc/AccountManager.cs index d36ea9314..2cea57e9e 100644 --- a/Ryujinx.HLE/HOS/Services/Account/Acc/AccountManager.cs +++ b/Ryujinx.HLE/HOS/Services/Account/Acc/AccountManager.cs @@ -1,41 +1,85 @@ -using Ryujinx.Common; +using LibHac; +using LibHac.Fs; +using LibHac.Fs.Shim; +using Ryujinx.Common; +using Ryujinx.HLE.FileSystem; +using Ryujinx.HLE.FileSystem.Content; +using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.IO; using System.Linq; namespace Ryujinx.HLE.HOS.Services.Account.Acc { public class AccountManager { + public static readonly UserId DefaultUserId = new UserId("00000000000000010000000000000000"); + + private readonly VirtualFileSystem _virtualFileSystem; + private readonly AccountSaveDataManager _accountSaveDataManager; + private ConcurrentDictionary _profiles; public UserProfile LastOpenedUser { get; private set; } - public AccountManager() + public AccountManager(VirtualFileSystem virtualFileSystem) { + _virtualFileSystem = virtualFileSystem; + _profiles = new ConcurrentDictionary(); - UserId defaultUserId = new UserId("00000000000000010000000000000000"); - byte[] defaultUserImage = EmbeddedResources.Read("Ryujinx.HLE/HOS/Services/Account/Acc/DefaultUserImage.jpg"); + _accountSaveDataManager = new AccountSaveDataManager(_profiles); - AddUser(defaultUserId, "Player", defaultUserImage); - - OpenUser(defaultUserId); + if (!_profiles.TryGetValue(DefaultUserId.ToString(), out _)) + { + byte[] defaultUserImage = EmbeddedResources.Read("Ryujinx.HLE/HOS/Services/Account/Acc/DefaultUserImage.jpg"); + + AddUser("RyuPlayer", defaultUserImage, DefaultUserId); + + OpenUser(DefaultUserId); + } + else + { + OpenUser(_accountSaveDataManager.LastOpened); + } } - public void AddUser(UserId userId, string name, byte[] image) + public void AddUser(string name, byte[] image, UserId userId = new UserId()) { + if (userId.IsNull) + { + userId = new UserId(Guid.NewGuid().ToString().Replace("-", "")); + } + UserProfile profile = new UserProfile(userId, name, image); _profiles.AddOrUpdate(userId.ToString(), profile, (key, old) => profile); + + _accountSaveDataManager.Save(_profiles); } public void OpenUser(UserId userId) { if (_profiles.TryGetValue(userId.ToString(), out UserProfile profile)) { + // TODO: Support multiple open users ? + foreach (UserProfile userProfile in GetAllUsers()) + { + if (userProfile == LastOpenedUser) + { + userProfile.AccountState = AccountState.Closed; + + break; + } + } + (LastOpenedUser = profile).AccountState = AccountState.Open; + + _accountSaveDataManager.LastOpened = userId; } + + _accountSaveDataManager.Save(_profiles); } public void CloseUser(UserId userId) @@ -44,9 +88,117 @@ namespace Ryujinx.HLE.HOS.Services.Account.Acc { profile.AccountState = AccountState.Closed; } + + _accountSaveDataManager.Save(_profiles); } - public int GetUserCount() + public void OpenUserOnlinePlay(UserId userId) + { + if (_profiles.TryGetValue(userId.ToString(), out UserProfile profile)) + { + // TODO: Support multiple open online users ? + foreach (UserProfile userProfile in GetAllUsers()) + { + if (userProfile == LastOpenedUser) + { + userProfile.OnlinePlayState = AccountState.Closed; + + break; + } + } + + profile.OnlinePlayState = AccountState.Open; + } + + _accountSaveDataManager.Save(_profiles); + } + + public void CloseUserOnlinePlay(UserId userId) + { + if (_profiles.TryGetValue(userId.ToString(), out UserProfile profile)) + { + profile.OnlinePlayState = AccountState.Closed; + } + + _accountSaveDataManager.Save(_profiles); + } + + public void SetUserImage(UserId userId, byte[] image) + { + foreach (UserProfile userProfile in GetAllUsers()) + { + if (userProfile.UserId == userId) + { + userProfile.Image = image; + + break; + } + } + + _accountSaveDataManager.Save(_profiles); + } + + public void SetUserName(UserId userId, string name) + { + foreach (UserProfile userProfile in GetAllUsers()) + { + if (userProfile.UserId == userId) + { + userProfile.Name = name; + + break; + } + } + + _accountSaveDataManager.Save(_profiles); + } + + public void DeleteUser(UserId userId) + { + DeleteSaveData(userId); + + _profiles.Remove(userId.ToString(), out _); + + OpenUser(DefaultUserId); + + _accountSaveDataManager.Save(_profiles); + } + + private void DeleteSaveData(UserId userId) + { + SaveDataFilter saveDataFilter = new SaveDataFilter(); + saveDataFilter.SetUserId(new LibHac.Fs.UserId((ulong)userId.High, (ulong)userId.Low)); + + Result result = _virtualFileSystem.FsClient.OpenSaveDataIterator(out SaveDataIterator saveDataIterator, SaveDataSpaceId.User, ref saveDataFilter); + if (result.IsSuccess()) + { + Span saveDataInfo = stackalloc SaveDataInfo[10]; + + while (true) + { + saveDataIterator.ReadSaveDataInfo(out long readCount, saveDataInfo); + + if (readCount == 0) + { + break; + } + + for (int i = 0; i < readCount; i++) + { + // TODO: We use Directory.Delete workaround because DeleteSaveData softlock without, due to a bug in LibHac 0.12.0. + string savePath = Path.Combine(_virtualFileSystem.GetNandPath(), $"user/save/{saveDataInfo[i].SaveDataId:x16}"); + string saveMetaPath = Path.Combine(_virtualFileSystem.GetNandPath(), $"user/saveMeta/{saveDataInfo[i].SaveDataId:x16}"); + + Directory.Delete(savePath, true); + Directory.Delete(saveMetaPath, true); + + _virtualFileSystem.FsClient.DeleteSaveData(SaveDataSpaceId.User, saveDataInfo[i].SaveDataId); + } + } + } + } + + internal int GetUserCount() { return _profiles.Count; } @@ -56,7 +208,7 @@ namespace Ryujinx.HLE.HOS.Services.Account.Acc return _profiles.TryGetValue(userId.ToString(), out profile); } - internal IEnumerable GetAllUsers() + public IEnumerable GetAllUsers() { return _profiles.Values; } diff --git a/Ryujinx.HLE/HOS/Services/Account/Acc/AccountSaveDataManager.cs b/Ryujinx.HLE/HOS/Services/Account/Acc/AccountSaveDataManager.cs new file mode 100644 index 000000000..44ef3f335 --- /dev/null +++ b/Ryujinx.HLE/HOS/Services/Account/Acc/AccountSaveDataManager.cs @@ -0,0 +1,87 @@ +using Ryujinx.Common.Configuration; +using Ryujinx.Common.Utilities; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Text.Json.Serialization; + +namespace Ryujinx.HLE.HOS.Services.Account.Acc +{ + class AccountSaveDataManager + { + private readonly string _profilesJsonPath = Path.Join(AppDataManager.BaseDirPath, "system", "Profiles.json"); + + private struct ProfilesJson + { + [JsonPropertyName("profiles")] + public List Profiles { get; set; } + [JsonPropertyName("last_opened")] + public string LastOpened { get; set; } + } + + private struct UserProfileJson + { + [JsonPropertyName("user_id")] + public string UserId { get; set; } + [JsonPropertyName("name")] + public string Name { get; set; } + [JsonPropertyName("account_state")] + public AccountState AccountState { get; set; } + [JsonPropertyName("online_play_state")] + public AccountState OnlinePlayState { get; set; } + [JsonPropertyName("last_modified_timestamp")] + public long LastModifiedTimestamp { get; set; } + [JsonPropertyName("image")] + public byte[] Image { get; set; } + } + + public UserId LastOpened { get; set; } + + public AccountSaveDataManager(ConcurrentDictionary profiles) + { + // TODO: Use 0x8000000000000010 system savedata instead of a JSON file if needed. + + if (File.Exists(_profilesJsonPath)) + { + ProfilesJson profilesJson = JsonHelper.DeserializeFromFile(_profilesJsonPath); + + foreach (var profile in profilesJson.Profiles) + { + UserProfile addedProfile = new UserProfile(new UserId(profile.UserId), profile.Name, profile.Image, profile.LastModifiedTimestamp); + + profiles.AddOrUpdate(profile.UserId, addedProfile, (key, old) => addedProfile); + } + + LastOpened = new UserId(profilesJson.LastOpened); + } + else + { + LastOpened = AccountManager.DefaultUserId; + } + } + + public void Save(ConcurrentDictionary profiles) + { + ProfilesJson profilesJson = new ProfilesJson() + { + Profiles = new List(), + LastOpened = LastOpened.ToString() + }; + + foreach (var profile in profiles) + { + profilesJson.Profiles.Add(new UserProfileJson() + { + UserId = profile.Value.UserId.ToString(), + Name = profile.Value.Name, + AccountState = profile.Value.AccountState, + OnlinePlayState = profile.Value.OnlinePlayState, + LastModifiedTimestamp = profile.Value.LastModifiedTimestamp, + Image = profile.Value.Image, + }); + } + + File.WriteAllText(_profilesJsonPath, JsonHelper.Serialize(profilesJson, true)); + } + } +} \ No newline at end of file diff --git a/Ryujinx.HLE/HOS/Services/Account/Acc/Types/UserProfile.cs b/Ryujinx.HLE/HOS/Services/Account/Acc/Types/UserProfile.cs index a57796c99..ef0a1a645 100644 --- a/Ryujinx.HLE/HOS/Services/Account/Acc/Types/UserProfile.cs +++ b/Ryujinx.HLE/HOS/Services/Account/Acc/Types/UserProfile.cs @@ -8,31 +8,80 @@ namespace Ryujinx.HLE.HOS.Services.Account.Acc public UserId UserId { get; } - public string Name { get; } + public long LastModifiedTimestamp { get; set; } - public byte[] Image { get; } + private string _name; - public long LastModifiedTimestamp { get; private set; } + public string Name + { + get => _name; + set + { + _name = value; - public AccountState AccountState { get; set; } - public AccountState OnlinePlayState { get; set; } + UpdateLastModifiedTimestamp(); + } + } - public UserProfile(UserId userId, string name, byte[] image) + private byte[] _image; + + public byte[] Image + { + get => _image; + set + { + _image = value; + + UpdateLastModifiedTimestamp(); + } + } + + private AccountState _accountState; + + public AccountState AccountState + { + get => _accountState; + set + { + _accountState = value; + + UpdateLastModifiedTimestamp(); + } + } + + public AccountState _onlinePlayState; + + public AccountState OnlinePlayState + { + get => _onlinePlayState; + set + { + _onlinePlayState = value; + + UpdateLastModifiedTimestamp(); + } + } + + public UserProfile(UserId userId, string name, byte[] image, long lastModifiedTimestamp = 0) { UserId = userId; Name = name; - - Image = image; - - LastModifiedTimestamp = 0; + Image = image; AccountState = AccountState.Closed; OnlinePlayState = AccountState.Closed; - UpdateTimestamp(); + if (lastModifiedTimestamp != 0) + { + LastModifiedTimestamp = lastModifiedTimestamp; + } + else + { + UpdateLastModifiedTimestamp(); + } } - private void UpdateTimestamp() + private void UpdateLastModifiedTimestamp() { LastModifiedTimestamp = (long)(DateTime.Now - Epoch).TotalSeconds; } diff --git a/Ryujinx.HLE/HOS/Services/Caps/CaptureManager.cs b/Ryujinx.HLE/HOS/Services/Caps/CaptureManager.cs index 37cc9bdab..357815627 100644 --- a/Ryujinx.HLE/HOS/Services/Caps/CaptureManager.cs +++ b/Ryujinx.HLE/HOS/Services/Caps/CaptureManager.cs @@ -1,7 +1,6 @@ using Ryujinx.Common.Memory; using Ryujinx.HLE.HOS.Services.Caps.Types; using SixLabors.ImageSharp; -using SixLabors.ImageSharp.Formats.Jpeg; using SixLabors.ImageSharp.PixelFormats; using System; using System.IO; @@ -19,11 +18,6 @@ namespace Ryujinx.HLE.HOS.Services.Caps public CaptureManager(Switch device) { _sdCardPath = device.FileSystem.GetSdCardPath(); - - SixLabors.ImageSharp.Configuration.Default.ImageFormatsManager.SetEncoder(JpegFormat.Instance, new JpegEncoder() - { - Quality = 100 - }); } public ResultCode SetShimLibraryVersion(ServiceCtx context) diff --git a/Ryujinx.HLE/HOS/Services/Friend/ServiceCreator/IFriendService.cs b/Ryujinx.HLE/HOS/Services/Friend/ServiceCreator/IFriendService.cs index 1ae5d487b..83b81e001 100644 --- a/Ryujinx.HLE/HOS/Services/Friend/ServiceCreator/IFriendService.cs +++ b/Ryujinx.HLE/HOS/Services/Friend/ServiceCreator/IFriendService.cs @@ -150,12 +150,9 @@ namespace Ryujinx.HLE.HOS.Services.Friend.ServiceCreator return ResultCode.InvalidArgument; } - if (context.Device.System.AccountManager.TryGetUser(userId, out UserProfile profile)) - { - profile.OnlinePlayState = AccountState.Open; - } - - Logger.Stub?.PrintStub(LogClass.ServiceFriend, new { UserId = userId.ToString(), profile.OnlinePlayState }); + context.Device.System.AccountManager.OpenUserOnlinePlay(userId); + + Logger.Stub?.PrintStub(LogClass.ServiceFriend, new { UserId = userId.ToString() }); return ResultCode.Success; } @@ -171,12 +168,9 @@ namespace Ryujinx.HLE.HOS.Services.Friend.ServiceCreator return ResultCode.InvalidArgument; } - if (context.Device.System.AccountManager.TryGetUser(userId, out UserProfile profile)) - { - profile.OnlinePlayState = AccountState.Closed; - } + context.Device.System.AccountManager.CloseUserOnlinePlay(userId); - Logger.Stub?.PrintStub(LogClass.ServiceFriend, new { UserId = userId.ToString(), profile.OnlinePlayState }); + Logger.Stub?.PrintStub(LogClass.ServiceFriend, new { UserId = userId.ToString() }); return ResultCode.Success; } diff --git a/Ryujinx/Program.cs b/Ryujinx/Program.cs index 4df82da66..16f3fd749 100644 --- a/Ryujinx/Program.cs +++ b/Ryujinx/Program.cs @@ -8,6 +8,7 @@ using Ryujinx.Configuration; using Ryujinx.Modules; using Ryujinx.Ui; using Ryujinx.Ui.Widgets; +using SixLabors.ImageSharp.Formats.Jpeg; using System; using System.IO; using System.Reflection; @@ -97,6 +98,12 @@ namespace Ryujinx // Initialize Discord integration. DiscordIntegrationModule.Initialize(); + // Sets ImageSharp Jpeg Encoder Quality. + SixLabors.ImageSharp.Configuration.Default.ImageFormatsManager.SetEncoder(JpegFormat.Instance, new JpegEncoder() + { + Quality = 100 + }); + string localConfigurationPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Config.json"); string appDataConfigurationPath = Path.Combine(AppDataManager.BaseDirPath, "Config.json"); diff --git a/Ryujinx/Ui/MainWindow.cs b/Ryujinx/Ui/MainWindow.cs index 433d23dc9..08527ea35 100644 --- a/Ryujinx/Ui/MainWindow.cs +++ b/Ryujinx/Ui/MainWindow.cs @@ -78,6 +78,7 @@ namespace Ryujinx.Ui [GUI] Box _footerBox; [GUI] Box _statusBar; [GUI] MenuItem _optionMenu; + [GUI] MenuItem _manageUserProfiles; [GUI] MenuItem _actionMenu; [GUI] MenuItem _stopEmulation; [GUI] MenuItem _simulateWakeUpMessage; @@ -140,7 +141,7 @@ namespace Ryujinx.Ui // Instanciate HLE objects. _virtualFileSystem = VirtualFileSystem.CreateInstance(); _contentManager = new ContentManager(_virtualFileSystem); - _accountManager = new AccountManager(); + _accountManager = new AccountManager(_virtualFileSystem); _userChannelPersistence = new UserChannelPersistence(); // Instanciate GUI objects. @@ -155,6 +156,7 @@ namespace Ryujinx.Ui _applicationLibrary.ApplicationCountUpdated += ApplicationCount_Updated; _actionMenu.StateChanged += ActionMenu_StateChanged; + _optionMenu.StateChanged += OptionMenu_StateChanged; _gameTable.ButtonReleaseEvent += Row_Clicked; _fullScreen.Activated += FullScreen_Toggled; @@ -1192,6 +1194,11 @@ namespace Ryujinx.Ui SaveConfig(); } + private void OptionMenu_StateChanged(object o, StateChangedArgs args) + { + _manageUserProfiles.Sensitive = _emulationContext == null; + } + private void Settings_Pressed(object sender, EventArgs args) { SettingsWindow settingsWindow = new SettingsWindow(this, _virtualFileSystem, _contentManager); @@ -1200,6 +1207,14 @@ namespace Ryujinx.Ui settingsWindow.Show(); } + private void ManageUserProfiles_Pressed(object sender, EventArgs args) + { + UserProfilesManagerWindow userProfilesManagerWindow = new UserProfilesManagerWindow(_accountManager, _contentManager, _virtualFileSystem); + + userProfilesManagerWindow.SetSizeRequest((int)(userProfilesManagerWindow.DefaultWidth * Program.WindowScaleFactor), (int)(userProfilesManagerWindow.DefaultHeight * Program.WindowScaleFactor)); + userProfilesManagerWindow.Show(); + } + private void Simulate_WakeUp_Message_Pressed(object sender, EventArgs args) { if (_emulationContext != null) diff --git a/Ryujinx/Ui/MainWindow.glade b/Ryujinx/Ui/MainWindow.glade index beeed265a..129b768ed 100644 --- a/Ryujinx/Ui/MainWindow.glade +++ b/Ryujinx/Ui/MainWindow.glade @@ -248,6 +248,16 @@ + + + True + False + Open User Profiles Manager window + Manage User Profiles + True + + + diff --git a/Ryujinx/Ui/Widgets/GameTableContextMenu.cs b/Ryujinx/Ui/Widgets/GameTableContextMenu.cs index 4fdc666a4..eb3150cea 100644 --- a/Ryujinx/Ui/Widgets/GameTableContextMenu.cs +++ b/Ryujinx/Ui/Widgets/GameTableContextMenu.cs @@ -115,7 +115,7 @@ namespace Ryujinx.Ui.Widgets Logger.Warning?.Print(LogClass.Application, "No control file was found for this game. Using a dummy one instead. This may cause inaccuracies in some games."); } - Uid user = new Uid(1, 0); // TODO: Remove Hardcoded value. + Uid user = new Uid((ulong)_accountManager.LastOpenedUser.UserId.High, (ulong)_accountManager.LastOpenedUser.UserId.Low); result = EnsureApplicationSaveData(_virtualFileSystem.FsClient, out _, new LibHac.Ncm.ApplicationId(titleId), ref control, ref user); diff --git a/Ryujinx/Ui/Widgets/GtkDialog.cs b/Ryujinx/Ui/Widgets/GtkDialog.cs index 3d19724d6..83f6bb2b7 100644 --- a/Ryujinx/Ui/Widgets/GtkDialog.cs +++ b/Ryujinx/Ui/Widgets/GtkDialog.cs @@ -1,6 +1,7 @@ using Gtk; using System.Reflection; using Ryujinx.Common.Logging; +using System.Collections.Generic; namespace Ryujinx.Ui.Widgets { @@ -76,6 +77,34 @@ namespace Ryujinx.Ui.Widgets return response == ResponseType.Yes; } + internal static ResponseType CreateCustomDialog(string title, string mainText, string secondaryText, Dictionary buttons, MessageType messageType = MessageType.Other) + { + GtkDialog gtkDialog = new GtkDialog(title, mainText, secondaryText, messageType, ButtonsType.None); + + foreach (var button in buttons) + { + gtkDialog.AddButton(button.Value, button.Key); + } + + return (ResponseType)gtkDialog.Run(); + } + + internal static string CreateInputDialog(Window parent, string title, string mainText, uint inputMax) + { + GtkInputDialog gtkDialog = new GtkInputDialog(parent, title, mainText, inputMax); + ResponseType response = (ResponseType)gtkDialog.Run(); + string responseText = gtkDialog.InputEntry.Text.TrimEnd(); + + gtkDialog.Dispose(); + + if (response == ResponseType.Ok) + { + return responseText; + } + + return ""; + } + internal static bool CreateExitDialog() { return CreateChoiceDialog("Ryujinx - Exit", "Are you sure you want to close Ryujinx?", "All unsaved data will be lost!"); diff --git a/Ryujinx/Ui/Widgets/GtkInputDialog.cs b/Ryujinx/Ui/Widgets/GtkInputDialog.cs new file mode 100644 index 000000000..21b349373 --- /dev/null +++ b/Ryujinx/Ui/Widgets/GtkInputDialog.cs @@ -0,0 +1,37 @@ +using Gtk; + +namespace Ryujinx.Ui.Widgets +{ + public class GtkInputDialog : MessageDialog + { + public Entry InputEntry { get; } + + public GtkInputDialog(Window parent, string title, string mainText, uint inputMax) : base(parent, DialogFlags.Modal | DialogFlags.DestroyWithParent, MessageType.Question, ButtonsType.OkCancel, null) + { + SetDefaultSize(300, 0); + + Title = title; + + Label mainTextLabel = new Label + { + Text = mainText + }; + + InputEntry = new Entry + { + MaxLength = (int)inputMax + }; + + Label inputMaxTextLabel = new Label + { + Text = $"(Max length: {inputMax})" + }; + + ((Box)MessageArea).PackStart(mainTextLabel, true, true, 0); + ((Box)MessageArea).PackStart(InputEntry, true, true, 5); + ((Box)MessageArea).PackStart(inputMaxTextLabel, true, true, 0); + + ShowAll(); + } + } +} \ No newline at end of file diff --git a/Ryujinx/Ui/Windows/AvatarWindow.cs b/Ryujinx/Ui/Windows/AvatarWindow.cs new file mode 100644 index 000000000..52e03d30b --- /dev/null +++ b/Ryujinx/Ui/Windows/AvatarWindow.cs @@ -0,0 +1,289 @@ +using Gtk; +using LibHac.Common; +using LibHac.Fs; +using LibHac.Fs.Fsa; +using LibHac.FsSystem; +using LibHac.FsSystem.NcaUtils; +using Ryujinx.HLE.FileSystem; +using Ryujinx.HLE.FileSystem.Content; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.IO; +using System.Reflection; + +using Image = SixLabors.ImageSharp.Image; + +namespace Ryujinx.Ui.Windows +{ + public class AvatarWindow : Window + { + public byte[] SelectedProfileImage; + public bool NewUser; + + private static Dictionary _avatarDict = new Dictionary(); + + private ListStore _listStore; + private IconView _iconView; + private Button _setBackgroungColorButton; + private Gdk.RGBA _backgroundColor; + + public AvatarWindow() : base($"Ryujinx {Program.Version} - Manage Accounts - Avatar") + { + Icon = new Gdk.Pixbuf(Assembly.GetExecutingAssembly(), "Ryujinx.Ui.Resources.Logo_Ryujinx.png"); + + CanFocus = false; + Resizable = false; + Modal = true; + TypeHint = Gdk.WindowTypeHint.Dialog; + + SetDefaultSize(740, 400); + SetPosition(WindowPosition.Center); + + VBox vbox = new VBox(false, 0); + Add(vbox); + + ScrolledWindow scrolledWindow = new ScrolledWindow + { + ShadowType = ShadowType.EtchedIn + }; + scrolledWindow.SetPolicy(PolicyType.Automatic, PolicyType.Automatic); + + HBox hbox = new HBox(false, 0); + + Button chooseButton = new Button() + { + Label = "Choose", + CanFocus = true, + ReceivesDefault = true + }; + chooseButton.Clicked += ChooseButton_Pressed; + + _setBackgroungColorButton = new Button() + { + Label = "Set Background Color", + CanFocus = true + }; + _setBackgroungColorButton.Clicked += SetBackgroungColorButton_Pressed; + + _backgroundColor.Red = 1; + _backgroundColor.Green = 1; + _backgroundColor.Blue = 1; + _backgroundColor.Alpha = 1; + + Button closeButton = new Button() + { + Label = "Close", + CanFocus = true + }; + closeButton.Clicked += CloseButton_Pressed; + + vbox.PackStart(scrolledWindow, true, true, 0); + hbox.PackStart(chooseButton, true, true, 0); + hbox.PackStart(_setBackgroungColorButton, true, true, 0); + hbox.PackStart(closeButton, true, true, 0); + vbox.PackStart(hbox, false, false, 0); + + _listStore = new ListStore(typeof(string), typeof(Gdk.Pixbuf)); + _listStore.SetSortColumnId(0, SortType.Ascending); + + _iconView = new IconView(_listStore); + _iconView.ItemWidth = 64; + _iconView.ItemPadding = 10; + _iconView.PixbufColumn = 1; + + _iconView.SelectionChanged += IconView_SelectionChanged; + + scrolledWindow.Add(_iconView); + + _iconView.GrabFocus(); + + ProcessAvatars(); + + ShowAll(); + } + + public static void PreloadAvatars(ContentManager contentManager, VirtualFileSystem virtualFileSystem) + { + if (_avatarDict.Count > 0) + { + return; + } + + string contentPath = contentManager.GetInstalledContentPath(0x010000000000080A, StorageId.NandSystem, NcaContentType.Data); + string avatarPath = virtualFileSystem.SwitchPathToSystemPath(contentPath); + + if (!string.IsNullOrWhiteSpace(avatarPath)) + { + using (IStorage ncaFileStream = new LocalStorage(avatarPath, FileAccess.Read, FileMode.Open)) + { + Nca nca = new Nca(virtualFileSystem.KeySet, ncaFileStream); + IFileSystem romfs = nca.OpenFileSystem(NcaSectionType.Data, IntegrityCheckLevel.ErrorOnInvalid); + + foreach (var item in romfs.EnumerateEntries()) + { + // TODO: Parse DatabaseInfo.bin and table.bin files for more accuracy. + + if (item.Type == DirectoryEntryType.File && item.FullPath.Contains("chara") && item.FullPath.Contains("szs")) + { + romfs.OpenFile(out IFile file, ("/" + item.FullPath).ToU8Span(), OpenMode.Read).ThrowIfFailure(); + + using (MemoryStream stream = new MemoryStream()) + using (MemoryStream streamPng = new MemoryStream()) + { + file.AsStream().CopyTo(stream); + + stream.Position = 0; + + Image avatarImage = Image.LoadPixelData(DecompressYaz0(stream), 256, 256); + + avatarImage.SaveAsPng(streamPng); + + _avatarDict.Add(item.FullPath, streamPng.ToArray()); + } + } + } + } + } + } + + private void ProcessAvatars() + { + _listStore.Clear(); + + foreach (var avatar in _avatarDict) + { + _listStore.AppendValues(avatar.Key, new Gdk.Pixbuf(ProcessImage(avatar.Value), 96, 96)); + } + + _iconView.SelectPath(new TreePath(new int[] { 0 })); + } + + private byte[] ProcessImage(byte[] data) + { + using (MemoryStream streamJpg = new MemoryStream()) + { + Image avatarImage = Image.Load(data, new PngDecoder()); + + avatarImage.Mutate(x => x.BackgroundColor(new Rgba32((byte)(_backgroundColor.Red * 255), + (byte)(_backgroundColor.Green * 255), + (byte)(_backgroundColor.Blue * 255), + (byte)(_backgroundColor.Alpha * 255)))); + avatarImage.SaveAsJpeg(streamJpg); + + return streamJpg.ToArray(); + } + } + + private void CloseButton_Pressed(object sender, EventArgs e) + { + SelectedProfileImage = null; + + Close(); + } + + private void IconView_SelectionChanged(object sender, EventArgs e) + { + if (_iconView.SelectedItems.Length > 0) + { + _listStore.GetIter(out TreeIter iter, _iconView.SelectedItems[0]); + + SelectedProfileImage = ProcessImage(_avatarDict[(string)_listStore.GetValue(iter, 0)]); + } + } + + private void SetBackgroungColorButton_Pressed(object sender, EventArgs e) + { + using (ColorChooserDialog colorChooserDialog = new ColorChooserDialog("Set Background Color", this)) + { + colorChooserDialog.UseAlpha = false; + colorChooserDialog.Rgba = _backgroundColor; + + if (colorChooserDialog.Run() == (int)ResponseType.Ok) + { + _backgroundColor = colorChooserDialog.Rgba; + + ProcessAvatars(); + } + + colorChooserDialog.Hide(); + } + } + + private void ChooseButton_Pressed(object sender, EventArgs e) + { + Close(); + } + + private static byte[] DecompressYaz0(Stream stream) + { + using (BinaryReader reader = new BinaryReader(stream)) + { + reader.ReadInt32(); // Magic + + uint decodedLength = BinaryPrimitives.ReverseEndianness(reader.ReadUInt32()); + + reader.ReadInt64(); // Padding + + byte[] input = new byte[stream.Length - stream.Position]; + stream.Read(input, 0, input.Length); + + long inputOffset = 0; + + byte[] output = new byte[decodedLength]; + long outputOffset = 0; + + ushort mask = 0; + byte header = 0; + + while (outputOffset < decodedLength) + { + if ((mask >>= 1) == 0) + { + header = input[inputOffset++]; + mask = 0x80; + } + + if ((header & mask) > 0) + { + if (outputOffset == output.Length) + { + break; + } + + output[outputOffset++] = input[inputOffset++]; + } + else + { + byte byte1 = input[inputOffset++]; + byte byte2 = input[inputOffset++]; + + int dist = ((byte1 & 0xF) << 8) | byte2; + int position = (int)outputOffset - (dist + 1); + + int length = byte1 >> 4; + if (length == 0) + { + length = input[inputOffset++] + 0x12; + } + else + { + length += 2; + } + + while (length-- > 0) + { + output[outputOffset++] = output[position++]; + } + } + } + + return output; + } + } + } +} \ No newline at end of file diff --git a/Ryujinx/Ui/Windows/UserProfilesManagerWindow.Designer.cs b/Ryujinx/Ui/Windows/UserProfilesManagerWindow.Designer.cs new file mode 100644 index 000000000..702912905 --- /dev/null +++ b/Ryujinx/Ui/Windows/UserProfilesManagerWindow.Designer.cs @@ -0,0 +1,255 @@ +using Gtk; +using Pango; + +namespace Ryujinx.Ui.Windows +{ + public partial class UserProfilesManagerWindow : Window + { + private Box _mainBox; + private Label _selectedLabel; + private Box _selectedUserBox; + private Image _selectedUserImage; + private VBox _selectedUserInfoBox; + private Entry _selectedUserNameEntry; + private Label _selectedUserIdLabel; + private VBox _selectedUserButtonsBox; + private Button _saveProfileNameButton; + private Button _changeProfileImageButton; + private Box _usersTreeViewBox; + private Label _availableUsersLabel; + private ScrolledWindow _usersTreeViewWindow; + private ListStore _tableStore; + private TreeView _usersTreeView; + private Box _bottomBox; + private Button _addButton; + private Button _deleteButton; + private Button _closeButton; + + private void InitializeComponent() + { + +#pragma warning disable CS0612 + + // + // UserProfilesManagerWindow + // + CanFocus = false; + Resizable = false; + Modal = true; + WindowPosition = WindowPosition.Center; + DefaultWidth = 620; + DefaultHeight = 548; + TypeHint = Gdk.WindowTypeHint.Dialog; + + // + // _mainBox + // + _mainBox = new Box(Orientation.Vertical, 0); + + // + // _selectedLabel + // + _selectedLabel = new Label("Selected User Profile:") + { + Margin = 15, + Attributes = new AttrList() + }; + _selectedLabel.Attributes.Insert(new Pango.AttrWeight(Weight.Bold)); + + // + // _viewBox + // + _usersTreeViewBox = new Box(Orientation.Vertical, 0); + + // + // _SelectedUserBox + // + _selectedUserBox = new Box(Orientation.Horizontal, 0) + { + MarginLeft = 30 + }; + + // + // _selectedUserImage + // + _selectedUserImage = new Image(); + + // + // _selectedUserInfoBox + // + _selectedUserInfoBox = new VBox(true, 0); + + // + // _selectedUserNameEntry + // + _selectedUserNameEntry = new Entry("") + { + MarginLeft = 15, + MaxLength = (int)MaxProfileNameLength + }; + _selectedUserNameEntry.KeyReleaseEvent += SelectedUserNameEntry_KeyReleaseEvent; + + // + // _selectedUserIdLabel + // + _selectedUserIdLabel = new Label("") + { + MarginTop = 15, + MarginLeft = 15 + }; + + // + // _selectedUserButtonsBox + // + _selectedUserButtonsBox = new VBox() + { + MarginRight = 30 + }; + + // + // _saveProfileNameButton + // + _saveProfileNameButton = new Button() + { + Label = "Save Profile Name", + CanFocus = true, + ReceivesDefault = true, + Sensitive = false + }; + _saveProfileNameButton.Clicked += EditProfileNameButton_Pressed; + + // + // _changeProfileImageButton + // + _changeProfileImageButton = new Button() + { + Label = "Change Profile Image", + CanFocus = true, + ReceivesDefault = true, + MarginTop = 10 + }; + _changeProfileImageButton.Clicked += ChangeProfileImageButton_Pressed; + + // + // _availableUsersLabel + // + _availableUsersLabel = new Label("Available User Profiles:") + { + Margin = 15, + Attributes = new AttrList() + }; + _availableUsersLabel.Attributes.Insert(new Pango.AttrWeight(Weight.Bold)); + + // + // _usersTreeViewWindow + // + _usersTreeViewWindow = new ScrolledWindow() + { + ShadowType = ShadowType.In, + CanFocus = true, + Expand = true, + MarginLeft = 30, + MarginRight = 30, + MarginBottom = 15 + }; + + // + // _tableStore + // + _tableStore = new ListStore(typeof(bool), typeof(Gdk.Pixbuf), typeof(string), typeof(Gdk.RGBA)); + + // + // _usersTreeView + // + _usersTreeView = new TreeView(_tableStore) + { + HoverSelection = true, + HeadersVisible = false, + }; + _usersTreeView.RowActivated += UsersTreeView_Activated; + + // + // _bottomBox + // + _bottomBox = new Box(Orientation.Horizontal, 0) + { + MarginLeft = 30, + MarginRight = 30, + MarginBottom = 15 + }; + + // + // _addButton + // + _addButton = new Button() + { + Label = "Add New Profile", + CanFocus = true, + ReceivesDefault = true, + HeightRequest = 35 + }; + _addButton.Clicked += AddButton_Pressed; + + // + // _deleteButton + // + _deleteButton = new Button() + { + Label = "Delete Selected Profile", + CanFocus = true, + ReceivesDefault = true, + HeightRequest = 35, + MarginLeft = 10 + }; + _deleteButton.Clicked += DeleteButton_Pressed; + + // + // _closeButton + // + _closeButton = new Button() + { + Label = "Close", + CanFocus = true, + ReceivesDefault = true, + HeightRequest = 35, + WidthRequest = 80 + }; + _closeButton.Clicked += CloseButton_Pressed; + +#pragma warning restore CS0612 + + ShowComponent(); + } + + private void ShowComponent() + { + _usersTreeViewWindow.Add(_usersTreeView); + + _usersTreeViewBox.Add(_usersTreeViewWindow); + + _bottomBox.PackStart(new Gtk.Alignment(-1, 0, 0, 0) { _addButton }, false, false, 0); + _bottomBox.PackStart(new Gtk.Alignment(-1, 0, 0, 0) { _deleteButton }, false, false, 0); + _bottomBox.PackEnd(new Gtk.Alignment(1, 0, 0, 0) { _closeButton }, false, false, 0); + + _selectedUserInfoBox.Add(_selectedUserNameEntry); + _selectedUserInfoBox.Add(_selectedUserIdLabel); + + _selectedUserButtonsBox.Add(_saveProfileNameButton); + _selectedUserButtonsBox.Add(_changeProfileImageButton); + + _selectedUserBox.Add(_selectedUserImage); + _selectedUserBox.PackStart(new Gtk.Alignment(-1, 0, 0, 0) { _selectedUserInfoBox }, true, true, 0); + _selectedUserBox.Add(_selectedUserButtonsBox); + + _mainBox.PackStart(new Gtk.Alignment(-1, 0, 0, 0) { _selectedLabel }, false, false, 0); + _mainBox.PackStart(_selectedUserBox, false, true, 0); + _mainBox.PackStart(new Gtk.Alignment(-1, 0, 0, 0) { _availableUsersLabel }, false, false, 0); + _mainBox.Add(_usersTreeViewBox); + _mainBox.Add(_bottomBox); + + Add(_mainBox); + + ShowAll(); + } + } +} \ No newline at end of file diff --git a/Ryujinx/Ui/Windows/UserProfilesManagerWindow.cs b/Ryujinx/Ui/Windows/UserProfilesManagerWindow.cs new file mode 100644 index 000000000..6a4788b15 --- /dev/null +++ b/Ryujinx/Ui/Windows/UserProfilesManagerWindow.cs @@ -0,0 +1,327 @@ +using Gtk; +using Ryujinx.HLE.FileSystem; +using Ryujinx.HLE.FileSystem.Content; +using Ryujinx.HLE.HOS.Services.Account.Acc; +using Ryujinx.Ui.Widgets; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Processing; +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +using Image = SixLabors.ImageSharp.Image; +using UserId = Ryujinx.HLE.HOS.Services.Account.Acc.UserId; + +namespace Ryujinx.Ui.Windows +{ + public partial class UserProfilesManagerWindow : Window + { + private const uint MaxProfileNameLength = 0x20; + + private readonly AccountManager _accountManager; + private readonly ContentManager _contentManager; + + private byte[] _bufferImageProfile; + private string _tempNewProfileName; + + private Gdk.RGBA _selectedColor; + + private ManualResetEvent _avatarsPreloadingEvent = new ManualResetEvent(false); + + public UserProfilesManagerWindow(AccountManager accountManager, ContentManager contentManager, VirtualFileSystem virtualFileSystem) : base($"Ryujinx {Program.Version} - Manage User Profiles") + { + Icon = new Gdk.Pixbuf(Assembly.GetExecutingAssembly(), "Ryujinx.Ui.Resources.Logo_Ryujinx.png"); + + InitializeComponent(); + + _selectedColor.Red = 0.212; + _selectedColor.Green = 0.843; + _selectedColor.Blue = 0.718; + _selectedColor.Alpha = 1; + + _accountManager = accountManager; + _contentManager = contentManager; + + CellRendererToggle userSelectedToggle = new CellRendererToggle(); + userSelectedToggle.Toggled += UserSelectedToggle_Toggled; + + // NOTE: Uncomment following line when multiple selection of user profiles is supported. + //_usersTreeView.AppendColumn("Selected", userSelectedToggle, "active", 0); + _usersTreeView.AppendColumn("User Icon", new CellRendererPixbuf(), "pixbuf", 1); + _usersTreeView.AppendColumn("User Info", new CellRendererText(), "text", 2, "background-rgba", 3); + + _tableStore.SetSortColumnId(0, SortType.Descending); + + RefreshList(); + + if (_contentManager.GetCurrentFirmwareVersion() != null) + { + Task.Run(() => + { + AvatarWindow.PreloadAvatars(contentManager, virtualFileSystem); + _avatarsPreloadingEvent.Set(); + }); + } + } + + public void RefreshList() + { + _tableStore.Clear(); + + foreach (UserProfile userProfile in _accountManager.GetAllUsers()) + { + _tableStore.AppendValues(userProfile.AccountState == AccountState.Open, new Gdk.Pixbuf(userProfile.Image, 96, 96), $"{userProfile.Name}\n{userProfile.UserId}", Gdk.RGBA.Zero); + + if (userProfile.AccountState == AccountState.Open) + { + _selectedUserImage.Pixbuf = new Gdk.Pixbuf(userProfile.Image, 96, 96); + _selectedUserIdLabel.Text = userProfile.UserId.ToString(); + _selectedUserNameEntry.Text = userProfile.Name; + + _deleteButton.Sensitive = userProfile.UserId != AccountManager.DefaultUserId; + + _usersTreeView.Model.GetIterFirst(out TreeIter firstIter); + _tableStore.SetValue(firstIter, 3, _selectedColor); + } + } + } + + // + // Events + // + + private void UsersTreeView_Activated(object o, RowActivatedArgs args) + { + SelectUserTreeView(); + } + + private void UserSelectedToggle_Toggled(object o, ToggledArgs args) + { + SelectUserTreeView(); + } + + private void SelectUserTreeView() + { + // Get selected item informations. + _usersTreeView.Selection.GetSelected(out TreeIter selectedIter); + + Gdk.Pixbuf userPicture = (Gdk.Pixbuf)_tableStore.GetValue(selectedIter, 1); + + string userName = _tableStore.GetValue(selectedIter, 2).ToString().Split("\n")[0]; + string userId = _tableStore.GetValue(selectedIter, 2).ToString().Split("\n")[1]; + + // Unselect the first user. + _usersTreeView.Model.GetIterFirst(out TreeIter firstIter); + _tableStore.SetValue(firstIter, 0, false); + _tableStore.SetValue(firstIter, 3, Gdk.RGBA.Zero); + + // Set new informations. + _tableStore.SetValue(selectedIter, 0, true); + + _selectedUserImage.Pixbuf = userPicture; + _selectedUserNameEntry.Text = userName; + _selectedUserIdLabel.Text = userId; + _saveProfileNameButton.Sensitive = false; + + // Open the selected one. + _accountManager.OpenUser(new UserId(userId)); + + _deleteButton.Sensitive = userId != AccountManager.DefaultUserId.ToString(); + + _tableStore.SetValue(selectedIter, 3, _selectedColor); + } + + private void SelectedUserNameEntry_KeyReleaseEvent(object o, KeyReleaseEventArgs args) + { + if (_saveProfileNameButton.Sensitive == false) + { + _saveProfileNameButton.Sensitive = true; + } + } + + private void AddButton_Pressed(object sender, EventArgs e) + { + _tempNewProfileName = GtkDialog.CreateInputDialog(this, "Choose the Profile Name", "Please Enter a Profile Name", MaxProfileNameLength); + + if (_tempNewProfileName != "") + { + SelectProfileImage(true); + + if (_bufferImageProfile != null) + { + AddUser(); + } + } + } + + private void DeleteButton_Pressed(object sender, EventArgs e) + { + if (GtkDialog.CreateChoiceDialog("Delete User Profile", "Are you sure you want to delete the profile ?", "Deleting this profile will also delete all associated save data.")) + { + _accountManager.DeleteUser(GetSelectedUserId()); + + RefreshList(); + } + } + + private void EditProfileNameButton_Pressed(object sender, EventArgs e) + { + _saveProfileNameButton.Sensitive = false; + + _accountManager.SetUserName(GetSelectedUserId(), _selectedUserNameEntry.Text); + + RefreshList(); + } + + private void ProcessProfileImage(byte[] buffer) + { + using (Image image = Image.Load(buffer)) + { + image.Mutate(x => x.Resize(256, 256)); + + using (MemoryStream streamJpg = new MemoryStream()) + { + image.SaveAsJpeg(streamJpg); + + _bufferImageProfile = streamJpg.ToArray(); + } + } + } + + private void ProfileImageFileChooser() + { + FileChooserDialog fileChooser = new FileChooserDialog("Import Custom Profile Image", this, FileChooserAction.Open, "Cancel", ResponseType.Cancel, "Import", ResponseType.Accept) + { + SelectMultiple = false, + Filter = new FileFilter() + }; + + fileChooser.SetPosition(WindowPosition.Center); + fileChooser.Filter.AddPattern("*.jpg"); + fileChooser.Filter.AddPattern("*.jpeg"); + fileChooser.Filter.AddPattern("*.png"); + fileChooser.Filter.AddPattern("*.bmp"); + + if (fileChooser.Run() == (int)ResponseType.Accept) + { + ProcessProfileImage(File.ReadAllBytes(fileChooser.Filename)); + } + + fileChooser.Dispose(); + } + + private void SelectProfileImage(bool newUser = false) + { + if (_contentManager.GetCurrentFirmwareVersion() == null) + { + ProfileImageFileChooser(); + } + else + { + Dictionary buttons = new Dictionary() + { + { 0, "Import Image File" }, + { 1, "Select Firmware Avatar" } + }; + + ResponseType responseDialog = GtkDialog.CreateCustomDialog("Profile Image Selection", + "Choose a Profile Image", + "You may import a custom profile image, or select an avatar from the system firmware.", + buttons, MessageType.Question); + + if (responseDialog == 0) + { + ProfileImageFileChooser(); + } + else if (responseDialog == (ResponseType)1) + { + AvatarWindow avatarWindow = new AvatarWindow() + { + NewUser = newUser + }; + + avatarWindow.DeleteEvent += AvatarWindow_DeleteEvent; + + avatarWindow.SetSizeRequest((int)(avatarWindow.DefaultWidth * Program.WindowScaleFactor), (int)(avatarWindow.DefaultHeight * Program.WindowScaleFactor)); + avatarWindow.Show(); + } + } + } + + private void ChangeProfileImageButton_Pressed(object sender, EventArgs e) + { + if (_contentManager.GetCurrentFirmwareVersion() != null) + { + _avatarsPreloadingEvent.WaitOne(); + } + + SelectProfileImage(); + + if (_bufferImageProfile != null) + { + SetUserImage(); + } + } + + private void AvatarWindow_DeleteEvent(object sender, DeleteEventArgs args) + { + _bufferImageProfile = ((AvatarWindow)sender).SelectedProfileImage; + + if (_bufferImageProfile != null) + { + if (((AvatarWindow)sender).NewUser) + { + AddUser(); + } + else + { + SetUserImage(); + } + } + } + + private void AddUser() + { + _accountManager.AddUser(_tempNewProfileName, _bufferImageProfile); + + _bufferImageProfile = null; + _tempNewProfileName = ""; + + RefreshList(); + } + + private void SetUserImage() + { + _accountManager.SetUserImage(GetSelectedUserId(), _bufferImageProfile); + + _bufferImageProfile = null; + + RefreshList(); + } + + private UserId GetSelectedUserId() + { + if (_usersTreeView.Model.GetIterFirst(out TreeIter iter)) + { + do + { + if ((bool)_tableStore.GetValue(iter, 0)) + { + break; + } + } + while (_usersTreeView.Model.IterNext(ref iter)); + } + + return new UserId(_tableStore.GetValue(iter, 2).ToString().Split("\n")[1]); + } + + private void CloseButton_Pressed(object sender, EventArgs e) + { + Close(); + } + } +} \ No newline at end of file