Skip to content

Commit

Permalink
"Add to Steam" and creation of shortcuts (#378)
Browse files Browse the repository at this point in the history
#### 1. Shortcut Creation
Asks for a folder and creates a .url file linking to Collapses' URL Protocol. 
"-p" is be added if selected in the UI. 

#### 2. Addition of regions to Steam
Parses and writes a new shortcuts.vdf including a new shortcut for the region for every folder in Steam's userdata folder.
Also downloads the assets related to this shortcut.
If the shortcut already exists, it will be removed and added again while also checking if the md5 checksum for all assets matches and if any file needs to be downloaded.

### Requirements 
 - CollapseLauncher/CollapseLauncher-ReleaseRepo#12
   - Adds the Assets needed to the metadata
 - System.Text.Encoding.CodePages
   - Adds ANSI encoding
--------------------------

* Add parsing for a play option

* launching arguments for regions

* No redirection way

* Handle redirection

* Update MainPage.xaml.cs

* Change the check for starting the game

* Fix out of bounds exception

* Separate activation handling from mainPage

Separated activation event from
Could be useful for future use.
For example, future CMD arguments that need redirection but are not related to the mainPage.

* Remove unused "using" statements && partial keyword

* Fix arguments not being cleared before parsing them on activation

* Alterations based on code review + Fix parsing of arguments between quotes

* Help message changes

* Basic UI

* Add icons + new class

* Start of steam support

* Shortcut struct

* Update ShortcutCreator.cs

* Why does valve need a proprietary data format :sku

* Update ShortcutCreator.cs

* The parser is real!

* Update ShortcutCreator.cs

* It works now

* Actually fully works now

* Add to steam

* Steam id generation

* Update ShortcutCreator.cs

* Add Game Logos

* Add other ids

* Shortcut assets + fix appid generation

Images are either official art or from SteamGridDB.
For testing, should be reviewed and/or changed after.

* Add banners to the shortcut creation

* Change usage of GamePresetProperty to PresetConfigV2

* Separate code into two classes

* Desktop shortcut using protocols

* URL Protocol creation and parsing

* Fix empty protocol and broken non-public commands

* Fix creation of shortcuts

* Limit commands allowed using protocols

* Better way to save the allowed commands

* Logging protocol activation

* Reorder imports and renaming variables (Code Review)

* Streamline asset copy

* Remove duplicated icons

* Experiment downloading logos from metadata

* try at getting username from steamid3

* Improve logging

* Remove assets in favour of download via metadata repo

* Fix names

* Update SteamShortcutParser.cs

* Remove old copy file section

* Remove creation of prop file, for now

* Reuse downloader from MainPage

* Asset download + verification

* UI changes

* ui changes

* Update HomePage.xaml.cs

* Dialogs for creating .url file

* Fix dialogs + Always allow adding to Steam

* UI tweaks

* Change SHA1 in favour or md5

* Localization support

* Verify is folder exists + new failure dialog

* Fix typo in namespace name

* Change metadata storage format

CollapseLauncher-ReleaseRepo/#12

* Revert changes in the CDNList
  • Loading branch information
gablm authored Jan 27, 2024
1 parent 60fc2ab commit 3f8c4a3
Show file tree
Hide file tree
Showing 17 changed files with 800 additions and 5 deletions.
Binary file not shown.
Binary file not shown.
Binary file not shown.
3 changes: 2 additions & 1 deletion CollapseLauncher/Classes/GamePropertyVault.cs
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,8 @@ private static List<T> CopyReturn<T>(IEnumerable<T> Source)
SubChannelID = GamePreset.SubChannelID,
LauncherID = GamePreset.LauncherID,
LauncherPluginURL = GamePreset.LauncherPluginURL,
GameDataTemplates = new Dictionary<string, GameDataTemplate>(GamePreset.GameDataTemplates)
GameDataTemplates = new Dictionary<string, GameDataTemplate>(GamePreset.GameDataTemplates),
ZoneSteamAssets = new Dictionary<string, SteamGameProp>(GamePreset.ZoneSteamAssets),
};
#endregion

Expand Down
108 changes: 108 additions & 0 deletions CollapseLauncher/Classes/ShortcutCreator/ShortcutCreator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
using Hi3Helper.Preset;
using Microsoft.Win32;
using System.IO;
using System.Linq;
using static Hi3Helper.Logger;
using static Hi3Helper.Shared.Region.LauncherConfig;

namespace CollapseLauncher.ShortcutUtils
{
public static class ShortcutCreator
{
public static void CreateShortcut(string path, PresetConfigV2 preset, bool play = false)
{
string shortcutName = string.Format("{0} ({1}) - Collapse Launcher.url", preset.GameName, preset.ZoneName).Replace(":", "");
string url = string.Format("collapse://open -g \"{0}\" -r \"{1}\"", preset.GameName, preset.ZoneName);

if (play)
url += " -p";

string icon = Path.Combine(Path.GetDirectoryName(AppExecutablePath), "Assets/Images/GameIcon/" + preset.GameType switch
{
GameType.StarRail => "icon-starrail.ico",
GameType.Genshin => "icon-genshin.ico",
_ => "icon-honkai.ico",
});

string fullPath = Path.Combine(path, shortcutName);

using (StreamWriter writer = new StreamWriter(fullPath, false))
{
writer.WriteLine(string.Format("[InternetShortcut]\nURL={0}\nIconIndex=0\nIconFile={1}", url, icon));
}
}

/// Heavily based on Heroic Games Launcher "Add to Steam" feature.
///
/// Source:
/// https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher/blob/8bdee1383446d3b81e240a4300baaf337d48ec92/src/backend/shortcuts/nonesteamgame/nonesteamgame.ts

public static bool AddToSteam(PresetConfigV2 preset, bool play)
{
var paths = GetShortcutsPath();

if (paths == null || paths.Length == 0)
return false;

foreach (string path in paths)
{
SteamShortcutParser parser = new SteamShortcutParser(path);

var splitPath = path.Split('\\');
string userId = splitPath[splitPath.Length - 3];

parser.Insert(preset, play);

parser.Save();
LogWriteLine(string.Format("Added shortcut for {0} - {1} for Steam3ID {2} ", preset.GameName, preset.ZoneName, userId));
}

return true;
}

public static bool IsAddedToSteam(PresetConfigV2 preset)
{
var paths = GetShortcutsPath();

if (paths == null || paths.Length == 0)
return false;

foreach (string path in paths)
{
SteamShortcutParser parser = new SteamShortcutParser(path);

if (!parser.Contains(new SteamShortcut(preset)))
return false;
}

return true;
}

private static string[] GetShortcutsPath()
{
RegistryKey reg = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\WOW6432Node\Valve\Steam", false);

if (reg == null)
return null;

string steamPath = (string)reg.GetValue("InstallPath", "C:\\Program Files (x86)\\Steam");

string steamUserData = steamPath + @"\userdata";

if (!Directory.Exists(steamUserData))
return null;

var res = Directory.GetDirectories(steamUserData)
.Where(x =>
!(x.EndsWith("ac") || x.EndsWith("0") || x.EndsWith("anonymous"))
).ToArray();

for (int i = 0; i < res.Length; i++)
{
res[i] = Path.Combine(res[i], @"config\shortcuts.vdf");
}

return res;
}
}
}
216 changes: 216 additions & 0 deletions CollapseLauncher/Classes/ShortcutCreator/SteamShortcut.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
using Hi3Helper.Data;
using Hi3Helper.Preset;
using System;
using System.Buffers;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using static Hi3Helper.Logger;
using static Hi3Helper.Shared.Region.LauncherConfig;

namespace CollapseLauncher.ShortcutUtils
{
public sealed class SteamShortcut
{
/// Based on CorporalQuesadilla's documentation on Steam Shortcuts.
///
/// Source:
/// https://github.com/CorporalQuesadilla/Steam-Shortcut-Manager/wiki/Steam-Shortcuts-Documentation

public string preliminaryAppID = "";

#region Shortcut fields
public string entryID = "";
public string appid = "";
public string AppName = "";
public string Exe = "";
public string StartDir = "";
public string icon = "";
public string ShortcutPath = "";
public string LaunchOptions = "";
public bool IsHidden = false;
public bool AllowDesktopConfig = false;
public bool AllowOverlay = false;
public bool OpenVR = false;
public bool Devkit = false;
public string DevkitGameID = "";
public bool DevkitOverrideAppID = false;
public string LastPlayTime = "\x00\x00\x00";
public string FlatpakAppID = "";
public string tags = "";
#endregion

public SteamShortcut() { }

public SteamShortcut(PresetConfigV2 preset, bool play = false)
{
AppName = string.Format("{0} - {1}", preset.GameName, preset.ZoneName);
Exe = AppExecutablePath;
var id = BitConverter.GetBytes(GenerateAppId(Exe, AppName));
appid = SteamShortcutParser.ANSI.GetString(id, 0, id.Length);

icon = Path.Combine(Path.GetDirectoryName(AppExecutablePath), "Assets/Images/GameIcon/" + preset.GameType switch
{
GameType.StarRail => "icon-starrail.ico",
GameType.Genshin => "icon-genshin.ico",
_ => "icon-honkai.ico",
});

preliminaryAppID = GeneratePreliminaryId(Exe, AppName).ToString();

StartDir = Path.GetDirectoryName(AppExecutablePath);

LaunchOptions = string.Format("open -g \"{0}\" -r \"{1}\"", preset.GameName, preset.ZoneName);
if (play)
LaunchOptions += " -p";
}

private static char BoolToByte(bool b) => b ? '\x01' : '\x00';

public string ToEntry(int entryID = -1)
{
return '\x00' + (entryID >= 0 ? entryID.ToString() : this.entryID) + '\x00'
+ '\x02' + "appid" + '\x00' + appid
+ '\x01' + "AppName" + '\x00' + AppName + '\x00'
+ '\x01' + "Exe" + '\x00' + Exe + '\x00'
+ '\x01' + "StartDir" + '\x00' + StartDir + '\x00'
+ '\x01' + "icon" + '\x00' + icon + '\x00'
+ '\x01' + "ShortcutPath" + '\x00' + ShortcutPath + '\x00'
+ '\x01' + "LaunchOptions" + '\x00' + LaunchOptions + '\x00'
+ '\x02' + "IsHidden" + '\x00' + BoolToByte(IsHidden) + "\x00\x00\x00"
+ '\x02' + "AllowDesktopConfig" + '\x00' + BoolToByte(AllowDesktopConfig) + "\x00\x00\x00"
+ '\x02' + "AllowOverlay" + '\x00' + BoolToByte(AllowOverlay) + "\x00\x00\x00"
+ '\x02' + "OpenVR" + '\x00' + BoolToByte(OpenVR) + "\x00\x00\x00"
+ '\x02' + "Devkit" + '\x00' + BoolToByte(Devkit) + "\x00\x00\x00"
+ '\x01' + "DevkitGameID" + '\x00' + DevkitGameID + '\x00'
+ '\x02' + "DevkitOverrideAppID" + '\x00' + BoolToByte(DevkitOverrideAppID) + "\x00\x00\x00"
+ '\x02' + "LastPlayTime" + '\x00' + LastPlayTime + '\x00'
+ '\x01' + "FlatpakAppID" + '\x00' + FlatpakAppID + '\x00'
+ '\x00' + "tags" + '\x00' + tags + "\x08\x08";
}


private static uint GeneratePreliminaryId(string exe, string appname)
{
string key = exe + appname;
var crc32 = new System.IO.Hashing.Crc32();
crc32.Append(SteamShortcutParser.ANSI.GetBytes(key));
uint top = BitConverter.ToUInt32(crc32.GetCurrentHash()) | 0x80000000;
return (top << 32) | 0x02000000;
}

public static uint GenerateAppId(string exe, string appname)
{
uint appId = GeneratePreliminaryId(exe, appname);

return appId >> 32;
}

private static uint GenerateGridId(string exe, string appname)
{
uint appId = GeneratePreliminaryId(exe, appname);

return (appId >> 32) - 0x10000000;
}

public void MoveImages(string path, PresetConfigV2 preset)
{
if (preset == null) return;

path = Path.GetDirectoryName(path);
string gridPath = Path.Combine(path, "grid");
if (!Directory.Exists(gridPath))
Directory.CreateDirectory(gridPath);

Dictionary<string, SteamGameProp> assets = preset.ZoneSteamAssets;

// Game background
GetImageFromUrl(gridPath, assets["Hero"], "_hero");

// Game logo
GetImageFromUrl(gridPath, assets["Logo"], "_logo");

// Vertical banner
// Shows when viewing all games of category or in the Home page
GetImageFromUrl(gridPath, assets["Banner"], "p");

// Horizontal banner
// Appears in Big Picture mode when the game is the most recently played
GetImageFromUrl(gridPath, assets["Preview"], "");
}

private static string MD5Hash(string path)
{
if (!File.Exists(path))
return "";
FileStream stream = File.OpenRead(path);
var hash = System.Security.Cryptography.MD5.Create().ComputeHash(stream);
stream.Close();
return BitConverter.ToString(hash).Replace("-", string.Empty).ToLower();
}

private async void GetImageFromUrl(string gridPath, SteamGameProp asset, string steamSuffix)
{
string steamPath = Path.Combine(gridPath, preliminaryAppID + steamSuffix + ".png");

string hash = MD5Hash(steamPath);

if (hash.ToLower() == asset.MD5) return;

for (int i = 0; i < 3; i++)
{
FileInfo info = new FileInfo(steamPath);
await DownloadImage(info, asset.URL, new CancellationToken());

hash = MD5Hash(steamPath);

if (hash.ToLower() == asset.MD5) return;

File.Delete(steamPath);

LogWriteLine(string.Format("Invalid checksum for file {0}! {1} does not match {2}.", steamPath, hash, asset.MD5), Hi3Helper.LogType.Error);
}

LogWriteLine("After 3 tries, " + asset.URL + " could not be downloaded successfully.", Hi3Helper.LogType.Error);
return;
}

private static async ValueTask DownloadImage(FileInfo fileInfo, string url, CancellationToken token)
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(4 << 10);
try
{
// Try get the remote stream and download the file
using Stream netStream = await FallbackCDNUtil.GetHttpStreamFromResponse(url, token);
using Stream outStream = fileInfo.Open(new FileStreamOptions()
{
Access = FileAccess.Write,
Mode = FileMode.Create,
Share = FileShare.ReadWrite,
Options = FileOptions.Asynchronous
});

// Get the file length
long fileLength = netStream.Length;

// Copy (and download) the remote streams to local
LogWriteLine($"Start downloading resource from: {url}", Hi3Helper.LogType.Default, true);
int read = 0;
while ((read = await netStream.ReadAsync(buffer, token)) > 0)
await outStream.WriteAsync(buffer, 0, read, token);

LogWriteLine($"Downloading resource from: {url} has been completed and stored locally into:"
+ $"\"{fileInfo.FullName}\" with size: {ConverterTool.SummarizeSizeSimple(fileLength)} ({fileLength} bytes)", Hi3Helper.LogType.Default, true);
}
catch (Exception ex)
{
ErrorSender.SendException(ex, ErrorType.Connection);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
}
}
Loading

0 comments on commit 3f8c4a3

Please sign in to comment.