ajhahn.de
← the-way-out
Python 239 lines
"""Frozen entry-point for the packaged macOS app.

This is the *only* Python that PyInstaller bakes into ``The Way Out.app``.
It never changes after a build. Its whole job:

  1. make sure runnable game code exists in ``~/.the-way-out/app/``
     (pull the newest commit from GitHub, or fall back to the snapshot
     baked into the .app on a first run with no internet),
  2. hand control to that external ``app/main.py`` *inside this same
     frozen Python* so ``import pygame`` resolves from the bundle.

Imports only stdlib + ``updater`` (the bundled bootstrap copy) +
``pygame`` (only for the offline error screen). No new dependencies.
"""
import os
import runpy
import shutil
import subprocess
import sys
import traceback

import updater  # bundled bootstrap copy — NOT re-implemented here

APP_NAME = "The Way Out.app"
APPLICATIONS = "/Applications"


def _bundle_seed():
    """Folder PyInstaller unpacked the source snapshot into.

    ``--add-data "<seed>:_seed"`` puts it next to the frozen app; in a
    plain ``python launcher.py`` dev run there is no ``_seed`` and
    ``updater.seed_from`` simply returns False (the GitHub path covers
    dev)."""
    base = getattr(sys, "_MEIPASS",
                   os.path.dirname(os.path.abspath(__file__)))
    return os.path.join(base, "_seed")


def _bundle_path():
    """Absolute path of the running ``.app`` bundle, or ``None`` when
    this is a plain ``python launcher.py`` dev run (nothing to install).

    Inside a PyInstaller ``--windowed`` bundle ``sys.executable`` is
    ``…/The Way Out.app/Contents/MacOS/The Way Out``; the bundle is
    three directories up.
    """
    if not getattr(sys, "frozen", False) or sys.platform != "darwin":
        return None
    contents_macos = os.path.dirname(os.path.realpath(sys.executable))
    bundle = os.path.dirname(os.path.dirname(contents_macos))
    return bundle if bundle.endswith(".app") else None


def _relocate_to_applications():
    """Make ``/Applications`` hold the canonical copy and run from there.

    macOS *App Translocation* runs a quarantined download from a random
    read-only path, so a user who keeps double-clicking the copy in
    Downloads never gets a stable, Gatekeeper-clean install. Mirroring
    standard Mac app behaviour: copy the bundle into ``/Applications``
    once (replacing any older copy so it always matches the build that
    was just launched), then relaunch from there and exit this process.

    Strictly best-effort — any failure falls through and the game still
    starts from wherever it is. The path-equality check below is the
    real relaunch-loop guard (the installed copy lives in
    ``/Applications`` by definition); ``TWO_RELOCATED`` is belt-and-
    braces for the current process.
    """
    if os.environ.get("TWO_RELOCATED") == "1":
        return
    bundle = _bundle_path()
    if bundle is None:
        return
    target = os.path.join(APPLICATIONS, APP_NAME)
    # Already canonical → nothing to do. A translocated bundle never
    # resolves under /Applications, so this still lets it through.
    if os.path.realpath(bundle) == os.path.realpath(target):
        return
    try:
        try:
            if os.path.exists(target):
                shutil.rmtree(target)
            shutil.copytree(bundle, target, symlinks=True)
        except (PermissionError, OSError):
            # Protected / non-admin /Applications: ask the OS for an
            # authenticated copy (standard password prompt). Paths are
            # passed as argv and quoted by AppleScript's ``quoted form
            # of`` so spaces in "The Way Out.app" can't break the shell
            # and nothing is injectable.
            subprocess.run([
                "osascript",
                "-e", "on run argv",
                "-e", "set s to item 1 of argv",
                "-e", "set t to item 2 of argv",
                "-e", ('do shell script "rm -rf " & quoted form of t & '
                       '" && ditto " & quoted form of s & " " & '
                       'quoted form of t with administrator privileges'),
                "-e", "end run",
                bundle, target,
            ], check=True)
        if not os.path.isdir(target):
            return
        # Hand off to the installed copy. ``open`` returns immediately;
        # exiting here leaves only the /Applications instance running.
        subprocess.Popen(["/usr/bin/open", "-n", target],
                          env=dict(os.environ, TWO_RELOCATED="1"))
        raise SystemExit(0)
    except SystemExit:
        raise
    except Exception:
        pass                                  # never block game start


def _log_crash(exc):
    """Persist a traceback so a failed launch is debuggable later."""
    try:
        updater.ROOT.mkdir(parents=True, exist_ok=True)
        with open(updater.ROOT / "last_error.log", "w") as fh:
            traceback.print_exception(
                type(exc), exc, exc.__traceback__, file=fh)
    except OSError:
        pass


def _error_screen(lines):
    """Tiny pygame window for the one case we can't recover from: very
    first launch with no code and no internet. Uses the game's pixel
    font baked into ``_seed`` so this screen matches the rest of the
    game; only falls back to the default font if the seed isn't
    unpacked. Best-effort; never raises."""
    try:
        import pygame
        pygame.init()
        sw, sh = 720, 360
        # set_icon must run BEFORE set_mode so macOS picks it up for the
        # actual window. Best-effort: the bundled seed may be missing.
        try:
            seed_icon = os.path.join(
                _bundle_seed(), "assets", "icon_1024.png")
            pygame.display.set_icon(pygame.image.load(seed_icon))
        except (pygame.error, FileNotFoundError, OSError):
            pass
        screen = pygame.display.set_mode((sw, sh))
        pygame.display.set_caption("The Way Out")
        # settings.FONT == "assets/gui/font/main_font.otf"; the frozen
        # launcher can't import settings, so reconstruct that path
        # under the bundled seed the same way _bundle_seed() does.
        seed_font = os.path.join(_bundle_seed(), "assets", "gui",
                                 "font", "main_font.otf")
        try:
            font = pygame.font.Font(seed_font, 30)
        except (OSError, pygame.error):
            font = pygame.font.Font(None, 30)  # seed missing → default
        clock = pygame.time.Clock()
        running = True
        while running:
            for e in pygame.event.get():
                if e.type == pygame.QUIT:
                    running = False
                if (e.type == pygame.KEYDOWN
                        and e.key == pygame.K_ESCAPE):
                    running = False
                if (e.type == pygame.KEYDOWN
                        and e.key == pygame.K_q
                        and (e.mod & pygame.KMOD_META)):
                    running = False
            # Hand-synced copies of theme.BG / theme.INK — the frozen
            # launcher runs before the game's own modules are on the
            # path, so it can't import theme.py. Keep these two tuples
            # in sync with theme.py manually.
            screen.fill((18, 18, 24))
            for i, ln in enumerate(lines):
                surf = font.render(ln, True, (214, 216, 226))
                screen.blit(surf, surf.get_rect(
                    center=(sw // 2, 70 + i * 42)))
            pygame.display.flip()
            clock.tick(30)
        pygame.quit()
    except Exception:
        pass


def ensure_code():
    """Guarantee ``app/`` holds a runnable game. Order: try update →
    else recover from a crashed prior update → else seed from the baked
    snapshot → else whatever is already there. Any network/IO failure is
    swallowed: a stale-but-working install is always better than not
    starting."""
    try:
        if updater.should_check():
            loc, rem, available = updater.check()
            if available and rem is not None:
                updater.apply_update(expected_sha=rem)
    except Exception:
        pass                                  # offline/error → fallbacks
    if not updater.has_code():
        updater.recover_from_prev()           # crashed mid-rename → use prev
    if not updater.has_code():
        updater.seed_from(_bundle_seed())     # offline first-run snapshot
    return updater.has_code()


def main():
    _relocate_to_applications()        # may exec the /Applications copy
    if not ensure_code():
        _error_screen([
            "The Way Out",
            "",
            "Couldn't load the game.",
            "Please connect to the internet and open it once.",
            "After that it runs offline.",
            "",
            "(close this window to quit)",
        ])
        return 1

    app = str(updater.app_dir())
    os.chdir(app)                  # CRITICAL: game uses relative asset paths
    sys.path.insert(0, app)        # so app/main.py finds its sibling modules
    # The bootstrap updater has done its job. Drop it so the game's own
    # ``import updater`` loads app/updater.py — that way the in-game
    # "Update" button evolves with the repo instead of being pinned to
    # the frozen bootstrap copy.
    sys.modules.pop("updater", None)
    try:
        runpy.run_path(os.path.join(app, "main.py"), run_name="__main__")
        return 0
    except SystemExit as e:
        return int(e.code) if isinstance(e.code, int) else 0
    except BaseException as e:     # last line of defence — log & exit clean
        _log_crash(e)
        return 1


if __name__ == "__main__":
    raise SystemExit(main())