<reponame>NotChristianGarcia/playnite-non-steam-shortcuts<filename>nonsteam.py
# Non-Steam Shortcuts

# Default settings for entries in shortcuts.vdf
# See https://github.com/CorporalQuesadilla/Steam-Shortcut-Manager/wiki/Steam-Shortcuts-Documentation
# Be very careful if you change these settings, you can easily break this script here.
SHORTCUT_DEFAULTS = {
    "allowoverlay": 1,
    "allowdesktopconfig": 1,
    "shortcutpath": "",
    "ishidden": 0,
    "openvr": 0,
    "tags": {},
    "lastplaytime": 0,
    "devkit": 0,
    "devkitgameid": "",
}


# Do not edit anything below this line


import struct
import shutil
from collections import Counter
import traceback
from os.path import isdir, isfile, join
import os
import re

import clr
import System
import System.Guid as Guid
from System.Collections.ObjectModel import ObservableCollection
from System.IO import FileInfo, Path
from System import Array, Object
from System.Windows import MessageBoxButton, MessageBoxImage, MessageBoxResult
from Playnite.SDK.Plugins import ScriptGameMenuItem
from Playnite.SDK import ImageFileOption, GenericItemOption
from Playnite.SDK.Models import GameAction
from shutil import copyfile

clr.AddReference("System.Core")
clr.ImportExtensions(System.Linq)

STEAM_PLUGIN_GUID = Guid.Parse("CB91DFC9-B977-43BF-8E70-55F46E410FAB")

def get_gamemenu_items(menu_args):
    menu_item = ScriptGameMenuItem()
    menu_item.Description = "Create non-Steam shortcuts"
    menu_item.FunctionName = "non_steam_shortcuts"
    yield menu_item

def validate_steam_userdata_dir(folder):
    return folder and isdir(join(folder, "config"))

def get_steam_userdata_dir():
    config_path = join(CurrentExtensionDataPath, "steam_userdata_path")
    folder = None

    if isfile(config_path):
        with open(config_path, "r") as f:
            folder = f.read()

    if validate_steam_userdata_dir(folder):
        return folder
    else:
        PlayniteApi.Dialogs.ShowMessage(
            "Please configure this extension by selecting your Steam profile's userdata folder. "
            r"The userdata folder is typically at C:\Program Files (x86)\Steam\userdata\12345678 if Steam is installed on the C:\ drive. ",
            "Extension not configured",
        )
        folder = PlayniteApi.Dialogs.SelectFolder()
        if validate_steam_userdata_dir(folder):
            with open(config_path, "w") as f:
                f.write(folder)
            return folder

    PlayniteApi.Dialogs.ShowErrorMessage(
        "Invalid userdata folder selected.",
        "Extension not configured",
    )
    return None


# Parse shortcuts.vdf
# Steam matches keys case insensitively, so lowercase all keys to be case insensitive


def parse_object(stream):
    k = parse_string(stream).lower()
    # Read key value pairs until a \x08 byte is reached
    v = dict(iter(lambda: parse(stream), None))
    return k, v


def parse_int_value(stream):
    k = parse_string(stream).lower()
    (v,) = struct.unpack("i", stream.read(4))
    return k, v


def parse_string_value(stream):
    k = parse_string(stream).lower()
    v = parse_string(stream)
    return k, v


def parse_string(stream):
    # Strings are null terminated
    return "".join(iter(lambda: stream.read(1), "\x00")).decode("utf-8")


parse_types = {
    "\x00": parse_object,
    "\x01": parse_string_value,
    "\x02": parse_int_value,
    "\x08": lambda stream: None,
}


def parse(stream):
    # Read a per type one byte header, then parse using the correct type
    data_type = stream.read(1)
    return parse_types[data_type](stream)


def parse_shortcuts(stream):
    shortcuts = parse(stream)[1].values()
    if Counter(s["appname"] for s in shortcuts).most_common()[0][1] > 1:
        raise "Duplicate appnames in Steam shortcuts"
    return {s["appname"]: s for s in shortcuts}


# Dump shortcuts.vdf


def dump_object_value(stream, k, values):
    stream.write("\x00")
    stream.write(k.encode("utf8"))
    stream.write("\x00")
    for k, v in values.iteritems():
        if isinstance(v, dict):
            dump_object_value(stream, k, v)
        elif isinstance(v, str):
            dump_string_value(stream, k, v)
        elif isinstance(v, int):
            dump_int_value(stream, k, v)
        elif isinstance(v, bool):
            dump_int_value(stream, k, int(v))
        else:
            raise TypeError("Unrecognized type:", type(v))
    stream.write("\x08")


def dump_string_value(stream, k, v):
    stream.write("\x01")
    stream.write(k.encode("utf-8"))
    stream.write("\x00")
    stream.write(v.encode("utf-8"))
    stream.write("\x00")


def dump_int_value(stream, k, v):
    stream.write("\x02")
    stream.write(k.encode("utf-8"))
    stream.write("\x00")
    stream.write(struct.pack("i", v))


def dump_shortcuts(stream, shortcuts):
    # Any string can be used as the key in shortcuts.vdf
    # Originally it is a string of the index, but we use the AppName instead
    dump_object_value(stream, "shortcuts", shortcuts)
    stream.write("\x08")


def steam_URL(shortcut):
    # Comments by <NAME>:
    """
    Calculates the filename for a given shortcut. This filename is a 64bit
    integer, where the first 32bits are a CRC32 based off of the name and
    target (with the added condition that the first bit is always high), and
    the last 32bits are 0x02000000.
    """
    # This will seem really strange (where I got all of these values), but I
    # got the xor_in and xor_out from disassembling the steamui library for
    # OSX. The reflect_in, reflect_out, and poly I figured out via trial and
    # error.
    algorithm = Crc(
        width=32,
        poly=0x04C11DB7,
        reflect_in=True,
        xor_in=0xFFFFFFFF,
        reflect_out=True,
        xor_out=0xFFFFFFFF,
    )
    input_string = shortcut["exe"].encode("utf-8") + shortcut["appname"].encode("utf-8")
    top_32 = algorithm.bit_by_bit(input_string) | 0x80000000
    full_64 = (top_32 << 32) | 0x02000000
    return "steam://rungameid/" + str(full_64)

def legacySteamID(shortcut):
    # SteamGrid Images uses the Legacy 32 Steam ID. We get it here
    # Comments by <NAME>:
    """
    Calculates the filename for a given shortcut. This filename is a 64bit
    integer, where the first 32bits are a CRC32 based off of the name and
    target (with the added condition that the first bit is always high), and
    the last 32bits are 0x02000000.
    """
    # This will seem really strange (where I got all of these values), but I
    # got the xor_in and xor_out from disassembling the steamui library for
    # OSX. The reflect_in, reflect_out, and poly I figured out via trial and
    # error.
    algorithm = Crc(
        width=32,
        poly=0x04C11DB7,
        reflect_in=True,
        xor_in=0xFFFFFFFF,
        reflect_out=True,
        xor_out=0xFFFFFFFF,
    )
    input_string = shortcut["exe"].encode("utf-8") + shortcut["appname"].encode("utf-8")
    top_32 = algorithm.bit_by_bit(input_string) | 0x80000000
    return str(top_32 )

def find_play_action(game):
    """
    Check if there is an existing OtherAction titled "Launch without Steam".

    This will occur if the script is rerun on a game. We want to find the
    original PlayAction to update the non-Steam shortcut.
    """

    if game.OtherActions:
        for play_action in game.OtherActions:
            if play_action.Name == "Launch without Steam":
                return play_action
    return game.PlayAction


def emulator_expand_variables(profile, game):
    # HACK! Import EmulatorProfileExtensions with reflection
    EmulatorProfileExtensions = PlayniteApi.GetType().Assembly.GetType(
        "Playnite.EmulatorProfileExtensions"
    )
    method = EmulatorProfileExtensions.GetMethod("ExpandVariables")
    return method.Invoke(None, Array[Object]((profile, game,)))


def open_playnite_log():
    path = join(PlayniteApi.Paths.ConfigurationPath, "playnite.log")
    os.startfile(path)


def non_steam_shortcuts(menu_args):
    games_updated = 0
    games_new = 0
    games_skipped_no_action = []
    games_skipped_steam_native = []
    games_skipped_bad_emulator = []
    games_url = []

    steam_userdata = get_steam_userdata_dir()
    if not steam_userdata:
        return

    shortcuts_vdf = join(steam_userdata, "config", "shortcuts.vdf")
    shortcuts_images = join(steam_userdata, "config", "grid")

    if isfile(shortcuts_vdf):
        try:
            shutil.copyfile(shortcuts_vdf, shortcuts_vdf + ".bak")
        except Exception as e:
            PlayniteApi.Dialogs.ShowErrorMessage(
                traceback.format_exc(), "Error backing up shortcuts.vdf"
            )
            return

        try:
            with open(shortcuts_vdf, "rb") as f:
                steam_shortcuts = parse_shortcuts(f)
        except Exception as e:
            PlayniteApi.Dialogs.ShowErrorMessage(
                traceback.format_exc(), "Error loading shortcuts.vdf"
            )
            return
    else:
        steam_shortcuts = {}

    for game in menu_args.Games:
        play_action = find_play_action(game)

        steam_url = None
        for link in game.Links:
            if link.Name.lower() == 'steam':
                steam_url = link.Url

        # https://store.steampowered.com/app/3232323/game_name_here --> 3232323
        if steam_url:
            steam_shortcut_appname_regex  = re.findall(r"\/(\d+)", steam_url)
            steam_shortcut_appname = steam_shortcut_appname_regex[0] if steam_shortcut_appname_regex else None                
            if steam_shortcut_appname:
                result = PlayniteApi.Dialogs.SelectString("We found a steam appid of {} in steam url, keep it or replace in the input box.".format(steam_shortcut_appname),
                                                          "Game name dialog",
                                                          steam_shortcut_appname)
                if not result.Result:
                    steam_shortcut_appname = game.Name
                    result = PlayniteApi.Dialogs.SelectString("Using game name, {}, as steam appid, keep it or replace in the input box.".format(steam_shortcut_appname),
                                                              "Game name dialog",
                                                              steam_shortcut_appname)
            else:
                steam_shortcut_appname = game.Name
                result = PlayniteApi.Dialogs.SelectString("Did not find steam appid in steam url, using game name, {}, as shortcut name, keep it or replace in the input box.".format(steam_shortcut_appname),
                                                          "Game name dialog",
                                                          steam_shortcut_appname)
        else:
            steam_shortcut_appname = game.Name
            result = PlayniteApi.Dialogs.SelectString("Did not find steam url, using game name, {}, as shortcut name, keep it or replace in the input box.".format(steam_shortcut_appname),
                                                      "Game name dialog",
                                                      steam_shortcut_appname)
        if not result.Result:
            PlayniteApi.Dialogs.ShowMessage("Got cancel response. Cancelling script for this game.")
            continue
        steam_shortcut_appname = result.SelectedString

        # If a game somehow has no PlayAction, skip it
        if not play_action:
            games_skipped_no_action.append(game.Name)
            __logger.Error("Non-Steam: Game has no PlayAction: {}".format(game.Name))
            continue

        # Skip the game if it is handled by the Steam plugin
        if game.PluginId == STEAM_PLUGIN_GUID:
            __logger.Warn(
                "Non-Steam: Game is already a Steam game: {}".format(game.Name)
            )
            games_skipped_steam_native.append(game.Name)
            continue

        # Only edit actions once with script.
        if play_action == game.PlayAction:
            play_action_already_created_by_script = False
        else:
            play_action_already_created_by_script = True
        # If a game has a URL PlayAction and it hasn't already been ran through this script, ask to change to exe, if not, use url anyway, but log it
        if play_action.Type == GameActionType.URL and not play_action_already_created_by_script:
            message = "Game, {}, has a URL as it's PlayAction, do you want to change that to an executable? Steam cannot open the overlay when games are started by URL.".format(game.Name)
            change_url = PlayniteApi.Dialogs.ShowMessage(message,
                                                         "Is this the game's executable?",
                                                         MessageBoxButton.YesNo)
            if change_url == MessageBoxResult.Yes:
                # Getting exe from listdir and getting the largest file with .exe (should work most of the time I think)
                game_dir = game.InstallDirectory
                largest_exe = None
                largest_exe_size = 0
                for exe in os.listdir(game_dir):
                    if exe.endswith(".exe"):
                        full_exe = join(game_dir, exe)
                        size = os.path.getsize(full_exe)
                        if size > largest_exe_size:
                            largest_exe = full_exe
                            largest_exe_size = size
                if largest_exe:
                    message = "This is the largest executable (.exe) in this game's directory, would you like to continue with it?\n\n{}".format(largest_exe)
                    good_exe = PlayniteApi.Dialogs.ShowMessage(message,
                                                               "Do you want to use this .exe?",
                                                               MessageBoxButton.YesNo)
                    if good_exe == MessageBoxResult.Yes:
                        # Move old PlayAction to OtherActions and create new url PlayAction
                        old_action = game.PlayAction
                        new_url_action = GameAction(Name="URL Action Created With Non-Steam Script",
                                                    Type=GameActionType.File,
                                                    Path=largest_exe,
                                                    IsHandledByPlugin=False)
                        game.PlayAction = new_url_action
                        if not game.OtherActions:
                            game.OtherActions = ObservableCollection[GameAction]()
                        # Move URL PlayAction created by script to Other Actions iff the action is new.
                        if not old_action == new_url_action:
                            old_action.Name = "Original Action Before Changing To Executable PlayAction"
                            game.OtherActions.Add(old_action)
                    else:
                        PlayniteApi.Dialogs.ShowMessage("No file selected, will continue with PlayAction as a URL.")
                else:
                    message = "Didn't find any executables (.exe) in this game's directory. Open a file selection window? (We'll continue otherwise)"
                    open_file_selection = PlayniteApi.Dialogs.ShowMessage(message,
                                                                          "No executable found",
                                                                           MessageBoxButton.YesNo)
                    if open_file_selection == MessageBoxResult.Yes:
                        file_selected = PlayniteApi.Dialogs.SelectFile("")
                        if file_selected:
                            # Move old PlayAction to OtherActions and create new url PlayAction
                            old_action = game.PlayAction
                            new_url_action = GameAction(Name="URL Action Created With Non-Steam Script",
                                                        Type=GameActionType.File,
                                                        Path=file_selected,
                                                        IsHandledByPlugin=False)
                            game.PlayAction = new_url_action
                            if not game.OtherActions:
                                game.OtherActions = ObservableCollection[GameAction]()
                            # Move URL PlayAction created by script to Other Actions iff the action is new.
                            if not old_action == new_url_action:
                                old_action.Name = "Original Action Before Changing To Executable PlayAction"
                                game.OtherActions.Add(old_action)
                        else:
                            PlayniteApi.Dialogs.ShowMessage("No file selected, will continue with PlayAction as a URL.")
                    else:
                        PlayniteApi.Dialogs.ShowMessage("No file selected, will continue with PlayAction as a URL.")
                        

        play_action = game.PlayAction
        # Check after changes if PlayAction is still a URL
        play_action_expanded = PlayniteApi.ExpandGameVariables(game, play_action)
        if play_action.Type == GameActionType.URL and not "steam" in play_action_expanded.Path:
            __logger.Warn(
                "Non-Steam: Game has a URL as PlayAction: {}".format(game.Name)
            )
            games_url.append(game.Name)

        # Create/Update Non-Steam shortcut
        play_action_expanded = PlayniteApi.ExpandGameVariables(game, play_action)
        if play_action_expanded.Type == GameActionType.Emulator:
            emulator = PlayniteApi.Database.Emulators.Get(play_action.EmulatorId)
            if emulator.Profiles:
                profile = emulator.Profiles.FirstOrDefault(
                    lambda a: a.Id == play_action.EmulatorProfileId
                )
            else:
                profile = None
            if not profile:
                games_skipped_bad_emulator.append(game.Name)
                continue
            profile_expanded = emulator_expand_variables(profile, game)
            start_dir = profile_expanded.WorkingDirectory
            exe = profile_expanded.Executable
            arguments = profile_expanded.Arguments or ""
            if play_action_expanded.AdditionalArguments:
                arguments += " " + play_action_expanded.AdditionalArguments
            if play_action_expanded.OverrideDefaultArgs:
                arguments = play_action_expanded.Arguments or ""
        elif play_action_expanded.Type == GameActionType.File:
            start_dir = play_action_expanded.WorkingDir
            exe = play_action_expanded.Path
            arguments = play_action_expanded.Arguments or ""
        elif play_action_expanded.Type == GameActionType.URL:
            exe = play_action_expanded.Path
            start_dir = ""
            arguments = ""
        if not play_action_expanded.Type == GameActionType.URL:
            if not start_dir:
                start_dir = FileInfo(exe).Directory.FullName
            exe = Path.Combine(start_dir, exe)
        if game.Icon:
            icon = PlayniteApi.Database.GetFullFilePath(game.Icon)
        else:
            icon = ""
        # this is the shortcut that will be past to steam, appname must equal game SteamID for steam controller to use configs.
        shortcut = {
            "icon": icon,
            "exe": '"{}"'.format(exe),
            "startdir": '"{}"'.format(start_dir),
            "appname": steam_shortcut_appname,
            "launchoptions": arguments,
        }
        if steam_shortcut_appname in steam_shortcuts:
            games_updated += 1
            steam_shortcuts[steam_shortcut_appname].update(shortcut)
            shortcut = steam_shortcuts[steam_shortcut_appname]
        else:
            games_new += 1
            shortcut.update(SHORTCUT_DEFAULTS)
            steam_shortcuts[steam_shortcut_appname] = shortcut

        # Update Playnite actions
        # Only run once, don't create duplicate OtherActions
        if not play_action_already_created_by_script:
            old_action = game.PlayAction
            steam_action = GameAction(
                Name="Non-Steam Steam Shortcut",
                Type=GameActionType.URL,
                Path=steam_URL(shortcut),
                IsHandledByPlugin=False,
            )
            game.PlayAction = steam_action
            if not game.OtherActions:
                game.OtherActions = ObservableCollection[GameAction]()
            old_action.Name = "Launch without Steam"
            game.OtherActions.Add(old_action)
        else:
            # play_action is already an OtherAction
            # Just make sure the URL is up to date on the main PlayAction
            game.PlayAction.Path = steam_URL(shortcut)

        # Copy Playnite Cover to Steam grids folder as portrait photo w/dialog box for selection
        if game.CoverImage:
          image_list = System.Collections.Generic.List[ImageFileOption]()
          image_list.Add(ImageFileOption(PlayniteApi.Database.GetFullFilePath(game.CoverImage)))
          image = PlayniteApi.Dialogs.ChooseImageFile(image_list, "Choose Steam Grid Background photo. Select cancel for none.", 300, 400)
          # If an image is selected, we use it, otherwise we just ignore the copyfile.
          if image:
            #PlayniteApi.Dialogs.ShowMessage(image.Path) # debug
            copyfile(str(image.Path), shortcuts_images +"\\" + legacySteamID(shortcut) + "p.png")
          else:
            PlayniteApi.Dialogs.ShowMessage("No image selected, will not use cover image then.")
        else:
          PlayniteApi.Dialogs.ShowMessage("This game does not have a cover image, won't be setting one.")


        # Copy Playnite Background to Steam grids folder as hero and default photo w/dialog box for selection
        if game.BackgroundImage:
          image_list = System.Collections.Generic.List[ImageFileOption]()
          image_list.Add(ImageFileOption(PlayniteApi.Database.GetFullFilePath(game.BackgroundImage)))
          image = PlayniteApi.Dialogs.ChooseImageFile(image_list, "Choose Steam Grid Cover photo. Select cancel for none.", 300, 400)
          # If an image is selected, we use it, otherwise we just ignore the copyfile.
          if image:
            #PlayniteApi.Dialogs.ShowMessage(image.Path) # debug
            copyfile(str(image.Path), shortcuts_images +"\\" + legacySteamID(shortcut) + "_hero.png")
            copyfile(str(image.Path), shortcuts_images +"\\" + legacySteamID(shortcut) + ".png")
          else:
            PlayniteApi.Dialogs.ShowMessage("No image selected, will not use cover image then.")
        else:
          PlayniteApi.Dialogs.ShowMessage("This game does not have a background image, won't be setting one.")

        #PlayniteApi.Dialogs.ShowMessage(broken-for-debugging-without-completing-script)


    # Save updated shortcuts.vdf
    try:
        with open(shortcuts_vdf, "wb") as f:
            dump_shortcuts(f, steam_shortcuts)
    except Exception as e:
        PlayniteApi.Dialogs.ShowErrorMessage(
            traceback.format_exc(), "Error saving shortcuts.vdf"
        )
        if isfile(shortcuts_vdf + ".bak"):
            try:
                shutil.copyfile(shortcuts_vdf + ".bak", shortcuts_vdf)
                PlayniteApi.Dialogs.ShowMessage(
                    "Successfully restored shortcuts.vdf backup"
                )
            except Exception as e:
                PlayniteApi.Dialogs.ShowErrorMessage(
                    traceback.format_exc(), "Error restoring shortcuts.vdf backup"
                )
        else:
            os.remove(shortcuts_vdf)
        return

    # Truncate long lists of games
    if len(games_skipped_steam_native) > 10:
        games_skipped_steam_native = games_skipped_steam_native[:10] + ["[...]"]
    if len(games_skipped_no_action) > 10:
        games_skipped_no_action = games_skipped_no_action[:10] + ["[...]"]
    if len(games_skipped_bad_emulator) > 10:
        games_skipped_bad_emulator = games_skipped_bad_emulator[:10] + ["[...]"]
    if len(games_url) > 10:
        games_url = games_url[:10] + ["[...]"]

    errors = False
    message = "Please relaunch Steam to update non-Steam shortcuts!\n\n"
    message += "Updated {} existing non-Steam shortcuts\n".format(games_updated)
    message += "Created {} new non-Steam shortcuts".format(games_new)
    if games_skipped_steam_native:
        message += "\n\nSkipped {} native Steam game(s):\n".format(
            len(games_skipped_steam_native)
        )
        message += "\n".join(games_skipped_steam_native)
        errors = True
    if games_skipped_no_action:                                                               
        message += "\n\nSkipped {} game(s) without any PlayAction set (not installed?):\n".format(
            len(games_skipped_no_action)
        )
        message += "\n".join(games_skipped_no_action)
        errors = True
    if games_skipped_bad_emulator:
        message += "\n\nSkipped {} emulated game(s) with bad emulator profiles:\n".format(
            len(games_skipped_bad_emulator)
        )
        message += "\n".join(games_skipped_bad_emulator)
        errors = True
    if games_url:
        message += "\n\nWarning: Some games had URL launch actions. (Typically managed by a library plugin.) "
        message += (
            "You may wish to update their actions and recreate non-Steam shortcuts. "
        )
        message += "Steam will still launch these games, but the Steam overlay will not function."
        message += "\n\nThe following {} game(s) had URL launch actions:\n".format(
            len(games_url)
        )
        message += "\n".join(games_url)
        errors = True
    if errors:
        message += "\n\nOpen playnite.log for full list of errors?"
        show_log = PlayniteApi.Dialogs.ShowMessage(
            message,
            "Updated Non-Steam Shortcuts",
            MessageBoxButton.YesNo,
            MessageBoxImage.Error,
        )
        if show_log == MessageBoxResult.Yes:
            open_playnite_log()
    else:
        PlayniteApi.Dialogs.ShowMessage(message, "Updated Non-Steam Shortcuts")


###############################################################################

### crc_algorithms.py (Playnite won't import python files)

#  pycrc -- parameterisable CRC calculation utility and C source code generator
#
#  Copyright (c) 2006-2013  <NAME>  <<EMAIL>>
#
#  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.


"""
CRC algorithms implemented in Python.
If you want to study the Python implementation of the CRC routines, then this
is a good place to start from.

The algorithms Bit by Bit, Bit by Bit Fast and Table-Driven are implemented.

This module can also be used as a library from within Python.

Examples
========

This is an example use of the different algorithms:

>>> from crc_algorithms import Crc
>>>
>>> crc = Crc(width = 16, poly = 0x8005,
...           reflect_in = True, xor_in = 0x0000,
...           reflect_out = True, xor_out = 0x0000)
>>> print("0x%x" % crc.bit_by_bit("123456789"))
>>> print("0x%x" % crc.bit_by_bit_fast("123456789"))
>>> print("0x%x" % crc.table_driven("123456789"))
"""

# Class Crc
###############################################################################
class Crc(object):
    """
    A base class for CRC routines.
    """

    # Class constructor
    ###############################################################################
    def __init__(
        self,
        width,
        poly,
        reflect_in,
        xor_in,
        reflect_out,
        xor_out,
        table_idx_width=None,
    ):
        """The Crc constructor.

        The parameters are as follows:
            width
            poly
            reflect_in
            xor_in
            reflect_out
            xor_out
        """
        self.Width = width
        self.Poly = poly
        self.ReflectIn = reflect_in
        self.XorIn = xor_in
        self.ReflectOut = reflect_out
        self.XorOut = xor_out
        self.TableIdxWidth = table_idx_width

        self.MSB_Mask = 0x1 << (self.Width - 1)
        self.Mask = ((self.MSB_Mask - 1) << 1) | 1
        if self.TableIdxWidth != None:
            self.TableWidth = 1 << self.TableIdxWidth
        else:
            self.TableIdxWidth = 8
            self.TableWidth = 1 << self.TableIdxWidth

        self.DirectInit = self.XorIn
        self.NonDirectInit = self.__get_nondirect_init(self.XorIn)
        if self.Width < 8:
            self.CrcShift = 8 - self.Width
        else:
            self.CrcShift = 0

    # function __get_nondirect_init
    ###############################################################################
    def __get_nondirect_init(self, init):
        """
        return the non-direct init if the direct algorithm has been selected.
        """
        crc = init
        for i in range(self.Width):
            bit = crc & 0x01
            if bit:
                crc ^= self.Poly
            crc >>= 1
            if bit:
                crc |= self.MSB_Mask
        return crc & self.Mask

    # function reflect
    ###############################################################################
    def reflect(self, data, width):
        """
        reflect a data word, i.e. reverts the bit order.
        """
        x = data & 0x01
        for i in range(width - 1):
            data >>= 1
            x = (x << 1) | (data & 0x01)
        return x

    # function bit_by_bit
    ###############################################################################
    def bit_by_bit(self, in_str):
        """
        Classic simple and slow CRC implementation.  This function iterates bit
        by bit over the augmented input message and returns the calculated CRC
        value at the end.
        """
        register = self.NonDirectInit
        for c in in_str:
            octet = ord(c)
            if self.ReflectIn:
                octet = self.reflect(octet, 8)
            for i in range(8):
                topbit = register & self.MSB_Mask
                register = ((register << 1) & self.Mask) | ((octet >> (7 - i)) & 0x01)
                if topbit:
                    register ^= self.Poly

        for i in range(self.Width):
            topbit = register & self.MSB_Mask
            register = (register << 1) & self.Mask
            if topbit:
                register ^= self.Poly

        if self.ReflectOut:
            register = self.reflect(register, self.Width)
        return register ^ self.XorOut

    # function bit_by_bit_fast
    ###############################################################################
    def bit_by_bit_fast(self, in_str):
        """
        This is a slightly modified version of the bit-by-bit algorithm: it
        does not need to loop over the augmented bits, i.e. the Width 0-bits
        wich are appended to the input message in the bit-by-bit algorithm.
        """
        register = self.DirectInit
        for c in in_str:
            octet = ord(c)
            if self.ReflectIn:
                octet = self.reflect(octet, 8)
            for i in range(8):
                topbit = register & self.MSB_Mask
                if octet & (0x80 >> i):
                    topbit ^= self.MSB_Mask
                register <<= 1
                if topbit:
                    register ^= self.Poly
            register &= self.Mask
        if self.ReflectOut:
            register = self.reflect(register, self.Width)
        return register ^ self.XorOut

    # function gen_table
    ###############################################################################
    def gen_table(self):
        """
        This function generates the CRC table used for the table_driven CRC
        algorithm.  The Python version cannot handle tables of an index width
        other than 8.  See the generated C code for tables with different sizes
        instead.
        """
        table_length = 1 << self.TableIdxWidth
        tbl = [0] * table_length
        for i in range(table_length):
            register = i
            if self.ReflectIn:
                register = self.reflect(register, self.TableIdxWidth)
            register = register << (self.Width - self.TableIdxWidth + self.CrcShift)
            for j in range(self.TableIdxWidth):
                if register & (self.MSB_Mask << self.CrcShift) != 0:
                    register = (register << 1) ^ (self.Poly << self.CrcShift)
                else:
                    register = register << 1
            if self.ReflectIn:
                register = (
                    self.reflect(register >> self.CrcShift, self.Width) << self.CrcShift
                )
            tbl[i] = register & (self.Mask << self.CrcShift)
        return tbl

    # function table_driven
    ###############################################################################
    def table_driven(self, in_str):
        """
        The Standard table_driven CRC algorithm.
        """
        tbl = self.gen_table()

        register = self.DirectInit << self.CrcShift
        if not self.ReflectIn:
            for c in in_str:
                tblidx = (
                    (register >> (self.Width - self.TableIdxWidth + self.CrcShift))
                    ^ ord(c)
                ) & 0xFF
                register = (
                    (register << (self.TableIdxWidth - self.CrcShift)) ^ tbl[tblidx]
                ) & (self.Mask << self.CrcShift)
            register = register >> self.CrcShift
        else:
            register = (
                self.reflect(register, self.Width + self.CrcShift) << self.CrcShift
            )
            for c in in_str:
                tblidx = ((register >> self.CrcShift) ^ ord(c)) & 0xFF
                register = ((register >> self.TableIdxWidth) ^ tbl[tblidx]) & (
                    self.Mask << self.CrcShift
                )
            register = self.reflect(register, self.Width + self.CrcShift) & self.Mask

        if self.ReflectOut:
            register = self.reflect(register, self.Width)
        return register ^ self.XorOut


#### End crc_algorithms

# Non-Steam Shortcuts

# Default settings for entries in shortcuts.vdf
# See https://github.com/CorporalQuesadilla/Steam-Shortcut-Manager/wiki/Steam-Shortcuts-Documentation
# Be very careful if you change these settings, you can easily break this script here.
SHORTCUT_DEFAULTS = {
    "allowoverlay": 1,
    "allowdesktopconfig": 1,
    "shortcutpath": "",
    "ishidden": 0,
    "openvr": 0,
    "tags": {},
    "lastplaytime": 0,
    "devkit": 0,
    "devkitgameid": "",
}


# Do not edit anything below this line


import struct
import shutil
from collections import Counter
import traceback
from os.path import isdir, isfile, join
import os
import re

import clr
import System
import System.Guid as Guid
from System.Collections.ObjectModel import ObservableCollection
from System.IO import FileInfo, Path
from System import Array, Object
from System.Windows import MessageBoxButton, MessageBoxImage, MessageBoxResult
from Playnite.SDK.Plugins import ScriptGameMenuItem
from Playnite.SDK import ImageFileOption, GenericItemOption
from Playnite.SDK.Models import GameAction
from shutil import copyfile

clr.AddReference("System.Core")
clr.ImportExtensions(System.Linq)

STEAM_PLUGIN_GUID = Guid.Parse("CB91DFC9-B977-43BF-8E70-55F46E410FAB")

def get_gamemenu_items(menu_args):
    menu_item = ScriptGameMenuItem()
    menu_item.Description = "Create non-Steam shortcuts"
    menu_item.FunctionName = "non_steam_shortcuts"
    yield menu_item

def validate_steam_userdata_dir(folder):
    return folder and isdir(join(folder, "config"))

def get_steam_userdata_dir():
    config_path = join(CurrentExtensionDataPath, "steam_userdata_path")
    folder = None

    if isfile(config_path):
        with open(config_path, "r") as f:
            folder = f.read()

    if validate_steam_userdata_dir(folder):
        return folder
    else:
        PlayniteApi.Dialogs.ShowMessage(
            "Please configure this extension by selecting your Steam profile's userdata folder. "
            r"The userdata folder is typically at C:\Program Files (x86)\Steam\userdata\12345678 if Steam is installed on the C:\ drive. ",
            "Extension not configured",
        )
        folder = PlayniteApi.Dialogs.SelectFolder()
        if validate_steam_userdata_dir(folder):
            with open(config_path, "w") as f:
                f.write(folder)
            return folder

    PlayniteApi.Dialogs.ShowErrorMessage(
        "Invalid userdata folder selected.",
        "Extension not configured",
    )
    return None


# Parse shortcuts.vdf
# Steam matches keys case insensitively, so lowercase all keys to be case insensitive


def parse_object(stream):
    k = parse_string(stream).lower()
    # Read key value pairs until a \x08 byte is reached
    v = dict(iter(lambda: parse(stream), None))
    return k, v


def parse_int_value(stream):
    k = parse_string(stream).lower()
    (v,) = struct.unpack("i", stream.read(4))
    return k, v


def parse_string_value(stream):
    k = parse_string(stream).lower()
    v = parse_string(stream)
    return k, v


def parse_string(stream):
    # Strings are null terminated
    return "".join(iter(lambda: stream.read(1), "\x00")).decode("utf-8")


parse_types = {
    "\x00": parse_object,
    "\x01": parse_string_value,
    "\x02": parse_int_value,
    "\x08": lambda stream: None,
}


def parse(stream):
    # Read a per type one byte header, then parse using the correct type
    data_type = stream.read(1)
    return parse_types[data_type](stream)


def parse_shortcuts(stream):
    shortcuts = parse(stream)[1].values()
    if Counter(s["appname"] for s in shortcuts).most_common()[0][1] > 1:
        raise "Duplicate appnames in Steam shortcuts"
    return {s["appname"]: s for s in shortcuts}


# Dump shortcuts.vdf


def dump_object_value(stream, k, values):
    stream.write("\x00")
    stream.write(k.encode("utf8"))
    stream.write("\x00")
    for k, v in values.iteritems():
        if isinstance(v, dict):
            dump_object_value(stream, k, v)
        elif isinstance(v, str):
            dump_string_value(stream, k, v)
        elif isinstance(v, int):
            dump_int_value(stream, k, v)
        elif isinstance(v, bool):
            dump_int_value(stream, k, int(v))
        else:
            raise TypeError("Unrecognized type:", type(v))
    stream.write("\x08")


def dump_string_value(stream, k, v):
    stream.write("\x01")
    stream.write(k.encode("utf-8"))
    stream.write("\x00")
    stream.write(v.encode("utf-8"))
    stream.write("\x00")


def dump_int_value(stream, k, v):
    stream.write("\x02")
    stream.write(k.encode("utf-8"))
    stream.write("\x00")
    stream.write(struct.pack("i", v))


def dump_shortcuts(stream, shortcuts):
    # Any string can be used as the key in shortcuts.vdf
    # Originally it is a string of the index, but we use the AppName instead
    dump_object_value(stream, "shortcuts", shortcuts)
    stream.write("\x08")


def steam_URL(shortcut):
    # Comments by <NAME>:
    """
    Calculates the filename for a given shortcut. This filename is a 64bit
    integer, where the first 32bits are a CRC32 based off of the name and
    target (with the added condition that the first bit is always high), and
    the last 32bits are 0x02000000.
    """
    # This will seem really strange (where I got all of these values), but I
    # got the xor_in and xor_out from disassembling the steamui library for
    # OSX. The reflect_in, reflect_out, and poly I figured out via trial and
    # error.
    algorithm = Crc(
        width=32,
        poly=0x04C11DB7,
        reflect_in=True,
        xor_in=0xFFFFFFFF,
        reflect_out=True,
        xor_out=0xFFFFFFFF,
    )
    input_string = shortcut["exe"].encode("utf-8") + shortcut["appname"].encode("utf-8")
    top_32 = algorithm.bit_by_bit(input_string) | 0x80000000
    full_64 = (top_32 << 32) | 0x02000000
    return "steam://rungameid/" + str(full_64)

def legacySteamID(shortcut):
    # SteamGrid Images uses the Legacy 32 Steam ID. We get it here
    # Comments by <NAME>:
    """
    Calculates the filename for a given shortcut. This filename is a 64bit
    integer, where the first 32bits are a CRC32 based off of the name and
    target (with the added condition that the first bit is always high), and
    the last 32bits are 0x02000000.
    """
    # This will seem really strange (where I got all of these values), but I
    # got the xor_in and xor_out from disassembling the steamui library for
    # OSX. The reflect_in, reflect_out, and poly I figured out via trial and
    # error.
    algorithm = Crc(
        width=32,
        poly=0x04C11DB7,
        reflect_in=True,
        xor_in=0xFFFFFFFF,
        reflect_out=True,
        xor_out=0xFFFFFFFF,
    )
    input_string = shortcut["exe"].encode("utf-8") + shortcut["appname"].encode("utf-8")
    top_32 = algorithm.bit_by_bit(input_string) | 0x80000000
    return str(top_32 )

def find_play_action(game):
    """
    Check if there is an existing OtherAction titled "Launch without Steam".

    This will occur if the script is rerun on a game. We want to find the
    original PlayAction to update the non-Steam shortcut.
    """

    if game.OtherActions:
        for play_action in game.OtherActions:
            if play_action.Name == "Launch without Steam":
                return play_action
    return game.PlayAction


def emulator_expand_variables(profile, game):
    # HACK! Import EmulatorProfileExtensions with reflection
    EmulatorProfileExtensions = PlayniteApi.GetType().Assembly.GetType(
        "Playnite.EmulatorProfileExtensions"
    )
    method = EmulatorProfileExtensions.GetMethod("ExpandVariables")
    return method.Invoke(None, Array[Object]((profile, game,)))


def open_playnite_log():
    path = join(PlayniteApi.Paths.ConfigurationPath, "playnite.log")
    os.startfile(path)


def non_steam_shortcuts(menu_args):
    games_updated = 0
    games_new = 0
    games_skipped_no_action = []
    games_skipped_steam_native = []
    games_skipped_bad_emulator = []
    games_url = []

    steam_userdata = get_steam_userdata_dir()
    if not steam_userdata:
        return

    shortcuts_vdf = join(steam_userdata, "config", "shortcuts.vdf")
    shortcuts_images = join(steam_userdata, "config", "grid")

    if isfile(shortcuts_vdf):
        try:
            shutil.copyfile(shortcuts_vdf, shortcuts_vdf + ".bak")
        except Exception as e:
            PlayniteApi.Dialogs.ShowErrorMessage(
                traceback.format_exc(), "Error backing up shortcuts.vdf"
            )
            return

        try:
            with open(shortcuts_vdf, "rb") as f:
                steam_shortcuts = parse_shortcuts(f)
        except Exception as e:
            PlayniteApi.Dialogs.ShowErrorMessage(
                traceback.format_exc(), "Error loading shortcuts.vdf"
            )
            return
    else:
        steam_shortcuts = {}

    for game in menu_args.Games:
        play_action = find_play_action(game)

        steam_url = None
        for link in game.Links:
            if link.Name.lower() == 'steam':
                steam_url = link.Url

        # https://store.steampowered.com/app/3232323/game_name_here --> 3232323
        if steam_url:
            steam_shortcut_appname_regex  = re.findall(r"\/(\d+)", steam_url)
            steam_shortcut_appname = steam_shortcut_appname_regex[0] if steam_shortcut_appname_regex else None                
            if steam_shortcut_appname:
                result = PlayniteApi.Dialogs.SelectString("We found a steam appid of {} in steam url, keep it or replace in the input box.".format(steam_shortcut_appname),
                                                          "Game name dialog",
                                                          steam_shortcut_appname)
                if not result.Result:
                    steam_shortcut_appname = game.Name
                    result = PlayniteApi.Dialogs.SelectString("Using game name, {}, as steam appid, keep it or replace in the input box.".format(steam_shortcut_appname),
                                                              "Game name dialog",
                                                              steam_shortcut_appname)
            else:
                steam_shortcut_appname = game.Name
                result = PlayniteApi.Dialogs.SelectString("Did not find steam appid in steam url, using game name, {}, as shortcut name, keep it or replace in the input box.".format(steam_shortcut_appname),
                                                          "Game name dialog",
                                                          steam_shortcut_appname)
        else:
            steam_shortcut_appname = game.Name
            result = PlayniteApi.Dialogs.SelectString("Did not find steam url, using game name, {}, as shortcut name, keep it or replace in the input box.".format(steam_shortcut_appname),
                                                      "Game name dialog",
                                                      steam_shortcut_appname)
        if not result.Result:
            PlayniteApi.Dialogs.ShowMessage("Got cancel response. Cancelling script for this game.")
            continue
        steam_shortcut_appname = result.SelectedString

        # If a game somehow has no PlayAction, skip it
        if not play_action:
            games_skipped_no_action.append(game.Name)
            __logger.Error("Non-Steam: Game has no PlayAction: {}".format(game.Name))
            continue

        # Skip the game if it is handled by the Steam plugin
        if game.PluginId == STEAM_PLUGIN_GUID:
            __logger.Warn(
                "Non-Steam: Game is already a Steam game: {}".format(game.Name)
            )
            games_skipped_steam_native.append(game.Name)
            continue

        # Only edit actions once with script.
        if play_action == game.PlayAction:
            play_action_already_created_by_script = False
        else:
            play_action_already_created_by_script = True
        # If a game has a URL PlayAction and it hasn't already been ran through this script, ask to change to exe, if not, use url anyway, but log it
        if play_action.Type == GameActionType.URL and not play_action_already_created_by_script:
            message = "Game, {}, has a URL as it's PlayAction, do you want to change that to an executable? Steam cannot open the overlay when games are started by URL.".format(game.Name)
            change_url = PlayniteApi.Dialogs.ShowMessage(message,
                                                         "Is this the game's executable?",
                                                         MessageBoxButton.YesNo)
            if change_url == MessageBoxResult.Yes:
                # Getting exe from listdir and getting the largest file with .exe (should work most of the time I think)
                game_dir = game.InstallDirectory
                largest_exe = None
                largest_exe_size = 0
                for exe in os.listdir(game_dir):
                    if exe.endswith(".exe"):
                        full_exe = join(game_dir, exe)
                        size = os.path.getsize(full_exe)
                        if size > largest_exe_size:
                            largest_exe = full_exe
                            largest_exe_size = size
                if largest_exe:
                    message = "This is the largest executable (.exe) in this game's directory, would you like to continue with it?\n\n{}".format(largest_exe)
                    good_exe = PlayniteApi.Dialogs.ShowMessage(message,
                                                               "Do you want to use this .exe?",
                                                               MessageBoxButton.YesNo)
                    if good_exe == MessageBoxResult.Yes:
                        # Move old PlayAction to OtherActions and create new url PlayAction
                        old_action = game.PlayAction
                        new_url_action = GameAction(Name="URL Action Created With Non-Steam Script",
                                                    Type=GameActionType.File,
                                                    Path=largest_exe,
                                                    IsHandledByPlugin=False)
                        game.PlayAction = new_url_action
                        if not game.OtherActions:
                            game.OtherActions = ObservableCollection[GameAction]()
                        # Move URL PlayAction created by script to Other Actions iff the action is new.
                        if not old_action == new_url_action:
                            old_action.Name = "Original Action Before Changing To Executable PlayAction"
                            game.OtherActions.Add(old_action)
                    else:
                        PlayniteApi.Dialogs.ShowMessage("No file selected, will continue with PlayAction as a URL.")
                else:
                    message = "Didn't find any executables (.exe) in this game's directory. Open a file selection window? (We'll continue otherwise)"
                    open_file_selection = PlayniteApi.Dialogs.ShowMessage(message,
                                                                          "No executable found",
                                                                           MessageBoxButton.YesNo)
                    if open_file_selection == MessageBoxResult.Yes:
                        file_selected = PlayniteApi.Dialogs.SelectFile("")
                        if file_selected:
                            # Move old PlayAction to OtherActions and create new url PlayAction
                            old_action = game.PlayAction
                            new_url_action = GameAction(Name="URL Action Created With Non-Steam Script",
                                                        Type=GameActionType.File,
                                                        Path=file_selected,
                                                        IsHandledByPlugin=False)
                            game.PlayAction = new_url_action
                            if not game.OtherActions:
                                game.OtherActions = ObservableCollection[GameAction]()
                            # Move URL PlayAction created by script to Other Actions iff the action is new.
                            if not old_action == new_url_action:
                                old_action.Name = "Original Action Before Changing To Executable PlayAction"
                                game.OtherActions.Add(old_action)
                        else:
                            PlayniteApi.Dialogs.ShowMessage("No file selected, will continue with PlayAction as a URL.")
                    else:
                        PlayniteApi.Dialogs.ShowMessage("No file selected, will continue with PlayAction as a URL.")
                        

        play_action = game.PlayAction
        # Check after changes if PlayAction is still a URL
        play_action_expanded = PlayniteApi.ExpandGameVariables(game, play_action)
        if play_action.Type == GameActionType.URL and not "steam" in play_action_expanded.Path:
            __logger.Warn(
                "Non-Steam: Game has a URL as PlayAction: {}".format(game.Name)
            )
            games_url.append(game.Name)

        # Create/Update Non-Steam shortcut
        play_action_expanded = PlayniteApi.ExpandGameVariables(game, play_action)
        if play_action_expanded.Type == GameActionType.Emulator:
            emulator = PlayniteApi.Database.Emulators.Get(play_action.EmulatorId)
            if emulator.Profiles:
                profile = emulator.Profiles.FirstOrDefault(
                    lambda a: a.Id == play_action.EmulatorProfileId
                )
            else:
                profile = None
            if not profile:
                games_skipped_bad_emulator.append(game.Name)
                continue
            profile_expanded = emulator_expand_variables(profile, game)
            start_dir = profile_expanded.WorkingDirectory
            exe = profile_expanded.Executable
            arguments = profile_expanded.Arguments or ""
            if play_action_expanded.AdditionalArguments:
                arguments += " " + play_action_expanded.AdditionalArguments
            if play_action_expanded.OverrideDefaultArgs:
                arguments = play_action_expanded.Arguments or ""
        elif play_action_expanded.Type == GameActionType.File:
            start_dir = play_action_expanded.WorkingDir
            exe = play_action_expanded.Path
            arguments = play_action_expanded.Arguments or ""
        elif play_action_expanded.Type == GameActionType.URL:
            exe = play_action_expanded.Path
            start_dir = ""
            arguments = ""
        if not play_action_expanded.Type == GameActionType.URL:
            if not start_dir:
                start_dir = FileInfo(exe).Directory.FullName
            exe = Path.Combine(start_dir, exe)
        if game.Icon:
            icon = PlayniteApi.Database.GetFullFilePath(game.Icon)
        else:
            icon = ""
        # this is the shortcut that will be past to steam, appname must equal game SteamID for steam controller to use configs.
        shortcut = {
            "icon": icon,
            "exe": '"{}"'.format(exe),
            "startdir": '"{}"'.format(start_dir),
            "appname": steam_shortcut_appname,
            "launchoptions": arguments,
        }
        if steam_shortcut_appname in steam_shortcuts:
            games_updated += 1
            steam_shortcuts[steam_shortcut_appname].update(shortcut)
            shortcut = steam_shortcuts[steam_shortcut_appname]
        else:
            games_new += 1
            shortcut.update(SHORTCUT_DEFAULTS)
            steam_shortcuts[steam_shortcut_appname] = shortcut

        # Update Playnite actions
        # Only run once, don't create duplicate OtherActions
        if not play_action_already_created_by_script:
            old_action = game.PlayAction
            steam_action = GameAction(
                Name="Non-Steam Steam Shortcut",
                Type=GameActionType.URL,
                Path=steam_URL(shortcut),
                IsHandledByPlugin=False,
            )
            game.PlayAction = steam_action
            if not game.OtherActions:
                game.OtherActions = ObservableCollection[GameAction]()
            old_action.Name = "Launch without Steam"
            game.OtherActions.Add(old_action)
        else:
            # play_action is already an OtherAction
            # Just make sure the URL is up to date on the main PlayAction
            game.PlayAction.Path = steam_URL(shortcut)

        # Copy Playnite Cover to Steam grids folder as portrait photo w/dialog box for selection
        if game.CoverImage:
          image_list = System.Collections.Generic.List[ImageFileOption]()
          image_list.Add(ImageFileOption(PlayniteApi.Database.GetFullFilePath(game.CoverImage)))
          image = PlayniteApi.Dialogs.ChooseImageFile(image_list, "Choose Steam Grid Background photo. Select cancel for none.", 300, 400)
          # If an image is selected, we use it, otherwise we just ignore the copyfile.
          if image:
            #PlayniteApi.Dialogs.ShowMessage(image.Path) # debug
            copyfile(str(image.Path), shortcuts_images +"\\" + legacySteamID(shortcut) + "p.png")
          else:
            PlayniteApi.Dialogs.ShowMessage("No image selected, will not use cover image then.")
        else:
          PlayniteApi.Dialogs.ShowMessage("This game does not have a cover image, won't be setting one.")


        # Copy Playnite Background to Steam grids folder as hero and default photo w/dialog box for selection
        if game.BackgroundImage:
          image_list = System.Collections.Generic.List[ImageFileOption]()
          image_list.Add(ImageFileOption(PlayniteApi.Database.GetFullFilePath(game.BackgroundImage)))
          image = PlayniteApi.Dialogs.ChooseImageFile(image_list, "Choose Steam Grid Cover photo. Select cancel for none.", 300, 400)
          # If an image is selected, we use it, otherwise we just ignore the copyfile.
          if image:
            #PlayniteApi.Dialogs.ShowMessage(image.Path) # debug
            copyfile(str(image.Path), shortcuts_images +"\\" + legacySteamID(shortcut) + "_hero.png")
            copyfile(str(image.Path), shortcuts_images +"\\" + legacySteamID(shortcut) + ".png")
          else:
            PlayniteApi.Dialogs.ShowMessage("No image selected, will not use cover image then.")
        else:
          PlayniteApi.Dialogs.ShowMessage("This game does not have a background image, won't be setting one.")

        #PlayniteApi.Dialogs.ShowMessage(broken-for-debugging-without-completing-script)


    # Save updated shortcuts.vdf
    try:
        with open(shortcuts_vdf, "wb") as f:
            dump_shortcuts(f, steam_shortcuts)
    except Exception as e:
        PlayniteApi.Dialogs.ShowErrorMessage(
            traceback.format_exc(), "Error saving shortcuts.vdf"
        )
        if isfile(shortcuts_vdf + ".bak"):
            try:
                shutil.copyfile(shortcuts_vdf + ".bak", shortcuts_vdf)
                PlayniteApi.Dialogs.ShowMessage(
                    "Successfully restored shortcuts.vdf backup"
                )
            except Exception as e:
                PlayniteApi.Dialogs.ShowErrorMessage(
                    traceback.format_exc(), "Error restoring shortcuts.vdf backup"
                )
        else:
            os.remove(shortcuts_vdf)
        return

    # Truncate long lists of games
    if len(games_skipped_steam_native) > 10:
        games_skipped_steam_native = games_skipped_steam_native[:10] + ["[...]"]
    if len(games_skipped_no_action) > 10:
        games_skipped_no_action = games_skipped_no_action[:10] + ["[...]"]
    if len(games_skipped_bad_emulator) > 10:
        games_skipped_bad_emulator = games_skipped_bad_emulator[:10] + ["[...]"]
    if len(games_url) > 10:
        games_url = games_url[:10] + ["[...]"]

    errors = False
    message = "Please relaunch Steam to update non-Steam shortcuts!\n\n"
    message += "Updated {} existing non-Steam shortcuts\n".format(games_updated)
    message += "Created {} new non-Steam shortcuts".format(games_new)
    if games_skipped_steam_native:
        message += "\n\nSkipped {} native Steam game(s):\n".format(
            len(games_skipped_steam_native)
        )
        message += "\n".join(games_skipped_steam_native)
        errors = True
    if games_skipped_no_action:                                                               
        message += "\n\nSkipped {} game(s) without any PlayAction set (not installed?):\n".format(
            len(games_skipped_no_action)
        )
        message += "\n".join(games_skipped_no_action)
        errors = True
    if games_skipped_bad_emulator:
        message += "\n\nSkipped {} emulated game(s) with bad emulator profiles:\n".format(
            len(games_skipped_bad_emulator)
        )
        message += "\n".join(games_skipped_bad_emulator)
        errors = True
    if games_url:
        message += "\n\nWarning: Some games had URL launch actions. (Typically managed by a library plugin.) "
        message += (
            "You may wish to update their actions and recreate non-Steam shortcuts. "
        )
        message += "Steam will still launch these games, but the Steam overlay will not function."
        message += "\n\nThe following {} game(s) had URL launch actions:\n".format(
            len(games_url)
        )
        message += "\n".join(games_url)
        errors = True
    if errors:
        message += "\n\nOpen playnite.log for full list of errors?"
        show_log = PlayniteApi.Dialogs.ShowMessage(
            message,
            "Updated Non-Steam Shortcuts",
            MessageBoxButton.YesNo,
            MessageBoxImage.Error,
        )
        if show_log == MessageBoxResult.Yes:
            open_playnite_log()
    else:
        PlayniteApi.Dialogs.ShowMessage(message, "Updated Non-Steam Shortcuts")


###############################################################################

### crc_algorithms.py (Playnite won't import python files)

#  pycrc -- parameterisable CRC calculation utility and C source code generator
#
#  Copyright (c) 2006-2013  <NAME>  <<EMAIL>>
#
#  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.


"""
CRC algorithms implemented in Python.
If you want to study the Python implementation of the CRC routines, then this
is a good place to start from.

The algorithms Bit by Bit, Bit by Bit Fast and Table-Driven are implemented.

This module can also be used as a library from within Python.

Examples
========

This is an example use of the different algorithms:

>>> from crc_algorithms import Crc
>>>
>>> crc = Crc(width = 16, poly = 0x8005,
...           reflect_in = True, xor_in = 0x0000,
...           reflect_out = True, xor_out = 0x0000)
>>> print("0x%x" % crc.bit_by_bit("123456789"))
>>> print("0x%x" % crc.bit_by_bit_fast("123456789"))
>>> print("0x%x" % crc.table_driven("123456789"))
"""

# Class Crc
###############################################################################
class Crc(object):
    """
    A base class for CRC routines.
    """

    # Class constructor
    ###############################################################################
    def __init__(
        self,
        width,
        poly,
        reflect_in,
        xor_in,
        reflect_out,
        xor_out,
        table_idx_width=None,
    ):
        """The Crc constructor.

        The parameters are as follows:
            width
            poly
            reflect_in
            xor_in
            reflect_out
            xor_out
        """
        self.Width = width
        self.Poly = poly
        self.ReflectIn = reflect_in
        self.XorIn = xor_in
        self.ReflectOut = reflect_out
        self.XorOut = xor_out
        self.TableIdxWidth = table_idx_width

        self.MSB_Mask = 0x1 << (self.Width - 1)
        self.Mask = ((self.MSB_Mask - 1) << 1) | 1
        if self.TableIdxWidth != None:
            self.TableWidth = 1 << self.TableIdxWidth
        else:
            self.TableIdxWidth = 8
            self.TableWidth = 1 << self.TableIdxWidth

        self.DirectInit = self.XorIn
        self.NonDirectInit = self.__get_nondirect_init(self.XorIn)
        if self.Width < 8:
            self.CrcShift = 8 - self.Width
        else:
            self.CrcShift = 0

    # function __get_nondirect_init
    ###############################################################################
    def __get_nondirect_init(self, init):
        """
        return the non-direct init if the direct algorithm has been selected.
        """
        crc = init
        for i in range(self.Width):
            bit = crc & 0x01
            if bit:
                crc ^= self.Poly
            crc >>= 1
            if bit:
                crc |= self.MSB_Mask
        return crc & self.Mask

    # function reflect
    ###############################################################################
    def reflect(self, data, width):
        """
        reflect a data word, i.e. reverts the bit order.
        """
        x = data & 0x01
        for i in range(width - 1):
            data >>= 1
            x = (x << 1) | (data & 0x01)
        return x

    # function bit_by_bit
    ###############################################################################
    def bit_by_bit(self, in_str):
        """
        Classic simple and slow CRC implementation.  This function iterates bit
        by bit over the augmented input message and returns the calculated CRC
        value at the end.
        """
        register = self.NonDirectInit
        for c in in_str:
            octet = ord(c)
            if self.ReflectIn:
                octet = self.reflect(octet, 8)
            for i in range(8):
                topbit = register & self.MSB_Mask
                register = ((register << 1) & self.Mask) | ((octet >> (7 - i)) & 0x01)
                if topbit:
                    register ^= self.Poly

        for i in range(self.Width):
            topbit = register & self.MSB_Mask
            register = (register << 1) & self.Mask
            if topbit:
                register ^= self.Poly

        if self.ReflectOut:
            register = self.reflect(register, self.Width)
        return register ^ self.XorOut

    # function bit_by_bit_fast
    ###############################################################################
    def bit_by_bit_fast(self, in_str):
        """
        This is a slightly modified version of the bit-by-bit algorithm: it
        does not need to loop over the augmented bits, i.e. the Width 0-bits
        wich are appended to the input message in the bit-by-bit algorithm.
        """
        register = self.DirectInit
        for c in in_str:
            octet = ord(c)
            if self.ReflectIn:
                octet = self.reflect(octet, 8)
            for i in range(8):
                topbit = register & self.MSB_Mask
                if octet & (0x80 >> i):
                    topbit ^= self.MSB_Mask
                register <<= 1
                if topbit:
                    register ^= self.Poly
            register &= self.Mask
        if self.ReflectOut:
            register = self.reflect(register, self.Width)
        return register ^ self.XorOut

    # function gen_table
    ###############################################################################
    def gen_table(self):
        """
        This function generates the CRC table used for the table_driven CRC
        algorithm.  The Python version cannot handle tables of an index width
        other than 8.  See the generated C code for tables with different sizes
        instead.
        """
        table_length = 1 << self.TableIdxWidth
        tbl = [0] * table_length
        for i in range(table_length):
            register = i
            if self.ReflectIn:
                register = self.reflect(register, self.TableIdxWidth)
            register = register << (self.Width - self.TableIdxWidth + self.CrcShift)
            for j in range(self.TableIdxWidth):
                if register & (self.MSB_Mask << self.CrcShift) != 0:
                    register = (register << 1) ^ (self.Poly << self.CrcShift)
                else:
                    register = register << 1
            if self.ReflectIn:
                register = (
                    self.reflect(register >> self.CrcShift, self.Width) << self.CrcShift
                )
            tbl[i] = register & (self.Mask << self.CrcShift)
        return tbl

    # function table_driven
    ###############################################################################
    def table_driven(self, in_str):
        """
        The Standard table_driven CRC algorithm.
        """
        tbl = self.gen_table()

        register = self.DirectInit << self.CrcShift
        if not self.ReflectIn:
            for c in in_str:
                tblidx = (
                    (register >> (self.Width - self.TableIdxWidth + self.CrcShift))
                    ^ ord(c)
                ) & 0xFF
                register = (
                    (register << (self.TableIdxWidth - self.CrcShift)) ^ tbl[tblidx]
                ) & (self.Mask << self.CrcShift)
            register = register >> self.CrcShift
        else:
            register = (
                self.reflect(register, self.Width + self.CrcShift) << self.CrcShift
            )
            for c in in_str:
                tblidx = ((register >> self.CrcShift) ^ ord(c)) & 0xFF
                register = ((register >> self.TableIdxWidth) ^ tbl[tblidx]) & (
                    self.Mask << self.CrcShift
                )
            register = self.reflect(register, self.Width + self.CrcShift) & self.Mask

        if self.ReflectOut:
            register = self.reflect(register, self.Width)
        return register ^ self.XorOut


#### End crc_algorithms
# Non-Steam Shortcuts # Default settings for entries in shortcuts.vdf # See https://github.com/CorporalQuesadilla/Steam-Shortcut-Manager/wiki/Steam-Shortcuts-Documentation # Be very careful if you change these settings, you can easily break this script here. # Do not edit anything below this line # Parse shortcuts.vdf # Steam matches keys case insensitively, so lowercase all keys to be case insensitive # Read key value pairs until a \x08 byte is reached # Strings are null terminated # Read a per type one byte header, then parse using the correct type # Dump shortcuts.vdf # Any string can be used as the key in shortcuts.vdf # Originally it is a string of the index, but we use the AppName instead # Comments by <NAME>: Calculates the filename for a given shortcut. This filename is a 64bit     integer, where the first 32bits are a CRC32 based off of the name and     target (with the added condition that the first bit is always high), and     the last 32bits are 0x02000000. # This will seem really strange (where I got all of these values), but I # got the xor_in and xor_out from disassembling the steamui library for # OSX. The reflect_in, reflect_out, and poly I figured out via trial and # error. # SteamGrid Images uses the Legacy 32 Steam ID. We get it here # Comments by <NAME>: Calculates the filename for a given shortcut. This filename is a 64bit     integer, where the first 32bits are a CRC32 based off of the name and     target (with the added condition that the first bit is always high), and     the last 32bits are 0x02000000. # This will seem really strange (where I got all of these values), but I # got the xor_in and xor_out from disassembling the steamui library for # OSX. The reflect_in, reflect_out, and poly I figured out via trial and # error. Check if there is an existing OtherAction titled "Launch without Steam".      This will occur if the script is rerun on a game. We want to find the     original PlayAction to update the non-Steam shortcut. # HACK! Import EmulatorProfileExtensions with reflection # https://store.steampowered.com/app/3232323/game_name_here --> 3232323 # If a game somehow has no PlayAction, skip it # Skip the game if it is handled by the Steam plugin # Only edit actions once with script. # If a game has a URL PlayAction and it hasn't already been ran through this script, ask to change to exe, if not, use url anyway, but log it # Getting exe from listdir and getting the largest file with .exe (should work most of the time I think) # Move old PlayAction to OtherActions and create new url PlayAction # Move URL PlayAction created by script to Other Actions iff the action is new. # Move old PlayAction to OtherActions and create new url PlayAction # Move URL PlayAction created by script to Other Actions iff the action is new. # Check after changes if PlayAction is still a URL # Create/Update Non-Steam shortcut # this is the shortcut that will be past to steam, appname must equal game SteamID for steam controller to use configs. # Update Playnite actions # Only run once, don't create duplicate OtherActions # play_action is already an OtherAction # Just make sure the URL is up to date on the main PlayAction # Copy Playnite Cover to Steam grids folder as portrait photo w/dialog box for selection # If an image is selected, we use it, otherwise we just ignore the copyfile. #PlayniteApi.Dialogs.ShowMessage(image.Path) # debug # Copy Playnite Background to Steam grids folder as hero and default photo w/dialog box for selection # If an image is selected, we use it, otherwise we just ignore the copyfile. #PlayniteApi.Dialogs.ShowMessage(image.Path) # debug #PlayniteApi.Dialogs.ShowMessage(broken-for-debugging-without-completing-script) # Save updated shortcuts.vdf # Truncate long lists of games ############################################################################### ### crc_algorithms.py (Playnite won't import python files) #  pycrc -- parameterisable CRC calculation utility and C source code generator # #  Copyright (c) 2006-2013  <NAME>  <<EMAIL>> # #  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. CRC algorithms implemented in Python. If you want to study the Python implementation of the CRC routines, then this is a good place to start from.  The algorithms Bit by Bit, Bit by Bit Fast and Table-Driven are implemented.  This module can also be used as a library from within Python.  Examples ========  This is an example use of the different algorithms:  >>> from crc_algorithms import Crc >>> >>> crc = Crc(width = 16, poly = 0x8005, ...           reflect_in = True, xor_in = 0x0000, ...           reflect_out = True, xor_out = 0x0000) >>> print("0x%x" % crc.bit_by_bit("123456789")) >>> print("0x%x" % crc.bit_by_bit_fast("123456789")) >>> print("0x%x" % crc.table_driven("123456789")) # Class Crc ############################################################################### A base class for CRC routines. # Class constructor ############################################################################### The Crc constructor.          The parameters are as follows:             width             poly             reflect_in             xor_in             reflect_out             xor_out # function __get_nondirect_init ############################################################################### return the non-direct init if the direct algorithm has been selected. # function reflect ############################################################################### reflect a data word, i.e. reverts the bit order. # function bit_by_bit ############################################################################### Classic simple and slow CRC implementation.  This function iterates bit         by bit over the augmented input message and returns the calculated CRC         value at the end. # function bit_by_bit_fast ############################################################################### This is a slightly modified version of the bit-by-bit algorithm: it         does not need to loop over the augmented bits, i.e. the Width 0-bits         wich are appended to the input message in the bit-by-bit algorithm. # function gen_table ############################################################################### This function generates the CRC table used for the table_driven CRC         algorithm.  The Python version cannot handle tables of an index width         other than 8.  See the generated C code for tables with different sizes         instead. # function table_driven ############################################################################### The Standard table_driven CRC algorithm. #### End crc_algorithms