diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d283d9e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +* text=auto + +.githooks/pre-commit text eol=lf + +*.yml text eol=lf +*.sh text eol=lf +*.py text +*.txt text +*.ps1 text eol=crlf +*.ps1.in text eol=crlf +*.bat text eol=crlf +*.bat.in text eol=crlf +*.cmd text eol=crlf +*.cmd.in text eol=crlf + diff --git a/.githooks/pre-commit b/.githooks/pre-commit index e5ce2ff..bf221e5 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -7,6 +7,7 @@ PROJECT_ROOT="$(dirname "$(dirname "$SELF")")" ; export PROJECT_ROOT pre_commit_d="${GITHOOKS_DIR}/pre-commit-d" +# run scripts from the pre-commit.d directory for i in $(ls "$pre_commit_d"); do script="${pre_commit_d}/$i" if [ -x "$script" ]; then diff --git a/sgbackup/command.py b/sgbackup/command.py index 21710f5..f59c2a3 100644 --- a/sgbackup/command.py +++ b/sgbackup/command.py @@ -17,6 +17,7 @@ # along with this program. If not, see . # ############################################################################### + class Command: def __init__(self,id:str,name:str,description:str): self.__id = id diff --git a/sgbackup/game.py b/sgbackup/game.py index b8e9be5..864e2e5 100644 --- a/sgbackup/game.py +++ b/sgbackup/game.py @@ -107,6 +107,8 @@ class GameFileType(StrEnum): raise ValueError("Unknown GameFileType \"{}\"!".fomrat(typestring)) class GameFileMatcher(GObject): + __gtype_name__ = "GameFileMatcher" + def __init__(self,match_type:GameFileType,match_file:str): GObject.__init__(self) self.match_type = type @@ -165,6 +167,8 @@ class GameFileMatcher(GObject): return False class GameData(GObject): + __gtype_name__ = 'GameData' + """ :class: GameData :brief: Base class for platform specific data. @@ -226,17 +230,41 @@ class GameData(GObject): self.__savegame_dir = sgdir @Property - def variables(self): + def variables(self)->dict: return self.__variables + @variables.setter + def variables(self,vars:dict|None): + if not vars: + self.__variables = {} + else: + self.__variables = dict(vars) @Property def file_match(self): return self.__filematch + @file_match.setter + def file_match(self,fm:list[GameFileMatcher]|None): + if not fm: + self.__filematch = [] + else: + for matcher in fm: + if not isinstance(matcher,GameFileMatcher): + raise TypeError("\"file_match\" needs to be \"None\" or a list of \"GameFileMatcher\" instances!") + self.__filematch = list(fm) @Property def ignore_match(self): return self.__ignorematch - + @file_match.setter + def file_match(self,im:list[GameFileMatcher]|None): + if not im: + self.__ignorematch = [] + else: + for matcher in im: + if not isinstance(matcher,GameFileMatcher): + raise TypeError("\"ignore_match\" needs to be \"None\" or a list of \"GameFileMatcher\" instances!") + self.__ignorematch = list(im) + def has_variable(self,name:str)->bool: return (name in self.__variables) @@ -535,18 +563,25 @@ class SteamGame(GameData): GameData.__init__(self, sgtype, - appid, savegame_root, savegame_dir, variables, file_match, ignore_match) - self.__installdir = installdir + self.appid = int(appid) + self.installdir = installdir def get_variables(self): vars = super().get_variables() vars["INSTALLDIR"] = self.installdir if self.installdir else "" + @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 @@ -622,6 +657,8 @@ class SteamMacOSGame(SteamGame): class Game(GObject): + __gtype_name__ = "Game" + @staticmethod def new_from_dict(config:str): logger = logger.getChild("Game.new_from_dict()") @@ -740,15 +777,17 @@ class Game(GObject): with open(filename,'rt',encoding="UTF-8") as ifile: return Game.new_from_dict(json.loads(ifile.read())) - def __init__(self,id:str,name:str,savegame_name:str): + def __init__(self,key:str,name:str,savegame_name:str): GObject.__init__(self) - self.__id = id + 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 @@ -762,12 +801,25 @@ class Game(GObject): self.__epic_linux = None @Property(type=str) - def id(self)->str: + def dbid(self)->str: return self.__id - @id.setter + @dbid.setter def id(self,id:str): self.__id = id + @Property(type=str) + def key(self)->str: + return self.__key + @key.setter + def key(self,key:str): + set_game = False + if self.__key in GAMES: + del GAMES[self.__key] + set_game = True + + self.__key = key + if set_game: + GAMES[self.__key] = self @Property(type=str) def name(self)->str: @@ -820,6 +872,16 @@ class Game(GObject): 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 def game_data(self): sgtype = self.savegame_type @@ -917,6 +979,22 @@ class Game(GObject): raise TypeError("SteamWindowsGame") self.__steam_macos = data + 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_variable(self,name): + vars = dict(os.environ) + #vars.update(settings.variables) + vars.update(self.__variables) + game_data = self.game_data + if (game_data is not None): + vars.update(game_data.variables) + + def serialize(self)->dict: ret = { 'id': self.id, @@ -949,9 +1027,8 @@ class Game(GObject): # ret['epic_linux'] = self.epic_linux.serialize() return ret - + def save(self): - data = self.serialize() old_path = pathlib.Path(self.filename).resolve() new_path = pathlib.Path(settings.gameconf_dir / '.'.join(self.id,'gameconf')).resolve() if (str(old_path) != str(new_path)) and old_path.is_file(): @@ -959,8 +1036,10 @@ class Game(GObject): if not new_path.parent.is_dir(): os.makedirs(new_path.parent) - with open(new_path,'wt',encoding='UTF-8') as ofile: + with open(new_path,'wt',encoding='utf-8') as ofile: ofile.write(json.dumps(self.serialize(),ensure_ascii=False,indent=4)) + + GAMES={} STEAM_GAMES={} @@ -973,7 +1052,7 @@ def __init_games(): if not os.path.isdir(gameconf_dir): return - for gcf in (os.path.join(gameconf_dir,i) for i in os.path.listdir(gameconf_dir)): + 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 @@ -984,7 +1063,7 @@ def __init_games(): except: continue - GAMES[game.id] = game + GAMES[game.key] = game if (game.steam_windows): if not game.steam_windows.appid in STEAM_GAMES: STEAM_GAMES[game.steam_windows.appid] = game @@ -1000,7 +1079,7 @@ def __init_games(): __init_games() def add_game(game:Game): - GAMES[game.id] = game + GAMES[game.key] = game if game.steam_windows: if not game.steam_windows.appid in STEAM_GAMES: STEAM_GAMES[game.steam_windows.appid] = game @@ -1012,4 +1091,5 @@ def add_game(game:Game): if (game.steam_macos): if not game.steam_macos.appid in STEAM_GAMES: STEAM_GAMES[game.steam_macos.appid] = game - STEAM_MACOS_GAMES[game.steam_macos.appid] = game \ No newline at end of file + STEAM_MACOS_GAMES[game.steam_macos.appid] = game + \ No newline at end of file diff --git a/sgbackup/gui/__init__.py b/sgbackup/gui/__init__.py index 9049356..ee4060d 100644 --- a/sgbackup/gui/__init__.py +++ b/sgbackup/gui/__init__.py @@ -15,4 +15,9 @@ # You should have received a copy of the GNU General Public License # # along with this program. If not, see . # ############################################################################### -from .application import Application + +from ._app import Application,AppWindow +from ._settingsdialog import SettingsDialog +from ._gamedialog import GameDialog + +app = None diff --git a/sgbackup/gui/_app.py b/sgbackup/gui/_app.py new file mode 100644 index 0000000..d81c401 --- /dev/null +++ b/sgbackup/gui/_app.py @@ -0,0 +1,448 @@ +############################################################################### +# sgbackup - The SaveGame Backup tool # +# Copyright (C) 2024 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 . # +############################################################################### + +from gi.repository import Gtk,GObject,Gio,Gdk + +import logging; logger=logging.getLogger(__name__) + +import os +from datetime import datetime as DateTime +from pathlib import Path + +from .. import game +from ..settings import settings +from ._settingsdialog import SettingsDialog +from ._gamedialog import GameDialog + +class GameView(Gtk.ScrolledWindow): + __gtype_name__ = "sgbackup-gui-GameView" + + def __init__(self): + Gtk.ScrolledWindow.__init__(self) + + self.__liststore = Gio.ListStore.new(game.Game) + for g in game.GAMES.values(): + pass + self.__liststore.append(g) + + factory_key = Gtk.SignalListItemFactory.new() + factory_key.connect('setup',self._on_key_column_setup) + factory_key.connect('bind',self._on_key_column_bind) + column_key = Gtk.ColumnViewColumn.new("Key",factory_key) + + factory_name = Gtk.SignalListItemFactory.new() + factory_name.connect('setup',self._on_name_column_setup) + factory_name.connect('bind',self._on_name_column_bind) + column_name = Gtk.ColumnViewColumn.new("Name",factory_name) + column_name.set_expand(True) + + factory_active = Gtk.SignalListItemFactory.new() + factory_active.connect('setup',self._on_active_column_setup) + factory_active.connect('bind',self._on_active_column_bind) + factory_active.connect('unbind',self._on_active_column_unbind) + column_active = Gtk.ColumnViewColumn.new("Active",factory_active) + + factory_live = Gtk.SignalListItemFactory.new() + factory_live.connect('setup',self._on_live_column_setup) + factory_live.connect('bind',self._on_live_column_bind) + factory_live.connect('unbind',self._on_live_column_unbind) + column_live = Gtk.ColumnViewColumn.new("Live",factory_live) + + selection = Gtk.SingleSelection.new(self._liststore) + self.__columnview = Gtk.ColumnView.new(selection) + self._columnview.append_column(column_key) + self._columnview.append_column(column_name) + self._columnview.append_column(column_active) + self._columnview.append_column(column_live) + self._columnview.set_single_click_activate(True) + + self.set_child(self._columnview) + + @property + def _liststore(self): + return self.__liststore + + @property + def _columnview(self): + return self.__columnview + + def _on_key_column_setup(self,factory,item): + item.set_child(Gtk.Label()) + + def _on_key_column_bind(self,factory,item): + label = item.get_child() + game = item.get_item() + label.bind_property(game,'key','label',GObject.BindingFlags.DEFAULT) + + def _on_name_column_setup(self,factory,item): + item.set_child(Gtk.Label()) + + def _on_name_column_bind(self,factory,item): + label = item.get_child() + game = item.get_item() + label.bind_proprety(game,'name','label',GObject.BindingFlags.DEFAULT) + + def _on_active_column_setup(self,factory,item): + item.set_child(Gtk.Switch()) + + def _on_active_column_bind(self,factory,item): + switch = item.get_child() + game = item.get_data() + switch.set_active(game.is_active) + item._signal_active_state_set = switch.connect('state-set',self._on_active_state_set,game) + + def _on_active_column_unbind(self,factory,item): + if hasattr(item,'_signal_active_state_set'): + item.get_child().disconnect(item._signal_active_state_set) + del item._signal_active_state_set + + def _on_active_state_set(self,switch,state,game): + game.is_active = state + game.save() + + def _on_live_column_setup(self,factory,item): + item.set_child(Gtk.Switch()) + + def _on_live_column_bind(self,factory,item): + switch = item.get_child() + game = item.get_data() + switch.set_active(game.is_active) + item._signal_live_state_set = switch.connect('state-set',self._on_live_state_set,game) + + def _on_live_column_unbind(self,factory,item): + if hasattr(item,'_signal_live_state_set'): + item.get_child().disconnect(item._signal_live_state_set) + del item._signal_live_state_set + + def _on_live_state_set(self,switch,state,game): + def on_dialog_response(dialog,response): + if response == Gtk.ResponseType.YES: + pass + #archiver.backup(game) + dialog.hide() + dialog.destroy() + + game.is_live = state + game.save() + if not state: + dialog = Gtk.MessageDialog() + dialog.set_transient_for(self.get_toplevel()) + dialog.props.buttons = Gtk.ButtonsType.YES_NO + dialog.props.text = "Do you want to create a new savegame for {game}?".format(game=game.name) + dialog.props.use_markup = True + dialog.props.secondary_text = "The new savegame is added to the finsihed savegames for the game." + dialog.props.secondary_use_markup = False + dialog.connect('response',on_dialog_response) + dialog.present() +# GameView class + +class BackupViewData(GObject.GObject): + def __init__(self,_game:game.Game,filename:str): + GObject.GObject.__init__(self) + self.__game = _game + self.__filename = filename + + basename = os.path.basename(filename) + self.__is_live = (os.path.basename(os.path.dirname(filename)) == 'live') + parts = filename.split('.') + self.__savegame_name = parts[0] + self.__timestamp = DateTime.strptime(parts[1],"%Y%m%d-%H%M%S") + + self.__extension = '.' + parts[3:] + + @property + def game(self)->game.Game: + return self.__game + + @GObject.Property + def savegame_name(self): + return self.__savegame_name + + @GObject.Property(type=str) + def filename(self)->str: + return self.__filename + + @GObject.Property(type=bool,default=False) + def is_live(self)->bool: + pass + + @GObject.Property + def extension(self): + return self.__extension + + @GObject.Property + def timestamp(self): + return self.__timestamp + +class BackupView(Gtk.ScrolledWindow): + __gtype_name__ = "BackupView" + def __init__(self,gameview:GameView): + Gtk.ScrolledWindow.__init__(self) + self.__gameview = gameview + + self.__liststore = Gio.ListStore() + selection = Gtk.SingleSelection.new(self.__liststore) + + live_factory = Gtk.SignalListItemFactory() + live_factory.connect('setup',self._on_live_column_setup) + live_factory.connect('bind',self._on_live_column_bind) + live_column = Gtk.ColumnViewColumn.new("Live",live_factory) + + sgname_factory = Gtk.SignalListItemFactory() + sgname_factory.connect('setup',self._on_savegamename_column_setup) + sgname_factory.connect('bind',self._on_savegamename_column_bind) + sgname_column = Gtk.ColumnViewColumn.new("Savegame name",sgname_factory) + sgname_column.set_expand(True) + + timestamp_factory = Gtk.SignalListItemFactory() + timestamp_factory.connect('setup',self._on_timestamp_column_setup) + timestamp_factory.connect('bind',self._on_timestamp_column_bind) + timestamp_column = Gtk.ColumnViewColumn.new("Timestamp",timestamp_factory) + + self.__columnview = Gtk.ColumnView.new(selection) + self.__columnview.append_column(live_column) + self.__columnview.append_column(sgname_column) + self.__columnview.append_column(timestamp_column) + + self._on_gameview_selection_changed(selection) + self.gameview._columnview.get_model().connect('selection-changed',self._on_gameview_selection_changed) + + self.set_child(self.__columnview) + + @property + def gameview(self)->GameView: + return self.__gameview + + def _on_live_column_setup(self,factory,item): + checkbutton = Gtk.CheckButton() + checkbutton.set_sensitive(False) + + def _on_live_column_bind(self,factory,item): + checkbutton = item.get_child() + data = item.get_item() + checkbutton.set_active(data.is_live) + + + def _on_savegamename_column_setup(self,factory,item): + label = Gtk.Label() + self.set_child(label) + + def _on_savegamename_column_bind(self,factory,item): + label = item.get_child() + data = item.get_item() + label.set_text(data.savegame_name) + + def _on_timestamp_column_setup(self,factory,item): + label = Gtk.Label() + item.set_child(label) + + def _on_timestamp_column_bind(self,factory,item): + label = item.get_child() + data = item.get_item() + label.set_text(data.timestamp.strftime("%d.%m.%Y %H:%M:%S")) + + def _on_size_column_setup(self,factory,item): + label = Gtk.Label() + item.set_child(label) + + def _on_size_column_bind(self,factory,item): + label = item.get_child() + data = item.get_item() + file = Path(data.filename).resolve() + + if not file.is_file(): + label.set_text("0 B") + return + + size = file.stat().st_size + if (size > 1073741824): + display_size = ".".join((str(int(size / 1073741824)),str(int(((size * 10) / 1073741824) % 10)))) + " GiB" + elif (size > 1048576): + display_size = ".".join((str(int(size / 1048576)), str(int(((size * 10) / 1048576) % 10)))) + " MiB" + elif (size > 1024): + display_size = ".".join((str(int(size / 1024)), str(int(((size * 10) / 1024) % 10)))) + " KiB" + else: + display_size = str(size) + " B" + label.set_text(display_size) + + def _on_gameview_selection_changed(self,model): + game = model.get_selected_item() + if game is None: + return + + +class AppWindow(Gtk.ApplicationWindow): + __gtype_name__ = "AppWindow" + def __init__(self,application=None,**kwargs): + kwargs['title'] = "SGBackup" + + if (application is not None): + kwargs['application']=application + if (hasattr(application,'builder')): + builder = application.builder + else: + builder = Gtk.Builder.new() + + Gtk.ApplicationWindow.__init__(self,**kwargs) + self.set_default_size(800,600) + self.set_icon_name('sgbackup') + + self.__builder = builder + self.builder.add_from_file(os.path.join(os.path.dirname(__file__),'appmenu.ui')) + gmenu = self.builder.get_object('appmenu') + appmenu_popover = Gtk.PopoverMenu.new_from_model(gmenu) + image = Gtk.Image.new_from_icon_name('open-menu-symbolic') + menubutton = Gtk.MenuButton.new() + menubutton.set_popover(appmenu_popover) + menubutton.set_child(image) + headerbar = Gtk.HeaderBar.new() + headerbar.pack_start(menubutton) + self.set_titlebar(headerbar) + + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + + self.__vpaned = Gtk.Paned(orientation=Gtk.Orientation.VERTICAL) + self.__vpaned.set_hexpand(True) + self.__vpaned.set_vexpand(True) + self.__vpaned.set_wide_handle(True) + self.__gameview = GameView() + self.__vpaned.set_start_child(self.gameview) + self.__backupview = BackupView(self.gameview) + self.__vpaned.set_end_child(self.backupview) + self.__vpaned.set_resize_start_child(True) + self.__vpaned.set_resize_end_child(True) + + vbox.append(self.__vpaned) + + statusbar = Gtk.Statusbar() + statusbar.set_hexpand(True) + statusbar.set_vexpand(False) + statusbar.push(0,'Running ...') + vbox.append(statusbar) + + self.set_child(vbox) + + @GObject.Property + def builder(self): + return self.__builder + + @GObject.Property + def backupview(self): + return self.__backupview + + @GObject.Property + def gameview(self): + return self.__gameview + + +class Application(Gtk.Application): + __gtype_name__ = "Application" + + def __init__(self,*args,**kwargs): + AppFlags = Gio.ApplicationFlags + kwargs['application_id'] = 'org.sgbackup.sgbackup' + kwargs['flags'] = AppFlags.FLAGS_NONE + Gtk.Application.__init__(self,*args,**kwargs) + + self.__logger = logger.getChild('Application') + self.__builder = None + self.__appwindow = None + + @property + def _logger(self): + return self.__logger + + @GObject.Property + def appwindow(self): + return self.__appwindow + + def do_startup(self): + self._logger.debug('do_startup()') + if not self.__builder: + self.__builder = Gtk.Builder.new() + Gtk.Application.do_startup(self) + + pkg_path = Path(__file__).resolve() + pkg_path = pkg_path.parent.parent + icons_path = pkg_path / "icons" + + theme = Gtk.IconTheme.get_for_display(Gdk.Display.get_default()) + theme.add_resource_path("/org/sgbackup/sgbackup/icons") + theme.add_search_path(str(icons_path)) + + action_about = Gio.SimpleAction.new('about',None) + action_about.connect('activate',self.on_action_about) + self.add_action(action_about) + + action_new_game = Gio.SimpleAction.new('new-game',None) + action_new_game.connect('activate',self.on_action_new_game) + self.add_action(action_new_game) + + action_quit = Gio.SimpleAction.new('quit',None) + action_quit.connect('activate',self.on_action_quit) + self.add_action(action_quit) + + action_settings = Gio.SimpleAction.new('settings',None) + action_settings.connect('activate',self.on_action_settings) + self.add_action(action_settings) + + # add accels + self.set_accels_for_action('app.quit',["q"]) + + @GObject.Property + def builder(self): + return self.__builder + + def do_activate(self): + self._logger.debug('do_activate()') + if not (self.__appwindow): + self.__appwindow = AppWindow(application=self) + + + self.appwindow.present() + + def on_action_about(self,action,param): + pass + + def on_action_settings(self,action,param): + dialog = self.new_settings_dialog() + dialog.present() + + def on_action_quit(self,action,param): + self.quit() + + def on_action_new_game(self,action,param): + def on_reponse(dialog,response): + dialog.destroy() + + dialog = GameDialog(self.appwindow) + dialog.connect('response',on_reponse) + dialog.present() + + def new_settings_dialog(self): + dialog = SettingsDialog(self.appwindow) + self.emit('settings-dialog-init',dialog) + return dialog + + @GObject.Signal(name='settings-dialog-init', + flags=GObject.SignalFlags.RUN_LAST, + return_type=None, + arg_types=(SettingsDialog,)) + def settings_dialog_init(self,dialog): + pass + \ No newline at end of file diff --git a/sgbackup/gui/_gamedialog.py b/sgbackup/gui/_gamedialog.py new file mode 100644 index 0000000..7f166d0 --- /dev/null +++ b/sgbackup/gui/_gamedialog.py @@ -0,0 +1,675 @@ +############################################################################### +# sgbackup - The SaveGame Backup tool # +# Copyright (C) 2024 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 . # +############################################################################### + +from gi.repository import GObject,Gio,GLib,Gtk,Pango +from ..game import Game,GameFileMatcher,GameFileType + +class GameVariableData(GObject.GObject): + def __init__(self,name:str,value:str): + GObject.GObject.__init__(self) + self.name = name + self.value = value + + @GObject.Property(type=str) + def name(self)->str: + return self.__name + @name.setter + def name(self,name): + self.__name = name + + @GObject.Property(type=str) + def value(self)->str: + return self.__value + @value.setter + def value(self,value:str): + self.__value = value + +class RegistryKeyData(GObject.GObject): + def __init__(self,regkey=None): + GObject.GObject.__init__(self) + if not regkey: + self.__regkey = "" + + @GObject.Property(type=str) + def regkey(self): + return self.__regkey + @regkey.setter + def regkey(self,key:str): + self.__regkey = key + + def __bool__(self): + return bool(self.__regkey) + +class GameVariableDialog(Gtk.Dialog): + def __init__(self,parent:Gtk.Window,columnview:Gtk.ColumnView,variable:GameVariableData|None=None): + Gtk.Dialog.__init__(self) + self.set_transient_for(parent) + self.set_default_size(600,-1) + + self.__columnview = columnview + + if variable: + self.__variable = variable + else: + self.__variable = None + + grid = Gtk.Grid() + label = Gtk.Label.new("Name:") + self.__name_entry = Gtk.Entry() + self.__name_entry.set_hexpand(True) + self.__name_entry.connect("changed",self._on_name_entry_changed) + grid.attach(label,0,0,1,1) + grid.attach(self.__name_entry,1,0,1,1) + + label = Gtk.Label.new("Value") + self.__value_entry = Gtk.Entry() + self.__value_entry.set_hexpand(True) + + grid.attach(label,0,1,1,1) + grid.attach(self.__value_entry,1,1,1,1) + + self.get_content_area().append(grid) + + if self.__variable: + self.__name_entry.set_text(self.__variable.name) + self.__value_entry.set_text(self.__variable.value) + + self.__apply_button = self.add_button("Apply",Gtk.ResponseType.APPLY) + self.__apply_button.set_sensitive(bool(self)) + + self.add_button("Cancel",Gtk.ResponseType.CANCEL) + + def __bool__(self): + name = self.__name_entry.get_text() + if name: + if self.__variable and self.__variable.name == name: + return True + model = self.__columnview.get_model().get_model() + for i in range(model.get_n_items()): + if name == model.get_item(i).name: + return False + return True + return False + + def _on_name_entry_changed(self,entry): + self.__apply_button.set_sensitive(bool(self)) + + def do_response(self,response): + if response == Gtk.ResponseType.APPLY: + if not bool(self): + return + if self.__variable: + self.__variable.name = self.__name_entry.get_text() + self.__variable.value = self.__value_entry.get_text() + else: + model = self.__columnview.get_model().get_model() + model.append(GameVariableData(self.__name_entry.get_text(),self.__value_entry.get_text())) + self.hide() + self.destroy() + +class GameDialog(Gtk.Dialog): + def __init__(self, + parent:Gtk.Window|None=None, + game:Game|None=Game): + + Gtk.Dialog.__init__(self) + if (parent): + self.set_transient_for(parent) + + if isinstance(game,Game): + self.__game = game + else: + self.__game = None + + self.set_default_size(800,600) + + paned = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL) + paned.set_position(200) + + self.__stack = Gtk.Stack.new() + paned.set_end_child(self.__stack) + paned.set_hexpand(True) + paned.set_vexpand(True) + + self.__stack_sidebar = Gtk.ListBox() + self.__stack_sidebar.set_activate_on_single_click(False) + self.__stack_sidebar.set_selection_mode(Gtk.SelectionMode.SINGLE) + self.__stack_sidebar.connect('selected_rows_changed',self._on_stack_sidebar_selected_rows_changed) + + sidebar_scrolled = Gtk.ScrolledWindow() + sidebar_scrolled.set_child(self.__stack_sidebar) + paned.set_start_child(sidebar_scrolled) + + self.__variable_name_factory = Gtk.SignalListItemFactory() + self.__variable_name_factory.connect('setup',self._on_variable_name_setup) + self.__variable_name_factory.connect('bind',self._on_variable_name_bind) + + self.__variable_value_factory = Gtk.SignalListItemFactory() + self.__variable_value_factory.connect('setup',self._on_variable_value_setup) + self.__variable_value_factory.connect('bind',self._on_variable_value_bind) + + self.__add_game_page() + self.__windows = self.__create_windows_page() + self.__linux = self.__create_linux_page() + self.__macos = self.__create_macos_page() + self.__steam_windows = self.__create_steam_page('steam-windows','Steam Windows') + self.__steam_linux = self.__create_steam_page('steam-linux','Steam Linux') + self.__steam_macos = self.__create_steam_page('steam-macos','Steam MacOS') + + + for stack_page in self.__stack.get_pages(): + hbox = Gtk.Box.new(Gtk.Orientation.HORIZONTAL,4) + label = Gtk.Label.new(stack_page.props.title) + attrs = Pango.AttrList.new() + size_attr = Pango.AttrSize.new(14 * Pango.SCALE) + attrs.insert(size_attr) + label.set_attributes(attrs) + icon = Gtk.Image.new_from_icon_name(stack_page.props.icon_name) + icon.set_pixel_size(20) + hbox.append(icon) + hbox.append(label) + hbox.page_name = stack_page.props.name + + self.__stack_sidebar.append(hbox) + + self.reset() + self.__stack_sidebar.select_row(self.__stack_sidebar.get_row_at_index(0)) + + self.get_content_area().append(paned) + self.add_button("Apply",Gtk.ResponseType.APPLY) + self.add_button("Cancel",Gtk.ResponseType.CANCEL) + + + def __add_game_page(self): + page = Gtk.ScrolledWindow() + vbox = Gtk.Box.new(Gtk.Orientation.VERTICAL,2) + self.__set_widget_margin(vbox,5) + + grid = Gtk.Grid.new() + + label = Gtk.Label.new("Is active?") + self.__active_switch = Gtk.Switch() + entry_hbox = Gtk.Box.new(Gtk.Orientation.HORIZONTAL,5) + entry_hbox.append(self.__active_switch) + entry_hbox.append(label) + vbox.append(entry_hbox) + + label = Gtk.Label.new("Is live?") + self.__live_switch = Gtk.Switch() + entry_hbox = Gtk.Box.new(Gtk.Orientation.HORIZONTAL,5) + + entry_hbox.append(self.__live_switch) + entry_hbox.append(label) + vbox.append(entry_hbox) + + label = Gtk.Label.new("ID:") + self.__id_label = Gtk.Label() + self.__id_label.set_hexpand(True) + grid.attach(label,0,0,1,1) + grid.attach(self.__id_label,1,0,1,1) + + label = Gtk.Label.new("Key:") + self.__key_entry = Gtk.Entry() + self.__key_entry.set_hexpand(True) + grid.attach(label,0,1,1,1) + grid.attach(self.__key_entry,1,1,1,1) + + label = Gtk.Label.new("Game name:") + self.__name_entry = Gtk.Entry() + self.__name_entry.set_hexpand(True) + grid.attach(label,0,2,1,1) + grid.attach(self.__name_entry,1,2,1,1) + + label = Gtk.Label.new("Savegame name:") + self.__sgname_entry = Gtk.Entry() + self.__sgname_entry.set_hexpand(True) + grid.attach(label,0,3,1,1) + grid.attach(self.__sgname_entry,1,3,1,1) + vbox.append(grid) + + self.__game_variables = self.__create_variables_widget() + + vbox.append(self.__game_variables) + + page.set_child(vbox) + self.__stack.add_titled(page,"main","Game") + stack_page = self.__stack.get_page(page) + stack_page.set_icon_name('sgbackup') + + + def __create_windows_page(self): + page = Gtk.ScrolledWindow() + vbox = Gtk.Box.new(Gtk.Orientation.VERTICAL,2) + self.__set_widget_margin(vbox,5) + + grid = Gtk.Grid() + + label = Gtk.Label.new("Root directory:") + page.sgroot_entry = Gtk.Entry() + page.sgroot_entry.set_hexpand(True) + grid.attach(label,0,0,1,1) + grid.attach(page.sgroot_entry,1,0,1,1) + + label = Gtk.Label.new("Backup directory:") + page.sgdir_entry = Gtk.Entry() + page.sgdir_entry.set_hexpand(True) + grid.attach(label,0,1,1,1) + grid.attach(page.sgdir_entry,1,1,1,1) + + vbox.append(grid) + + page.lookup_regkeys = self.__create_registry_key_widget("Lookup Registry keys") + vbox.append(page.lookup_regkeys) + + page.installdir_regkeys = self.__create_registry_key_widget("Installations directory Registry keys") + vbox.append(page.installdir_regkeys) + + page.variables = self.__create_variables_widget() + vbox.append(page.variables) + + page.set_child(vbox) + self.__stack.add_titled(page,"windows","Windows") + stack_page = self.__stack.get_page(page) + stack_page.set_icon_name("windows-svgrepo-com") + + return page + + + def __create_linux_page(self): + page = Gtk.ScrolledWindow() + vbox = Gtk.Box.new(Gtk.Orientation.VERTICAL,2) + self.__set_widget_margin(vbox,5) + + grid = Gtk.Grid() + label = Gtk.Label.new("Root directory:") + page.sgroot_entry = Gtk.Entry() + page.sgroot_entry.set_hexpand(True) + grid.attach(label,0,0,1,1) + grid.attach(page.sgroot_entry,1,0,1,1) + + label = Gtk.Label.new("Backup directory:") + page.sgdir_entry = Gtk.Entry() + page.sgdir_entry.set_hexpand(True) + grid.attach(label,0,1,1,1) + grid.attach(page.sgdir_entry,1,1,1,1) + + label = Gtk.Label.new("Executable") + page.binary_entry = Gtk.Entry() + page.binary_entry.set_hexpand(True) + grid.attach(label,0,2,1,1) + grid.attach(page.binary_entry,1,2,1,1) + vbox.append(grid) + + page.variables = self.__create_variables_widget() + vbox.append(page.variables) + + page.set_child(vbox) + self.__stack.add_titled(page,"linux","Linux") + stack_page = self.__stack.get_page(page) + stack_page.set_icon_name("linux-svgrepo-com") + + return page + + def __create_macos_page(self): + page = Gtk.ScrolledWindow() + vbox = Gtk.Box.new(Gtk.Orientation.VERTICAL,2) + self.__set_widget_margin(vbox,5) + + grid = Gtk.Grid() + label = Gtk.Label.new("Root directory:") + page.sgroot_entry = Gtk.Entry() + page.sgroot_entry.set_hexpand(True) + grid.attach(label,0,0,1,1) + grid.attach(page.sgroot_entry,1,0,1,1) + + label = Gtk.Label.new("Backup directory:") + page.sgdir_entry = Gtk.Entry() + page.sgdir_entry.set_hexpand(True) + grid.attach(label,0,1,1,1) + grid.attach(page.sgdir_entry,1,1,1,1) + + label = Gtk.Label.new("Executable") + page.binary_entry = Gtk.Entry() + page.binary_entry.set_hexpand(True) + grid.attach(label,0,2,1,1) + grid.attach(page.binary_entry,1,2,1,1) + vbox.append(grid) + + page.variables = self.__create_variables_widget() + vbox.append(page.variables) + + page.set_child(vbox) + self.__stack.add_titled(page,"macos","MacOS") + stack_page = self.__stack.get_page(page) + stack_page.set_icon_name("apple-svgrepo-com") + + return page + + def __create_steam_page(self,name,title): + page = Gtk.ScrolledWindow() + vbox = Gtk.Box.new(Gtk.Orientation.VERTICAL,2) + self.__set_widget_margin(vbox,5) + + grid = Gtk.Grid() + + label = Gtk.Label.new("App ID:") + page.appid_entry = Gtk.Entry() + page.appid_entry.set_hexpand(True) + grid.attach(label,0,0,1,1) + grid.attach(page.appid_entry,1,0,1,1) + vbox.append(grid) + + + label = Gtk.Label.new("Root directory:") + page.sgroot_entry = Gtk.Entry() + page.sgroot_entry.set_hexpand(True) + grid.attach(label,0,1,1,1) + grid.attach(page.sgroot_entry,1,1,1,1) + + label = Gtk.Label.new("Backup directory:") + page.sgdir_entry = Gtk.Entry() + page.sgdir_entry.set_hexpand(True) + grid.attach(label,0,2,1,1) + grid.attach(page.sgdir_entry,1,2,1,1) + + page.variables = self.__create_variables_widget() + vbox.append(page.variables) + + page.set_child(vbox) + self.__stack.add_titled(page,name,title) + stack_page = self.__stack.get_page(page) + stack_page.set_icon_name("steam-svgrepo-com") + + return page + def __set_widget_margin(self,widget,margin): + widget.set_margin_start(margin) + widget.set_margin_end(margin) + widget.set_margin_top(margin) + widget.set_margin_bottom(margin) + + def __create_variables_widget(self): + widget = Gtk.Frame.new("Variables") + vbox = Gtk.Box.new(Gtk.Orientation.VERTICAL,0) + + model = Gio.ListStore.new(GameVariableData) + selection = Gtk.SingleSelection.new(model) + selection.set_autoselect(False) + selection.set_can_unselect(True) + + widget.columnview = Gtk.ColumnView.new(selection) + widget.columnview.set_vexpand(True) + + widget.actions = Gtk.ActionBar() + icon = Gtk.Image.new_from_icon_name("list-add-symbolic") + widget.add_button = Gtk.Button() + widget.add_button.set_child(icon) + widget.add_button.connect('clicked', + self._on_variables_add_button_clicked, + widget.columnview) + widget.actions.pack_start(widget.add_button) + + icon = Gtk.Image.new_from_icon_name("document-edit-symbolic") + widget.edit_button = Gtk.Button() + widget.edit_button.set_child(icon) + widget.edit_button.set_sensitive(False) + widget.edit_button.connect('clicked', + self._on_variables_edit_buton_clicked, + widget.columnview) + widget.actions.pack_start(widget.edit_button) + + icon = Gtk.Image.new_from_icon_name("list-remove-symbolic") + widget.remove_button = Gtk.Button() + widget.remove_button.set_child(icon) + widget.remove_button.set_sensitive(False) + widget.remove_button.connect('clicked', + self._on_variables_remove_button_clicked, + widget.columnview) + widget.actions.pack_start(widget.remove_button) + + name_column = Gtk.ColumnViewColumn.new("Name",self.__variable_name_factory) + name_column.set_expand(True) + widget.columnview.append_column(name_column) + + value_column = Gtk.ColumnViewColumn.new("Value",self.__variable_value_factory) + value_column.set_expand(True) + widget.columnview.append_column(value_column) + + selection.connect('selection-changed',self._on_variable_selection_changed,widget) + + vbox.append(widget.actions) + vbox.append(widget.columnview) + + widget.set_child(vbox) + return widget + + def __create_registry_key_widget(self,title): + widget = Gtk.Frame.new(title) + vbox=Gtk.Box.new(Gtk.Orientation.VERTICAL,2) + + widget.actions = Gtk.ActionBar() + icon = Gtk.Image.new_from_icon_name("list-add-symbolic") + button = Gtk.Button.new() + button.set_child(icon) + button.connect('clicked',self._on_windows_regkey_add_button_clicked,widget) + widget.actions.pack_start(button) + + model = Gio.ListStore.new(RegistryKeyData) + selection = Gtk.SingleSelection.new(model) + selection.set_autoselect(False) + selection.set_can_unselect(True) + + factory = Gtk.SignalListItemFactory() + factory.connect('setup',self._on_windows_regkey_setup) + factory.connect('bind',self._on_windows_regkey_bind,widget) + + widget.listview = Gtk.ListView.new(selection,factory) + + vbox.append(widget.actions) + vbox.append(widget.listview) + widget.set_child(vbox) + + return widget + + def reset(self): + self.__active_switch.set_active(True) + self.__live_switch.set_active(True) + self.__name_entry.set_text("") + self.__sgname_entry.set_text("") + self.__game_variables.columnview.get_model().get_model().remove_all() + + #windows + self.__windows.sgroot_entry.set_text("") + self.__windows.sgdir_entry.set_text("") + self.__windows.variables.columnview.get_model().get_model().remove_all() + self.__windows.lookup_regkeys.listview.get_model().get_model().remove_all() + self.__windows.installdir_regkeys.listview.get_model().get_model().remove_all() + + #linux + self.__linux.sgroot_entry.set_text("") + self.__linux.sgdir_entry.set_text("") + self.__linux.binary_entry.set_text("") + self.__linux.variables.columnview.get_model().get_model().remove_all() + + #linux + self.__macos.sgroot_entry.set_text("") + self.__macos.sgdir_entry.set_text("") + self.__macos.binary_entry.set_text("") + self.__macos.variables.columnview.get_model().get_model().remove_all() + + #steam windows + self.__steam_windows.sgroot_entry.set_text("") + self.__steam_windows.sgdir_entry.set_text("") + self.__steam_windows.appid_entry.set_text("") + self.__steam_windows.variables.columnview.get_model().get_model().remove_all() + + #steam linux + self.__steam_linux.sgroot_entry.set_text("") + self.__steam_linux.sgdir_entry.set_text("") + self.__steam_linux.appid_entry.set_text("") + self.__steam_linux.variables.columnview.get_model().get_model().remove_all() + + #steam macos + self.__steam_macos.sgroot_entry.set_text("") + self.__steam_macos.sgdir_entry.set_text("") + self.__steam_macos.appid_entry.set_text("") + self.__steam_macos.variables.columnview.get_model().get_model().remove_all() + + if self.__game: + self.__active_switch.set_active(self.__game.is_active) + self.__live_switch.set_active(self.__game.is_live) + self.__name_entry.set_text(self.__game.name) + self.__sgname_entry.set_text(self.__game.savegame_name) + for name,value in self.__game.variables.items(): + self.__game_variables.get_model().get_model().append(GameVariableData(name,value)) + + if self.__game.windows: + self.__windows.sgroot_entry.set_text(self.__game.windows.savegame_root) + self.__windows.sgdir_entry.set_text(self.__game.windows.savegame_dir) + + # set lookup regkeys + var_model = self.__windows.variables.columnview.get_model().get_model() + grk_model = self.__windows.lookup_regkeys.listview.get_model().get_model() + irk_model = self.__windows.installdir_regkeys.listview.get_model().get_model() + for rk in self.__game.windows.game_registry_keys: + grk_model.append(RegistryKeyData(rk)) + + #set installdir regkeys + for rk in self.__game.windows.installdir_registry_keys: + irk_model.append(RegistryKeyData(rk)) + + #set variables + for name,value in self.__game.windows.variables.items(): + var_model.append(GameVariableData(name,value)) + + if self.__game.linux: + self.__linux.sgroot_entry.set_text(self.__game.linux.savegame_root) + self.__linux.sgdir_entry.set_text(self.__game.linux.savegame_dir) + self.__linux.binary_entry.set_text(self.__game.linux.binary) + var_model = self.__linux.variables.columnview.get_model().get_model() + for name,value in self.__game.linux.variables.items(): + var_model.append(GameVariableData(name,value)) + + if self.__game.macos: + self.__macos.sgroot_entry.set_text(self.__game.linux.savegame_root) + self.__macos.sgdir_entry.set_text(self.__game.linux.savegame_dir) + self.__macos.binary_entry.set_text(self.__game.linux.binary) + var_model = self.__macos.variables.columnview.get_model().get_model() + for name,value in self.__game.linux.variables.items(): + var_model.append(GameVariableData(name,value)) + + if self.__game.steam_windows: + self.__steam_windows.sgroot_entry.set_text(self.__game.steam_windows.savegame_root) + self.__steam_windows.sgdir_entry.set_text(self.__game.steam_windows.savegame_dir) + self.__steam_windows.appid_entry.set_text(self.__game.steam_windows.appid) + var_model = self.__steam_windows.variables.columnview.get_model().get_model() + for name,value in self.__game.steam_windows.variables.items(): + var_model.append(GameVariableData(name,value)) + + if self.__game.steam_linux: + self.__steam_linux.sgroot_entry.set_text(self.__game.steam_linux.savegame_root) + self.__steam_linux.sgdir_entry.set_text(self.__game.steam_linux.savegame_dir) + self.__steam_linux.appid_entry.set_text(self.__game.steam_linux.appid) + var_model = self.__steam_linux.variables.columnview.get_model().get_model() + for name,value in self.__game.steam_linux.variables.items(): + var_model.append(GameVariableData(name,value)) + + if self.__game.steam_macos: + self.__steam_macos.sgroot_entry.set_text(self.__game.steam_macos.savegame_root) + self.__steam_macos.sgdir_entry.set_text(self.__game.steam_macos.savegame_dir) + self.__steam_macos.appid_entry.set_text(self.__game.steam_macos.appid) + var_model = self.__steam_macos.variables.columnview.get_model().get_model() + for name,value in self.__game.steam_macos.variables.items(): + var_model.append(GameVariableData(name,value)) + # reset() + + def _on_variable_name_setup(self,factory,item): + label = Gtk.Label() + item.set_child(label) + + def _on_variable_name_bind(self,factory,item): + label = item.get_child() + data = item.get_item() + data.bind_property('name',label,'label',GObject.BindingFlags.SYNC_CREATE) + + def _on_variable_value_setup(self,factory,item): + label = Gtk.Label() + item.set_child(label) + + def _on_variable_value_bind(self,factory,item): + label = item.get_child() + data = item.get_item() + data.bind_property('value',label,'label',GObject.BindingFlags.SYNC_CREATE) + + def _on_windows_regkey_setup(self,factory,item): + label = Gtk.EditableLabel() + item.set_child(label) + + def _on_windows_regkey_bind(self,factory,item,widget): + label = item.get_child() + data = item.get_item() + label.set_text(data.regkey) + label.bind_property('text',data,'regkey',GObject.BindingFlags.DEFAULT) + label.connect('changed',self._on_windows_regkey_label_changed,widget) + if not label.get_text(): + label.start_editing() + label.grab_focus() + + def _on_stack_sidebar_selected_rows_changed(self,sidebar): + row = sidebar.get_selected_row() + self.__stack.set_visible_child_name(row.get_child().page_name) + + def _on_variables_add_button_clicked(self,button,columnview): + dialog = GameVariableDialog(self,columnview) + dialog.present() + + def _on_variables_remove_button_clicked(self,button,columnview): + selection = columnview.get_model() + model = selection.get_model() + selected = selection.get_selected() + if selected == Gtk.INVALID_LIST_POSITION: + return + model.remove(selected) + + def _on_variables_edit_buton_clicked(self,button,columnview): + data = columnview.get_model().get_selected() + if data: + dialog = GameVariableDialog(self,columnview,data) + dialog.present() + + def _on_variable_selection_changed(self,selection,position,n_items,var_widget): + if (selection.get_model().get_n_items() == 0) or (selection.get_selected() == Gtk.INVALID_LIST_POSITION): + var_widget.edit_button.set_sensitive(False) + var_widget.remove_button.set_sensitive(False) + else: + var_widget.edit_button.set_sensitive(True) + var_widget.remove_button.set_sensitive(True) + + def _on_windows_regkey_add_button_clicked(self,button,widget): + widget.listview.get_model().get_model().append(RegistryKeyData()) + + def _on_windows_regkey_label_changed(self,label,widget): + if not label.get_text(): + model = widget.listview.get_model().get_model() + i = 0 + while i < model.get_n_items(): + item = model.get_item(i) + if not item.regkey: + model.remove(i) + continue + i += 1 + \ No newline at end of file diff --git a/sgbackup/gui/_settingsdialog.py b/sgbackup/gui/_settingsdialog.py new file mode 100644 index 0000000..93ac5d9 --- /dev/null +++ b/sgbackup/gui/_settingsdialog.py @@ -0,0 +1,97 @@ +############################################################################### +# sgbackup - The SaveGame Backup tool # +# Copyright (C) 2024 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 . # +############################################################################### + +from gi.repository import Gtk,GLib,Gio +from gi.repository.GObject import GObject,Signal,Property + +from ..settings import settings + +class SettingsDialog(Gtk.Dialog): + def __init__(self,parent=None): + Gtk.Dialog.__init__(self) + if parent: + self.set_transient_for(parent) + self.set_default_size(800,600) + vbox = self.get_content_area() + paned = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL) + paned.set_position(250) + + self.__stack = Gtk.Stack() + self.__stack_sidebar = Gtk.StackSidebar.new() + self.__add_general_settings_page() + + paned.set_start_child(self.__stack_sidebar) + paned.set_end_child(self.__stack) + paned.set_vexpand(True) + self.__stack_sidebar.set_stack(self.__stack) + + vbox.append(paned) + + self.add_button("Apply",Gtk.ResponseType.APPLY) + self.add_button("Cancel",Gtk.ResponseType.CANCEL) + + def __add_general_settings_page(self): + page = Gtk.ScrolledWindow() + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + grid = Gtk.Grid() + + label = Gtk.Label.new('Backup directory: ') + grid.attach(label,0,0,1,1) + self.__backupdir_label = Gtk.Label.new(settings.backup_dir) + self.__backupdir_label.set_hexpand(True) + grid.attach(self.__backupdir_label,1,0,1,1) + img = Gtk.Image.new_from_icon_name('document-open-symbolic') + img.set_pixel_size(16) + backupdir_button = Gtk.Button() + backupdir_button.set_child(img) + backupdir_button.connect('clicked',self._on_backupdir_button_clicked) + grid.attach(backupdir_button,2,0,1,1) + + vbox.append(grid) + page.set_child(vbox) + + self.add_page(page,"general","Generic settings") + + def _on_backupdir_dialog_select_folder(self,dialog,result,*data): + try: + dir = dialog.select_folder_finish(result) + if dir is not None: + self.__backupdir_label.set_text(dir.get_path()) + except: + pass + + def _on_backupdir_button_clicked(self,button): + dialog = Gtk.FileDialog.new() + dialog.set_title("sgbackup: Choose backup folder") + dialog.select_folder(self,None,self._on_backupdir_dialog_select_folder) + + + + def add_page(self,page,name,title): + self.__stack.add_titled(page,name,title) + + def do_response(self,response): + if response == Gtk.ResponseType.APPLY: + self.emit('save') + settings.save() + self.destroy() + + @Signal(name='save') + def do_save(self): + settings.backup_dir = self.__backupdir_label.get_text() + \ No newline at end of file diff --git a/sgbackup/gui/application.py b/sgbackup/gui/application.py deleted file mode 100644 index 26075df..0000000 --- a/sgbackup/gui/application.py +++ /dev/null @@ -1,83 +0,0 @@ -############################################################################### -# sgbackup - The SaveGame Backup tool # -# Copyright (C) 2024 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 . # -############################################################################### - -from gi.repository import Gtk,GObject,Gio -from .appwindow import AppWindow - -import logging; logger=logging.getLogger(__name__) - -class Application(Gtk.Application): - def __init__(self,*args,**kwargs): - AppFlags = Gio.ApplicationFlags - kwargs['application_id'] = 'org.sgbackup.sgbackup' - kwargs['flags'] = AppFlags.FLAGS_NONE - Gtk.Application.__init__(self,*args,**kwargs) - - self.__logger = logger.getChild('Application') - self.__builder = None - self.__appwindow = None - - @property - def _logger(self): - return self.__logger - - @GObject.Property - def appwindow(self): - return self.__appwindow - - def do_startup(self): - self._logger.debug('do_startup()') - if not self.__builder: - self.__builder = Gtk.Builder.new() - - Gtk.Application.do_startup(self) - - action_about = Gio.SimpleAction.new('about',None) - action_about.connect('activate',self.on_action_about) - self.add_action(action_about) - - action_quit = Gio.SimpleAction.new('quit',None) - action_quit.connect('activate',self.on_action_quit) - self.add_action(action_quit) - - action_settings = Gio.SimpleAction.new('settings',None) - action_settings.connect('activate',self.on_action_settings) - self.add_action(action_settings) - - # add accels - self.set_accels_for_action('app.quit',["q"]) - - @GObject.Property - def builder(self): - return self.__builder - - def do_activate(self): - self._logger.debug('do_activate()') - if not (self.__appwindow): - self.__appwindow = AppWindow(application=self) - - self.appwindow.present() - - def on_action_about(self,action,param): - pass - - def on_action_settings(self,action,param): - pass - - def on_action_quit(self,action,param): - self.quit() \ No newline at end of file diff --git a/sgbackup/gui/appmenu.ui b/sgbackup/gui/appmenu.ui index acb558e..a87c277 100644 --- a/sgbackup/gui/appmenu.ui +++ b/sgbackup/gui/appmenu.ui @@ -1,6 +1,25 @@ +
+ + _Game + + + _Add Game + app.new-game + + + + _Steam + + + _Epic + + + _GoG + +
_Settings diff --git a/sgbackup/gui/appwindow.py b/sgbackup/gui/appwindow.py deleted file mode 100644 index cec6ced..0000000 --- a/sgbackup/gui/appwindow.py +++ /dev/null @@ -1,86 +0,0 @@ -############################################################################### -# sgbackup - The SaveGame Backup tool # -# Copyright (C) 2024 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 . # -############################################################################### - -from gi.repository import Gtk,Gio,GObject - -import os -from .gameview import GameView -from .backupview import BackupView - -class AppWindow(Gtk.ApplicationWindow): - def __init__(self,application=None,**kwargs): - kwargs['title'] = "SGBackup" - - if (application is not None): - kwargs['application']=application - if (hasattr(application,'builder')): - builder = application.builder - else: - builder = Gtk.Builder.new() - - Gtk.ApplicationWindow.__init__(self,**kwargs) - self.set_default_size(800,600) - - self.__builder = builder - self.builder.add_from_file(os.path.join(os.path.dirname(__file__),'appmenu.ui')) - gmenu = self.builder.get_object('appmenu') - appmenu_popover = Gtk.PopoverMenu.new_from_model(gmenu) - image = Gtk.Image.new_from_icon_name('open-menu-symbolic') - menubutton = Gtk.MenuButton.new() - menubutton.set_popover(appmenu_popover) - menubutton.set_child(image) - headerbar = Gtk.HeaderBar.new() - headerbar.pack_start(menubutton) - self.set_titlebar(headerbar) - - vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - - self.__vpaned = Gtk.Paned(orientation=Gtk.Orientation.VERTICAL) - self.__vpaned.set_hexpand(True) - self.__vpaned.set_vexpand(True) - self.__vpaned.set_wide_handle(True) - self.__gameview = GameView() - self.__vpaned.set_start_child(self.gameview) - self.__backupview = BackupView(self.gameview) - self.__vpaned.set_end_child(self.backupview) - self.__vpaned.set_resize_start_child(True) - self.__vpaned.set_resize_end_child(True) - - vbox.append(self.__vpaned) - - statusbar = Gtk.Statusbar() - statusbar.set_hexpand(True) - statusbar.set_vexpand(False) - statusbar.push(0,'Running ...') - vbox.append(statusbar) - - self.set_child(vbox) - - @GObject.Property - def builder(self): - return self.__builder - - @GObject.Property - def backupview(self): - return self.__backupview - - @GObject.Property - def gameview(self): - return self.__gameview - - \ No newline at end of file diff --git a/sgbackup/gui/backupview.py b/sgbackup/gui/backupview.py deleted file mode 100644 index 1afec6d..0000000 --- a/sgbackup/gui/backupview.py +++ /dev/null @@ -1,30 +0,0 @@ -############################################################################### -# sgbackup - The SaveGame Backup tool # -# Copyright (C) 2024 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 . # -############################################################################### - -from gi.repository import Gtk,Gio,GObject - -from .gameview import GameView - -class BackupView(Gtk.ScrolledWindow): - def __init__(self,gameview:GameView): - Gtk.ScrolledWindow.__init__(self) - self.__gameview = GameView - - @GObject.Property - def gameview(self): - return self.__gameview diff --git a/sgbackup/gui/gameview.py b/sgbackup/gui/gameview.py deleted file mode 100644 index 1f4b623..0000000 --- a/sgbackup/gui/gameview.py +++ /dev/null @@ -1,24 +0,0 @@ -############################################################################### -# sgbackup - The SaveGame Backup tool # -# Copyright (C) 2024 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 . # -############################################################################### - -from gi.repository import Gtk,Gio,GObject - -class GameView(Gtk.ScrolledWindow): - def __init__(self): - Gtk.ScrolledWindow.__init__(self) - \ No newline at end of file diff --git a/sgbackup/icons/hicolor/128x128/apps/sgbackup.png b/sgbackup/icons/hicolor/128x128/apps/sgbackup.png new file mode 100644 index 0000000..b86f422 Binary files /dev/null and b/sgbackup/icons/hicolor/128x128/apps/sgbackup.png differ diff --git a/sgbackup/icons/hicolor/256x256/apps/sgbackup.png b/sgbackup/icons/hicolor/256x256/apps/sgbackup.png new file mode 100644 index 0000000..9ab7b91 Binary files /dev/null and b/sgbackup/icons/hicolor/256x256/apps/sgbackup.png differ diff --git a/sgbackup/icons/hicolor/32x32/apps/sgbackup.png b/sgbackup/icons/hicolor/32x32/apps/sgbackup.png new file mode 100644 index 0000000..81b08e7 Binary files /dev/null and b/sgbackup/icons/hicolor/32x32/apps/sgbackup.png differ diff --git a/sgbackup/icons/hicolor/32x32/apps/windows.png b/sgbackup/icons/hicolor/32x32/apps/windows.png new file mode 100644 index 0000000..386a629 Binary files /dev/null and b/sgbackup/icons/hicolor/32x32/apps/windows.png differ diff --git a/sgbackup/icons/hicolor/512x512/apps/sgbackup.png b/sgbackup/icons/hicolor/512x512/apps/sgbackup.png new file mode 100644 index 0000000..9c5dd7e Binary files /dev/null and b/sgbackup/icons/hicolor/512x512/apps/sgbackup.png differ diff --git a/sgbackup/icons/hicolor/64x64/apps/sgbackup.png b/sgbackup/icons/hicolor/64x64/apps/sgbackup.png new file mode 100644 index 0000000..960ae88 Binary files /dev/null and b/sgbackup/icons/hicolor/64x64/apps/sgbackup.png differ diff --git a/sgbackup/icons/hicolor/org.sgabackup.sgbackup.gresource.xml b/sgbackup/icons/hicolor/org.sgabackup.sgbackup.gresource.xml new file mode 100644 index 0000000..9f01832 --- /dev/null +++ b/sgbackup/icons/hicolor/org.sgabackup.sgbackup.gresource.xml @@ -0,0 +1,21 @@ + + + + sgbackup.png + + + sgbackup.png + + + sgbackup.png + + + sgbackup.png + + + sgbackup.png + + + icons8-windows-10.svg + + \ No newline at end of file diff --git a/sgbackup/icons/hicolor/scalable/apps/apple-svgrepo-com.svg b/sgbackup/icons/hicolor/scalable/apps/apple-svgrepo-com.svg new file mode 100644 index 0000000..f2b8d63 --- /dev/null +++ b/sgbackup/icons/hicolor/scalable/apps/apple-svgrepo-com.svg @@ -0,0 +1,19 @@ + + + + + + + + \ No newline at end of file diff --git a/sgbackup/icons/hicolor/scalable/apps/linux-svgrepo-com.svg b/sgbackup/icons/hicolor/scalable/apps/linux-svgrepo-com.svg new file mode 100644 index 0000000..1f30a25 --- /dev/null +++ b/sgbackup/icons/hicolor/scalable/apps/linux-svgrepo-com.svg @@ -0,0 +1,49 @@ + + + + + + + \ No newline at end of file diff --git a/sgbackup/icons/hicolor/scalable/apps/steam-svgrepo-com.svg b/sgbackup/icons/hicolor/scalable/apps/steam-svgrepo-com.svg new file mode 100644 index 0000000..27fb392 --- /dev/null +++ b/sgbackup/icons/hicolor/scalable/apps/steam-svgrepo-com.svg @@ -0,0 +1,6 @@ + + + +steam + + \ No newline at end of file diff --git a/sgbackup/icons/hicolor/scalable/apps/windows-svgrepo-com.svg b/sgbackup/icons/hicolor/scalable/apps/windows-svgrepo-com.svg new file mode 100644 index 0000000..b591081 --- /dev/null +++ b/sgbackup/icons/hicolor/scalable/apps/windows-svgrepo-com.svg @@ -0,0 +1,19 @@ + + + + + + + + + + \ No newline at end of file diff --git a/sgbackup/main.py b/sgbackup/main.py index 50e2d0c..0d528bf 100644 --- a/sgbackup/main.py +++ b/sgbackup/main.py @@ -19,9 +19,9 @@ import logging from . import gui -from .gui.application import Application +from .gui import Application from .steam import SteamLibrary -import sys + logger=logging.getLogger(__name__) @@ -37,6 +37,6 @@ def curses_main(): def gui_main(): logger.debug("Running gui_main()") - gui.app = Application() - gui.app.run() + gui._app = Application() + gui._app.run() return 0 \ No newline at end of file diff --git a/sgbackup/settings.py b/sgbackup/settings.py index f249405..8265bf1 100644 --- a/sgbackup/settings.py +++ b/sgbackup/settings.py @@ -23,6 +23,8 @@ import sys from gi.repository import GLib,GObject class Settings(GObject.GObject): + __gtype_name__ = "Settings" + def __init__(self): super().__init__() @@ -35,6 +37,13 @@ class Settings(GObject.GObject): if (os.path.isfile(self.__config_file)): with open(self.__config_file,'r') as conf: self.__configparser.read_file(conf) + + if not os.path.isdir(self.config_dir): + os.makedirs(self.config_dir) + + if not os.path.isdir(self.gameconf_dir): + os.makedirs(self.gameconf_dir) + @GObject.Property(nick="parser") def parser(self)->ConfigParser: @@ -61,11 +70,12 @@ class Settings(GObject.GObject): def backup_dir(self)->str: if self.parser.has_option('sgbackup','backupDirectory'): return self.parser.get('sgbackup','backupDirectory') - return GLib.build_filename(GLib.build_filename(GLib.get_home_dir(),'SavagameBackups')) + return os.path.join(GLib.get_home_dir(),'SavagameBackups') @backup_dir.setter def backup_dir(self,directory:str): if not os.path.isabs(directory): raise ValueError("\"backup_dir\" needs to be an absolute path!") + self.ensure_section('sgbackup') return self.parser.set('sgbackup','backupDirectory',directory) @GObject.Property(type=str) @@ -77,6 +87,10 @@ class Settings(GObject.GObject): def save(self): self.emit('save') + def ensure_section(self,section:str): + if not self.parser.has_section(section): + self.parser.add_section(section) + @GObject.Signal(name='save',flags=GObject.SIGNAL_RUN_LAST,return_type=None,arg_types=()) def do_save(self): with open(self.config_file,'w') as ofile: diff --git a/sgbackup/steam.py b/sgbackup/steam.py index 2161f9d..9780c66 100644 --- a/sgbackup/steam.py +++ b/sgbackup/steam.py @@ -23,8 +23,11 @@ import sys import json from .settings import settings +from .game import STEAM_GAMES,STEAM_WINDOWS_GAMES,STEAM_LINUX_GAMES,STEAM_MACOS_GAMES PLATFORM_WINDOWS = (sys.platform.lower() == 'win32') +PLATFORM_LINUX = (sys.platform.lower() in ('linux','freebsd','netbsd','openbsd','dragonfly')) +PLATFORM_MACOS = (sys.platform.lower() == 'macos') from gi.repository.GObject import GObject,Property,Signal @@ -69,8 +72,6 @@ class AcfFileParser(object): return line_count,ret - - def parse_file(self,acf_file)->dict: if not os.path.isfile(acf_file): @@ -86,6 +87,14 @@ class AcfFileParser(object): raise RuntimeError("Not a acf file!") class IgnoreSteamApp(GObject): + __gtype_name__ = "sgbackup-steam-IgnoreSteamApp" + + def __init__(self,appid:int,name:str,reason:str): + GObject.__init__(self) + self.__appid = int(appid) + self.__name = name + self.__reason = reason + @staticmethod def new_from_dict(conf:dict): if ('appid' in conf and 'name' in conf): @@ -95,12 +104,6 @@ class IgnoreSteamApp(GObject): return SteamIgnoreApp(appid,name,reason) return None - - def __init__(self,appid:int,name:str,reason:str): - GObject.__init__(self) - self.__appid = int(appid) - self.__name = name - self.__reason = reason @Property(type=int) def appid(self)->str: @@ -120,7 +123,6 @@ class IgnoreSteamApp(GObject): def reason(self,reason:str): self.__reason = reason - def serialize(self): return { 'appid': self.appid, @@ -128,7 +130,10 @@ class IgnoreSteamApp(GObject): 'reason': self.reason, } + class SteamApp(GObject): + __gtype_name__ = "sgbackup-steam-SteamApp" + def __init__(self,appid:int,name:str,installdir:str): GObject.__init__(self) self.__appid = int(appid) @@ -159,8 +164,11 @@ class SteamApp(GObject): def __eq__(self,other): return self.appid == other.appid + class SteamLibrary(GObject): + __gtype_name__ = "sgbackup-steam-SteamLibrary" + def __init__(self,library_path:str): GObject.__init__(self) self.directory = library_path @@ -183,7 +191,7 @@ class SteamLibrary(GObject): return Path(self.directory).resolve() @Property - def steam_apps(self)->list: + def steam_apps(self)->list[SteamApp]: parser = AcfFileParser() appdir = self.path / "steamapps" commondir = appdir / "common" @@ -202,10 +210,12 @@ class SteamLibrary(GObject): return sorted(ret) class Steam(GObject): + __gtype_name__ = "sgbackup-steam-Steam" + def __init__(self): GObject.__init__(self) self.__libraries = [] - self.__ignore_apps = [] + self.__ignore_apps = {} if not self.steamlib_list_file.is_file(): if (PLATFORM_WINDOWS): @@ -229,7 +239,6 @@ class Steam(GObject): except: pass - ignore_apps = [] if self.ignore_apps_file.is_file(): with open(str(self.ignore_apps_file),'r',encoding="utf-8") as ifile: ignore_list = json.loads(ifile.read()) @@ -239,8 +248,8 @@ class Steam(GObject): except: continue if ignore_app: - self.__ignore_apps.append(ignore_app) - self.__ignore_apps = sorted(ignore_apps) + self.__ignore_apps[ignore_app.appid] = ignore_app + #__init__() @Property @@ -252,17 +261,21 @@ class Steam(GObject): return Path(settings.config_dir).resolve / 'ignore_steamapps.json' @Property - def libraries(self): + def libraries(self)->list[SteamLibrary]: return self.__libraries @Property - def ignore_apps(self): + def ignore_apps(self)->dict[int:IgnoreSteamApp]: return self.__ignore_apps def __write_steamlib_list_file(self): with open(self.steamlib_list_file,'w',encoding='utf-8') as ofile: ofile.write('\n'.join(str(sl.directory) for sl in self.libraries)) + def __write_ignore_steamapps_file(self): + with open(self.ignore_apps_file,'w',encoding='utf-8') as ofile: + ofile.write(json.dumps([i.serialize() for i in self.ignore_apps.values()])) + def add_library(self,steamlib:SteamLibrary|str): if isinstance(steamlib,SteamLibrary): lib = steamlib @@ -294,3 +307,46 @@ class Steam(GObject): for i in sorted(delete_libs,reverse=True): del self.__libraries[i] self.__write_steamlib_list_file() + + def add_ignore_app(self,app:IgnoreSteamApp): + self.__ignore_apps[app.appid] = app + self.__write_ignore_steamapps_file() + + def remove_ignore_app(self,app:IgnoreSteamApp|int): + if isinstance(app,IgnoreSteamApp): + appid = app.appid + else: + appid = int(app) + if appid in self.__ignore_apps: + del self.__ignore_apps[appid] + self.__write_ignore_steamapps_file() + + def find_new_steamapps(self)->list[SteamApp]: + new_apps = [] + for lib in self.libraries: + for app in lib.steam_apps: + if not app.appid in STEAM_GAMES and not app.appid in self.ignore_apps: + new_apps.append(app) + return sorted(new_apps) + + def update_steam_apps(self): + for lib in self.libraries(): + for app in lib.steam_apps: + if PLATFORM_WINDOWS: + if ((app.appid in STEAM_WINDOWS_GAMES) + and (STEAM_WINDOWS_GAMES[app.appid].installdir != app.installdir)): + game = STEAM_WINDOWS_GAMES[app.appid] + game.installdir = app.installdir + game.save() + elif PLATFORM_LINUX: + if ((app.appid in STEAM_LINUX_GAMES) + and (STEAM_LINUX_GAMES[app.appid].installdir != app.installdir)): + game = STEAM_LINUX_GAMES[app.appid] + game.installdir = app.installdir + game.save() + elif PLATFORM_MACOS: + if ((app.appid in STEAM_MACOS_GAMES) + and (STEAM_MACOS_GAMES[app.appid].installdir != app.installdir)): + game = STEAM_MACOS_GAMES[app.appid] + game.installdir = app.installdir + game.save()