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
This commit is contained in:
Ac_K 2021-04-23 22:26:31 +02:00 committed by GitHub
parent 3e61fb0268
commit c46f6879ff
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 1286 additions and 41 deletions

View file

@ -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.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.Linq; using System.Linq;
namespace Ryujinx.HLE.HOS.Services.Account.Acc namespace Ryujinx.HLE.HOS.Services.Account.Acc
{ {
public class AccountManager public class AccountManager
{ {
public static readonly UserId DefaultUserId = new UserId("00000000000000010000000000000000");
private readonly VirtualFileSystem _virtualFileSystem;
private readonly AccountSaveDataManager _accountSaveDataManager;
private ConcurrentDictionary<string, UserProfile> _profiles; private ConcurrentDictionary<string, UserProfile> _profiles;
public UserProfile LastOpenedUser { get; private set; } public UserProfile LastOpenedUser { get; private set; }
public AccountManager() public AccountManager(VirtualFileSystem virtualFileSystem)
{ {
_virtualFileSystem = virtualFileSystem;
_profiles = new ConcurrentDictionary<string, UserProfile>(); _profiles = new ConcurrentDictionary<string, UserProfile>();
UserId defaultUserId = new UserId("00000000000000010000000000000000"); _accountSaveDataManager = new AccountSaveDataManager(_profiles);
byte[] defaultUserImage = EmbeddedResources.Read("Ryujinx.HLE/HOS/Services/Account/Acc/DefaultUserImage.jpg");
AddUser(defaultUserId, "Player", defaultUserImage); if (!_profiles.TryGetValue(DefaultUserId.ToString(), out _))
{
byte[] defaultUserImage = EmbeddedResources.Read("Ryujinx.HLE/HOS/Services/Account/Acc/DefaultUserImage.jpg");
OpenUser(defaultUserId); 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); UserProfile profile = new UserProfile(userId, name, image);
_profiles.AddOrUpdate(userId.ToString(), profile, (key, old) => profile); _profiles.AddOrUpdate(userId.ToString(), profile, (key, old) => profile);
_accountSaveDataManager.Save(_profiles);
} }
public void OpenUser(UserId userId) public void OpenUser(UserId userId)
{ {
if (_profiles.TryGetValue(userId.ToString(), out UserProfile profile)) 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; (LastOpenedUser = profile).AccountState = AccountState.Open;
_accountSaveDataManager.LastOpened = userId;
} }
_accountSaveDataManager.Save(_profiles);
} }
public void CloseUser(UserId userId) public void CloseUser(UserId userId)
@ -44,9 +88,117 @@ namespace Ryujinx.HLE.HOS.Services.Account.Acc
{ {
profile.AccountState = AccountState.Closed; 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> 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; return _profiles.Count;
} }
@ -56,7 +208,7 @@ namespace Ryujinx.HLE.HOS.Services.Account.Acc
return _profiles.TryGetValue(userId.ToString(), out profile); return _profiles.TryGetValue(userId.ToString(), out profile);
} }
internal IEnumerable<UserProfile> GetAllUsers() public IEnumerable<UserProfile> GetAllUsers()
{ {
return _profiles.Values; return _profiles.Values;
} }

View file

@ -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<UserProfileJson> 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<string, UserProfile> profiles)
{
// TODO: Use 0x8000000000000010 system savedata instead of a JSON file if needed.
if (File.Exists(_profilesJsonPath))
{
ProfilesJson profilesJson = JsonHelper.DeserializeFromFile<ProfilesJson>(_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<string, UserProfile> profiles)
{
ProfilesJson profilesJson = new ProfilesJson()
{
Profiles = new List<UserProfileJson>(),
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));
}
}
}

View file

@ -8,31 +8,80 @@ namespace Ryujinx.HLE.HOS.Services.Account.Acc
public UserId UserId { get; } 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; } UpdateLastModifiedTimestamp();
public AccountState OnlinePlayState { get; set; } }
}
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; UserId = userId;
Name = name; Name = name;
Image = image;
Image = image;
LastModifiedTimestamp = 0;
AccountState = AccountState.Closed; AccountState = AccountState.Closed;
OnlinePlayState = 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; LastModifiedTimestamp = (long)(DateTime.Now - Epoch).TotalSeconds;
} }

View file

@ -1,7 +1,6 @@
using Ryujinx.Common.Memory; using Ryujinx.Common.Memory;
using Ryujinx.HLE.HOS.Services.Caps.Types; using Ryujinx.HLE.HOS.Services.Caps.Types;
using SixLabors.ImageSharp; using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.PixelFormats;
using System; using System;
using System.IO; using System.IO;
@ -19,11 +18,6 @@ namespace Ryujinx.HLE.HOS.Services.Caps
public CaptureManager(Switch device) public CaptureManager(Switch device)
{ {
_sdCardPath = device.FileSystem.GetSdCardPath(); _sdCardPath = device.FileSystem.GetSdCardPath();
SixLabors.ImageSharp.Configuration.Default.ImageFormatsManager.SetEncoder(JpegFormat.Instance, new JpegEncoder()
{
Quality = 100
});
} }
public ResultCode SetShimLibraryVersion(ServiceCtx context) public ResultCode SetShimLibraryVersion(ServiceCtx context)

View file

@ -150,12 +150,9 @@ namespace Ryujinx.HLE.HOS.Services.Friend.ServiceCreator
return ResultCode.InvalidArgument; return ResultCode.InvalidArgument;
} }
if (context.Device.System.AccountManager.TryGetUser(userId, out UserProfile profile)) context.Device.System.AccountManager.OpenUserOnlinePlay(userId);
{
profile.OnlinePlayState = AccountState.Open;
}
Logger.Stub?.PrintStub(LogClass.ServiceFriend, new { UserId = userId.ToString(), profile.OnlinePlayState }); Logger.Stub?.PrintStub(LogClass.ServiceFriend, new { UserId = userId.ToString() });
return ResultCode.Success; return ResultCode.Success;
} }
@ -171,12 +168,9 @@ namespace Ryujinx.HLE.HOS.Services.Friend.ServiceCreator
return ResultCode.InvalidArgument; return ResultCode.InvalidArgument;
} }
if (context.Device.System.AccountManager.TryGetUser(userId, out UserProfile profile)) context.Device.System.AccountManager.CloseUserOnlinePlay(userId);
{
profile.OnlinePlayState = AccountState.Closed;
}
Logger.Stub?.PrintStub(LogClass.ServiceFriend, new { UserId = userId.ToString(), profile.OnlinePlayState }); Logger.Stub?.PrintStub(LogClass.ServiceFriend, new { UserId = userId.ToString() });
return ResultCode.Success; return ResultCode.Success;
} }

View file

@ -8,6 +8,7 @@ using Ryujinx.Configuration;
using Ryujinx.Modules; using Ryujinx.Modules;
using Ryujinx.Ui; using Ryujinx.Ui;
using Ryujinx.Ui.Widgets; using Ryujinx.Ui.Widgets;
using SixLabors.ImageSharp.Formats.Jpeg;
using System; using System;
using System.IO; using System.IO;
using System.Reflection; using System.Reflection;
@ -97,6 +98,12 @@ namespace Ryujinx
// Initialize Discord integration. // Initialize Discord integration.
DiscordIntegrationModule.Initialize(); 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 localConfigurationPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Config.json");
string appDataConfigurationPath = Path.Combine(AppDataManager.BaseDirPath, "Config.json"); string appDataConfigurationPath = Path.Combine(AppDataManager.BaseDirPath, "Config.json");

View file

@ -78,6 +78,7 @@ namespace Ryujinx.Ui
[GUI] Box _footerBox; [GUI] Box _footerBox;
[GUI] Box _statusBar; [GUI] Box _statusBar;
[GUI] MenuItem _optionMenu; [GUI] MenuItem _optionMenu;
[GUI] MenuItem _manageUserProfiles;
[GUI] MenuItem _actionMenu; [GUI] MenuItem _actionMenu;
[GUI] MenuItem _stopEmulation; [GUI] MenuItem _stopEmulation;
[GUI] MenuItem _simulateWakeUpMessage; [GUI] MenuItem _simulateWakeUpMessage;
@ -140,7 +141,7 @@ namespace Ryujinx.Ui
// Instanciate HLE objects. // Instanciate HLE objects.
_virtualFileSystem = VirtualFileSystem.CreateInstance(); _virtualFileSystem = VirtualFileSystem.CreateInstance();
_contentManager = new ContentManager(_virtualFileSystem); _contentManager = new ContentManager(_virtualFileSystem);
_accountManager = new AccountManager(); _accountManager = new AccountManager(_virtualFileSystem);
_userChannelPersistence = new UserChannelPersistence(); _userChannelPersistence = new UserChannelPersistence();
// Instanciate GUI objects. // Instanciate GUI objects.
@ -155,6 +156,7 @@ namespace Ryujinx.Ui
_applicationLibrary.ApplicationCountUpdated += ApplicationCount_Updated; _applicationLibrary.ApplicationCountUpdated += ApplicationCount_Updated;
_actionMenu.StateChanged += ActionMenu_StateChanged; _actionMenu.StateChanged += ActionMenu_StateChanged;
_optionMenu.StateChanged += OptionMenu_StateChanged;
_gameTable.ButtonReleaseEvent += Row_Clicked; _gameTable.ButtonReleaseEvent += Row_Clicked;
_fullScreen.Activated += FullScreen_Toggled; _fullScreen.Activated += FullScreen_Toggled;
@ -1192,6 +1194,11 @@ namespace Ryujinx.Ui
SaveConfig(); SaveConfig();
} }
private void OptionMenu_StateChanged(object o, StateChangedArgs args)
{
_manageUserProfiles.Sensitive = _emulationContext == null;
}
private void Settings_Pressed(object sender, EventArgs args) private void Settings_Pressed(object sender, EventArgs args)
{ {
SettingsWindow settingsWindow = new SettingsWindow(this, _virtualFileSystem, _contentManager); SettingsWindow settingsWindow = new SettingsWindow(this, _virtualFileSystem, _contentManager);
@ -1200,6 +1207,14 @@ namespace Ryujinx.Ui
settingsWindow.Show(); 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) private void Simulate_WakeUp_Message_Pressed(object sender, EventArgs args)
{ {
if (_emulationContext != null) if (_emulationContext != null)

View file

@ -248,6 +248,16 @@
<signal name="activate" handler="Settings_Pressed" swapped="no"/> <signal name="activate" handler="Settings_Pressed" swapped="no"/>
</object> </object>
</child> </child>
<child>
<object class="GtkMenuItem" id="_manageUserProfiles">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="tooltip_text" translatable="yes">Open User Profiles Manager window</property>
<property name="label" translatable="yes">Manage User Profiles</property>
<property name="use_underline">True</property>
<signal name="activate" handler="ManageUserProfiles_Pressed" swapped="no"/>
</object>
</child>
</object> </object>
</child> </child>
</object> </object>

View file

@ -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."); 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); result = EnsureApplicationSaveData(_virtualFileSystem.FsClient, out _, new LibHac.Ncm.ApplicationId(titleId), ref control, ref user);

View file

@ -1,6 +1,7 @@
using Gtk; using Gtk;
using System.Reflection; using System.Reflection;
using Ryujinx.Common.Logging; using Ryujinx.Common.Logging;
using System.Collections.Generic;
namespace Ryujinx.Ui.Widgets namespace Ryujinx.Ui.Widgets
{ {
@ -76,6 +77,34 @@ namespace Ryujinx.Ui.Widgets
return response == ResponseType.Yes; return response == ResponseType.Yes;
} }
internal static ResponseType CreateCustomDialog(string title, string mainText, string secondaryText, Dictionary<int, string> 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() internal static bool CreateExitDialog()
{ {
return CreateChoiceDialog("Ryujinx - Exit", "Are you sure you want to close Ryujinx?", "All unsaved data will be lost!"); return CreateChoiceDialog("Ryujinx - Exit", "Are you sure you want to close Ryujinx?", "All unsaved data will be lost!");

View file

@ -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();
}
}
}

View file

@ -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<string, byte[]> _avatarDict = new Dictionary<string, byte[]>();
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<Rgba32>(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;
}
}
}
}

View file

@ -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();
}
}
}

View file

@ -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<int, string> buttons = new Dictionary<int, string>()
{
{ 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();
}
}
}