1# Copyright 2004-2021 Tom Rothamel <pytom@bishoujo.us>
2#
3# Permission is hereby granted, free of charge, to any person
4# obtaining a copy of this software and associated documentation files
5# (the "Software"), to deal in the Software without restriction,
6# including without limitation the rights to use, copy, modify, merge,
7# publish, distribute, sublicense, and/or sell copies of the Software,
8# and to permit persons to whom the Software is furnished to do so,
9# subject to the following conditions:
10#
11# The above copyright notice and this permission notice shall be
12# included in all copies or substantial portions of the Software.
13#
14# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21
22# This code applies an update.
23init -1500 python in updater:
24    from store import renpy, config, Action, DictEquality, persistent
25    import store.build as build
26
27    import tarfile
28    import threading
29    import traceback
30    import os
31    import urllib.parse as urlparse
32    import json
33    import subprocess
34    import hashlib
35    import time
36    import sys
37    import struct
38    import zlib
39    import codecs
40    import io
41    import future.utils
42
43    def urlopen(url):
44        import requests
45        return io.BytesIO(requests.get(url).content)
46
47    def urlretrieve(url, fn):
48        import requests
49
50        data = requests.get(url).content
51
52        with open(fn, "wb") as f:
53            f.write(data)
54
55    try:
56        import rsa
57    except:
58        rsa = None
59
60    from renpy.exports import fsencode
61
62    # A map from update URL to the last version found at that URL.
63    if persistent._update_version is None:
64        persistent._update_version = { }
65
66    # A map from update URL to the time we last checked that URL.
67    if persistent._update_last_checked is None:
68        persistent._update_last_checked = { }
69
70    # A file containing deferred update commands, one per line. Right now,
71    # there are two commands:
72    # R <path>
73    #     Rename <path>.new to <path>.
74    # D <path>
75    #     Delete <path>.
76    # Deferred commands that cannot be accomplished on start are ignored.
77    DEFERRED_UPDATE_FILE = os.path.join(config.renpy_base, "update", "deferred.txt")
78    DEFERRED_UPDATE_LOG = os.path.join(config.renpy_base, "update", "log.txt")
79
80    def process_deferred_line(l):
81        cmd, _, fn = l.partition(" ")
82
83        if cmd == "R":
84            if os.path.exists(fn + ".new"):
85
86                if os.path.exists(fn):
87                    os.unlink(fn)
88
89                os.rename(fn + ".new", fn)
90
91        elif cmd == "D":
92
93            if os.path.exists(fn):
94                os.unlink(fn)
95
96        else:
97            raise Exception("Bad command.")
98
99    def process_deferred():
100        if not os.path.exists(DEFERRED_UPDATE_FILE):
101            return
102
103        # Give a previous process time to quit (and let go of the
104        # open files.)
105        time.sleep(3)
106
107        try:
108            log = file(DEFERRED_UPDATE_LOG, "ab")
109        except:
110            log = io.BytesIO()
111
112        with log:
113            with open(DEFERRED_UPDATE_FILE, "rb") as f:
114                for l in f:
115
116                    l = l.rstrip("\r\n")
117                    l = l.decode("utf-8")
118
119                    log.write(l.encode("utf-8"))
120
121                    try:
122                        process_deferred_line(l)
123                    except:
124                        traceback.print_exc(file=log)
125
126            try:
127                os.unlink(DEFERRED_UPDATE_FILE)
128            except:
129                traceback.print_exc(file=log)
130
131    # Process deferred updates on startup, if any exist.
132    process_deferred()
133
134    def zsync_path(command):
135        """
136        Returns the full platform-specific path to command, which is one
137        of zsync or zsyncmake. If the file doesn't exists, returns the
138        command so the system-wide copy is used.
139        """
140
141        if renpy.windows:
142            suffix = ".exe"
143        else:
144            suffix = ""
145
146        executable = renpy.fsdecode(sys.executable)
147
148        rv = os.path.join(os.path.dirname(executable), command + suffix)
149
150        if os.path.exists(rv):
151            return rv
152
153        return command + suffix
154
155    class UpdateError(Exception):
156        """
157        Used to report known errors.
158        """
159
160    class UpdateCancelled(Exception):
161        """
162        Used to report the update being cancelled.
163        """
164
165    class Updater(threading.Thread):
166        """
167        Applies an update.
168
169        Fields on this object are used to communicate the state of the update process.
170
171        self.state
172            The state that the updater is in.
173
174        self.message
175            In an error state, the error message that occured.
176
177        self.progress
178            If not None, a number between 0.0 and 1.0 giving some sort of
179            progress indication.
180
181        self.can_cancel
182            A boolean that indicates if cancelling the update is allowed.
183
184        """
185
186        # Here are the possible states.
187
188        # An error occured during the update process.
189        # self.message is set to the error message.
190        ERROR = "ERROR"
191
192        # Checking to see if an update is necessary.
193        CHECKING = "CHECKING"
194
195        # We are up to date. The update process has ended.
196        # Calling proceed will return to the main menu.
197        UPDATE_NOT_AVAILABLE = "UPDATE NOT AVAILABLE"
198
199        # An update is available.
200        # The interface should ask the user if he wants to upgrade, and call .proceed()
201        # if he wants to continue.
202        UPDATE_AVAILABLE = "UPDATE AVAILABLE"
203
204        # Preparing to update by packing the current files into a .update file.
205        # self.progress is updated during this process.
206        PREPARING = "PREPARING"
207
208        # Downloading the update.
209        # self.progress is updated during this process.
210        DOWNLOADING = "DOWNLOADING"
211
212        # Unpacking the update.
213        # self.progress is updated during this process.
214        UNPACKING = "UNPACKING"
215
216        # Finishing up, by moving files around, deleting obsolete files, and writing out
217        # the state.
218        FINISHING = "FINISHING"
219
220        # Done. The update completed successfully.
221        # Calling .proceed() on the updater will trigger a game restart.
222        DONE = "DONE"
223
224        # Done. The update completed successfully.
225        # Calling .proceed() on the updater will trigger a game restart.
226        DONE_NO_RESTART = "DONE_NO_RESTART"
227
228        # The update was cancelled.
229        CANCELLED = "CANCELLED"
230
231        def __init__(self, url, base=None, force=False, public_key=None, simulate=None, add=[], restart=True, check_only=False, confirm=True, patch=True):
232            """
233            Takes the same arguments as update().
234            """
235
236            self.patch = patch
237
238            if not url.startswith("http:"):
239                self.patch = False
240
241            threading.Thread.__init__(self)
242
243            import os
244            if "RENPY_FORCE_UPDATE" in os.environ:
245                force = True
246
247            # The main state.
248            self.state = Updater.CHECKING
249
250            # An additional message to show to the user.
251            self.message = None
252
253            # The progress of the current operation, or None.
254            self.progress = None
255
256            # True if the user can click the cancel button.
257            self.can_cancel = True
258
259            # True if the user can click the proceed button.
260            self.can_proceed = False
261
262            # True if the user has clicked the cancel button.
263            self.cancelled = False
264
265            # True if the user has clocked the proceed button.
266            self.proceeded = False
267
268            # The url of the updates.json file.
269            self.url = url
270
271            # Force the update?
272            self.force = force
273
274            # Packages to add during the update.
275            self.add = add
276
277            # Do we need to restart Ren'Py at the end?
278            self.restart = restart
279
280            # If true, we check for an update, and update persistent._update_version
281            # as appropriate.
282            self.check_only = check_only
283
284            # Do we prompt for confirmation?
285            self.confirm = confirm
286
287            # The base path of the game that we're updating, and the path to the update
288            # directory underneath it.
289
290            if base is None:
291                base = config.basedir
292
293            self.base = os.path.abspath(base)
294            self.updatedir = os.path.join(self.base, "update")
295
296            # If we're a mac, the directory in which our app lives.
297            splitbase = self.base.split('/')
298            if (len(splitbase) >= 4 and
299                splitbase[-1] == "autorun" and
300                splitbase[-2] == "Resources" and
301                splitbase[-3] == "Contents" and
302                splitbase[-4].endswith(".app")):
303
304                self.app = "/".join(splitbase[:-3])
305            else:
306                self.app = None
307
308            # A condition that's used to coordinate things between the various
309            # threads.
310            self.condition = threading.Condition()
311
312            # The modules we'll be updating.
313            self.modules = [ ]
314
315            # A list of files that have to be moved into place. This is a list of filenames,
316            # where each file is moved from <file>.new to <file>.
317            self.moves = [ ]
318
319            if public_key is not None:
320                with renpy.file(public_key) as f:
321                    self.public_key = rsa.PublicKey.load_pkcs1(f.read())
322            else:
323                self.public_key = None
324
325            # The logfile that update errors are written to.
326            try:
327                self.log = open(os.path.join(self.updatedir, "log.txt"), "wb")
328            except:
329                self.log = None
330
331            self.simulate = simulate
332
333            self.daemon = True
334            self.start()
335
336
337        def run(self):
338            """
339            The main function of the update thread, handles errors by reporting
340            them to the user.
341            """
342
343            try:
344                if self.simulate:
345                    self.simulation()
346                else:
347                    self.update()
348
349            except UpdateCancelled as e:
350                self.can_cancel = True
351                self.can_proceed = False
352                self.progress = None
353                self.message = None
354                self.state = self.CANCELLED
355
356                if self.log:
357                    traceback.print_exc(None, self.log)
358                    self.log.flush()
359
360            except UpdateError as e:
361                self.message = e.message
362                self.can_cancel = True
363                self.can_proceed = False
364                self.state = self.ERROR
365
366                if self.log:
367                    traceback.print_exc(None, self.log)
368                    self.log.flush()
369
370            except Exception as e:
371                self.message = _type(e).__name__ + ": " + unicode(e)
372                self.can_cancel = True
373                self.can_proceed = False
374                self.state = self.ERROR
375
376                if self.log:
377                    traceback.print_exc(None, self.log)
378                    self.log.flush()
379
380            self.clean_old()
381
382            if self.log:
383                self.log.close()
384
385        def update(self):
386            """
387            Performs the update.
388            """
389
390            if getattr(renpy, "mobile", False):
391                raise UpdateError(_("The Ren'Py Updater is not supported on mobile devices."))
392
393            self.load_state()
394            self.test_write()
395            self.check_updates()
396
397            pretty_version = self.check_versions()
398
399            if not self.modules:
400                self.can_cancel = False
401                self.can_proceed = True
402                self.state = self.UPDATE_NOT_AVAILABLE
403                persistent._update_version[self.url] = None
404                renpy.restart_interaction()
405                return
406
407            persistent._update_version[self.url] = pretty_version
408
409            if self.check_only:
410                renpy.restart_interaction()
411                return
412
413            if self.confirm and (not self.add):
414
415                # Confirm with the user that the update is available.
416                with self.condition:
417                    self.can_cancel = True
418                    self.can_proceed = True
419                    self.state = self.UPDATE_AVAILABLE
420                    self.version = pretty_version
421
422                    renpy.restart_interaction()
423
424                    while True:
425                        if self.cancelled or self.proceeded:
426                            break
427
428                        self.condition.wait()
429
430            if self.cancelled:
431                raise UpdateCancelled()
432
433            self.can_cancel = True
434            self.can_proceed = False
435
436            # Disable autoreload.
437            renpy.set_autoreload(False)
438
439            # Perform the update.
440            self.new_state = dict(self.current_state)
441            renpy.restart_interaction()
442
443            self.progress = 0.0
444            self.state = self.PREPARING
445
446            if self.patch:
447                for i in self.modules:
448                    self.prepare(i)
449
450            self.progress = 0.0
451            self.state = self.DOWNLOADING
452            renpy.restart_interaction()
453
454            for i in self.modules:
455
456                if self.patch:
457
458                    try:
459                        self.download(i)
460                    except:
461                        self.download(i, standalone=True)
462
463                else:
464                    self.download_direct(i)
465
466            self.clean_old()
467
468            self.can_cancel = False
469            self.progress = 0.0
470            self.state = self.UNPACKING
471            renpy.restart_interaction()
472
473            for i in self.modules:
474                self.unpack(i)
475
476            self.progress = None
477            self.state = self.FINISHING
478            renpy.restart_interaction()
479
480            self.move_files()
481            self.delete_obsolete()
482            self.save_state()
483            self.clean_new()
484
485            self.message = None
486            self.progress = None
487            self.can_proceed = True
488            self.can_cancel = False
489
490            persistent._update_version[self.url] = None
491
492            if self.restart:
493                self.state = self.DONE
494            else:
495                self.state = self.DONE_NO_RESTART
496
497            renpy.restart_interaction()
498
499            return
500
501        def simulation(self):
502            """
503            Simulates the update.
504            """
505
506            def simulate_progress():
507                for i in range(0, 30):
508                    self.progress = i / 30.0
509                    time.sleep(.1)
510
511                    if self.cancelled:
512                        raise UpdateCancelled()
513
514            time.sleep(1.5)
515
516            if self.cancelled:
517                raise UpdateCancelled()
518
519            if self.simulate == "error":
520                raise UpdateError(_("An error is being simulated."))
521
522            if self.simulate == "not_available":
523                self.can_cancel = False
524                self.can_proceed = True
525                self.state = self.UPDATE_NOT_AVAILABLE
526                persistent._update_version[self.url] = None
527                return
528
529            pretty_version = build.version or build.directory_name
530            persistent._update_version[self.url] = pretty_version
531
532            if self.check_only:
533                renpy.restart_interaction()
534                return
535
536            # Confirm with the user that the update is available.
537
538            if self.confirm:
539
540                with self.condition:
541                    self.can_cancel = True
542                    self.can_proceed = True
543                    self.state = self.UPDATE_AVAILABLE
544                    self.version = pretty_version
545
546                    while True:
547                        if self.cancelled or self.proceeded:
548                            break
549
550                        self.condition.wait()
551
552            self.can_proceed = False
553
554            if self.cancelled:
555                raise UpdateCancelled()
556
557            self.progress = 0.0
558            self.state = self.PREPARING
559            renpy.restart_interaction()
560
561            simulate_progress()
562
563            self.progress = 0.0
564            self.state = self.DOWNLOADING
565            renpy.restart_interaction()
566
567            simulate_progress()
568
569            self.can_cancel = False
570            self.progress = 0.0
571            self.state = self.UNPACKING
572            renpy.restart_interaction()
573
574            simulate_progress()
575
576            self.progress = None
577            self.state = self.FINISHING
578            renpy.restart_interaction()
579
580            time.sleep(1.5)
581
582            self.message = None
583            self.progress = None
584            self.can_proceed = True
585            self.can_cancel = False
586
587            persistent._update_version[self.url] = None
588
589            if self.restart:
590                self.state = self.DONE
591            else:
592                self.state = self.DONE_NO_RESTART
593
594            renpy.restart_interaction()
595
596            return
597
598        def proceed(self):
599            """
600            Causes the upgraded to proceed with the next step in the process.
601            """
602
603            if not self.can_proceed:
604                return
605
606            if self.state == self.UPDATE_NOT_AVAILABLE:
607                renpy.full_restart()
608
609            elif self.state == self.ERROR:
610                renpy.full_restart()
611
612            elif self.state == self.CANCELLED:
613                renpy.full_restart()
614
615            elif self.state == self.DONE:
616                renpy.quit(relaunch=True)
617
618            elif self.state == self.DONE_NO_RESTART:
619                return True
620
621            elif self.state == self.UPDATE_AVAILABLE:
622                with self.condition:
623                    self.proceeded = True
624                    self.condition.notify_all()
625
626        def cancel(self):
627
628            if not self.can_cancel:
629                return
630
631            with self.condition:
632                self.cancelled = True
633                self.condition.notify_all()
634
635            if self.restart:
636                renpy.full_restart()
637            else:
638                return False
639
640        def unlink(self, path):
641            """
642            Tries to unlink the file at `path`.
643            """
644
645            if os.path.exists(path + ".old"):
646                os.unlink(path + ".old")
647
648            if os.path.exists(path):
649
650                # This might fail because of a sharing violation on Windows.
651                try:
652                    os.rename(path, path + ".old")
653                    os.unlink(path + ".old")
654                except:
655                    pass
656
657        def rename(self, old, new):
658            """
659            Renames the old name to the new name. Tries to enforce the unix semantics, even
660            on windows.
661            """
662
663            try:
664                os.rename(old, new)
665                return
666            except:
667                pass
668
669            try:
670                os.unlink(new)
671            except:
672                pass
673
674            os.rename(old, new)
675
676
677        def path(self, name):
678            """
679            Converts a filename to a path on disk.
680            """
681
682            if self.app is not None:
683
684                path = name.split("/")
685                if path[0].endswith(".app"):
686                    rv = os.path.join(self.app, "/".join(path[1:]))
687                    return rv
688
689            rv = os.path.join(self.base, name)
690
691            if renpy.windows:
692                rv = "\\\\?\\" + rv.replace("/", "\\")
693
694            return rv
695
696        def load_state(self):
697            """
698            Loads the current update state from update/current.json
699            """
700
701            fn = os.path.join(self.updatedir, "current.json")
702
703            if not os.path.exists(fn):
704                raise UpdateError(_("Either this project does not support updating, or the update status file was deleted."))
705
706            with open(fn, "rb") as f:
707                self.current_state = json.load(f)
708
709        def test_write(self):
710            fn = os.path.join(self.updatedir, "test.txt")
711
712            try:
713                with open(fn, "wb") as f:
714                    f.write("Hello, World.")
715
716                os.unlink(fn)
717            except:
718                raise UpdateError(_("This account does not have permission to perform an update."))
719
720            if not self.log:
721                raise UpdateError(_("This account does not have permission to write the update log."))
722
723        def check_updates(self):
724            """
725            Downloads the list of updates from the server, parses it, and stores it in
726            self.updates.
727            """
728
729            fn = os.path.join(self.updatedir, "updates.json")
730            urlretrieve(self.url, fn)
731
732            with open(fn, "rb") as f:
733                updates_json = f.read()
734                self.updates = json.loads(updates_json)
735
736            if self.public_key is not None:
737                fn = os.path.join(self.updatedir, "updates.json.sig")
738                urlretrieve(self.url + ".sig", fn)
739
740                with open(fn, "rb") as f:
741                    signature = f.read().decode("base64")
742
743                try:
744                    rsa.verify(updates_json, signature, self.public_key)
745                except:
746                    raise UpdateError(_("Could not verify update signature."))
747
748                if "monkeypatch" in self.updates:
749                    future.utils.exec_(self.updates["monkeypatch"], globals(), globals())
750
751        def add_dlc_state(self, name):
752            url = urlparse.urljoin(self.url, self.updates[name]["json_url"])
753            f = urlopen(url)
754            d = json.load(f)
755            d[name]["version"] = 0
756            self.current_state.update(d)
757
758
759        def check_versions(self):
760            """
761            Decides what modules need to be updated, if any.
762            """
763
764            rv = None
765
766            # A list of names of modules we want to update.
767            self.modules = [ ]
768
769            # DLC?
770            if self.add:
771                for name in self.add:
772                    if name in self.updates:
773                        self.modules.append(name)
774
775                    if name not in self.current_state:
776                        self.add_dlc_state(name)
777
778                    rv = self.updates[name]["pretty_version"]
779
780                return rv
781
782            # We update the modules that are in both versions, and that are out of date.
783            for name, data in self.current_state.items():
784
785                if name not in self.updates:
786                    continue
787
788                if data["version"] == self.updates[name]["version"]:
789                    if not self.force:
790                        continue
791
792                self.modules.append(name)
793
794                rv = self.updates[name]["pretty_version"]
795
796            return rv
797
798        def update_filename(self, module, new):
799            """
800            Returns the update filename for the given module.
801            """
802
803            rv = os.path.join(self.updatedir, module + ".update")
804            if new:
805                return rv + ".new"
806
807            return rv
808
809        def prepare(self, module):
810            """
811            Creates a tarfile creating the files that make up module.
812            """
813
814            state = self.current_state[module]
815
816            xbits = set(state["xbit"])
817            directories = set(state["directories"])
818            all = state["files"] + state["directories"]
819            all.sort()
820
821            # Add the update directory and state file.
822            all.append("update")
823            directories.add("update")
824            all.append("update/current.json")
825
826            with tarfile.open(self.update_filename(module, False), "w") as tf:
827                for i, name in enumerate(all):
828
829                    if self.cancelled:
830                        raise UpdateCancelled()
831
832                    self.progress = 1.0 * i / len(all)
833
834                    directory = name in directories
835                    xbit = name in xbits
836
837                    path = self.path(name)
838
839                    if directory:
840                        info = tarfile.TarInfo(name)
841                        info.size = 0
842                        info.type = tarfile.DIRTYPE
843                    else:
844                        if not os.path.exists(path):
845                            continue
846
847                        info = tf.gettarinfo(path, name)
848
849                        if not info.isreg():
850                            continue
851
852                    info.uid = 1000
853                    info.gid = 1000
854                    info.mtime = 0
855                    info.uname = "renpy"
856                    info.gname = "renpy"
857
858                    if xbit or directory:
859                        info.mode = 0o777
860                    else:
861                        info.mode = 0o666
862
863                    if info.isreg():
864                        with open(path, "rb") as f:
865                            tf.addfile(info, f)
866                    else:
867                        tf.addfile(info)
868
869        def download(self, module, standalone=False):
870            """
871            Uses zsync to download the module.
872            """
873
874            start_progress = None
875
876            new_fn = self.update_filename(module, True)
877
878            # Download the sums file.
879            sums = [ ]
880
881            f = urlopen(urlparse.urljoin(self.url, self.updates[module]["sums_url"]))
882            data = f.read()
883
884            for i in range(0, len(data), 4):
885                try:
886                    sums.append(struct.unpack("<I", data[i:i+4])[0])
887                except:
888                    pass
889
890            f.close()
891
892            # Figure out the zsync command.
893
894            zsync_fn = os.path.join(self.updatedir, module + ".zsync")
895
896            # May not exist, but if it does, we want to delete it.
897            try:
898                os.unlink(zsync_fn + ".part")
899            except:
900                pass
901
902            try:
903                os.unlink(new_fn)
904            except:
905                pass
906
907            cmd = [
908                zsync_path("zsync"),
909                "-o", new_fn,
910                ]
911
912            if not standalone:
913                cmd.extend([
914                    "-k", zsync_fn,
915                ])
916
917            if os.path.exists(new_fn + ".part"):
918                self.rename(new_fn + ".part", new_fn + ".part.old")
919
920                if not standalone:
921                    cmd.append("-i")
922                    cmd.append(new_fn + ".part.old")
923
924            if not standalone:
925                for i in self.modules:
926                    cmd.append("-i")
927                    cmd.append(self.update_filename(module, False))
928
929            cmd.append(urlparse.urljoin(self.url, self.updates[module]["zsync_url"]))
930
931            cmd = [ fsencode(i) for i in cmd ]
932
933            self.log.write("running %r\n" % cmd)
934            self.log.flush()
935
936            if renpy.windows:
937
938                CREATE_NO_WINDOW=0x08000000
939                p = subprocess.Popen(cmd,
940                    stdin=subprocess.PIPE,
941                    stdout=self.log,
942                    stderr=self.log,
943                    creationflags=CREATE_NO_WINDOW,
944                    cwd=renpy.fsencode(self.updatedir))
945            else:
946
947                p = subprocess.Popen(cmd,
948                    stdin=subprocess.PIPE,
949                    stdout=self.log,
950                    stderr=self.log,
951                    cwd=renpy.fsencode(self.updatedir))
952
953            p.stdin.close()
954
955            while True:
956                if self.cancelled:
957                    p.kill()
958                    break
959
960                time.sleep(1)
961
962                if p.poll() is not None:
963                    break
964
965                try:
966                    f = file(new_fn + ".part", "rb")
967                except:
968                    self.log.write("partfile does not exist\n")
969                    continue
970
971                done_sums = 0
972
973                with f:
974                    for i in sums:
975
976                        if self.cancelled:
977                            break
978
979                        data = f.read(65536)
980
981                        if not data:
982                            break
983
984                        if (zlib.adler32(data) & 0xffffffff) == i:
985                            done_sums += 1
986
987                raw_progress = 1.0 * done_sums / len(sums)
988
989                if raw_progress == 1.0:
990                    start_progress = None
991                    self.progress = 1.0
992                    continue
993
994                if start_progress is None:
995                    start_progress = raw_progress
996                    self.progress = 0.0
997                    continue
998
999                self.progress = (raw_progress - start_progress) / (1.0 - start_progress)
1000
1001            p.wait()
1002
1003            self.log.seek(0, 2)
1004
1005            if self.cancelled:
1006                raise UpdateCancelled()
1007
1008            # Check the existence of the downloaded file.
1009            if not os.path.exists(new_fn):
1010                if os.path.exists(new_fn + ".part"):
1011                    os.rename(new_fn + ".part", new_fn)
1012                else:
1013                    raise UpdateError(_("The update file was not downloaded."))
1014
1015            # Check that the downloaded file has the right digest.
1016            import hashlib
1017            with open(new_fn, "rb") as f:
1018                hash = hashlib.sha256()
1019
1020                while True:
1021                    data = f.read(1024 * 1024)
1022
1023                    if not data:
1024                        break
1025
1026                    hash.update(data)
1027
1028                digest = hash.hexdigest()
1029
1030            if digest != self.updates[module]["digest"]:
1031                raise UpdateError(_("The update file does not have the correct digest - it may have been corrupted."))
1032
1033            if os.path.exists(new_fn + ".part.old"):
1034                os.unlink(new_fn + ".part.old")
1035
1036            if self.cancelled:
1037                raise UpdateCancelled()
1038
1039
1040        def download_direct(self, module):
1041            """
1042            Uses zsync to download the module.
1043            """
1044
1045            import requests
1046
1047            start_progress = None
1048
1049            new_fn = self.update_filename(module, True)
1050            part_fn = new_fn + ".part.gz"
1051
1052            # Figure out the zsync command.
1053
1054            zsync_fn = os.path.join(self.updatedir, module + ".zsync")
1055
1056            try:
1057                os.unlink(new_fn)
1058            except:
1059                pass
1060
1061            zsync_url = self.updates[module]["zsync_url"][:-6] + ".update.gz"
1062            url = urlparse.urljoin(self.url, zsync_url)
1063
1064            self.log.write("downloading %r\n" % url)
1065            self.log.flush()
1066
1067            resp = requests.get(url, stream=True)
1068
1069            if not resp.ok:
1070                raise UpdateError(_("The update file was not downloaded."))
1071
1072            try:
1073                length = int(resp.headers.get("Content-Length", "20000000"))
1074            except:
1075                length = 20000000
1076
1077            done = 0
1078
1079            with open(part_fn, "wb") as part_f:
1080
1081                for data in resp.iter_content(1000000):
1082
1083                    if self.cancelled:
1084                        break
1085
1086                    part_f.write(data)
1087
1088                    done += len(data)
1089
1090                    self.progress = min(1.0, 1.0 * done / length)
1091
1092            resp.close()
1093
1094            if self.cancelled:
1095                raise UpdateCancelled()
1096
1097            # Decompress the file.
1098            import gzip
1099
1100            with gzip.open(part_fn, "rb") as gz_f:
1101                with open(new_fn, "wb") as new_f:
1102
1103                    while True:
1104                        data = gz_f.read(1000000)
1105
1106                        if not data:
1107                            break
1108
1109                        new_f.write(data)
1110
1111            os.unlink(part_fn)
1112
1113            # Check that the downloaded file has the right digest.
1114            import hashlib
1115            with open(new_fn, "rb") as f:
1116                hash = hashlib.sha256()
1117
1118                while True:
1119                    data = f.read(1024 * 1024)
1120
1121                    if not data:
1122                        break
1123
1124                    hash.update(data)
1125
1126                digest = hash.hexdigest()
1127
1128            if digest != self.updates[module]["digest"]:
1129                raise UpdateError(_("The update file does not have the correct digest - it may have been corrupted."))
1130
1131            if self.cancelled:
1132                raise UpdateCancelled()
1133
1134
1135
1136        def unpack(self, module):
1137            """
1138            This unpacks the module. Directories are created immediately, while files are
1139            created as filename.new, and marked to be moved into position when all packing
1140            is done.
1141            """
1142
1143            update_fn = self.update_filename(module, True)
1144
1145            # First pass, just figure out how many tarinfo objects are in the tarfile.
1146            tf_len = 0
1147            with tarfile.open(update_fn, "r") as tf:
1148                for i in tf:
1149                    tf_len += 1
1150
1151            with tarfile.open(update_fn, "r") as tf:
1152                for i, info in enumerate(tf):
1153
1154                    self.progress = 1.0 * i / tf_len
1155
1156                    if info.name == "update":
1157                        continue
1158
1159                    # Process the status info for the current module.
1160                    if info.name == "update/current.json":
1161                        tff = tf.extractfile(info)
1162                        state = json.load(tff)
1163                        tff.close()
1164
1165                        self.new_state[module] = state[module]
1166
1167                        continue
1168
1169                    path = self.path(info.name)
1170
1171                    # Extract directories.
1172                    if info.isdir():
1173                        try:
1174                            os.makedirs(path)
1175                        except:
1176                            pass
1177
1178                        continue
1179
1180                    if not info.isreg():
1181                        raise UpdateError(__("While unpacking {}, unknown type {}.").format(info.name, info.type))
1182
1183                    # Extract regular files.
1184                    tff = tf.extractfile(info)
1185                    new_path = path + ".new"
1186                    with file(new_path, "wb") as f:
1187                        while True:
1188                            data = tff.read(1024 * 1024)
1189                            if not data:
1190                                break
1191                            f.write(data)
1192
1193                    tff.close()
1194
1195                    if info.mode & 1:
1196                        # If the xbit is set in the tar info, set it on disk if we can.
1197                        try:
1198                            umask = os.umask(0)
1199                            os.umask(umask)
1200
1201                            os.chmod(new_path, 0o777 & (~umask))
1202                        except:
1203                            pass
1204
1205                    self.moves.append(path)
1206
1207        def move_files(self):
1208            """
1209            Move new files into place.
1210            """
1211
1212            for path in self.moves:
1213
1214                self.unlink(path)
1215
1216                if os.path.exists(path):
1217                    self.log.write("could not rename file %s" % path.encode("utf-8"))
1218
1219                    with open(DEFERRED_UPDATE_FILE, "ab") as f:
1220                        f.write("R " + path.encode("utf-8") + "\r\n")
1221
1222                    continue
1223
1224                try:
1225                    os.rename(path + ".new", path)
1226                except:
1227                    pass
1228
1229        def delete_obsolete(self):
1230            """
1231            Delete files and directories that have been made obsolete by the upgrade.
1232            """
1233
1234            def flatten_path(d, key):
1235                rv = set()
1236
1237                for i in d.values():
1238                    for j in i[key]:
1239                        rv.add(self.path(j))
1240
1241                return rv
1242
1243            old_files = flatten_path(self.current_state, 'files')
1244            old_directories = flatten_path(self.current_state, 'directories')
1245
1246            new_files = flatten_path(self.new_state, 'files')
1247            new_directories = flatten_path(self.new_state, 'directories')
1248
1249            old_files -= new_files
1250            old_directories -= new_directories
1251
1252            old_files = list(old_files)
1253            old_files.sort()
1254            old_files.reverse()
1255
1256            old_directories = list(old_directories)
1257            old_directories.sort()
1258            old_directories.reverse()
1259
1260            for i in old_files:
1261                self.unlink(i)
1262
1263                if os.path.exists(i):
1264                    self.log.write("could not delete file %s" % i.encode("utf-8"))
1265                    with open(DEFERRED_UPDATE_FILE, "wb") as f:
1266                        f.write("D " + i.encode("utf-8") + "\r\n")
1267
1268            for i in old_directories:
1269                try:
1270                    os.rmdir(i)
1271                except:
1272                    pass
1273
1274        def save_state(self):
1275            """
1276            Saves the current state to update/current.json
1277            """
1278
1279            fn = os.path.join(self.updatedir, "current.json")
1280
1281            with open(fn, "wb") as f:
1282                json.dump(self.new_state, f)
1283
1284        def clean(self, fn):
1285            """
1286            Cleans the file named fn from the updates directory.
1287            """
1288
1289            fn = os.path.join(self.updatedir, fn)
1290            if os.path.exists(fn):
1291                try:
1292                    os.unlink(fn)
1293                except:
1294                    pass
1295
1296        def clean_old(self):
1297            for i in self.modules:
1298                self.clean(i + ".update")
1299
1300        def clean_new(self):
1301            for i in self.modules:
1302                self.clean(i + ".update.new")
1303                self.clean(i + ".zsync")
1304
1305    installed_state_cache = None
1306
1307    def get_installed_state(base=None):
1308        """
1309        :undocumented:
1310
1311        Returns the state of the installed packages.
1312
1313        `base`
1314            The base directory to update. Defaults to the current project's
1315            base directory.
1316        """
1317
1318        global installed_state_cache
1319
1320        if installed_state_cache is not None:
1321            return installed_state_cache
1322
1323        if base is None:
1324            base = config.basedir
1325
1326        fn = os.path.join(base, "update", "current.json")
1327
1328        if not os.path.exists(fn):
1329            return None
1330
1331        with open(fn, "rb") as f:
1332            state = json.load(f)
1333
1334        installed_state_cache = state
1335        return state
1336
1337    def get_installed_packages(base=None):
1338        """
1339        :doc: updater
1340
1341        Returns a list of installed DLC package names.
1342
1343        `base`
1344            The base directory to update. Defaults to the current project's
1345            base directory.
1346        """
1347
1348        state = get_installed_state(base)
1349
1350        if state is None:
1351            return [ ]
1352
1353        rv = list(state.keys())
1354        return rv
1355
1356
1357    def can_update(base=None):
1358        """
1359        :doc: updater
1360
1361        Returns true if it's possible that an update can succeed. Returns false
1362        if updating is totally impossible. (For example, if the update directory
1363        was deleted.)
1364
1365
1366        Note that this does not determine if an update is actually available.
1367        To do that, use :func:`updater.UpdateVersion`.
1368        """
1369
1370        # Written this way so we can use this code with 6.18 and earlier.
1371        if getattr(renpy, "mobile", False):
1372            return False
1373
1374        if rsa is None:
1375            return False
1376
1377        return not not get_installed_packages(base)
1378
1379
1380    def update(url, base=None, force=False, public_key=None, simulate=None, add=[], restart=True, confirm=True, patch=True):
1381        """
1382        :doc: updater
1383
1384        Updates this Ren'Py game to the latest version.
1385
1386        `url`
1387            The URL to the updates.json file.
1388
1389        `base`
1390            The base directory that will be updated. Defaults to the base
1391            of the current game. (This can usually be ignored.)
1392
1393        `force`
1394            Force the update to occur even if the version numbers are
1395            the same. (Used for testing.)
1396
1397        `public_key`
1398            The path to a PEM file containing a public key that the
1399            update signature is checked against. (This can usually be ignored.)
1400
1401        `simulate`
1402            This is used to test update guis without actually performing
1403            an update. This can be:
1404
1405            * None to perform an update.
1406            * "available" to test the case where an update is available.
1407            * "not_available" to test the case where no update is available.
1408            * "error" to test an update error.
1409
1410        `add`
1411            A list of packages to add during this update. This is only necessary
1412            for dlc.
1413
1414        `restart`
1415            Restart the game after the update.
1416
1417        `confirm`
1418            Should Ren'Py prompt the user to confirm the update? If False, the
1419            update will proceed without confirmation.
1420
1421        `patch`
1422            If true, Ren'Py will attempt to patch the game, downloading only
1423            changed data. If false, Ren'Py will download a complete copy of
1424            the game, and update from that. This is set to false automatically
1425            when the url does not begin with "http:".
1426        """
1427
1428        global installed_packages_cache
1429        installed_packages_cache = None
1430
1431        u = Updater(url=url, base=base, force=force, public_key=public_key, simulate=simulate, add=add, restart=restart, confirm=confirm, patch=patch)
1432        ui.timer(.1, repeat=True, action=renpy.restart_interaction)
1433        renpy.call_screen("updater", u=u)
1434
1435
1436    @renpy.pure
1437    class Update(Action, DictEquality):
1438        """
1439        :doc: updater
1440
1441        An action that calls :func:`updater.update`. All arguments are
1442        stored and passed to that function.
1443        """
1444
1445        def __init__(self, *args, **kwargs):
1446            self.args = args
1447            self.kwargs = kwargs
1448
1449        def __call__(self):
1450            renpy.invoke_in_new_context(update, *self.args, **self.kwargs)
1451
1452
1453    # A list of URLs that we've checked for the update version.
1454    checked = set()
1455
1456    def UpdateVersion(url, check_interval=3600*6, simulate=None, **kwargs):
1457        """
1458        :doc: updater
1459
1460        This function contacts the server at `url`, and determines if there is
1461        a newer version of software available at that url. If there is, this
1462        function returns the new version. Otherwise, it returns None.
1463
1464        Since contacting the server can take some time, this function launches
1465        a thread in the background, and immediately returns the version from
1466        the last time the server was contacted, or None if the server has never
1467        been contacted. The background thread will restart the current interaction
1468        once the server has been contacted, which will cause screens that call
1469        this function to update.
1470
1471        Each url will be contacted at most once per Ren'Py session, and not
1472        more than once every `check_interval` seconds. When the server is not
1473        contacted, cached data will be returned.
1474
1475        Additional keyword arguments (including `simulate`) are passed to the
1476        update mechanism as if they were given to :func:`updater.update`.
1477        """
1478
1479        if not can_update() and not simulate:
1480            return None
1481
1482        check = True
1483
1484        if url in checked:
1485            check = False
1486
1487        if time.time() < persistent._update_last_checked.get(url, 0) + check_interval:
1488            check = False
1489
1490        if check:
1491            checked.add(url)
1492            persistent._update_last_checked[url] = time.time()
1493            Updater(url, check_only=True, simulate=simulate, **kwargs)
1494
1495        return persistent._update_version.get(url, None)
1496
1497
1498    def update_command():
1499        import time
1500
1501        ap = renpy.arguments.ArgumentParser()
1502
1503        ap.add_argument("url")
1504        ap.add_argument("--base", action='store', help="The base directory of the game to update. Defaults to the current game.")
1505        ap.add_argument("--force", action="store_true", help="Force the update to run even if the version numbers are the same.")
1506        ap.add_argument("--key", action="store", help="A file giving the public key to use of the update.")
1507        ap.add_argument("--simulate", help="The simulation mode to use. One of available, not_available, or error.")
1508
1509        args = ap.parse_args()
1510
1511        u = Updater(args.url, args.base, args.force, public_key=args.key, simulate=args.simulate)
1512
1513        while True:
1514
1515            state = u.state
1516
1517            print("State:", state)
1518
1519            if u.progress:
1520                print("Progress: {:.1%}".format(u.progress))
1521
1522            if u.message:
1523                print("Message:", u.message)
1524
1525            if state == u.ERROR:
1526                break
1527            elif state == u.UPDATE_NOT_AVAILABLE:
1528                break
1529            elif state == u.UPDATE_AVAILABLE:
1530                u.proceed()
1531            elif state == u.DONE:
1532                break
1533            elif state == u.CANCELLED:
1534                break
1535
1536            time.sleep(.1)
1537
1538        return False
1539
1540    renpy.arguments.register_command("update", update_command)
1541
1542init -1500:
1543    screen updater:
1544
1545        add "#000"
1546
1547        frame:
1548            style_group ""
1549
1550            has side "t c b":
1551                spacing gui._scale(10)
1552
1553            label _("Updater")
1554
1555            fixed:
1556
1557                vbox:
1558
1559                    if u.state == u.ERROR:
1560                        text _("An error has occured:")
1561                    elif u.state == u.CHECKING:
1562                        text _("Checking for updates.")
1563                    elif u.state == u.UPDATE_NOT_AVAILABLE:
1564                        text _("This program is up to date.")
1565                    elif u.state == u.UPDATE_AVAILABLE:
1566                        text _("[u.version] is available. Do you want to install it?")
1567                    elif u.state == u.PREPARING:
1568                        text _("Preparing to download the updates.")
1569                    elif u.state == u.DOWNLOADING:
1570                        text _("Downloading the updates.")
1571                    elif u.state == u.UNPACKING:
1572                        text _("Unpacking the updates.")
1573                    elif u.state == u.FINISHING:
1574                        text _("Finishing up.")
1575                    elif u.state == u.DONE:
1576                        text _("The updates have been installed. The program will restart.")
1577                    elif u.state == u.DONE_NO_RESTART:
1578                        text _("The updates have been installed.")
1579                    elif u.state == u.CANCELLED:
1580                        text _("The updates were cancelled.")
1581
1582                    if u.message is not None:
1583                        null height gui._scale(10)
1584                        text "[u.message!q]"
1585
1586                    if u.progress is not None:
1587                        null height gui._scale(10)
1588                        bar value (u.progress or 0.0) range 1.0 style "_bar"
1589
1590            hbox:
1591
1592                spacing gui._scale(25)
1593
1594                if u.can_proceed:
1595                    textbutton _("Proceed") action u.proceed
1596
1597                if u.can_cancel:
1598                    textbutton _("Cancel") action u.cancel
1599