323 lines
9.0 KiB
Python
323 lines
9.0 KiB
Python
"""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)
|