diff options
Diffstat (limited to 'src/Ryujinx.UI.Common/Helper')
| -rw-r--r-- | src/Ryujinx.UI.Common/Helper/CommandLineState.cs | 99 | ||||
| -rw-r--r-- | src/Ryujinx.UI.Common/Helper/ConsoleHelper.cs | 50 | ||||
| -rw-r--r-- | src/Ryujinx.UI.Common/Helper/FileAssociationHelper.cs | 202 | ||||
| -rw-r--r-- | src/Ryujinx.UI.Common/Helper/LinuxHelper.cs | 62 | ||||
| -rw-r--r-- | src/Ryujinx.UI.Common/Helper/ObjectiveC.cs | 160 | ||||
| -rw-r--r-- | src/Ryujinx.UI.Common/Helper/OpenHelper.cs | 112 | ||||
| -rw-r--r-- | src/Ryujinx.UI.Common/Helper/SetupValidator.cs | 114 | ||||
| -rw-r--r-- | src/Ryujinx.UI.Common/Helper/ShortcutHelper.cs | 162 | ||||
| -rw-r--r-- | src/Ryujinx.UI.Common/Helper/TitleHelper.cs | 30 | ||||
| -rw-r--r-- | src/Ryujinx.UI.Common/Helper/ValueFormatUtils.cs | 219 |
10 files changed, 1210 insertions, 0 deletions
diff --git a/src/Ryujinx.UI.Common/Helper/CommandLineState.cs b/src/Ryujinx.UI.Common/Helper/CommandLineState.cs new file mode 100644 index 00000000..c3c5bd37 --- /dev/null +++ b/src/Ryujinx.UI.Common/Helper/CommandLineState.cs @@ -0,0 +1,99 @@ +using Ryujinx.Common.Logging; +using System.Collections.Generic; + +namespace Ryujinx.UI.Common.Helper +{ + public static class CommandLineState + { + public static string[] Arguments { get; private set; } + + public static bool? OverrideDockedMode { get; private set; } + public static string OverrideGraphicsBackend { get; private set; } + public static string OverrideHideCursor { get; private set; } + public static string BaseDirPathArg { get; private set; } + public static string Profile { get; private set; } + public static string LaunchPathArg { get; private set; } + public static bool StartFullscreenArg { get; private set; } + + public static void ParseArguments(string[] args) + { + List<string> arguments = new(); + + // Parse Arguments. + for (int i = 0; i < args.Length; ++i) + { + string arg = args[i]; + + switch (arg) + { + case "-r": + case "--root-data-dir": + if (i + 1 >= args.Length) + { + Logger.Error?.Print(LogClass.Application, $"Invalid option '{arg}'"); + + continue; + } + + BaseDirPathArg = args[++i]; + + arguments.Add(arg); + arguments.Add(args[i]); + break; + case "-p": + case "--profile": + if (i + 1 >= args.Length) + { + Logger.Error?.Print(LogClass.Application, $"Invalid option '{arg}'"); + + continue; + } + + Profile = args[++i]; + + arguments.Add(arg); + arguments.Add(args[i]); + break; + case "-f": + case "--fullscreen": + StartFullscreenArg = true; + + arguments.Add(arg); + break; + case "-g": + case "--graphics-backend": + if (i + 1 >= args.Length) + { + Logger.Error?.Print(LogClass.Application, $"Invalid option '{arg}'"); + + continue; + } + + OverrideGraphicsBackend = args[++i]; + break; + case "--docked-mode": + OverrideDockedMode = true; + break; + case "--handheld-mode": + OverrideDockedMode = false; + break; + case "--hide-cursor": + if (i + 1 >= args.Length) + { + Logger.Error?.Print(LogClass.Application, $"Invalid option '{arg}'"); + + continue; + } + + OverrideHideCursor = args[++i]; + break; + default: + LaunchPathArg = arg; + break; + } + } + + Arguments = arguments.ToArray(); + } + } +} diff --git a/src/Ryujinx.UI.Common/Helper/ConsoleHelper.cs b/src/Ryujinx.UI.Common/Helper/ConsoleHelper.cs new file mode 100644 index 00000000..208ff5c9 --- /dev/null +++ b/src/Ryujinx.UI.Common/Helper/ConsoleHelper.cs @@ -0,0 +1,50 @@ +using Ryujinx.Common.Logging; +using System; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace Ryujinx.UI.Common.Helper +{ + public static partial class ConsoleHelper + { + public static bool SetConsoleWindowStateSupported => OperatingSystem.IsWindows(); + + public static void SetConsoleWindowState(bool show) + { + if (OperatingSystem.IsWindows()) + { + SetConsoleWindowStateWindows(show); + } + else if (show == false) + { + Logger.Warning?.Print(LogClass.Application, "OS doesn't support hiding console window"); + } + } + + [SupportedOSPlatform("windows")] + private static void SetConsoleWindowStateWindows(bool show) + { + const int SW_HIDE = 0; + const int SW_SHOW = 5; + + IntPtr hWnd = GetConsoleWindow(); + + if (hWnd == IntPtr.Zero) + { + Logger.Warning?.Print(LogClass.Application, "Attempted to show/hide console window but console window does not exist"); + return; + } + + ShowWindow(hWnd, show ? SW_SHOW : SW_HIDE); + } + + [SupportedOSPlatform("windows")] + [LibraryImport("kernel32")] + private static partial IntPtr GetConsoleWindow(); + + [SupportedOSPlatform("windows")] + [LibraryImport("user32")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool ShowWindow(IntPtr hWnd, int nCmdShow); + } +} diff --git a/src/Ryujinx.UI.Common/Helper/FileAssociationHelper.cs b/src/Ryujinx.UI.Common/Helper/FileAssociationHelper.cs new file mode 100644 index 00000000..7ed02031 --- /dev/null +++ b/src/Ryujinx.UI.Common/Helper/FileAssociationHelper.cs @@ -0,0 +1,202 @@ +using Microsoft.Win32; +using Ryujinx.Common; +using Ryujinx.Common.Logging; +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace Ryujinx.UI.Common.Helper +{ + public static partial class FileAssociationHelper + { + private static readonly string[] _fileExtensions = { ".nca", ".nro", ".nso", ".nsp", ".xci" }; + + [SupportedOSPlatform("linux")] + private static readonly string _mimeDbPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "share", "mime"); + + private const int SHCNE_ASSOCCHANGED = 0x8000000; + private const int SHCNF_FLUSH = 0x1000; + + [LibraryImport("shell32.dll", SetLastError = true)] + public static partial void SHChangeNotify(uint wEventId, uint uFlags, IntPtr dwItem1, IntPtr dwItem2); + + public static bool IsTypeAssociationSupported => (OperatingSystem.IsLinux() || OperatingSystem.IsWindows()) && !ReleaseInformation.IsFlatHubBuild; + + [SupportedOSPlatform("linux")] + private static bool AreMimeTypesRegisteredLinux() => File.Exists(Path.Combine(_mimeDbPath, "packages", "Ryujinx.xml")); + + [SupportedOSPlatform("linux")] + private static bool InstallLinuxMimeTypes(bool uninstall = false) + { + string installKeyword = uninstall ? "uninstall" : "install"; + + if ((uninstall && AreMimeTypesRegisteredLinux()) || (!uninstall && !AreMimeTypesRegisteredLinux())) + { + string mimeTypesFile = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "mime", "Ryujinx.xml"); + string additionalArgs = !uninstall ? "--novendor" : ""; + + using Process mimeProcess = new(); + + mimeProcess.StartInfo.FileName = "xdg-mime"; + mimeProcess.StartInfo.Arguments = $"{installKeyword} {additionalArgs} --mode user {mimeTypesFile}"; + + mimeProcess.Start(); + mimeProcess.WaitForExit(); + + if (mimeProcess.ExitCode != 0) + { + Logger.Error?.PrintMsg(LogClass.Application, $"Unable to {installKeyword} mime types. Make sure xdg-utils is installed. Process exited with code: {mimeProcess.ExitCode}"); + + return false; + } + + using Process updateMimeProcess = new(); + + updateMimeProcess.StartInfo.FileName = "update-mime-database"; + updateMimeProcess.StartInfo.Arguments = _mimeDbPath; + + updateMimeProcess.Start(); + updateMimeProcess.WaitForExit(); + + if (updateMimeProcess.ExitCode != 0) + { + Logger.Error?.PrintMsg(LogClass.Application, $"Could not update local mime database. Process exited with code: {updateMimeProcess.ExitCode}"); + } + } + + return true; + } + + [SupportedOSPlatform("windows")] + private static bool AreMimeTypesRegisteredWindows() + { + static bool CheckRegistering(string ext) + { + RegistryKey key = Registry.CurrentUser.OpenSubKey(@$"Software\Classes\{ext}"); + + if (key is null) + { + return false; + } + + var openCmd = key.OpenSubKey(@"shell\open\command"); + + string keyValue = (string)openCmd.GetValue(""); + + return keyValue is not null && (keyValue.Contains("Ryujinx") || keyValue.Contains(AppDomain.CurrentDomain.FriendlyName)); + } + + bool registered = false; + + foreach (string ext in _fileExtensions) + { + registered |= CheckRegistering(ext); + } + + return registered; + } + + [SupportedOSPlatform("windows")] + private static bool InstallWindowsMimeTypes(bool uninstall = false) + { + static bool RegisterExtension(string ext, bool uninstall = false) + { + string keyString = @$"Software\Classes\{ext}"; + + if (uninstall) + { + // If the types don't already exist, there's nothing to do and we can call this operation successful. + if (!AreMimeTypesRegisteredWindows()) + { + return true; + } + Logger.Debug?.Print(LogClass.Application, $"Removing type association {ext}"); + Registry.CurrentUser.DeleteSubKeyTree(keyString); + Logger.Debug?.Print(LogClass.Application, $"Removed type association {ext}"); + } + else + { + using var key = Registry.CurrentUser.CreateSubKey(keyString); + + if (key is null) + { + return false; + } + + Logger.Debug?.Print(LogClass.Application, $"Adding type association {ext}"); + using var openCmd = key.CreateSubKey(@"shell\open\command"); + openCmd.SetValue("", $"\"{Environment.ProcessPath}\" \"%1\""); + Logger.Debug?.Print(LogClass.Application, $"Added type association {ext}"); + + } + + return true; + } + + bool registered = false; + + foreach (string ext in _fileExtensions) + { + registered |= RegisterExtension(ext, uninstall); + } + + // Notify Explorer the file association has been changed. + SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_FLUSH, IntPtr.Zero, IntPtr.Zero); + + return registered; + } + + public static bool AreMimeTypesRegistered() + { + if (OperatingSystem.IsLinux()) + { + return AreMimeTypesRegisteredLinux(); + } + + if (OperatingSystem.IsWindows()) + { + return AreMimeTypesRegisteredWindows(); + } + + // TODO: Add macOS support. + + return false; + } + + public static bool Install() + { + if (OperatingSystem.IsLinux()) + { + return InstallLinuxMimeTypes(); + } + + if (OperatingSystem.IsWindows()) + { + return InstallWindowsMimeTypes(); + } + + // TODO: Add macOS support. + + return false; + } + + public static bool Uninstall() + { + if (OperatingSystem.IsLinux()) + { + return InstallLinuxMimeTypes(true); + } + + if (OperatingSystem.IsWindows()) + { + return InstallWindowsMimeTypes(true); + } + + // TODO: Add macOS support. + + return false; + } + } +} diff --git a/src/Ryujinx.UI.Common/Helper/LinuxHelper.cs b/src/Ryujinx.UI.Common/Helper/LinuxHelper.cs new file mode 100644 index 00000000..b5779379 --- /dev/null +++ b/src/Ryujinx.UI.Common/Helper/LinuxHelper.cs @@ -0,0 +1,62 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.Versioning; + +namespace Ryujinx.UI.Common.Helper +{ + [SupportedOSPlatform("linux")] + public static class LinuxHelper + { + // NOTE: This value was determined by manual tests and might need to be increased again. + public const int RecommendedVmMaxMapCount = 524288; + public const string VmMaxMapCountPath = "/proc/sys/vm/max_map_count"; + public const string SysCtlConfigPath = "/etc/sysctl.d/99-Ryujinx.conf"; + public static int VmMaxMapCount => int.Parse(File.ReadAllText(VmMaxMapCountPath)); + public static string PkExecPath { get; } = GetBinaryPath("pkexec"); + + private static string GetBinaryPath(string binary) + { + string pathVar = Environment.GetEnvironmentVariable("PATH"); + + if (pathVar is null || string.IsNullOrEmpty(binary)) + { + return null; + } + + foreach (var searchPath in pathVar.Split(":", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + string binaryPath = Path.Combine(searchPath, binary); + + if (File.Exists(binaryPath)) + { + return binaryPath; + } + } + + return null; + } + + public static int RunPkExec(string command) + { + if (PkExecPath == null) + { + return 1; + } + + using Process process = new() + { + StartInfo = + { + FileName = PkExecPath, + ArgumentList = { "sh", "-c", command }, + }, + }; + + process.Start(); + process.WaitForExit(); + + return process.ExitCode; + } + } +} diff --git a/src/Ryujinx.UI.Common/Helper/ObjectiveC.cs b/src/Ryujinx.UI.Common/Helper/ObjectiveC.cs new file mode 100644 index 00000000..6aba377a --- /dev/null +++ b/src/Ryujinx.UI.Common/Helper/ObjectiveC.cs @@ -0,0 +1,160 @@ +using System; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace Ryujinx.UI.Common.Helper +{ + [SupportedOSPlatform("macos")] + public static partial class ObjectiveC + { + private const string ObjCRuntime = "/usr/lib/libobjc.A.dylib"; + + [LibraryImport(ObjCRuntime, StringMarshalling = StringMarshalling.Utf8)] + private static partial IntPtr sel_getUid(string name); + + [LibraryImport(ObjCRuntime, StringMarshalling = StringMarshalling.Utf8)] + private static partial IntPtr objc_getClass(string name); + + [LibraryImport(ObjCRuntime)] + private static partial void objc_msgSend(IntPtr receiver, Selector selector); + + [LibraryImport(ObjCRuntime)] + private static partial void objc_msgSend(IntPtr receiver, Selector selector, byte value); + + [LibraryImport(ObjCRuntime)] + private static partial void objc_msgSend(IntPtr receiver, Selector selector, IntPtr value); + + [LibraryImport(ObjCRuntime)] + private static partial void objc_msgSend(IntPtr receiver, Selector selector, NSRect point); + + [LibraryImport(ObjCRuntime)] + private static partial void objc_msgSend(IntPtr receiver, Selector selector, double value); + + [LibraryImport(ObjCRuntime, EntryPoint = "objc_msgSend")] + private static partial IntPtr IntPtr_objc_msgSend(IntPtr receiver, Selector selector); + + [LibraryImport(ObjCRuntime, EntryPoint = "objc_msgSend")] + private static partial IntPtr IntPtr_objc_msgSend(IntPtr receiver, Selector selector, IntPtr param); + + [LibraryImport(ObjCRuntime, EntryPoint = "objc_msgSend", StringMarshalling = StringMarshalling.Utf8)] + private static partial IntPtr IntPtr_objc_msgSend(IntPtr receiver, Selector selector, string param); + + [LibraryImport(ObjCRuntime, EntryPoint = "objc_msgSend")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool bool_objc_msgSend(IntPtr receiver, Selector selector, IntPtr param); + + public readonly struct Object + { + public readonly IntPtr ObjPtr; + + private Object(IntPtr pointer) + { + ObjPtr = pointer; + } + + public Object(string name) + { + ObjPtr = objc_getClass(name); + } + + public void SendMessage(Selector selector) + { + objc_msgSend(ObjPtr, selector); + } + + public void SendMessage(Selector selector, byte value) + { + objc_msgSend(ObjPtr, selector, value); + } + + public void SendMessage(Selector selector, Object obj) + { + objc_msgSend(ObjPtr, selector, obj.ObjPtr); + } + + public void SendMessage(Selector selector, NSRect point) + { + objc_msgSend(ObjPtr, selector, point); + } + + public void SendMessage(Selector selector, double value) + { + objc_msgSend(ObjPtr, selector, value); + } + + public Object GetFromMessage(Selector selector) + { + return new Object(IntPtr_objc_msgSend(ObjPtr, selector)); + } + + public Object GetFromMessage(Selector selector, Object obj) + { + return new Object(IntPtr_objc_msgSend(ObjPtr, selector, obj.ObjPtr)); + } + + public Object GetFromMessage(Selector selector, NSString nsString) + { + return new Object(IntPtr_objc_msgSend(ObjPtr, selector, nsString.StrPtr)); + } + + public Object GetFromMessage(Selector selector, string param) + { + return new Object(IntPtr_objc_msgSend(ObjPtr, selector, param)); + } + + public bool GetBoolFromMessage(Selector selector, Object obj) + { + return bool_objc_msgSend(ObjPtr, selector, obj.ObjPtr); + } + } + + public readonly struct Selector + { + public readonly IntPtr SelPtr; + + private Selector(string name) + { + SelPtr = sel_getUid(name); + } + + public static implicit operator Selector(string value) => new(value); + } + + public readonly struct NSString + { + public readonly IntPtr StrPtr; + + public NSString(string aString) + { + IntPtr nsString = objc_getClass("NSString"); + StrPtr = IntPtr_objc_msgSend(nsString, "stringWithUTF8String:", aString); + } + + public static implicit operator IntPtr(NSString nsString) => nsString.StrPtr; + } + + public readonly struct NSPoint + { + public readonly double X; + public readonly double Y; + + public NSPoint(double x, double y) + { + X = x; + Y = y; + } + } + + public readonly struct NSRect + { + public readonly NSPoint Pos; + public readonly NSPoint Size; + + public NSRect(double x, double y, double width, double height) + { + Pos = new NSPoint(x, y); + Size = new NSPoint(width, height); + } + } + } +} diff --git a/src/Ryujinx.UI.Common/Helper/OpenHelper.cs b/src/Ryujinx.UI.Common/Helper/OpenHelper.cs new file mode 100644 index 00000000..af6170af --- /dev/null +++ b/src/Ryujinx.UI.Common/Helper/OpenHelper.cs @@ -0,0 +1,112 @@ +using Ryujinx.Common.Logging; +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; + +namespace Ryujinx.UI.Common.Helper +{ + public static partial class OpenHelper + { + [LibraryImport("shell32.dll", SetLastError = true)] + private static partial int SHOpenFolderAndSelectItems(IntPtr pidlFolder, uint cidl, IntPtr apidl, uint dwFlags); + + [LibraryImport("shell32.dll", SetLastError = true)] + private static partial void ILFree(IntPtr pidlList); + + [LibraryImport("shell32.dll", SetLastError = true)] + private static partial IntPtr ILCreateFromPathW([MarshalAs(UnmanagedType.LPWStr)] string pszPath); + + public static void OpenFolder(string path) + { + if (Directory.Exists(path)) + { + Process.Start(new ProcessStartInfo + { + FileName = path, + UseShellExecute = true, + Verb = "open", + }); + } + else + { + Logger.Notice.Print(LogClass.Application, $"Directory \"{path}\" doesn't exist!"); + } + } + + public static void LocateFile(string path) + { + if (File.Exists(path)) + { + if (OperatingSystem.IsWindows()) + { + IntPtr pidlList = ILCreateFromPathW(path); + if (pidlList != IntPtr.Zero) + { + try + { + Marshal.ThrowExceptionForHR(SHOpenFolderAndSelectItems(pidlList, 0, IntPtr.Zero, 0)); + } + finally + { + ILFree(pidlList); + } + } + } + else if (OperatingSystem.IsMacOS()) + { + ObjectiveC.NSString nsStringPath = new(path); + ObjectiveC.Object nsUrl = new("NSURL"); + var urlPtr = nsUrl.GetFromMessage("fileURLWithPath:", nsStringPath); + + ObjectiveC.Object nsArray = new("NSArray"); + ObjectiveC.Object urlArray = nsArray.GetFromMessage("arrayWithObject:", urlPtr); + + ObjectiveC.Object nsWorkspace = new("NSWorkspace"); + ObjectiveC.Object sharedWorkspace = nsWorkspace.GetFromMessage("sharedWorkspace"); + + sharedWorkspace.SendMessage("activateFileViewerSelectingURLs:", urlArray); + } + else if (OperatingSystem.IsLinux()) + { + Process.Start("dbus-send", $"--session --print-reply --dest=org.freedesktop.FileManager1 --type=method_call /org/freedesktop/FileManager1 org.freedesktop.FileManager1.ShowItems array:string:\"file://{path}\" string:\"\""); + } + else + { + OpenFolder(Path.GetDirectoryName(path)); + } + } + else + { + Logger.Notice.Print(LogClass.Application, $"File \"{path}\" doesn't exist!"); + } + } + + public static void OpenUrl(string url) + { + if (OperatingSystem.IsWindows()) + { + Process.Start(new ProcessStartInfo("cmd", $"/c start {url.Replace("&", "^&")}")); + } + else if (OperatingSystem.IsLinux()) + { + Process.Start("xdg-open", url); + } + else if (OperatingSystem.IsMacOS()) + { + ObjectiveC.NSString nsStringPath = new(url); + ObjectiveC.Object nsUrl = new("NSURL"); + var urlPtr = nsUrl.GetFromMessage("URLWithString:", nsStringPath); + + ObjectiveC.Object nsWorkspace = new("NSWorkspace"); + ObjectiveC.Object sharedWorkspace = nsWorkspace.GetFromMessage("sharedWorkspace"); + + sharedWorkspace.GetBoolFromMessage("openURL:", urlPtr); + } + else + { + Logger.Notice.Print(LogClass.Application, $"Cannot open url \"{url}\" on this platform!"); + } + } + } +} diff --git a/src/Ryujinx.UI.Common/Helper/SetupValidator.cs b/src/Ryujinx.UI.Common/Helper/SetupValidator.cs new file mode 100644 index 00000000..a954be26 --- /dev/null +++ b/src/Ryujinx.UI.Common/Helper/SetupValidator.cs @@ -0,0 +1,114 @@ +using Ryujinx.Common.Logging; +using Ryujinx.HLE.FileSystem; +using System; +using System.IO; + +namespace Ryujinx.UI.Common.Helper +{ + /// <summary> + /// Ensure installation validity + /// </summary> + public static class SetupValidator + { + public static bool IsFirmwareValid(ContentManager contentManager, out UserError error) + { + bool hasFirmware = contentManager.GetCurrentFirmwareVersion() != null; + + if (hasFirmware) + { + error = UserError.Success; + + return true; + } + + error = UserError.NoFirmware; + + return false; + } + + public static bool CanFixStartApplication(ContentManager contentManager, string baseApplicationPath, UserError error, out SystemVersion firmwareVersion) + { + try + { + firmwareVersion = contentManager.VerifyFirmwarePackage(baseApplicationPath); + } + catch (Exception) + { + firmwareVersion = null; + } + + return error == UserError.NoFirmware && Path.GetExtension(baseApplicationPath).ToLowerInvariant() == ".xci" && firmwareVersion != null; + } + + public static bool TryFixStartApplication(ContentManager contentManager, string baseApplicationPath, UserError error, out UserError outError) + { + if (error == UserError.NoFirmware) + { + string baseApplicationExtension = Path.GetExtension(baseApplicationPath).ToLowerInvariant(); + + // If the target app to start is a XCI, try to install firmware from it + if (baseApplicationExtension == ".xci") + { + SystemVersion firmwareVersion; + + try + { + firmwareVersion = contentManager.VerifyFirmwarePackage(baseApplicationPath); + } + catch (Exception) + { + firmwareVersion = null; + } + + // The XCI is a valid firmware package, try to install the firmware from it! + if (firmwareVersion != null) + { + try + { + Logger.Info?.Print(LogClass.Application, $"Installing firmware {firmwareVersion.VersionString}"); + + contentManager.InstallFirmware(baseApplicationPath); + + Logger.Info?.Print(LogClass.Application, $"System version {firmwareVersion.VersionString} successfully installed."); + + outError = UserError.Success; + + return true; + } + catch (Exception) { } + } + + outError = error; + + return false; + } + } + + outError = error; + + return false; + } + + public static bool CanStartApplication(ContentManager contentManager, string baseApplicationPath, out UserError error) + { + if (Directory.Exists(baseApplicationPath) || File.Exists(baseApplicationPath)) + { + string baseApplicationExtension = Path.GetExtension(baseApplicationPath).ToLowerInvariant(); + + // NOTE: We don't force homebrew developers to install a system firmware. + if (baseApplicationExtension == ".nro" || baseApplicationExtension == ".nso") + { + error = UserError.Success; + + return true; + } + + return IsFirmwareValid(contentManager, out error); + } + + error = UserError.ApplicationNotFound; + + return false; + } + } +} diff --git a/src/Ryujinx.UI.Common/Helper/ShortcutHelper.cs b/src/Ryujinx.UI.Common/Helper/ShortcutHelper.cs new file mode 100644 index 00000000..c2085b28 --- /dev/null +++ b/src/Ryujinx.UI.Common/Helper/ShortcutHelper.cs @@ -0,0 +1,162 @@ +using Ryujinx.Common; +using Ryujinx.Common.Configuration; +using ShellLink; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.Versioning; + +namespace Ryujinx.UI.Common.Helper +{ + public static class ShortcutHelper + { + [SupportedOSPlatform("windows")] + private static void CreateShortcutWindows(string applicationFilePath, byte[] iconData, string iconPath, string cleanedAppName, string desktopPath) + { + string basePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, AppDomain.CurrentDomain.FriendlyName + ".exe"); + iconPath += ".ico"; + + MemoryStream iconDataStream = new(iconData); + var image = Image.Load(iconDataStream); + image.Mutate(x => x.Resize(128, 128)); + SaveBitmapAsIcon(image, iconPath); + + var shortcut = Shortcut.CreateShortcut(basePath, GetArgsString(applicationFilePath), iconPath, 0); + shortcut.StringData.NameString = cleanedAppName; + shortcut.WriteToFile(Path.Combine(desktopPath, cleanedAppName + ".lnk")); + } + + [SupportedOSPlatform("linux")] + private static void CreateShortcutLinux(string applicationFilePath, byte[] iconData, string iconPath, string desktopPath, string cleanedAppName) + { + string basePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Ryujinx.sh"); + var desktopFile = EmbeddedResources.ReadAllText("Ryujinx.UI.Common/shortcut-template.desktop"); + iconPath += ".png"; + + var image = Image.Load<Rgba32>(iconData); + image.SaveAsPng(iconPath); + + using StreamWriter outputFile = new(Path.Combine(desktopPath, cleanedAppName + ".desktop")); + outputFile.Write(desktopFile, cleanedAppName, iconPath, $"{basePath} {GetArgsString(applicationFilePath)}"); + } + + [SupportedOSPlatform("macos")] + private static void CreateShortcutMacos(string appFilePath, byte[] iconData, string desktopPath, string cleanedAppName) + { + string basePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Ryujinx"); + var plistFile = EmbeddedResources.ReadAllText("Ryujinx.UI.Common/shortcut-template.plist"); + var shortcutScript = EmbeddedResources.ReadAllText("Ryujinx.UI.Common/shortcut-launch-script.sh"); + // Macos .App folder + string contentFolderPath = Path.Combine("/Applications", cleanedAppName + ".app", "Contents"); + string scriptFolderPath = Path.Combine(contentFolderPath, "MacOS"); + + if (!Directory.Exists(scriptFolderPath)) + { + Directory.CreateDirectory(scriptFolderPath); + } + + // Runner script + const string ScriptName = "runner.sh"; + string scriptPath = Path.Combine(scriptFolderPath, ScriptName); + using StreamWriter scriptFile = new(scriptPath); + + scriptFile.Write(shortcutScript, basePath, GetArgsString(appFilePath)); + + // Set execute permission + FileInfo fileInfo = new(scriptPath); + fileInfo.UnixFileMode |= UnixFileMode.UserExecute; + + // img + string resourceFolderPath = Path.Combine(contentFolderPath, "Resources"); + if (!Directory.Exists(resourceFolderPath)) + { + Directory.CreateDirectory(resourceFolderPath); + } + + const string IconName = "icon.png"; + var image = Image.Load<Rgba32>(iconData); + image.SaveAsPng(Path.Combine(resourceFolderPath, IconName)); + + // plist file + using StreamWriter outputFile = new(Path.Combine(contentFolderPath, "Info.plist")); + outputFile.Write(plistFile, ScriptName, cleanedAppName, IconName); + } + + public static void CreateAppShortcut(string applicationFilePath, string applicationName, string applicationId, byte[] iconData) + { + string desktopPath = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory); + string cleanedAppName = string.Join("_", applicationName.Split(Path.GetInvalidFileNameChars())); + + if (OperatingSystem.IsWindows()) + { + string iconPath = Path.Combine(AppDataManager.BaseDirPath, "games", applicationId, "app"); + + CreateShortcutWindows(applicationFilePath, iconData, iconPath, cleanedAppName, desktopPath); + + return; + } + + if (OperatingSystem.IsLinux()) + { + string iconPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "share", "icons", "Ryujinx"); + + Directory.CreateDirectory(iconPath); + CreateShortcutLinux(applicationFilePath, iconData, Path.Combine(iconPath, applicationId), desktopPath, cleanedAppName); + + return; + } + + if (OperatingSystem.IsMacOS()) + { + CreateShortcutMacos(applicationFilePath, iconData, desktopPath, cleanedAppName); + + return; + } + + throw new NotImplementedException("Shortcut support has not been implemented yet for this OS."); + } + + private static string GetArgsString(string appFilePath) + { + // args are first defined as a list, for easier adjustments in the future + var argsList = new List<string>(); + + if (!string.IsNullOrEmpty(CommandLineState.BaseDirPathArg)) + { + argsList.Add("--root-data-dir"); + argsList.Add($"\"{CommandLineState.BaseDirPathArg}\""); + } + + argsList.Add($"\"{appFilePath}\""); + + return String.Join(" ", argsList); + } + + /// <summary> + /// Creates a Icon (.ico) file using the source bitmap image at the specified file path. + /// </summary> + /// <param name="source">The source bitmap image that will be saved as an .ico file</param> + /// <param name="filePath">The location that the new .ico file will be saved too (Make sure to include '.ico' in the path).</param> + [SupportedOSPlatform("windows")] + private static void SaveBitmapAsIcon(Image source, string filePath) + { + // Code Modified From https://stackoverflow.com/a/11448060/368354 by Benlitz + byte[] header = { 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 32, 0, 0, 0, 0, 0, 22, 0, 0, 0 }; + using FileStream fs = new(filePath, FileMode.Create); + + fs.Write(header); + // Writing actual data + source.Save(fs, PngFormat.Instance); + // Getting data length (file length minus header) + long dataLength = fs.Length - header.Length; + // Write it in the correct place + fs.Seek(14, SeekOrigin.Begin); + fs.WriteByte((byte)dataLength); + fs.WriteByte((byte)(dataLength >> 8)); + } + } +} diff --git a/src/Ryujinx.UI.Common/Helper/TitleHelper.cs b/src/Ryujinx.UI.Common/Helper/TitleHelper.cs new file mode 100644 index 00000000..8b47ac38 --- /dev/null +++ b/src/Ryujinx.UI.Common/Helper/TitleHelper.cs @@ -0,0 +1,30 @@ +using Ryujinx.HLE.Loaders.Processes; +using System; + +namespace Ryujinx.UI.Common.Helper +{ + public static class TitleHelper + { + public static string ActiveApplicationTitle(ProcessResult activeProcess, string applicationVersion, string pauseString = "") + { + if (activeProcess == null) + { + return String.Empty; + } + + string titleNameSection = string.IsNullOrWhiteSpace(activeProcess.Name) ? string.Empty : $" {activeProcess.Name}"; + string titleVersionSection = string.IsNullOrWhiteSpace(activeProcess.DisplayVersion) ? string.Empty : $" v{activeProcess.DisplayVersion}"; + string titleIdSection = $" ({activeProcess.ProgramIdText.ToUpper()})"; + string titleArchSection = activeProcess.Is64Bit ? " (64-bit)" : " (32-bit)"; + + string appTitle = $"Ryujinx {applicationVersion} -{titleNameSection}{titleVersionSection}{titleIdSection}{titleArchSection}"; + + if (!string.IsNullOrEmpty(pauseString)) + { + appTitle += $" ({pauseString})"; + } + + return appTitle; + } + } +} diff --git a/src/Ryujinx.UI.Common/Helper/ValueFormatUtils.cs b/src/Ryujinx.UI.Common/Helper/ValueFormatUtils.cs new file mode 100644 index 00000000..8ea3e721 --- /dev/null +++ b/src/Ryujinx.UI.Common/Helper/ValueFormatUtils.cs @@ -0,0 +1,219 @@ +using System; +using System.Globalization; +using System.Linq; + +namespace Ryujinx.UI.Common.Helper +{ + public static class ValueFormatUtils + { + private static readonly string[] _fileSizeUnitStrings = + { + "B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", // Base 10 units, used for formatting and parsing + "KB", "MB", "GB", "TB", "PB", "EB", // Base 2 units, used for parsing legacy values + }; + + /// <summary> + /// Used by <see cref="FormatFileSize"/>. + /// </summary> + public enum FileSizeUnits + { + Auto = -1, + Bytes = 0, + Kibibytes = 1, + Mebibytes = 2, + Gibibytes = 3, + Tebibytes = 4, + Pebibytes = 5, + Exbibytes = 6, + Kilobytes = 7, + Megabytes = 8, + Gigabytes = 9, + Terabytes = 10, + Petabytes = 11, + Exabytes = 12, + } + + private const double SizeBase10 = 1000; + private const double SizeBase2 = 1024; + private const int UnitEBIndex = 6; + + #region Value formatters + + /// <summary> + /// Creates a human-readable string from a <see cref="TimeSpan"/>. + /// </summary> + /// <param name="timeSpan">The <see cref="TimeSpan"/> to be formatted.</param> + /// <returns>A formatted string that can be displayed in the UI.</returns> + public static string FormatTimeSpan(TimeSpan? timeSpan) + { + if (!timeSpan.HasValue || timeSpan.Value.TotalSeconds < 1) + { + // Game was never played + return TimeSpan.Zero.ToString("c", CultureInfo.InvariantCulture); + } + + if (timeSpan.Value.TotalDays < 1) + { + // Game was played for less than a day + return timeSpan.Value.ToString("c", CultureInfo.InvariantCulture); + } + + // Game was played for more than a day + TimeSpan onlyTime = timeSpan.Value.Subtract(TimeSpan.FromDays(timeSpan.Value.Days)); + string onlyTimeString = onlyTime.ToString("c", CultureInfo.InvariantCulture); + + return $"{timeSpan.Value.Days}d, {onlyTimeString}"; + } + + /// <summary> + /// Creates a human-readable string from a <see cref="DateTime"/>. + /// </summary> + /// <param name="utcDateTime">The <see cref="DateTime"/> to be formatted. This is expected to be UTC-based.</param> + /// <param name="culture">The <see cref="CultureInfo"/> that's used in formatting. Defaults to <see cref="CultureInfo.CurrentCulture"/>.</param> + /// <returns>A formatted string that can be displayed in the UI.</returns> + public static string FormatDateTime(DateTime? utcDateTime, CultureInfo culture = null) + { + culture ??= CultureInfo.CurrentCulture; + + if (!utcDateTime.HasValue) + { + // In the Avalonia UI, this is turned into a localized version of "Never" by LocalizedNeverConverter. + return "Never"; + } + + return utcDateTime.Value.ToLocalTime().ToString(culture); + } + + /// <summary> + /// Creates a human-readable file size string. + /// </summary> + /// <param name="size">The file size in bytes.</param> + /// <param name="forceUnit">Formats the passed size value as this unit, bypassing the automatic unit choice.</param> + /// <returns>A human-readable file size string.</returns> + public static string FormatFileSize(long size, FileSizeUnits forceUnit = FileSizeUnits.Auto) + { + if (size <= 0) + { + return $"0 {_fileSizeUnitStrings[0]}"; + } + + int unitIndex = (int)forceUnit; + if (forceUnit == FileSizeUnits.Auto) + { + unitIndex = Convert.ToInt32(Math.Floor(Math.Log(size, SizeBase10))); + + // Apply an upper bound so that exabytes are the biggest unit used when formatting. + if (unitIndex > UnitEBIndex) + { + unitIndex = UnitEBIndex; + } + } + + double sizeRounded; + + if (unitIndex > UnitEBIndex) + { + sizeRounded = Math.Round(size / Math.Pow(SizeBase10, unitIndex - UnitEBIndex), 1); + } + else + { + sizeRounded = Math.Round(size / Math.Pow(SizeBase2, unitIndex), 1); + } + + string sizeFormatted = sizeRounded.ToString(CultureInfo.InvariantCulture); + + return $"{sizeFormatted} {_fileSizeUnitStrings[unitIndex]}"; + } + + #endregion + + #region Value parsers + + /// <summary> + /// Parses a string generated by <see cref="FormatTimeSpan"/> and returns the original <see cref="TimeSpan"/>. + /// </summary> + /// <param name="timeSpanString">A string representing a <see cref="TimeSpan"/>.</param> + /// <returns>A <see cref="TimeSpan"/> object. If the input string couldn't been parsed, <see cref="TimeSpan.Zero"/> is returned.</returns> + public static TimeSpan ParseTimeSpan(string timeSpanString) + { + TimeSpan returnTimeSpan = TimeSpan.Zero; + + // An input string can either look like "01:23:45" or "1d, 01:23:45" if the timespan represents a duration of more than a day. + // Here, we split the input string to check if it's the former or the latter. + var valueSplit = timeSpanString.Split(", "); + if (valueSplit.Length > 1) + { + var dayPart = valueSplit[0].Split("d")[0]; + if (int.TryParse(dayPart, out int days)) + { + returnTimeSpan = returnTimeSpan.Add(TimeSpan.FromDays(days)); + } + } + + if (TimeSpan.TryParse(valueSplit.Last(), out TimeSpan parsedTimeSpan)) + { + returnTimeSpan = returnTimeSpan.Add(parsedTimeSpan); + } + + return returnTimeSpan; + } + + /// <summary> + /// Parses a string generated by <see cref="FormatDateTime"/> and returns the original <see cref="DateTime"/>. + /// </summary> + /// <param name="dateTimeString">The string representing a <see cref="DateTime"/>.</param> + /// <returns>A <see cref="DateTime"/> object. If the input string couldn't be parsed, <see cref="DateTime.UnixEpoch"/> is returned.</returns> + public static DateTime ParseDateTime(string dateTimeString) + { + if (!DateTime.TryParse(dateTimeString, CultureInfo.CurrentCulture, out DateTime parsedDateTime)) + { + // Games that were never played are supposed to appear before the oldest played games in the list, + // so returning DateTime.UnixEpoch here makes sense. + return DateTime.UnixEpoch; + } + + return parsedDateTime; + } + + /// <summary> + /// Parses a string generated by <see cref="FormatFileSize"/> and returns a <see cref="long"/> representing a number of bytes. + /// </summary> + /// <param name="sizeString">A string representing a file size formatted with <see cref="FormatFileSize"/>.</param> + /// <returns>A <see cref="long"/> representing a number of bytes.</returns> + public static long ParseFileSize(string sizeString) + { + // Enumerating over the units backwards because otherwise, sizeString.EndsWith("B") would exit the loop in the first iteration. + for (int i = _fileSizeUnitStrings.Length - 1; i >= 0; i--) + { + string unit = _fileSizeUnitStrings[i]; + if (!sizeString.EndsWith(unit)) + { + continue; + } + + string numberString = sizeString.Split(" ")[0]; + if (!double.TryParse(numberString, CultureInfo.InvariantCulture, out double number)) + { + break; + } + + double sizeBase = SizeBase2; + + // If the unit index is one that points to a base 10 unit in the FileSizeUnitStrings array, subtract 6 to arrive at a usable power value. + if (i > UnitEBIndex) + { + i -= UnitEBIndex; + sizeBase = SizeBase10; + } + + number *= Math.Pow(sizeBase, i); + + return Convert.ToInt64(number); + } + + return 0; + } + + #endregion + } +} |
