diff --git a/data/gtk/preferences.blp b/data/gtk/preferences.blp
index 85dfa1e..5a87688 100644
--- a/data/gtk/preferences.blp
+++ b/data/gtk/preferences.blp
@@ -185,6 +185,21 @@ template PreferencesWindow : Adw.PreferencesWindow {
}
}
}
+
+ Adw.ExpanderRow itch_expander_row {
+ title: _("itch");
+ show-enable-switch: true;
+
+ Adw.ActionRow {
+ title: _("itch Install Location");
+ subtitle: _("Directory to use when importing games");
+
+ Button itch_file_chooser_button {
+ icon-name: "folder-symbolic";
+ valign: center;
+ }
+ }
+ }
}
}
}
diff --git a/data/hu.kramo.Cartridges.gschema.xml b/data/hu.kramo.Cartridges.gschema.xml
index 108e336..d05ef97 100644
--- a/data/hu.kramo.Cartridges.gschema.xml
+++ b/data/hu.kramo.Cartridges.gschema.xml
@@ -51,6 +51,12 @@
"~/.var/app/com.usebottles.bottles/data/bottles/"
+
+
+ true
+
+
+ "~/.var/app/io.itch.itch/config/itch/"
diff --git a/hu.kramo.Cartridges.json b/hu.kramo.Cartridges.json
index 6dc752a..1182fcb 100644
--- a/hu.kramo.Cartridges.json
+++ b/hu.kramo.Cartridges.json
@@ -5,22 +5,23 @@
"sdk" : "org.gnome.Sdk",
"command" : "cartridges",
"finish-args" : [
+ "--share=network",
"--share=ipc",
"--socket=fallback-x11",
"--device=dri",
"--socket=wayland",
"--talk-name=org.freedesktop.Flatpak",
- "--talk-name=org.gtk.vfs.*",
- "--filesystem=xdg-run/gvfsd",
"--filesystem=~/.steam/steam/:ro",
"--filesystem=xdg-data/lutris/:ro",
"--filesystem=xdg-cache/lutris/:ro",
"--filesystem=xdg-config/heroic/:ro",
"--filesystem=xdg-data/bottles/:ro",
+ "--filesystem=xdg-config/itch/:ro",
"--filesystem=~/.var/app/com.valvesoftware.Steam/data/Steam/:ro",
"--filesystem=~/.var/app/net.lutris.Lutris/:ro",
"--filesystem=~/.var/app/com.heroicgameslauncher.hgl/config/heroic/:ro",
- "--filesystem=~/.var/app/com.usebottles.bottles/data/bottles/:ro"
+ "--filesystem=~/.var/app/com.usebottles.bottles/data/bottles/:ro",
+ "--filesystem=~/.var/app/io.itch.itch/config/itch/:ro"
],
"cleanup" : [
"/include",
diff --git a/src/main.py b/src/main.py
index ad56e7f..6a92140 100644
--- a/src/main.py
+++ b/src/main.py
@@ -33,6 +33,7 @@ from .create_details_window import create_details_window
from .get_games import get_games
from .heroic_parser import heroic_parser
from .importer import Importer
+from .itch_parser import itch_parser
from .lutris_parser import lutris_parser
from .preferences import PreferencesWindow
from .save_game import save_game
@@ -206,6 +207,9 @@ class CartridgesApplication(Adw.Application):
if self.win.schema.get_boolean("bottles"):
bottles_parser(self.win)
+ if self.win.schema.get_boolean("itch"):
+ itch_parser(self.win)
+
self.win.importer.blocker = False
if self.win.importer.import_dialog.is_visible and self.win.importer.queue == 0:
diff --git a/src/meson.build b/src/meson.build
index 5bbaae6..708174b 100644
--- a/src/meson.build
+++ b/src/meson.build
@@ -24,9 +24,10 @@ cartridges_sources = [
'game.py',
'utils/importer.py',
'utils/steam_parser.py',
+ 'utils/lutris_parser.py',
'utils/heroic_parser.py',
'utils/bottles_parser.py',
- 'utils/lutris_parser.py',
+ 'utils/itch_parser.py',
'utils/get_games.py',
'utils/save_game.py',
'utils/save_cover.py',
diff --git a/src/preferences.py b/src/preferences.py
index a2077fc..1c275aa 100644
--- a/src/preferences.py
+++ b/src/preferences.py
@@ -110,6 +110,9 @@ class PreferencesWindow(Adw.PreferencesWindow):
bottles_expander_row = Gtk.Template.Child()
bottles_file_chooser_button = Gtk.Template.Child()
+ itch_expander_row = Gtk.Template.Child()
+ itch_file_chooser_button = Gtk.Template.Child()
+
def __init__(self, parent_widget, **kwargs):
super().__init__(**kwargs)
self.schema = parent_widget.schema
@@ -281,6 +284,18 @@ class PreferencesWindow(Adw.PreferencesWindow):
if os.name == "nt":
self.sources_group.remove(self.bottles_expander_row)
+ # itch
+ ImportPreferences(
+ self,
+ "itch",
+ "itch",
+ "itch-location",
+ [Path("db") / "butler.db"],
+ self.itch_expander_row,
+ self.itch_file_chooser_button,
+ True,
+ )
+
def choose_folder(self, _widget, function):
self.file_chooser.select_folder(self.parent_widget, None, function, None)
diff --git a/src/utils/importer.py b/src/utils/importer.py
index 894525d..951b06d 100644
--- a/src/utils/importer.py
+++ b/src/utils/importer.py
@@ -51,8 +51,8 @@ class Importer:
self.import_dialog.present()
- def save_cover(self, game_id, cover_path):
- save_cover(self.parent_widget, game_id, cover_path)
+ def save_cover(self, game_id, cover_path=None, pixbuf=None):
+ save_cover(self.parent_widget, game_id, cover_path, pixbuf)
def save_game(self, values=None):
if values:
diff --git a/src/utils/itch_parser.py b/src/utils/itch_parser.py
new file mode 100644
index 0000000..c165b07
--- /dev/null
+++ b/src/utils/itch_parser.py
@@ -0,0 +1,161 @@
+# itch_parser.py
+#
+# Copyright 2022-2023 kramo
+#
+# 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 .
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+import urllib.request
+from pathlib import Path
+from shutil import copyfile
+from sqlite3 import connect
+from time import time
+
+from gi.repository import GdkPixbuf, Gio
+
+
+def get_game(task, current_time, parent_widget, row, importer):
+ values = {}
+
+ values["game_id"] = f"itch_{row[0]}"
+
+ if (
+ values["game_id"] in parent_widget.games
+ and not parent_widget.games[values["game_id"]].removed
+ ):
+ task.return_value(None)
+ return
+
+ values["added"] = current_time
+ values["executable"] = ["xdg-open", f"itch://caves/{row[4]}/launch"]
+ values["hidden"] = False
+ values["last_played"] = 0
+ values["name"] = row[1]
+ values["source"] = "itch"
+
+ if row[3] or row[2]:
+ tmp_file = Gio.File.new_tmp(None)[0]
+ try:
+ with urllib.request.urlopen(row[3] or row[2], timeout=5) as open_file:
+ Path(tmp_file.get_path()).write_bytes(open_file.read())
+ except urllib.error.URLError:
+ task.return_value(values)
+ return
+
+ cover_pixbuf = GdkPixbuf.Pixbuf.new_from_stream_at_scale(
+ tmp_file.read(), 2, 2, False
+ ).scale_simple(400, 600, GdkPixbuf.InterpType.BILINEAR)
+
+ itch_pixbuf = GdkPixbuf.Pixbuf.new_from_stream(tmp_file.read())
+ itch_pixbuf = itch_pixbuf.scale_simple(
+ 400,
+ itch_pixbuf.get_height() * (400 / itch_pixbuf.get_width()),
+ GdkPixbuf.InterpType.BILINEAR,
+ )
+ itch_pixbuf.composite(
+ cover_pixbuf,
+ 0,
+ (600 - itch_pixbuf.get_height()) / 2,
+ itch_pixbuf.get_width(),
+ itch_pixbuf.get_height(),
+ 0,
+ (600 - itch_pixbuf.get_height()) / 2,
+ 1.0,
+ 1.0,
+ GdkPixbuf.InterpType.BILINEAR,
+ 255,
+ )
+ importer.save_cover(values["game_id"], pixbuf=cover_pixbuf)
+ task.return_value(values)
+
+
+def get_games_async(parent_widget, rows, importer):
+ current_time = int(time())
+
+ # Wrap the function in another one as Gio.Task.run_in_thread does not allow for passing args
+ def create_func(current_time, parent_widget, row):
+ def wrapper(task, *_unused):
+ get_game(
+ task,
+ current_time,
+ parent_widget,
+ row,
+ importer,
+ )
+
+ return wrapper
+
+ def update_games(_task, result):
+ final_values = result.propagate_value()[1]
+ # No need for an if statement as final_value would be None for games we don't want to save
+ importer.save_game(final_values)
+
+ for row in rows:
+ task = Gio.Task.new(None, None, update_games)
+ task.run_in_thread(create_func(current_time, parent_widget, row))
+
+
+def itch_parser(parent_widget):
+ schema = parent_widget.schema
+
+ database_path = (
+ Path(schema.get_string("itch-location")) / "db" / "butler.db"
+ ).expanduser()
+ if not database_path.is_file():
+ if Path("~/.var/app/io.itch.itch/config/itch/").expanduser().exists():
+ schema.set_string("itch-location", "~/.var/app/io.itch.itch/config/itch/")
+ elif (parent_widget.config_dir / "itch").exists():
+ schema.set_string("itch-location", str(parent_widget.config_dir / "itch"))
+ else:
+ return
+
+ database_path = (
+ Path(schema.get_string("itch-location")) / "db" / "butler.db"
+ ).expanduser()
+
+ db_cache_dir = parent_widget.cache_dir / "cartridges" / "itch"
+ db_cache_dir.mkdir(parents=True, exist_ok=True)
+
+ # Copy the file because sqlite3 doesn't like databases in /run/user/
+ database_tmp_path = db_cache_dir / "butler.db"
+ copyfile(database_path, database_tmp_path)
+
+ db_request = """
+ SELECT
+ games.id,
+ games.title,
+ games.cover_url,
+ games.still_cover_url,
+ caves.id
+ FROM
+ 'caves'
+ INNER JOIN
+ 'games'
+ ON
+ caves.game_id = games.id
+ ;
+ """
+
+ connection = connect(database_tmp_path)
+ cursor = connection.execute(db_request)
+ rows = cursor.fetchall()
+ connection.close()
+ database_tmp_path.unlink(missing_ok=True)
+
+ importer = parent_widget.importer
+ importer.total_queue += len(rows)
+ importer.queue += len(rows)
+
+ get_games_async(parent_widget, rows, importer)
diff --git a/src/utils/save_cover.py b/src/utils/save_cover.py
index e6d75e8..22b3638 100644
--- a/src/utils/save_cover.py
+++ b/src/utils/save_cover.py
@@ -21,7 +21,7 @@
from gi.repository import GdkPixbuf, Gio
-def save_cover(parent_widget, game_id, cover_path, pixbuf=None):
+def save_cover(parent_widget, game_id, cover_path=None, pixbuf=None):
covers_dir = parent_widget.data_dir / "cartridges" / "covers"
covers_dir.mkdir(parents=True, exist_ok=True)
diff --git a/src/utils/steam_parser.py b/src/utils/steam_parser.py
index 87aada9..f3bce0a 100644
--- a/src/utils/steam_parser.py
+++ b/src/utils/steam_parser.py
@@ -24,7 +24,7 @@ import urllib.request
from pathlib import Path
from time import time
-from gi.repository import Gio, GLib
+from gi.repository import Gio
def update_values_from_data(content, values):
@@ -70,25 +70,6 @@ def get_game(
values["added"] = current_time
values["last_played"] = 0
- url = f'https://store.steampowered.com/api/appdetails?appids={values["appid"]}'
-
- # On Linux the request is made through gvfs so the app can run without network permissions
- if os.name == "nt":
- try:
- with urllib.request.urlopen(url, timeout=10) as open_file:
- content = open_file.read().decode("utf-8")
- except urllib.error.URLError:
- content = None
- else:
- open_file = Gio.File.new_for_uri(url)
- try:
- content = open_file.load_contents()[1]
- except GLib.GError:
- content = None
-
- if content:
- values = update_values_from_data(content, values)
-
if (
steam_dir
/ "appcache"
@@ -105,8 +86,18 @@ def get_game(
),
)
+ try:
+ with urllib.request.urlopen(
+ f'https://store.steampowered.com/api/appdetails?appids={values["appid"]}',
+ timeout=5,
+ ) as open_file:
+ content = open_file.read().decode("utf-8")
+ except urllib.error.URLError:
+ task.return_value(values)
+ return
+
+ values = update_values_from_data(content, values)
task.return_value(values)
- return
def get_games_async(parent_widget, appmanifests, steam_dir, importer):
@@ -129,19 +120,12 @@ def get_games_async(parent_widget, appmanifests, steam_dir, importer):
return wrapper
def update_games(_task, result):
- try:
- final_values = result.propagate_value()[1]
- # No need for an if statement as final_value would be None for games we don't want to save
- importer.save_game(final_values)
- except GLib.GError: # Handle the exception for the timeout
- importer.save_game()
+ final_values = result.propagate_value()[1]
+ # No need for an if statement as final_value would be None for games we don't want to save
+ importer.save_game(final_values)
for appmanifest in appmanifests:
- cancellable = Gio.Cancellable.new()
- GLib.timeout_add_seconds(5, cancellable.cancel)
-
- task = Gio.Task.new(None, cancellable, update_games)
- task.set_return_on_cancel(True)
+ task = Gio.Task.new(None, None, update_games)
task.run_in_thread(
create_func(datatypes, current_time, parent_widget, appmanifest, steam_dir)
)