Add release 0.12.0, the handover of installed launchers to Triforce: the source of its window, its tests and its notes.

The release is one launcher version that opens a single window in place of the launcher: Get Triforce starts the Triforce Setup the release carries, after checking its sha256, and Play NX2navi starts the game that is built on the PC. Nothing of NX2navi is removed. handover/ holds the source of that window as it was published; the drawn pictures, the Setup program and the release files are not kept in the repository (handover/README.md, "In the repository").

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
chrg6
2026-11-14 23:52:26 -05:00
parent 1c3fa7df2a
commit 62cc3b1312
18 changed files with 2008 additions and 1 deletions

View File

@@ -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. - 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. - `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. - 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 ## Writing

24
docs/releases/v0.12.0.md Normal file
View File

@@ -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).

5
handover/.gitignore vendored Normal file
View File

@@ -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/

144
handover/README.md Normal file
View File

@@ -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/<old version>` (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/<version>`, 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 <unpacked NX2navi-app-0.12.0.zip>\launcher\start.pyw --selftest --shots <folder>
```
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\<version>\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-<version>.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-<version>.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.

View File

@@ -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.

View File

@@ -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())

View File

@@ -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/<version>, 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/<version>; 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 <system drive>\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('<Return>', lambda e: self.primary())
self.win.bind('<Escape>', 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, '<Enter>', lambda e: (self.c.itemconfigure(tag, fill=hot), self.c.configure(cursor='hand2')))
self.c.tag_bind(tag, '<Leave>', lambda e: (self.c.itemconfigure(tag, fill=rest), self.c.configure(cursor='')))
self.c.tag_bind(tag, '<Button-1>', 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', '<Enter>', lambda e: (c.itemconfigure('primary_bar', image=self.img['btn_hi']),
c.configure(cursor='hand2')))
c.tag_bind('primary', '<Leave>', lambda e: (c.itemconfigure('primary_bar', image=self.img['btn']),
c.configure(cursor='')))
c.tag_bind('primary', '<Button-1>', 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', '<Enter>', lambda e: (c.itemconfigure('second_box', outline='#8C7230'),
c.itemconfigure('second_text', fill=TEXT_HI),
c.configure(cursor='hand2')))
c.tag_bind('second', '<Leave>', lambda e: (c.itemconfigure('second_box', outline=LINE),
c.itemconfigure('second_text', fill=TEXT),
c.configure(cursor='')))
c.tag_bind('second', '<Button-1>', 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

View File

@@ -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-<state>.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('<IiiHHIIiiII', 40, w, -h, 1, 32, 0, 0, 0, 0, 0, 0)
info = ctypes.create_string_buffer(header, 40 + 12)
buf = ctypes.create_string_buffer(w * h * 4)
if not gdi32.GetDIBits(mem, bmp, 0, h, buf, info, 0):
return False
raw = buf.raw
rgb = bytearray(w * h * 3)
rgb[0::3], rgb[1::3], rgb[2::3] = raw[2::4], raw[1::4], raw[0::4]
write_png(path, w, h, bytes(rgb))
return True
finally:
gdi32.SelectObject(mem, old)
gdi32.DeleteObject(bmp)
gdi32.DeleteDC(mem)
user32.ReleaseDC(hwnd, dc)
SAMPLE = {'dump': r'D:\Games\dumps\My game dump [0400486024dec000][v131072].nup', 'game_dir': r'D:\Games\NX2navi',
'game': Path(r'D:\Games\NX2navi\ocarina.exe')}
STATES = [
('new', dict(SAMPLE, triforce=None), '', False),
('triforce-installed', dict(SAMPLE, triforce=Path(r'C:\Triforce\TriforceSetup.exe')), '', False),
('nothing-built', {'triforce': None, 'game': None, 'dump': '', 'game_dir': ''}, '', False),
('copied', dict(SAMPLE, triforce=None), 'Copied. Paste it where Triforce Setup asks for it.', False),
('problem', dict(SAMPLE, triforce=None),
'Triforce Setup could not be started from here. Get it from triforceproject.com, which opens now.', True),
]
def run(app, out):
out = Path(out)
out.mkdir(parents=True, exist_ok=True)
real = dict(app.found)
shots = {}
for _ in range(5):
app.win.update()
time.sleep(0.1)
try: # the first picture Windows gives of a window is an empty frame: take it and throw it away
snap(app.win, out / 'first-frame.png')
(out / 'first-frame.png').unlink()
except Exception:
pass
for name, found, status, bad in STATES + [('this-pc', real, '', False)]:
app.found, app.status, app.status_color = dict(found), status, handover.BAD if bad else handover.GOLD_300
app.draw()
app.win.update()
time.sleep(0.15)
app.win.update()
try:
shots[name] = bool(snap(app.win, out / f'handover-{name}.png'))
except Exception as e:
shots[name] = f'{type(e).__name__}: {e}'
state = {'version': handover.my_version(), 'arguments': app.argv, 'launcher_root': str(app.root),
'scale': app.s, 'tk': str(app.win.tk.call('info', 'patchlevel')),
'found': {k: (str(v) if v else '') for k, v in real.items()}, 'shots': shots}
try:
_, info = handover.bundled_setup()
state['setup'] = {'ok': True, **info}
except Exception as e:
state['setup'] = {'ok': False, 'error': f'{type(e).__name__}: {e}'}
(out / 'state.json').write_text(json.dumps(state, indent=1), encoding='utf-8')
wait = max(0.0, 8.0 - (time.time() - BORN)) if os.environ.get('NX2NAVI_HANDOVER_TEST') else 0.0
app.win.after(int(wait * 1000) + 50, app.win.destroy)

View File

@@ -0,0 +1,93 @@
"""What NX2navi.exe runs: python\\pythonw.exe app\\<version>\\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()

View File

@@ -0,0 +1,7 @@
{
"name": "TriforceSetup.exe",
"version": "v0.3.6",
"size": 635904,
"sha256": "59fba57af1e9b15e66bfe3a2e1fb0127d1e7deb0c4b8688206c2935a66e7eb2e",
"from": "https://triforceproject.com"
}

105
handover/make_handover.py Normal file
View File

@@ -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-<version>.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<version> 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. <version> 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())

Binary file not shown.

After

Width:  |  Height:  |  Size: 587 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 587 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 587 KiB

342
handover/tests/e2e.py Normal file
View File

@@ -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())

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

240
handover/tools/make_art.py Normal file
View File

@@ -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_<scale>.png the whole background, <scale> = 100, 125, 150, 200 (percent)
btn_<scale>.png the gold bar of the primary button, and btn_<scale>_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())