############################################################################### # 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 . # ############################################################################### from gi.repository import Gtk,Gio,GLib from gi.repository.GObject import GObject,Property,Signal,BindingFlags from ..i18n import gettext as _ import os from ..steam import ( Steam, SteamLibrary, SteamApp, IgnoreSteamApp, PLATFORM_LINUX, PLATFORM_MACOS, PLATFORM_WINDOWS ) from ..game import ( GameManager, Game, SteamGameData, SteamLinuxData, SteamMacOSData, SteamWindowsData, SavegameType, ) from ._gamedialog import GameDialog,GameSearchDialog class SteamLibrariesDialog(Gtk.Dialog): def __init__(self,parent:Gtk.Window|None=None): Gtk.Dialog.__init__(self) self.set_title("SGBackup: Steam Libraries") self.set_default_size(620,480) if parent is not None: self.set_transient_for(parent) self.__steam = Steam() hbox = Gtk.Box.new(Gtk.Orientation.HORIZONTAL,2) icon = Gtk.Image.new_from_icon_name('list-add-symbolic') self.__add_lib_button=Gtk.Button() self.__add_lib_button.set_child(icon) self.__add_lib_button.connect('clicked',self._on_add_library_button_clicked) hbox.append(self.__add_lib_button) self.__lib_editable = Gtk.EditableLabel() self.__lib_editable.set_hexpand(True) self.__lib_editable.connect('changed',self._on_add_library_label_changed) self._on_add_library_label_changed(self.__lib_editable) hbox.append(self.__lib_editable) icon = Gtk.Image.new_from_icon_name('document-open-symbolic') self.__lib_chooser_button = Gtk.Button() self.__lib_chooser_button.set_child(icon) self.__lib_chooser_button.connect('clicked',self._on_choose_library_button_clicked) hbox.append(self.__lib_chooser_button) self.get_content_area().append(hbox) frame = Gtk.Frame.new(_("Steam libraries")) scrolled = Gtk.ScrolledWindow() self.__listmodel = Gio.ListStore.new(SteamLibrary) for lib in self.__steam.libraries: self.__listmodel.append(lib) factory = Gtk.SignalListItemFactory() factory.connect('setup',self._on_library_setup) factory.connect('bind',self._on_library_bind) selection = Gtk.SingleSelection.new(self.__listmodel) self.__listview = Gtk.ListView.new(selection,factory) scrolled.set_child(self.__listview) scrolled.set_hexpand(True) scrolled.set_vexpand(True) frame.set_child(scrolled) frame.set_hexpand(True) frame.set_vexpand(True) self.get_content_area().append(frame) self.add_button(_("Apply"),Gtk.ResponseType.APPLY) self.add_button(_("Cancel"),Gtk.ResponseType.CANCEL) def _on_add_library_button_clicked(self,button): try: steamlib = SteamLibrary(self.__lib_editable.get_text()) for i in range(self.__listmodel.get_n_items()): item = self.__listmodel.get_item(i) if steamlib.directory == item.directory: self.__lib_editable.set_text("") return self.__listmodel.append(steamlib) self.__lib_editable.set_text("") except: pass def _on_choose_library_button_clicked(self,button): dialog = Gtk.FileDialog.new() dialog.set_title(_("SGBackup: Select Steam library path")) dialog.set_modal(True) if (self.__lib_editable.get_text() and os.path.isdir(self.__lib_editable.get_text())): dialog.set_initial_folder(Gio.File.new_for_path(self.__lib_editable.get_text())) dialog.select_folder(self,None,self._on_choose_library_select_folder) def _on_choose_library_select_folder(self,dialog,result,*args): try: file = dialog.select_folder_finish(result) except GLib.Error as ex: return if file is None: return self.__lib_editable.set_text(file.get_path()) def _on_add_library_label_changed(self,label): if label.get_text() and os.path.isdir(label.get_text()): self.__add_lib_button.set_sensitive(True) else: self.__add_lib_button.set_sensitive(False) def _on_list_chooser_button_clicked(self,button,label): dialog = Gtk.FileDialog.new() dialog.set_initial_folder(Gio.File.new_for_path(label.get_text())) dialog.set_modal(True) dialog.set_title(_("SGBackup: Change Steam library path")) dialog.select_folder(self,None,self._on_list_chooser_dialog_select_folder,label) def _on_list_chooser_dialog_select_folder(self,dialog,result,label,*data): try: file = dialog.select_folder_finish(result) except GLib.Error as ex: return if file is None or not file.get_path(): return label.set_text(file.get_path()) def _on_list_remove_button_clicked(self,button,label): for i in range(self.__listmodel.get_n_items()): item = self.__listmodel.get_item(i) if label.get_text() == item.directory: self.__listmodel.remove(i) return def _on_library_setup(self,factory,item): child = Gtk.Box.new(Gtk.Orientation.HORIZONTAL,2) child.label = Gtk.EditableLabel() child.label.set_hexpand(True) child.append(child.label) icon = Gtk.Image.new_from_icon_name('document-open-symbolic') child.chooser_button = Gtk.Button() child.chooser_button.set_child(icon) child.append(child.chooser_button) icon = Gtk.Image.new_from_icon_name('list-remove-symbolic') child.remove_button = Gtk.Button() child.remove_button.set_child(icon) child.append(child.remove_button) item.set_child(child) def _on_library_bind(self,factory,item): child = item.get_child() lib = item.get_item() child.label.set_text(lib.directory) child.label.bind_property('text',lib,'directory',BindingFlags.DEFAULT) if hasattr(child.chooser_button,'_signal_clicked_connector'): child.chooser_button.disconnect(child.chooser_button._signal_clicked_connector) child.chooser_button._signal_clicked_connector = child.chooser_button.connect('clicked', self._on_list_chooser_button_clicked, child.label) if hasattr(child.remove_button,'_signal_clicked_connector'): child.remove_button.disconnect(child.remove_button._signal_clicked_connector) child.remove_button._signal_clicked_connector = child.remove_button.connect('clicked', self._on_list_remove_button_clicked, child.label) def do_response(self,response): if response == Gtk.ResponseType.APPLY: steamlibs = [] for i in range(self.__listmodel.get_n_items()): item = self.__listmodel.get_item(i) if os.path.isdir(item.directory): steamlibs.append(item) self.__steam.libraries = steamlibs self.hide() self.destroy() class NewSteamAppSorter(Gtk.Sorter): def do_compare(self,item1:SteamApp,item2:SteamApp): if item1.name < item2.name: return Gtk.Ordering.SMALLER elif item1.name > item2.name: return Gtk.Ordering.LARGER else: return Gtk.Ordering.EQUAL class SteamGameLookupDialog(GameSearchDialog): def __init__(self,parent,steam_app:SteamApp): GameSearchDialog.__init__(self,parent,steam_app.name,_("Search Steam Apps")) self.__steam_app = steam_app @Property def steam_app(self)->SteamApp: return self.__steam_app def do_prepare_game(self, game): game = super().do_prepare_game(game) if game.steam: if not game.steam.appid != self.steam_app.appid: raise ValueError("Steam appid error") if PLATFORM_WINDOWS: if not game.steam.windows: game.steam.windows = SteamWindowsData("","",installdir=self.steam_app.installdir) else: game.steam.windows.installdir=self.steam_app.installdir if PLATFORM_LINUX: if not game.steam.linux: game.steam.linux = SteamLinuxData("","",installdir=self.steam_app.installdir) else: game.steam.linux.installdir=self.steam_app.installdir if PLATFORM_MACOS: if not game.steam.macos: game.steam.macos = SteamWindowsData("","",installdir=self.steam_app.installdir) else: game.steam.macos.installdir=self.steam_app.installdir else: if PLATFORM_WINDOWS: windows = SteamWindowsData("","",installdir=self.steam_app.installdir) else: windows = None if PLATFORM_LINUX: linux = SteamLinuxData("","",installdir=self.steam_app.installdir) else: linux = None if PLATFORM_MACOS: macos = SteamMacOSData("","",installdir=self.steam_app.installdir) else: macos = None game.steam = SteamGameData(appid=self.steam_app.appid, windows=windows, linux=linux, macos=macos) return game class NewSteamAppsDialog(Gtk.Dialog): def __init__(self,parent:Gtk.Window|None=None): Gtk.Dialog.__init__(self) if parent: self.set_transient_for(parent) self.set_title(_('SGBackup: New Steam apps')) self.set_default_size(800,600) self.__steam = Steam() self.__listmodel = Gio.ListStore.new(SteamApp) for app in self.__steam.find_new_steamapps(): self.__listmodel.append(app) sortmodel = Gtk.SortListModel.new(self.__listmodel,NewSteamAppSorter()) selection = Gtk.SingleSelection.new(sortmodel) selection.set_can_unselect(True) selection.set_autoselect(False) factory = Gtk.SignalListItemFactory() factory.connect('setup',self._on_listitem_setup) factory.connect('bind',self._on_listitem_bind) self.__listview = Gtk.ListView.new(selection,factory) self.__listview.set_vexpand(True) self.__listview.set_show_separators(True) self.__listview.set_single_click_activate(True) scrolled = Gtk.ScrolledWindow() scrolled.set_child(self.__listview) scrolled.set_vexpand(True) self.get_content_area().append(scrolled) self.__gamedialog = None self.add_button("OK",Gtk.ResponseType.OK) def _on_listitem_setup(self,factory,item): grid = Gtk.Grid() grid.set_hexpand(True) grid.set_column_spacing(5) #label = Gtk.Label.new("Name:") #label.set_xalign(0.0) grid.name_label = Gtk.Label() grid.name_label.set_hexpand(True) grid.name_label.set_xalign(0.0) #grid.attach(label,0,0,1,1) grid.attach(grid.name_label,1,0,1,1) label = Gtk.Label.new("AppID:") label.set_xalign(0.0) grid.appid_label = Gtk.Label() grid.appid_label.set_hexpand(True) grid.appid_label.set_xalign(0.0) grid.attach(label,0,1,1,1) grid.attach(grid.appid_label,1,1,1,1) label = Gtk.Label.new("Installdir:") label.set_xalign(0.0) grid.installdir_label = Gtk.Label() grid.installdir_label.set_hexpand(True) grid.installdir_label.set_xalign(0.0) grid.attach(label,0,2,1,1) grid.attach(grid.installdir_label,1,2,1,1) # Buttons button_grid = Gtk.Grid() button_grid.set_column_spacing(2) button_grid.set_row_spacing(2) icon = Gtk.Image.new_from_icon_name('list-add-symbolic') icon.set_pixel_size(16) grid.add_app_button = Gtk.Button() grid.add_app_button.set_child(icon) grid.add_app_button.set_tooltip_markup(_('Add SteamApp as new Game.')) button_grid.attach(grid.add_app_button,0,0,1,1) icon = Gtk.Image.new_from_icon_name('edit-delete-symbolic') icon.set_pixel_size(16) grid.ignore_app_button = Gtk.Button() grid.ignore_app_button.set_child(icon) grid.ignore_app_button.set_tooltip_text(_('Add SteamApp to ignore list.')) button_grid.attach(grid.ignore_app_button,1,0,1,1) icon = Gtk.Image.new_from_icon_name('edit-find-symbolic') icon.set_pixel_size(16) grid.lookup_button = Gtk.Button() grid.lookup_button.set_child(icon) grid.lookup_button.set_tooltip_text(_("Lookup SteamApp for already registered game.")) button_grid.attach(grid.lookup_button,0,1,1,1) icon = Gtk.Image.new_from_icon_name('folder-download-symbolic') icon.set_pixel_size(16) grid.search_online_button = Gtk.Button() grid.search_online_button.set_child(icon) grid.search_online_button.set_tooltip_text(_("Lookup SteamApp online.")) button_grid.attach(grid.search_online_button,1,1,1,1) grid.attach(button_grid,3,0,1,3) item.set_child(grid) def _on_listitem_bind(self,factory,item): child = item.get_child() data = item.get_item() child.name_label.set_markup("{}".format(GLib.markup_escape_text(data.name))) child.appid_label.set_text(str(data.appid)) child.installdir_label.set_text(data.installdir) # Check if we are already connected. # if we dont check we might have more than one dialog open # due to Gtk4 reusing the widgets. # When selecting a row in the columnview this method is called so we # need to ensure that the last binding is used to work as expected. if hasattr(child.add_app_button,'_signal_clicked_connector'): child.add_app_button.disconnect(child.add_app_button._signal_clicked_connector) child.add_app_button._signal_clicked_connector = child.add_app_button.connect('clicked',self._on_add_steamapp_button_clicked,data) if hasattr(child.ignore_app_button,'_signal_clicked_connector'): child.ignore_app_button.disconnect(child.ignore_app_button._signal_clicked_connector) child.ignore_app_button._signal_clicked_connector = child.ignore_app_button.connect('clicked',self._on_ignore_steamapp_button_clicked,data) if hasattr(child.lookup_button,'_signal_clicked_connector'): child.lookup_button.disconnect(child.lookup_button._signal_clicked_connector) child.lookup_button._signal_clicked_connector = child.lookup_button.connect('clicked',self._on_lookup_steamapp_button_clicked,data) if hasattr(child.search_online_button,'_signal_clicked_connector'): child.search_online_button.disconnect(child.search_online_button._signal_clicked_connector) #child.search_button._signal_clicked_connector = child.search_online_button.connect('clicked',self._on_lookup_steamapp_button_clicked,data) child.search_online_button.set_sensitive(False) def _on_add_steamapp_button_clicked(self,button,data:SteamApp,*args): def on_dialog_response(dialog,response): if (response == Gtk.ResponseType.APPLY): for i in reversed(range(self.__listmodel.get_n_items())): item = self.__listmodel.get_item(i) if item.appid == data.appid: self.__listmodel.remove(i) game = Game("",data.name,"") game.steam = SteamGameData(appid=data.appid) if PLATFORM_WINDOWS: game.steam.windows = SteamWindowsData("","",installdir=data.installdir) game.savegame_type = SavegameType.STEAM_WINDOWS elif PLATFORM_LINUX: game.steam.linux = SteamLinuxData("","",installdir=data.installdir) game.savegame_type = SavegameType.STEAM_LINUX elif PLATFORM_MACOS: game.steam.macos = SteamMacOSData("","",installdir=data.installdir) game.savegame_type = SavegameType.STEAM_MACOS gamedialog = GameDialog(self,game) gamedialog.set_title(_("SGBackup: Add Steam Game")) gamedialog.set_modal(False) gamedialog.connect_after('response',on_dialog_response) gamedialog.present() def _on_ignore_steamapp_button_clicked(self,button,data:SteamApp,*args): def on_dialog_response(dialog,response,data:SteamApp): dialog.hide() dialog.destroy() if response == Gtk.ResponseType.YES: ignore = IgnoreSteamApp(data.appid,data.name,dialog.reason_entry.get_text()) self.__steam.add_ignore_app(ignore) for i in reversed(range(self.__listmodel.get_n_items())): item = self.__listmodel.get_item(i) if item.appid == data.appid: self.__listmodel.remove(i) dialog = Gtk.MessageDialog(buttons=Gtk.ButtonsType.YES_NO) dialog.set_transient_for(self) dialog.set_modal(False) dialog.props.text = _("Do you want to put \"{steamapp}\" on the ignore list?").format( steamapp=GLib.markup_escape_text(data.name)) dialog.props.use_markup = True dialog.props.secondary_text = _("Please enter the reason for ignoring this app.") dialog.reason_entry = Gtk.Entry() dialog.reason_entry.set_hexpand(True) dialog.get_content_area().append(dialog.reason_entry) dialog.connect('response',on_dialog_response,data) dialog.present() def _on_lookup_steamapp_button_clicked(self,dialog,data:SteamApp): dialog = SteamGameLookupDialog(parent=self,steam_app=data) dialog.present() def refresh(self): self.__listmodel.remove_all() for app in self.__steam.find_new_steamapps(): self.__listmodel.append(app) def do_response(self,response): self.hide() self.destroy() class SteamNoNewAppsDialog(Gtk.MessageDialog): def __init__(self,parent:Gtk.Window|None=None): Gtk.MessageDialog.__init__(self,buttons=Gtk.ButtonsType.OK) if parent: self.set_transient_for(parent) self.props.text = _("There were no new Steam-Apps found!") self.props.use_markup = False def do_response(self,response): self.hide() self.destroy() class SteamNoIgnoredAppsDialog(Gtk.MessageDialog): def __init__(self,parent:Gtk.Window|None=None): Gtk.MessageDialog.__init__(self,buttons=Gtk.ButtonsType.OK) if parent: self.set_transient_for(parent) self.props.text = _("There are no Steam-Apps that are ignored!") self.props.use_markup = False def do_response(self,response): self.hide() self.destroy() class SteamIgnoreAppsSorter(Gtk.Sorter): def do_compare(self,item1:IgnoreSteamApp,item2:IgnoreSteamApp): s1 = item1.name.lower() s2 = item2.name.lower() if s1 > s2: return Gtk.Ordering.LARGER elif s1 < s2: return Gtk.Ordering.SMALLER else: return Gtk.Ordering.EQUAL class SteamIgnoreAppsDialog(Gtk.Dialog): def __init__(self,parent:Gtk.Window|None=None): Gtk.Dialog.__init__(self) self.set_default_size(640,480) self.set_title(_("SGBackup: manage ignored SteamApps")) if parent: self.set_transient_for(parent) self.set_modal(False) steam = Steam() self.__liststore = Gio.ListStore.new(IgnoreSteamApp) for sia in steam.ignore_apps.values(): self.__liststore.append(sia) self.__sortmodel = Gtk.SortListModel(model=self.__liststore,sorter=SteamIgnoreAppsSorter()) self.__selection = Gtk.SingleSelection(model=self.__sortmodel, can_unselect=True, autoselect=True) factory = Gtk.SignalListItemFactory() factory.connect('setup',self._on_listview_setup) factory.connect('bind',self._on_listview_bind) self.__listview = Gtk.ListView(model=self.__selection, factory=factory, single_click_activate=True, enable_rubberband=True) self.__listview.set_vexpand(True) scrolled = Gtk.ScrolledWindow() scrolled.set_child(self.__listview) scrolled.set_vexpand(True) self.get_content_area().append(scrolled) self.add_button(_("Close"),Gtk.ResponseType.OK) def _on_listview_setup(self,factory,item): child = Gtk.Grid(column_spacing=4,row_spacing=5) child.name_label = Gtk.Label() child.name_label.set_xalign(0.0) child.name_label.set_hexpand(True) child.attach(child.name_label,1,0,2,1) label = Gtk.Label.new(_("Reason:")) label.set_xalign(0.0) child.reason_label = Gtk.Label() child.reason_label.set_xalign(0.0) child.reason_label.set_hexpand(True) child.attach(label,0,1,1,1) child.attach(child.reason_label,1,1,1,1) action_grid = Gtk.Grid() icon = Gtk.Image.new_from_icon_name("document-new-symbolic") icon.set_pixel_size(16) child.new_game_button = Gtk.Button() child.new_game_button.set_child(icon) child.new_game_button.set_tooltip_text("Add ignored SteamApp as a new game.") action_grid.attach(child.new_game_button,0,0,1,1) icon = Gtk.Image.new_from_icon_name("list-remove-symbolic") icon.set_pixel_size(16) child.remove_button = Gtk.Button() child.remove_button.set_child(icon) child.remove_button.set_tooltip_text("Remove ignored SteamApp from the list.") action_grid.attach(child.remove_button,0,1,1,1) child.attach(action_grid,2,0,1,2) item.set_child(child) def _on_listview_bind(self,factory,item): child = item.get_child() data = item.get_item() child.name_label.set_markup("{}".format(GLib.markup_escape_text(data.name))) child.reason_label.set_text(data.reason) if hasattr(child.new_game_button,'_sginal_clicked_connector'): child.new_game_button.disconnect(child.new_game_button._signal_clicked_connector) child.new_game_button._signal_clicked_connector = child.new_game_button.connect('clicked',self._on_new_game_button_clicked,data) if hasattr(child.remove_button,'_signal_clicked_connector'): child.remove_button.disconnect(child.remove_button._signal_clicked_connector) child.remove_button._signal_clicked_connector = child.remove_button.connect('clicked',self._on_remove_button_clicked,data) def __remove_item(self,item:IgnoreSteamApp): for i in range(self.__liststore.get_n_items()): ignoreapp = self.__liststore.get_item(i) if item.appid == ignoreapp.appid: self.__liststore.remove(i) break def _on_remove_button_clicked(self,button,item:IgnoreSteamApp): self.__remove_item(item) steam = Steam() steam.remove_ignore_app(item) def _on_new_game_button_clicked(self,button,item:IgnoreSteamApp): game = None steam = Steam() steam.remove_ignore_app(item) steam = Steam() steamapps = steam.find_new_steamapps() for steamapp in steamapps: if steamapp.appid == item.appid: game = Game("",steamapp.name,"") if PLATFORM_WINDOWS: windows = SteamWindowsData("","",installdir=steamapp.installdir) game.savegame_type = SavegameType.STEAM_WINDOWS else: windows = None if PLATFORM_LINUX: linux = SteamLinuxData("","",installdir=steamapp.installdir) game.savegame_type = SavegameType.STEAM_LINUX else: linux = None if PLATFORM_MACOS: macos = SteamMacOSData("","",installdir=steamapp.installdir) game.savegame_type = SavegameType.STEAM_MACOS else: macos = None steamdata = SteamGameData(steamapp.appid, windows=windows, linux=linux, macos=macos) game.steam = steamdata break if game is None: gamemanager = GameManager.get_global() for g in gamemanager.games.values(): if g.steam and g.steam.appid == item.appid: game = g break if game is None: game = Game("",item.name,"") if PLATFORM_WINDOWS: game.savegame_type = SavegameType.STEAM_WINDOWS elif PLATFORM_LINUX: game.savegame_type = SavegameType.STEAM_LINUX elif PLATFORM_MACOS: game.savegame_type = SavegameType.STEAM_MACOS game.steam = SteamGameData(steamapp.appid) dialog = GameDialog(self.get_transient_for(),game=game) dialog.present() def _on_game_dialog_response(self,dialog,response,item=IgnoreSteamApp): if response == Gtk.ResponseType.APPLY: self.__remove_item(item) else: steam = Steam() steam.add_ignore_app(item) def do_response(self,response): self.hide() self.destroy()