# Copyright 2004-2023 Tom Rothamel # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation files # (the "Software"), to deal in the Software without restriction, # including without limitation the rights to use, copy, modify, merge, # publish, distribute, sublicense, and/or sell copies of the Software, # and to permit persons to whom the Software is furnished to do so, # subject to the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. # Code that manages projects. init python: import os init python in project: from store import persistent, config, Action, renpy, _preferences, MultiPersistent import store.util as util import store.interface as interface import sys import os.path import json import subprocess import re import tempfile multipersistent = MultiPersistent("launcher.renpy.org") if persistent.blurb is None: persistent.blurb = 0 project_filter = [ i.strip() for i in os.environ.get("RENPY_PROJECT_FILTER", "").split(":") if i.strip() ] LAUNCH_BLURBS = [ _("After making changes to the script, press shift+R to reload your game."), _("Press shift+O (the letter) to access the console."), _("Press shift+D to access the developer menu."), _("Have you backed up your projects recently?"), _("Lint checks your game for potential mistakes, and gives you statistics."), ] class Project(object): def __init__(self, path, name=None): if name is None: name = os.path.basename(path) while path.endswith("/"): path = path[:-1] if not os.path.exists(path): raise Exception("{} does not exist.".format(path)) self.name = name # The path to the project. self.path = path # The path to the game directory. gamedir = os.path.join(path, "game") if os.path.isdir(gamedir): self.gamedir = gamedir else: self.gamedir = path # Load the data. self.load_data() # A name to display the project. self.display_name = self.data.get("display_name", self.name) # The project's temporary directory. self.tmp = None # This contains the result of dumping information about the game # to disk. self.dump = { } # The mtime of the last dump file loaded. self.dump_mtime = 0 def get_dump_filename(self): if os.path.exists(os.path.join(self.gamedir, "saves")): return os.path.join(self.gamedir, "saves", "navigation.json") self.make_tmp() return os.path.join(self.tmp, "navigation.json") def load_data(self): try: with open(os.path.join(self.path, "project.json"), "r") as f: self.data = json.load(f) except Exception: self.data = { } self.update_data() def save_data(self): """ Saves the project data. """ try: with open(os.path.join(self.path, "project.json"), "w") as f: json.dump(self.data, f) except Exception: self.load_data() def update_data(self): data = self.data data.setdefault("build_update", False) data.setdefault("packages", [ "pc", "mac" ]) data.setdefault("add_from", True) data.setdefault("force_recompile", True) data.setdefault("android_build", "Release") if "renamed_all" not in data: dp = data["packages"] if "all" in dp: dp.remove("all") if "pc" not in dp: dp.append("pc") if "mac" not in dp: dp.append("mac") data["renamed_all"] = True if "renamed_steam" not in data: dp = data["packages"] if "steam" in dp: dp.remove("steam") if "market" not in dp: dp.append("market") data["renamed_steam"] = True def make_tmp(self): """ Makes the project's temporary directory, if it doesn't exist yet. """ if self.tmp and os.path.isdir(self.tmp): return tmp = os.path.join(config.renpy_base, "tmp", self.name) try: os.makedirs(tmp) except Exception: pass if os.path.isdir(tmp): try: fn = os.path.join(tmp, "write_test.txt") if os.path.exists(fn): os.unlink(fn) with open(fn, "w") as f: f.write("Test") os.unlink(fn) self.tmp = tmp return except Exception: pass self.tmp = tempfile.mkdtemp() def temp_filename(self, filename): """ Returns a filename in the temporary directory. """ self.make_tmp() return os.path.join(self.tmp, filename) def launch(self, args=[], wait=False, env={}): """ Launches the project. `args` Additional arguments to give to the project. `wait` If true, waits for the launched project to terminate before continuing. `env` Additional variables to include in the environment. """ self.make_tmp() # Find the python executable to run. executable_path = os.path.dirname(renpy.fsdecode(sys.executable)) if renpy.renpy.windows: extension = ".exe" else: extension = "" if persistent.windows_console: executables = [ "python" + extension ] else: executables = [ "pythonw" + extension ] executables.append(sys.executable) for i in executables: executable = os.path.join(executable_path, i) if os.path.exists(executable): break else: raise Exception("Python interpreter not found: %r", executables) # Put together the basic command line. cmd = [ executable, sys.argv[0] ] cmd.append(self.path) cmd.extend(args) # Add flags to dump game info. cmd.append("--json-dump") cmd.append(self.get_dump_filename()) if persistent.navigate_private: cmd.append("--json-dump-private") if persistent.navigate_library: cmd.append("--json-dump-common") cmd.append("--errors-in-editor") environ = dict(os.environ) environ["RENPY_LAUNCHER_LANGUAGE"] = _preferences.language or "english" environ.update(env) # Filter out system PYTHON* environment variables. if hasattr(sys, "renpy_executable"): environ = { k : v for k, v in environ.items() if not k.startswith("PYTHON") } encoded_environ = { } for k, v in environ.items(): if v is None: continue encoded_environ[renpy.fsencode(k)] = renpy.fsencode(v) # Launch the project. cmd = [ renpy.fsencode(i) for i in cmd ] p = subprocess.Popen(cmd, env=encoded_environ) if wait: if p.wait(): print("Launch failed. command={!r}, returncode={!r}".format(cmd, p.returncode)) if args and not self.is_writeable(): interface.error(_("Launching the project failed."), _("This may be because the project is not writeable.")) else: interface.error(_("Launching the project failed."), _("Please ensure that your project launches normally before running this command.")) renpy.not_infinite_loop(30) def update_dump(self, force=False, gui=True, compile=False): """ If the dumpfile does not exist, runs Ren'Py to create it. Otherwise, loads it in iff it's newer than the one that's already loaded. """ dump_filename = self.get_dump_filename() if force or not os.path.exists(dump_filename): if gui: interface.processing(_("Ren'Py is scanning the project...")) if compile: self.launch(["compile", "--keep-orphan-rpyc" ], wait=True) else: self.launch(["quit"], wait=True) if not os.path.exists(dump_filename): self.dump["error"] = True return file_mtime = os.path.getmtime(dump_filename) if file_mtime == self.dump_mtime: return self.dump_mtime = file_mtime try: with open(dump_filename, "r") as f: self.dump = json.load(f) # add todo list to dump data self.update_todos() except Exception: self.dump["error"] = True def update_todos(self): """ Scans the scriptfiles for lines TODO comments and add them to the dump data. """ todos = self.dump.setdefault("location", {})["todo"] = {} files = self.script_files() for f in files: data = open(self.unelide_filename(f), encoding="utf-8") for l, line in enumerate(data): l += 1 line = line[:1024] if PY2: try: line = line.decode("utf-8") except Exception: continue m = re.search(r"#\s*TODO(\s*:\s*|\s+)(.*)", line, re.I) if m is None: continue raw_todo_text = m.group(2).strip() todo_text = raw_todo_text index = 0 while not todo_text or todo_text in todos: index += 1 todo_text = u"{0} ({1})".format(raw_todo_text, index) todos[todo_text] = [f, l] def unelide_filename(self, fn): """ Unelides the filename relative to the project base. """ fn = os.path.normpath(fn) fn1 = os.path.join(self.path, fn) if os.path.exists(fn1): return fn1 fn2 = os.path.join(config.renpy_base, fn) if os.path.exists(fn2): return fn2 return fn def script_files(self): """ Return a list of the script files that make up the project. These are elided, and so need to be passed to unelide_filename before they can be included in the project. """ def is_script(fn): fn = fn.lower() for i in [ ".rpy", ".rpym", "_ren.py" ]: if fn.endswith(i): return True return False rv = [ ] rv.extend(i for i, isdir in util.walk(self.path) if (not isdir) and is_script(i) and (not i.startswith("tmp/")) ) return rv def exists(self, fn): """ Returns true if the file exists in the game. """ return os.path.exists(os.path.join(self.path, fn)) def is_writeable(self): """ Returns true if it's possible to write a file in the projects directory. """ return os.access(self.path, os.W_OK) class ProjectManager(object): """ This maintains a list of the various types of projects that we know about. """ def __init__(self): # The projects directory. self.projects_directory = "" # Normal projects, in alphabetical order by lowercase name. self.projects = [ ] # Template projects. self.templates = [ ] # All projects - normal, template, and hidden. self.all_projects = [ ] # Directories that have been scanned. self.scanned = set() # The tutorial game, and the language it's for. self.tutoral = None self.tutorial_language = "the meowing of a cat" self.scan() def scan(self): """ Scans for projects. """ global current if persistent.projects_directory is None: if multipersistent.projects_directory is not None: persistent.projects_directory = multipersistent.projects_directory if (persistent.projects_directory is not None) and not os.path.isdir(persistent.projects_directory): persistent.projects_directory = None if persistent.projects_directory is not None: if multipersistent.projects_directory is None: multipersistent.projects_directory = persistent.projects_directory multipersistent.save() self.projects_directory = persistent.projects_directory self.projects = [ ] self.templates = [ ] self.all_projects = [ ] self.scanned = set() if self.projects_directory is not None: self.scan_directory(self.projects_directory) self.scan_directory(config.renpy_base) self.scan_directory(os.path.join(config.renpy_base, "templates")) self.projects.sort(key=lambda p : p.name.lower()) self.templates.sort(key=lambda p : p.name.lower()) # Select the default project. if persistent.active_project is not None: p = self.get(persistent.active_project) if (p is not None) and (p.name not in [ "tutorial", "tutorial_7" ]): current = p return current = self.get_tutorial() def find_basedir(self, d): """ Try to find a project basedir in d. """ def has_game(dn): return os.path.isdir(os.path.join(dn, "game")) if has_game(d): return d dn = os.path.join(d, "Contents", "Resources", "autorun") if has_game(dn): return dn for dn in os.listdir(d): if not dn.endswith(".app"): continue dn = os.path.join(d, dn, "Contents", "Resources", "autorun") if has_game(dn): return dn return None def scan_directory(self, d): """ Scans for projects in directories directly underneath `d`. """ global current d = os.path.abspath(d) if not os.path.isdir(d): return for pdir in util.listdir(d): ppath = os.path.join(d, pdir) self.scan_directory_direct(ppath, pdir) # If a file called "projects.txt" exists, include any projects listed in it. extra_projects_fn = os.path.join(d, "projects.txt") if os.path.exists(extra_projects_fn): with open(extra_projects_fn, "r") as f: for path in f: path = path.strip() if len(path) > 0: self.scan_directory_direct(path) def scan_directory_direct(self, ppath, name=None): """ Checks if there is a project in `ppath` and creates a project object with the name `name` if so. """ # A project must be a directory. if not os.path.isdir(ppath): return try: ppath = self.find_basedir(ppath) except Exception: return if ppath is None: return if ppath in self.scanned: return self.scanned.add(ppath) # We have a project directory, so create a Project. p = Project(ppath, name) if project_filter and (p.name not in project_filter): return project_type = p.data.get("type", "normal") if project_type == "hidden": pass elif project_type == "template": self.templates.append(p) else: self.projects.append(p) self.all_projects.append(p) def get(self, name): """ Gets the project with the given name. Returns None if the project doesn't exist. """ for p in self.all_projects: if p.name == name: return p return None def get_tutorial(self): language = _preferences.language if persistent.force_new_tutorial: language = None if language == self.tutorial_language: return self.tutorial rv = self.get("oldtutorial") p = self.get("tutorial") if p is not None: if language is None: rv = p elif rv is None: rv = p elif os.path.exists(os.path.join(p.path, "game", "tl", _preferences.language)): rv = p elif not os.path.exists(os.path.join(rv.path, "game", "tl", _preferences.language)): rv = p self.tutorial_language = language self.tutorial = rv return rv manager = ProjectManager() # The current project. current = None # Actions class Select(Action): """ An action that causes p to become the selected project when it was clicked. If label is not None, jumps to the given label. """ def __init__(self, p, label=None): """ `p` Either a project object, or a string giving the name of a project. `label` The label to jump to when clicked. """ if isinstance(p, basestring): p = manager.get(p) self.project = p self.label = label def get_selected(self): if self.project is None: return False if current is None: return False return current.path == self.project.path def get_sensitive(self): return self.project is not None def __call__(self): global current current = self.project persistent.active_project = self.project.name renpy.restart_interaction() if self.label is not None: renpy.jump(self.label) class SelectTutorial(Action): """ Selects the tutorial. """ def __init__(self, if_tutorial=False): """ Only selects if we're already in a tutorial. """ self.if_tutorial = if_tutorial def __call__(self): p = manager.get_tutorial() if p is None: return global current if self.if_tutorial: if (current is not None) and current.name not in [ "tutorial", "oldtutorial" ]: return None current = p persistent.active_project = p.name renpy.restart_interaction() def get_sensitive(self): if self.if_tutorial: return True return (manager.get_tutorial() is not None) def get_selected(self): if self.if_tutorial: return False p = manager.get_tutorial() if p is None: return False if current is None: return False return current.path == p.path class Launch(Action): """ An action that launches the supplied project, or the current project if no project is supplied. """ def __init__(self, p=None): if p is None: self.project = current elif isinstance(p, basestring): self.project = manager.get(p) else: self.project = p def get_sensitive(self): return self.project is not None def post_launch(self): blurb = LAUNCH_BLURBS[persistent.blurb % len(LAUNCH_BLURBS)] persistent.blurb += 1 interface.interaction(_("Launching"), blurb, pause=2.5) def __call__(self): self.project.launch() renpy.invoke_in_new_context(self.post_launch) class Rescan(Action): def __call__(self): """ Rescans the projects directory. """ manager.scan() renpy.restart_interaction() manager.scan() if isinstance(persistent.projects_directory, str): persistent.projects_directory = renpy.fsdecode(persistent.projects_directory) init 10 python: if persistent.projects_directory is not None: if not directory_is_writable(persistent.projects_directory): persistent.projects_directory = None label after_load: python: if project.current is not None: project.current.update_dump() return ############################################################################### # Code to choose the projects directory. label choose_projects_directory: python hide: interface.interaction(_("PROJECTS DIRECTORY"), _("Please choose the projects directory using the directory chooser.\n{b}The directory chooser may have opened behind this window.{/b}"), _("This launcher will scan for projects in this directory, will create new projects in this directory, and will place built projects into this directory."),) path, is_default = choose_directory(persistent.projects_directory) if is_default: interface.info(_("Ren'Py has set the projects directory to:"), "[path!q]", path=path) persistent.projects_directory = path project.multipersistent.projects_directory = path project.multipersistent.save() project.manager.scan() return init python: def set_projects_directory_command(): ap = renpy.arguments.ArgumentParser() ap.add_argument("projects", help="The path to the projects directory.") args = ap.parse_args() persistent.projects_directory = renpy.fsdecode(args.projects) project.multipersistent.projects_directory = persistent.projects_directory project.multipersistent.save() renpy.save_persistent() return False renpy.arguments.register_command("set_projects_directory", set_projects_directory_command) def get_projects_directory_command(): ap = renpy.arguments.ArgumentParser() args = ap.parse_args() if persistent.projects_directory is not None: print(persistent.projects_directory) return False renpy.arguments.register_command("get_projects_directory", get_projects_directory_command) def set_project_command(): ap = renpy.arguments.ArgumentParser() ap.add_argument("project", help="The full path to the project to select.") args = ap.parse_args() projects = os.path.dirname(os.path.abspath(args.project)) name = os.path.basename(args.project) persistent.projects_directory = renpy.fsdecode(projects) project.multipersistent.projects_directory = persistent.projects_directory persistent.active_project = name project.multipersistent.save() renpy.save_persistent() return False renpy.arguments.register_command("set_project", set_project_command)