From f9504a960529808dc5e4ef7726f0f27bab8f8d00 Mon Sep 17 00:00:00 2001 From: Marko Semet Date: Fri, 13 Mar 2020 17:16:49 +0100 Subject: [PATCH] Start --- .gitignore | 1 + setup.py | 10 + winestarter/__main__.py | 47 +++++ winestarter/config.py | 43 ++++ winestarter/gui.glade | 123 ++++++++++++ winestarter/gui.py | 101 ++++++++++ winestarter/instance.py | 260 ++++++++++++++++++++++++ winestarter/paths.py | 77 +++++++ winestarter/templates.glade | 391 ++++++++++++++++++++++++++++++++++++ winestarter/wine.py | 100 +++++++++ 10 files changed, 1153 insertions(+) create mode 100644 setup.py create mode 100644 winestarter/__main__.py create mode 100644 winestarter/config.py create mode 100644 winestarter/gui.glade create mode 100644 winestarter/gui.py create mode 100644 winestarter/instance.py create mode 100644 winestarter/paths.py create mode 100644 winestarter/templates.glade create mode 100644 winestarter/wine.py diff --git a/.gitignore b/.gitignore index 13d1490..069549f 100644 --- a/.gitignore +++ b/.gitignore @@ -129,3 +129,4 @@ dmypy.json # Pyre type checker .pyre/ +*.glade~ \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..1f19766 --- /dev/null +++ b/setup.py @@ -0,0 +1,10 @@ +from distutils.core import setup + +setup(name="winestarter", + version="0.0.1-dev", + description="Setup tools for wine", + author="Marko Semet", + author_email="marko@marko10-000.de", + url="https://marko10-000.de/project/winestarter", + packages=["winestarter"] + ) diff --git a/winestarter/__main__.py b/winestarter/__main__.py new file mode 100644 index 0000000..d45d8b3 --- /dev/null +++ b/winestarter/__main__.py @@ -0,0 +1,47 @@ +from . import gui +import asyncio +import argparse +import threading + + +LOOP = asyncio.get_event_loop() + +# Args parse +parser = argparse.ArgumentParser(description="Wine Starter") +parser.add_argument("--config", "-c", dest="configs", type=str, + action="append", nargs=1, help="Configuration to make software runable.") +parser.add_argument("--not-run", nargs="?", const=True, + default=False, help="Doesn't start software directly.") +parser.add_argument("start", type=str, nargs="?", + const=None, help="Instance to run.") +args = parser.parse_args() +print(repr(args)) + + +# Run gui and asyncio +_terminate = threading.Lock() +_terminate.acquire() + + +async def _run_async(): + # Wait for gui thread init + while not _terminate.acquire(blocking=False): + await asyncio.sleep(0) + + # Set main window + + # Wait for async ends + while not _terminate.acquire(blocking=False): + await asyncio.sleep(0.1) + + +def _run_gui(): + def inited_func(): + _terminate.release() + gui.run_gui(inited=inited_func, configs=list( + map(lambda x: x[0], args.configs))) + _terminate.release() + + +threading.Thread(target=_run_gui).start() +LOOP.run_until_complete(_run_async()) diff --git a/winestarter/config.py b/winestarter/config.py new file mode 100644 index 0000000..6306866 --- /dev/null +++ b/winestarter/config.py @@ -0,0 +1,43 @@ +from . import paths +import asyncio +import hashlib +import json +import os + + +def _hash_instance(instance: str): + # Get instance hash + hash = hashlib.sha3_256() + hash.update(instance.encode()) + return hash.hexdigest() + + +async def list_installed_hashes(instance: str): + instance_hash = _hash_instance(instance) + archives = [] + archives_proc = await asyncio.create_subprocess_exec([b"borg", b"list", b"--json-lines", paths.BACKUP_DIR.decode()], stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) + result_code = await archives_proc.communicate() + if result_code == 0: + for i in filter(bool, archives_proc.stdout.splitlines()): + print("list: " + repr(i)) + return archives + + +async def _gen_backup_system(): + # TODO: check if exists + + # Gen archive + gen_proc = await asyncio.create_subprocess_exec([b"borg", b"init", b"--json", b"--encryption", b"none", os.path.join(paths.INSTANCE_DIR, instance).decode()]) + result = await gen_proc.communicate() + return result == 0 + + +async def gen_backup_init(instance: str, hash: str, volumes: list): + # Gen backupsystem + await _gen_backup_system() + + # Gen backup + name = ("install_" + _hash_instance(instance) + "_" + hash).lower() + archive_proc = await asyncio.create_subprocess_exec([b"borg", b"create", b"--json", b"--progress", b"-C", b"zstd,5", paths.BACKUP_DIR.decode() + b"::" + name.decode(), os.path.join(paths.INSTANCE_DIR, instance).decode()]) + result = await archive_proc.communicate() + return result == 0 diff --git a/winestarter/gui.glade b/winestarter/gui.glade new file mode 100644 index 0000000..dd7ab28 --- /dev/null +++ b/winestarter/gui.glade @@ -0,0 +1,123 @@ + + + + + + True + False + + + gtk-preferences + True + False + True + True + + + + + gtk-dialog-info + True + False + True + True + + + + + 800 + 600 + False + + + True + False + Wine Starter + <Application> + True + + + True + True + True + mainMenu + + + + + + end + + + + + + + True + False + + + True + False + vertical + + + True + False + vertical + main_content + + + True + True + 0 + + + + + gtk-add + True + True + True + True + True + + + False + True + 1 + + + + + False + True + 0 + + + + + True + False + + + True + False + <TO REPLACE> + + + page0 + page0 + + + + + True + True + 1 + + + + + + diff --git a/winestarter/gui.py b/winestarter/gui.py new file mode 100644 index 0000000..20625a3 --- /dev/null +++ b/winestarter/gui.py @@ -0,0 +1,101 @@ +from . import instance +import os +from gi.repository import Gtk, Gio + + +def __loader_helper(): + import gi + gi.require_version("Gtk", "3.0") + + +__loader_helper() + + +# Templates +_template_file = os.path.join(os.path.split(__file__)[0], "templates.glade") + + +@Gtk.Template(filename=_template_file) +class _StartWidget(Gtk.OffscreenWindow): + __gtype_name__ = "StartWidget" + template_type = "StartWidget" + + __image = Gtk.Template.Child("start_image") + __name: Gtk.Label = Gtk.Template.Child("start_name") + __settings = Gtk.Template.Child("start_settings") + __run = Gtk.Template.Child("start_run") + __add_snapshot = Gtk.Template.Child("start_add_snapshot") + __snapshots = Gtk.Template.Child("start_snapshots") + + def __init__(self, name, *args, **kargs): + super(*args, **kargs) + self.__name.set_label(name) + + +@Gtk.Template(filename=_template_file) +class _AboutDialog(Gtk.AboutDialog): + __gtype_name__ = "AboutDialog" + template_type = "AboutDialog" + + +# Application +class _Application(Gtk.Application): + _builder: Gtk.Builder + _window: Gtk.ApplicationWindow + _stack: Gtk.Stack + _configsSource: list + _instance: str + _configs: dict + _inited = None + + def __init__(self, inited, configs: list = [], instance: str = None, *args, **kargs): + super().__init__(*args, application_id="de.marko10_000.WineStarter", **kargs) + self._configsSource = list(configs) + self._instance = str(instance) + self._inited = inited + + def do_startup(self): + # Load configs + configs = self._configs = {} + for i in self._configsSource: + tmp = instance.gen_instance(i) + configs[tmp.get_instance()] = tmp + + # Start gtk application + Gtk.Application.do_startup(self) + + def do_activate(self): + # Load gui + self._builder = builder = Gtk.Builder() + builder.add_from_file(os.path.join( + os.path.split(__file__)[0], "gui.glade")) + + # Gen gui + self._window = builder.get_object("main_window") + self._stack = builder.get_object("main_content") + if len(self._configs) == 1: + self._window.remove(self._window.get_child()) + else: + for i in self._stack.get_children(): + self._stack.remove(i) + + # Add main window callbacks + def show_about(menu_entry): + _AboutDialog(parent=self._window).show_all() + builder.get_object("mainMenuAbout").connect("activate", show_about) + + # Show main window + self._window.set_application(self) + self._window.show_all() + + # Notify init end + self._inited() + + +app: _Application + + +def run_gui(inited=lambda: None, configs: list = None): + global app + app = _Application(inited=inited, configs=configs) + app.run() diff --git a/winestarter/instance.py b/winestarter/instance.py new file mode 100644 index 0000000..ed40d8e --- /dev/null +++ b/winestarter/instance.py @@ -0,0 +1,260 @@ +import abc +import asyncio +import os +import yaml +from . import paths, wine + + +# Step +class Step(abc.ABC): + @abc.abstractclassmethod + async def run(self, wine, instance): + raise NotImplementedError() + + @abc.abstractclassmethod + async def content_to_hash(self): + raise NotImplementedError() + + +class _StepPreInit(Step): + async def run(self, wine, instance): + pass + + async def content_to_hash(self): + return b"" + + +class _StepWineInit(Step): + async def run(self, wine, isinstance): + return await wine.run_command([b"wineboot", b"--init"], with_display=False) + # TODO: gen volumes + + async def content_to_hash(self): + version = await asyncio.create_subprocess_exec(["wine", "--version"], stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) + result = version.communicate() + if result != 0: + raise RuntimeError("Can't get wine version.") + return version.stdout.splitlines()[0] + + +class _StepExec(Step): + __command: list + __in_drive_c: bool + __cwd_path: str + + def __init__(self, command, in_drive_c: bool = False, cwd_path: str = None): + self.__command = list(map(str.encode, command)) + self.__in_drive_c = in_drive_c + self.__cwd_path = cwd_path + + async def run(self, wine, instance): + tmp_cwd = self.__cwd_path + if self.__in_drive_c: + tmp_cwd = None + return await wine.run_command(command=self.__command, cwd_in_instance=self.__in_drive_c, cwd_path=tmp_cwd) + + async def content_to_hash(self): + return b"\x00".join(self.__command) + + +# Instance management +class Instance(): + __instance: str + __run: _StepExec + __wine: wine.WineConfig + __steps: list + __volumes: dict + + def __init__(self, instance: str, run: _StepExec, steps: list = None, volumes: dict = {}): + if not instance: + raise ValueError("instance name can't be empty") + self.__instance = str(instance) + self.__run = run + self.__wine = wine.WineConfig(os.path.join( + paths.INSTANCE_DIR, instance), os.path.join(paths.FAKEHOME_DIR, instance)) + self.__steps = list(steps) + self.__volumes = dict(volumes) + + async def __run_install(self, name: str, step: Step, last_hash: bytes): + if not isinstance(step, Step): + raise ValueError("step have to be a Step.") + # TODO: Check if allready done + # TODO: Update gui (show install window) + # TODO: Update gui (show that step is running) + result = step.run(self.__wine, self) + # TODO: Create backup + + async def install(self): + # TODO: Set gui information + last_hash = await self.__run_install("", _StepPreInit(), b"") + last_hash = await self.__run_install("init", _StepWineInit(), last_hash) + for name, step in self.__steps: + last_hash = await self.__run_install(name, step, last_hash) + + def get_instance(self): + return self.__instance + + async def run(self): + await self.install() + # TODO: Update gui (hide main window) + await self.__run.run(self.__wine, self) + + +# Create instance +def _get_relative_file(current_file, file): + return os.path.abspath(os.path.join(os.path.split(current_file)[0], file)) + + +def _gen_run(file, config): + config = dict(config) + + # Load command + if "command" not in config: + raise ValueError( + "Can't find command in exec in file " + repr(file) + ".") + if not isinstance(config["command"], list): + raise ValueError( + "Command in exec is not an array in file " + repr(file) + ".") + command = config["command"] + del config["command"] + + # Check if use in_drive_c + in_drive_c = False + if "in_drive_c" in config: + if config["in_drive_c"]: + in_drive_c = True + del config["in_drive_c"] + + # Check for unused arguments + if config: + raise ValueError("Unused attributes " + + ", ".join(config.keys()) + " in file " + repr(file) + ".") + + # Return new exec step + return _StepExec(command=command, in_drive_c=in_drive_c) + + +def _load_volumes(file, config): + # Precheck + if not isinstance(config, list): + raise ValueError( + "Volume list have to be a list in file " + repr(file) + ".") + + # Volumes + volumes = [] + for i in config: + # Check entry + if not isinstance(i, dict): + raise ValueError( + "Volume instance have to be an dict in file " + repr(file) + ".") + i = dict(i) + + # Load name and path + if "name" not in i: + raise ValueError( + "Can't find name in volume info in file " + repr(file) + ".") + if "path" not in i: + raise ValueError( + "Can't find path in volume info in file " + repr(file) + ".") + name = str(i["name"]) + path = str(i["path"]) + del i["name"] + del i["path"] + volumes.append((name, path)) + + # Check for unused attributes + if i: + raise ValueError("Volume has unused attirbutes " + + ", ".join(i.keys()) + " in file " + repr(file) + ".") + return volumes + + +def _load_module(file: str, content: dict): + # Check if string to load file + if isinstance(content, str): + file = _get_relative_file(file, content) + with open(file, "rb") as f: + content = yaml.load(f, Loader=yaml.SafeLoader) + content = dict(content) + + # Load sub modules + result = [] + volumes = [] + if "modules" in content: + modules = content["modules"] + del content["modules"] + if not isinstance(modules, list): + raise ValueError( + "\"modules\" isn't a list in file " + repr(modules) + ".") + for i in modules: + tmp_result, tmp_volumes = _load_module(file, i) + result += tmp_result + volumes += tmp_volumes + + # Load volumes + if "volumes" in content: + volumes += _load_volumes(file, content["volumes"]) + del content["volumes"] + + # Load name + if "name" not in content: + raise ValueError( + "name is missing in module in file " + repr(file) + ".") + name = content["name"] + del content["name"] + + # Check if type exists + if "type" not in content: + raise ValueError("Can't find type in file " + repr(file) + ".") + + type_name = content["type"] + del content["type"] + if type_name == "exec": + if tuple(content.keys()) != ("run",): + raise ValueError("Exec only supports \"run\" attribute but has " + + ", ".join(content.keys()) + " in file " + repr(file) + ".") + result.append((name, _gen_run(file, content["run"]))) + else: + raise ValueError("Type " + repr(type_name) + + " is unknown in file " + repr(file) + ".") + + # Cleanup modules + return result, volumes + + +def gen_instance(file): + # Load base data + with open(file, "rb") as f: + content = dict(yaml.load(f, Loader=yaml.SafeLoader)) + if "name" not in content: + raise ValueError("Can't find name inside of " + repr(file) + ".") + name = content["name"] + del content["name"] + + # Load run + if "run" not in content: + raise ValueError("Can't find run inside of " + repr(file) + ".") + run = _gen_run(file, content["run"]) + del content["run"] + + # Load modules + modules = [] + volumes = [] + if "modules" not in content: + raise ValueError("Can't find \"modules\" in " + repr(file) + ".") + if not isinstance(content["modules"], list): + raise ValueError( + "\"modules\" have to be an array in file " + repr(file) + ".") + for i in content["modules"]: + tmp_modules, tmp_volumes = _load_module(file, i) + modules += tmp_modules + volumes += tmp_volumes + del content["modules"] + + # Check unused attributes + if content: + raise ValueError("Unused attributes " + + ", ".join(content.keys()) + " in file " + repr(file) + ".") + + # Return new instance + return Instance(instance=name, run=run, steps=modules, volumes=dict(volumes)) diff --git a/winestarter/paths.py b/winestarter/paths.py new file mode 100644 index 0000000..0b6fce2 --- /dev/null +++ b/winestarter/paths.py @@ -0,0 +1,77 @@ +import os + + +# Find dirs +INSTANCE_DIR = None +FAKEHOME_DIR = None +CONFIG_DIR = None +BACKUP_DIR = None +DOWNLOADS_DIR = None +if os.environ.get("XDG_CACHE_HOME", None) is not None: + # Check if in flatpak + if os.environ.get("FLATPAK_ID", None) is None: + tmp = os.path.join(os.environ.get("XDG_CACHE_HOME"), "winestarter") + else: + tmp = os.environ.get("XDG_CACHE_HOME") + + # Set paths for backup and downloads + if BACKUP_DIR is None: + BACKUP_DIR = os.path.join(tmp, "backups") + if DOWNLOADS_DIR is None: + DOWNLOADS_DIR = os.path.join(tmp, "downloads") + del tmp +if os.environ.get("XDG_CONFIG_HOME", None) is not None: + # Check if in flatpak + if os.environ.get("FLATPAK_ID", None) is None: + tmp = os.path.join(os.environ.get("XDG_CONFIG_HOME"), "winestarter") + else: + tmp = os.environ.get("XDG_CONFIG_HOME") + + # Set paths for config dir + if CONFIG_DIR is None: + CONFIG_DIR = os.path.join(tmp, "config") + del tmp +if os.environ.get("XDG_DATA_HOME", None) is not None: + # Check if in flatpak + if os.environ.get("FLATPAK_ID", None) is None: + tmp = os.path.join(os.environ.get("XDG_DATA_HOME"), "winestarter") + else: + tmp = os.environ.get("XDG_DATA_HOME") + + # Set paths for instances, fakehomes, config, backups and downloads + if INSTANCE_DIR is None: + INSTANCE_DIR = os.path.join(tmp, "instances") + if FAKEHOME_DIR is None: + FAKEHOME_DIR = os.path.join(tmp, "fakehomes") + if CONFIG_DIR is None: + CONFIG_DIR = os.path.join(tmp, "config") + if BACKUP_DIR is None: + BACKUP_DIR = os.path.join(tmp, "backups") + if DOWNLOADS_DIR is None: + DOWNLOADS_DIR = os.path.join(tmp, "downloads") + del tmp +if os.environ.get("HOME", None): + # As backup the home dir + tmp = os.path.join(os.environ.get("HOME"), ".winestarter") + if INSTANCE_DIR is None: + INSTANCE_DIR = os.path.join(tmp, "instances") + if FAKEHOME_DIR is None: + FAKEHOME_DIR = os.path.join(tmp, "fakehomes") + if CONFIG_DIR is None: + CONFIG_DIR = os.path.join(tmp, "config") + if BACKUP_DIR is None: + BACKUP_DIR = os.path.join(tmp, "backups") + if DOWNLOADS_DIR is None: + DOWNLOADS_DIR = os.path.join(tmp, "downloads") + del tmp +if None in (INSTANCE_DIR, FAKEHOME_DIR, CONFIG_DIR, BACKUP_DIR, DOWNLOADS_DIR): + print(repr((INSTANCE_DIR, FAKEHOME_DIR, CONFIG_DIR, BACKUP_DIR, DOWNLOADS_DIR))) + raise ValueError("Can't find HOME or XDG_DATA_HOME") + + +# Create paths +os.makedirs(INSTANCE_DIR, exist_ok=True) +os.makedirs(FAKEHOME_DIR, exist_ok=True) +os.makedirs(CONFIG_DIR, exist_ok=True) +os.makedirs(BACKUP_DIR, exist_ok=True) +os.makedirs(DOWNLOADS_DIR, exist_ok=True) diff --git a/winestarter/templates.glade b/winestarter/templates.glade new file mode 100644 index 0000000..e9a4cf4 --- /dev/null +++ b/winestarter/templates.glade @@ -0,0 +1,391 @@ + + + + + + + False + + + + + + True + True + in + + + True + False + + + True + False + 0 + 0 + + + True + False + vertical + + + True + False + 0 + 0 + + + True + False + vertical + + + True + False + 0 + 0 + + + True + False + 32 + gtk-cdrom + 6 + + + + + False + True + 0 + + + + + True + False + 0 + 0 + + + True + False + + + True + False + <Application> + + + False + True + 0 + + + + + True + True + True + none + + + + + + False + True + end + 1 + + + + + + + False + True + 1 + + + + + True + True + True + 8 + True + + + True + False + 0 + 0 + + + True + False + + + True + False + gtk-media-play + + + False + True + 0 + + + + + True + False + RUN + + + False + True + 1 + + + + + + + + + False + True + 2 + + + + + + + False + True + 0 + + + + + 250 + True + False + 32 + vertical + + + True + False + + + True + False + 0 + 1 + 0 + + + True + False + Snapshots: + start + True + + + + + True + True + 0 + + + + + True + True + True + none + + + True + False + gtk-add + + + + + False + True + 1 + + + + + False + True + 0 + + + + + True + False + + + False + True + 1 + + + + + False + True + 1 + + + + + + + + + + + + + False + + + + + + True + True + in + + + True + False + + + True + False + 0 + 0 + + + True + False + vertical + + + True + False + + + True + False + gtk-cdrom + 6 + + + + + False + True + 0 + + + + + True + False + + + True + False + <Application> + + + + + False + True + 1 + + + + + True + False + + + True + False + none + + + + + False + True + 2 + + + + + + + + + + + + diff --git a/winestarter/wine.py b/winestarter/wine.py new file mode 100644 index 0000000..97bedd3 --- /dev/null +++ b/winestarter/wine.py @@ -0,0 +1,100 @@ +import asyncio +import os + + +class _Result(): + stdout = str + stderr = str + result = int + + def __init__(self, result, stdout, stderr): + # Check values + if not isinstance(result, int): + raise ValueError("result have to be an int.") + if not isinstance(stdout, str): + raise ValueError("stdout have to be an string.") + if not isinstance(stderr, str): + raise ValueError("stderr have to be an string.") + + # Set values + self.stdout = stdout + self.stderr = stderr + self.result = result + + def __bool__(self): + return self.result == 0 + + +class _RunCommand(): + __args = list + __envs = dict + __cwd: str + + def __init__(self, args, envs): + self.__args = args + self.__envs = envs + self.__cwd = None + + @property + def cwd(self): + return self.__cwd + + @cwd.setter + def cwd(self, cwd: str): + if self.__cwd is not None: + raise RuntimeError("change dir can be done onle ones.") + if not isinstance(cwd, str): + raise ValueError("change dir have to be a string.") + self.__cwd = cwd + + async def run(self): + proc = await asyncio.create_subprocess_exec(self.__args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=self.__envs, cwd=self.__cwd) + result_code = await proc.communicate() + return _Result(result_code, proc.stdout.decode(), proc.stderr.decode()) + + +class WineConfig(): + path = str + home = str + + def __init__(self, path, home): + # Check values + if not isinstance(path, str): + raise ValueError("path have to be a string.") + if not isinstance(home, str): + raise ValueError("home have to be a string.") + + # Set values + self.path = path + self.home = home + + def preapre_command(self, args, envs={}, with_display=True, cwd_in_instance: bool = False, cwd_path: str = None): + envs = dict(envs) + + # Set base env + envs["HOME"] = self.home + envs["WINEPREFIX"] = self.path + + # Check if display have to remove + if not with_display: + envs["DISPLAY"] = "" + envs["WAYLAND_DISPLAY"] = "" + + # Create prepared command + result = _RunCommand(args, envs) + + # Change dir into instance + if cwd_in_instance: + result.cwd = os.path.join(self.path, "drive_c") + if cwd_path: + if cwd_in_instance: + raise ValueError( + "cwd_path and cwd_in_instance can't be set at the same time.") + result.cwd = cwd_path + + # Return result + return result + + async def run_command(self, *args, **kargs): + cmd = self.preapre_command(*args, **kargs) + return await cmd.run()