[game-data-packager] 06/09: runtime: add a generic Gtk launcher, initially for Unreal

Simon McVittie smcv at debian.org
Mon Jan 4 09:04:50 UTC 2016


This is an automated email from the git hooks/post-receive script.

smcv pushed a commit to branch master
in repository game-data-packager.

commit 4e733a9897184cbd1dfdfde3829613da1bfe325b
Author: Simon McVittie <smcv at debian.org>
Date:   Sun Jan 3 23:27:21 2016 +0000

    runtime: add a generic Gtk launcher, initially for Unreal
---
 Makefile                        |   3 +
 doc/launcher.mdwn               | 103 ++++++++++
 runtime/confirm-binary-only.txt |   8 +
 runtime/launcher.py             | 403 ++++++++++++++++++++++++++++++++++++++++
 runtime/missing-data.txt        |   4 +
 5 files changed, 521 insertions(+)

diff --git a/Makefile b/Makefile
index a7ab191..667f29f 100644
--- a/Makefile
+++ b/Makefile
@@ -117,6 +117,7 @@ install: default
 
 	mkdir -p $(DESTDIR)/usr/share/games/game-data-packager
 	cp -ar game_data_packager/                             $(DESTDIR)/usr/share/games/game-data-packager/
+	install runtime/launcher.py                            $(DESTDIR)/usr/share/games/game-data-packager/gdp-launcher
 	install -m0644 out/*.copyright                         $(DESTDIR)/usr/share/games/game-data-packager/
 	install -m0644 out/*.png                               $(DESTDIR)/usr/share/games/game-data-packager/
 	install -m0644 out/*.svgz                              $(DESTDIR)/usr/share/games/game-data-packager/
@@ -143,6 +144,8 @@ install: default
 	install -m0644 runtime/doom2-masterlevels.desktop      $(DESTDIR)/usr/share/applications/
 	install -m0644 doc/doom2-masterlevels.6                $(DESTDIR)/usr/share/man/man6/
 	install -m0644 out/doom-common.png                     $(DESTDIR)/usr/share/pixmaps/doom2-masterlevels.png
+	install -m0644 data/confirm-binary-only.txt            $(DESTDIR)/usr/share/games/game-data-packager/
+	install -m0644 data/missing-data.txt                   $(DESTDIR)/usr/share/games/game-data-packager/
 
 # Requires additional setup, so not part of "make check"
 manual-check:
diff --git a/doc/launcher.mdwn b/doc/launcher.mdwn
new file mode 100644
index 0000000..2c9ce87
--- /dev/null
+++ b/doc/launcher.mdwn
@@ -0,0 +1,103 @@
+game-data-packager's Gtk launcher stub
+======================================
+
+Here are some design notes about the Gtk game launcher.
+
+Requirements
+------------
+
+All of these are already implemented, and should be kept.
+
+* Written in a high-level language (not a shell script with Zenity or
+  xdialog)
+  - complex shell scripts are a pain to keep maintainable
+
+* All logic is in `game-data-packager.deb` or a future
+  `game-data-packager-runtime.deb`, not in the .deb that g-d-p produces
+  - the logic might have bugs which we want to fix
+  - we can rely on being able to update g-d-p itself via normal Debian
+    mechanisms
+  - we cannot rely on being able to update anything in a g-d-p-generated
+    .deb
+
+* Has a GUI
+  - games are often run from menu systems
+
+* Can check whether a representative sample of required files are present
+  - some games' data sets have non-obvious tangles of dependencies
+
+* Each user is prompted before running a binary-only game like Unreal
+  (or eventually Quake 4, but for now Quake 4 is handled by src:quake),
+  so they can choose to not run it
+  - this is per-user so that users can choose to run all binary-only
+    games as uid "jbloggs-games" or something, and protect their
+    (potentially root-equivalent) normal uid "jbloggs" from attacks
+    via compromised games
+
+* The launcher does not `chdir()` or manipulate `LD_LIBRARY_PATH` until the
+  binary-only game is actually run, and the `.desktop` file does not have
+  to use `WorkingDirectory=`
+  - again, this is to protect individual uids from being attacked via a
+    compromised game that they accidentally run from a menu, even if `.`
+    is already (unwisely) on a search path
+
+* Optionally constructs a symbolic link farm from one or more system-wide
+  search directories
+  - this is required for Unreal 1
+
+* If constructing a symlink farm, can copy selected files instead of
+  linking them
+  - this is required for `*.ini` in Unreal 1
+
+* Games can ship their own proprietary icons which will be used by our
+  .desktop files
+  - we don't have a Free reinterpretation of the logo for all games
+
+"Nice to have"
+--------------
+
+All of these are already implemented, and it would be nice to keep them.
+
+* Game metadata is also in `g-d-p.deb` or `g-d-p-runtime.deb`,
+  so we can fix its bugs
+  - As currently implemented, it's in an extra section in
+    /usr/share/games/g-d-p/unreal-gold.desktop
+    or similar. It isn't clear whether this is the right solution.
+
+* The game only installs proprietary files that we cannot fix anyway,
+  and symbolic links to stable paths provided by g-d-p or a separate
+  game engine like ioquake3
+  - `/usr/games/unreal-gold` -> `/usr/share/games/g-d-p/gdp-launcher`
+  - `/usr/share/applications/unreal-gold.desktop` ->
+    `/usr/share/games/g-d-p/unreal-gold.desktop`
+
+* Errors while launching the game are displayed in the GUI
+
+* Does not import from `game_data_packager` and does not rely
+  on the YAML/JSON, or only relies on a defined subset (tbd)
+  - this means we can consider splitting out
+    `game-data-packager-runtime.deb` in future
+
+* GUI is done with a modern toolkit that supports Wayland, etc.,
+  and has nice Python bindings
+  - implementation detail: it's currently Gtk 3
+
+* freedesktop.org basedir compliant
+  - uses `XDG_DATA_HOME`, etc., for games that do not already have a
+    well-established dot-directory
+
+* Implementation does not entirely rule out being able to install game data
+  "for just me" while unprivileged
+  - this may seem rather backwards, but there's some value in having
+    a tool like g-d-p shared between multiple games instead of each game
+    inventing its own
+
+TODO
+----
+
+* Ability to make e.g. `~/.loki/ut` a symlink to e.g. `$XDG_DATA_HOME/ut99`
+  if the former does not already exist?
+  - we would need to be prepared to fall back to `~/.loki/ut` being a real
+    directory, though
+
+* Maybe supersede the ad-hoc launchers in src:quake?
diff --git a/runtime/confirm-binary-only.txt b/runtime/confirm-binary-only.txt
new file mode 100644
index 0000000..a17d29d
--- /dev/null
+++ b/runtime/confirm-binary-only.txt
@@ -0,0 +1,8 @@
+${name} is a binary-only game and might contain security vulnerabilities
+or other bugs. If it does, ${distro} cannot fix them.
+
+Using this game for multiplayer on untrusted networks is not
+recommended. To protect personal files, you could create a dedicated
+user ID to run games.
+
+This message will be shown once for each user ID that runs ${name}.
diff --git a/runtime/launcher.py b/runtime/launcher.py
new file mode 100755
index 0000000..c86288b
--- /dev/null
+++ b/runtime/launcher.py
@@ -0,0 +1,403 @@
+#!/usr/bin/python3
+# encoding=utf-8
+
+# game-data-packager Gtk launcher stub. See doc/launcher.mdwn for design
+
+# Copyright © 2015-2016 Simon McVittie <smcv at debian.org>
+#
+# 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 2
+# 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.
+#
+# You can find the GPL license text on a Debian system under
+# /usr/share/common-licenses/GPL-2.
+
+import argparse
+import fnmatch
+import logging
+import os
+import shlex
+import shutil
+import string
+import sys
+import traceback
+
+import gi
+gi.require_version('Gtk', '3.0')
+
+from gi.repository import (GLib, GObject)
+from gi.repository import Gtk
+
+if 'GDP_UNINSTALLED' in os.environ:
+    GDP_DIR = './runtime'
+else:
+    GDP_DIR = '/usr/share/games/game-data-packager'
+
+GDL_GROUP = 'game-data-launcher'
+GDL_KEY_BINARY_ONLY = 'BinaryOnly'
+GDL_KEY_BASE_DIRECTORIES = 'BaseDirectories'
+GDL_KEY_REQUIRED_FILES = 'RequiredFiles'
+GDL_KEY_DOT_DIRECTORY = 'DotDirectory'
+GDL_KEY_LIBRARY_PATH = 'LibraryPath'
+GDL_KEY_LINK_FILES = 'LinkFiles'
+GDL_KEY_COPY_FILES = 'CopyFiles'
+GDL_KEY_WORKING_DIRECTORY = 'WorkingDirectory'
+GDL_KEY_EXEC = 'Exec'
+
+# Normalize environment so we can use ${XDG_DATA_HOME} unconditionally.
+# Do this before we use GLib functions that might create worker threads,
+# because setenv() is not thread-safe.
+ORIG_ENVIRON = os.environ.copy()
+os.environ.setdefault('XDG_CACHE_HOME', os.path.expanduser('~/.cache'))
+os.environ.setdefault('XDG_CONFIG_HOME', os.path.expanduser('~/.config'))
+os.environ.setdefault('XDG_CONFIG_DIRS', '/etc/xdg')
+os.environ.setdefault('XDG_DATA_HOME', os.path.expanduser('~/.local/share'))
+os.environ.setdefault('XDG_DATA_DIRS', '/usr/local/share:/usr/share')
+
+logger = logging.getLogger('game-data-packager.launcher')
+logging.basicConfig()
+
+if os.environ.get('GDP_DEBUG'):
+    logger.setLevel(logging.DEBUG)
+else:
+    logger.setLevel(logging.INFO)
+
+DISTRO = 'the distribution'
+
+try:
+    os_release = open('/usr/lib/os-release')
+except:
+    pass
+else:
+    for line in os_release:
+        if line.startswith('NAME='):
+            line = line[5:].strip()
+            if line.startswith('"'):
+                line = line.strip('"')
+            elif line.startswith("'"):
+                line = line.strip("'")
+            DISTRO = line
+
+def expand(path):
+    if path is None:
+        return None
+
+    return os.path.expanduser(os.path.expandvars(path))
+
+class Launcher:
+    def __init__(self, argv=None):
+        name = os.path.basename(sys.argv[0])
+
+        if name.endswith('.py'):
+            name = name[:-3]
+
+        parser = argparse.ArgumentParser()
+        parser.add_argument('--id', default=name,
+                help='identity of launched game (default: %s)' % name)
+        parser.add_argument('arguments', nargs='*',
+                help='arguments for the launched game')
+        self.args = parser.parse_args(argv)
+
+        self.id = self.args.id
+        self.keyfile = GLib.KeyFile()
+        self.keyfile.load_from_file(os.path.join(GDP_DIR,
+                    self.id + '.desktop'),
+                GLib.KeyFileFlags.NONE)
+
+        self.name = self.keyfile.get_string(GLib.KEY_FILE_DESKTOP_GROUP,
+            GLib.KEY_FILE_DESKTOP_KEY_NAME)
+        logger.debug('Name: %s', self.name)
+        GLib.set_application_name(self.name)
+
+        self.icon_name = self.keyfile.get_string(GLib.KEY_FILE_DESKTOP_GROUP,
+            GLib.KEY_FILE_DESKTOP_KEY_ICON)
+        logger.debug('Icon: %s', self.icon_name)
+
+        self.binary_only = self.keyfile.get_boolean(GDL_GROUP,
+            GDL_KEY_BINARY_ONLY)
+        logger.debug('Binary-only: %r', self.binary_only)
+        self.required_files = list(map(expand,
+                self.keyfile.get_string_list(GDL_GROUP,
+                    GDL_KEY_REQUIRED_FILES)))
+        logger.debug('Checked files: %r', sorted(self.required_files))
+
+        try:
+            self.dot_directory = expand(self.keyfile.get_string(GDL_GROUP,
+                GDL_KEY_DOT_DIRECTORY))
+        except:
+            self.dot_directory = expand('${XDG_DATA_HOME}/' + self.id)
+        logger.debug('Dot directory: %s', self.dot_directory)
+
+        try:
+            self.base_directories = list(map(expand,
+                    self.keyfile.get_string_list(GDL_GROUP,
+                        GDL_KEY_BASE_DIRECTORIES)))
+        except:
+            # this launcher is for binary-only games so assume /usr/lib
+            self.base_directories = ['/usr/lib/' + self.id]
+        logger.debug('Base directories: %r', self.base_directories)
+
+        try:
+            self.library_path = self.keyfile.get_string_list(GDL_GROUP,
+                GDL_KEY_LIBRARY_PATH)
+        except:
+            self.library_path = []
+        logger.debug('Library path: %r', self.library_path)
+
+        try:
+            self.working_directory = expand(self.keyfile.get_string(GDL_GROUP,
+                GDL_KEY_WORKING_DIRECTORY))
+        except:
+            self.working_directory = None
+        logger.debug('Working directory: %s', self.working_directory)
+
+        try:
+            self.link_files = self.keyfile.get_boolean(GDL_GROUP,
+                GDL_KEY_LINK_FILES)
+        except:
+            self.link_files = False
+        logger.debug('Link files: %r', self.link_files)
+
+        if self.link_files:
+            try:
+                self.copy_files = self.keyfile.get_string_list(GDL_GROUP,
+                    GDL_KEY_COPY_FILES)
+            except:
+                self.copy_files = []
+            logger.debug('... but copy files matching: %r', self.copy_files)
+        else:
+            self.copy_files = []
+
+        exec_ = self.keyfile.get_string(GDL_GROUP, GDL_KEY_EXEC)
+        self.argv = list(map(expand, shlex.split(exec_)))
+        logger.debug('Arguments: %r', self.argv)
+
+        self.exit_status = 1
+
+    def main(self):
+        have_all_data = True
+        warning_stamp = os.path.join(self.dot_directory,
+                'confirmed-binary-only.stamp')
+
+        for p in self.base_directories:
+            logger.debug('Searching: %s' % p)
+
+        # sanity check: game engines often don't cope well with missing data
+        for f in self.required_files:
+            logger.debug('looking for %s', f)
+            for p in self.base_directories:
+                logger.debug('looking for %s in %s', f, p)
+                if os.path.exists(os.path.join(p, f)):
+                    logger.debug('found %s in %s', f, p)
+                    break
+            else:
+                logger.warning('Data file is missing: %s' % f)
+                have_all_data = False
+
+        os.makedirs(self.dot_directory, exist_ok=True)
+
+        if not have_all_data:
+            gui = Gui(self)
+            gui.text_view.get_buffer().set_text(
+                    self.load_text('missing-data.txt', 'Data files missing'))
+            gui.window.show_all()
+            gui.check_box.hide()
+            Gtk.main()
+            sys.exit(72)    # EX_OSFILE
+
+        elif self.binary_only and not os.path.exists(warning_stamp):
+            self.exit_status = 77   # EX_NOPERM
+            gui = Gui(self)
+            gui.text_view.get_buffer().set_text(
+                    self.load_text('confirm-binary-only.txt',
+                        'Binary-only game, we cannot fix bugs or security '
+                        'vulnerabilities!'))
+            gui.check_box.bind_property('active', gui.ok_button, 'sensitive',
+                    GObject.BindingFlags.SYNC_CREATE)
+            gui.ok_button.connect('clicked', lambda _:
+                    self._confirm_binary_only_cb(gui))
+
+            gui.window.show_all()
+            Gtk.main()
+            sys.exit(self.exit_status)
+
+        else:
+            try:
+                self.exec_game()
+            except:
+                gui = Gui(self)
+                gui.text_view.get_buffer().set_text(traceback.format_exc())
+                gui.ok_button.set_sensitive(False)
+                gui.window.show_all()
+                gui.check_box.hide()
+                Gtk.main()
+                sys.exit(self.exit_status)
+            else:
+                raise AssertionError('exec_game should never return')
+
+    def flush(self):
+        for f in (sys.stdout, sys.stderr):
+            f.flush()
+
+    def _confirm_binary_only_cb(self, gui):
+        warning_stamp = os.path.join(self.dot_directory,
+                'confirmed-binary-only.stamp')
+
+        try:
+            open(warning_stamp, 'a').close()
+            self.exec_game()
+        except:
+            gui.text_view.get_buffer().set_text(traceback.format_exc())
+            gui.check_box.hide()
+            gui.ok_button.set_sensitive(False)
+
+    def exec_game(self, _unused=None):
+        self.exit_status = 69   # EX_UNAVAILABLE
+
+        if self.link_files:
+            logger.debug('linking in files')
+            # prune dangling symbolic links
+            if os.path.exists(self.dot_directory):
+                logger.debug('checking %r for dangling symlinks',
+                        self.dot_directory)
+                for dirpath, dirnames, filenames in os.walk(self.dot_directory):
+                    logger.debug('walking: %r %r %r', dirpath, dirnames,
+                            filenames)
+                    for filename in filenames:
+                        logger.debug('checking whether %r is a dangling '
+                                'symlink', filename)
+                        f = os.path.join(dirpath, filename)
+
+                        if not os.path.exists(f):
+                            logger.info('Removing dangling symlink %s', f)
+                            os.remove(f)
+
+            logger.debug('%r', self.base_directories)
+
+            # symlink in all base directories, highest priority first
+            for p in self.base_directories:
+                logger.debug('Searching for files to link in %s', p)
+                for dirpath, dirnames, filenames in os.walk(p):
+                    logger.debug('walking: %r %r %r', dirpath, dirnames,
+                            filenames)
+                    for filename in filenames:
+                        logger.debug('ensuring that %s is symlinked in',
+                                filename)
+
+                        f = os.path.join(dirpath, filename)
+                        logger.debug('%s', f)
+                        assert f.startswith(p + '/')
+
+                        target = os.path.join(self.dot_directory,
+                                f[len(p) + 1:])
+                        d = os.path.dirname(target)
+
+                        if os.path.exists(target):
+                            logger.debug('Already exists: %s', target)
+                            continue
+
+                        if os.path.lexists(target):
+                            logger.info('Removing dangling symlink %s', target)
+                            os.remove(target)
+
+                        if d:
+                            logger.info('Creating directory: %s', d)
+                            os.makedirs(d, exist_ok=True)
+
+                        for pattern in self.copy_files:
+                            if fnmatch.fnmatch(f, pattern):
+                                logger.info('Copying %s -> %s', f, target)
+                                shutil.copyfile(f, target)
+                                break
+                        else:
+                            logger.info('Symlinking %s -> %s', f, target)
+                            os.symlink(f, target)
+        else:
+            logger.debug('not linking in files')
+
+        if self.working_directory is not None:
+            os.chdir(self.working_directory)
+
+        self.flush()
+
+        environ = os.environ.copy()
+
+        library_path = self.library_path[:]
+
+        if 'LD_LIBRARY_PATH' in environ:
+            library_path.append(environ['LD_LIBRARY_PATH'])
+
+        environ['LD_LIBRARY_PATH'] = ':'.join(library_path)
+
+        os.execve(self.argv[0], self.argv + self.args.arguments, environ)
+
+        raise AssertionError('nope')
+        raise AssertionError('os.execve should never return')
+
+    def load_text(self, filename, placeholder):
+        for f in ('%s.%s' % (self.id, filename), filename):
+            try:
+                path = os.path.join(GDP_DIR, f)
+                text = open(path).read()
+            except OSError:
+                pass
+            else:
+                text = string.Template(text).safe_substitute(
+                        distro=DISTRO,
+                        name=self.name,
+                        )
+                # strip single \n
+                text = text.replace('\n\n', '\r\r').replace('\n', ' ')
+                text = text.replace('\r', '\n')
+                return text
+        else:
+            return placeholder
+
+class Gui:
+    def __init__(self, launcher):
+        self.window = Gtk.Window()
+        self.window.set_default_size(600, 300)
+        self.window.connect('delete-event', Gtk.main_quit)
+        self.window.set_title(launcher.name)
+        self.window.set_icon_name(launcher.icon_name)
+
+        self.grid = Gtk.Grid(row_spacing=6, column_spacing=6,
+                margin_top=12, margin_bottom=12, margin_start=12, margin_end=12)
+        self.window.add(self.grid)
+
+        image = Gtk.Image.new_from_icon_name(launcher.icon_name,
+                Gtk.IconSize.DIALOG)
+        image.set_valign(Gtk.Align.START)
+        self.grid.attach(image, 0, 0, 1, 1)
+
+        self.text_view = Gtk.TextView(editable=False, cursor_visible=False,
+            hexpand=True, vexpand=True, wrap_mode=Gtk.WrapMode.WORD,
+            top_margin=6, left_margin=6, right_margin=6, bottom_margin=6)
+        self.grid.attach(self.text_view, 1, 0, 1, 1)
+
+        subgrid = Gtk.Grid(column_spacing=6, column_homogeneous=True,
+                halign=Gtk.Align.END)
+
+        cancel_button = Gtk.Button.new_with_label('Cancel')
+        cancel_button.connect('clicked', Gtk.main_quit)
+        subgrid.attach(cancel_button, 0, 0, 1, 1)
+
+        self.check_box = Gtk.CheckButton.new_with_label("I'll be careful")
+        self.check_box.set_hexpand(True)
+        self.grid.attach(self.check_box, 0, 1, 2, 1)
+
+        self.ok_button = Gtk.Button.new_with_label('Run')
+        self.ok_button.set_sensitive(False)
+        subgrid.attach(self.ok_button, 1, 0, 1, 1)
+
+        self.grid.attach(subgrid, 0, 2, 2, 1)
+
+        self.window.show_all()
+
+if __name__ == '__main__':
+    Launcher().main()
diff --git a/runtime/missing-data.txt b/runtime/missing-data.txt
new file mode 100644
index 0000000..9cc3c10
--- /dev/null
+++ b/runtime/missing-data.txt
@@ -0,0 +1,4 @@
+Required data files are missing.
+
+Please use game-data-packager to build and install the data packages
+for ${name}.

-- 
Alioth's /usr/local/bin/git-commit-notice on /srv/git.debian.org/git/pkg-games/game-data-packager.git



More information about the Pkg-games-commits mailing list