diff --git a/AGENTS.md b/AGENTS.md index ac1ea783..5ef79456 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ The repository's additions are all rights reserved (`LICENSE-ADDITIONS`). It nev - Every lifter change passes the differential tests against Unicorn ([docs/building.md](docs/building.md), "Checks"). After changing `recomp/jumptables.py`, `tests\jumptables_check.py` reports 0 disagreements on 1.2.1 and 1.4.3. - `recomp/layout.py` alone decides module bases. - Graphics bugs: [docs/maxwell.md](docs/maxwell.md) lists the Maxwell behaviour the Vulkan path must match, what goes wrong when it does not, and what our runtime does. -- Comments say what the code does and why, in words that stand on their own. Cite our code by file plus function (a line number may follow as a hint). Comments and docs do not name other projects or say where code came from: the license header of copied code and its `THIRD_PARTY_NOTICES.md` row carry that. +- Comments say what the code does and why, in words that stand on their own. Cite our code by file plus function (a line number may follow as a hint). Comments and docs do not name other projects or say where code came from: the license header of copied code and its `THIRD_PARTY_NOTICES.md` row carry that. `handover/` is the one exception: it is release 0.12.0, which hands installed launchers over to Triforce, and it names that project throughout. ## Writing diff --git a/docs/releases/v0.12.0.md b/docs/releases/v0.12.0.md new file mode 100644 index 00000000..f0c600b5 --- /dev/null +++ b/docs/releases/v0.12.0.md @@ -0,0 +1,24 @@ +NX2navi and Triforce have merged and go on as Triforce. + +- This update turns NX2navi's launcher into the door to Triforce: one window that starts Triforce Setup, Triforce's + launcher. +- Nothing of NX2navi is removed or changed: your built game, your saves and your settings stay where they are. +- **Play NX2navi** in that window still starts the game you have, for as long as you want it. +- Triforce builds the game from your own files, as NX2navi did, and its Convert saves page brings your NX2navi saves + over. + +## Good to know + +- **Already have NX2navi:** the launcher updates itself to this version at its next check, as for any update. There is + nothing to download by hand. +- **New here:** get Triforce from https://triforceproject.com. This release carries no setup. +- **What is no longer in the launcher:** its own pages for settings, mods, rebuilding the game, saves and Add to + Steam. The game's own settings menu is unchanged. +- **Mods:** Play NX2navi starts the game as it is in its folder. A mod added after this update is not built in. +- **Steam:** a shortcut made with Add to Steam opens this window; choose Play NX2navi there. +- **Convert saves in Triforce v0.3.6** asks for the folder with your saves: it is the one the window shows as + *Your NX2navi game and saves* (Copy path). +- **Uninstalling NX2navi** from Windows' Apps list works as before and asks before it removes the game folder. +- **Linux:** the AppImage has no updater and is not changed by this release. + +The window's source is in [`handover/`](https://github.com/tangerineturtle1/NX2navi/tree/main/handover). diff --git a/handover/.gitignore b/handover/.gitignore new file mode 100644 index 00000000..c113c6e1 --- /dev/null +++ b/handover/.gitignore @@ -0,0 +1,5 @@ +# Not kept in the repository (README.md, "In the repository"): the window's drawn pictures, the Triforce Setup a +# release carries, and what make_handover.py writes. +/bridge/launcher/art/ +/bridge/launcher/triforce/TriforceSetup.exe +/release/ diff --git a/handover/README.md b/handover/README.md new file mode 100644 index 00000000..6b3d217a --- /dev/null +++ b/handover/README.md @@ -0,0 +1,144 @@ +# NX2navi → Triforce: the launcher handover + +NX2navi and Triforce have merged and go on as Triforce. This package is one NX2navi release that turns every +installed NX2navi launcher into the door to Triforce's launcher, through NX2navi's own update channel. Nothing new +has to be installed by hand, and nothing of NX2navi is removed from a player's PC. + +It is made for whoever publishes NX2navi's releases (`tangerineturtle1/NX2navi`). + +## What to do + +1. Look at it first: the pictures in `preview/`, and the code in `bridge/launcher/` (four small Python files). +2. Upload the two files in `release/` to a new GitHub release **`v0.12.0`** of `tangerineturtle1/NX2navi`: + - `NX2navi-app-0.12.0.zip` + - `NX2navi-update.json` +3. Publish it as a **full release, not a pre-release**. Launchers on the Stable channel pass pre-releases over. + +That is all. Every launcher sees it at its next check (when it opens, and every hour while it runs), downloads the +3.3 MB archive, checks its size and sha256 against the manifest and switches to it, exactly as for any update. With +Install Updates Automatically on (the default) that happens by itself while the game is not running. + +`0.12.0` is newer than everything published as of 2026-11-14 (Stable `0.10.1`, Preview `0.11.0-rc4`). For another +number, see "Another version" below. The number must be plain (`0.12.0`, not `0.12.0-rc1`). + +## What a player sees + +After the update the launcher restarts, and instead of the old window this one opens (`preview/`): + +- **Get Triforce** starts Triforce Setup, Triforce's launcher, which installs itself per user as NX2navi's did and + takes the player through building the game from their own files. When Triforce is already on the PC the button + reads **Open Triforce** and starts the installed one. +- **Play NX2navi** starts the game that is built on the PC (`ocarina.exe` in the game folder), as the launcher's + Play did. It is there for as long as the player wants it. It is not shown when no game was built. +- **Your game files** and **Your NX2navi game and saves**: the two paths from the launcher's settings, each with + Copy path, because Triforce Setup asks for the game files and its Convert saves page takes the saves from the + game folder. +- Links to triforceproject.com and the Discord. + +The NX2navi shortcuts keep working and open this window. A Steam shortcut made by Add to Steam opens it too. + +## What it touches, and what it does not + +- **Not touched:** the built game, the saves in it, `data/launcher.json`, the build tools, the mods, the dump. The + old launcher version stays on the disk as `app/` (the stub's rollback needs it). +- **Written:** `app/0.12.0/` (the archive), `app/current.txt` and `app/previous.txt` (by the old launcher, as for any + update), `data/logs/handover.log`, `data/handover.json` (the time of the last update check), and, when the player + selects Get Triforce, a copy of `TriforceSetup.exe` in `%TEMP%\TriforceSetup\`. +- **Uninstalling NX2navi** (Windows' Apps list, which runs `NX2navi.exe --uninstall`) still works: the window asks, + then removes the shortcuts, the Apps entry and the same files and folders the launcher's own uninstall removed + (`winsys.OWNED`); the built game goes only on a second, separate Yes. + +## Safety + +- **A handover that cannot start rolls back.** `start.pyw` keeps the stub's contract: a failure before the window is + up ends the process with exit code 86 inside the stub's six seconds, and the stub goes back to the version before + and starts it with `--rolled-back`. That launcher then never installs this version by itself again + (`rolled_back`), as with any failed update. +- **The way back stays open.** Once an hour at most (and only while Check for Updates is on), the window asks the + same feed the launcher asked (`feed.json` of the version before; `feed_repo` in the settings still overrides it) + for a version newer than its own. When there is one it stages it the launcher's way: checked download, + `app/`, a `runtime` the manifest names into `python.next`, then `previous.txt` and `current.txt`. So a + later NX2navi release, say `0.12.1`, replaces this window on every PC: a fixed handover, or a launcher again. +- **The Triforce Setup it carries is checked.** `launcher/triforce/setup.json` holds its size and sha256 + (Triforce `v0.3.6`, sha256 `59fba57a…e7eb2e`, the file published at triforceproject.com); the window compares + before it starts it and starts nothing that differs. Setup updates itself from triforceproject.com when it + starts, so the carried copy does not have to be the newest. +- Only the standard library and tkinter are used, so it runs on the Python each installed launcher already has: the + manifest names no `runtime`, and no launcher downloads a Python for it. +- No game data and nothing derived from the game is in the archive. + +## Looking at it before it is released + +Pictures without showing a window or doing anything, from any installed launcher's Python: + +``` +python\pythonw.exe \launcher\start.pyw --selftest --shots +``` + +A private run through a real launcher: put the two release files in a release of another repository and set +`"feed_repo": "owner/name"` in that launcher's `data\launcher.json`, or serve them on the same PC and put +`{"type": "url", "url": "http://127.0.0.1:8000/NX2navi-update.json"}` in its `app\\feed.json`. + +## Another version, another Setup, other notes + +Python 3.8 or later, no packages: + +``` +python make_handover.py --version 0.12.0 +python make_handover.py --version 1.0.0 --setup TriforceSetup.exe --setup-version v0.3.7 --notes notes.md +``` + +It packs `bridge/` and writes `release/NX2navi-app-.zip` and `release/NX2navi-update.json`. The window's +pictures are in `bridge/launcher/art/`; `tools/make_art.py` draws them again (Pillow and numpy) from `tools/art-src/`. + +## What was tested, and what was not + +`tests/e2e.py` (run 2026-11-14, all passed) copies an installed NX2navi launcher (versions 0.10.1 and 0.9.2 were on +the PC) and lets its own code and its own `NX2navi.exe` do the work against a feed on 127.0.0.1: + +- `cli.py check` and `cli.py update` of both versions see, download, check, stage and switch to the handover; +- the path the window takes in the background (`feed.latest`, `updater.stage`, `staged_info`, `install_staged`); +- the real stub starts the handover with `--continue-update`, and nothing is rolled back; +- a handover that cannot start: exit code 86, and the stub goes back to the version before; +- a GitHub-shaped feed: offered on Stable as a full release, passed over as a pre-release; +- the way back: the handover stages a newer release, and refuses a file that does not match its sha256; +- the uninstall's plan is the same command line the launcher's own `winsys` builds for the same folder; +- the window drawn with NX2navi's own Python (3.14.9, Tk 9.0.4) at 125 % display scale. + +Not tested: a release on the real GitHub repository; the 0.11.0 previews' launcher as a running program (in the +0.11.0-rc4 source, `feed.py`, `updater.py`, `versions.py`, `net.py`, `settings.py` and `start.pyw` are the same files +as 0.10.1's, byte for byte, and `cli.py` differs by six added lines); a person clicking through the window on a PC that has +an NX2navi game; Windows 10; display scales other than 125 %; the Linux AppImage, which has no launcher updater and +is not reached by this at all. + +## For players who are new + +This reaches installed launchers. `NX2navi-Setup-.exe` on the old releases still installs the old launcher, +which then updates to the handover at its first check. The download links for new players should point at +https://triforceproject.com instead. + +## Before publishing + +Triforce Setup's Convert saves page finds NX2navi's saves by itself from the Triforce release after `v0.3.6` (in +`v0.3.6` the save folder is picked by hand there). The window tells players the page brings their saves over, so +publish this once that Triforce release is out. Nothing in this package has to change for it: Setup updates itself. + +Triforce is free software under the GPL-3; its source, Triforce Setup's included, is at +https://git.triforceproject.com/triforce/source + +## In the repository + +This folder is the package's source. Three parts of the package are not kept here, and `make_handover.py` names the +one it misses when it is run without them: + +- `bridge/launcher/art/`, the window's pictures: `tools/make_art.py` draws them from `tools/art-src/`. +- `bridge/launcher/triforce/TriforceSetup.exe`: `setup.json` beside it names the file release 0.12.0 carries (its + Triforce release, its size and its sha256). Put that file there, or give another with `--setup`. +- `release/`, which `make_handover.py` writes. The archive published as release 0.12.0 is `NX2navi-app-0.12.0.zip`, + 3,333,988 bytes, sha256 `45c9c50d98fa4771c6d7330e8fa2353c9db1e8b65e3b37ef14174fb4b57e1793`. + +`tests/e2e.py` ran again on 2026-11-14 against the launchers of 0.9.1, 0.9.2, 0.10.0, 0.10.1 and the 0.10.2 and +0.11.0 previews: all of its checks passed. The pictures the self-test takes with the window off the screen came out +empty on that PC (150 % display scale) while the window itself drew as in `preview/`, and the test does not look into +the pictures: look at them yourself. Release 0.12.0 was published on 2026-11-14 with Triforce `v0.3.6` as its newest +release, before the one "Before publishing" waits for: in `v0.3.6` the Convert saves page asks for the save folder. diff --git a/handover/bridge/NOTICE.txt b/handover/bridge/NOTICE.txt new file mode 100644 index 00000000..03e2b086 --- /dev/null +++ b/handover/bridge/NOTICE.txt @@ -0,0 +1,18 @@ +NX2navi is now Triforce +======================= + +This version of NX2navi's launcher is the handover to Triforce: the two projects have merged and go on as Triforce. + +launcher/start.pyw what NX2navi.exe starts, as in every version +launcher/handover.py the window: Get Triforce / Open Triforce, Play NX2navi, where your game files are +launcher/cli.py the command line's answer +launcher/selftest.py pictures of the window for a look before release (NX2navi.exe --selftest --shots DIR) +launcher/art/ the window's pictures (the Triforce crest; the fairy is NX2navi's logo) +launcher/triforce/ Triforce Setup, Triforce's launcher, as published at https://triforceproject.com, + with setup.json: its release, size and sha256. The window checks the file against + them before it starts it. + +Triforce is free software under the GNU General Public License, version 3. Its source code, Triforce Setup's +included, is at https://git.triforceproject.com/triforce/source + +Nothing in this folder is game data, and nothing in it is derived from the game. diff --git a/handover/bridge/launcher/cli.py b/handover/bridge/launcher/cli.py new file mode 100644 index 00000000..0d3be514 --- /dev/null +++ b/handover/bridge/launcher/cli.py @@ -0,0 +1,20 @@ +"""NX2navi's launcher without its window was launcher/cli.py, and its `update` command finishes an update by running +the NEW version's cli.py (`--root DIR continue-update OLD`). From this version on there is nothing left to finish: +NX2navi is now Triforce, and this version is the handover to it (handover.py). So every command says that and ends +with exit code 0, which lets an `update` started from a console end cleanly. +""" +import sys + +TEXT = ('NX2navi is now Triforce: the two projects have merged and go on as Triforce.\n' + 'This version of NX2navi\'s launcher is the handover: start NX2navi (its shortcut or NX2navi.exe) and choose\n' + 'Get Triforce, or get Triforce from https://triforceproject.com\n' + 'Nothing of NX2navi was removed: the built game, its saves and the settings are where they were.') + + +def main(argv=None): + print(TEXT, flush=True) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/handover/bridge/launcher/handover.py b/handover/bridge/launcher/handover.py new file mode 100644 index 00000000..e5d449f9 --- /dev/null +++ b/handover/bridge/launcher/handover.py @@ -0,0 +1,883 @@ +"""NX2navi is now Triforce: the window NX2navi's launcher becomes with this release. + +NX2navi and Triforce have merged and go on as Triforce. This release takes the place of NX2navi's launcher in its own +update channel: the stub (NX2navi.exe) starts launcher/start.pyw of the version app/current.txt names, as it starts +every version, and start.pyw opens this window instead of the old launcher's. The window + + - starts Triforce Setup, Triforce's launcher: the installed one when Triforce is on the PC already, else the copy + this release carries (launcher/triforce/TriforceSetup.exe, checked against the sha256 in setup.json beside it); + - still starts the NX2navi game that is built on the PC (ocarina.exe in the game folder), for whoever wants to go on + with it for now; + - shows where the player's game files and NX2navi game folder are, which Triforce Setup asks for. + +It removes and changes nothing of NX2navi: the game folder, the saves in it, the settings (data/launcher.json) and the +build tools stay as they are, so Triforce Setup's Convert saves page finds the saves. Uninstalling NX2navi (Windows' +Apps list runs NX2navi.exe --uninstall) is done here as the old launcher did it. + +It keeps the way back open: once an hour at most it asks the feed NX2navi's launcher asked (the GitHub releases of +feed.json's repository) for a version newer than this one and, when there is one, stages it exactly as the old +launcher did (checked download, app/, previous.txt, current.txt), so the stub starts that version next time +and falls back to this one when it does not start. Whoever publishes NX2navi's releases can so replace this window. + +Only the standard library and tkinter are used: the launcher's own Python (python/ beside NX2navi.exe) runs it. +""" +import ctypes +import hashlib +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import threading +import time +import urllib.error +import urllib.parse +import urllib.request +import webbrowser +import zipfile +from pathlib import Path + +HERE = Path(__file__).resolve().parent +APP = HERE.parent +ART = HERE / 'art' +SETUP_DIR = HERE / 'triforce' + +SITE = 'https://triforceproject.com' +DISCORD = 'https://discord.gg/triforce' +DEFAULT_FEED = {'type': 'github', 'repo': 'tangerineturtle1/NX2navi'} +GITHUB_API = 'https://api.github.com' +MANIFEST = 'NX2navi-update.json' +CHECK_EVERY = 3600 + +CREATE_NO_WINDOW = 0x08000000 +DETACHED_PROCESS = 0x00000008 +CREATE_NEW_PROCESS_GROUP = 0x00000200 +NX_UNINSTALL_KEY = r'Software\Microsoft\Windows\CurrentVersion\Uninstall\NX2navi' +TRIFORCE_UNINSTALL_KEY = r'Software\Microsoft\Windows\CurrentVersion\Uninstall\Triforce' +# what NX2navi's launcher put in its folder (its launcher/winsys.py OWNED): an uninstall deletes these and nothing else +OWNED = ('NX2navi.exe', 'app', 'python', 'python.next', 'pydeps', 'data', 'toolchain') + + +# ---------------------------------------------------------------- the launcher's folder + +def launcher_root(): + return Path(os.environ.get('NX2NAVI_LAUNCHER_ROOT') or APP.parents[1]) + + +def read_text(path, default=''): + try: + return Path(path).read_text(encoding='utf-8-sig').strip() + except (OSError, UnicodeDecodeError): + return default + + +def write_text(path, text): + path = Path(path) + tmp = path.with_name(path.name + '.tmp') + tmp.write_text(text + '\n', encoding='utf-8') + os.replace(tmp, path) + + +def my_version(): + return read_text(APP / 'VERSION').split('+', 1)[0].strip() or APP.name + + +def log(root, line): + """One line into data/logs/handover.log; never an error.""" + try: + logs = Path(root) / 'data' / 'logs' + logs.mkdir(parents=True, exist_ok=True) + with open(logs / 'handover.log', 'a', encoding='utf-8') as f: + f.write(time.strftime('%Y-%m-%d %H:%M:%S ') + line + '\n') + except OSError: + pass + + +def read_settings(root): + """NX2navi's data/launcher.json as a dict ({} when it is not there or not JSON). Only read, never written.""" + try: + data = json.loads((Path(root) / 'data' / 'launcher.json').read_text(encoding='utf-8-sig')) + return data if isinstance(data, dict) else {} + except (OSError, ValueError): + return {} + + +def nx_game(settings, root): + """The NX2navi game built on this PC, as the path of its ocarina.exe, or None: the folder the settings name, else + the launcher's own game folder.""" + for folder in (settings.get('game_dir'), Path(root) / 'game'): + try: + if folder and (Path(folder) / 'ocarina.exe').is_file(): + return Path(folder) / 'ocarina.exe' + except (OSError, TypeError, ValueError): + pass + return None + + +def facts(root): + """What the window shows and acts on, read from the PC.""" + settings = read_settings(root) + game = nx_game(settings, root) + dump = settings.get('dump') if isinstance(settings.get('dump'), str) else '' + if dump and not os.path.exists(dump): + dump = '' + return {'triforce': triforce_installed(), 'game': game, 'dump': os.path.normpath(dump) if dump else '', + 'game_dir': str(game.parent) if game else ''} + + +# ---------------------------------------------------------------- Triforce Setup + +def triforce_installed(): + """The installed Triforce Setup (Triforce's launcher), or None: the folder its entry in Windows' Apps list names + (HKCU ...\\Uninstall\\Triforce, InstallLocation), else its default folder.""" + folders = [] + try: + import winreg + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, TRIFORCE_UNINSTALL_KEY) as k: + folders.append(winreg.QueryValueEx(k, 'InstallLocation')[0]) + except (OSError, ImportError): + pass + if os.environ.get('LOCALAPPDATA'): + folders.append(os.path.join(os.environ['LOCALAPPDATA'], 'Programs', 'Triforce')) + for f in folders: + try: + exe = Path(f) / 'TriforceSetup.exe' + if exe.is_file(): + return exe + except (OSError, TypeError, ValueError): + pass + return None + + +def sha256_file(path): + h = hashlib.sha256() + with open(path, 'rb') as f: + for block in iter(lambda: f.read(1 << 20), b''): + h.update(block) + return h.hexdigest() + + +def bundled_setup(): + """(path, info) of the Triforce Setup this release carries, after checking it against setup.json (its size and + sha256, written when the release was made). OSError or ValueError when it is missing or is not that file.""" + info = json.loads((SETUP_DIR / 'setup.json').read_text(encoding='utf-8')) + exe = SETUP_DIR / info['name'] + if exe.stat().st_size != info['size'] or sha256_file(exe).lower() != info['sha256'].lower(): + raise ValueError(f'{exe.name} is not the file this release was made with') + return exe, info + + +def start_detached(cmd, cwd): + flags = DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP if os.name == 'nt' else 0 + return subprocess.Popen(cmd, cwd=str(cwd), stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, close_fds=True, creationflags=flags) + + +def start_triforce(installed): + """Start Triforce's launcher: the installed one, else this release's copy from a folder of its own in the temp + folder (Setup then installs itself, as it does for anyone who downloads it). Returns what was started.""" + if installed: + start_detached([str(installed)], Path(installed).parent) + return str(installed) + src, info = bundled_setup() + folder = Path(tempfile.gettempdir()) / 'TriforceSetup' + folder.mkdir(parents=True, exist_ok=True) + exe = folder / info['name'] + try: + same = exe.is_file() and exe.stat().st_size == info['size'] and sha256_file(exe).lower() == info['sha256'].lower() + except OSError: + same = False + if not same: + shutil.copyfile(src, exe) + start_detached([str(exe)], folder) + return str(exe) + + +def start_game(exe): + """Start the NX2navi game from its own folder, as NX2navi's launcher did (its console stays hidden).""" + exe = Path(exe) + flags = CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP if os.name == 'nt' else 0 + return subprocess.Popen([str(exe)], cwd=str(exe.parent), stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, close_fds=True, creationflags=flags) + + +def copy_to_clipboard(text): + """Windows' clipboard, set through Win32 so that it outlives this window. False when that did not work.""" + if os.name != 'nt': + return False + try: + from ctypes import wintypes + u, k = ctypes.WinDLL('user32'), ctypes.WinDLL('kernel32') + k.GlobalAlloc.restype, k.GlobalAlloc.argtypes = wintypes.HGLOBAL, (wintypes.UINT, ctypes.c_size_t) + k.GlobalLock.restype, k.GlobalLock.argtypes = ctypes.c_void_p, (wintypes.HGLOBAL,) + k.GlobalUnlock.argtypes = (wintypes.HGLOBAL,) + u.OpenClipboard.argtypes = (wintypes.HWND,) + u.SetClipboardData.restype, u.SetClipboardData.argtypes = wintypes.HANDLE, (wintypes.UINT, wintypes.HANDLE) + data = ctypes.create_unicode_buffer(text) + if not u.OpenClipboard(None): + return False + try: + u.EmptyClipboard() + h = k.GlobalAlloc(0x0002, ctypes.sizeof(data)) # GMEM_MOVEABLE + p = k.GlobalLock(h) + ctypes.memmove(p, data, ctypes.sizeof(data)) + k.GlobalUnlock(h) + return bool(u.SetClipboardData(13, h)) # CF_UNICODETEXT + finally: + u.CloseClipboard() + except (AttributeError, OSError): + return False + + +# ---------------------------------------------------------------- the way back: a newer release of the old feed + +class UpdateError(Exception): + pass + + +_VERSION = re.compile(r'^\s*v?(\d+(?:\.\d+)*)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?\s*$') + + +def parse_version(text): + """As NX2navi's launcher orders versions (its launcher/versions.py): a pre-release sorts before its release.""" + m = _VERSION.match(text or '') + if not m: + raise ValueError(f'not a version: {text!r}') + nums = tuple(int(x) for x in m.group(1).split('.')) + nums = nums + (0,) * (4 - len(nums)) if len(nums) < 4 else nums + return nums, (1,) if m.group(2) is None else (0, m.group(2)) + + +def newer(a, b): + return parse_version(a) > parse_version(b) + + +def display(version): + return (version or '').split('+', 1)[0].strip() + + +def _is_local(url): + return urllib.parse.urlsplit(url).hostname in ('127.0.0.1', 'localhost', '::1') + + +def _allowed(url): + """https, or plain http to this PC (tests).""" + scheme = urllib.parse.urlsplit(url).scheme + return scheme == 'https' or (scheme == 'http' and _is_local(url)) + + +class _NoDowngrade(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + if not _allowed(newurl): + raise UpdateError(f'redirected to {newurl}, which is not https') + return super().redirect_request(req, fp, code, msg, headers, newurl) + + +_opener = None + + +def _open(url, headers=None, timeout=30): + global _opener + if not _allowed(url): + raise UpdateError(f'not an https address: {url}') + if _opener is None: + try: # Windows' own certificate check, when the launcher's Python brings the package for it + import truststore + truststore.inject_into_ssl() + except Exception: + pass + _opener = urllib.request.build_opener(_NoDowngrade) + req = urllib.request.Request(url, headers={'User-Agent': f'NX2navi-handover/{my_version()}', **(headers or {})}) + return _opener.open(req, timeout=timeout) + + +def get_json(url, headers=None): + with _open(url, headers) as r: + return json.loads(r.read(8 << 20).decode('utf-8-sig')) + + +def load_feed(root, settings): + """Where NX2navi's launcher looked for releases: the feed.json of the version before this one (or of any other + version in app/), with the settings' feed_repo over it, as there.""" + feed = dict(DEFAULT_FEED) + apps = Path(root) / 'app' + names = [read_text(apps / 'previous.txt')] + try: + names += sorted(d.name for d in apps.iterdir() if d.is_dir()) + except OSError: + pass + for name in names: + try: + if name and name != APP.name: + got = json.loads((apps / name / 'feed.json').read_text(encoding='utf-8-sig')) + if isinstance(got, dict) and got.get('type') in ('github', 'url'): + feed.update(got) + break + except (OSError, ValueError): + continue + own = APP / 'feed.json' # a release of this window may name its feed itself + try: + got = json.loads(own.read_text(encoding='utf-8-sig')) + if isinstance(got, dict) and got.get('type') in ('github', 'url'): + feed = got + except (OSError, ValueError): + pass + if isinstance(settings.get('feed_repo'), str) and settings.get('feed_repo'): + feed = {'type': 'github', 'repo': settings['feed_repo']} + return feed + + +def _check_manifest(m, where): + if not isinstance(m, dict) or m.get('format') != 1 or 'version' not in m or not isinstance(m.get('app'), dict): + raise UpdateError(f'{where}: not an NX2navi update manifest') + parse_version(m['version']) + return m + + +def latest(feed, channel='stable'): + """(manifest, {file name: address}) of the newest release the feed offers on the channel, or None.""" + if feed.get('type') == 'url': + url = feed['url'] + m = _check_manifest(get_json(url), url) + base = url.rsplit('/', 1)[0] + '/' + return m, {e['name']: urllib.parse.urljoin(base, e['name']) for e in (m.get('app'), m.get('runtime')) + if isinstance(e, dict) and isinstance(e.get('name'), str)} + api = str(feed.get('api', GITHUB_API)).rstrip('/') + headers = {'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28'} + for r in get_json(f'{api}/repos/{feed["repo"]}/releases?per_page=20', headers): + if r.get('draft') or (r.get('prerelease') and channel != 'preview'): + continue + assets = {a['name']: a['browser_download_url'] for a in r.get('assets', [])} + if MANIFEST not in assets: + continue + with _open(assets[MANIFEST]) as f: + m = json.loads(f.read(8 << 20).decode('utf-8-sig')) + return _check_manifest(m, f'{r.get("tag_name")}/{MANIFEST}'), assets + return None + + +def _entry(manifest, key, assets): + """(name, address, size, sha256) of the manifest's 'app' or 'runtime' file; refused without a plain file name, a + size and a sha256, as NX2navi's launcher refuses it.""" + e = manifest.get(key) or {} + name, size, sha = e.get('name'), e.get('size'), e.get('sha256') + if (not isinstance(name, str) or not name or name in ('.', '..') or name != Path(name).name + or '/' in name or '\\' in name or ':' in name): + raise UpdateError(f'the manifest names its {key} file {name!r}, not a plain file name') + if (not isinstance(size, int) or isinstance(size, bool) or size <= 0 or not isinstance(sha, str) + or not re.fullmatch(r'[0-9a-fA-F]{64}', sha)): + raise UpdateError(f'the manifest gives no size and sha256 for {name}') + if not assets.get(name): + raise UpdateError(f'the release lacks {name}') + return name, assets[name], size, sha.lower() + + +def download(url, dest, size, sha): + dest = Path(dest) + dest.parent.mkdir(parents=True, exist_ok=True) + part = dest.with_name(dest.name + '.handover-part') + h, got = hashlib.sha256(), 0 + try: + with _open(url, timeout=60) as r, open(part, 'wb') as f: + for block in iter(lambda: r.read(1 << 18), b''): + got += len(block) + if got > size: + raise UpdateError(f'{dest.name} is larger than the manifest says') + h.update(block) + f.write(block) + if got != size or h.hexdigest() != sha: + raise UpdateError(f'{dest.name} does not match the manifest (size or sha256)') + os.replace(part, dest) + finally: + try: + part.unlink() + except OSError: + pass + return dest + + +def safe_extract(zip_path, dest): + """Unpack a zip into dest, refusing absolute names and names that climb out of it.""" + dest = Path(dest) + root = dest.resolve() + with zipfile.ZipFile(zip_path) as z: + for info in z.infolist(): + name = info.filename.replace('\\', '/') + if name.startswith('/') or ':' in name.split('/')[0] or '..' in name.split('/'): + raise UpdateError(f'{Path(zip_path).name}: unsafe path {info.filename}') + target = (dest / name).resolve() + if target != root and root not in target.parents: + raise UpdateError(f'{Path(zip_path).name}: unsafe path {info.filename}') + z.extractall(dest) + + +def stage(root, manifest, assets): + """Make the release the version the stub starts next, as NX2navi's launcher did (its launcher/updater.py): the app + archive, checked, unpacks into app/; a Python the release needs unpacks into python.next, which the stub + swaps in at its next start; then previous.txt names this version and current.txt the new one. Returns the + version. Nothing is switched when any step fails.""" + root = Path(root) + apps, downloads = root / 'app', root / 'data' / 'downloads' + ver = display(manifest['version']) + name, url, size, sha = _entry(manifest, 'app', assets) + archive = download(url, downloads / name, size, sha) + part, final = apps / f'{ver}.part', apps / ver + shutil.rmtree(part, ignore_errors=True) + part.mkdir(parents=True) + try: + safe_extract(archive, part) + if display(read_text(part / 'VERSION')) != ver or not (part / 'launcher' / 'start.pyw').is_file(): + raise UpdateError(f'{name} does not hold version {ver} with a launcher') + if final.exists(): + if final.resolve() == APP.resolve(): + raise UpdateError(f'{ver} is the version that runs now') + shutil.rmtree(final) + os.replace(part, final) + except BaseException: + shutil.rmtree(part, ignore_errors=True) + raise + finally: + try: + archive.unlink() + except OSError: + pass + rt = manifest.get('runtime') + if isinstance(rt, dict) and rt.get('id') and rt.get('id') != read_text(root / 'python' / 'runtime-id.txt'): + name, url, size, sha = _entry(manifest, 'runtime', assets) + archive = download(url, downloads / name, size, sha) + nxt = root / 'python.next' + part = root / 'python.next.part' + shutil.rmtree(nxt, ignore_errors=True) + shutil.rmtree(part, ignore_errors=True) + part.mkdir() + try: + safe_extract(archive, part) + if not (part / 'python.exe').is_file() or read_text(part / 'runtime-id.txt') != rt['id']: + raise UpdateError(f'{name} is not the runtime {rt["id"]}') + os.replace(part, nxt) + except BaseException: + shutil.rmtree(part, ignore_errors=True) + shutil.rmtree(final, ignore_errors=True) + raise + finally: + try: + archive.unlink() + except OSError: + pass + write_text(apps / 'previous.txt', APP.name) + write_text(apps / 'current.txt', ver) + return ver + + +def check_for_newer(root, settings=None, force=False): + """Ask the feed for a version newer than this one and stage it. Returns the staged version, or '' when there is + none, the player turned update checks off, or the last check was less than an hour ago. Raises on a failure.""" + root = Path(root) + settings = read_settings(root) if settings is None else settings + if not force: + if settings.get('auto_check') is False: + return '' + state = root / 'data' / 'handover.json' + try: + last = float(json.loads(state.read_text(encoding='utf-8')).get('last_check', 0)) + except (OSError, ValueError, TypeError, AttributeError): + last = 0.0 + if 0 <= time.time() - last < CHECK_EVERY: + return '' + try: + state.parent.mkdir(parents=True, exist_ok=True) + write_text(state, json.dumps({'last_check': time.time()})) + except OSError: + pass + channel = settings.get('channel') if settings.get('channel') in ('stable', 'preview') else 'stable' + found = latest(load_feed(root, settings), channel) + if not found or not newer(found[0]['version'], my_version()): + return '' + return stage(root, *found) + + +def check_in_background(root): + def work(): + try: + ver = check_for_newer(root) + if ver: + log(root, f'a newer release, {ver}, is staged: NX2navi.exe starts it next time') + except Exception as e: # no network, a refused request, a damaged file: tried again at a later start + log(root, f'update check: {type(e).__name__}: {e}') + t = threading.Thread(target=work, name='handover-update', daemon=True) + t.start() + return t + + +# ---------------------------------------------------------------- uninstalling NX2navi + +def message_box(text, title='NX2navi', flags=0x40): + try: + return ctypes.windll.user32.MessageBoxW(None, text, title, flags) + except (AttributeError, OSError): + print(text, file=sys.stderr) + return 0 + + +def ansi_safe(path): + try: + str(path).encode('mbcs', errors='strict') + return True + except (UnicodeEncodeError, LookupError): + return False + + +def uninstall_plan(root, remove_game=''): + """(paths to delete with all they hold, folders to delete only when that left them empty), as NX2navi's launcher + planned it (its launcher/winsys.py uninstall_plan).""" + root = Path(root) + gone = [Path(remove_game)] if remove_game else [] + gone += [root / n for n in OWNED] + try: + gone += sorted(root.glob('python.old-*')) + except OSError: + pass + empty = [root] + if not ansi_safe(root): # the build tools then went to \NX2navi\toolchain + tools = Path(os.environ.get('SystemDrive', 'C:') + '\\') / 'NX2navi' / 'toolchain' + gone.append(tools) + empty.append(tools.parent) + return gone, empty + + +def uninstall_script(gone, empty): + """The cmd.exe line that deletes them once this process has exited. A path holding % is left out: cmd.exe expands + %NAME% even inside quotes.""" + steps = ['ping -n 3 127.0.0.1 >nul'] + for p in gone: + if '%' not in str(p): + steps.append(f'(if exist "{p}\\*" (rmdir /s /q "{p}") else if exist "{p}" (del /f /q "{p}"))') + steps += [f'rmdir "{p}" 2>nul' for p in empty if '%' not in str(p)] + return ' & '.join(steps) + + +def uninstall(root): + """Windows' Apps list > NX2navi > Uninstall (NX2navi.exe --uninstall): asks, then removes the shortcuts, the Apps + entry and what the launcher put in its folder. The built game goes only when the player says so in a second + question; the game dump is never touched. Triforce is not touched either.""" + root = Path(root) + if message_box('Remove NX2navi from this PC?\n\nIts launcher, its build tools and its shortcuts are removed. ' + 'Your game dump is never touched, and neither is Triforce.', 'Uninstall NX2navi', 0x24) != 6: + return 0 + game = nx_game(read_settings(root), root) + remove_game = '' + if game and len(game.parent.parts) > 1: + if message_box(f'Also remove the NX2navi game in\n{game.parent}?\n\nYour NX2navi saves are in that folder. ' + 'Choose No to keep it, for instance until Triforce has taken your saves over.', + 'Uninstall NX2navi', 0x124) == 6: # Yes / No, No preselected + remove_game = str(game.parent) + log(root, f'uninstall (game folder too: {bool(remove_game)})') + stub = root / 'NX2navi.exe' + if stub.is_file(): + try: + subprocess.run([str(stub), '--shortcuts', 'off'], timeout=30, capture_output=True, + creationflags=CREATE_NO_WINDOW) + except (OSError, subprocess.TimeoutExpired): + pass + try: + import winreg + winreg.DeleteKey(winreg.HKEY_CURRENT_USER, NX_UNINSTALL_KEY) + except (OSError, ImportError): + pass + gone, empty = uninstall_plan(root, remove_game) + subprocess.Popen(f'cmd.exe /c "{uninstall_script(gone, empty)}"', cwd=os.environ.get('TEMP', 'C:\\'), + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, close_fds=True, + creationflags=DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP) + return 0 + + +# ---------------------------------------------------------------- the window + +W, H = 820, 480 # the composition, in units; tools/make_art.py draws the hall for it +SCALES = (1.0, 1.25, 1.5, 2.0) +TEXT_HI, TEXT, TEXT_DIM, TEXT_FAINT = '#F3EAD5', '#CDC2A8', '#948A70', '#5F5744' +GOLD, GOLD_300, LINE, LINE_GOLD = '#E0B23E', '#F7DC8C', '#2C2518', '#4A3C1B' +BAD = '#FF6B76' +HAIR = '\u200a' + + +def dpi_aware(): + if os.name != 'nt': + return + try: + ctypes.windll.shcore.SetProcessDpiAwareness(1) + except (AttributeError, OSError): + try: + ctypes.windll.user32.SetProcessDPIAware() + except (AttributeError, OSError): + pass + + +def caps(text, gaps=1): + """Letter-spaced capitals: hair spaces between the letters, as the Triforce client sets its labels.""" + return (HAIR * gaps).join(text.upper()) + + +class App: + def __init__(self, tk_root, root, found, argv=(), offscreen=False): + import tkinter as tk + import tkinter.font as tkfont + self.tk, self.tkfont = tk, tkfont + self.win, self.root, self.found, self.argv = tk_root, Path(root), dict(found), list(argv) + self.status, self.status_color = '', GOLD_300 + self.game_proc = None + self.closing = False + dpi = self.win.winfo_fpixels('1i') / 96.0 + self.s = max([s for s in SCALES if s <= dpi + 0.01] or [1.0]) + tag = str(round(self.s * 100)) + self.img = {n: tk.PhotoImage(master=self.win, file=str(ART / f'{n}_{tag}.png')) for n in ('hall', 'btn')} + self.img['btn_hi'] = tk.PhotoImage(master=self.win, file=str(ART / f'btn_{tag}_hi.png')) + self.icons = [tk.PhotoImage(master=self.win, file=str(ART / f'icon_{n}.png')) for n in (64, 32, 16)] + families = set(tkfont.families(self.win)) + serif = next((f for f in ('Palatino Linotype', 'Book Antiqua', 'Georgia') if f in families), 'Times New Roman') + sans = next((f for f in ('Segoe UI', 'Tahoma') if f in families), 'Arial') + semi = 'Segoe UI Semibold' if 'Segoe UI Semibold' in families else sans + + def font(family, px, weight='normal'): + return tkfont.Font(root=self.win, family=family, size=-max(8, round(px * self.s)), weight=weight) + self.f_title = font(serif, 31) + self.f_body = font(sans, 14) + self.f_small = font(sans, 12.5) + self.f_label = font(semi, 10.5, 'normal' if semi != sans else 'bold') + self.f_button = font(serif, 13, 'bold') + self.f_second = font(sans, 13) + + w, h = round(W * self.s), round(H * self.s) + self.win.title('Triforce') + self.win.resizable(False, False) + self.win.configure(bg='#0A0908') + try: + self.win.iconphoto(True, *self.icons) + except tk.TclError: + pass + x = -32000 if offscreen else max(0, (self.win.winfo_screenwidth() - w) // 2) + y = -32000 if offscreen else max(0, (self.win.winfo_screenheight() - h) // 2 - round(20 * self.s)) + self.win.geometry(f'{w}x{h}+{x}+{y}') + self.c = tk.Canvas(self.win, width=w, height=h, highlightthickness=0, bd=0, bg='#0A0908') + self.c.pack() + self.win.protocol('WM_DELETE_WINDOW', self.close) + self.win.bind('', lambda e: self.primary()) + self.win.bind('', lambda e: self.close()) + self.draw() + self.win.after(60, self.dress) + + # ---- drawing + + def u(self, v): + return round(v * self.s) + + def dress(self): + """The title bar in the hall's colours (Windows 11; Windows 10 takes the dark frame).""" + if os.name != 'nt': + return + try: + user32, dwm = ctypes.windll.user32, ctypes.windll.dwmapi + user32.GetParent.restype, user32.GetParent.argtypes = ctypes.c_void_p, [ctypes.c_void_p] + hwnd = ctypes.c_void_p(user32.GetParent(self.win.winfo_id()) or self.win.winfo_id()) + for attr, value in ((20, 1), (19, 1), (35, 0x00090A0B), (36, 0x003EB2E0), (34, 0x001E546E)): + v = ctypes.c_int(value) + dwm.DwmSetWindowAttribute(hwnd, attr, ctypes.byref(v), ctypes.sizeof(v)) + except (AttributeError, OSError): + pass + + def wrap(self, text, font, width): + lines, line = [], '' + for word in text.split(): + trial = (line + ' ' + word).strip() + if line and font.measure(trial) > width: + lines.append(line) + line = word + else: + line = trial + return lines + [line] if line else lines + + def fit(self, text, font, width): + """A path shortened in its middle until it fits.""" + if font.measure(text) <= width: + return text + head, tail = text[:len(text) // 2], text[len(text) // 2:] + while head and tail and font.measure(head + ' … ' + tail) > width: + if len(head) >= len(tail): + head = head[:-1] + else: + tail = tail[1:] + return head + ' … ' + tail + + def link(self, tag, on, rest, hot): + self.c.tag_bind(tag, '', lambda e: (self.c.itemconfigure(tag, fill=hot), self.c.configure(cursor='hand2'))) + self.c.tag_bind(tag, '', lambda e: (self.c.itemconfigure(tag, fill=rest), self.c.configure(cursor=''))) + self.c.tag_bind(tag, '', lambda e: on()) + + def draw(self): + c, u = self.c, self.u + c.delete('all') + c.configure(cursor='') + c.create_image(0, 0, anchor='nw', image=self.img['hall']) + left, col = u(52), u(348) + y = u(54) + # the eyebrow: a short rule, then the label + c.create_line(left, y + u(6), left + u(20), y + u(6), fill=LINE_GOLD) + c.create_text(left + u(30), y, anchor='nw', text=caps('NX2navi × Triforce'), font=self.f_label, fill=GOLD) + y += u(28) + c.create_text(left - u(1), y, anchor='nw', text='NX2navi is now Triforce', font=self.f_title, fill=TEXT_HI) + y += u(52) + body = ('The two projects have merged and go on under one name. Triforce is your launcher from here: it ' + 'builds the game from your own files, as NX2navi did, and its Convert saves page brings your ' + 'NX2navi saves over.') + for line in self.wrap(body, self.f_body, col): + c.create_text(left, y, anchor='nw', text=line, font=self.f_body, fill=TEXT) + y += u(22) + y += u(14) + rows = [] + if self.found.get('dump'): + rows.append(('Your game files', self.found['dump'], 'copy_dump')) + if self.found.get('game_dir'): + rows.append(('Your NX2navi game and saves', self.found['game_dir'], 'copy_game')) + for label, path, tag in rows: + c.create_text(left, y, anchor='nw', text=caps(label), font=self.f_label, fill=TEXT_DIM) + copy_w = self.f_small.measure('Copy path') + c.create_text(left, y + u(17), anchor='nw', text=self.fit(path, self.f_small, col - copy_w - u(18)), + font=self.f_small, fill=TEXT) + c.create_text(left + col, y + u(17), anchor='ne', text='Copy path', font=self.f_small, fill=TEXT_DIM, + tags=(tag,)) + self.link(tag, lambda p=path: self.copy(p), TEXT_DIM, GOLD_300) + y += u(44) + # the actions, on a fixed line near the foot + by = u(370) + if self.status: # (two lines at most, between the rows above, which end at 316, and the buttons) + for i, line in enumerate(self.wrap(self.status, self.f_small, col)[:2]): + c.create_text(left, by - u(44) + i * u(18), anchor='nw', text=line, font=self.f_small, + fill=self.status_color) + bw, bh = u(196), u(44) + c.create_image(left, by, anchor='nw', image=self.img['btn'], tags=('primary', 'primary_bar')) + label = 'Open Triforce' if self.found.get('triforce') else 'Get Triforce' + c.create_text(left + bw // 2, by + bh // 2 + u(1), text=caps(label, 2), font=self.f_button, fill='#F9E7A8', + tags=('primary',)) + c.create_text(left + bw // 2, by + bh // 2, text=caps(label, 2), font=self.f_button, fill='#2A1C05', + tags=('primary',)) + c.tag_bind('primary', '', lambda e: (c.itemconfigure('primary_bar', image=self.img['btn_hi']), + c.configure(cursor='hand2'))) + c.tag_bind('primary', '', lambda e: (c.itemconfigure('primary_bar', image=self.img['btn']), + c.configure(cursor=''))) + c.tag_bind('primary', '', lambda e: self.primary()) + if self.found.get('game'): + sx, sw, sh = left + bw + u(14), u(138), u(36) + sy = by + (bh - sh) // 2 + c.create_rectangle(sx, sy, sx + sw, sy + sh, outline=LINE, fill='#0C0B09', tags=('second', 'second_box')) + c.create_text(sx + sw // 2, sy + sh // 2, text='Play NX2navi', font=self.f_second, fill=TEXT, + tags=('second', 'second_text')) + c.tag_bind('second', '', lambda e: (c.itemconfigure('second_box', outline='#8C7230'), + c.itemconfigure('second_text', fill=TEXT_HI), + c.configure(cursor='hand2'))) + c.tag_bind('second', '', lambda e: (c.itemconfigure('second_box', outline=LINE), + c.itemconfigure('second_text', fill=TEXT), + c.configure(cursor=''))) + c.tag_bind('second', '', lambda e: self.play()) + # the foot: what stays, and where Triforce lives + fy = u(H - 36) + c.create_line(left, fy - u(12), left + col, fy - u(12), fill=LINE) + c.create_text(left, fy, anchor='nw', font=self.f_small, fill=TEXT_DIM, + text='Nothing of NX2navi is removed or changed.') + # (under the crest, centred on its column: tools/make_art.py CX) + site_w, dot_w, disc_w = (self.f_small.measure(t) for t in ('triforceproject.com', '·', 'Discord')) + gap = u(12) + sx = u(632) - (site_w + dot_w + disc_w + 2 * gap) // 2 + c.create_text(sx, fy, anchor='nw', text='triforceproject.com', font=self.f_small, fill=TEXT_DIM, tags=('site',)) + self.link('site', lambda: self.browse(SITE), TEXT_DIM, GOLD_300) + c.create_text(sx + site_w + gap, fy, anchor='nw', text='·', font=self.f_small, fill=TEXT_FAINT) + c.create_text(sx + site_w + dot_w + 2 * gap, fy, anchor='nw', text='Discord', font=self.f_small, + fill=TEXT_DIM, tags=('discord',)) + self.link('discord', lambda: self.browse(DISCORD), TEXT_DIM, GOLD_300) + + # ---- actions + + def say(self, text, bad=False): + self.status, self.status_color = text, BAD if bad else GOLD_300 + self.draw() + + def copy(self, path): + self.say('Copied. Paste it where Triforce Setup asks for it.' if copy_to_clipboard(path) + else 'The path could not be copied.', bad=False) + + def browse(self, url): + try: + webbrowser.open(url) + except Exception: + self.say(url) + + def primary(self): + if self.closing: + return + try: + started = start_triforce(self.found.get('triforce')) + except Exception as e: + log(self.root, f'Triforce Setup could not be started: {type(e).__name__}: {e}') + self.say('Triforce Setup could not be started from here. Get it from triforceproject.com, which opens ' + 'now.', bad=True) + self.browse(SITE) + return + log(self.root, f'started {started}') + self.closing = True + self.say('Triforce is opening.') + self.win.after(700, self.win.destroy) + + def play(self): + if self.closing or not self.found.get('game'): + return + try: + self.game_proc = start_game(self.found['game']) + except OSError as e: + log(self.root, f'{self.found["game"]}: {e}') + self.say(f'The NX2navi game could not be started: {e}', bad=True) + return + log(self.root, f'started {self.found["game"]}') + self.closing = True + self.win.withdraw() + self.wait_game() + + def wait_game(self): + """Stay (hidden) while the game runs, as the old launcher did: whoever started this window (Steam, with + --play) sees the game as running for that time.""" + if self.game_proc is not None and self.game_proc.poll() is None: + self.win.after(1000, self.wait_game) + else: + self.win.destroy() + + def close(self): + self.win.destroy() + + +def main(argv=None): + """The window. Returns the process's exit code; raises when the window cannot be made (start.pyw then lets the + stub go back to the version before).""" + argv = list(sys.argv[1:] if argv is None else argv) + root = launcher_root() + if '--uninstall' in argv: + return uninstall(root) + selftest = os.environ.get('NX2NAVI_HANDOVER_TEST') or ('--selftest' in argv and '.') or '' + if '--shots' in argv and argv.index('--shots') + 1 < len(argv): + selftest = argv[argv.index('--shots') + 1] + dpi_aware() + try: + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID('Triforce.NX2naviHandover') + except (AttributeError, OSError): + pass + import tkinter as tk + win = tk.Tk(className='Triforce') + app = App(win, root, facts(root), argv, offscreen=bool(selftest)) + log(root, f'window up: version {my_version()}, arguments {argv}, Triforce installed: ' + f'{bool(app.found["triforce"])}, NX2navi game: {bool(app.found["game"])}') + if selftest: + import selftest as st + win.after(300, lambda: st.run(app, selftest)) + else: + check_in_background(root) + win.mainloop() + return 0 diff --git a/handover/bridge/launcher/selftest.py b/handover/bridge/launcher/selftest.py new file mode 100644 index 00000000..803e95d7 --- /dev/null +++ b/handover/bridge/launcher/selftest.py @@ -0,0 +1,126 @@ +"""Pictures of the handover window without showing it and without doing anything: for whoever wants to look at it +before it is released, and for the tests. + + NX2navi.exe --selftest --shots DIR through the stub of an installed launcher + pythonw start.pyw --selftest --shots DIR directly + NX2NAVI_HANDOVER_TEST=DIR the same from the environment (a test that starts the stub) + +The window opens far off the screen, each state is drawn and saved as handover-.png (the window as Windows +draws it, title bar included), state.json says what was found on this PC, and the process ends with exit code 0. +Started through the environment it first stays until it is eight seconds old, longer than the stub waits for a +failed start. No button is pressed, nothing is started, downloaded or written outside DIR. +""" +import ctypes +import json +import os +import struct +import time +import zlib +from pathlib import Path + +import handover + +BORN = time.time() + + +def write_png(path, w, h, rgb): + def chunk(kind, data): + return struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data)) + rows = b''.join(b'\0' + rgb[y * w * 3:(y + 1) * w * 3] for y in range(h)) + Path(path).write_bytes(b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, 8, 2, 0, 0, 0)) + + chunk(b'IDAT', zlib.compress(rows, 6)) + chunk(b'IEND', b'')) + + +def snap(win, path): + """The window as Windows draws it (PrintWindow: it need not be on the screen). False where that is not possible.""" + if os.name != 'nt': + return False + from ctypes import wintypes + user32, gdi32 = ctypes.WinDLL('user32'), ctypes.WinDLL('gdi32') + P = ctypes.c_void_p + user32.GetParent.restype, user32.GetParent.argtypes = P, [P] + user32.GetWindowRect.argtypes = [P, ctypes.POINTER(wintypes.RECT)] + user32.GetWindowDC.restype, user32.GetWindowDC.argtypes = P, [P] + user32.ReleaseDC.argtypes = [P, P] + user32.PrintWindow.argtypes = [P, P, ctypes.c_uint] + gdi32.CreateCompatibleDC.restype, gdi32.CreateCompatibleDC.argtypes = P, [P] + gdi32.CreateCompatibleBitmap.restype, gdi32.CreateCompatibleBitmap.argtypes = P, [P, ctypes.c_int, ctypes.c_int] + gdi32.SelectObject.restype, gdi32.SelectObject.argtypes = P, [P, P] + gdi32.GetDIBits.argtypes = [P, P, ctypes.c_uint, ctypes.c_uint, P, P, ctypes.c_uint] + gdi32.DeleteObject.argtypes = [P] + gdi32.DeleteDC.argtypes = [P] + hwnd = user32.GetParent(win.winfo_id()) or win.winfo_id() + r = wintypes.RECT() + user32.GetWindowRect(hwnd, ctypes.byref(r)) + w, h = r.right - r.left, r.bottom - r.top + if w <= 0 or h <= 0: + return False + dc = user32.GetWindowDC(hwnd) + mem = gdi32.CreateCompatibleDC(dc) + bmp = gdi32.CreateCompatibleBitmap(dc, w, h) + old = gdi32.SelectObject(mem, bmp) + try: + user32.PrintWindow(hwnd, mem, 2) # PW_RENDERFULLCONTENT + header = struct.pack('\\launcher\\start.pyw [arguments]. + +From this version on that is the handover to Triforce (handover.py beside this file), not NX2navi's launcher. + +The contract with the stub (NX2navi's launcher/native/nx2navi.c) is kept as the launcher kept it: a failure before +the window is up is written to data/logs/crash.log and ends the process with exit code 86 within the six seconds the +stub waits, so the stub goes back to the version before (app/previous.txt), which is still on the PC. Only when there +is no version to go back to, or the stub no longer waits, the failure is shown in a message box instead, with the +address Triforce is at. +""" +import os +import sys +import traceback + +HERE = os.path.dirname(os.path.abspath(__file__)) +APP = os.path.dirname(HERE) +STARTUP_FAILED = 86 +STUB_WAITS = 5.0 # the stub waits 6 s for the exit code (QUICK_MS); a second is left for the exit itself + + +def launcher_root(): + return os.environ.get('NX2NAVI_LAUNCHER_ROOT') or os.path.dirname(os.path.dirname(APP)) + + +def rollback_target(root): + """The version the stub goes back to on exit code 86: the one app/previous.txt names, when it is another than + this one and holds a launcher. '' when there is none.""" + try: + with open(os.path.join(root, 'app', 'previous.txt'), encoding='utf-8-sig') as f: + prev = (f.read().splitlines() or [''])[0].strip() + except (OSError, UnicodeDecodeError): + return '' + if not prev or prev == os.path.basename(APP): + return '' + return prev if os.path.isfile(os.path.join(root, 'app', prev, 'launcher', 'start.pyw')) else '' + + +def process_age(): + """Seconds since Windows made this process, which is when the stub began to wait. Infinite where that cannot be + read.""" + try: + import ctypes + from ctypes import wintypes + k = ctypes.windll.kernel32 + k.GetCurrentProcess.restype = wintypes.HANDLE + k.GetProcessTimes.argtypes = [wintypes.HANDLE] + [ctypes.POINTER(wintypes.FILETIME)] * 4 + made, ended, kernel, user, now = (wintypes.FILETIME() for _ in range(5)) + if not k.GetProcessTimes(k.GetCurrentProcess(), *(ctypes.byref(t) for t in (made, ended, kernel, user))): + return float('inf') + k.GetSystemTimeAsFileTime(ctypes.byref(now)) + ticks = [t.dwHighDateTime << 32 | t.dwLowDateTime for t in (made, now)] + return max(0.0, (ticks[1] - ticks[0]) / 1e7) + except Exception: + return float('inf') + + +def crash(text, box): + try: + logs = os.path.join(launcher_root(), 'data', 'logs') + os.makedirs(logs, exist_ok=True) + with open(os.path.join(logs, 'crash.log'), 'a', encoding='utf-8') as f: + f.write(text + '\n') + except OSError: + pass + if not box: + return + try: + import ctypes + ctypes.windll.user32.MessageBoxW( + None, 'NX2navi is now Triforce, but the window that takes you there could not open.\n\n' + 'Get Triforce from https://triforceproject.com\n\n' + text[-900:], 'NX2navi', 0x30) + except Exception: + print(text, file=sys.stderr) + + +def run(): + sys.path.insert(0, HERE) # the embeddable Python puts only its ._pth entries on sys.path + argv = sys.argv[1:] + try: + import handover + code = handover.main(argv) + except SystemExit: + raise + except Exception: + root = launcher_root() + quiet = bool(rollback_target(root)) and '--play' not in argv and process_age() < STUB_WAITS + crash(traceback.format_exc(), box=not quiet) + sys.exit(STARTUP_FAILED if quiet else 0) + sys.exit(code or 0) + + +if __name__ == '__main__': + run() diff --git a/handover/bridge/launcher/triforce/setup.json b/handover/bridge/launcher/triforce/setup.json new file mode 100644 index 00000000..6bce6cc0 --- /dev/null +++ b/handover/bridge/launcher/triforce/setup.json @@ -0,0 +1,7 @@ +{ + "name": "TriforceSetup.exe", + "version": "v0.3.6", + "size": 635904, + "sha256": "59fba57af1e9b15e66bfe3a2e1fb0127d1e7deb0c4b8688206c2935a66e7eb2e", + "from": "https://triforceproject.com" +} diff --git a/handover/make_handover.py b/handover/make_handover.py new file mode 100644 index 00000000..bd7966ee --- /dev/null +++ b/handover/make_handover.py @@ -0,0 +1,105 @@ +"""Packs the handover as a release NX2navi's launcher installs by itself. + + python make_handover.py --version 0.12.0 [--setup TriforceSetup.exe --setup-version v0.3.6] [--notes notes.md] + +Needs only Python 3.8 or later (no packages). It writes into release/: + + NX2navi-app-.zip the app archive: VERSION and launcher/ (the handover window, bridge/ of this folder) + NX2navi-update.json the manifest NX2navi's launcher reads (format 1: version, notes, and the archive's + name, size and sha256) + +Upload both files to a GitHub release v of the repository the launchers ask (tangerineturtle1/NX2navi), +as a full release, not a pre-release: launchers on the Stable channel pass pre-releases over, and a version with a +pre-release tag (0.12.0-rc1) counts as one whatever the release says. must be newer than every version +published so far, the previews included. + +The manifest names no "runtime": the handover runs on whatever Python the installed launcher has, so no launcher +downloads a Python for it. +""" +import argparse +import hashlib +import json +import re +import shutil +import sys +import time +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +BRIDGE = ROOT / 'bridge' +VERSION = re.compile(r'^\d+(?:\.\d+){1,3}$') +NOTES = """NX2navi and Triforce have merged and go on as Triforce. + +- This update turns NX2navi's launcher into the door to Triforce: one window that starts Triforce Setup, Triforce's launcher. +- Nothing of NX2navi is removed or changed: your built game, your saves and your settings stay where they are. +- Play NX2navi in that window still starts the game you have, for as long as you want it. +- Triforce builds the game from your own files, as NX2navi did, and its Convert saves page brings your NX2navi saves over. +""" + + +def sha256_file(path): + h = hashlib.sha256() + with open(path, 'rb') as f: + for block in iter(lambda: f.read(1 << 20), b''): + h.update(block) + return h.hexdigest() + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument('--version', required=True, help='the release version, such as 0.12.0: plain numbers, newer than ' + 'every NX2navi version published so far') + ap.add_argument('--setup', type=Path, help='another TriforceSetup.exe to carry than the one in bridge/') + ap.add_argument('--setup-version', help='the Triforce release that TriforceSetup.exe is from (with --setup)') + ap.add_argument('--notes', type=Path, help='release notes (markdown) for the manifest instead of the built-in ones') + ap.add_argument('--out', type=Path, default=ROOT / 'release') + a = ap.parse_args() + if not VERSION.match(a.version): + sys.exit(f'{a.version}: give plain numbers such as 0.12.0. A version with a tag (0.12.0-rc1) is a ' + 'pre-release to the launcher, which only the Preview channel installs.') + ver = a.version + setup_dir = BRIDGE / 'launcher' / 'triforce' + info_file = setup_dir / 'setup.json' + info = json.loads(info_file.read_text(encoding='utf-8')) if info_file.is_file() else {} + if a.setup: + if not a.setup_version: + sys.exit('--setup needs --setup-version (the Triforce release the file is from)') + shutil.copyfile(a.setup, setup_dir / 'TriforceSetup.exe') + info['version'] = a.setup_version + exe = setup_dir / 'TriforceSetup.exe' + if not exe.is_file(): + sys.exit(f'{exe} is missing: give --setup') + info = {'name': exe.name, 'version': info.get('version', ''), 'size': exe.stat().st_size, + 'sha256': sha256_file(exe), 'from': 'https://triforceproject.com'} + info_file.write_text(json.dumps(info, indent=1) + '\n', encoding='utf-8') + + for need in ('start.pyw', 'handover.py', 'cli.py', 'selftest.py', 'art/hall_100.png'): + if not (BRIDGE / 'launcher' / need).is_file(): + sys.exit(f'bridge/launcher/{need} is missing') + a.out.mkdir(parents=True, exist_ok=True) + for old in list(a.out.glob('NX2navi-app-*.zip')) + list(a.out.glob('NX2navi-update.json')): + old.unlink() + archive = a.out / f'NX2navi-app-{ver}.zip' + stamp = time.localtime()[:6] + with zipfile.ZipFile(archive, 'w', zipfile.ZIP_DEFLATED, compresslevel=9) as z: + z.writestr(zipfile.ZipInfo('VERSION', stamp), ver + '\n') + for p in sorted(BRIDGE.rglob('*')): + if p.is_file() and '__pycache__' not in p.parts and p.name != 'VERSION': + z.write(p, p.relative_to(BRIDGE).as_posix()) + notes = a.notes.read_text(encoding='utf-8') if a.notes else NOTES + manifest = {'format': 1, 'version': ver, 'build': ver, 'notes': notes, + 'app': {'name': archive.name, 'size': archive.stat().st_size, 'sha256': sha256_file(archive)}, + 'made': time.strftime('%Y-%m-%d %H:%M')} + (a.out / 'NX2navi-update.json').write_text(json.dumps(manifest, indent=1), encoding='utf-8') + with zipfile.ZipFile(archive) as z: + names = z.namelist() + print(f'{archive.name}: {archive.stat().st_size / 1e6:.2f} MB, {len(names)} files, sha256 {manifest["app"]["sha256"]}') + print(f'carries {info["name"]} {info["version"]} ({info["size"]} bytes, sha256 {info["sha256"]})') + print(f'NX2navi-update.json: version {ver}') + print(f'upload both files from {a.out} to GitHub release v{ver} (a full release, not a pre-release)') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/handover/preview/handover-new.png b/handover/preview/handover-new.png new file mode 100644 index 00000000..f55d6e37 Binary files /dev/null and b/handover/preview/handover-new.png differ diff --git a/handover/preview/handover-nothing-built.png b/handover/preview/handover-nothing-built.png new file mode 100644 index 00000000..f04fbb15 Binary files /dev/null and b/handover/preview/handover-nothing-built.png differ diff --git a/handover/preview/handover-triforce-installed.png b/handover/preview/handover-triforce-installed.png new file mode 100644 index 00000000..50e1678c Binary files /dev/null and b/handover/preview/handover-triforce-installed.png differ diff --git a/handover/tests/e2e.py b/handover/tests/e2e.py new file mode 100644 index 00000000..e315b3ed --- /dev/null +++ b/handover/tests/e2e.py @@ -0,0 +1,342 @@ +"""The handover against NX2navi's real launcher, on a copy, without a window on the screen. + + python tests/e2e.py [--nx2navi DIR] [--work DIR] + +DIR is an installed NX2navi launcher (default %LOCALAPPDATA%\\Programs\\NX2navi). It is only read: its stub, its +Python and the launcher code of each version in app/ are copied into a work folder, where the launcher's own code +then finds this handover in a feed on 127.0.0.1 and installs it, and its own stub starts it. + + A each launcher version's command line (`cli.py check`, `cli.py update`): sees the handover, downloads, checks, + stages and switches to it, and the handover's cli.py ends the update with exit code 0 + B the stub (NX2navi.exe) starts the handover: it is still up when the stub stops waiting, nothing is rolled back + C a handover that cannot start: exit code 86 inside the stub's wait, and the stub goes back to the version before + D the path the launcher's window takes in the background (feed.latest, updater.stage, staged_info, install_staged) + E a GitHub-shaped feed, Stable channel: found as a full release, passed over as a pre-release + F the way back: the handover stages a newer release of the feed, as the launcher did + G the handover's own parts: what it finds on the PC, the Setup it carries and starts, the uninstall plan against + the launcher's own +""" +import argparse +import functools +import http.server +import importlib.util +import json +import os +import shutil +import subprocess +import sys +import tempfile +import threading +import time +from pathlib import Path + +HERE = Path(__file__).resolve().parent +PROJECT = HERE.parent +FAILED = [] + + +def check(name, ok, detail=''): + print((' ok ' if ok else ' FAIL ') + name + (f': {detail}' if detail and not ok else ''), flush=True) + if not ok: + FAILED.append(name) + + +class Handler(http.server.SimpleHTTPRequestHandler): + releases = [] + + def log_message(self, *a): + pass + + def do_GET(self): + if self.path.startswith('/api/repos/'): + body = json.dumps(self.releases).encode() + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.send_header('Content-Length', str(len(body))) + self.end_headers() + self.wfile.write(body) + return + super().do_GET() + + +def serve(folder): + srv = http.server.ThreadingHTTPServer(('127.0.0.1', 0), functools.partial(Handler, directory=str(folder))) + threading.Thread(target=srv.serve_forever, daemon=True).start() + return srv, f'http://127.0.0.1:{srv.server_address[1]}' + + +def make_release(version, out): + r = subprocess.run([sys.executable, '-B', str(PROJECT / 'make_handover.py'), '--version', version, '--out', str(out)], + capture_output=True, text=True) + if r.returncode: + sys.exit(r.stdout + r.stderr) + + +def clean_env(**more): + env = {k: v for k, v in os.environ.items() if not k.startswith('NX2NAVI_')} + env.update(more) + return env + + +def make_root(work, nx, name, version, feed): + """A launcher folder like the installed one, holding only that version of the launcher.""" + root = work / name + shutil.rmtree(root, ignore_errors=True) + (root / 'app' / version).mkdir(parents=True) + shutil.copy2(nx / 'NX2navi.exe', root / 'NX2navi.exe') + if not (work / 'python').is_dir(): + shutil.copytree(nx / 'python', work / 'python') + # (a junction to the one copy: the stub wants python\ beside it, and 100 MB need not be copied per test) + subprocess.run(['cmd', '/c', 'mklink', '/J', str(root / 'python'), str(work / 'python')], capture_output=True) + src = nx / 'app' / version + for part in ('launcher', 'tools'): + shutil.copytree(src / part, root / 'app' / version / part, ignore=shutil.ignore_patterns('__pycache__')) + for f in ('VERSION', 'toolchain.lock.json'): + if (src / f).is_file(): + shutil.copy2(src / f, root / 'app' / version / f) + (root / 'app' / version / 'feed.json').write_text(json.dumps(feed), encoding='utf-8') + (root / 'app' / 'current.txt').write_text(version + '\n', encoding='utf-8') + (root / 'data').mkdir() + (root / 'data' / 'launcher.json').write_text(json.dumps({'game_dir': '', 'dump': '', 'channel': 'stable'}), + encoding='utf-8') + return root + + +def text(path): + try: + return Path(path).read_text(encoding='utf-8-sig').strip() + except OSError: + return '' + + +def run_py(root, version, args, **kw): + py = root / 'python' / 'python.exe' + return subprocess.run([str(py), str(root / 'app' / version / 'launcher' / 'cli.py'), *args], capture_output=True, + text=True, cwd=str(root), env=clean_env(), timeout=300, **kw) + + +def load_handover(app_dir): + sys.path.insert(0, str(app_dir / 'launcher')) + spec = importlib.util.spec_from_file_location('handover', app_dir / 'launcher' / 'handover.py') + mod = importlib.util.module_from_spec(spec) + sys.modules['handover'] = mod + spec.loader.exec_module(mod) + return mod + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('--nx2navi', type=Path, default=Path(os.environ.get('LOCALAPPDATA', '')) / 'Programs' / 'NX2navi') + ap.add_argument('--work', type=Path, default=Path(tempfile.gettempdir()) / 'nx2navi-handover-e2e') + a = ap.parse_args() + nx, work = a.nx2navi, a.work + if not (nx / 'NX2navi.exe').is_file(): + sys.exit(f'{nx} is not an installed NX2navi launcher') + work.mkdir(parents=True, exist_ok=True) + versions = sorted(d.name for d in (nx / 'app').iterdir() if (d / 'launcher' / 'updater.py').is_file()) + print(f'NX2navi launcher versions on this PC: {", ".join(versions)}') + NEW, NEWER = '0.12.0', '0.13.0' + feed_dir, feed2_dir = work / 'feed', work / 'feed2' + for d in (feed_dir, feed2_dir): + shutil.rmtree(d, ignore_errors=True) + make_release(NEW, feed_dir) + make_release(NEWER, feed2_dir) + srv, base = serve(feed_dir) + srv2, base2 = serve(feed2_dir) + url_feed = {'type': 'url', 'url': f'{base}/NX2navi-update.json'} + + for v in versions: + print(f'\nA the command line of launcher {v}') + root = make_root(work, nx, f'root-a-{v}', v, url_feed) + r = run_py(root, v, ['check']) + check('check sees the handover', f'update available: {NEW}' in r.stdout, r.stdout + r.stderr) + r = run_py(root, v, ['update']) + check('update ends with exit code 0', r.returncode == 0, r.stdout[-600:] + r.stderr[-600:]) + check('the handover\'s cli.py finished it', 'NX2navi is now Triforce' in r.stdout, r.stdout[-400:]) + check('current.txt names the handover', text(root / 'app' / 'current.txt') == NEW) + check('previous.txt names the old version', text(root / 'app' / 'previous.txt') == v) + check('the old version is still on the disk', (root / 'app' / v / 'launcher' / 'start.pyw').is_file()) + check('the archive unpacked whole', all((root / 'app' / NEW / p).is_file() for p in ( + 'VERSION', 'launcher/start.pyw', 'launcher/handover.py', 'launcher/triforce/TriforceSetup.exe', + 'launcher/art/hall_100.png'))) + + print(f'B the stub starts the handover (after launcher {v})') + shots = work / f'shots-{v}' + shutil.rmtree(shots, ignore_errors=True) + t0 = time.time() + r = subprocess.run([str(root / 'NX2navi.exe'), '--continue-update', v], cwd=str(root), + env=clean_env(NX2NAVI_HANDOVER_TEST=str(shots)), timeout=60) + waited = time.time() - t0 + check('the stub ends with 0 after its six seconds', r.returncode == 0 and 5.5 < waited < 9, f'{r.returncode} after {waited:.1f} s') + for _ in range(40): + if (shots / 'state.json').is_file(): + break + time.sleep(0.25) + state = json.loads(text(shots / 'state.json') or '{}') + check('the window came up with the stub\'s arguments', state.get('arguments') == ['--continue-update', v], str(state.get('arguments'))) + check('it knows its launcher folder', os.path.normcase(state.get('launcher_root', '')) == os.path.normcase(str(root))) + check('its pictures were taken', bool(state.get('shots')) and all(v2 is True for v2 in state['shots'].values()), str(state.get('shots'))) + check('the Setup it carries is intact', state.get('setup', {}).get('ok') is True, str(state.get('setup'))) + time.sleep(3) + check('nothing was rolled back', text(root / 'app' / 'current.txt') == NEW and text(root / 'app' / 'previous.txt') == v) + check('no crash was logged', not (root / 'data' / 'logs' / 'crash.log').exists(), text(root / 'data' / 'logs' / 'crash.log')[-500:]) + + print(f'C a handover that cannot start (after launcher {v})') + (root / 'app' / NEW / 'launcher' / 'handover.py').rename(root / 'app' / NEW / 'launcher' / 'handover.py.off') + dummy = root / 'app' / '0.0.9' / 'launcher' + dummy.mkdir(parents=True) + (dummy / 'start.pyw').write_text( + 'import os, sys\nopen(os.path.join(os.path.dirname(__file__), "started.txt"), "w").write(" ".join(sys.argv[1:]))\n', + encoding='utf-8') + (root / 'app' / 'previous.txt').write_text('0.0.9\n', encoding='utf-8') + t0 = time.time() + r = subprocess.run([str(root / 'NX2navi.exe')], cwd=str(root), env=clean_env(), timeout=60) + waited = time.time() - t0 + for _ in range(40): + if (dummy / 'started.txt').is_file(): + break + time.sleep(0.25) + check('the stub went back to the version before', text(root / 'app' / 'current.txt') == '0.0.9', text(root / 'app' / 'current.txt')) + check('and started it with --rolled-back', text(dummy / 'started.txt') == f'--rolled-back {NEW}', text(dummy / 'started.txt')) + check('no message box held the exit code back', waited < 14, f'{waited:.1f} s') + check('the failure is in crash.log', 'handover' in text(root / 'data' / 'logs' / 'crash.log')) + + print(f'D the background path of launcher {v} (stage, then install when the game is not running)') + root = make_root(work, nx, f'root-d-{v}', v, url_feed) + script = work / 'stage_test.py' + script.write_text( + 'import json, sys\n' + 'sys.path.insert(0, sys.argv[1])\n' + 'from launcher import feed, updater\n' + 'from launcher.paths import Layout\n' + 'from launcher.settings import Settings\n' + 'lay = Layout().ensure()\n' + 's = Settings(lay.settings)\n' + 'rel = feed.latest(feed.load(lay, s), s.get("channel", "stable"))\n' + 'updater.stage(lay, rel)\n' + 'ver = rel.version\n' + 'out = {"version": ver, "prerelease": updater.prerelease(rel), "staged": bool(updater.staged_info(lay, ver)),\n' + ' "found": (updater.staged(lay, "stable") or {}).get("version"), "runtime_needed": updater.runtime_needed(lay, rel)}\n' + 'updater.install_staged(lay, ver)\n' + 'out["current"], out["previous"] = updater.current(lay), updater.previous(lay)\n' + 'print(json.dumps(out))\n', encoding='utf-8') + r = subprocess.run([str(root / 'python' / 'python.exe'), str(script), str(root / 'app' / v)], capture_output=True, + text=True, cwd=str(root), env=clean_env(NX2NAVI_LAUNCHER_ROOT=str(root)), timeout=300) + try: + out = json.loads(r.stdout.strip().splitlines()[-1]) + except (ValueError, IndexError): + out = {} + check('staged complete and found again', out.get('staged') is True and out.get('found') == NEW, r.stdout[-300:] + r.stderr[-900:]) + check('a full release to the Stable channel, no Python to fetch', out.get('prerelease') is False and out.get('runtime_needed') is False, str(out)) + check('installed: current and previous switched', out.get('current') == NEW and out.get('previous') == v, str(out)) + + v = versions[-1] + print(f'\nE a GitHub-shaped feed (launcher {v})') + man = json.loads(text(feed_dir / 'NX2navi-update.json')) + assets = [{'name': n, 'url': f'{base}/{n}', 'browser_download_url': f'{base}/{n}'} + for n in ('NX2navi-update.json', man['app']['name'])] + older = {'tag_name': 'v0.11.0-rc4', 'draft': False, 'prerelease': True, 'body': '', 'html_url': '', 'assets': []} + gh_feed = {'type': 'github', 'repo': 'someone/NX2navi', 'api': f'{base}/api'} + root = make_root(work, nx, 'root-e', v, gh_feed) + Handler.releases = [{'tag_name': f'v{NEW}', 'draft': False, 'prerelease': False, 'body': 'notes', 'html_url': '', 'assets': assets}, older] + r = run_py(root, v, ['check']) + check('a full release is offered on Stable', f'update available: {NEW}' in r.stdout, r.stdout + r.stderr) + Handler.releases = [{'tag_name': f'v{NEW}', 'draft': False, 'prerelease': True, 'body': 'notes', 'html_url': '', 'assets': assets}, older] + r = run_py(root, v, ['check']) + check('a pre-release is passed over on Stable', 'update available' not in r.stdout, r.stdout + r.stderr) + + print('\nF the way back: the handover stages a newer release of the launcher\'s feed') + root = make_root(work, nx, 'root-f', v, {'type': 'url', 'url': f'{base2}/NX2navi-update.json'}) + r = run_py(root, v, ['update']) + # (that installed 0.13.0 from the second feed; set the folder up as a PC on 0.12.0 whose old version asks that feed) + shutil.rmtree(root / 'app' / NEWER, ignore_errors=True) + shutil.unpack_archive(str(feed_dir / f'NX2navi-app-{NEW}.zip'), str(root / 'app' / NEW), 'zip') + (root / 'app' / 'current.txt').write_text(NEW + '\n', encoding='utf-8') + (root / 'app' / 'previous.txt').write_text(v + '\n', encoding='utf-8') + os.environ['NX2NAVI_LAUNCHER_ROOT'] = str(root) + h = load_handover(root / 'app' / NEW) + check('it reads the old version\'s feed', h.load_feed(root, {}).get('url', '').startswith(base2), str(h.load_feed(root, {}))) + found = h.latest(h.load_feed(root, {}), 'stable') + check('it finds the newer release', bool(found) and found[0]['version'] == NEWER) + try: + staged = h.check_for_newer(root, force=True) + except Exception as e: + staged = f'{type(e).__name__}: {e}' + check('it staged it', staged == NEWER, str(staged)) + check('current.txt names the newer release, previous.txt the handover', + text(root / 'app' / 'current.txt') == NEWER and text(root / 'app' / 'previous.txt') == NEW) + check('the newer release is whole', (root / 'app' / NEWER / 'launcher' / 'start.pyw').is_file() and text(root / 'app' / NEWER / 'VERSION') == NEWER) + check('nothing newer than itself: nothing staged', h.check_for_newer(root, {'feed_repo': ''}, force=True) in ('', NEWER) and + not h.newer(NEW, NEWER) and h.newer('0.12.0', '0.11.0-rc4') and h.newer('0.11.0', '0.11.0-rc4') and not h.newer('0.12.0-rc1', '0.12.0')) + bad = dict(found[0], app=dict(found[0]['app'], sha256='0' * 64)) + (root / 'app' / 'current.txt').write_text(NEW + '\n', encoding='utf-8') + shutil.rmtree(root / 'app' / NEWER, ignore_errors=True) + try: + h.stage(root, bad, found[1]) + refused = False + except h.UpdateError: + refused = True + check('a file that does not match its sha256 is refused and nothing is switched', + refused and text(root / 'app' / 'current.txt') == NEW and not (root / 'app' / NEWER).exists()) + + print('\nG the handover\'s own parts') + game = work / 'fake game' + shutil.rmtree(game, ignore_errors=True) + game.mkdir() + (game / 'ocarina.exe').write_bytes(b'MZ') + dump = work / 'fake dump.nup' + dump.write_bytes(b'x') + (root / 'data' / 'launcher.json').write_text(json.dumps({'game_dir': str(game), 'dump': str(dump).replace('\\', '/')}), encoding='utf-8') + f = h.facts(root) + check('it finds the NX2navi game and the dump from the launcher\'s settings', + f['game'] == game / 'ocarina.exe' and f['game_dir'] == str(game) and f['dump'] == str(dump), str(f)) + started = [] + h.start_detached = lambda cmd, cwd: started.append((cmd, str(cwd))) + temp = work / 'temp' + shutil.rmtree(temp, ignore_errors=True) + temp.mkdir() + h.tempfile.gettempdir = lambda: str(temp) + exe = h.start_triforce(None) + info = json.loads(text(root / 'app' / NEW / 'launcher' / 'triforce' / 'setup.json')) + check('Get Triforce starts the carried Setup from its own temp folder, checked', + started and started[-1][0] == [exe] and Path(exe).parent == temp / 'TriforceSetup' and h.sha256_file(exe) == info['sha256'], str(started)) + h.start_triforce(Path(r'C:\somewhere\TriforceSetup.exe')) + check('Open Triforce starts the installed one', started[-1][0] == [r'C:\somewhere\TriforceSetup.exe']) + carried = root / 'app' / NEW / 'launcher' / 'triforce' / 'TriforceSetup.exe' + carried.write_bytes(carried.read_bytes() + b'x') + try: + h.start_triforce(None) + refused = False + except (ValueError, OSError): + refused = True + check('a changed Setup is not started', refused and len(started) == 2) + # the uninstall plan against the launcher's own, on the same folder + script = work / 'plan_test.py' + script.write_text( + 'import json, sys\n' + 'sys.path.insert(0, sys.argv[1])\n' + 'from launcher import winsys\n' + 'from launcher.paths import Layout\n' + 'lay = Layout()\n' + 'out = []\n' + 'for game in ("", sys.argv[2]):\n' + ' gone, empty = winsys.uninstall_plan(lay, game)\n' + ' out.append(winsys.uninstall_script(gone, empty))\n' + 'print(json.dumps(out))\n', encoding='utf-8') + r = subprocess.run([str(root / 'python' / 'python.exe'), str(script), str(root / 'app' / v), str(game)], capture_output=True, + text=True, cwd=str(root), env=clean_env(NX2NAVI_LAUNCHER_ROOT=str(root)), timeout=120) + try: + theirs = json.loads(r.stdout.strip().splitlines()[-1]) + except (ValueError, IndexError): + theirs = [r.stderr[-600:]] + mine = [h.uninstall_script(*h.uninstall_plan(root, g)) for g in ('', str(game))] + check('its uninstall removes exactly what the launcher\'s own would', mine == theirs, f'\n mine {mine}\n theirs {theirs}') + + srv.shutdown() + srv2.shutdown() + print(f'\n{"ALL PASSED" if not FAILED else str(len(FAILED)) + " FAILED: " + "; ".join(FAILED)}') + return 1 if FAILED else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/handover/tools/art-src/nx2navi_logo.png b/handover/tools/art-src/nx2navi_logo.png new file mode 100644 index 00000000..7209900b Binary files /dev/null and b/handover/tools/art-src/nx2navi_logo.png differ diff --git a/handover/tools/art-src/triforce.png b/handover/tools/art-src/triforce.png new file mode 100644 index 00000000..7349f55b Binary files /dev/null and b/handover/tools/art-src/triforce.png differ diff --git a/handover/tools/make_art.py b/handover/tools/make_art.py new file mode 100644 index 00000000..297d1648 --- /dev/null +++ b/handover/tools/make_art.py @@ -0,0 +1,240 @@ +"""Draws the pictures of the handover window into bridge/launcher/art (run once, by whoever changes the look). + + python tools/make_art.py needs Pillow and numpy; reads tools/art-src/triforce.png and nx2navi_logo.png + +The window (bridge/launcher/handover.py) is one fixed composition of 820 x 480 units. Everything that needs soft light +(the hall, the halo behind the crest, the crest, the wordmark, the fairy) is drawn here, once per display scale, so the +player's PC only puts text and buttons on top of a PNG: no picture library is needed there. + + hall_.png the whole background, = 100, 125, 150, 200 (percent) + btn_.png the gold bar of the primary button, and btn__hi.png for the mouse over it + icon_64/32/16.png the crest for the title bar and the taskbar + +The look is the Triforce start client's ("the relic in a dark hall"): a warm near-black page, one light from above, a +gold halo and a dial's arc behind the painted crest, the wordmark under it. The fairy is NX2navi's logo, kept pixel +exact (whole cells, hard edges), arriving beside the crest. +""" +import math +import sys +from pathlib import Path + +import numpy as np +from PIL import Image, ImageDraw, ImageFilter, ImageFont + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / 'tools' / 'art-src' +OUT = ROOT / 'bridge' / 'launcher' / 'art' + +W, H = 820, 480 # the window, in units (pixels at 100 %) +CX, CREST_W, CREST_TOP = 632, 228, 70 # the crest's centre line, width and top: handover.py leaves this column free +SCALES = (1.0, 1.25, 1.5, 2.0) +SS = 2 # everything soft is drawn at twice the size and scaled down + +INK = (0x0A, 0x09, 0x08) +GOLD = (0xE0, 0xB2, 0x3E) +GOLD_100, GOLD_300, GOLD_700 = (0xFF, 0xF4, 0xCC), (0xF7, 0xDC, 0x8C), (0x9A, 0x6A, 0x18) + + +def grid(h, w): + yy, xx = np.mgrid[0:h, 0:w].astype(np.float32) + return xx + 0.5, yy + 0.5 + + +def ramp(t, stops): + return np.interp(t, [p for p, _ in stops], [a for _, a in stops]).astype(np.float32) + + +def over(base, color, alpha): + c = np.array(color, np.float32) / 255 + return base * (1 - alpha[..., None]) + c * alpha[..., None] + + +def mask_of(size, draw): + """An 8-bit mask drawn with PIL, as a float array 0..1.""" + m = Image.new('L', size, 0) + draw(ImageDraw.Draw(m)) + return np.asarray(m, np.float32) / 255 + + +def crest_art(width): + im = Image.open(SRC / 'triforce.png').convert('RGBA') + return im.resize((width, round(width * im.height / im.width)), Image.LANCZOS) + + +def navi_cells(): + """NX2navi's logo as its 22 x 21 cells (the source picture is each cell 54 x 54).""" + im = Image.open(SRC / 'nx2navi_logo.png').convert('RGBA') + cw, ch = im.width // 22, im.height // 21 + cells = Image.new('RGBA', (22, 21)) + for y in range(21): + for x in range(22): + cells.putpixel((x, y), im.getpixel((x * cw + cw // 2, y * ch + ch // 2))) + return cells + + +def wordmark(k): + """TRIFORCE in engraved gold with its shadow, as an RGBA picture at k pixels per unit.""" + font = ImageFont.truetype('palab.ttf', round(21 * k)) + gap = 0.40 * 21 * k + letters = 'TRIFORCE' + widths = [font.getlength(c) for c in letters] + total = sum(widths) + gap * (len(letters) - 1) + pad = round(12 * k) + size = (round(total) + 2 * pad, round(34 * k) + 2 * pad) + text = Image.new('L', size, 0) + d = ImageDraw.Draw(text) + x = pad + for c, w in zip(letters, widths): + d.text((x, pad), c, font=font, fill=255) + x += w + gap + a = np.asarray(text, np.float32) / 255 + box = text.getbbox() + t = np.clip((np.arange(size[1], dtype=np.float32) - box[1]) / max(1, box[3] - box[1]), 0, 1) + stops = [(0, (0xFF, 0xF1, 0xB8)), (0.42, (0xE8, 0xB8, 0x40)), (0.56, (0xB5, 0x7E, 0x1C)), (1, (0xF3, 0xCF, 0x6A))] + rgb = np.stack([np.interp(t, [p for p, _ in stops], [c[i] for _, c in stops]) for i in range(3)], -1) + rgb = np.repeat(rgb[:, None, :], size[0], 1).astype(np.float32) + shadow = np.asarray(Image.fromarray((a * 255).astype(np.uint8)).filter(ImageFilter.GaussianBlur(3 * k)), + np.float32) / 255 + shadow = np.roll(shadow, round(2 * k), 0) * 0.8 + alpha = a + shadow * (1 - a) + color = np.where(alpha[..., None] > 0, rgb * (a / np.maximum(alpha, 1e-6))[..., None], 0) + out = np.dstack([color, alpha * 255]).clip(0, 255).astype(np.uint8) + return Image.fromarray(out, 'RGBA'), total, pad + + +def hall(scale): + k = scale * SS + w, h = round(W * k), round(H * k) + xx, yy = grid(h, w) + img = np.empty((h, w, 3), np.float32) + img[:] = np.array(INK, np.float32) / 255 + + crest = crest_art(round(CREST_W * k)) + cw, ch = crest.size + cl, ct = round(CX * k - cw / 2), round(CREST_TOP * k) + ccx, ccy = CX * k, ct + ch / 2 + + # 2. the light from above + img = over(img, (0xFF, 0xEC, 0xAA), ramp(yy / h, [(0, 0.045), (0.45, 0)])) + # 3. the halo behind the crest + halo_r = 1.2 * cw + t = np.sqrt((xx - ccx) ** 2 + (yy - ccy) ** 2) / halo_r + img = over(img, GOLD, ramp(t, [(0, 0.24), (0.45, 0.075), (1, 0)])) + # 4. the lattice of triangles, only inside the halo + side, row = 56 * k, 56 * k * math.sqrt(3) / 2 + + def lattice(d): + lw = max(1, round(k * 0.9)) + y = ccy % row - row + while y < h: + d.line([(0, y), (w, y)], fill=255, width=lw) + y += row + slope = math.tan(math.radians(60)) + x0 = ccx - (ccy / slope) + n = int(w / side) + 6 + for i in range(-n, n): + xa = x0 + i * side + d.line([(xa, 0), (xa + h / slope, h)], fill=255, width=lw) + xb = x0 + 2 * (ccy / slope) + i * side + d.line([(xb, 0), (xb - h / slope, h)], fill=255, width=lw) + img = over(img, GOLD, mask_of((w, h), lattice) * 0.06 * ramp(t, [(0, 1), (0.5, 0.5), (0.95, 0)])) + # 6. the vignette (before the arc, which sits in front of it) + tv = np.sqrt(((xx - w / 2) / (0.78 * w)) ** 2 + ((yy - h / 2) / (0.78 * h)) ** 2) + img = over(img, (0, 0, 0), ramp(tv, [(0, 0), (0.55, 0), (1, 0.55)])) + # 5. the arc over the crest: a ring and a ring of marks, fading out on the way down + acx, acy = ccx, ccy + 0.06 * ch + r_in, r_out = 0.66 * cw, 0.705 * cw + + def ring(d): + d.ellipse([acx - r_in, acy - r_in, acx + r_in, acy + r_in], outline=255, width=max(1, round(k * 0.9))) + + def marks(d): + for i in range(120): + a = 2 * math.pi * i / 120 + ca, sa = math.cos(a), math.sin(a) + d.line([(acx + (r_out - 1.5 * k) * ca, acy + (r_out - 1.5 * k) * sa), + (acx + (r_out + 1.5 * k) * ca, acy + (r_out + 1.5 * k) * sa)], fill=255, width=max(1, round(k * 0.9))) + box_top, box_h = acy - r_out, 2 * r_out + fade = ramp((yy - box_top) / box_h, [(0, 1), (0.42, 1), (0.74, 0), (1, 0)]) + img = over(img, GOLD, mask_of((w, h), ring) * 0.17 * fade) + img = over(img, GOLD, mask_of((w, h), marks) * 0.28 * fade) + # the floor glow under the crest + fx, fy = ccx, ct + ch - 2 * k + tf = np.sqrt(((xx - fx) / (0.43 * cw)) ** 2 + ((yy - fy) / (15 * k)) ** 2) + img = over(img, GOLD, ramp(tf, [(0, 0.26), (0.55, 0.08), (1, 0)])) + # the fairy's light, where she will be put (soft, so it is drawn here; her pixels go on after the scaling down) + n = max(2, round(2 * scale)) # pixels per cell of the logo, in the finished picture + nx, ny = round((CX - CREST_W / 2 - 34) * scale), round((CREST_TOP + 26) * scale) + ncx, ncy = (nx + 11 * n) * SS, (ny + 10.5 * n) * SS + tn = np.sqrt((xx - ncx) ** 2 + (yy - ncy) ** 2) / (46 * k) + img = over(img, (0x8F, 0xC0, 0xFF), ramp(tn, [(0, 0.30), (0.4, 0.10), (1, 0)])) + + pic = Image.fromarray((img.clip(0, 1) * 255).astype(np.uint8), 'RGB').convert('RGBA') + pic.alpha_composite(crest, (cl, ct)) + wm, total, pad = wordmark(k) + wm_top = ct + ch + round(22 * k) + pic.alpha_composite(wm, (round(ccx - total / 2) - pad, wm_top - pad)) + # the rule with its diamond, under the wordmark + rule_y, half = wm_top + round(46 * k), 90 * k + rule = np.asarray(pic, np.float32) / 255 + rgb = rule[..., :3] + line = ((np.abs(yy - rule_y) < max(0.5, k * 0.5)) & (np.abs(xx - ccx) < half)).astype(np.float32) + rgb = over(rgb, GOLD, line * 0.55 * ramp(np.abs(xx - ccx) / half, [(0, 1), (0.7, 1), (1, 0)])) + diamond = ((np.abs(xx - ccx) + np.abs(yy - rule_y)) < 3.2 * k).astype(np.float32) + rgb = over(rgb, GOLD_300, diamond) + # down to the display's size, in float, then dithered: a dark gradient bands in 8 bits without it + small = np.asarray(Image.fromarray((rgb.clip(0, 1) * 255).astype(np.uint8), 'RGB') + .resize((round(W * scale), round(H * scale)), Image.LANCZOS), np.float32) + rng = np.random.default_rng(7) + small = small + (rng.random(small.shape, np.float32) - rng.random(small.shape, np.float32)) * 0.6 + out = Image.fromarray(small.clip(0, 255).astype(np.uint8), 'RGB').convert('RGBA') + + # the fairy: whole cells, hard edges, and a short trail of sparks she came along + cells = navi_cells().resize((22 * n, 21 * n), Image.NEAREST) + out.alpha_composite(cells, (nx, ny)) + d = ImageDraw.Draw(out, 'RGBA') + # (the trail stays right of x = 420: the text column of handover.py ends at 400) + trail = [(-14, 28, (0x8F, 0xC0, 0xFF), 160), (-26, 40, (0xFF, 0xFF, 0xFF), 125), (-40, 46, (0x8F, 0xC0, 0xFF), 95), + (-52, 60, (0xC8, 0xE0, 0xFF), 65), (-64, 66, (0x8F, 0xC0, 0xFF), 38)] + for dx, dy, c, a in trail: + px, py = nx + round(dx * scale / n) * n, ny + round(dy * scale / n) * n + d.rectangle([px, py, px + n - 1, py + n - 1], fill=c + (a,)) + return out.convert('RGB') + + +def button(scale, hot): + k = scale * SS + w, h = round(196 * k), round(44 * k) + t = (np.arange(h, dtype=np.float32) + 0.5) / h + stops = [(0, GOLD_100), (0.12, GOLD_300), (0.48, GOLD), (1, GOLD_700)] + rgb = np.stack([np.interp(t, [p for p, _ in stops], [c[i] for _, c in stops]) for i in range(3)], -1) + rgb = np.repeat(rgb[:, None, :], w, 1).astype(np.float32) + if hot: + rgb = rgb * 0.82 + 255 * 0.18 + rgb[:max(1, round(k * 0.8))] = rgb[:max(1, round(k * 0.8))] * 0.5 + np.array(GOLD_100, np.float32) * 0.5 + a = Image.new('L', (w, h), 0) + ImageDraw.Draw(a).rounded_rectangle([0, 0, w - 1, h - 1], radius=round(2 * k), fill=255) + pic = Image.fromarray(rgb.clip(0, 255).astype(np.uint8), 'RGB').convert('RGBA') + pic.putalpha(a) + return pic.resize((round(196 * scale), round(44 * scale)), Image.LANCZOS) + + +def main(): + OUT.mkdir(parents=True, exist_ok=True) + for s in SCALES: + tag = f'{round(s * 100)}' + hall(s).save(OUT / f'hall_{tag}.png', optimize=True) + button(s, False).save(OUT / f'btn_{tag}.png', optimize=True) + button(s, True).save(OUT / f'btn_{tag}_hi.png', optimize=True) + print(f'scale {tag}: hall {(OUT / f"hall_{tag}.png").stat().st_size // 1024} KB') + crest = Image.open(SRC / 'triforce.png').convert('RGBA') + side = max(crest.size) + square = Image.new('RGBA', (side, side)) + square.alpha_composite(crest, ((side - crest.width) // 2, (side - crest.height) // 2)) + for n in (64, 32, 16): + square.resize((n, n), Image.LANCZOS).save(OUT / f'icon_{n}.png', optimize=True) + return 0 + + +if __name__ == '__main__': + sys.exit(main())