This commit is contained in:
2026-08-27 17:50:36 +02:00
commit 5a0e6520f4
51 changed files with 14027 additions and 0 deletions

322
ptlinux/launcher.py Normal file
View File

@@ -0,0 +1,322 @@
"""Wine launcher preparation and exact-profile WPF compatibility patching."""
from __future__ import annotations
from dataclasses import asdict, 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 patch_result_dict(result: LauncherPatchResult) -> dict[str, object]:
return asdict(result)
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)