Files
psxrecomp/tools/toolchain_pack.py
Alex Vanderveen 922e8c5a07 Rename the product from RetComM to Retro
The launcher and studio ship as Retro Launcher and Retro Studio now,
from the RetroPortingToolKit org, so the old product name is gone from
display text, documentation and release asset filenames.

The split is by case, and it is not cosmetic. CamelCase RetComM was
always the product; lowercase retcomm and RETCOMM_ are identifiers --
the C++ namespace, the binaries, the data directories, the CMake and
environment variables, the Cloudflare service names, the readme markers.
Renaming those would orphan existing installs for no benefit, so they
are untouched: 1101 lowercase retcomm and 205 RETCOMM_ still stand in
the launcher alone.

Four things are deliberately still spelled RetComM, and three of them
would have been real breakage:

- RetComM-Data is CamelCase but it is a directory on disk --
  <exe_dir>\RetComM-Data\{runtime,config,data} for a portable install.
  Renaming it strands every portable user's config, toolchains and
  catalog cache.

- TechnicallyComputers/RetComM-Launcher in readme_has_launcher() is the
  historical half of a both-slugs test. Renaming it makes it a duplicate
  of the line above and sends every port built before the transfer back
  to reporting its launcher section missing.

- _LEGACY_LAUNCHER_RE matched "^## RetComM Launcher". A sweep renamed it,
  which would have silently stopped it matching every README already out
  there; it now accepts both headings.

- The portable-stub lookup gained the pre-rename filenames back as
  fallbacks, because old zips stay on the releases page and a rollback
  downloads one.

Release assets become Retro-Launcher-* / Retro-Studio-*. That is safe
for installed clients: pick_launcher_asset() gates on *linux*/*windows*/
*macos* plus extension and arch, not on the product name, and the
Windows portable stub is identified by its RCM1 trailer rather than its
filename.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013DpaQqVguHFpBuCmdoFYWY
2026-09-09 17:32:19 -04:00

1044 lines
36 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Resolve / download / unpack portable cmake-clang-v1 toolchain packs.
Shared cache matches Retro / retcomm-toolchains install.sh:
Windows: %LOCALAPPDATA%/retcomm/toolchains/cmake-clang-v1/<tag>/
Linux/macOS: $XDG_DATA_HOME/retcomm/… and ~/.local/share/retcomm/…
plus latest → current <tag> (pointer only; never a pack extract target)
Overrides: RETCOMM_TOOLCHAIN_CACHE, RETCOMM_DATA_HOME, RETCOMM_TOOLCHAIN_DIR.
Legacy …/psxrecomp/toolchains/… is still searched and migrated on ensure.
"""
from __future__ import annotations
import json
import os
import platform
import re
import shutil
import subprocess
import sys
import tempfile
import zipfile
from pathlib import Path
from typing import Optional
DEFAULT_REPO = "RetroPortingToolKit/RetroPorting-Toolchains"
PACK_ID = "cmake-clang-v1"
_ASSET = {
"linux-x64": "cmake-clang-v1-linux-x64.zip",
"windows-x64": "cmake-clang-v1-windows-x64.zip",
"macos-arm64": "cmake-clang-v1-macos-universal.zip",
"macos-x64": "cmake-clang-v1-macos-universal.zip",
}
def sys_platform_is_windows() -> bool:
return sys.platform == "win32"
def host_artifact() -> str:
if sys_platform_is_windows():
return "windows-x64"
if sys.platform == "darwin":
return "macos-arm64" if platform.machine().lower() in ("arm64", "aarch64") else "macos-x64"
return "linux-x64"
def cmake_name() -> str:
return "cmake.exe" if sys_platform_is_windows() else "cmake"
def bin_looks_usable(bin_dir: Path) -> bool:
return bin_dir.is_dir() and (bin_dir / cmake_name()).is_file()
def pack_root_looks_usable(root: Path) -> bool:
return bin_looks_usable(root / "bin")
def toolchain_bin_runs(bin_dir: Path, log=None) -> bool:
"""True when bin/cmake exists and ``cmake --version`` exits 0."""
if not bin_looks_usable(bin_dir):
return False
cmake = bin_dir / cmake_name()
try:
proc = subprocess.run(
[str(cmake), "--version"],
capture_output=True,
text=True,
timeout=45,
check=False,
)
except (OSError, subprocess.SubprocessError) as exc:
if log:
log(f"Toolchain cmake probe failed ({cmake}): {exc}")
return False
if proc.returncode != 0 and log:
err = (proc.stderr or proc.stdout or "").strip().splitlines()
detail = err[0] if err else f"exit {proc.returncode}"
log(f"Toolchain cmake unusable ({cmake}): {detail}")
return proc.returncode == 0
def clear_project_toolchain_stamp(project_root: Optional[Path]) -> None:
if project_root is None:
return
stamp = project_root / "toolchain" / STAMP_NAME
try:
if stamp.is_file():
stamp.unlink()
except OSError:
pass
def prune_old_toolchain_tags(keep_pack: Path, log=None) -> int:
"""Delete sibling versioned ``<tag>/`` dirs under the managed install root.
Keeps *keep_pack* (and anything under it), ``latest/``, and dot-dirs
(staging). Returns the number of removed entries. No-op when *keep_pack*
is outside ``preferred_install_root()`` (e.g. ``RETCOMM_TOOLCHAIN_DIR``).
"""
keep = unwrap_pack_root(Path(keep_pack).expanduser())
try:
cache_root = preferred_install_root()
except OSError:
return 0
if not cache_root.is_dir():
return 0
if not (_paths_equal(keep, cache_root) or _is_under(keep, cache_root)):
return 0
removed = 0
try:
children = list(cache_root.iterdir())
except OSError:
return 0
for child in children:
name = child.name
if name.startswith(".") or name == "latest":
continue
try:
if not (child.is_dir() or child.is_symlink()):
continue
except OSError:
continue
if (
_paths_equal(keep, child)
or _is_under(keep, child)
or _is_under(child, keep)
):
continue
if log:
log(f"Removing old toolchain install: {child}")
try:
if child.is_symlink() or child.is_file():
child.unlink(missing_ok=True)
else:
shutil.rmtree(child, ignore_errors=True)
removed += 1
except OSError as exc:
if log:
log(f"Could not remove old toolchain {child}: {exc}")
return removed
def heal_broken_toolchain_pointers(log=None) -> None:
"""Remove unusable ``latest`` pointers so ensure can reinstall cleanly.
Does not delete versioned ``<tag>/`` packs — only broken ``latest``
symlinks/directories that lack a runnable bin/cmake. Sibling tags are
pruned after a successful install via ``prune_old_toolchain_tags``.
"""
for base in shared_cache_roots():
latest = base / "latest"
try:
present = latest.exists() or latest.is_symlink()
except OSError:
present = False
if not present:
continue
root = unwrap_pack_root(latest)
if pack_root_looks_usable(root) and toolchain_bin_runs(root / "bin"):
continue
if log:
log(f"Removing broken toolchain pointer: {latest}")
try:
if latest.is_symlink() or latest.is_file():
latest.unlink(missing_ok=True)
elif latest.is_dir():
shutil.rmtree(latest, ignore_errors=True)
else:
latest.unlink(missing_ok=True)
except OSError as exc:
if log:
log(f"Could not remove broken toolchain pointer: {exc}")
def unwrap_pack_root(path: Path) -> Path:
"""If *path* is a single nested directory with bin/, return that child."""
if pack_root_looks_usable(path):
return path
try:
kids = [p for p in path.iterdir() if p.is_dir() and not p.name.startswith(".")]
except OSError:
return path
if len(kids) == 1 and pack_root_looks_usable(kids[0]):
return kids[0]
return path
def resolve_embedded_bin(project_root: Path) -> Optional[Path]:
root = project_root / "toolchain"
if not root.is_dir():
return None
direct = root / "bin"
if bin_looks_usable(direct):
return direct
try:
kids = [p for p in root.iterdir() if p.is_dir()]
except OSError:
return None
if len(kids) == 1:
nested = kids[0] / "bin"
if bin_looks_usable(nested):
return nested
for kid in kids:
nested = kid / "bin"
if bin_looks_usable(nested):
return nested
return None
def env_toolchain_roots() -> list[Path]:
out: list[Path] = []
for key in ("RETCOMM_TOOLCHAIN_DIR", "PSXRECOMP_TOOLCHAIN_DIR", "TOOLCHAIN_DIR",
"BPE_TOOLCHAIN_DIR"):
raw = os.environ.get(key)
if raw:
out.append(Path(raw).expanduser())
return out
def _dedupe_paths(paths: list[Path]) -> list[Path]:
seen: set[str] = set()
uniq: list[Path] = []
for r in paths:
try:
key = str(r.expanduser())
except OSError:
key = str(r)
if key not in seen:
seen.add(key)
uniq.append(Path(key))
return uniq
def retcomm_data_homes() -> list[Path]:
"""Candidate …/retcomm data roots (RETCOMM_DATA_HOME, XDG, ~/.local/share)."""
homes: list[Path] = []
env = (os.environ.get("RETCOMM_DATA_HOME") or "").strip()
if env:
homes.append(Path(env).expanduser())
xdg = (os.environ.get("XDG_DATA_HOME") or "").strip()
if xdg:
homes.append(Path(xdg).expanduser() / "retcomm")
# Always also try the conventional home path — install.sh may have used it
# even when XDG_DATA_HOME is set to something else (or vice versa).
homes.append(Path.home() / ".local" / "share" / "retcomm")
if sys_platform_is_windows():
local = os.environ.get("LOCALAPPDATA")
if local:
homes.append(Path(local) / "retcomm")
return _dedupe_paths(homes)
def shared_cache_roots() -> list[Path]:
"""Candidate parent dirs that contain <tag>/ packs (or a flat pack).
Layout matches retcomm-toolchains install.sh:
…/retcomm/toolchains/cmake-clang-v1/<tag>/ plus latest → current
Retro (`retcomm`) is preferred; legacy `psxrecomp` remains a read/migrate
fallback. Honors RETCOMM_TOOLCHAIN_CACHE / RETCOMM_DATA_HOME.
"""
roots: list[Path] = []
cache = (os.environ.get("RETCOMM_TOOLCHAIN_CACHE") or "").strip()
if cache:
roots.append(Path(cache).expanduser())
for data in retcomm_data_homes():
roots.append(data / "toolchains" / PACK_ID)
# Legacy psxrecomp mirrors beside each retcomm root's parent share.
for data in retcomm_data_homes():
parent = data.parent # …/share or LOCALAPPDATA
roots.append(parent / "psxrecomp" / "toolchains" / PACK_ID)
if sys_platform_is_windows():
local = os.environ.get("LOCALAPPDATA")
if local:
roots.append(Path(local) / "psxrecomp" / "toolchains" / PACK_ID)
return _dedupe_paths(roots)
def preferred_install_root() -> Path:
"""Where newly downloaded / offline-unpacked packs land (Retro shared)."""
cache = (os.environ.get("RETCOMM_TOOLCHAIN_CACHE") or "").strip()
if cache:
r = Path(cache).expanduser()
r.mkdir(parents=True, exist_ok=True)
return r
for r in shared_cache_roots():
if "retcomm" in r.parts:
r.mkdir(parents=True, exist_ok=True)
return r
r = shared_cache_roots()[0]
r.mkdir(parents=True, exist_ok=True)
return r
def read_pack_version(root: Path) -> str:
meta = unwrap_pack_root(root) / "retcomm-toolchain.json"
try:
data = json.loads(meta.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, TypeError):
return ""
ver = data.get("version") if isinstance(data, dict) else None
return str(ver).strip() if ver else ""
def parse_version_tuple(ver: str) -> tuple[int, ...]:
s = ver.strip()
if s.lower().startswith("v") and len(s) > 1 and s[1].isdigit():
s = s[1:]
parts: list[int] = []
for chunk in re.split(r"[^\d]+", s):
if not chunk:
continue
try:
parts.append(int(chunk))
except ValueError:
break
if len(parts) >= 4:
break
return tuple(parts) if parts else (0,)
def version_satisfies(have: str, need: str) -> bool:
if not need:
return True
if not have:
return False
return parse_version_tuple(have) >= parse_version_tuple(need)
# Framework floor, not a per-title pin: v1.0.14 is the first pack with the
# Linux build sysroot (glibc/libstdc++/GL headers + stubs). Older cached packs
# compile against host headers, which do not exist on stock SteamOS, and their
# objects mis-link after a pack upgrade (__isoc23_strtoul). Because the cache
# is accepted forever once any cmake runs, a floor is the only way a stale
# pre-sysroot pack ever gets replaced.
DEFAULT_MIN_VERSION = "1.0.14"
# RETCOMM_TOOLCHAIN_MIN_VERSION values that disable the floor entirely.
_MIN_VERSION_DISABLE = {"0", "off", "none"}
def default_min_version() -> str:
"""Semver floor for accepted packs (DEFAULT_MIN_VERSION unless overridden).
RETCOMM_TOOLCHAIN_MIN_VERSION overrides in either direction: a version
raises/lowers the floor, and "0" / "off" / "none" disables it (accept any
usable pack, matching the pre-floor behavior).
"""
env = (os.environ.get("RETCOMM_TOOLCHAIN_MIN_VERSION") or "").strip()
if env:
return "" if env.lower() in _MIN_VERSION_DISABLE else env
return DEFAULT_MIN_VERSION
def pack_satisfies_min(root: Path, min_version: str = "") -> bool:
need = min_version or default_min_version()
if not need:
return True
return version_satisfies(read_pack_version(root), need)
def _best_pack_under(base: Path, min_version: str = "") -> Optional[Path]:
if not base.is_dir():
return None
if pack_root_looks_usable(base) and pack_satisfies_min(base, min_version):
return unwrap_pack_root(base)
prefer_names = ("latest", "offline")
candidates: list[Path] = []
try:
kids = [p for p in base.iterdir() if p.is_dir() and not p.name.startswith(".")]
except OSError:
return None
for name in prefer_names:
for kid in kids:
if kid.name == name:
root = unwrap_pack_root(kid)
if pack_root_looks_usable(root) and pack_satisfies_min(root, min_version):
return root
for kid in kids:
root = unwrap_pack_root(kid)
if pack_root_looks_usable(root) and pack_satisfies_min(root, min_version):
candidates.append(root)
if not candidates:
return None
def sort_key(p: Path) -> tuple:
ver = read_pack_version(p)
return (parse_version_tuple(ver), p.stat().st_mtime)
candidates.sort(key=sort_key, reverse=True)
return candidates[0]
def migrate_legacy_psxrecomp_cache(log=None) -> None:
"""Copy legacy psxrecomp cache into retcomm when retcomm has no usable pack."""
retcomm = next((r for r in shared_cache_roots() if "retcomm" in r.parts), None)
legacy = next((r for r in shared_cache_roots() if "psxrecomp" in r.parts), None)
if retcomm is None or legacy is None or not legacy.is_dir():
return
if _best_pack_under(retcomm):
return
src = _best_pack_under(legacy)
if src is None:
return
tag = src.name if src.parent == legacy else "latest"
if src == unwrap_pack_root(legacy):
tag = "latest"
dest = retcomm / tag
try:
retcomm.mkdir(parents=True, exist_ok=True)
if dest.exists():
shutil.rmtree(dest, ignore_errors=True)
shutil.copytree(src, dest)
if log:
log(f"Migrated toolchain cache {src} -> {dest}")
except OSError as exc:
if log:
log(f"Could not migrate legacy toolchain cache: {exc}")
STAMP_NAME = ".psxrecomp-bin"
def is_windows_store_python() -> bool:
"""Microsoft Store Python redirects %LOCALAPPDATA% writes into LocalCache."""
if not sys_platform_is_windows():
return False
try:
exe = str(Path(sys.executable).resolve()).lower()
except OSError:
exe = str(sys.executable).lower()
return "windowsapps" in exe or "pythonsoftwarefoundation" in exe
def write_toolchain_stamp(project_root: Path, bin_dir: Path) -> None:
"""Write project_root/toolchain/.psxrecomp-bin for the C host to read."""
stamp_dir = project_root / "toolchain"
try:
stamp_dir.mkdir(parents=True, exist_ok=True)
(stamp_dir / STAMP_NAME).write_text(
str(bin_dir.resolve()) + "\n", encoding="utf-8"
)
except OSError:
pass
def materialize_into_project(
project_root: Path, pack_root: Path, log=None
) -> Path:
"""Point the project at a shared pack via stamp (no multi‑GB copy)."""
src = unwrap_pack_root(pack_root)
if not pack_root_looks_usable(src):
raise RuntimeError(f"toolchain pack unusable: {pack_root}")
write_toolchain_stamp(project_root, src / "bin")
if log:
log(f"Using shared toolchain at {src}")
return src
def find_cached_pack(min_version: str = "") -> Optional[Path]:
for base in shared_cache_roots():
found = _best_pack_under(base, min_version=min_version)
if found is not None:
return found
return None
def resolve_toolchain_bin(
project_root: Optional[Path] = None, *, min_version: str = ""
) -> Optional[Path]:
for env_root in env_toolchain_roots():
root = unwrap_pack_root(env_root)
if pack_root_looks_usable(root) and pack_satisfies_min(root, min_version):
return root / "bin"
if project_root is not None:
embedded = resolve_embedded_bin(project_root)
if embedded:
root = embedded.parent
if pack_satisfies_min(root, min_version):
return embedded
cached = find_cached_pack(min_version=min_version)
if cached:
return cached / "bin"
return None
def pack_python_exe(pack_root: Path) -> Optional[Path]:
"""Return portable CPython under <pack>/python/ (cmake-clang-v1 1.0.6+)."""
py_root = pack_root / "python"
if not py_root.is_dir():
return None
if sys_platform_is_windows():
for name in ("python.exe", "python3.exe"):
cand = py_root / name
if cand.is_file():
return cand
for rel in ("bin/python3", "bin/python"):
cand = py_root / rel
if cand.is_file():
return cand
# macOS universal pack: arch-specific trees (+ bin/ dispatcher).
if sys.platform == "darwin":
machine = platform.machine().lower()
if machine in ("arm64", "aarch64"):
arch_rels = (
"aarch64-apple-darwin/bin/python3",
"aarch64-apple-darwin/bin/python",
)
else:
arch_rels = (
"x86_64-apple-darwin/bin/python3",
"x86_64-apple-darwin/bin/python",
)
for rel in arch_rels:
cand = py_root / rel
if cand.is_file():
return cand
return None
def activate_toolchain_bin(bin_dir: Path, log=None) -> None:
prefix = str(bin_dir)
pack_root = bin_dir.parent
cur = os.environ.get("PATH", "")
parts = [p for p in (cur.split(os.pathsep) if cur else []) if p]
# Idempotent session PATH: only prepend when missing.
path_prefixes: list[str] = []
py_exe = pack_python_exe(pack_root)
if py_exe is not None:
# Windows PBS: <pack>/python/python.exe — Unix: <pack>/python/bin/python3
py_dir = str(py_exe.parent)
if py_dir not in parts:
path_prefixes.append(py_dir)
os.environ["RETCOMM_PYTHON"] = str(py_exe)
if str(bin_dir) not in parts and prefix not in parts:
path_prefixes.append(prefix)
if path_prefixes:
os.environ["PATH"] = os.pathsep.join(path_prefixes) + (
os.pathsep + cur if cur else ""
)
# Host deps under deps/ (1.0.9+); legacy packs keep zlib/SDL3 at pack root.
# Never put pack root on CMAKE_PREFIX_PATH — mingw include/ poisons libc++.
pack_s = str(pack_root)
deps_root = pack_root / "deps"
os.environ["RETCOMM_TOOLCHAIN_DIR"] = pack_s
if "PSXRECOMP_TOOLCHAIN_DIR" not in os.environ:
os.environ["PSXRECOMP_TOOLCHAIN_DIR"] = pack_s
if (deps_root / "include" / "zlib.h").is_file():
os.environ["ZLIB_ROOT"] = str(deps_root)
elif (pack_root / "include" / "zlib.h").is_file():
os.environ["ZLIB_ROOT"] = pack_s
prev_prefix = os.environ.get("CMAKE_PREFIX_PATH", "")
if prev_prefix:
filtered = [
p for p in prev_prefix.split(os.pathsep)
if p and os.path.normcase(os.path.normpath(p))
!= os.path.normcase(os.path.normpath(pack_s))
]
os.environ["CMAKE_PREFIX_PATH"] = os.pathsep.join(filtered)
elif "CMAKE_PREFIX_PATH" in os.environ:
del os.environ["CMAKE_PREFIX_PATH"]
sdl3_dir = None
for root in (deps_root, pack_root):
cfg = root / "lib" / "cmake" / "SDL3" / "SDL3Config.cmake"
cfg_alt = root / "lib" / "cmake" / "SDL3" / "SDL3-config.cmake"
if cfg.is_file() or cfg_alt.is_file():
sdl3_dir = root / "lib" / "cmake" / "SDL3"
break
if sdl3_dir is not None:
os.environ["SDL3_DIR"] = str(sdl3_dir)
lib_dir = pack_root / "lib"
if lib_dir.is_dir() and not sys_platform_is_windows():
lib_s = str(lib_dir)
prev_llp = os.environ.get("LD_LIBRARY_PATH", "")
parts = prev_llp.split(os.pathsep) if prev_llp else []
if lib_s not in parts:
os.environ["LD_LIBRARY_PATH"] = lib_s + (
os.pathsep + prev_llp if prev_llp else ""
)
for name, env_key in (
("clang", "CC"),
("clang++", "CXX"),
("clang.exe", "CC"),
("clang++.exe", "CXX"),
):
cand = bin_dir / name
if cand.is_file() and env_key not in os.environ:
os.environ[env_key] = str(cand)
if log:
log(f"Using toolchain: {bin_dir}")
_MARKER_BEGIN = f"# >>> retcomm-toolchain ({PACK_ID}) >>>"
_MARKER_END = f"# <<< retcomm-toolchain ({PACK_ID}) <<<"
def _strip_marker_block(text: str) -> str:
lines = text.splitlines(keepends=True)
out: list[str] = []
skip = False
for line in lines:
stripped = line.rstrip("\n\r")
if stripped == _MARKER_BEGIN:
skip = True
continue
if stripped == _MARKER_END:
skip = False
continue
if not skip:
out.append(line)
return "".join(out)
def _upsert_profile_block(path: Path, block: str) -> None:
body = ""
if path.is_file():
try:
body = path.read_text(encoding="utf-8")
except OSError:
body = ""
body = _strip_marker_block(body)
if body and not body.endswith("\n"):
body += "\n"
body += block
if not body.endswith("\n"):
body += "\n"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(body, encoding="utf-8")
def _paths_equal(a: Path, b: Path) -> bool:
try:
return a.resolve() == b.resolve()
except OSError:
return os.path.normcase(os.path.normpath(str(a))) == os.path.normcase(
os.path.normpath(str(b))
)
def _is_under(child: Path, parent: Path) -> bool:
try:
child.resolve().relative_to(parent.resolve())
return True
except (ValueError, OSError):
c = os.path.normcase(os.path.normpath(str(child)))
p = os.path.normcase(os.path.normpath(str(parent)))
sep = os.sep
return c == p or c.startswith(p + sep)
def _set_latest_pointer(cache_root: Path, pack_root: Path) -> Path:
"""Ensure cache_root/latest points at *pack_root* (symlink, else copy).
The C setup host unpacks cmake-clang-v1 *into* ``…/latest`` as a real
directory. Do not delete that tree to create a self-pointer (WinError 3).
"""
latest = cache_root / "latest"
root = unwrap_pack_root(pack_root)
# Pack already *is* latest/ (or lives under it) — leave it alone.
if _paths_equal(root, latest) or _paths_equal(pack_root, latest):
return root if pack_root_looks_usable(root) else latest
if (latest.exists() or latest.is_symlink()) and (
_is_under(root, latest) or _is_under(pack_root, latest)
):
return unwrap_pack_root(latest) if pack_root_looks_usable(
unwrap_pack_root(latest)
) else root
if latest.exists() or latest.is_symlink():
if latest.is_dir() and not latest.is_symlink():
shutil.rmtree(latest, ignore_errors=True)
else:
latest.unlink(missing_ok=True)
try:
latest.symlink_to(root, target_is_directory=True)
except OSError:
shutil.copytree(root, latest)
return latest
def _register_user_path_windows(bin_dir: Path, pack_root: Path, log=None) -> None:
try:
import winreg # type: ignore
except ImportError:
if log:
log("Could not import winreg; skipped persistent user PATH")
return
bin_s = str(bin_dir)
try:
with winreg.OpenKey(
winreg.HKEY_CURRENT_USER, "Environment", 0, winreg.KEY_READ | winreg.KEY_SET_VALUE
) as key:
try:
raw, _ = winreg.QueryValueEx(key, "Path")
except FileNotFoundError:
raw = ""
parts = [p.rstrip("\\/") for p in str(raw).split(";") if p.strip()]
if bin_s.rstrip("\\/") not in parts and all(
p.lower() != bin_s.rstrip("\\/").lower() for p in parts
):
parts.append(bin_s)
winreg.SetValueEx(key, "Path", 0, winreg.REG_EXPAND_SZ, ";".join(parts))
if log:
log(f"Added to user PATH: {bin_s}")
elif log:
log(f"Already on user PATH: {bin_s}")
winreg.SetValueEx(
key, "RETCOMM_TOOLCHAIN_DIR", 0, winreg.REG_EXPAND_SZ, str(pack_root)
)
except OSError as exc:
if log:
log(f"Could not update user PATH: {exc}")
return
# Broadcast env change (best-effort).
try:
import ctypes
HWND_BROADCAST = 0xFFFF
WM_SETTINGCHANGE = 0x001A
ctypes.windll.user32.SendMessageTimeoutW(
HWND_BROADCAST, WM_SETTINGCHANGE, 0, "Environment", 0x0002, 5000, None
)
except Exception:
pass
def _register_user_path_unix(cache_root: Path, latest: Path, log=None) -> None:
hook = cache_root / "path.sh"
hook.write_text(
"# Auto-generated by psxrecomp ensure-toolchain — do not edit.\n"
f'RETCOMM_TC_LATEST="{latest}"\n'
'if [ -f "${RETCOMM_TC_LATEST}/env.sh" ]; then\n'
" # shellcheck disable=SC1091\n"
' . "${RETCOMM_TC_LATEST}/env.sh"\n'
'elif [ -d "${RETCOMM_TC_LATEST}/bin" ]; then\n'
' case ":${PATH}:" in\n'
' *":${RETCOMM_TC_LATEST}/bin:"*) ;;\n'
' *) export PATH="${RETCOMM_TC_LATEST}/bin${PATH:+:${PATH}}" ;;\n'
" esac\n"
"fi\n"
'export RETCOMM_TOOLCHAIN_DIR="${RETCOMM_TC_LATEST}"\n',
encoding="utf-8",
)
block = (
f"{_MARKER_BEGIN}\n"
f"# Managed by psxrecomp / Retro — remove via uninstall.sh\n"
f'if [ -f "{hook}" ]; then\n'
f" # shellcheck disable=SC1091\n"
f' . "{hook}"\n'
f"fi\n"
f"{_MARKER_END}\n"
)
home = Path.home()
profiles: list[Path] = []
for name in (".bashrc", ".zshrc", ".profile"):
p = home / name
if p.is_file():
profiles.append(p)
if not profiles:
profiles.append(home / (".zshrc" if sys.platform == "darwin" else ".bashrc"))
for rc in profiles:
try:
_upsert_profile_block(rc, block)
if log:
log(f"Updated PATH hook in {rc}")
except OSError as exc:
if log:
log(f"Could not update {rc}: {exc}")
def register_toolchain_user_env(pack_root: Path, log=None) -> Path:
"""Refresh ``latest`` + idempotently add ``latest/bin`` to the user login PATH.
Mirrors retcomm-toolchains zip ``install.sh`` / ``install.ps1`` so Retro
setup hosts and the standalone wizard leave cmake/clang on PATH for shell
development after placing the pack in the shared cache.
"""
root = unwrap_pack_root(pack_root)
if not pack_root_looks_usable(root):
raise RuntimeError(f"toolchain pack unusable for PATH register: {pack_root}")
cache_root = preferred_install_root()
cache_root.mkdir(parents=True, exist_ok=True)
try:
latest = _set_latest_pointer(cache_root, root)
except OSError as exc:
# Pointer refresh is best-effort — never discard a usable pack over it.
if log:
log(f"Could not refresh latest pointer: {exc}")
latest = root
# PATH / env must target a pack with bin/cmake — unwrap nested zip layouts
# and prefer the real tag dir when latest is only a pointer.
usable = unwrap_pack_root(latest)
if not pack_root_looks_usable(usable):
usable = root
bin_dir = usable / "bin"
if not bin_looks_usable(bin_dir):
bin_dir = root / "bin"
if sys_platform_is_windows():
_register_user_path_windows(bin_dir, usable, log=log)
else:
_register_user_path_unix(cache_root, usable, log=log)
# Session PATH for this process (idempotent).
activate_toolchain_bin(bin_dir if bin_looks_usable(bin_dir) else root / "bin", log=None)
if log:
log(f"Registered toolchain user PATH via {bin_dir}")
return usable
def unpack_zip_to(zip_path: Path, dest: Path) -> Path:
"""Extract *zip_path* into *dest* (replaced) and return usable pack root."""
if dest.exists():
shutil.rmtree(dest, ignore_errors=True)
dest.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(zip_path, "r") as zf:
zf.extractall(dest)
# zipfile.extractall() discards the Unix mode held in external_attr, so
# on POSIX hosts every binary lands 0644 and the pack is unusable:
# "Toolchain cmake probe failed ... [Errno 13] Permission denied".
# Restore the recorded mode (zips built on Windows record none, in
# which case the extracted default is already correct).
if os.name != "nt":
for info in zf.infolist():
if info.is_dir():
continue
mode = (info.external_attr >> 16) & 0o7777
if mode:
target = dest / info.filename
if target.exists():
target.chmod(mode)
root = unwrap_pack_root(dest)
if not pack_root_looks_usable(root):
raise RuntimeError(f"toolchain zip missing bin/{cmake_name()}: {zip_path}")
# If unwrap pointed at a child, normalize to dest by moving contents up when
# dest itself is not the pack root.
if root != dest and pack_root_looks_usable(root):
# Leave nested layout — resolve/activate handle it via unwrap.
return root
return root
def _install_tag_for_root(root: Path, fallback: str) -> str:
ver = read_pack_version(root)
if ver:
safe = re.sub(r"[^\w.\-]+", "_", ver.strip())
return safe or fallback
return fallback
def install_from_zip(
zip_path: Path,
tag: str = "offline",
*,
project_root: Optional[Path] = None,
min_version: str = "",
log=None,
) -> Path:
zip_path = zip_path.expanduser().resolve()
if not zip_path.is_file():
raise FileNotFoundError(f"toolchain zip not found: {zip_path}")
staging = preferred_install_root() / ".staging-offline"
root = unpack_zip_to(zip_path, staging)
if not pack_satisfies_min(root, min_version):
need = min_version or default_min_version()
have = read_pack_version(root) or "(unknown)"
raise RuntimeError(
f"Toolchain zip version {have} does not meet min_version {need}."
)
dest_tag = _install_tag_for_root(root, tag)
dest = preferred_install_root() / dest_tag
if dest.resolve() != root.resolve():
if dest.exists():
shutil.rmtree(dest, ignore_errors=True)
shutil.move(str(root), str(dest))
root = unwrap_pack_root(dest)
# Drop empty staging parent left behind by a nested unzip layout.
if staging.exists() and staging != dest:
shutil.rmtree(staging, ignore_errors=True)
# latest pointer + idempotent user login PATH (same as zip install.sh).
register_toolchain_user_env(root, log=log)
# Drop prior versioned <tag>/ installs now that the new pack is active.
prune_old_toolchain_tags(root, log=log)
if project_root is not None:
return materialize_into_project(project_root, root, log=log)
return root
def download_url(url: str, dest: Path, token: Optional[str] = None) -> None:
# Lazy: keep urllib off the import path so `python tools/...` still works
# when ambient PYTHONHOME breaks _socket (launcher clears PYTHONHOME on
# spawn).
import urllib.request
dest.parent.mkdir(parents=True, exist_ok=True)
req = urllib.request.Request(url, headers={"User-Agent": "psxrecomp-toolchain"})
if token:
req.add_header("Authorization", f"Bearer {token}")
with urllib.request.urlopen(req, timeout=120) as resp, open(dest, "wb") as out:
shutil.copyfileobj(resp, out)
def download_latest_pack(
artifact: Optional[str] = None,
repo: str = DEFAULT_REPO,
log=None,
*,
project_root: Optional[Path] = None,
min_version: str = "",
) -> Path:
import urllib.error
art = artifact or host_artifact()
asset = _ASSET.get(art)
if not asset:
raise RuntimeError(f"unknown toolchain artifact: {art}")
token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") or ""
url = f"https://github.com/{repo}/releases/latest/download/{asset}"
if log:
log(f"Downloading {asset} from {repo}...")
with tempfile.TemporaryDirectory(prefix="psxrecomp-tc-") as tmp:
zpath = Path(tmp) / asset
try:
download_url(url, zpath, token=token or None)
except urllib.error.HTTPError as e:
raise RuntimeError(f"toolchain download failed ({e.code}): {url}") from e
except urllib.error.URLError as e:
raise RuntimeError(f"toolchain download failed: {e.reason}") from e
# Tag fallback is "offline" — never install *into* latest/; latest is
# pointer-only (symlink/copy) matching retcomm-toolchains install.sh.
return install_from_zip(
zpath,
tag="offline",
project_root=project_root,
min_version=min_version,
log=log,
)
def ensure_toolchain(
project_root: Optional[Path] = None,
*,
from_zip: Optional[Path] = None,
download: bool = False,
repo: str = DEFAULT_REPO,
min_version: str = "",
log=None,
) -> Path:
"""Return usable toolchain *bin* directory, installing if requested.
Resolution order: env override → project stamp/toolchain/ → shared
retcomm cache (legacy psxrecomp migrated) → optional --from-zip → download.
Installs always land under the shared Retro cache; the project only gets
a stamp file pointing at that pack.
"""
need = min_version or default_min_version()
if is_windows_store_python() and log:
log(
"Warning: Microsoft Store Python redirects AppData writes. "
"Prefer python.org Python if toolchain setup fails to find cmake."
)
migrate_legacy_psxrecomp_cache(log=log)
if from_zip is not None:
heal_broken_toolchain_pointers(log=log)
clear_project_toolchain_stamp(project_root)
root = install_from_zip(
Path(from_zip),
project_root=project_root,
min_version=need,
log=log,
)
bin_dir = unwrap_pack_root(root) / "bin"
if not bin_looks_usable(bin_dir):
bin_dir = root / "bin"
if not toolchain_bin_runs(bin_dir, log=log):
raise RuntimeError(f"Toolchain zip installed but cmake is unusable: {bin_dir}")
if project_root is not None:
write_toolchain_stamp(project_root, bin_dir)
activate_toolchain_bin(bin_dir, log=log)
return bin_dir
existing = resolve_toolchain_bin(project_root, min_version=need)
if existing and toolchain_bin_runs(existing, log=log):
if project_root is not None:
write_toolchain_stamp(project_root, existing)
# Cached pack: still publish latest + user PATH (idempotent).
# PATH register must not fail ensure (e.g. Store Python AppData quirks).
try:
register_toolchain_user_env(existing.parent, log=log)
except Exception as exc: # noqa: BLE001 — best-effort side effect
if log:
log(f"PATH register skipped: {exc}")
activate_toolchain_bin(existing, log=log)
return existing
if existing and log:
log(
f"Cached toolchain at {existing} is missing or broken; "
"will repair."
)
# Usable pack exists but is too old — fall through to download/replace.
# min_version="0" probes without the default floor ("" would re-apply it).
stale = resolve_toolchain_bin(project_root, min_version="0")
if stale and need and not existing and log:
log(
f"Cached toolchain does not meet min_version {need}; "
"will download/replace."
)
# Drop bad latest/ pointers and project stamps before reinstall.
heal_broken_toolchain_pointers(log=log)
clear_project_toolchain_stamp(project_root)
if download:
if log:
log("Downloading portable cmake/clang toolchain…")
root = download_latest_pack(
repo=repo, log=log, project_root=project_root, min_version=need
)
bin_dir = unwrap_pack_root(root) / "bin"
if not bin_looks_usable(bin_dir):
bin_dir = root / "bin"
if not toolchain_bin_runs(bin_dir, log=log):
raise RuntimeError(
f"Downloaded toolchain but cmake is unusable: {bin_dir}"
)
if project_root is not None:
write_toolchain_stamp(project_root, bin_dir)
activate_toolchain_bin(bin_dir, log=log)
return bin_dir
raise RuntimeError(
"No portable toolchain found. Pass --from-zip PATH to a "
"cmake-clang-v1-*.zip, use --download, set RETCOMM_TOOLCHAIN_DIR / "
"PSXRECOMP_TOOLCHAIN_DIR, or install cmake on PATH."
+ (f" (required min_version {need})" if need else "")
)