#!/usr/bin/env python """ Usage: install-lmk [--from ] [options] The available project names are: classic (or final or minetest) to install ~/minetest-rsync, finetest (or fine) to install ~/finetest-rsync (for the game that ripped off Multicraft.org's name), or trolltest (or troll) to install ~/trolltest-rsync (based on MT5). If the current directory is not ~/minetest-rsync, the suffix "local" will be used for installed directories and shortcuts instead of rsync to indicate you are using a downloaded copy (not using a copy obtained via rsync access to the build server). Options: --from Install from this directory. Defaults to "$HOME/minebest-rsync/mtkit/minetest". --server Require a binary ending with "server" to be present in built dir. Defaults to False. --client Require a binary ending with "server" to be present in built dir. Defaults to True, but False if --server is used without --client. """ from __future__ import print_function import copy import json import os import platform import shutil import stat import sys import tempfile from pprint import pformat if platform.system() == "Windows": HOME = os.environ['USERPROFILE'] SHORTCUTS_DIR = os.path.join(HOME, "Desktop") elif platform.system() == "Darwin": HOME = os.environ['HOME'] SHORTCUTS_DIR = os.path.join(HOME, "Desktop") else: HOME = os.environ['HOME'] SHORTCUTS_DIR = os.path.join(HOME, ".local", "share", "applications") if sys.version_info.major < 3: FileNotFoundError = IOError ModuleNotFoundError = ImportError INSTALL_SRC = os.path.join(HOME, "minebest-rsync", "mtkit", "minetest") # ^ Changed later if detected in current dir (in use_if_source). DETECT_KIT_SUBDIRS = ["minetest", "mtsrc"] # Use via detect_source # ^ First entry of DETECT_KIT_SUBDIRS has to be the new INSTALL_SRC! VARIANT = "rsync" # Changed to "local" if not in universal directory! MINETEST_KEYWORDS = ("sandbox;world;mining;crafting;blocks;nodes;multiplayer;" "roleplaying;") project_metas = { 'classic': { # minetest is the project name (in mtsrc/newline dir) 'shortcut': { 'GenericName': "Final Minetest", 'Keywords': MINETEST_KEYWORDS, }, 'dirname': "minetest", 'name_and_variant_fmt': "Final Minetest ({})", 'name': "Final Minetest", 'shortcut_exe_relpaths': [ os.path.join("bin", "minetest"), ], 'platform_icon_relpath': { 'Linux': os.path.join("misc", "minetest.svg"), 'Darwin': os.path.join("misc", "minetest-icon.icns"), 'Windows': os.path.join("misc", "minetest-icon-24x24.png"), }, 'shortcut_relpath': os.path.join("misc", "net.minetest.minetest.desktop"), 'shortcut_name_noext': "org.minetest.minetest", }, 'finetest': { 'shortcut': { 'GenericName': "Finetest", 'Keywords': MINETEST_KEYWORDS+"minetest;", }, 'dirname': "finetest", 'name_and_variant_fmt': "Finetest ({})", 'name': "Finetest", 'shortcut_exe_relpaths': [ # os.path.join("bin", "multicraft"), os.path.join("bin", "finetest"), ], 'platform_icon_relpath': { 'Linux': os.path.join("misc", "multicraft-xorg-icon-128.png"), 'Darwin': os.path.join("misc", "minetest-icon.icns"), 'Windows': os.path.join("misc", "multicraft-xorg-icon-128.png"), }, 'shortcut_relpath': os.path.join("misc", "net.minetest.minetest.desktop"), 'shortcut_name_noext': "org.minetest.finetest", }, 'trolltest': { 'shortcut': { 'GenericName': "Trolltest", 'Keywords': MINETEST_KEYWORDS+"minetest;", }, 'dirname': "trolltest", 'name_and_variant_fmt': "Trolltest ({}) (minetest.org build)", 'name': "Trolltest (minetest.org)", 'shortcut_exe_relpaths': [ os.path.join("bin", "trolltest"), ], 'platform_icon_relpath': { 'Linux': os.path.join("misc", "minetest.svg"), 'Darwin': os.path.join("misc", "minetest-icon.icns"), 'Windows': os.path.join("misc", "minetest-icon-24x24.png"), }, 'shortcut_relpath': os.path.join("misc", "net.minetest.minetest.desktop"), 'shortcut_name_noext': "org.minetest.trolltest", }, } arg_project_name = { # 'final': "classic", 'classic': "classic", 'trolltest': "trolltest", # 'troll': "trolltest", 'finetest': "finetest", # 'fine': "finetest", } for _name, _meta in project_metas.items(): _meta['project_name'] = _name def write0(*args): sys.stderr.write(*args) sys.stderr.flush() def echo0(*args): print(*args, file=sys.stderr) def usage(): echo0(__doc__) def detect_source(path): """Get a built minetest directory inside of path if present. It must contain all DETECT_KIT_SUBDIRS for the subdirectory to be detected. Returns: str: minetest subdirectory. If path does not have all DETECT_KIT_SUBDIRS, result is None. """ for sub in DETECT_KIT_SUBDIRS: sub_path = os.path.join(path, sub) if not os.path.isdir(sub_path): return None return os.path.join(path, DETECT_KIT_SUBDIRS[0]) def use_if_source(path): """Use the path as INSTALL_SRC if it contains a minetest install. See detect_source for details. A message is shown regarding the status. Affects globals: - INSTALL_SRC - VARIANT Returns: bool: True if is a source (even if INSTALL_SRC is already the same). """ global INSTALL_SRC global VARIANT detected_src = detect_source(path) if detected_src: if detected_src != INSTALL_SRC: echo0('Switching from "{}" to local copy:' '\n "{}"' ''.format(INSTALL_SRC, detected_src)) INSTALL_SRC = detected_src VARIANT = "local" else: echo0('Using standard source location (same as current dir):' '\n "{}"' ''.format(INSTALL_SRC)) return True else: echo0('Using standard source location' ' (since current dir does not have both "mtsrc and "minetest"):' '\n "{}"' ''.format(INSTALL_SRC)) return False def main(): prefix = "[main] " use_if_source(os.getcwd()) required_bin_suffixes = None why_meta = "detected" project_meta = detect_project_meta(INSTALL_SRC) if project_meta is None: why_meta = "undetected" key_arg = None install_from = None project_name = None if len(sys.argv) < 2: usage() if project_meta is None: echo0("Error: You must specify one of the names above" " unless well-known executable files can be detected" " to determine what project is being installed.") return 1 else: echo0("using detected project: {}".format( json.dumps(project_meta, indent=2), )) # NOTE: ^ shows name_and_variant_fmt with literal "{}" still # (unavoidable without messing with it), so see # "Name={}" further down for that output (Only possible # after `variant` is set). elif len(sys.argv) == 2: pass # 1st arg (arg [1]) is always handled further down else: for argi in range(2, len(sys.argv)): arg = sys.argv[argi] if key_arg is not None: if arg.startswith("--"): usage() echo0("Error: {} must be followed by a value but got {}." "".format(key_arg, arg)) return 1 if key_arg == "--from": install_from = arg else: usage() echo0("Error: unknown argument {}".format(key_arg)) return 1 elif arg == "--server": if required_bin_suffixes is None: required_bin_suffixes = ["server"] else: required_bin_suffixes.append("server") elif arg == "--client": if required_bin_suffixes is None: required_bin_suffixes = [""] else: required_bin_suffixes.append("") elif arg == "--from": key_arg = arg else: usage() echo0('Error: The 2nd argument must be "server" or left out') return 1 if key_arg is not None: usage() echo0("Error: {} must be followed by a value." "".format(key_arg)) return 1 if len(sys.argv) > 1: name_arg = sys.argv[1] project_name = arg_project_name.get(name_arg) if project_name is None: raise ValueError( "Got %s but expected one from %s" % ( pformat(name_arg), pformat(list(arg_project_name.keys())) ) ) if project_meta is not None: echo0(prefix+"reverting detected meta due to %s argument." % pformat(name_arg)) project_meta = None why_meta = "cleared by %s argument" % name_arg elif project_meta is not None: project_name = project_meta.get('project_name') # ^ May differ from name. For example, project name for # Final Minetest is "classic". echo0(prefix+"detected %s" % project_name) if install_from is None: install_from = INSTALL_SRC if project_meta is None: if project_name is None: raise ValueError( "You must either specify one of %" " or the source must be a well-known project that can be" " detected." % pformat(list(project_metas.keys())) ) project_meta = project_metas[project_name] project_meta['required_relpaths'] = [] if required_bin_suffixes is None: required_bin_suffixes = [""] # only check for * not *server # when no options were specified. echo0("Warning: No --client or --server option was set, and" " source was %s so only client binary will be verified" " to exist." % why_meta) for relpath in project_meta['shortcut_exe_relpaths']: for suffix in required_bin_suffixes: # for each file such as suffix "" for minetest and # suffix "server" for minetestserver, add to required # files if specified (Instead of if exists, which # only is behavior on detect, though in both cases # they are verified to exist before install, later). try_relpath = relpath + suffix project_meta['required_relpaths'].append(try_relpath) echo0("Generated relpaths: %s" % pformat(project_meta['required_relpaths'])) else: if project_meta.get('required_relpaths') is None: raise NotImplementedError( "Project %s was detected but required_relpaths was not set." % pformat(project_meta.get('project_name')) ) if len(project_meta['required_relpaths']) == 0: raise FileNotFoundError( "None of the well-known executables for %s could be found: %s" % ( project_name, pformat(project_meta.get('shortcut_exe_relpaths')) ) ) results = install_minetest( install_from, project_meta, ) error = results.get('error') if error is not None: echo0("Error: %s" % error) return 0 def install_minetest(src, project_meta, dst=None, variant_dirname=None, variant=None): """Install Minetest Args: project_meta (dict[string]): The information necessary to install the program. It must have the keys: - 'dirname' (string): The directory under the OS program files. - 'required_files' (list): Paths relative to src that are required (for ensuring src is intact). - There are more required keys for shortcut generation (See install_shortcut). src (string): The location of the minetest install source to copy. dst (Optional[string]): Install here. If None, it will become the default. Defaults to variant_dirname under C:\games on Windows, otherwise under HOME. variant_dirname (Optional[string]): Set the install directory name (ignored if dst is set). If None, it will become the default. Defaults to project_name + "-" + VARIANT (such as minetest-rsync). If VARIANT is blank or None, the variant_dirname will become the same as the dirname (such as minetest). variant (str): Append this to the dirname. It also affects the shortcut--see "variant" under install_shortcut. On desktops environments following the XDG standard, also appended to the icon filename so the variant's can co-exist with other variants (such as deb and AppImage and so on). Defaults to VARIANT (which is set automatically to "rsync" or "local" elsewhere). Returns: dict: "destination" is where it was installed if at all. See "warning" in case there was something incorrect about the install. """ if variant is None: variant = VARIANT project_name = project_meta.get('name') project_msg = project_name if project_msg is None: project_msg = pformat(project_meta) del project_name src_files = project_meta.get('required_relpaths') if src_files is None: usage() error = ("There are no specified source files for %s" " so whether it is intact can't be checked." "" % pformat(project_msg)) raise NotImplementedError(error) missing_files = [] for src_file in src_files: if not os.path.isfile(os.path.join(src, src_file)): missing_files.append(src_file) if len(missing_files) > 0: error = ("Error: The following files are required to be compiled" " for {} before install but are missing: {}" "".format(project_msg, missing_files)) return { 'error': error, } dirname = project_meta['dirname'] variant_dirname = dirname if (variant is not None) and (len(variant.strip()) > 0): variant_dirname += "-" + variant else: variant = None if dst is None: if platform.system() == "Windows": GAMES = "C:\\games" if not os.path.isdir(GAMES): os.mkdir(GAMES) dst = os.path.join(GAMES, variant_dirname) else: dst = os.path.join(HOME, variant_dirname) warning = None if not os.path.isdir(dst): write0('Installing %s to %s...' % (pformat(project_msg), pformat(dst))) shutil.copytree(src, dst) version_path = project_meta.get('version_path') if version_path and os.path.isfile(version_path): version_name = os.path.basename(version_path) shutil.copy( version_path, os.path.join(dst, version_name), ) echo0("Done") result_path = dst else: # Leave result_path as None warning = 'Skipping installed "{}".'.format(dst) echo0('WARNING: {}'.format(warning)) for Exec_relpath in project_meta['shortcut_exe_relpaths']: Exec = os.path.join(dst, Exec_relpath) sc_results = install_shortcut(Exec, dst, project_meta, variant) sc_warning = sc_results.get('warning') if sc_warning is not None: if warning is not None: warning += "; " + sc_warning else: warning = sc_warning return { 'dst': dst, 'warning': warning, } def generate_caption(project_meta, variant): """Generate the icon caption. Args: project_meta (dict): The dict containing 'name' and 'name_and_variant_fmt' where 'name' is like "Trolltest (minetest.org)", and 'name_and_variant_fmt' is like 'Trolltest ({}) (minetest.org build)'. """ Name = project_meta['name'] if variant is not None: name_and_variant_fmt = project_meta.get('name_and_variant_fmt') if name_and_variant_fmt is not None: Name = name_and_variant_fmt.format(variant) else: Name += " (" + project_meta['variant'] + ")" # raise if None return Name def install_shortcut(Exec, dst, project_meta, variant): """Install a shortcut to any program on any understood platform. - sc_template_path is determined based on dst and shortcut_relpath - sc_installed_path (path) is determined from OS and shortcut_name_noext (and variant if not None). - sc_template_path is read, Exec string is filled based on dst (the selected destination where the program is installed) then the resulting shortcut is saved to sc_installed_path (only after temp file is complete). Args: Exec (string): The executable path where the shortcut should point. dst (string): The directory path where the program is installed. project_meta (dict): All metadata describing the program. For this method, it must have the keys: - 'name': The entire name (except variant) that should be displayed as the shortcut's caption. - 'name_and_variant_fmt': Should either be not present or contain the name and the placeholder "{}" where the variant should go. If not present, " " and variant will be added to the end of Name. - 'shortcut' (dict): contains: - 'GenericName' (Optional[string]): A simplified name for the program. If None, the GenericName line will be removed from the shortcut. This option is only for GNU/Linux systems or other systems using XDG. - 'Keywords' (Optional[string]): If None, Keywords line will be removed from the shortcut. This option is only for GNU/Linux systems or other systems using XDG. - 'shortcut_relpath': The location of an existing shortcut file to use and modify. - 'platform_icon_relpath' (dict[string]): A dict where the key is platform.system() (Must have at least 'Linux', 'Windows', *AND* 'Darwin') and the value is the relative path from dst to the icon image file. variant (string): The special string to put in parenthesis after the name to denote what kind of package or source was used to obtain the program, such as "rsync" if a local custom build, or more commonly "git", "deb", etc. If it is an official binary archive, set this to "release". However, if the package type (such as deb) is native to your distro, set this to None to indicate it is the package supported for your distro. - Name is constructed using project_meta['name_and_variant_fmt'] if present, otherwise Name will be project_meta['name] + " (" + 'variant' + ")". If variant is None, name is project_meta['name']. Raises: FileNotFoundError: If src does not exist. """ warning = None Name = generate_caption(project_meta, variant) echo0("Name={}".format(Name)) platform_icon_relpath = project_meta.get('platform_icon_relpath') icon_relpath = None if platform_icon_relpath is not None: icon_relpath = platform_icon_relpath.get(platform.system()) if icon_relpath is None: raise NotImplementedError( "There is no platform icon for {}.".format(platform.system()) ) Icon = os.path.join(dst, icon_relpath) shortcut_meta = copy.deepcopy(project_meta.get('shortcut')) shortcut_meta['Name'] = Name shortcut_meta['Exec'] = Exec shortcut_meta['Icon'] = Icon # ^ rewrite_conf *removes* any lines where value is None if platform.system() == "Linux": sc_template_path = os.path.join(dst, project_meta['shortcut_relpath']) shortcut_name = "{}.{}.desktop".format( project_meta['shortcut_name_noext'], variant, ) sc_installed_path = os.path.join(SHORTCUTS_DIR, shortcut_name) if not os.path.isdir(SHORTCUTS_DIR): os.makedirs(SHORTCUTS_DIR) # default mode is 511 write0('Installing icon to "{}"...'.format(sc_installed_path)) rewrite_conf( sc_template_path, sc_installed_path, changes=shortcut_meta, ) echo0("OK") elif platform.system() == "Darwin": shortcut_name = Name + ".command" sc_installed_path = os.path.join(SHORTCUTS_DIR, shortcut_name) with open(sc_installed_path) as stream: stream.write('"%s"\n' % Exec) # ^ Run the game & close Command Prompt immediately. # ^ First arg is Command Prompt title, so leave it blank. st = os.stat(sc_installed_path) os.chmod(sc_installed_path, st.st_mode | stat.S_IXUSR) # ^ same as stat.S_IEXEC: "Unix V7 synonym for S_IXUSR." elif platform.system() == "Windows": shortcut_name = Name + ".bat" sc_installed_path = os.path.join(SHORTCUTS_DIR, shortcut_name) with open(sc_installed_path) as stream: stream.write('start "" "%s"\n' % Exec) # ^ Run the game & close Command Prompt immediately. # ^ First arg is Command Prompt title, so leave it blank. else: warning = ("Icon install isn't implemented for {}." "".format(platform.system())) return { "warning": warning, # may be None "destination": dst, } # def get_missing_subs(mt_share_path, subs): # """Get a list of any missing files for a source *or* destination. # """ def detect_project_meta(mt_share_path): """Detect the project info from a source *or* destination. Only first entry will be used & get "server" added. Args: mt_share_path (string): The path containing project_meta['shortcut_exe_relpaths'] filename(s). Returns: A copy of the matching project_meta (from project_metas) with an added entry 'required_relpaths'. - *detecting errors*: If the list length is 0 or the key is not present, no required files were found and the install source is not understandable (There is no known binary such as for different code to make a shortcut). - If relpath+"server" exists in the case of the first entry in 'shortcut_exe_relpaths', that server binary will be added to the 'required_relpaths' list whether or not the filename without "server" in the name exists. """ prefix = "[detect_project_meta] " matches = [] for mode, meta in project_metas.items(): new_meta = copy.deepcopy(meta) if 'required_relpaths' not in new_meta: new_meta['required_relpaths'] = \ meta['shortcut_exe_relpaths'].copy() for sub in meta.get('shortcut_exe_relpaths'): sub_path = os.path.join(mt_share_path, sub) try_extra_rel = sub+"server" try_extra_exe = os.path.join(mt_share_path, try_extra_rel) found_any = False if os.path.isfile(try_extra_exe): found_any = True if try_extra_rel not in new_meta['required_relpaths']: new_meta['required_relpaths'].append(try_extra_exe) # For example, bin/minetestserver is required # if in --server install mode (and this # function detects that mode) # but there is no shortcut to it in the GUI. else: echo0(prefix+"There is no %s" % try_extra_exe) if os.path.isfile(sub_path): found_any = True else: echo0(prefix+"There is no %s" % sub_path) new_meta['required_relpaths'].remove(sub) # For example, remove "minetest" if not present (but # install can still proceed if "minetestserver" was # added to the required list). mt_share_path = os.path.realpath(mt_share_path) version_paths = [ os.path.join(mt_share_path, "release.txt"), os.path.join(os.path.dirname(mt_share_path), "release.txt"), ] for version_path in version_paths: if os.path.isfile(version_path): new_meta['version_path'] = version_path version = None with open(version_path, 'r') as stream: for rawL in stream: if version is None: version = rawL.strip() # ^ Use `for` to avoid Exception on empty file. if version is None: echo0('Warning: "{}" is empty!'.format(version_path)) continue elif not version: echo0('Warning: "{}" had a blank line not version' ''.format(version_path)) version = None continue if version: new_meta['version'] = version break if found_any: matches.append(new_meta) break # only first entry will be used & get "server" added if len(matches) == 1: echo0(prefix+"found source files: %s" % pformat(matches[0]['required_relpaths'])) return matches[0] return None def rewrite_conf(src, dst, changes={}): """Install a conf such as an XDG desktop shortcut with changes. Args: src (string): The conf file to read. dst (string): The conf file to write or overwrite. changes (dict): A set of values to change by name. For any value that is None, the line will be removed! """ # This function is redefined further down in the case of Python 2. fd, path = tempfile.mkstemp() try: with os.fdopen(fd, 'wb') as tmp: # ^ ensure still exists when moving write0("Generating temporary icon %s..." % path) # NOTE: tmp.name is just some number (int)! with open(src, "rb") as stream: for rawL in stream: signI = rawL.find(b'=') # commentI = rawl.find(b'#') if rawL.strip().startswith(b"#"): tmp.write(rawL) continue if rawL.strip().startswith(b"["): tmp.write(rawL) continue if signI < 0: tmp.write(rawL) continue key_bytes = rawL[:signI].strip() key = key_bytes.decode("utf-8") value = changes.get(key) if key not in changes: # The value wasn't changed so write it as-is # echo0("%s not in %s" % (key, changes)) tmp.write(rawL) continue if value is None: echo0("%s was excluded from the icon" % key) continue line = "%s=%s\n" % (key, value) tmp.write(line.encode("utf-8")) shutil.copy(path, dst) finally: write0("removing tmp file...") os.remove(path) if sys.version_info.major < 3: # Python 2 (strings are bytes) def rewrite_conf(src, dst, changes={}): """Install a conf such as an XDG desktop shortcut with changes. """ fd, path = tempfile.mkstemp() try: with os.fdopen(fd, 'wb') as tmp: write0("Generating temporary icon %s..." % path) with open(src, "rb") as stream: for rawL in stream: signI = rawL.find('=') # commentI = rawl.find('#') if rawL.strip().startswith("#"): tmp.write(rawL) continue if rawL.strip().startswith("["): tmp.write(rawL) continue if signI < 0: tmp.write(rawL) continue key_bytes = rawL[:signI].strip() key = key_bytes value = changes.get(key) if key not in changes: # The value wasn't changed so write it as-is tmp.write(rawL) continue if value is None: echo0("%s was excluded from the icon" % key) continue line = "%s=%s\n" % (key, value) tmp.write(line) shutil.copy(path, dst) finally: write0("removing tmp file...") os.remove(path) if __name__ == "__main__": sys.exit(main())