"""Wine launcher preparation and exact-profile WPF compatibility patching.""" from __future__ import annotations from dataclasses import dataclass from datetime import datetime import hashlib import json import os from pathlib import Path import shutil from typing import Iterable from .command import CommandOutcome, CommandRunner from .paths import AppPaths KNOWN_PRISTINE_LAUNCHER_SHA256 = ( "64e46469d7de675e52741cabf71a8c4b7852769b96bd59ef9a15032b3d61c4d7" ) KNOWN_PATCHED_LAUNCHER_SHA256 = ( "aa18a8d74b1c5a644251babe15ae6828a221bdb65f970cdef275276ca750f1e8" ) KNOWN_COMPLETE_PATCHED_LAUNCHER_SHA256 = ( "fb89f2e8a7f68775c4c1a09ea7f88f1799b8f36355985d26b30f56701d11f677" ) LAUNCHER_PREFIX_CONFIGURATION_VERSION = 1 _NEWS_FEED_FONT_OFFSET = 269118 _PATCHES = ( (b"/Resources/Fonts/#FontAwesome", b"FontAwesome,Lato,Arial,Tahoma", 1), (b"/Resources/Fonts/#Lato Regular", b"Lato Regular,Lato,Arial,Tahoma", 1), ) class LauncherError(RuntimeError): """Raised when the launcher cannot be safely prepared.""" @dataclass(frozen=True) class WineTools: wine: Path wineboot: Path winetricks: Path @dataclass(frozen=True) class LauncherPatchResult: source: str destination: str source_sha256: str destination_sha256: str patched: bool supported_profile: bool replacements: dict[str, int] def find_wine_tools() -> WineTools | None: wine_candidates = ( Path("/opt/wine-stable/bin/wine"), Path("/opt/wine-staging/bin/wine"), Path("/opt/wine/bin/wine"), Path("/usr/local/bin/wine"), Path("/usr/bin/wine"), Path("/usr/bin/wine64"), ) wine = next((path for path in wine_candidates if path.is_file()), None) if wine is None: located = shutil.which("wine") or shutil.which("wine64") wine = Path(located) if located else None if wine is None: return None wineboot = wine.with_name("wineboot") if not wineboot.is_file(): located_boot = shutil.which("wineboot") if not located_boot: return None wineboot = Path(located_boot) located_winetricks = shutil.which("winetricks") if not located_winetricks: return None return WineTools( wine=wine.resolve(), # Preserve the wineboot basename when it is a dispatcher symlink to Wine. wineboot=wineboot.absolute(), winetricks=Path(located_winetricks).resolve(), ) def prepare_launcher_binary(source: Path, destination: Path) -> LauncherPatchResult: """Patch only the exact known launcher profile; retain unknown builds intact.""" source_hash = _sha256(source) data = source.read_bytes() replacements: dict[str, int] = {} supported = source_hash == KNOWN_PRISTINE_LAUNCHER_SHA256 if supported: for old, new, expected_count in _PATCHES: if len(old) != len(new): raise AssertionError("launcher replacement lengths must match") actual_count = data.count(old) if actual_count != expected_count: raise LauncherError( f"known launcher profile changed for {old!r}: " f"expected {expected_count}, found {actual_count}" ) data = data.replace(old, new) replacements[old.decode("ascii")] = actual_count intermediate_hash = hashlib.sha256(data).hexdigest() if intermediate_hash != KNOWN_PATCHED_LAUNCHER_SHA256: raise LauncherError( "known launcher font fallback patch produced an unexpected SHA-256: " f"{intermediate_hash}" ) news_old = b"FontAwesome#" news_new = b"LatoRegular#" news_end = _NEWS_FEED_FONT_OFFSET + len(news_old) if data[_NEWS_FEED_FONT_OFFSET:news_end] != news_old: raise LauncherError("known NewsFeed font offset no longer matches") data = ( data[:_NEWS_FEED_FONT_OFFSET] + news_new + data[news_end:] ) replacements[f"FontAwesome#@{_NEWS_FEED_FONT_OFFSET}"] = 1 destination.parent.mkdir(parents=True, exist_ok=True) temporary = destination.with_name(f".{destination.name}.{os.getpid()}.tmp") try: temporary.write_bytes(data) shutil.copymode(source, temporary) destination_hash = _sha256(temporary) if supported and destination_hash != KNOWN_COMPLETE_PATCHED_LAUNCHER_SHA256: raise LauncherError( "known launcher patch produced an unexpected SHA-256: " f"{destination_hash}" ) temporary.replace(destination) except BaseException: temporary.unlink(missing_ok=True) raise return LauncherPatchResult( source=str(source), destination=str(destination), source_sha256=source_hash, destination_sha256=destination_hash, patched=supported, supported_profile=supported, replacements=replacements, ) def launcher_environment(paths: AppPaths, tools: WineTools) -> dict[str, str]: return { "WINEPREFIX": str(paths.launcher_prefix), "WINE": str(tools.wine), "WINEDEBUG": "-all", "WINEESYNC": "1", "DOTNET_SYSTEM_GLOBALIZATION_USENLS": "1", } def configure_launcher_prefix( paths: AppPaths, tools: WineTools, runner: CommandRunner, *, force_runtime: bool = False, ) -> list[CommandOutcome]: """Create the launcher prefix, install .NET Desktop 6, and apply WPF fixes.""" paths.create_base_directories() if not runner.dry_run: paths.launcher_prefix.mkdir(parents=True, exist_ok=True) environment = launcher_environment(paths, tools) marker = _read_json(paths.launcher_prefix_marker) if not force_runtime and _launcher_prefix_ready(paths, tools, marker): if not runner.dry_run and "configuration_version" not in marker: _write_json( paths.launcher_prefix_marker, { **marker, "configuration_version": LAUNCHER_PREFIX_CONFIGURATION_VERSION, }, ) return [] results = [ runner.run( [str(tools.wineboot), "-u"], env_overrides={**environment, "WINEARCH": "win64"}, timeout_seconds=300.0, ) ] if force_runtime or not _dotnet_desktop_ready(paths, marker): results.append( runner.run( [str(tools.winetricks), "-q", "dotnetdesktop6"], env_overrides=environment, timeout_seconds=1800.0, ) ) registry_commands: Iterable[list[str]] = ( [ str(tools.wine), "reg", "add", r"HKCU\Software\Microsoft\Avalon.Graphics", "/v", "DisableHWAcceleration", "/t", "REG_DWORD", "/d", "1", "/f", ], [ str(tools.wine), "reg", "add", r"HKCU\Software\Microsoft\Avalon.Graphics", "/v", "MaxMultisampleType", "/t", "REG_DWORD", "/d", "0", "/f", ], ) for command in registry_commands: results.append( runner.run(command, env_overrides=environment, timeout_seconds=60.0) ) for requested, replacement in ( ("Segoe UI", "Carlito"), ("Segoe UI Regular", "Carlito"), ("Segoe UI Light", "Carlito"), ("Segoe UI Semilight", "Carlito"), ("Segoe UI Semibold", "Carlito"), ("Segoe UI Bold", "Carlito"), ("Segoe UI Black", "Carlito"), ("Segoe UI Symbol", "Noto Sans Symbols 2"), ("Lato Regular", "Lato"), ): results.append( runner.run( [ str(tools.wine), "reg", "add", r"HKLM\Software\Microsoft\Windows NT\CurrentVersion\FontSubstitutes", "/v", requested, "/t", "REG_SZ", "/d", replacement, "/f", ], env_overrides=environment, timeout_seconds=60.0, ) ) if not runner.dry_run: _write_json( paths.launcher_prefix_marker, { "configuration_version": LAUNCHER_PREFIX_CONFIGURATION_VERSION, "configured_at": datetime.now().astimezone().isoformat(), "dotnetdesktop6": True, "wine": str(tools.wine), }, ) return results def launch_official_updater( paths: AppPaths, tools: WineTools, runner: CommandRunner, ) -> CommandOutcome: if not paths.launcher_patched.is_file() and not runner.dry_run: raise LauncherError("prepared launcher is missing; run fetch first") return runner.run( [str(tools.wine), str(paths.launcher_patched)], cwd=paths.launcher_patched.parent, env_overrides=launcher_environment(paths, tools), timeout_seconds=4 * 60 * 60, ) def _sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def _read_json(path: Path) -> dict[str, object]: try: value = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return {} return value if isinstance(value, dict) else {} def _dotnet_desktop_ready(paths: AppPaths, marker: dict[str, object]) -> bool: if not bool(marker.get("dotnetdesktop6")): return False desktop_root = ( paths.launcher_prefix / "drive_c/Program Files/dotnet/shared/Microsoft.WindowsDesktop.App" ) try: return any(path.is_dir() and path.name.startswith("6.") for path in desktop_root.iterdir()) except OSError: return False def _launcher_prefix_ready( paths: AppPaths, tools: WineTools, marker: dict[str, object], ) -> bool: configuration_version = marker.get( "configuration_version", 1, ) return ( configuration_version == LAUNCHER_PREFIX_CONFIGURATION_VERSION and marker.get("wine") == str(tools.wine) and _dotnet_desktop_ready(paths, marker) ) def _write_json(path: Path, value: object) -> None: path.parent.mkdir(parents=True, exist_ok=True) temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8") temporary.replace(path)