Skip to content

Commit

Permalink
Add "Create Shortcut" To app context menu (#4734)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
Co-authored-by: Jose Estrada <[email protected]>
  • Loading branch information
3 people authored Oct 20, 2023
1 parent b4bb22b commit a42f0bb
Show file tree
Hide file tree
Showing 17 changed files with 316 additions and 17 deletions.
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -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" />
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
29 changes: 29 additions & 0 deletions distribution/legal/THIRDPARTY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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>
4 changes: 2 additions & 2 deletions distribution/linux/Ryujinx.desktop
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
13 changes: 13 additions & 0 deletions distribution/linux/shortcut-template.desktop
Original file line number Diff line number Diff line change
@@ -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
35 changes: 35 additions & 0 deletions distribution/macos/shortcut-template.plist
Original file line number Diff line number Diff line change
@@ -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>
2 changes: 2 additions & 0 deletions src/Ryujinx.Ava/Assets/Locales/en_US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/Ryujinx.Ava/Ryujinx.Ava.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -145,4 +145,4 @@
<ItemGroup>
<AdditionalFiles Include="Assets\Locales\en_US.json" />
</ItemGroup>
</Project>
</Project>
7 changes: 6 additions & 1 deletion src/Ryujinx.Ava/UI/Controls/ApplicationContextMenu.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,9 @@
Header="{locale:Locale GameListContextMenuExtractDataLogo}"
ToolTip.Tip="{locale:Locale GameListContextMenuExtractDataLogoToolTip}" />
</MenuItem>
</MenuFlyout>
<MenuItem
Click="CreateApplicationShortcut_Click"
Header="{locale:Locale GameListContextMenuCreateShortcut}"
IsEnabled="{Binding CreateShortcutEnabled}"
ToolTip.Tip="{locale:Locale GameListContextMenuCreateShortcutToolTip}" />
</MenuFlyout>
11 changes: 11 additions & 0 deletions src/Ryujinx.Ava/UI/Controls/ApplicationContextMenu.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,17 @@ await ApplicationHelper.ExtractSection(
}
}

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;
Expand Down
5 changes: 3 additions & 2 deletions src/Ryujinx.Ava/UI/ViewModels/MainWindowViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,8 @@ public ApplicationData SelectedApplication

public bool OpenBcatSaveDirectoryEnabled => !SelectedApplication.ControlHolder.ByteSpan.IsZeros() && SelectedApplication.ControlHolder.Value.BcatDeliveryCacheStorageSize > 0;

public bool CreateShortcutEnabled => !ReleaseInformation.IsFlatHubBuild();

public string LoadHeading
{
get => _loadHeading;
Expand Down Expand Up @@ -1488,7 +1490,7 @@ await ContentDialogHelper.CreateInfoDialog(

Logger.RestartTime();

SelectedIcon ??= ApplicationLibrary.GetApplicationIcon(path);
SelectedIcon ??= ApplicationLibrary.GetApplicationIcon(path, ConfigurationState.Instance.System.Language);

PrepareLoadScreen();

Expand Down Expand Up @@ -1696,7 +1698,6 @@ public static async Task PerformanceCheck()
}
}
}

#endregion
}
}
4 changes: 2 additions & 2 deletions src/Ryujinx.Ui.Common/App/ApplicationLibrary.cs
Original file line number Diff line number Diff line change
Expand Up @@ -546,7 +546,7 @@ public static ApplicationMetadata LoadAndSaveMetaData(string titleId, Action<App
return appMetadata;
}

public byte[] GetApplicationIcon(string applicationPath)
public byte[] GetApplicationIcon(string applicationPath, Language desiredTitleLanguage)
{
byte[] applicationIcon = null;

Expand Down Expand Up @@ -600,7 +600,7 @@ public byte[] GetApplicationIcon(string applicationPath)
{
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();

Expand Down
171 changes: 171 additions & 0 deletions src/Ryujinx.Ui.Common/Helper/ShortcutHelper.cs
Original file line number Diff line number Diff line change
@@ -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));
}
}
}
10 changes: 10 additions & 0 deletions src/Ryujinx.Ui.Common/Ryujinx.Ui.Common.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Loading

0 comments on commit a42f0bb

Please sign in to comment.