gsgbackup/sgbackup/game.py

1871 lines
63 KiB
Python

###############################################################################
# sgbackup - The SaveGame Backup tool #
# Copyright (C) 2024,2025 Christian Moser #
# #
# This program is free software: you can redistribute it and/or modify #
# it under the terms of the GNU General Public License as published by #
# the Free Software Foundation, either version 3 of the License, or #
# (at your option) any later version. #
# #
# This program is distributed in the hope that it will be useful, #
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
# GNU General Public License for more details. #
# #
# You should have received a copy of the GNU General Public License #
# along with this program. If not, see <https://www.gnu.org/licenses/>. #
###############################################################################
from . import _import_gtk
from gi.repository.GObject import Property,GObject,Signal,SignalFlags
from gi.repository import GLib
import os
import json
import re
import fnmatch
from enum import StrEnum
import sys
import logging
import pathlib
import datetime
from string import Template
logger = logging.getLogger(__name__)
from .settings import settings
if sys.platform.lower() == "win32":
PLATFORM_WIN32 = True
import winreg
else:
PLATFORM_WIN32 = False
if sys.platform.lower() in ['linux','freebsd','netbsd','openbsd','dragonfly']:
PLATFORM_LINUX = True
else:
PLATFORM_LINUX = False
class SavegameType(StrEnum):
"""
SavegameType The savegame type for `Game` instance.
The SavegameType selects the `GameData` provider for the
`Game` instance.
"""
#: UNSET The SavegameType is unset
UNSET = "unset"
#: OTHER Not listed game-provider.
#:
#: **Currently not supported!**
OTHER = "other"
#: WINDOWS Native Windows game
WINDOWS = "windows"
#: LINUX Native Linux game
LINUX = "linux"
#: MACOS Native MacOS game
MACOS = "macos"
#: STEAM_WINDOWS *Steam* for Windows
STEAM_WINDOWS = "steam_windows"
#: STEAM_LINUX *Steam* for Linux
STEAM_LINUX = "steam_linux"
#: STEAM_MACOS *Steam* for MacOS
STEAM_MACOS = "steam_macos"
#: GOG WINDOWS *Good old Games* for Windows
#:
#: **Currently not supported!**
GOG_WINDOWS = "gog_windows"
#: GOG_LINUX *Good old Games* for Linux
#:
#: **Currently not supported!**
GOG_LINUX = "gog_linux"
#: EPIC_WINDOWS *Epic Games* for Windows
#:
#: **Currently not supported!**
EPIC_WINDOWS = "epic_windows"
#: EPIC_LINUX *Epic Games* for Linux
#:
#: **Currently not supported!**
EPIC_LINUX = "epic_linux"
@staticmethod
def from_string(typestring:str):
"""
from_string Get SavegameType from string.
:param typestring: The string to parse
:type typestring: str
:return: The SavegameType if any. If no matching SavegameType is found,
SavegameType.UNSET is returned.
:rtype: SavegameType
"""
st=SavegameType
s=typestring.lower()
if (s == 'other'):
return st.OTHER
elif (s == 'windows'):
return st.WINDOWS
elif (s == 'linux'):
return st.LINUX
elif (s == 'macos'):
return st.MACOS
elif (s == 'steam_windows' or s == 'steam-windows' or s == 'steamwindows' or s == 'steam.windows'):
return st.STEAM_WINDOWS
elif (s == 'steam_linux' or s == 'steam-linux' or s == 'steamlinux' or s == 'steam.linux'):
return st.STEAM_LINUX
elif (s == 'steam_macos' or s == 'steam-macos' or s == 'steammacos' or s == 'steam.macos'):
return st.STEAM_MACOS
elif (s == 'gog_winows' or s == 'gog-windows' or s == 'gogwindows' or s == 'gog.windows'):
return st.GOG_WINDOWS
elif (s == 'gog_linux' or s == 'gog-linux' or s == 'goglinux' or s == 'gog.linux'):
return st.GOG_LINUX
elif (s == 'epic_windows' or s == 'epic-windows' or s == 'epicwindows' or s == 'epic.windows'):
return st.EPIC_WINDOWS
elif (s == 'epic_linux' or s == 'epic-linux' or s == 'epiclinux' or s == 'epic.linux'):
return st.EPIC_LINUX
return st.UNSET
VALID_SAVEGAME_TYPES = [
SavegameType.WINDOWS,
SavegameType.LINUX,
SavegameType.MACOS,
SavegameType.STEAM_LINUX,
SavegameType.STEAM_MACOS,
SavegameType.STEAM_WINDOWS,
#SavegameType.EPIC_LINUX,
SavegameType.EPIC_WINDOWS,
#SavegameType.GOG_LINUX,
#SavegameType.GOG_WINDOWS,
]
SAVEGAME_TYPE_ICONS = {
SavegameType.UNSET : None,
SavegameType.WINDOWS: 'windows-svgrepo-com-symbolic',
SavegameType.LINUX: 'linux-svgrepo-com-symbolic',
SavegameType.MACOS: 'apple-svgrepo-com-symbolic',
SavegameType.STEAM_LINUX: 'steam-svgrepo-com-symbolic',
SavegameType.STEAM_MACOS: 'steam-svgrepo-com-symbolic',
SavegameType.STEAM_WINDOWS: 'steam-svgrepo-com-symbolic',
SavegameType.EPIC_LINUX: 'epic-games-svgrepo-com-symbolic',
SavegameType.EPIC_WINDOWS: 'epic-games-svgrepo-com-symbolic',
SavegameType.GOG_LINUX: 'gog-com-svgrepo-com-symbolic',
SavegameType.GOG_WINDOWS: 'gog-com-svgrepo-com-symbolic',
}
class GameProvider(StrEnum):
WINDOWS = "windows"
LINUX = "linux"
MACOS = "macos"
STEAM = "steam"
EPIC_GAMES = "epic-games"
GOG = "gog"
GAME_PROVIDER_ICONS = {
GameProvider.WINDOWS: 'windows-svgrepo-com-symbolic',
GameProvider.LINUX: 'liux-svgrepo-com-symbolic',
GameProvider.MACOS: 'apple-svgrepo-com-symbolic',
GameProvider.STEAM: 'steam-svgrepo-com-symbolic',
GameProvider.EPIC_GAMES: 'epic-games-svgrepo-com-symbolic',
GameProvider.GOG: 'gog-com-svgrepo-com-symbolic',
}
class GameFileType(StrEnum):
"""
GameFileType The file matcher type for `GameFileMatcher`.
The path to be matched is originating from *${SAVEGAME_ROOT}/${SAVEGAME_DIR}*.
"""
#: GLOB Glob matching.
GLOB = "glob"
#: REGEX Regex file matching
REGEX = "regex"
#: FILENAME Filename matching.
FILENAME = "filename"
@staticmethod
def from_string(typestring:str):
"""
from_string Get the `GameFileType` from a string.
If an illegal string-value is given this method raises a `ValueError`.
:param typestring: The string to be used.
:type typestring: str
:raises ValueError: If an illegal string is given.
:return: The corresponding Enum-value
:rtype: GameFileType
"""
s = typestring.lower()
if (s == 'glob'):
return GameFileType.GLOB
elif (s == 'regex'):
return GameFileType.REGEX
elif (s == 'filename'):
return GameFileType.FILENAME
raise ValueError("Unknown GameFileType \"{}\"!".format(typestring))
class GameFileMatcher(GObject):
"""
GameFileMatcher Match savegame files if they are to be included in the backup.
"""
__gtype_name__ = "GameFileMatcher"
def __init__(self,match_type:GameFileType,match_file:str):
GObject.__init__(self)
self.match_type = match_type
self.match_file = match_file
@Property
def match_type(self)->GameFileType:
"""
match_type The type of the matcher.
:type: GameFileType
"""
return self.__match_type
@match_type.setter
def match_type(self,match_type:GameFileType):
if not isinstance(match_type,GameFileType):
raise TypeError("match_type is not a GameFileType instance!")
self.__match_type = match_type
@Property(type=str)
def match_file(self)->str:
"""
match_file The matcher value.
:type: str
"""
return str(self.__match_file)
@match_file.setter
def match_file(self,file:str):
self.__match_file = file
def match(self,rel_filename:str)->bool:
"""
match Match the file.
:param rel_filename: The relative filename originating from
*${SAVEGAME_ROOT}/${SAVEGAME_DIR}*.
:type rel_filename: str
:rtype: bool
:returns: True if file matches
"""
def match_glob(filename)->bool:
return fnmatch.fnmatch(filename,self.__match_file)
# match_glob()
def match_filename(filename):
if (self.match_file.endswith('/')):
if filename == self.match_file[:-1] or filename.startswith(self.match_file):
return True
elif filename == self.match_file:
return True
return False
# match_filename()
def match_regex(filename):
return (re.search(self.match_file,filename) is not None)
# match_filename()
if (self.match_type == GameFileType.FILENAME):
return match_filename(rel_filename)
elif (self.match_type == GameFileType.GLOB):
return match_glob(rel_filename)
elif (self.match_type == GameFileType.REGEX):
return match_regex(rel_filename)
return False
class GameData(GObject):
__gtype_name__ = 'GameData'
"""
:class: GameData Base class for savegame specific data data.
"""
def __init__(self,
savegame_type:SavegameType,
savegame_root:str,
savegame_dir:str,
variables:dict|None=None,
file_match:list|None=None,
ignore_match:list|None=None):
GObject.__init__(self)
self.__savegame_type = savegame_type
self.__savegame_root = savegame_root
self.__savegame_dir = savegame_dir
self.__variables = {}
self.file_matchers = file_match
self.ignore_matchers = ignore_match
if variables is not None:
variables.update(variables)
@Property
def savegame_type(self)->SavegameType:
"""
:type: SavegameType
"""
return self.__savegame_type
@Property(type=str)
def savegame_root(self)->str:
"""
:type: str
"""
return self.__savegame_root
@savegame_root.setter
def savegame_root(self,sgroot:str):
self.__savegame_root = sgroot
@Property
def savegame_dir(self)->str:
"""
:type: str
"""
return self.__savegame_dir
@savegame_dir.setter
def savegame_dir(self,sgdir:str):
self.__savegame_dir = sgdir
@Property
def variables(self)->dict[str:str]:
"""
:type: dict[str:str]
"""
return self.__variables
@variables.setter
def variables(self,vars:dict|None):
if not vars:
self.__variables = {}
else:
self.__variables = dict(vars)
@Property
def file_matchers(self)->list[GameFileMatcher]:
"""
:type: list[GameFileMatcher]
"""
return self.__filematchers
@file_matchers.setter
def file_matchers(self,fm:list[GameFileMatcher]|None):
self.__filematchers = []
if fm:
for matcher in fm:
if not isinstance(matcher,GameFileMatcher):
raise TypeError("\"file_match\" needs to be \"None\" or a list of \"GameFileMatcher\" instances!")
self.__filematchers = list(fm)
@Property
def ignore_matchers(self)->list[GameFileMatcher]:
"""
:type: list[GameFileMatcher]
"""
return self.__ignorematchers
@ignore_matchers.setter
def ignore_matchers(self,im:list[GameFileMatcher]|None):
self.__ignorematchers = []
if im:
for matcher in im:
if not isinstance(matcher,GameFileMatcher):
raise TypeError("\"ignore_match\" needs to be \"None\" or a list of \"GameFileMatcher\" instances!")
self.__ignorematchers = list(im)
@Property(type=bool,default=False)
def is_valid(self)->bool:
return (bool(self.__savegame_root) and bool(self.__savegame_dir) and (self.__savegame_type != SavegameType.UNSET))
def has_variable(self,name:str)->bool:
"""
has_variable Check if variable exists.
:param name: The variable name.
:type name: str
:return: `True` if the variable exists.
:rtype: bool
"""
return (name in self.__variables)
def get_variable(self,name:str)->str:
"""
get_variable Get a variable value byy variable name.
:param name: The variable name
:type name: str
:return: The vairable value if the variable exists or an empty string.
:rtype: str
"""
if name not in self.__variables:
return ""
return self.__variables[name]
def set_variable(self,name:str,value:str):
"""
set_variable Set a variable.
If the variable exists, it is replaced by the new variable.
:param name: The variable name.
:type name: str
:param value: The variable value.
:type value: str
"""
self.__variables[name] = value
def delete_variable(self,name:str):
"""
delete_variable Deletes as variable if the variable exists
:param name: The vairable name to delete.
:type name: str
"""
if name in self.__variables:
del self.__variables[name]
def get_variables(self)->dict[str:str]:
"""
get_variables Get the variables set by this instance.
:return: The variables as a dict.
:rtype: dict[str:str]
"""
return dict(self.variables)
def match_file(self,rel_filename:str)->bool:
"""
match_file Matches a file with the `GameFileMatcher`s for this class.
This method returns `True` if there is no `GameFileMatcher` set for
`GameData.file_match`.
:param rel_filename: The relative filename originating from *$SAVEGAME_DIR*
:type rel_filename: str
:return: `True` if the file matches.
:rtype: bool
"""
if not self.file_matchers:
return True
for fm in self.file_matchers:
if fm.match(rel_filename):
return True
return False
def match_ignore(self,rel_filename:str)->bool:
"""
match_ignore Matches file agains the ignore_match `GameFileMatcher`s.
This method returns `False` if there is no `GameFileMatcher` set in
`GameData.ignore_match`.
:param rel_filename: The relative filename originating from *$SAVEGAME_DIR*
:type rel_filename: str
:return: `True` if the file matches.
:rtype: bool
"""
if not self.ignore_matchers:
return False
for fm in self.ignore_matchers:
if fm.match(rel_filename):
return True
return False
def match(self,rel_filename:str)->bool:
"""
match Match files against `file_match` and `ignore_match`.
If this method returns `True` the file should be included in the
savegame backup.
:param rel_filename: The relative filename originating from *$SAVEGAME_DIR*
:type rel_filename: str
:return: True if the file should be included in the savegame backup.
:rtype: bool
"""
if self.match_file(rel_filename) and not self.match_ignore(rel_filename):
return True
return False
def add_file_match(self,matcher:GameFileMatcher):
"""
add_file_match Add a `GameFileMatcher` to `file_match`.
:param matcher: The `GameFileMatcher` to add.
:type matcher: GameFileMatcher
:raises TypeError: If the `matcher` is not a `GameFileMatcher` instance.
"""
if not isinstance(matcher,GameFileMatcher):
raise TypeError("matcher is not a \"GameFileMatcher\" instance!")
self.__filematchers.append(matcher)
def remove_file_match(self,matcher:GameFileMatcher):
"""
remove_file_match Remove a file_matcher.
:param matcher: The `GameFileMatcher` to remove.
:type matcher: GameFileMatcher
"""
for i in reversed(range(len(self.__filematchers))):
if (matcher == self.__filematchers[i]):
del self.__filematchers[i]
def add_ignore_match(self,matcher:GameFileMatcher):
"""
add_file_match Add a `GameFileMatcher` to `ignore_match`.
:param matcher: The `GameFileMatcher` to add.
:type matcher: GameFileMatcher
:raises TypeError: If the `matcher` is not a `GameFileMatcher` instance.
"""
if not isinstance(matcher,GameFileMatcher):
raise TypeError("matcher is not a \"GameFileMatcher\" instance!")
self.__ignorematchers.append(matcher)
def remove_ignore_match(self,matcher:GameFileMatcher):
"""
remove_file_match Remove a ignore_match.
:param matcher: The `GameFileMatcher` to remove.
:type matcher: GameFileMatcher
"""
for i in reversed(range(len(self.__ignorematchers))):
if (matcher == self.__ignorematchers[i]):
del self.__ignorematchers[i]
def serialize(self)->dict:
"""
serialize Serialize the instance to a dict.
This method should be overloaded by child-classes, so that their data
is exported too.
:return: The dict holding the data for recreating an instance of this class.
:rtype: dict
"""
ret = {
'savegame_root': self.savegame_root,
'savegame_dir': self.savegame_dir,
}
if (self.__variables):
ret['variables'] = self.variables
if (self.file_matchers):
fm = []
for matcher in self.file_matchers:
fm.append({'type':matcher.match_type.value,'match':matcher.match_file})
ret['file_match'] = fm
if (self.ignore_matchers):
im = []
for matcher in self.ignore_matchers:
im.append({'type':matcher.match_type.value,'match':matcher.match_file})
ret['ignore_match'] = im
return ret
class WindowsGame(GameData):
def __init__(self,
savegame_root:str,
savegame_dir:str,
variables:dict|None=None,
installdir:str|None=None,
game_registry_keys:str|list|None=None,
installdir_registry_keys:str|list|None=None,
file_match:list|None=None,
ignore_match:list|None=None):
GameData.__init__(self,
SavegameType.WINDOWS,
savegame_root,
savegame_dir,
variables,
file_match,
ignore_match)
if not installdir:
self.__installdir = None
else:
self.__installdir = installdir
if not game_registry_keys:
self.__game_registry_keys = []
elif isinstance(game_registry_keys,str):
self.__game_registry_keys = [game_registry_keys]
else:
self.__game_registry_keys = list(game_registry_keys)
if not installdir_registry_keys:
self.__installdir_registry_keys = []
elif isinstance(installdir_registry_keys,str):
self.__installdir_registry_keys = [installdir_registry_keys]
else:
self.__installdir_registry_keys = list(installdir_registry_keys)
def __get_hkey(self,regkey:str):
regvec = regkey.split("\\")
hkstr = regvec[0]
if (hkstr == 'HKLM' or hkstr == 'HKEY_LOCAL_MACHINE'):
return winreg.HKEY_LOCAL_MACHINE
elif (hkstr == 'HKCU' or hkstr == 'HKEY_CURRENT_USER'):
return winreg.HKEY_CURRENT_USER
elif (hkstr == 'HKCC' or hkstr == 'HKEY_CURRENT_CONFIG'):
return winreg.HKEY_CURRENT_CONFIG
elif (hkstr == 'HKCR' or hkstr == 'HKEY_CLASSES_ROOT'):
return winreg.HKEY_CLASSES_ROOT
elif (hkstr == 'HKU' or hkstr == 'HKEY_USERS'):
return winreg.HKEY_USERS
return None
@Property
def installdir(self)->str|None:
return self.__installdir
@installdir.setter
def installdir(self,installdir:str|None):
self.__installdir = installdir
@Property
def game_registry_keys(self)->list:
return self.__game_registry_keys
@game_registry_keys.setter
def game_registry_keys(self,keys:list[str]|tuple[str]|None):
self.__game_registry_keys = []
if keys:
for rk in keys:
self.__game_registry_keys.append(str(rk))
@Property
def installdir_registry_keys(self)->list:
return self.__installdir_registry_keys
@installdir_registry_keys.setter
def installdir_registry_keys(self,keys:list[str]|tuple[str]|None):
self.__installdir_registry_keys = []
if keys:
for rk in keys:
self.__installdir_registry_keys.append(str(rk))
@Property
def is_installed(self)->bool|None:
if not PLATFORM_WIN32 or not self.game_registry_keys:
return None
for regkey in self.__game_registry_keys:
hkey = self.__get_hkey(regkey)
regvec = regkey.split('\\')
if (regvec > 1):
key = '\\'.join(regvec[1:])
try:
rkey = winreg.OpenKeyEx(hkey,key)
winreg.CloseKey(rkey)
return True
except OSError as ex:
continue
return False
@Property
def registry_installdir(self)->str|None:
if not PLATFORM_WIN32 or not (self.installdir_registry_keys):
return None
for regkey in self.__game_registry_keys:
hkey = self.__get_hkey(regkey)
regvec = regkey.split('\\')
if (regvec > 2):
key = '\\'.join(regvec[1:-1])
try:
rkey = None
rkey = winreg.OpenKeyEx(hkey,key)
retval = winreg.QueryValue(rkey,regvec[-1])
winreg.CloseKey(rkey)
if retval:
return str(retval[0])
except OSError as ex:
if (rkey):
winreg.CloseKey(rkey)
continue
return None
def get_variables(self):
variables = super().get_variables()
variables["INSTALLDIR"] = self.installdir if self.installdir else ""
return variables
def serialize(self):
ret = super().serialize()
if (self.installdir):
ret['installdir'] = self.installdir
if (self.game_registry_keys):
ret['game_registry_keys'] = self.game_registry_keys
if (self.installdir_registry_keys):
ret['installdir_registry_keys'] = self.installdir_registry_keys
return ret
class LinuxGame(GameData):
def __init__(self,
savegame_root:str,
savegame_dir:str,
variables:dict|None=None,
binary:str|None=None,
file_match:list|None=None,
ignore_match:list|None=None):
GameData.__init__(self,
SavegameType.LINUX,
savegame_root,
savegame_dir,
variables,
file_match,
ignore_match)
self.__binary = binary
@Property
def binary(self)->str|None:
return self.__binary
@binary.setter
def binary(self,bin:str):
self.__binary = bin
@Property
def is_installed(self)->bool|None:
if PLATFORM_LINUX and self.binary:
return bool(GLib.find_program_in_path(self.binary))
else:
return None
def serialize(self):
ret = super().serialize()
if self.binary:
ret['binary'] = self.binary
return ret
class MacOSGame(GameData):
def __init__(self,
savegame_root:str,
savegame_dir:str,
variables:dict|None=None,
binary:str|None=None,
file_match:list|None=None,
ignore_match:list|None=None):
GameData.__init__(self,
SavegameType.MACOS,
savegame_root,
savegame_dir,
variables,
file_match,
ignore_match)
self.__binary = binary
@Property
def binary(self)->str|None:
return self.__binary
@binary.setter
def binary(self,bin:str):
self.__binary = bin
@Property
def is_installed(self)->bool|None:
if PLATFORM_LINUX and self.binary:
return bool(GLib.find_program_in_path(self.binary))
else:
return None
def serialize(self):
ret = super().serialize()
if self.binary:
ret['binary'] = self.binary
return ret
class SteamGame(GameData):
def __init__(self,
sgtype:SavegameType,
appid:int,
savegame_root:str,
savegame_dir:str,
variables:dict|None=None,
installdir:str|None=None,
file_match:list|None=None,
ignore_match:list|None=None):
if sgtype not in (SavegameType.STEAM_WINDOWS,
SavegameType.STEAM_LINUX,
SavegameType.STEAM_MACOS):
raise TypeError("SaveGameType")
GameData.__init__(self,
sgtype,
savegame_root,
savegame_dir,
variables,
file_match,
ignore_match)
self.appid = int(appid)
self.installdir = installdir
self.librarydir=None
def get_variables(self):
vars = super().get_variables()
vars["INSTALLDIR"] = self.installdir if self.installdir else ""
vars["STEAM_APPID"] = str(self.appid)
vars["STEAM_LIBDIR"] = self.librarydir if self.librarydir else ""
vars["STEAM_LIBRARY_DIR"] = self.librarydir if self.librarydir else ""
vars["STEAM_COMPATDATA"] = self.compatdata if self.compatdata else ""
return vars
@Property(type=int)
def appid(self):
return self.__appid
@appid.setter
def appid(self,appid):
self.__appid = appid
@Property
def installdir(self):
return self.__installdir
@installdir.setter
def installdir(self,installdir:str|None):
self.__installdir = installdir
@Property
def librarydir(self)->str|None:
if not self.__librarydir and self.installdir:
return str(pathlib.Path(self.installdir).resolve().parent.parent.parent)
return self.__librarydir
@librarydir.setter
def librarydir(self,directory):
if not directory:
self.__librarydir = None
elif not os.path.isdir(directory):
raise ValueError("Steam librarydir is not a valid directory!")
self.__librarydir = directory
@Property
def compatdata(self)->str|None:
libdir = self.librarydir
if libdir:
return str(pathlib.Path(libdir).resolve() / 'steamapps' / 'compatdata')
return None
def serialize(self):
ret = super().serialize()
ret['appid'] = self.appid
if self.installdir:
ret['installdir'] = str(self.installdir) if self.installdir else ""
ret['librarydir'] = str(self.librarydir) if self.librarydir else ""
return ret
class SteamWindowsGame(SteamGame):
def __init__(self,
appid:int,
savegame_root:str,
savegame_dir:str,
variables:dict|None=None,
installdir:str|None=None,
file_match:list|None=None,
ignore_match:list|None=None):
SteamGame.__init__(self,
SavegameType.STEAM_WINDOWS,
appid,
savegame_root,
savegame_dir,
variables,
installdir,
file_match,
ignore_match)
class SteamLinuxGame(SteamGame):
def __init__(self,
appid:int,
savegame_root:str,
savegame_dir:str,
variables:dict|None=None,
installdir:str|None=None,
file_match:list|None=None,
ignore_match:list|None=None):
SteamGame.__init__(self,
SavegameType.STEAM_LINUX,
appid,
savegame_root,
savegame_dir,
variables,
installdir,
file_match,
ignore_match)
class SteamMacOSGame(SteamGame):
def __init__(self,
appid:int,
savegame_root:str,
savegame_dir:str,
variables:dict|None=None,
installdir:str|None=None,
file_match:list|None=None,
ignore_match:list|None=None):
SteamGame.__init__(self,
SavegameType.STEAM_MACOS,
appid,
savegame_root,
savegame_dir,
variables,
installdir,
file_match,
ignore_match)
class SteamPlatformData(GameData):
def __init__(self,
savegame_type:SavegameType,
savegame_root:str,
savegame_dir:str,
variables:dict|None=None,
file_match:str|None=None,
ignore_match:str|None=None,
installdir:str|None=None,
librarydir:str|None=None):
if savegame_type not in (SavegameType.STEAM_WINDOWS,
SavegameType.STEAM_LINUX,
SavegameType.STEAM_MACOS):
raise TypeError("\"savegame_type\" is not valid!")
GameData.__init__(self,
savegame_type=savegame_type,
savegame_root=savegame_root,
savegame_dir=savegame_dir,
variables=variables,
file_match=file_match,
ignore_match=ignore_match)
self.installdir = installdir
self.librarydir = librarydir
@Property(type=str)
def installdir(self)->str:
return self.__installdir if self.__installdir else ""
@installdir.setter
def installdir(self,installdir:str|None):
self.__installdir = installdir
@Property(type=str)
def librarydir(self)->str:
return self.__librarydir if self.__librarydir else ""
@librarydir.setter
def librarydir(self,steam_libdir:str|None):
self.__librarydir = steam_libdir
def serialize(self)->dict:
data = super().serialize()
if self.__installdir:
data['installdir'] = self.__installdir
if self.__librarydir:
data['librarydir'] = self.__librarydir
return data
class SteamWindowsData(SteamPlatformData):
def __init__(self,
savegame_root:str,
savegame_dir:str,
variables:dict|None=None,
file_match:str|None=None,
ignore_match:str|None=None,
installdir:str|None=None,
librarydir:str|None=None):
SteamPlatformData.__init__(self,
savegame_type=SavegameType.STEAM_WINDOWS,
savegame_root=savegame_root,
savegame_dir=savegame_dir,
variables=variables,
file_match=file_match,
ignore_match=ignore_match,
installdir=installdir,
librarydir=librarydir)
class SteamLinuxData(SteamPlatformData):
def __init__(self,
savegame_root:str,
savegame_dir:str,
variables:dict|None=None,
file_match:str|None=None,
ignore_match:str|None=None,
installdir:str|None=None,
librarydir:str|None=None):
SteamPlatformData.__init__(self,
savegame_type=SavegameType.STEAM_LINUX,
savegame_root=savegame_root,
savegame_dir=savegame_dir,
variables=variables,
file_match=file_match,
ignore_match=ignore_match,
installdir=installdir,
librarydir=librarydir)
class SteamMacOSData(SteamPlatformData):
def __init__(self,
savegame_root:str,
savegame_dir:str,
variables:dict|None=None,
file_match:str|None=None,
ignore_match:str|None=None,
installdir:str|None=None,
librarydir:str|None=None):
SteamPlatformData.__init__(self,
savegame_type=SavegameType.STEAM_MACOS,
savegame_root=savegame_root,
savegame_dir=savegame_dir,
variables=variables,
file_match=file_match,
ignore_match=ignore_match,
installdir=installdir,
librarydir=librarydir)
class SteamGameData(GObject):
def __init__(self,appid:int,
windows:SteamWindowsData|None=None,
linux:SteamLinuxData|None=None,
macos:SteamMacOSData|None=None):
GObject.__init__(self)
self.__appid = int(appid)
self.windows = windows
self.linux = linux
self.macos = macos
@Property(type=int)
def appid(self)->int:
return self.__appid
@appid.setter
def appid(self,appid:int):
return self.__appid
@property
def windows(self)->SteamWindowsData|None:
return self.__windows_data
@windows.setter
def windows(self,data:SteamWindowsData|None):
self.__windows_data = data
@property
def linux(self)->SteamLinuxData|None:
return self.__linux_data
@linux.setter
def linux(self,data:SteamLinuxData|None):
self.__linux_data = data
@property
def macos(self)->SteamMacOSData|None:
return self.__macos_data
@macos.setter
def macos(self,data:SteamMacOSData|None):
self.__macos_data = data
@property
def windows_game(self)->SteamWindowsGame|None:
if self.windows:
return SteamWindowsGame(appid=self.appid,
savegame_root=self.windows.savegame_root,
savegame_dir=self.windows.savegame_dir,
variables=self.windows.variables,
installdir=self.windows.installdir,
file_match=self.windows.file_matchers,
ignore_match=self.windows.ignore_matchers)
return None
@property
def linux_game(self)->SteamLinuxGame|None:
if self.linux:
return SteamLinuxGame(appid=self.appid,
savegame_root=self.linux.savegame_root,
savegame_dir=self.linux.savegame_dir,
variables=self.linux.variables,
installdir=self.linux.installdir,
file_match=self.linux.file_matchers,
ignore_match=self.linux.ignore_matchers)
return None
@property
def macos_game(self)->SteamLinuxGame|None:
if self.macos:
return SteamMacOSGame(appid=self.appid,
savegame_root=self.macos.savegame_root,
savegame_dir=self.macos.savegame_dir,
variables=self.macos.variables,
installdir=self.macos.installdir,
file_match=self.macos.file_matchers,
ignore_match=self.macos.ignore_matchers)
return None
def serialize(self)->dict:
data = {
'appid': self.appid,
}
if self.windows:
data['windows'] = self.windows.serialize()
if self.linux:
data['linux'] = self.linux.serialize()
if self.macos:
data['macos'] = self.macos.serialize()
return data
class EpicPlatformData(GameData):
def __init__(self,
savegame_type:SavegameType,
savegame_root:str,
savegame_dir:str,
variables:dict[str:str],
file_match:list[GameFileMatcher],
ignore_match:list[GameFileMatcher],
installdir:str|None):
if savegame_type not in (SavegameType.EPIC_WINDOWS,SavegameType.EPIC_LINUX):
raise ValueError("Savegame type needs to be EPIC_WINDOWS or EPIC_LINUX!")
GameData.__init__(self,
savegame_type=savegame_type,
savegame_root=savegame_root,
savegame_dir=savegame_dir,
variables=variables,
file_match=file_match,
ignore_match=ignore_match)
self.__installdir=installdir
@Property
def installdir(self)->str:
return self.__installdir if self.__installdir else ""
@installdir.setter
def installdir(self,directory:str|None):
self.__installdir = directory
def serialize(self):
data = super().serialize()
if self.__installdir:
data['installdir'] = self.__installdir
return data
class EpicWindowsData(EpicPlatformData):
def __init__(self,
savegame_root:str,
savegame_dir:str,
variables:dict[str:str],
file_match:list[GameFileMatcher],
ignore_match:list[GameFileMatcher],
installdir:str|None):
GameData.__init__(self,
savegame_type=SavegameType.EPIC_WINDOWS,
savegame_root=savegame_root,
savegame_dir=savegame_dir,
variables=variables,
file_match=file_match,
ignore_match=ignore_match,
installdir=installdir)
class EpicGameData(GObject):
def __init__(self,appname:str,windows:EpicWindowsData|None):
GObject.__init__(self)
self.__appname = appname
self.windows = windows
@Property(type=str)
def appname(self)->str:
return self.__appname
@appname.setter
def appname(self,appname:str):
self.__appname = appname
@Property
def windows(self)->EpicWindowsData|None:
return self.__windows
@windows.setter
def windows(self,data:EpicWindowsData|None):
self.__windows = data
@Property(type=bool,default=False)
def is_valid(self)->bool:
if (self.windows and self.windows.is_valid):
return True
def serialize(self):
ret = {
"appname": self.appname
}
if self.windows and self.windows.is_valid:
ret["windows"] = self.windows.serialize()
return ret
class Game(GObject):
__gtype_name__ = "Game"
@staticmethod
def new_from_dict(config:str):
_logger = logger.getChild("Game.new_from_dict()")
def get_file_match(conf:dict):
conf_fm = conf['file_match'] if 'file_match' in conf else []
conf_im = conf['ignore_match'] if 'ignore_match' in conf else []
if (conf_fm):
file_match = []
for cfm in conf_fm:
if ('type' in cfm and 'match' in cfm):
try:
file_match.append(GameFileMatcher(GameFileType.from_string(cfm['type']),cfm['match']))
except Exception as ex:
_logger.error("Adding GameFileMatcher to file_match failed! ({})!".format(ex))
else:
_logger.error("Illegal file_match settings! (\"type\" or \"match\" missing!)")
else:
file_match = None
if (conf_im):
ignore_match = []
for cim in conf_im:
if ('type' in cim and 'match' in cim):
try:
ignore_match.append(GameFileMatcher(GameFileType.from_string(cim['type']),cim['match']))
except Exception as ex:
_logger.error("Adding GameFileMatcher to ignore_match failed! ({})!".format(ex))
else:
_logger.error("Illegal ignore_match settings! (\"type\" or \"match\" missing!)")
else:
ignore_match = None
return (file_match,ignore_match)
def new_steamdata(conf)->SteamGameData|None:
def new_steam_platform_data(data:dict,cls:SteamPlatformData)->SteamPlatformData|None:
if not 'savegame_root' in data or not 'savegame_dir' in data:
return None
file_match,ignore_match = get_file_match(data)
return cls(
savegame_root=data['savegame_root'],
savegame_dir=data['savegame_dir'],
variables=dict(((v['name'],v['value']) for v in data['variables'])) if ('variables' in data and config['variables']) else None,
file_match=file_match,
ignore_match=ignore_match,
installdir=data['installdir'] if ('installdir' in data and data['installdir']) else None,
librarydir=data['librarydir'] if ('librarydir' in data and data['librarydir']) else None
)
if ('steam' not in conf or not 'appid' in conf['steam']):
return None
steam=conf['steam']
if 'windows' in steam:
windows = new_steam_platform_data(steam['windows'],SteamWindowsData)
else:
windows = None
if 'linux' in steam:
linux = new_steam_platform_data(steam['linux'],SteamLinuxData)
else:
linux = None
if 'macos' in steam:
macos = new_steam_platform_data(steam['macos'],SteamMacOSData)
else:
macos = None
if windows is None and linux is None and macos is None:
return None
return SteamGameData(steam['appid'],
windows=windows,
linux=linux,
macos=macos)
# new_steamdata()
def new_epic_data(conf:dict):
def new_epic_platform_data(data,cls):
if not 'savegame_root' in data or not 'savegame_dir' in data:
return None
file_match,ignore_match = get_file_match(data)
return cls(
savegame_root=data['savegame_root'],
savegame_dir=data['savegame_dir'],
variables=dict(((v['name'],v['value']) for v in data['variables'])) if ('variables' in data and config['variables']) else None,
file_match=file_match,
ignore_match=ignore_match,
installdir=data['installdir'] if ('installdir' in data and data['installdir']) else None,
librarydir=data['librarydir'] if ('librarydir' in data and data['librarydir']) else None
)
if not "epic" in conf or not "appname" in conf["epic"]:
return None
if ("windows" in conf['epic']):
windows = new_epic_platform_data(conf['windows'],EpicWindowsData)
else:
windows = None
return EpicGameData(conf['epic']['appname'],
windows=windows)
# new_epic_data()
if not 'key' in config or not 'name' in config:
return None
dbid = config['dbid'] if 'dbid' in config else None
key = config['key']
name = config['name']
sgname = config['savegame_name'] if 'savegame_name' in config else key
sgtype = SavegameType.from_string(config['savegame_type']) if 'savegame_type' in config else SavegameType.UNSET
game = Game(key,name,sgname)
if dbid:
game.dbid = dbid
game.savegame_type = sgtype
game.is_active = config['is_active'] if 'is_active' in config else False
game.is_live = config['is_live'] if 'is_live' in config else True
if 'windows' in config:
winconf = config['windows']
sgroot = winconf['savegame_root'] if 'savegame_root' in winconf else None
sgdir = winconf['savegame_dir'] if 'savegame_dir' in winconf else None
vars = winconf['variables'] if 'variables' in winconf else {}
installdir = winconf['installdir'] if 'installdir' in winconf else None
game_regkeys = winconf['game_registry_keys'] if 'game_registry_keys' in winconf else []
installdir_regkeys = winconf['installdir_registry_keys'] if 'installdir_registry_keys' in winconf else []
file_match,ignore_match = get_file_match(winconf)
game.windows = WindowsGame(sgroot,
sgdir,
vars,
installdir,
game_regkeys,
installdir_regkeys,
file_match,
ignore_match)
if 'linux' in config:
linconf = config['linux']
sgroot = linconf['savegame_root'] if 'savegame_root' in linconf else None
sgdir = linconf['savegame_dir'] if 'savegame_dir' in linconf else None
vars = linconf['variables'] if 'variables' in linconf else {}
binary = linconf['binary'] if 'binary' in linconf else None
file_match,ignore_match = get_file_match(linconf)
game.linux = LinuxGame(sgroot,sgdir,vars,binary,file_match,ignore_match)
if 'macos' in config:
macconf = config['macos']
sgroot = macconf['savegame_root'] if 'savegame_root' in macconf else None
sgdir = macconf['savegame_dir'] if 'savegame_dir' in macconf else None
vars = macconf['variables'] if 'variables' in macconf else {}
binary = macconf['binary'] if 'binary' in macconf else None
file_match,ignore_match = get_file_match(macconf)
game.macos = MacOSGame(sgroot,sgdir,vars,binary,file_match,ignore_match)
game.steam = new_steamdata(config)
game.epic = new_epic_data(config)
return game
@staticmethod
def new_from_json_file(filename:str):
if not os.path.isfile(filename):
raise FileNotFoundError("Filename \"{filename}\" not found!".format(filename=filename))
with open(filename,'rt',encoding="UTF-8") as ifile:
x=json.loads(ifile.read())
game = Game.new_from_dict(x)
if game is not None:
game.filename = filename
return game
def __init__(self,key:str,name:str,savegame_name:str):
GObject.__init__(self)
self.__dbid = None
self.__key = key
self.__name = name
self.__filename = None
self.__savegame_name = savegame_name
self.__savegame_type = SavegameType.UNSET
self.__active = False
self.__live = True
self.__variables = dict()
self.__windows = None
self.__linux = None
self.__macos = None
self.__steam = None
self.__epic = None
self.__gog = None
self.__epic = None
@Property(type=str)
def dbid(self)->str:
return self.__dbid
@dbid.setter
def dbid(self,id:str):
self.__dbid = id
@Property(type=str)
def key(self)->str:
return self.__key
@key.setter
def key(self,key:str):
if self.__key and self.__key != key:
self._old_key = self.__key
self.__key = key
@Property(type=str)
def name(self)->str:
return self.__name
@name.setter
def name(self,name:str):
self.__name = name
@Property(type=str)
def savegame_name(self)->str:
return self.__savegame_name
@savegame_name.setter
def savegame_name(self,sgname:str):
self.__savegame_name = sgname
@Property
def savegame_type(self)->SavegameType:
return self.__savegame_type
@savegame_type.setter
def savegame_type(self,sgtype:SavegameType):
self.__savegame_type = sgtype
@Property(type=bool,default=False)
def is_active(self)->bool:
return self.__active
@is_active.setter
def is_active(self,active:bool):
self.__active = bool(active)
@Property(type=bool,default=True)
def is_live(self)->bool:
return self.__live
@is_live.setter
def is_live(self,live:bool):
self.__live = bool(live)
@Property
def filename(self)->str|None:
if not self.__filename:
if not self.key:
return None
return os.path.join(settings.gameconf_dir,'.'.join((self.key,'gameconf')))
return self.__filename
@filename.setter
def filename(self,fn:str):
if self.__filename and fn != self.__filename and os.path.isfile(self.__filename):
self.__old_filename = self.__filename
if not os.path.isabs(fn):
self.__filename = os.path.join(settings.gameconf_dir,fn)
else:
self.__filename = fn
@Property
def variables(self):
return self.__variables
@variables.setter
def variables(self,vars:dict|None):
if not vars:
self.__variables = {}
else:
self.__variables = dict(vars)
@Property(type=str)
def subdir(self):
if self.is_live:
return "live"
else:
return "finished"
@Property
def game_data(self):
sgtype = self.savegame_type
if (sgtype == SavegameType.WINDOWS):
return self.windows
elif (sgtype == SavegameType.LINUX):
return self.linux
elif (sgtype == SavegameType.MACOS):
return self.macos
elif (sgtype == SavegameType.STEAM_WINDOWS):
if self.steam:
return self.steam.windows_game
elif (sgtype == SavegameType.STEAM_LINUX):
if self.steam:
return self.steam.linux_game
elif (sgtype == SavegameType.STEAM_MACOS):
if self.steam:
return self.steam.macos_game
#elif (sgtype == SavegameType.GOG_WINDOWS):
# return self.__gog_windows
#elif (sgtype == SavegameType.GOG_LINUX):
# return self.__gog_linux
elif (sgtype == SavegameType.EPIC_WINDOWS):
if self.epic:
return self.__epic.windows
elif (sgtype == SavegameType.EPIC_LINUX):
return None
return None
@Property
def windows(self)->WindowsGame|None:
return self.__windows
@windows.setter
def windows(self,data:WindowsGame|None):
if not data:
self.__windows = None
else:
if not isinstance(data,WindowsGame):
raise TypeError("WindowsGame")
self.__windows = data
@Property
def linux(self)->LinuxGame|None:
return self.__linux
@linux.setter
def linux(self,data:LinuxGame):
if not data:
self.__linux = None
else:
if not isinstance(data,LinuxGame):
raise TypeError("LinuxGame")
self.__linux = data
@Property
def macos(self)->MacOSGame|None:
return self.__macos
@macos.setter
def macos(self,data:MacOSGame|None):
if not data:
self.__macos = None
else:
if not isinstance(data,MacOSGame):
raise TypeError("MacOSGame")
self.__macos = data
@Property
def steam(self)->SteamGameData|None:
return self.__steam
@steam.setter
def steam(self,steam_data:SteamGameData|None):
if (not steam_data):
self.__steam = steam_data
return
if (not isinstance(steam_data,SteamGameData)):
raise TypeError("\"steam\" is not \"None\" or a \"SteamGameData\" instance!")
self.__steam = steam_data
@Property
def steam_windows(self)->SteamWindowsGame|None:
if self.steam:
return self.steam.windows_game
return None
@Property
def steam_linux(self)->SteamLinuxGame|None:
if self.steam:
return self.steam.linux_game
return None
@Property
def steam_macos(self)->SteamMacOSGame|None:
if self.steam:
return self.steam.macos_game
return None
@Property
def savegame_root(self)->str|None:
if not self.game_data:
return None
t = Template(self.game_data.savegame_root)
return t.safe_substitute(self.get_variables())
@Property
def savegame_dir(self)->str|None:
if not self.game_data:
return None
t = Template(self.game_data.savegame_dir)
return t.safe_substitute(self.get_variables())
@Property
def epic(self)->EpicGameData|None:
return self.__epic
@epic.setter
def epic(self,epic:EpicGameData|None):
self.__epic = epic
def add_variable(self,name:str,value:str):
self.__variables[str(name)] = str(value)
def delete_variable(self,name):
if name in self.__variables:
del self.__variables[name]
def get_variables(self):
vars = settings.get_variables()
vars.update(self.__variables)
game_data = self.game_data
if game_data is not None:
vars.update(game_data.get_variables())
return vars
def get_variable(self,name):
try:
return self.get_variables()[name]
except:
return ""
def serialize(self)->dict:
ret = {
'key': self.key,
'name': self.name,
'savegame_name': self.savegame_name,
'savegame_type': self.savegame_type.value,
'is_active': self.is_active,
'is_live': self.is_live,
}
if self.dbid:
ret['dbid'] = self.dbid
if (self.windows and self.windows.is_valid):
ret['windows'] = self.windows.serialize()
if (self.linux and self.linux.is_valid):
ret['linux'] = self.linux.serialize()
if (self.macos and self.macos.is_valid):
ret['macos'] = self.macos.serialize()
if (self.steam):
ret['steam'] = self.steam.serialize()
if (self.epic and self.epic.is_valid):
ret['epic'] = self.epic.serialize()
#if self.gog_windows:
# ret['gog_windows'] = self.gog_windows.serialize()
#if self.gog_linux:
# ret['gog_linux'] = self.gog_linux.serialize()
return ret
def save(self):
path = pathlib.Path(self.filename).resolve() if self.filename else None
if path is None:
logger.error("No filename for saving the game \"{game}\" set! Not saving file!".format(game=self.name))
return
if hasattr(self,'__old_filename'):
old_path = pathlib.Path(self.__old_filename).resolve()
if old_path.is_file():
os.unlink(old_path)
delattr(self,'__old_filename')
if not path.parent.is_dir():
os.makedirs(path.parent)
with open(path,'wt',encoding='utf-8') as ofile:
ofile.write(json.dumps(self.serialize(),ensure_ascii=False,indent=4))
gm = GameManager.get_global()
if hasattr(self,'_old_key'):
if self._old_key in gm.games:
del gm.games[self._old_key]
delattr(self,'_old_key')
gm.add_game(self)
def __bool__(self):
return (bool(self.game_data) and bool(self.savegame_root) and bool(self.savegame_dir))
def get_backup_files(self)->dict[str:str]|None:
def get_backup_files_recursive(sgroot:pathlib.Path,sgdir:str,subdir:str|None=None):
if subdir:
path = sgroot / sgdir / subdir
else:
path = sgroot / sgdir
ret = {}
for dirent in os.listdir(path):
file_path = path / dirent
if file_path.is_file():
if subdir:
fname = os.path.join(subdir,dirent).replace("\\","/")
else:
fname = dirent
if self.game_data.match(fname):
ret[str(file_path)] = os.path.join(sgdir,fname)
elif file_path.is_dir():
if subdir:
ret.update(get_backup_files_recursive(sgroot,sgdir,os.path.join(subdir,dirent)))
else:
ret.update(get_backup_files_recursive(sgroot,sgdir,dirent))
return ret
if not bool(self):
return None
sgroot = pathlib.Path(self.savegame_root).resolve()
sgdir = self.savegame_dir
sgpath = sgroot / sgdir
if not os.path.exists(sgpath):
return None
return get_backup_files_recursive(sgroot,sgdir)
@Property(type=str)
def savegame_subdir(self)->str:
"""
savegame_subdir The subdir for the savegame backup.
If `is_live` results to `True`, *"live"* is returned. Else *"finished"* is returned.
:type: str
"""
if (self.is_live):
return "live"
return "finished"
class GameManager(GObject):
__global_gamemanager = None
logger = logger.getChild('GameManager')
@staticmethod
def get_global():
if GameManager.__global_gamemanager is None:
GameManager.__global_gamemanager = GameManager()
return GameManager.__global_gamemanager
def __init__(self):
GObject.__init__(self)
self.__games = {}
self.__steam_games = {}
self.__epic_games = {}
self.load()
@Property(type=object)
def games(self)->dict[str:Game]:
return self.__games
@Property
def steam_games(self)->dict[int:Game]:
return self.__steam_games
def load(self):
if self.__games:
self.__games = {}
gameconf_dir = settings.gameconf_dir
if not os.path.isdir(gameconf_dir):
return
for gcf in (os.path.join(gameconf_dir,i) for i in os.listdir(gameconf_dir)):
if not os.path.isfile(gcf) or not gcf.endswith('.gameconf'):
continue
try:
game = Game.new_from_json_file(gcf)
if not game:
self.logger.warn("Not loaded game \"{game}\"!".format(
game=(game.name if game is not None else "UNKNOWN GAME")))
continue
except GLib.Error as ex: #Exception as ex:
self.logger.error("Unable to load gameconf {gameconf}! ({what})".format(
gameconf = os.path.basename(gcf),
what = str(ex)))
continue
self.add_game(game)
def add_game(self,game:Game):
self.__games[game.key] = game
if game.steam:
self.__steam_games[game.steam.appid] = game
if game.epic:
self.__epic_games[game.epic.appname] = game
def remove_game(self,game:Game|str):
if isinstance(game,str):
if key not in self.__games:
return
key = game
game = self.__games[key]
elif isinstance(game,Game):
if game.key not in self.__games:
return
key = game.key
for appid,steam_game in list(self.__steam_games.items()):
if steam_game.key == game.key:
del self.__steam_games[appid]
for appname,epic_game in list(self.__epic_games.items()):
if epic_game.key == game.key:
del self.__epic_games[appname]
del self.__games[key]