Init
This commit is contained in:
322
ptlinux/launcher.py
Normal file
322
ptlinux/launcher.py
Normal 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)
|
||||
Reference in New Issue
Block a user