From a42f0bbb87b890d4f16b1148f9398210a5bfedfa Mon Sep 17 00:00:00 2001
From: NitroTears <73270647+NitroTears@users.noreply.github.com>
Date: Sat, 21 Oct 2023 05:51:15 +1100
Subject: [PATCH] Add "Create Shortcut" To app context menu (#4734)

* Added basic implementation for shortcut creation

Currently bitmaps (.bmp) are used as the source file, colours are good (unlike .ico rn) but are scaled poorly on desktop.

* Icons display properly in shortcut

* code cleanup

* Moved shortcut logic to specific file, added Ava UI for shortcuts

* Added linux .desktop shortcut creation

* fixes to .shortcut data

* code issue fixes

* Added basic implementation for shortcut creation

Currently bitmaps (.bmp) are used as the source file, colours are good (unlike .ico rn) but are scaled poorly on desktop.

* Icons display properly in shortcut

* code cleanup

* Moved shortcut logic to specific file, added Ava UI for shortcuts

* Added linux .desktop shortcut creation

* fixes to .shortcut data

* code issue fixes

* added back shortcut to new contextmenu file

* Replaced COM reference with ComImport for shortcut functionality

* remove specific platform values and regions

* Move ShortcutHelper to Ryujinx.Ui.Common.Helpers

* Adjust styling and structure

* code feedback changes

* Added MacOS support using .app folder

* Added basic implementation for shortcut creation

Currently bitmaps (.bmp) are used as the source file, colours are good (unlike .ico rn) but are scaled poorly on desktop.

* Icons display properly in shortcut

* code cleanup

* Moved shortcut logic to specific file, added Ava UI for shortcuts

* Added linux .desktop shortcut creation

* fixes to .shortcut data

* code issue fixes

* Added basic implementation for shortcut creation

Currently bitmaps (.bmp) are used as the source file, colours are good (unlike .ico rn) but are scaled poorly on desktop.

* Icons display properly in shortcut

* code cleanup

* Moved shortcut logic to specific file, added Ava UI for shortcuts

* Added linux .desktop shortcut creation

* fixes to .shortcut data

* code issue fixes

* Replaced COM reference with ComImport for shortcut functionality

* remove specific platform values and regions

* Move ShortcutHelper to Ryujinx.Ui.Common.Helpers

* Adjust styling and structure

* code feedback changes

* adjust tooltip message

* added shortcut-template.desktop file

* set shortcut icon location to .local/share/icons

* Linux code feedback changes

* change InteropServices to new securifybv.ShellLink Package

* added ShellLink to readme, updated shortcut comment

* Code feedback changes

* Added MacOS Support (As per Jose Estrada's PR)

* dotnet format

* Small restructuring

* Embed template files into Ryujinx.Ui.Common

* Disable "CreateShortcut" option for flatpak builds

---------

Co-authored-by: TSR Berry <20988865+TSRBerry@users.noreply.github.com>
Co-authored-by: Jose Estrada <joseestradacobo@gmail.com>
---
 Directory.Packages.props                      |   1 +
 README.md                                     |   1 +
 distribution/legal/THIRDPARTY.md              |  29 +++
 distribution/linux/Ryujinx.desktop            |   4 +-
 distribution/linux/shortcut-template.desktop  |  13 ++
 distribution/macos/shortcut-template.plist    |  35 ++++
 src/Ryujinx.Ava/Assets/Locales/en_US.json     |   2 +
 src/Ryujinx.Ava/Ryujinx.Ava.csproj            |   2 +-
 .../UI/Controls/ApplicationContextMenu.axaml  |   7 +-
 .../Controls/ApplicationContextMenu.axaml.cs  |  11 ++
 .../UI/ViewModels/MainWindowViewModel.cs      |   5 +-
 .../App/ApplicationLibrary.cs                 |   4 +-
 .../Helper/ShortcutHelper.cs                  | 171 ++++++++++++++++++
 .../Ryujinx.Ui.Common.csproj                  |  10 +
 src/Ryujinx/Ryujinx.csproj                    |  18 +-
 .../Widgets/GameTableContextMenu.Designer.cs  |  11 ++
 .../Ui/Widgets/GameTableContextMenu.cs        |   9 +
 17 files changed, 316 insertions(+), 17 deletions(-)
 create mode 100644 distribution/linux/shortcut-template.desktop
 create mode 100644 distribution/macos/shortcut-template.plist
 create mode 100644 src/Ryujinx.Ui.Common/Helper/ShortcutHelper.cs

diff --git a/Directory.Packages.props b/Directory.Packages.props
index b34b882b4..6fdaafddc 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -35,6 +35,7 @@
     <PackageVersion Include="Ryujinx.Graphics.Vulkan.Dependencies.MoltenVK" Version="1.2.0" />
     <PackageVersion Include="Ryujinx.GtkSharp" Version="3.24.24.59-ryujinx" />
     <PackageVersion Include="Ryujinx.SDL2-CS" Version="2.28.1-build28" />
+    <PackageVersion Include="securifybv.ShellLink" Version="0.1.0" />
     <PackageVersion Include="shaderc.net" Version="0.1.0" />
     <PackageVersion Include="SharpZipLib" Version="1.4.2" />
     <PackageVersion Include="Silk.NET.Vulkan" Version="2.16.0" />
diff --git a/README.md b/README.md
index 7021abc45..56333278f 100644
--- a/README.md
+++ b/README.md
@@ -141,3 +141,4 @@ See [LICENSE.txt](LICENSE.txt) and [THIRDPARTY.md](distribution/legal/THIRDPARTY
 
 - [LibHac](https://github.com/Thealexbarney/LibHac) is used for our file-system.
 - [AmiiboAPI](https://www.amiiboapi.com) is used in our Amiibo emulation.
+- [ShellLink](https://github.com/securifybv/ShellLink) is used for Windows shortcut generation.
diff --git a/distribution/legal/THIRDPARTY.md b/distribution/legal/THIRDPARTY.md
index 4cc8b7a45..b0bd5a690 100644
--- a/distribution/legal/THIRDPARTY.md
+++ b/distribution/legal/THIRDPARTY.md
@@ -681,4 +681,33 @@
 
                        END OF TERMS AND CONDITIONS
   ```
+</details>
+
+# ShellLink (MIT)
+<details>
+  <summary>See License</summary>
+
+  ```
+  MIT License
+
+  Copyright (c) 2017 Yorick Koster, Securify B.V.
+
+  Permission is hereby granted, free of charge, to any person obtaining a copy
+  of this software and associated documentation files (the "Software"), to deal
+  in the Software without restriction, including without limitation the rights
+  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+  copies of the Software, and to permit persons to whom the Software is
+  furnished to do so, subject to the following conditions:
+
+  The above copyright notice and this permission notice shall be included in all
+  copies or substantial portions of the Software.
+
+  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+  SOFTWARE.
+  ```
 </details>
\ No newline at end of file
diff --git a/distribution/linux/Ryujinx.desktop b/distribution/linux/Ryujinx.desktop
index 19cc5d6cc..a4550d104 100644
--- a/distribution/linux/Ryujinx.desktop
+++ b/distribution/linux/Ryujinx.desktop
@@ -3,8 +3,8 @@ Version=1.0
 Name=Ryujinx
 Type=Application
 Icon=Ryujinx
-Exec=env DOTNET_EnableAlternateStackCheck=1 Ryujinx %f
-Comment=A Nintendo Switch Emulator
+Exec=Ryujinx.sh %f
+Comment=Plays Nintendo Switch applications
 GenericName=Nintendo Switch Emulator
 Terminal=false
 Categories=Game;Emulator;
diff --git a/distribution/linux/shortcut-template.desktop b/distribution/linux/shortcut-template.desktop
new file mode 100644
index 000000000..6bee0f8d1
--- /dev/null
+++ b/distribution/linux/shortcut-template.desktop
@@ -0,0 +1,13 @@
+[Desktop Entry]
+Version=1.0
+Name={0}
+Type=Application
+Icon={1}
+Exec={2} %f
+Comment=Nintendo Switch application
+GenericName=Nintendo Switch Emulator
+Terminal=false
+Categories=Game;Emulator;
+Keywords=Switch;Nintendo;Emulator;
+StartupWMClass=Ryujinx
+PrefersNonDefaultGPU=true
diff --git a/distribution/macos/shortcut-template.plist b/distribution/macos/shortcut-template.plist
new file mode 100644
index 000000000..27a9e46a9
--- /dev/null
+++ b/distribution/macos/shortcut-template.plist
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>CFBundleDevelopmentRegion</key>
+	<string>English</string>
+	<key>CFBundleExecutable</key>
+	<string>{0}</string>
+	<key>CFBundleGetInfoString</key>
+	<string>{1}</string>
+	<key>CFBundleIconFile</key>
+	<string>{2}</string>
+	<key>CFBundleInfoDictionaryVersion</key>
+	<string>6.0</string>
+	<key>CFBundleVersion</key>
+	<string>1.0</string>
+	<key>NSHighResolutionCapable</key>
+	<true/>
+	<key>CSResourcesFileMapped</key>
+	<true/>
+	<key>NSHumanReadableCopyright</key>
+	<string>Copyright © 2018 - 2023 Ryujinx Team and Contributors.</string>
+	<key>LSApplicationCategoryType</key>
+	<string>public.app-category.games</string>
+	<key>LSMinimumSystemVersion</key>
+	<string>11.0</string>
+	<key>UIPrerenderedIcon</key>
+	<true/>
+	<key>LSEnvironment</key>
+	<dict>
+		<key>DOTNET_DefaultStackSize</key>
+		<string>200000</string>
+	</dict>
+</dict>
+</plist>
diff --git a/src/Ryujinx.Ava/Assets/Locales/en_US.json b/src/Ryujinx.Ava/Assets/Locales/en_US.json
index 53e277ba9..a67b796bd 100644
--- a/src/Ryujinx.Ava/Assets/Locales/en_US.json
+++ b/src/Ryujinx.Ava/Assets/Locales/en_US.json
@@ -72,6 +72,8 @@
   "GameListContextMenuExtractDataRomFSToolTip": "Extract the RomFS section from Application's current config (including updates)",
   "GameListContextMenuExtractDataLogo": "Logo",
   "GameListContextMenuExtractDataLogoToolTip": "Extract the Logo section from Application's current config (including updates)",
+  "GameListContextMenuCreateShortcut": "Create Application Shortcut",
+  "GameListContextMenuCreateShortcutToolTip": "Create a Desktop Shortcut that launches the selected Application",
   "StatusBarGamesLoaded": "{0}/{1} Games Loaded",
   "StatusBarSystemVersion": "System Version: {0}",
   "LinuxVmMaxMapCountDialogTitle": "Low limit for memory mappings detected",
diff --git a/src/Ryujinx.Ava/Ryujinx.Ava.csproj b/src/Ryujinx.Ava/Ryujinx.Ava.csproj
index a4c1ebf16..f0e99f427 100644
--- a/src/Ryujinx.Ava/Ryujinx.Ava.csproj
+++ b/src/Ryujinx.Ava/Ryujinx.Ava.csproj
@@ -145,4 +145,4 @@
   <ItemGroup>
     <AdditionalFiles Include="Assets\Locales\en_US.json" />
   </ItemGroup>
-</Project>
\ No newline at end of file
+</Project>
diff --git a/src/Ryujinx.Ava/UI/Controls/ApplicationContextMenu.axaml b/src/Ryujinx.Ava/UI/Controls/ApplicationContextMenu.axaml
index 93638fc53..d81050f83 100644
--- a/src/Ryujinx.Ava/UI/Controls/ApplicationContextMenu.axaml
+++ b/src/Ryujinx.Ava/UI/Controls/ApplicationContextMenu.axaml
@@ -82,4 +82,9 @@
             Header="{locale:Locale GameListContextMenuExtractDataLogo}"
             ToolTip.Tip="{locale:Locale GameListContextMenuExtractDataLogoToolTip}" />
     </MenuItem>
-</MenuFlyout>
\ No newline at end of file
+	<MenuItem
+		Click="CreateApplicationShortcut_Click"
+		Header="{locale:Locale GameListContextMenuCreateShortcut}"
+        IsEnabled="{Binding CreateShortcutEnabled}"
+		ToolTip.Tip="{locale:Locale GameListContextMenuCreateShortcutToolTip}" />
+</MenuFlyout>
diff --git a/src/Ryujinx.Ava/UI/Controls/ApplicationContextMenu.axaml.cs b/src/Ryujinx.Ava/UI/Controls/ApplicationContextMenu.axaml.cs
index d75572e65..0f0071065 100644
--- a/src/Ryujinx.Ava/UI/Controls/ApplicationContextMenu.axaml.cs
+++ b/src/Ryujinx.Ava/UI/Controls/ApplicationContextMenu.axaml.cs
@@ -337,6 +337,17 @@ namespace Ryujinx.Ava.UI.Controls
             }
         }
 
+        public void CreateApplicationShortcut_Click(object sender, RoutedEventArgs args)
+        {
+            var viewModel = (sender as MenuItem)?.DataContext as MainWindowViewModel;
+
+            if (viewModel?.SelectedApplication != null)
+            {
+                ApplicationData selectedApplication = viewModel.SelectedApplication;
+                ShortcutHelper.CreateAppShortcut(selectedApplication.Path, selectedApplication.TitleName, selectedApplication.TitleId, selectedApplication.Icon);
+            }
+        }
+
         public async void RunApplication_Click(object sender, RoutedEventArgs args)
         {
             var viewModel = (sender as MenuItem)?.DataContext as MainWindowViewModel;
diff --git a/src/Ryujinx.Ava/UI/ViewModels/MainWindowViewModel.cs b/src/Ryujinx.Ava/UI/ViewModels/MainWindowViewModel.cs
index 7a9e4df14..b14905204 100644
--- a/src/Ryujinx.Ava/UI/ViewModels/MainWindowViewModel.cs
+++ b/src/Ryujinx.Ava/UI/ViewModels/MainWindowViewModel.cs
@@ -356,6 +356,8 @@ namespace Ryujinx.Ava.UI.ViewModels
 
         public bool OpenBcatSaveDirectoryEnabled => !SelectedApplication.ControlHolder.ByteSpan.IsZeros() && SelectedApplication.ControlHolder.Value.BcatDeliveryCacheStorageSize > 0;
 
+        public bool CreateShortcutEnabled => !ReleaseInformation.IsFlatHubBuild();
+
         public string LoadHeading
         {
             get => _loadHeading;
@@ -1488,7 +1490,7 @@ namespace Ryujinx.Ava.UI.ViewModels
 
             Logger.RestartTime();
 
-            SelectedIcon ??= ApplicationLibrary.GetApplicationIcon(path);
+            SelectedIcon ??= ApplicationLibrary.GetApplicationIcon(path, ConfigurationState.Instance.System.Language);
 
             PrepareLoadScreen();
 
@@ -1696,7 +1698,6 @@ namespace Ryujinx.Ava.UI.ViewModels
                 }
             }
         }
-
         #endregion
     }
 }
diff --git a/src/Ryujinx.Ui.Common/App/ApplicationLibrary.cs b/src/Ryujinx.Ui.Common/App/ApplicationLibrary.cs
index 33e6c4aad..36b2b727d 100644
--- a/src/Ryujinx.Ui.Common/App/ApplicationLibrary.cs
+++ b/src/Ryujinx.Ui.Common/App/ApplicationLibrary.cs
@@ -546,7 +546,7 @@ namespace Ryujinx.Ui.App.Common
             return appMetadata;
         }
 
-        public byte[] GetApplicationIcon(string applicationPath)
+        public byte[] GetApplicationIcon(string applicationPath, Language desiredTitleLanguage)
         {
             byte[] applicationIcon = null;
 
@@ -600,7 +600,7 @@ namespace Ryujinx.Ui.App.Common
                                 {
                                     using var icon = new UniqueRef<IFile>();
 
-                                    controlFs.OpenFile(ref icon.Ref, $"/icon_{_desiredTitleLanguage}.dat".ToU8Span(), OpenMode.Read).ThrowIfFailure();
+                                    controlFs.OpenFile(ref icon.Ref, $"/icon_{desiredTitleLanguage}.dat".ToU8Span(), OpenMode.Read).ThrowIfFailure();
 
                                     using MemoryStream stream = new();
 
diff --git a/src/Ryujinx.Ui.Common/Helper/ShortcutHelper.cs b/src/Ryujinx.Ui.Common/Helper/ShortcutHelper.cs
new file mode 100644
index 000000000..dab473fa3
--- /dev/null
+++ b/src/Ryujinx.Ui.Common/Helper/ShortcutHelper.cs
@@ -0,0 +1,171 @@
+using Ryujinx.Common;
+using Ryujinx.Common.Configuration;
+using ShellLink;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.PixelFormats;
+using System;
+using System.Collections.Generic;
+using System.Drawing;
+using System.Drawing.Drawing2D;
+using System.Drawing.Imaging;
+using System.IO;
+using System.Runtime.Versioning;
+using Image = System.Drawing.Image;
+
+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);
+            using Image image = Image.FromStream(iconDataStream);
+            using Bitmap bitmap = new(128, 128);
+            using System.Drawing.Graphics graphic = System.Drawing.Graphics.FromImage(bitmap);
+            graphic.InterpolationMode = InterpolationMode.HighQualityBicubic;
+            graphic.DrawImage(image, 0, 0, 128, 128);
+            SaveBitmapAsIcon(bitmap, iconPath);
+
+            var shortcut = Shortcut.CreateShortcut(basePath, GetArgsString(basePath, 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 = SixLabors.ImageSharp.Image.Load<Rgba32>(iconData);
+            image.SaveAsPng(iconPath);
+
+            using StreamWriter outputFile = new(Path.Combine(desktopPath, cleanedAppName + ".desktop"));
+            outputFile.Write(desktopFile, cleanedAppName, iconPath, GetArgsString(basePath, applicationFilePath));
+        }
+
+        [SupportedOSPlatform("macos")]
+        private static void CreateShortcutMacos(string appFilePath, byte[] iconData, string desktopPath, string cleanedAppName)
+        {
+            string basePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, AppDomain.CurrentDomain.FriendlyName);
+            var plistFile = EmbeddedResources.ReadAllText("Ryujinx.Ui.Common/shortcut-template.plist");
+            // Macos .App folder
+            string contentFolderPath = Path.Combine(desktopPath, 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.WriteLine("#!/bin/sh");
+            scriptFile.WriteLine(GetArgsString(basePath, 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 = SixLabors.ImageSharp.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 basePath, string appFilePath)
+        {
+            // args are first defined as a list, for easier adjustments in the future
+            var argsList = new List<string>
+            {
+                basePath,
+            };
+
+            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(Bitmap 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, ImageFormat.Png);
+            // 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/Ryujinx.Ui.Common.csproj b/src/Ryujinx.Ui.Common/Ryujinx.Ui.Common.csproj
index 511a03897..3da47431f 100644
--- a/src/Ryujinx.Ui.Common/Ryujinx.Ui.Common.csproj
+++ b/src/Ryujinx.Ui.Common/Ryujinx.Ui.Common.csproj
@@ -45,8 +45,18 @@
     <EmbeddedResource Include="Resources\Logo_Twitter_Light.png" />
   </ItemGroup>
 
+  <ItemGroup Condition="'$(RuntimeIdentifier)' == 'linux-x64' OR '$(RuntimeIdentifier)' == ''">
+    <EmbeddedResource Include="..\..\distribution\linux\shortcut-template.desktop" />
+  </ItemGroup>
+
+  <ItemGroup Condition="'$(RuntimeIdentifier)' == 'osx-x64' OR '$(RuntimeIdentifier)' == 'osx-arm64' OR '$(RuntimeIdentifier)' == ''">
+    <EmbeddedResource Include="..\..\distribution\macos\shortcut-template.plist" />
+  </ItemGroup>
+
   <ItemGroup>
     <PackageReference Include="DiscordRichPresence" />
+    <PackageReference Include="securifybv.ShellLink" />
+    <PackageReference Include="System.Drawing.Common" />
   </ItemGroup>
 
   <ItemGroup>
diff --git a/src/Ryujinx/Ryujinx.csproj b/src/Ryujinx/Ryujinx.csproj
index cf4435e57..5b5ed4637 100644
--- a/src/Ryujinx/Ryujinx.csproj
+++ b/src/Ryujinx/Ryujinx.csproj
@@ -63,15 +63,15 @@
     </Content>
   </ItemGroup>
 
-   <ItemGroup Condition="'$(RuntimeIdentifier)' == 'linux-x64'">
-     <Content Include="..\..\distribution\linux\Ryujinx.sh">
+  <ItemGroup Condition="'$(RuntimeIdentifier)' == 'linux-x64'">
+    <Content Include="..\..\distribution\linux\Ryujinx.sh">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
-     </Content>
-     <Content Include="..\..\distribution\linux\mime\Ryujinx.xml">
-       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
-       <TargetPath>mime\Ryujinx.xml</TargetPath>
-     </Content>
-   </ItemGroup>
+    </Content>
+    <Content Include="..\..\distribution\linux\mime\Ryujinx.xml">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+      <TargetPath>mime\Ryujinx.xml</TargetPath>
+    </Content>
+  </ItemGroup>
 
   <!-- Due to .net core 3.1 embedded resource loading -->
   <PropertyGroup>
@@ -101,4 +101,4 @@
     <EmbeddedResource Include="Modules\Updater\UpdateDialog.glade" />
   </ItemGroup>
 
-</Project>
\ No newline at end of file
+</Project>
diff --git a/src/Ryujinx/Ui/Widgets/GameTableContextMenu.Designer.cs b/src/Ryujinx/Ui/Widgets/GameTableContextMenu.Designer.cs
index 0f7b4f22b..75b166136 100644
--- a/src/Ryujinx/Ui/Widgets/GameTableContextMenu.Designer.cs
+++ b/src/Ryujinx/Ui/Widgets/GameTableContextMenu.Designer.cs
@@ -23,6 +23,7 @@ namespace Ryujinx.Ui.Widgets
         private MenuItem _purgeShaderCacheMenuItem;
         private MenuItem _openPtcDirMenuItem;
         private MenuItem _openShaderCacheDirMenuItem;
+        private MenuItem _createShortcutMenuItem;
 
         private void InitializeComponent()
         {
@@ -187,6 +188,15 @@ namespace Ryujinx.Ui.Widgets
             };
             _openShaderCacheDirMenuItem.Activated += OpenShaderCacheDir_Clicked;
 
+            //
+            // _createShortcutMenuItem
+            //
+            _createShortcutMenuItem = new MenuItem("Create Application Shortcut")
+            {
+                TooltipText = "Create a Desktop Shortcut that launches the selected Application."
+            };
+            _createShortcutMenuItem.Activated += CreateShortcut_Clicked;
+
             ShowComponent();
         }
 
@@ -213,6 +223,7 @@ namespace Ryujinx.Ui.Widgets
             Add(new SeparatorMenuItem());
             Add(_manageCacheMenuItem);
             Add(_extractMenuItem);
+            Add(_createShortcutMenuItem);
 
             ShowAll();
         }
diff --git a/src/Ryujinx/Ui/Widgets/GameTableContextMenu.cs b/src/Ryujinx/Ui/Widgets/GameTableContextMenu.cs
index c2e0d8ebc..ea60421f8 100644
--- a/src/Ryujinx/Ui/Widgets/GameTableContextMenu.cs
+++ b/src/Ryujinx/Ui/Widgets/GameTableContextMenu.cs
@@ -10,6 +10,7 @@ using LibHac.Ns;
 using LibHac.Tools.Fs;
 using LibHac.Tools.FsSystem;
 using LibHac.Tools.FsSystem.NcaUtils;
+using Ryujinx.Common;
 using Ryujinx.Common.Configuration;
 using Ryujinx.Common.Logging;
 using Ryujinx.HLE.FileSystem;
@@ -77,6 +78,8 @@ namespace Ryujinx.Ui.Widgets
             _extractExeFsMenuItem.Sensitive = hasNca;
             _extractLogoMenuItem.Sensitive = hasNca;
 
+            _createShortcutMenuItem.Sensitive = !ReleaseInformation.IsFlatHubBuild();
+
             PopupAtPointer(null);
         }
 
@@ -629,5 +632,11 @@ namespace Ryujinx.Ui.Widgets
                 }
             }
         }
+
+        private void CreateShortcut_Clicked(object sender, EventArgs args)
+        {
+            byte[] appIcon = new ApplicationLibrary(_virtualFileSystem).GetApplicationIcon(_titleFilePath, ConfigurationState.Instance.System.Language);
+            ShortcutHelper.CreateAppShortcut(_titleFilePath, _titleName, _titleIdText, appIcon);
+        }
     }
 }