# Copyright 2004-2024 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. # This code applies an update. init -1500 python in updater: # Do not participate in saves. _constant = True from store import renpy, config, Action, DictEquality, persistent import store.build as build import tarfile import threading import traceback import os import urllib.parse as urlparse import json import subprocess import hashlib import time import sys import struct import zlib import codecs import io import future.utils def urlopen(url): import requests return io.BytesIO(requests.get(url, proxies=renpy.exports.proxies, timeout=15).content) def urlretrieve(url, fn): import requests data = requests.get(url, proxies=renpy.exports.proxies, timeout=15).content with open(fn, "wb") as f: f.write(data) try: import rsa except Exception: rsa = None from renpy.exports import fsencode # A map from update URL to the last version found at that URL. if persistent._update_version is None: persistent._update_version = { } # A map from update URL to the time we last checked that URL. if persistent._update_last_checked is None: persistent._update_last_checked = { } # A file containing deferred update commands, one per line. Right now, # there are two commands: # R # Rename .new to . # D # Delete . # Deferred commands that cannot be accomplished on start are ignored. DEFERRED_UPDATE_FILE = os.path.join(config.renpy_base, "update", "deferred.txt") DEFERRED_UPDATE_LOG = os.path.join(config.renpy_base, "update", "log.txt") def process_deferred_line(l): cmd, _, fn = l.partition(" ") if cmd == "R": if os.path.exists(fn + ".new"): if os.path.exists(fn): os.unlink(fn) os.rename(fn + ".new", fn) elif cmd == "D": if os.path.exists(fn): os.unlink(fn) elif cmd == "": pass else: raise Exception("Bad command. %r (%r %r)" % (l, cmd, fn)) def process_deferred(): if not os.path.exists(DEFERRED_UPDATE_FILE): return # Give a previous process time to quit (and let go of the # open files.) time.sleep(3) try: log = open(DEFERRED_UPDATE_LOG, "a") except Exception: log = io.BytesIO() with log: with open(DEFERRED_UPDATE_FILE, "r") as f: for l in f: l = l.rstrip("\r\n") log.write(l) try: process_deferred_line(l) except Exception: traceback.print_exc(file=log) try: os.unlink(DEFERRED_UPDATE_FILE + ".old") except Exception: pass try: os.rename(DEFERRED_UPDATE_FILE, DEFERRED_UPDATE_FILE + ".old") except Exception: traceback.print_exc(file=log) # Process deferred updates on startup, if any exist. process_deferred() DELETED = os.path.join(config.renpy_base, "update", "deleted") def process_deleted(): if not os.path.exists(DELETED): return import shutil try: shutil.rmtree(DELETED) except Exception as e: pass process_deleted() def zsync_path(command): """ Returns the full platform-specific path to command, which is one of zsync or zsyncmake. If the file doesn't exists, returns the command so the system-wide copy is used. """ if renpy.windows: suffix = ".exe" else: suffix = "" executable = renpy.fsdecode(sys.executable) rv = os.path.join(os.path.dirname(executable), command + suffix) if os.path.exists(rv): return rv return command + suffix class UpdateError(Exception): """ Used to report known errors. """ class UpdateCancelled(Exception): """ Used to report the update being cancelled. """ class Updater(threading.Thread): """ Applies an update. Fields on this object are used to communicate the state of the update process. self.state The state that the updater is in. self.message In an error state, the error message that occured. self.progress If not None, a number between 0.0 and 1.0 giving some sort of progress indication. self.can_cancel A boolean that indicates if cancelling the update is allowed. """ # Here are the possible states. # An error occured during the update process. # self.message is set to the error message. ERROR = "ERROR" # Checking to see if an update is necessary. CHECKING = "CHECKING" # We are up to date. The update process has ended. # Calling proceed will return to the main menu. UPDATE_NOT_AVAILABLE = "UPDATE NOT AVAILABLE" # An update is available. # The interface should ask the user if he wants to upgrade, and call .proceed() # if he wants to continue. UPDATE_AVAILABLE = "UPDATE AVAILABLE" # Preparing to update by packing the current files into a .update file. # self.progress is updated during this process. PREPARING = "PREPARING" # Downloading the update. # self.progress is updated during this process. DOWNLOADING = "DOWNLOADING" # Unpacking the update. # self.progress is updated during this process. UNPACKING = "UNPACKING" # Finishing up, by moving files around, deleting obsolete files, and writing out # the state. FINISHING = "FINISHING" # Done. The update completed successfully. # Calling .proceed() on the updater will trigger a game restart. DONE = "DONE" # Done. The update completed successfully. # Calling .proceed() on the updater will trigger a game restart. DONE_NO_RESTART = "DONE_NO_RESTART" # The update was cancelled. CANCELLED = "CANCELLED" def __init__(self, url, base=None, force=False, public_key=None, simulate=None, add=[], restart=True, check_only=False, confirm=True, patch=True, prefer_rpu=True, size_only=False, allow_empty=False, done_pause=True, allow_cancel=True): """ Takes the same arguments as update(). """ # Make sure the URL has the right type. url = str(url) self.patch = patch if not url.startswith("http:"): self.patch = False threading.Thread.__init__(self) import os if "RENPY_FORCE_UPDATE" in os.environ: force = True # The main state. self.state = Updater.CHECKING # An additional message to show to the user. self.message = None # The progress of the current operation, or None. self.progress = None # True if the user can click the cancel button. self.can_cancel = True # True if the user can click the proceed button. self.can_proceed = False # True if the user has clicked the cancel button. self.cancelled = False # True if the user has clocked the proceed button. self.proceeded = False # The url of the updates.json file. self.url = url # Force the update? self.force = force # Packages to add during the update. self.add = add # Do we need to restart Ren'Py at the end? self.restart = restart # If true, we check for an update, and update persistent._update_version # as appropriate. self.check_only = check_only # Do we prompt for confirmation? self.confirm = confirm # Should rpu updates be preferred? self.prefer_rpu = prefer_rpu # Should the update be allowed even if current.json is empty? self.allow_empty = allow_empty # Should the user be asked to proceed when done. self.done_pause = done_pause # Should the user be allowed to cancel the update? self.allow_cancel = False # Public attributes set by the RPU updater self.new_disk_size = None self.old_disk_size = None self.download_total = None self.download_done = None self.write_total = None self.write_done = None # The base path of the game that we're updating, and the path to the update # directory underneath it. if base is None: base = config.basedir self.base = os.path.abspath(base) self.updatedir = os.path.join(self.base, "update") # If we're a mac, the directory in which our app lives. splitbase = self.base.split('/') if (len(splitbase) >= 4 and splitbase[-1] == "autorun" and splitbase[-2] == "Resources" and splitbase[-3] == "Contents" and splitbase[-4].endswith(".app")): self.app = "/".join(splitbase[:-3]) else: self.app = None # A condition that's used to coordinate things between the various # threads. self.condition = threading.Condition() # The modules we'll be updating. self.modules = [ ] # A list of files that have to be moved into place. This is a list of filenames, # where each file is moved from .new to . self.moves = [ ] if self.allow_empty: if not os.path.isdir(self.updatedir): os.makedirs(self.updatedir) if public_key is not None: with renpy.open_file(public_key, False) as f: self.public_key = rsa.PublicKey.load_pkcs1(f.read()) else: self.public_key = None # The logfile that update errors are written to. try: self.log = open(os.path.join(self.updatedir, "log.txt"), "w") except Exception: self.log = None self.simulate = simulate self.daemon = True self.start() def run(self): """ The main function of the update thread, handles errors by reporting them to the user. """ try: if self.simulate: self.simulation() else: self.update() except UpdateCancelled as e: self.can_cancel = False self.can_proceed = True self.progress = None self.message = None self.state = self.CANCELLED if self.log: traceback.print_exc(None, self.log) self.log.flush() except UpdateError as e: self.message = e.args[0] self.can_cancel = False self.can_proceed = True self.state = self.ERROR if self.log: traceback.print_exc(None, self.log) self.log.flush() except Exception as e: self.message = _type(e).__name__ + ": " + unicode(e) self.can_cancel = False self.can_proceed = True self.state = self.ERROR if self.log: traceback.print_exc(None, self.log) self.log.flush() self.clean_old() if self.log: self.log.close() def update(self): """ Performs the update. """ self.load_state() self.test_write() self.check_updates() self.pretty_version = self.check_versions() if not self.modules: self.can_cancel = False self.can_proceed = True self.state = self.UPDATE_NOT_AVAILABLE persistent._update_version[self.url] = None renpy.restart_interaction() return persistent._update_version[self.url] = self.pretty_version if self.check_only: renpy.restart_interaction() return # Disable autoreload. renpy.set_autoreload(False) self.new_state = dict(self.current_state) renpy.restart_interaction() self.progress = 0.0 self.state = self.PREPARING import os has_rpu = False has_zsync = False prefer_rpu = self.prefer_rpu or "RPU_UPDATE" in os.environ for i in self.modules: for d in self.updates: if "rpu_url" in self.updates[d]: has_rpu = True if "zsync_url" in self.updates[d]: has_zsync = True if has_rpu and has_zsync: if prefer_rpu: self.rpu_update() else: self.zsync_update() elif has_rpu: self.rpu_update() elif has_zsync: self.zsync_update() else: raise UpdateError(_("No update methods found.")) def prompt_confirm(self): """ Prompts the user to confirm the update. Returns if the update should proceed, or raises UpdateCancelled if it should not. """ if self.confirm: self.progress = None # Confirm with the user that the update is available. with self.condition: self.can_cancel = self.allow_cancel self.can_proceed = True self.state = self.UPDATE_AVAILABLE self.version = self.pretty_version renpy.restart_interaction() while self.confirm: if self.cancelled or self.proceeded: break self.condition.wait(.1) if self.cancelled: raise UpdateCancelled() self.can_cancel = False self.can_proceed = False def fetch_files_rpu(self, module): """ Fetches the rpu file list for the given module. """ import requests, zlib url = urlparse.urljoin(self.url, self.updates[module]["rpu_url"]) try: resp = requests.get(url, proxies=renpy.exports.proxies, timeout=15) resp.raise_for_status() except Exception as e: raise UpdateError(__("Could not download file list: ") + str(e)) if hashlib.sha256(resp.content).hexdigest() != self.updates[module]["rpu_digest"]: raise UpdateError(__("File list digest does not match.")) data = zlib.decompress(resp.content) from renpy.update.common import FileList rv = FileList.from_json(json.loads(data)) return rv def rpu_copy_fields(self): """ Copy fields from the rpu object. """ self.old_disk_total = self.u.old_disk_total self.new_disk_total = self.u.new_disk_total self.download_total = self.u.download_total self.download_done = self.u.download_done self.write_total = self.u.write_total self.write_done = self.u.write_done def rpu_progress(self, state, progress): """ Called by the rpu code to update the progress. """ self.rpu_copy_fields() old_state = self.state self.state = state self.progress = progress if state != old_state or progress == 1.0 or progress == 0.0: renpy.restart_interaction() def rpu_update(self): """ Perform an update using the .rpu files. """ from renpy.update.common import FileList from renpy.update.update import Update # 1. Load the current files. target_file_lists = [ ] for i in self.modules: target_file_lists.append(FileList.from_current_json(self.current_state[i])) # 2. Fetch the file lists. source_file_lists = [ ] module_lists = { } for i in self.modules: fl = self.fetch_files_rpu(i) module_lists[i] = fl source_file_lists.append(fl) # 3. Compute the update, and confirm it. self.u = Update( urlparse.urljoin(self.url, "rpu"), source_file_lists, self.base, target_file_lists, progress_callback=self.rpu_progress, logfile=self.log ) self.u.init() self.rpu_copy_fields() self.prompt_confirm() self.can_cancel = False # 4. Remove the version.json file. version_json = os.path.join(self.updatedir, "version.json") if os.path.exists(version_json): os.unlink(version_json) # 5. Apply the update. self.u.update() # 6. Update the new state. for i in self.modules: d = module_lists[i].to_current_json() d["version"] = self.updates[i]["version"] d["renpy_version"] = self.updates[i]["renpy_version"] d["pretty_version"] = self.updates[i]["pretty_version"] self.new_state[i] = d # 7. Write the version.json file. version_state = { } for i in self.modules: version_state[i] = { "version" : self.updates[i]["version"], "renpy_version" : self.updates[i]["renpy_version"], "pretty_version" : self.updates[i]["pretty_version"] } with open(os.path.join(self.updatedir, "version.json"), "w") as f: json.dump(version_state, f) # 8. Finish up. persistent._update_version[self.url] = None if self.restart: self.state = self.DONE else: self.state = self.DONE_NO_RESTART self.message = None self.progress = None self.can_proceed = self.done_pause self.can_cancel = False renpy.restart_interaction() def zsync_update(self): self.prompt_confirm() self.can_cancel = self.allow_cancel if self.patch: for i in self.modules: self.prepare(i) self.progress = 0.0 self.state = self.DOWNLOADING renpy.restart_interaction() for i in self.modules: if self.patch: try: self.download(i) except Exception: self.download(i, standalone=True) else: self.download_direct(i) self.clean_old() self.can_cancel = False self.progress = 0.0 self.state = self.UNPACKING renpy.restart_interaction() for i in self.modules: self.unpack(i) self.progress = None self.state = self.FINISHING renpy.restart_interaction() self.move_files() self.delete_obsolete() self.save_state() self.clean_new() persistent._update_version[self.url] = None if self.restart: self.state = self.DONE else: self.state = self.DONE_NO_RESTART self.message = None self.progress = None self.can_proceed = self.done_pause self.can_cancel = False renpy.restart_interaction() return def simulation(self): """ Simulates the update. """ def simulate_progress(): for i in range(0, 30): self.progress = i / 30.0 time.sleep(.1) if self.cancelled: raise UpdateCancelled() time.sleep(1.5) if self.cancelled: raise UpdateCancelled() if self.simulate == "error": raise UpdateError(_("An error is being simulated.")) if self.simulate == "not_available": self.can_cancel = False self.can_proceed = True self.state = self.UPDATE_NOT_AVAILABLE persistent._update_version[self.url] = None return pretty_version = build.version or build.directory_name persistent._update_version[self.url] = pretty_version if self.check_only: renpy.restart_interaction() return # Confirm with the user that the update is available. self.prompt_confirm() if self.cancelled: raise UpdateCancelled() self.progress = 0.0 self.state = self.PREPARING renpy.restart_interaction() simulate_progress() self.progress = 0.0 self.state = self.DOWNLOADING renpy.restart_interaction() simulate_progress() self.can_cancel = False self.progress = 0.0 self.state = self.UNPACKING renpy.restart_interaction() simulate_progress() self.progress = None self.state = self.FINISHING renpy.restart_interaction() time.sleep(1.5) persistent._update_version[self.url] = None if self.restart: self.state = self.DONE else: self.state = self.DONE_NO_RESTART self.message = None self.progress = None self.can_proceed = self.done_pause self.can_cancel = False renpy.restart_interaction() return def periodic(self): """ Called periodically by the screen. """ renpy.restart_interaction() if self.state == self.DONE or self.state == self.DONE_NO_RESTART: if not self.done_pause: return self.proceed(force=True) def proceed(self, force=False): """ Causes the upgraded to proceed with the next step in the process. """ if not self.can_proceed and not force: return if self.state == self.UPDATE_NOT_AVAILABLE or self.state == self.ERROR or self.state == self.CANCELLED: return False elif self.state == self.DONE: if self.restart == "utter": renpy.utter_restart() else: renpy.quit(relaunch=True) elif self.state == self.DONE_NO_RESTART: return True elif self.state == self.UPDATE_AVAILABLE: with self.condition: self.proceeded = True self.condition.notify_all() def cancel(self): if not self.can_cancel: return with self.condition: self.cancelled = True self.condition.notify_all() if self.restart: renpy.full_restart() else: return False def unlink(self, path): """ Tries to unlink the file at `path`. """ if os.path.exists(path): import random newname = os.path.join(DELETED, os.path.basename(path) + "." + str(random.randint(0, 1000000))) try: os.mkdir(DELETED) except Exception: pass # This might fail because of a sharing violation on Windows. try: os.rename(path, newname) os.unlink(newname) except Exception: pass def rename(self, old, new): """ Renames the old name to the new name. Tries to enforce the unix semantics, even on windows. """ try: os.rename(old, new) return except Exception: pass try: os.unlink(new) except Exception: pass os.rename(old, new) def path(self, name): """ Converts a filename to a path on disk. """ if self.app is not None: path = name.split("/") if path[0].endswith(".app"): rv = os.path.join(self.app, "/".join(path[1:])) return rv rv = os.path.join(self.base, name) if renpy.windows: rv = "\\\\?\\" + rv.replace("/", "\\") return rv def load_state(self): """ Loads the current update state from update/current.json """ fn = os.path.join(self.updatedir, "current.json") if not os.path.exists(fn): if self.allow_empty: self.current_state = { } return raise UpdateError(_("Either this project does not support updating, or the update status file was deleted.")) with open(fn, "r") as f: self.current_state = json.load(f) def test_write(self): fn = os.path.join(self.updatedir, "test.txt") try: with open(fn, "w") as f: f.write("Hello, World.") os.unlink(fn) except Exception: raise UpdateError(_("This account does not have permission to perform an update.")) if not self.log: raise UpdateError(_("This account does not have permission to write the update log.")) def check_updates(self): """ Downloads the list of updates from the server, parses it, and stores it in self.updates. """ fn = os.path.join(self.updatedir, "updates.json") urlretrieve(self.url, fn) with open(fn, "rb") as f: updates_json = f.read() # Was updates.json verified? verified = False # Does updates.json need to be verified? require_verified = False # New-style ECDSA signature. key = os.path.join(config.basedir, "update", "key.pem") if not os.path.exists(key): key = os.path.join(self.updatedir, "key.pem") if os.path.exists(key): require_verified = True self.log.write("Verifying with ECDSA.\n") try: import ecdsa verifying_key = ecdsa.VerifyingKey.from_pem(open(key, "rb").read()) url = urlparse.urljoin(self.url, "updates.ecdsa") f = urlopen(url) while True: signature = f.read(64) if not signature: break if verifying_key.verify(signature, updates_json): verified = True self.log.write("Verified with ECDSA.\n") except Exception: if self.log: import traceback traceback.print_exc(None, self.log) # Old-style RSA signature. if self.public_key is not None: require_verified = True self.log.write("Verifying with RSA.\n") try: fn = os.path.join(self.updatedir, "updates.json.sig") urlretrieve(self.url + ".sig", fn) with open(fn, "rb") as f: import codecs signature = codecs.decode(f.read(), "base64") rsa.verify(updates_json, signature, self.public_key) verified = True self.log.write("Verified with RSA.\n") except Exception: if self.log: import traceback traceback.print_exc(None, self.log) if require_verified and not verified: raise UpdateError(_("Could not verify update signature.")) self.updates = json.loads(updates_json) if verified and "monkeypatch" in self.updates: future.utils.exec_(self.updates["monkeypatch"], globals(), globals()) def add_dlc_state(self, name): has_rpu = "rpu_url" in self.updates[name] has_zsync = "zsync_url" in self.updates[name] prefer_rpu = self.prefer_rpu or "RPU_UPDATE" in os.environ if has_rpu and has_zsync: if prefer_rpu: has_zsync = False else: has_rpu = False if has_rpu: fl = self.fetch_files_rpu(name) d = { name : fl.to_current_json() } else: url = urlparse.urljoin(self.url, self.updates[name]["json_url"]) f = urlopen(url) d = json.load(f) d[name]["version"] = 0 self.current_state.update(d) def check_versions(self): """ Decides what modules need to be updated, if any. """ rv = None # A list of names of modules we want to update. self.modules = [ ] # DLC? if self.add: for name in self.add: if name in self.updates: self.modules.append(name) if name not in self.current_state: self.add_dlc_state(name) rv = self.updates[name]["pretty_version"] return rv # We update the modules that are in both versions, and that are out of date. for name, data in self.current_state.items(): if name not in self.updates: continue if data["version"] == self.updates[name]["version"]: if not self.force: continue self.modules.append(name) rv = self.updates[name]["pretty_version"] return rv def update_filename(self, module, new): """ Returns the update filename for the given module. """ rv = os.path.join(self.updatedir, module + ".update") if new: return rv + ".new" return rv def prepare(self, module): """ Creates a tarfile creating the files that make up module. """ state = self.current_state[module] xbits = set(state["xbit"]) directories = set(state["directories"]) all = state["files"] + state["directories"] all.sort() # Add the update directory and state file. all.append("update") directories.add("update") all.append("update/current.json") with tarfile.open(self.update_filename(module, False), "w") as tf: for i, name in enumerate(all): if self.cancelled: raise UpdateCancelled() self.progress = 1.0 * i / len(all) directory = name in directories xbit = name in xbits path = self.path(name) if directory: info = tarfile.TarInfo(name) info.size = 0 info.type = tarfile.DIRTYPE else: if not os.path.exists(path): continue info = tf.gettarinfo(path, name) if not info.isreg(): continue info.uid = 1000 info.gid = 1000 info.mtime = 0 info.uname = "renpy" info.gname = "renpy" if xbit or directory: info.mode = 0o777 else: info.mode = 0o666 if info.isreg(): with open(path, "rb") as f: tf.addfile(info, f) else: tf.addfile(info) def split_inputs(self, sfn): """ Given an input file `sfn`, returns a list of option arguments and input files that can be supplied to zsync. """ size = os.path.getsize(sfn) if size < (1 << 30): return [ "-i", sfn ] rv = [ ] with open(sfn, "rb") as f: count = 0 while count * (1 << 30) < size: count += 1 out_fn = sfn + "." + str(count) with open(out_fn, "wb") as out_f: for i in range(1 << 4): data = f.read(1 << 26) if not data: break out_f.write(data) rv.extend([ "-i", out_fn ]) return rv def download(self, module, standalone=False): """ Uses zsync to download the module. """ start_progress = None new_fn = self.update_filename(module, True) # Download the sums file. sums = [ ] f = urlopen(urlparse.urljoin(self.url, self.updates[module]["sums_url"])) data = f.read() for i in range(0, len(data), 4): try: sums.append(struct.unpack("