mirror of
https://github.com/tangerineturtle1/NX2navi.git
synced 2026-11-15 19:19:58 -04:00
Compare commits
1 Commits
v0.11.0-rc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62cc3b1312 |
@@ -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
|
||||
|
||||
|
||||
24
docs/releases/v0.12.0.md
Normal file
24
docs/releases/v0.12.0.md
Normal 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
5
handover/.gitignore
vendored
Normal 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
144
handover/README.md
Normal 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.
|
||||
18
handover/bridge/NOTICE.txt
Normal file
18
handover/bridge/NOTICE.txt
Normal 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.
|
||||
20
handover/bridge/launcher/cli.py
Normal file
20
handover/bridge/launcher/cli.py
Normal 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())
|
||||
883
handover/bridge/launcher/handover.py
Normal file
883
handover/bridge/launcher/handover.py
Normal 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
|
||||
126
handover/bridge/launcher/selftest.py
Normal file
126
handover/bridge/launcher/selftest.py
Normal 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)
|
||||
93
handover/bridge/launcher/start.pyw
Normal file
93
handover/bridge/launcher/start.pyw
Normal 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()
|
||||
7
handover/bridge/launcher/triforce/setup.json
Normal file
7
handover/bridge/launcher/triforce/setup.json
Normal 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
105
handover/make_handover.py
Normal 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())
|
||||
BIN
handover/preview/handover-new.png
Normal file
BIN
handover/preview/handover-new.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 587 KiB |
BIN
handover/preview/handover-nothing-built.png
Normal file
BIN
handover/preview/handover-nothing-built.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 587 KiB |
BIN
handover/preview/handover-triforce-installed.png
Normal file
BIN
handover/preview/handover-triforce-installed.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 587 KiB |
342
handover/tests/e2e.py
Normal file
342
handover/tests/e2e.py
Normal 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())
|
||||
BIN
handover/tools/art-src/nx2navi_logo.png
Normal file
BIN
handover/tools/art-src/nx2navi_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
BIN
handover/tools/art-src/triforce.png
Normal file
BIN
handover/tools/art-src/triforce.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 277 KiB |
240
handover/tools/make_art.py
Normal file
240
handover/tools/make_art.py
Normal 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())
|
||||
Reference in New Issue
Block a user