summaryrefslogtreecommitdiffstats
path: root/gamechestcli/src
diff options
context:
space:
mode:
authorvg <vgm+dev@devys.org>2026-02-04 17:02:50 +0100
committervg <vgm+dev@devys.org>2026-02-04 17:02:50 +0100
commitdfd985bcc2b15390e29dc7f779cf2edb69071ca0 (patch)
tree27a76dd6f5a1dbd0996bc1f91bebabd58f5e6620 /gamechestcli/src
parent26e22b96e6e1f1cd4669887e2be2f23b82ae4323 (diff)
downloadgamechest-dfd985bcc2b15390e29dc7f779cf2edb69071ca0.tar.gz
gamechest-dfd985bcc2b15390e29dc7f779cf2edb69071ca0.tar.bz2
gamechest-dfd985bcc2b15390e29dc7f779cf2edb69071ca0.zip
git-sync on dita
Diffstat (limited to 'gamechestcli/src')
-rwxr-xr-xgamechestcli/src/__main__.py67
-rw-r--r--gamechestcli/src/gamechest/cliactions/install.py41
-rw-r--r--gamechestcli/src/gamechest/cliactions/remove.py38
-rw-r--r--gamechestcli/src/gamechest/cliactions/run.py59
-rw-r--r--gamechestcli/src/gamechest/config.py51
-rw-r--r--gamechestcli/src/gamechest/consts.py2
-rw-r--r--gamechestcli/src/gamechest/gameconfig.py151
-rw-r--r--gamechestcli/src/gamechest/gamedb.py61
-rw-r--r--gamechestcli/src/gamechest/processor.py41
-rw-r--r--gamechestcli/src/gamechest/runners/download.py68
-rw-r--r--gamechestcli/src/gamechest/runners/extract.py224
-rw-r--r--gamechestcli/src/gamechest/runners/install.py40
-rw-r--r--gamechestcli/src/gamechest/runners/remove.py150
-rw-r--r--gamechestcli/src/gamechest/runners/runnerbase.py44
-rw-r--r--gamechestcli/src/gamechest/runners/runnermulti.py91
-rw-r--r--gamechestcli/src/gamechest/statusdb.py106
-rw-r--r--gamechestcli/src/gamechest/structures.py38
17 files changed, 1272 insertions, 0 deletions
diff --git a/gamechestcli/src/__main__.py b/gamechestcli/src/__main__.py
new file mode 100755
index 0000000..8452b99
--- /dev/null
+++ b/gamechestcli/src/__main__.py
@@ -0,0 +1,67 @@
+#!/usr/bin/python3
+'''
+Manage games. Install, remove, run them.
+
+Usage: gamechest install <GAME_ID>
+ gamechest remove <GAME_ID>
+ gamechest run [--profile_id=<PROFILE_ID>] <GAME_ID>
+ gamechest set [--profile_id=<PROFILE_ID>] [--remote_basedir=<PATH>] [--gamesaves_path=<PATH>]
+ gamechest list [--installed|--not-installed]
+ gamechest showconfig
+
+Options:
+ --profile_id=<PROFILE_ID>, -p <PROFILE_ID>
+ use profile <PROFILE_ID> instead of the default one.
+'''
+
+import sys
+
+import docopt
+from rich import print
+
+from gamechest.cliactions import install, remove, run
+from gamechest.gameconfig import config
+from gamechest.statusdb import StatusDB
+from gamechest.gamedb import GameDB
+
+
+def main():
+ args = docopt.docopt(__doc__)
+ #print(args); raise SystemExit(0)
+
+ if args['install']:
+ install.install(args['<GAME_ID>'])
+ elif args['remove']:
+ remove.remove(args['<GAME_ID>'])
+ elif args['run']:
+ profile_id = args['--profile_id'] or config.get_profile_id()
+ if not profile_id:
+ print('profile_id must be not null', file=sys.stderr)
+ run.run(args['<GAME_ID>'], profile_id)
+ elif args['set']:
+ if args['--profile_id']:
+ config.set_profile_id(args['--profile_id'])
+ if args['--remote_basedir']:
+ config.set_remote_basedir(args['--remote_basedir'])
+ if args['--gamesaves_path']:
+ config.set_gamesaves_path(args['--gamesaves_path'])
+ config.save()
+ elif args['list']:
+ status_db = StatusDB()
+ if args['--installed']:
+ print(list(status_db.get_installed()))
+ else:
+ game_db = GameDB()
+ list_installed = list(status_db.get_installed())
+ for game_id in game_db.get_ids():
+ if args['--not-installed']:
+ if game_id not in list_installed:
+ print(game_id)
+ else:
+ print(game_id, 'installed:', game_id in list_installed)
+ elif args['showconfig']:
+ config.print_config()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/gamechestcli/src/gamechest/cliactions/install.py b/gamechestcli/src/gamechest/cliactions/install.py
new file mode 100644
index 0000000..90e9ac4
--- /dev/null
+++ b/gamechestcli/src/gamechest/cliactions/install.py
@@ -0,0 +1,41 @@
+import functools
+
+from .. import gamedb, processor
+from ..gameconfig import config
+from ..runners.install import Install
+from ..statusdb import StatusDB
+
+
+def install_game(status_db, game_info):
+ remote_basedir = config.get_remote_basedir()
+ package_name = game_info.get("package_name")
+ dest = config.get_games_install_basedir() / game_info['id']
+ dest.mkdir(parents=True, exist_ok=True)
+ # now as of 2026-01-14: not having a package_name or having it set to none
+ # means there is no installation archive (all is managed by the runner),
+ # thus consider it installed with version given without extracting
+ # anything.
+ if package_name:
+ title = 'Installing game...'
+ source = f'{remote_basedir}/{package_name}'
+ task = functools.partial(Install, source, dest)
+ processor.process_task(title, task)
+ else:
+ (dest / "dummy").mkdir(parents=True, exist_ok=True)
+
+ status_db.set_installed(game_info)
+
+
+def install(game_id):
+ game_db = gamedb.GameDB()
+ game_info = game_db.get_game_info(game_id)
+ status_db = StatusDB()
+ if status_db.is_installed(game_info):
+ # games is already installed
+ return
+ install_game(status_db, game_info)
+
+
+if __name__ == "__main__":
+ import sys
+ install(sys.argv[1])
diff --git a/gamechestcli/src/gamechest/cliactions/remove.py b/gamechestcli/src/gamechest/cliactions/remove.py
new file mode 100644
index 0000000..6573aff
--- /dev/null
+++ b/gamechestcli/src/gamechest/cliactions/remove.py
@@ -0,0 +1,38 @@
+import contextlib
+import functools
+import selectors
+
+from rich import print
+from rich.progress import Progress as RichProgress
+
+from .. import gamedb, processor
+from ..gameconfig import config
+from ..runners.remove import Remove
+from ..statusdb import StatusDB
+
+
+def remove_game(status_db, game_info):
+ remote_basedir = config.get_remote_basedir()
+ path = (
+ config.get_games_install_basedir()
+ / game_info['id']
+ )
+ title = 'Removing game...'
+ task = functools.partial(Remove, path)
+ processor.process_task(title, task)
+ status_db.set_uninstalled(game_info)
+
+
+def remove(game_id):
+ game_db = gamedb.GameDB()
+ game_info = game_db.get_game_info(game_id)
+ status_db = StatusDB()
+ if status_db.is_installed(game_info):
+ remove_game(status_db, game_info)
+ else:
+ print('Game not installed')
+
+
+if __name__ == "__main__":
+ import sys
+ remove(sys.argv[1])
diff --git a/gamechestcli/src/gamechest/cliactions/run.py b/gamechestcli/src/gamechest/cliactions/run.py
new file mode 100644
index 0000000..1c55cc4
--- /dev/null
+++ b/gamechestcli/src/gamechest/cliactions/run.py
@@ -0,0 +1,59 @@
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+from ..gameconfig import config
+from ..gamedb import GameDB
+from ..statusdb import StatusDB
+
+
+def run_game(game_db, profile_id, game_info):
+ command = game_db.get_game_command(profile_id, game_info['id'])
+ profile_dir = Path(config.get_profile_dir(profile_id))
+ profile_game_dir = profile_dir / game_info['id']
+ game_dir = config.get_games_install_basedir() / game_info['id']
+ # note: glob('*/') globs regular files too, thus I filter on is_dir.
+ game_dir = list(item
+ for item in game_dir.glob('*') if item.is_dir())[-1]
+ game_dir = Path(game_dir)
+ #tools_bin_path = config.get_games_saves_tools_bin_path()
+ #new_env = dict(os.environ)
+ #new_env['PATH'] = f'{tools_bin_path}:{new_env["PATH"]}'
+ # path to mod/run scripts are already prepended by get_game_command, no
+ # need to modify the path environment variable.
+ profile_lockdir = profile_dir / '.lock'
+ try:
+ profile_lockdir.mkdir(parents=True, exist_ok=False)
+ except FileExistsError:
+ print(f'ERROR: profile is already locked, {profile_lockdir}',
+ file=sys.stderr)
+ sys.exit(1)
+ try:
+ subprocess.run(
+ command,
+ env={
+ **os.environ,
+ 'PROFILE_DIR': str(profile_game_dir),
+ 'GAME_DIR': str(game_dir),
+ 'GAME_ID': game_info['id'],
+ },
+ )
+ finally:
+ profile_lockdir.rmdir()
+ print(f'INFO: {profile_lockdir} removed')
+
+
+def run(game_id, profile_id):
+ game_db = GameDB()
+ status_db = StatusDB()
+ game_info = game_db.get_game_info(game_id)
+ if not status_db.is_installed(game_info):
+ # games is already installed
+ print('Game', game_id, 'is not installed, aborting.', file=sys.stderr)
+ return
+ run_game(game_db, profile_id, game_info)
+
+
+if __name__ == "__main__":
+ run(*sys.argv[1:])
diff --git a/gamechestcli/src/gamechest/config.py b/gamechestcli/src/gamechest/config.py
new file mode 100644
index 0000000..cd0c7e8
--- /dev/null
+++ b/gamechestcli/src/gamechest/config.py
@@ -0,0 +1,51 @@
+from dataclasses import asdict, dataclass
+from pathlib import Path
+
+import yaml
+from xdg.BaseDirectory import (load_first_config, save_config_path,
+ xdg_data_home)
+
+CONFIG_PATH_TUPLE = ('org.devys.gamechest', 'config.yaml')
+
+
+@dataclass
+class GamechestConfig:
+
+ # TODO issue: having a Path() here raise yaml dump error when saving
+ games_path: Path = Path(xdg_data_home)
+ #games_path: str
+
+ @classmethod
+ def load_config_from_path(cls, path):
+ #import dataconf
+ #conf = dataconf.file('test.yaml', GamechestConfig)
+ #print(conf.games_path)
+ with Path(path).open() as fdin:
+ config_yaml = yaml.safe_load(fdin)
+ for key in ('games_path', ):
+ config_yaml[key] = Path(config_yaml[key]).expanduser()
+ return cls(**config_yaml)
+
+ @classmethod
+ def load_config(cls):
+ first_config_path = load_first_config(*CONFIG_PATH_TUPLE)
+ return_config = None
+ if first_config_path is None:
+ return_config = GamechestConfig()
+ return_config.save_config()
+ else:
+ return_config = cls.load_config_from_path(first_config_path)
+ return return_config
+
+ def save_config(self):
+ save_path = (
+ Path(save_config_path(*CONFIG_PATH_TUPLE[:-1]))
+ / CONFIG_PATH_TUPLE[-1]
+ )
+ with save_path.open('w', encoding='utf8') as fdout:
+ yaml.safe_dump(asdict(self), fdout)
+
+
+if __name__ == "__main__":
+ #print(GamechestConfig.load_config_from_path('test.yaml'))
+ print(GamechestConfig.load_config())
diff --git a/gamechestcli/src/gamechest/consts.py b/gamechestcli/src/gamechest/consts.py
new file mode 100644
index 0000000..091fd70
--- /dev/null
+++ b/gamechestcli/src/gamechest/consts.py
@@ -0,0 +1,2 @@
+XDG_RESOURCE_NAME = 'org.devys.gamechest'
+STATUS_DB_NAME = 'status.sqlite'
diff --git a/gamechestcli/src/gamechest/gameconfig.py b/gamechestcli/src/gamechest/gameconfig.py
new file mode 100644
index 0000000..0b3d855
--- /dev/null
+++ b/gamechestcli/src/gamechest/gameconfig.py
@@ -0,0 +1,151 @@
+import contextlib
+from pathlib import Path
+
+import yaml
+from xdg import xdg_data_home, xdg_config_home
+
+from . import consts
+
+
+DEFAULT_CONFIG_DICT = {
+ 'remote_basedir': 'jibril:/storage/games',
+ 'gamesaves_path': Path('~/syncthing/gamesaves').expanduser(),
+
+ # profile_id: no default for this, only set by user.
+ 'profile_id': None,
+}
+
+
+def path_relative_to_user(path):
+ # first ensure the path is a Path
+ path = Path(path)
+ with contextlib.suppress(ValueError):
+ return '~' / path.relative_to(Path.home())
+ # in case the path is not relative to the user home ValueError is raised,
+ # thus we return a fallback value (the path unmodified).
+ return path
+
+
+class GameConfig:
+
+ def __init__(self):
+ self.config = {}
+ game_config_path = xdg_config_home() / consts.XDG_RESOURCE_NAME
+ game_config_path.mkdir(parents=True, exist_ok=True)
+ game_config_filepath = game_config_path / 'config.yaml'
+ self.game_config_filepath = game_config_filepath
+ self.config = {}
+ with contextlib.ExitStack() as stack:
+ stack.enter_context(contextlib.suppress(FileNotFoundError))
+ fdin = stack.enter_context(open(game_config_filepath, 'r',
+ encoding='utf8'))
+ # in case yaml file is empty, None will be returned, in this case
+ # fallback to empty dict.
+ self.config = yaml.safe_load(fdin) or {}
+ self.config = {
+ **DEFAULT_CONFIG_DICT,
+ **self.config,
+ }
+ # convert game_saves_path to a path if not already the case (in case
+ # loaded from yaml file).
+ self.config['gamesaves_path'] = (
+ Path(self.config['gamesaves_path'])
+ .expanduser()
+ )
+
+ def get_remote_basedir(self):
+ return self.config['remote_basedir']
+
+ def get_gamesaves_path(self):
+ return self.config['gamesaves_path']
+
+ def get_games_saves_tools_bin_path(self):
+ return self.get_gamesaves_path() / 'tools' / 'bin'
+
+ def get_profile_dir(self, profile_id):
+ return self.get_gamesaves_path() / 'profiles' / profile_id
+
+ def get_games_database_path(self):
+ return self.get_gamesaves_path() / 'gamedata.yaml'
+
+ def get_data_d(self):
+ return self.get_gamesaves_path() / 'gamedata.yaml.d'
+
+ def get_profile_id(self):
+ return self.config['profile_id']
+
+ def save(self):
+ dict_transformed_for_save = { **self.config }
+ dict_transformed_for_save['gamesaves_path'] = str(
+ path_relative_to_user(
+ self.config['gamesaves_path']
+ )
+ )
+ with open(self.game_config_filepath, 'w', encoding='utf8') as fdout:
+ yaml.safe_dump(dict_transformed_for_save, fdout)
+
+ def set_profile_id(self, profile_id):
+ self.config['profile_id'] = profile_id
+
+ def set_remote_basedir(self, remote_basedir):
+ self.config['remote_basedir'] = remote_basedir
+
+ def set_gamesaves_path(self, path):
+ self.config['gamesaves_path'] = Path(path).expanduser()
+
+ def get_games_install_basedir(self):
+ games_install_path = (
+ xdg_data_home() / consts.XDG_RESOURCE_NAME / 'games'
+ )
+ games_install_path.mkdir(parents=True, exist_ok=True)
+ return games_install_path
+
+ def print_config(self):
+ print(self.config)
+
+
+# default instance of gameconfig, same instance intended to be shared through
+# all modules which needs it.
+config = GameConfig()
+
+class GameStatus:
+
+ def load_yaml(self):
+ with contextlib.ExitStack() as stack:
+ stack.enter_context(contextlib.suppress(FileNotFoundError))
+ fdin = stack.enter_context(open(self.yaml_path, 'r',
+ encoding='utf8'))
+ data = yaml.safe_load(fdin)
+ self.last_loaded_profile = data.get('last_loaded_profile',
+ self.last_loaded_profile)
+ self.last_loaded_game = data.get('last_loaded_game',
+ self.last_loaded_game)
+
+ def write_yaml(self):
+ with contextlib.ExitStack() as stack:
+ stack.enter_context(contextlib.suppress(FileNotFoundError))
+ fdout = stack.enter_context(open(self.yaml_path, 'w',
+ encoding='utf8'))
+ data = {
+ 'last_loaded_profile': self.last_loaded_profile,
+ 'last_loaded_game': self.last_loaded_game,
+ }
+ yaml.safe_dump(data, fdout)
+
+ def __init__(self):
+ self.yaml_path = (
+ xdg_data_home() / consts.XDG_RESOURCE_NAME / 'status.yaml'
+ )
+ self.last_loaded_profile = config.get_profile_id()
+ self.last_loaded_game = None
+ self.load_yaml()
+
+ def set_last_loaded_profile(self, profile):
+ self.last_loaded_profile = profile
+ self.write_yaml()
+
+ def set_last_loaded_game(self, game):
+ self.last_loaded_game = game
+ self.write_yaml()
+
+status = GameStatus()
diff --git a/gamechestcli/src/gamechest/gamedb.py b/gamechestcli/src/gamechest/gamedb.py
new file mode 100644
index 0000000..1f9e8d8
--- /dev/null
+++ b/gamechestcli/src/gamechest/gamedb.py
@@ -0,0 +1,61 @@
+import yaml
+
+from .gameconfig import config
+
+
+class GameDB:
+
+ def __init__(self):
+ database_path = config.get_games_database_path()
+ with open(database_path, 'rb') as fdin:
+ self.db = yaml.safe_load(fdin)
+ self.last_game_info = None
+
+ def get_game_info(self, game_id=None):
+ if self.last_game_info and self.last_game_info['id'] == game_id:
+ return self.last_game_info
+ game_info = next(game_info
+ for game_info in self.db['games']
+ if game_info['id'] == game_id)
+ self.last_game_info = game_info
+ return game_info
+
+ def get_game_env(self, profile_id, game_id=None):
+ # TODO
+ raise Error
+
+ def get_game_command(self, profile_id, game_id=None):
+ game_info = self.get_game_info(game_id)
+ game_mods = game_info.get('mods', [])
+ #game_mods += ['locked-run-profiledir']
+ #game_dir = config.get_games_install_basedir() / game_info['id']
+ ## note: glob('*/') globs regular files too, thus I filter on is_dir.
+ #game_dir = list(item
+ # for item in game_dir.glob('*') if item.is_dir())[-1]
+ tools_bin_path = config.get_games_saves_tools_bin_path()
+ #profile_dir = config.get_profile_dir(profile_id)
+ command_from_game_info = [
+ f'{tools_bin_path}/runners/{game_info["command"][0]}',
+ *game_info['command'][1:],
+ ] if game_info.get('command') else [
+ f'{tools_bin_path}/runners/run-{game_info["id"]}',
+ ]
+ command = [
+ # mods
+ *[f'{tools_bin_path}/mod-{item}' for item in game_mods],
+ # runner
+ *command_from_game_info,
+ ]
+ return command
+
+ def get_ids(self):
+ for game_info in self.db['games']:
+ yield game_info['id']
+
+ def get_idtitles(self):
+ for game_info in self.db['games']:
+ yield game_info['id'], game_info['title']
+
+ def get_info(self, game_id):
+ return next(game_info for game_info in self.db['games']
+ if game_info['id'] == game_id)
diff --git a/gamechestcli/src/gamechest/processor.py b/gamechestcli/src/gamechest/processor.py
new file mode 100644
index 0000000..e31d023
--- /dev/null
+++ b/gamechestcli/src/gamechest/processor.py
@@ -0,0 +1,41 @@
+import contextlib
+import selectors
+
+from rich import print
+from rich.progress import Progress as RichProgress
+
+
+def process_task(title, task):
+ with contextlib.ExitStack() as stack:
+ runner = stack.enter_context(task())
+ selector = stack.enter_context(selectors.DefaultSelector())
+ selector.register(runner.get_read_fd(), selectors.EVENT_READ)
+ rich_progress = stack.enter_context(RichProgress())
+ known_total = 100
+ global_id = rich_progress.add_task(title, total=known_total)
+ last_step = None
+ while (rc := runner.poll()) is None:
+ if selector.select():
+ progress = runner.progress_read()
+ #rich_progress.console.log(progress)
+ known_total = progress.steps_count*100
+ rich_progress.update(global_id,
+ completed=progress.step*100+progress.percent,
+ total=known_total)
+ if last_step != progress.step:
+ if last_step is not None:
+ rich_progress.update(step_id, completed=100)
+ else:
+ rich_progress.console.print('Total steps:',
+ progress.steps_count)
+ last_step = progress.step
+ step_id = rich_progress.add_task(
+ f'Step {progress.step}', total=100)
+ rich_progress.update(step_id, completed=progress.percent)
+ rich_progress.update(step_id, completed=100)
+ rich_progress.update(global_id, completed=known_total)
+ #rich_progress.console.print('installation ended with code:', rc)
+ if rc != 0:
+ print('Process failed, aborting')
+ raise SystemExit(1)
+ print('ended with code:', rc)
diff --git a/gamechestcli/src/gamechest/runners/download.py b/gamechestcli/src/gamechest/runners/download.py
new file mode 100644
index 0000000..8629b16
--- /dev/null
+++ b/gamechestcli/src/gamechest/runners/download.py
@@ -0,0 +1,68 @@
+import os
+import re
+import subprocess
+
+import humanfriendly
+
+from ..structures import Progress
+from .runnerbase import RunnerBase, neutral_locale_variables
+
+
+class Download(RunnerBase):
+
+ _rsync_progress_re = re.compile(r'^\s*(\S+)\s+(\d+)%\s+(\S+)\s+(\S+)\s+$')
+
+ def __init__(self, src, dst):
+ self.proc = subprocess.Popen(
+ [
+ 'rsync',
+ '--partial',
+ # --no-h: not human, easier to parse (but speed still appears
+ # in human form).
+ '--no-h',
+ '--info=progress2',
+ src,
+ dst,
+ ],
+ stdout=subprocess.PIPE,
+ encoding='utf8',
+ env={**os.environ,
+ **neutral_locale_variables,
+ },
+ )
+ self.last_progress = Progress()
+
+ def get_read_fd(self):
+ return self.proc.stdout
+
+ def progress_read(self):
+ line = self.proc.stdout.readline()
+ if match := self._rsync_progress_re.search(line):
+ self.last_progress = Progress(
+ nbbytes=int(match.group(1)),
+ percent=int(match.group(2)),
+ speed=humanfriendly.parse_size(match.group(3), binary=True),
+ eta=match.group(4),
+ )
+ return self.last_progress
+
+ def terminate(self):
+ self.proc.terminate()
+
+ def poll(self):
+ return self.proc.poll()
+
+ def close(self):
+ self.proc.wait()
+
+
+if __name__ == '__main__':
+ import contextlib
+ import sys
+ with contextlib.ExitStack() as stack:
+ stack.enter_context(contextlib.suppress(KeyboardInterrupt))
+ runner = stack.enter_context(Download(sys.argv[1], sys.argv[2]))
+ while (rc := runner.poll()) is None:
+ print(runner.progress_read())
+ print('runner ended with code:', rc)
+ print('test main ended')
diff --git a/gamechestcli/src/gamechest/runners/extract.py b/gamechestcli/src/gamechest/runners/extract.py
new file mode 100644
index 0000000..970c956
--- /dev/null
+++ b/gamechestcli/src/gamechest/runners/extract.py
@@ -0,0 +1,224 @@
+import os
+import re
+import select
+import struct
+import subprocess
+import threading
+import zipfile
+
+import humanfriendly
+
+from ..structures import Progress
+from .runnerbase import RunnerBase, neutral_locale_variables
+
+
+class ExtractZip(RunnerBase):
+ """
+ Simple zip wrapper without multithreading. Should not be an issue as zip
+ is used for little archives only, bigger archives are tar+zstd with
+ multithreaded algorithms. So keep this class simple.
+ """
+
+ def __init__(self, src, dst):
+ self.last_progress = Progress()
+ self.read_fd, self.write_fd = os.pipe()
+ self.cancel_event = threading.Event()
+
+ # No need to lock this unless GIL is disabled.
+ self.written_bytes = 0
+ self.total_bytes = 0
+ self.progress = 0
+
+ def extract_and_report():
+ try:
+ chunk_size = 1024**2 # 1MiB chunks
+ with zipfile.ZipFile(src, 'r') as zip_ref:
+ self.total_bytes = sum(getattr(file_info, 'file_size', 0) for file_info in zip_ref.infolist() if not file_info.is_dir())
+ for file_info in zip_ref.infolist():
+ if file_info.is_dir():
+ continue
+ with zip_ref.open(file_info) as source_file:
+ target_path = os.path.join(dst, file_info.filename)
+ os.makedirs(os.path.dirname(target_path), exist_ok=True)
+ with open(target_path, 'wb') as target_file:
+ while not self.cancel_event.is_set():
+ chunk = source_file.read(chunk_size)
+ if not chunk or self.cancel_event.is_set():
+ # if we have read everything from the
+ # source file, OR, we got a cancel event
+ # in-between the read and the write, we
+ # break.
+ break
+ target_file.write(chunk)
+ self.written_bytes += len(chunk)
+ self.progress = (self.written_bytes / self.total_bytes) * 100
+ #progress_message = f"Progress: {progress:.2f}% ({bytes_written} of {total_bytes} bytes)\n"
+ os.write(self.write_fd, b'.')
+ finally:
+ self.cancel_event.set()
+ self.progress = 100
+ os.write(self.write_fd, b'.')
+ #os.close(self.read_fd)
+ #os.close(self.write_fd)
+
+ # Create a thread for extraction
+ extraction_thread = threading.Thread(target=extract_and_report)
+ extraction_thread.start()
+ self.extraction_thread = extraction_thread
+
+
+ def get_read_fd(self):
+ return self.read_fd
+
+ def progress_read(self):
+ ready, _, _ = select.select([self.read_fd], [], [])
+ if ready:
+ # flush any present data from the pipe
+ os.read(self.read_fd, 1024)
+ self.last_progress = Progress(
+ nbbytes=self.written_bytes,
+ percent=int(100 * self.written_bytes / self.total_bytes),
+ #speed=humanfriendly.parse_size(match.group(2)),
+ speed=0,
+ eta="UNK",
+ )
+ return self.last_progress
+
+ def terminate(self):
+ self.close() # same as self.close, can be called multiple time
+
+ def poll(self):
+ return None if not self.cancel_event.is_set() else 0
+
+ def close(self):
+ self.cancel_event.set()
+ self.extraction_thread.join()
+
+
+class ExtractTar(RunnerBase):
+
+ _progress_re = re.compile(r'^\s*(\S+)\s+\[([^]]+)/s\]\s+ETA\s+(\S+)\s*$')
+
+ def __init__(self, src, dst, compression_type):
+ common_parameters = dict(
+ encoding='utf8',
+ env={**os.environ,
+ **neutral_locale_variables,
+ },
+ )
+ self.src_size = os.stat(src).st_size
+ self.pv_proc = subprocess.Popen(
+ [
+ 'pv',
+ '--force',
+ '--format', '%b %a %e',
+ src,
+ ],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ **common_parameters,
+ )
+ lzip_command = '/usr/bin/lzip'
+ plzip_command = '/usr/bin/plzip'
+ uncompress_command_to_use = [lzip_command, '--decompress']
+ if compression_type == 'lzip':
+ if os.path.exists(plzip_command):
+ uncompress_command_to_use = [plzip_command, '--decompress']
+ elif compression_type == 'zstd':
+ uncompress_command_to_use = ['zstd', '-T0', '-d', '--stdout']
+ self.zip_proc = subprocess.Popen(
+ uncompress_command_to_use,
+ stdin=self.pv_proc.stdout,
+ stdout=subprocess.PIPE,
+ **common_parameters,
+ )
+ self.tar_proc = subprocess.Popen(
+ [
+ 'tar',
+ '-C', dst,
+ '-xf', '-'
+ ],
+ stdin=self.zip_proc.stdout,
+ **common_parameters,
+ )
+ self.last_progress = Progress()
+
+ def get_read_fd(self):
+ return self.pv_proc.stderr
+
+ def progress_read(self):
+ line = self.pv_proc.stderr.readline()
+ if match := self._progress_re.search(line):
+ written_bytes = humanfriendly.parse_size(match.group(1))
+ self.last_progress = Progress(
+ nbbytes=written_bytes,
+ percent=int(100 * written_bytes / self.src_size),
+ speed=humanfriendly.parse_size(match.group(2)),
+ eta=match.group(3),
+ )
+ return self.last_progress
+
+ def terminate(self):
+ for proc in (self.pv_proc, self.zip_proc, self.tar_proc):
+ proc.terminate()
+
+ def poll(self):
+ return self.tar_proc.poll()
+
+ def close(self):
+ self.pv_proc.wait()
+ self.zip_proc.wait()
+ self.tar_proc.wait()
+
+
+class Extract(RunnerBase):
+ def __init__(self, src, dst):
+ zip_magics = (
+ b'PK\x03\x04',
+ b'PK\x05\x06', # empty
+ b'PK\x07\x08', # spanned
+ )
+
+ first_word_magic = 0
+ first_word_magic_b = b''
+ compression_type = None
+ with open(src, 'rb') as stream:
+ first_word_magic_b = stream.read(4)
+ first_word_magic = struct.unpack('>L', first_word_magic_b)[0]
+ if first_word_magic_b in zip_magics:
+ compression_type = 'zip'
+ elif first_word_magic == 0x4c5a4950: # lzip magic
+ compression_type = 'lzip'
+ elif first_word_magic == 0x28B52FFD: # zstd magic
+ compression_type = 'zstd'
+ if compression_type == 'zip':
+ self.inner_class = ExtractZip(src, dst)
+ else:
+ self.inner_class = ExtractTar(src, dst, compression_type)
+
+ def get_read_fd(self):
+ return self.inner_class.get_read_fd()
+
+ def progress_read(self):
+ return self.inner_class.progress_read()
+
+ def terminate(self):
+ return self.inner_class.terminate()
+
+ def poll(self):
+ return self.inner_class.poll()
+
+ def close(self):
+ return self.inner_class.close()
+
+
+if __name__ == '__main__':
+ import contextlib
+ import sys
+ with contextlib.suppress(KeyboardInterrupt):
+ with Extract(sys.argv[1], sys.argv[2]) as runner:
+ while runner.poll() is None:
+ print(runner.progress_read())
+ rc = runner.poll()
+ print('ended with code:', rc)
+ print('test main ended')
diff --git a/gamechestcli/src/gamechest/runners/install.py b/gamechestcli/src/gamechest/runners/install.py
new file mode 100644
index 0000000..0b37304
--- /dev/null
+++ b/gamechestcli/src/gamechest/runners/install.py
@@ -0,0 +1,40 @@
+import functools
+from pathlib import Path
+
+from .download import Download
+from .extract import Extract
+from .remove import Remove
+from .runnermulti import MultiSequencialRunnerBase
+
+
+class Install(MultiSequencialRunnerBase):
+
+ def __init__(self, source, dest):
+ tmpdest = Path(dest) / 'archive.rsynctmp'
+ runners = [
+ functools.partial(Download, source, tmpdest),
+ functools.partial(Extract, tmpdest, dest),
+ functools.partial(Remove, tmpdest),
+ ]
+ super().__init__(runners)
+
+
+if __name__ == '__main__':
+ import contextlib
+ import selectors
+ import sys
+ print('main test')
+ with contextlib.ExitStack() as stack:
+ stack.enter_context(contextlib.suppress(KeyboardInterrupt))
+ runner = stack.enter_context(Install(sys.argv[1], sys.argv[2]))
+ selector = stack.enter_context(selectors.DefaultSelector())
+ selector.register(runner.get_read_fd(), selectors.EVENT_READ)
+ last_progress = None
+ while (rc := runner.poll()) is None:
+ if selector.select():
+ progress = runner.progress_read()
+ if progress != last_progress:
+ print(progress)
+ last_progress = progress
+ print('ended with code:', rc)
+ print('test main ended')
diff --git a/gamechestcli/src/gamechest/runners/remove.py b/gamechestcli/src/gamechest/runners/remove.py
new file mode 100644
index 0000000..87177f3
--- /dev/null
+++ b/gamechestcli/src/gamechest/runners/remove.py
@@ -0,0 +1,150 @@
+import os
+import re
+import subprocess
+import threading
+
+import humanfriendly
+
+from ..structures import Progress
+from .runnerbase import RunnerBase, neutral_locale_variables
+
+
+class Remove(RunnerBase):
+
+ _progress_re = re.compile(r'^\s*(\S+)\s+\[([^]]+)/s\]\s+ETA\s+(\S+)\s*$')
+
+ def __init__(self, path):
+ self.last_progress = Progress(linemode=True)
+ self.path = path
+ self.rm_proc = None
+ self.pv_proc = None
+ self.pipe = os.pipe()
+ self.read_fd = open(self.pipe[0], 'r', encoding='utf8')
+ #self.read_fd = open(self.pipe[0], 'rb', buffering=0)
+ self._proc_started = False
+ self._filescount = 0
+ self._counting_quit_event = threading.Event()
+ self._counting_thread = threading.Thread(
+ target=self._counting_worker,
+ args=(self._counting_quit_event, path),
+ )
+ self._counting_thread.start()
+
+ def _start_proc(self):
+ common_parameters = dict(
+ encoding='utf8',
+ env={**os.environ,
+ **neutral_locale_variables,
+ },
+ )
+ self.rm_proc = subprocess.Popen(
+ [
+ 'rm',
+ '--verbose',
+ '--recursive',
+ '--one-file-system',
+ '--preserve-root=all',
+ '--interactive=never',
+ '--',
+ self.path,
+ ],
+ # [ # only for testing
+ # 'testtools/fake-rm',
+ # self.path,
+ # ],
+ stdout=subprocess.PIPE,
+ **common_parameters,
+ )
+ self.pv_proc = subprocess.Popen(
+ [
+ 'pv',
+ '--force',
+ '--size', str(self._filescount),
+ '--format', '%b %a %e',
+ '--line-mode',
+ ],
+ stdin=self.rm_proc.stdout,
+ stdout=subprocess.DEVNULL,
+ stderr=self.pipe[1],
+ **common_parameters,
+ )
+ # close child's pipe fd on parent's side or selectors will hang if
+ # process closes.
+ os.close(self.pipe[1])
+ self._proc_started = True
+
+ def _counting_worker(self, event, path):
+ # as counting files for a deep directory can take some time, make it
+ # interruptible with a thread and event for cancelling.
+ # using functools.reduce(x+1+len(y[2]), os.walk) would have been much
+ # cleaner, but the code gets harder to read with the addition of the
+ # event checking. Fallback to accumalator and iterations.
+ total = 0
+ for _, _, files in os.walk(path):
+ total += 1 + len(files)
+ if event.is_set():
+ break
+ # set filescount with calculated total (even then interrupted, as it
+ # would be a better estimation than nothing).
+ if os.path.isfile(path):
+ self._filescount = 1
+ else:
+ self._filescount = total
+ # start next processes (if event was not set)
+ if not event.is_set():
+ self._start_proc()
+
+ def get_read_fd(self):
+ return self.read_fd
+
+ def progress_read(self):
+ if not self._proc_started:
+ return self.last_progress
+ line = self.read_fd.readline()
+ #line = self.read_fd.readline().decode('utf8')
+ if match := self._progress_re.search(line):
+ written_bytes = humanfriendly.parse_size(match.group(1))
+ self.last_progress = Progress(
+ linemode=True,
+ nbbytes=written_bytes,
+ percent=int(100 * written_bytes / self._filescount),
+ speed=humanfriendly.parse_size(match.group(2)),
+ eta=match.group(3),
+ )
+ return self.last_progress
+
+ def terminate(self):
+ self._counting_quit_event.set()
+ if self._proc_started:
+ for proc in (self.rm_proc, self.pv_proc):
+ proc.terminate()
+
+ def poll(self):
+ if not self._proc_started:
+ return None
+ return self.pv_proc.poll()
+
+ def close(self):
+ self._counting_thread.join()
+ if self._proc_started:
+ for proc in (self.rm_proc, self.pv_proc):
+ proc.wait()
+ self.read_fd.close()
+
+
+if __name__ == '__main__':
+ import contextlib
+ import selectors
+ import sys
+ import time
+
+ with contextlib.ExitStack() as stack:
+ stack.enter_context(contextlib.suppress(KeyboardInterrupt))
+ runner = stack.enter_context(Remove(sys.argv[1]))
+ selector = stack.enter_context(selectors.DefaultSelector())
+ selector.register(runner.get_read_fd(), selectors.EVENT_READ)
+ while (rc := runner.poll()) is None:
+ if selector.select():
+ print(runner.progress_read())
+ print('ended with code:', rc)
+ print('test main ended')
diff --git a/gamechestcli/src/gamechest/runners/runnerbase.py b/gamechestcli/src/gamechest/runners/runnerbase.py
new file mode 100644
index 0000000..ac64ebb
--- /dev/null
+++ b/gamechestcli/src/gamechest/runners/runnerbase.py
@@ -0,0 +1,44 @@
+from abc import ABCMeta, abstractmethod
+
+
+class RunnerBase(metaclass=ABCMeta):
+
+ @abstractmethod
+ def get_read_fd(self):
+ 'fd to select for read evts to know when to progress_read nonblockingly'
+ raise NotImplementedError
+
+ @abstractmethod
+ def progress_read(self):
+ 'parse the fd given by get_read_fd and returns current progress'
+ raise NotImplementedError
+
+ @abstractmethod
+ def terminate(self):
+ '''signal runner(s) to cancel immediately the operation, must
+ be idempotent if already terminated (not running)'''
+ raise NotImplementedError
+
+ @abstractmethod
+ def poll(self):
+ 'returns None if still running, otherwise return returncode'
+ raise NotImplementedError
+
+ def close(self):
+ 'frees resources, clean/unzombify/gc/close-fds, can block if running'
+
+ def __enter__(self):
+ 'returns self'
+ return self
+
+ def __exit__(self, exc_type, value, traceback):
+ 'terminate and wait garbage-collect'
+ self.terminate()
+ self.close()
+
+
+neutral_locale_variables = {
+ 'LC_ALL':'C.UTF-8',
+ 'LANG':'C.UTF-8',
+ 'LANGUAGE':'C.UTF-8',
+}
diff --git a/gamechestcli/src/gamechest/runners/runnermulti.py b/gamechestcli/src/gamechest/runners/runnermulti.py
new file mode 100644
index 0000000..2a5e17c
--- /dev/null
+++ b/gamechestcli/src/gamechest/runners/runnermulti.py
@@ -0,0 +1,91 @@
+import contextlib
+import os
+import selectors
+import threading
+from functools import partial
+
+from ..structures import Progress
+from .runnerbase import RunnerBase
+
+
+def _create_pipe():
+ pipe_rd, pipe_wd = os.pipe()
+ return open(pipe_rd, 'rb', buffering=0), open(pipe_wd, 'wb', buffering=0)
+
+
+class MultiSequencialRunnerBase(RunnerBase):
+
+ def __init__(self, runners):
+ super().__init__()
+ self.runners = runners
+ self._pipe_rd, self._pipe_wd = _create_pipe()
+ self._pipesig_rd, self._pipesig_wd = _create_pipe()
+ self._thread = threading.Thread(target=self._thread_target)
+ self._thread.start()
+ self._last_progress = Progress()
+ self._last_rc = -1
+
+ def _runner_run(self,
+ runner_callable,
+ step_index,
+ runners_count):
+ with contextlib.ExitStack() as stack:
+ runner = stack.enter_context(runner_callable())
+ selector = stack.enter_context(selectors.DefaultSelector())
+ selector.register(self._pipesig_rd, selectors.EVENT_READ)
+ selector.register(runner.get_read_fd(), selectors.EVENT_READ)
+ while (rc := runner.poll()) is None:
+ for key, events in selector.select():
+ if key.fileobj is self._pipesig_rd:
+ self._pipesig_rd.read(1)
+ return -1
+ progress = runner.progress_read()
+ self._last_progress = progress._replace(
+ step=step_index,
+ steps_count=runners_count,
+ )
+ self._pipe_wd.write(b'p') # (p)rogress, could be anything
+ return rc
+
+ def _thread_target(self):
+ runners_count = len(self.runners)
+ with contextlib.ExitStack() as stack:
+ for step_index, runner_callable in enumerate(self.runners):
+ self._last_rc = self._runner_run(runner_callable, step_index,
+ runners_count)
+ if self._last_rc != 0:
+ break
+ # closing writing end of a pipe, allows select on reading-end to
+ # immediately return and reading on reading-end will returns EOF (or
+ # empty string on python).
+ self._pipe_wd.close()
+
+ def get_read_fd(self):
+ return self._pipe_rd
+
+ def progress_read(self):
+ # read: discard byte used to signal progress, and ignore if EOF.
+ self._pipe_rd.read(1)
+ return self._last_progress
+
+ def terminate(self):
+ # ignore ValueError, can arrive on closed _pipesig_wd if terminate is
+ # called multiple times.
+ with contextlib.suppress(ValueError):
+ self._pipesig_wd.write(b't') # t for terminate, could be anything
+ # close to avoid filling the pipe buffer uselessly if called multiple
+ # times, since nothing will read next sent bytes.
+ self._pipesig_wd.close()
+
+ def poll(self):
+ return None if self._thread.is_alive() else self._last_rc
+
+ def close(self):
+ self._thread.join()
+ for fd in (
+ self._pipesig_rd,
+ self._pipesig_wd,
+ self._pipe_rd,
+ self._pipe_wd,
+ ):
+ fd.close()
diff --git a/gamechestcli/src/gamechest/statusdb.py b/gamechestcli/src/gamechest/statusdb.py
new file mode 100644
index 0000000..3f2af31
--- /dev/null
+++ b/gamechestcli/src/gamechest/statusdb.py
@@ -0,0 +1,106 @@
+import sqlite3
+
+from xdg import xdg_state_home
+
+from . import consts
+
+
+class StatusDB:
+
+ def __init__(self):
+ db_path_dir = xdg_state_home() / consts.XDG_RESOURCE_NAME
+ db_path_dir.mkdir(parents=True, exist_ok=True)
+ db_path = db_path_dir / consts.STATUS_DB_NAME
+ self.conn = sqlite3.connect(db_path)
+ with self.conn:
+ self.conn.execute('''
+ CREATE TABLE IF NOT EXISTS status (
+ game_id text,
+ installed bool,
+ version_installed text
+ )
+ --CREATE TABLE IF NOT EXISTS last_items (
+ -- game_id text,
+ -- profile_id text,
+ -- type text,
+ --)
+ ''')
+
+ def close(self):
+ self.conn.close()
+
+ def is_installed(self, game_info):
+ row = (
+ self.conn
+ .execute('SELECT installed FROM status WHERE game_id = ?',
+ (game_info['id'], ))
+ .fetchone()
+ )
+ if row is None:
+ return False
+ return bool(row[0])
+
+ def set_installed(self, game_info, installed=True):
+ cursor = self.conn.cursor()
+ row = (
+ cursor
+ .execute('SELECT installed FROM status WHERE game_id = ?',
+ (game_info['id'], ))
+ .fetchone()
+ )
+ with self.conn:
+ if row is None:
+ cursor.execute('''
+ INSERT INTO status
+ (game_id, installed, version_installed)
+ VALUES (?, ?, ?)
+ ''', (
+ game_info['id'],
+ installed,
+ game_info['version'],
+ ))
+ else:
+ cursor.execute('''
+ UPDATE status SET
+ installed = ?,
+ version_installed = ?
+ WHERE game_id = ?
+ ''', (
+ installed,
+ game_info['version'],
+ game_info['id'],
+ ))
+
+ def set_last_played_game(self, game_id):
+ #cursor = self.conn.cursor()
+ #with self.conn:
+ # cursor.execute('''
+ # INSERT INTO
+ # game_id text,
+ # profile_id text,
+ # type text,
+ # ''')
+ pass
+
+ def set_last_played_profile(self, profile_id):
+ pass
+
+ def get_installed(self):
+ for row in (
+ self.conn
+ .execute('SELECT game_id FROM status WHERE installed = 1')
+ ):
+ yield row[0]
+
+ def set_uninstalled(self, game_info):
+ return self.set_installed(game_info, installed=False)
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc_value, traceback):
+ self.close()
+
+
+if __name__ == "__main__":
+ status_db = StatusDB()
diff --git a/gamechestcli/src/gamechest/structures.py b/gamechestcli/src/gamechest/structures.py
new file mode 100644
index 0000000..35a8861
--- /dev/null
+++ b/gamechestcli/src/gamechest/structures.py
@@ -0,0 +1,38 @@
+from typing import NamedTuple
+
+
+class Progress(NamedTuple):
+ '''
+ linemode(False)
+ Change number of bytes to number of lines in nbbytes field. Same is
+ true for speed field which in this case will be expressed as lines per
+ second.
+
+ percent(0)
+ Current percentage for the current step.
+
+ step(0)
+ Current step index.
+
+ steps_count(1)
+ Number of steps.
+
+ nbbytes(0)
+ Number of bytes currently processed in this step.
+
+ speed(0)
+ Current number of bytes per second processed.
+
+ eta("inifinite")
+ Current estimation for the end of this step.
+ '''
+
+ linemode: bool = False
+
+ percent: int = 0
+ step: int = 0
+ steps_count: int = 1
+
+ nbbytes: int = 0
+ speed: int = 0
+ eta: str = "infinite"