Fixed issues with encodings and wine / proton compatibilities.

This commit is contained in:
2026-08-30 00:08:41 +02:00
parent d1587b785a
commit bef8d04d72
13 changed files with 772 additions and 132 deletions

View File

@@ -85,7 +85,20 @@ class LauncherUpdateResult:
previous_release: str | None
archive: str | None
archive_sha256: str | None
restarted_required: bool
restart_required: bool
state_recorded: bool
warning: str | None
@dataclass(frozen=True)
class LauncherUpdateVerification:
current_version: str
release_version: str
manifest_url: str
archive_url: str
archive_size: int
archive_sha256: str
payload_files: int
@dataclass(frozen=True)
@@ -262,6 +275,7 @@ def _evaluate_launcher_update(
def install_launcher_update(
paths: AppPaths,
*,
current_version: str = __version__,
manifest_url: str | None = None,
public_key_path: Path | None = None,
environment: Mapping[str, str] | None = None,
@@ -270,6 +284,7 @@ def install_launcher_update(
"""Authenticate, stage, validate, and atomically activate one update."""
check = check_for_launcher_update(
current_version=current_version,
manifest_url=manifest_url,
public_key_path=public_key_path,
environment=environment,
@@ -284,7 +299,9 @@ def install_launcher_update(
previous_release=None,
archive=None,
archive_sha256=None,
restarted_required=False,
restart_required=False,
state_recorded=False,
warning=None,
)
if not check.supported:
raise LauncherUpdateError(check.reason)
@@ -299,20 +316,26 @@ def install_launcher_update(
)
release_directory = _stage_and_install_release(paths, archive, check.manifest)
previous = _activate_release(paths, release_directory)
_write_json(
paths.launcher_update_state,
{
"schema": 1,
"updated_at": datetime.now().astimezone().isoformat(),
"from_version": check.current_version,
"to_version": check.manifest.version,
"manifest_url": check.manifest_url,
"archive_url": check.manifest.archive_url,
"archive_sha256": check.manifest.archive_sha256,
"release_directory": str(release_directory),
"previous_release": str(previous) if previous else None,
},
)
state_warning = None
try:
_write_json(
paths.launcher_update_state,
{
"schema": 1,
"updated_at": datetime.now().astimezone().isoformat(),
"from_version": check.current_version,
"to_version": check.manifest.version,
"manifest_url": check.manifest_url,
"archive_url": check.manifest.archive_url,
"archive_sha256": check.manifest.archive_sha256,
"release_directory": str(release_directory),
"previous_release": str(previous) if previous else None,
},
)
except OSError as exc:
# Activation is the durable user-facing operation. A diagnostics-only
# state record must not strand the dashboard on the old release.
state_warning = f"launcher updated, but update history could not be saved: {exc}"
return LauncherUpdateResult(
changed=True,
current_version=check.current_version,
@@ -321,7 +344,57 @@ def install_launcher_update(
previous_release=str(previous) if previous else None,
archive=str(archive),
archive_sha256=check.manifest.archive_sha256,
restarted_required=True,
restart_required=True,
state_recorded=state_warning is None,
warning=state_warning,
)
def verify_launcher_update_channel(
*,
current_version: str = __version__,
manifest_url: str | None = None,
public_key_path: Path | None = None,
environment: Mapping[str, str] | None = None,
timeout_seconds: float = 60.0,
) -> LauncherUpdateVerification:
"""Authenticate, download, extract, and import a release without activating it."""
check = check_for_launcher_update(
current_version=current_version,
manifest_url=manifest_url,
public_key_path=public_key_path,
environment=environment,
timeout_seconds=min(timeout_seconds, 30.0),
)
with tempfile.TemporaryDirectory(prefix="ptlinux-update-verification-") as temporary:
root = Path(temporary)
archive = root / f"{UPDATE_ARCHIVE_PREFIX}{check.manifest.version}.tar.gz"
_download_archive(
check.manifest,
archive,
timeout_seconds=timeout_seconds,
)
staging = root / "release"
staging.mkdir(mode=0o700)
_extract_release_archive(archive, staging, check.manifest.version)
staged_version = _validate_release_directory(staging)
if staged_version != check.manifest.version:
raise LauncherUpdateError(
f"verified launcher version is {staged_version}, "
f"expected {check.manifest.version}"
)
_self_check_release(staging, check.manifest.version)
_validate_release_directory(staging)
payload_files = sum(1 for item in staging.rglob("*") if item.is_file())
return LauncherUpdateVerification(
current_version=current_version,
release_version=check.manifest.version,
manifest_url=check.manifest_url,
archive_url=check.manifest.archive_url,
archive_size=check.manifest.archive_size,
archive_sha256=check.manifest.archive_sha256,
payload_files=payload_files,
)
@@ -397,6 +470,7 @@ def parse_update_manifest(data: bytes) -> UpdateManifest:
f"update archive size must be between 1 and {MAX_ARCHIVE_BYTES} bytes"
)
published_at = _required_string(value, "published_at")
_parse_published_at(published_at)
notes_value = value.get("notes_url")
if notes_value is not None and not isinstance(notes_value, str):
raise LauncherUpdateError("notes_url must be a string or null")
@@ -428,6 +502,8 @@ def _fetch_small_file(
"User-Agent": USER_AGENT,
"Accept": "application/octet-stream, application/json",
"Accept-Encoding": "identity",
"Cache-Control": "no-cache",
"Pragma": "no-cache",
},
method="GET",
)
@@ -438,7 +514,13 @@ def _fetch_small_file(
except (URLError, OSError) as exc:
raise LauncherUpdateError(f"{label} request failed: {exc}") from exc
with response:
_require_https_url(response.geturl(), f"final {label} URL")
final_url = response.geturl()
_require_https_url(final_url, f"final {label} URL")
if _resource_url(final_url) != _resource_url(url):
raise LauncherUpdateError(
f"{label} redirected unexpectedly to {final_url}; "
"the launcher update channel must be publicly readable"
)
length = _content_length(response.headers.get("Content-Length"), label)
if length is not None and length > maximum_bytes:
raise LauncherUpdateError(f"{label} exceeds its size limit")
@@ -447,6 +529,8 @@ def _fetch_small_file(
raise LauncherUpdateError(f"{label} exceeds its size limit")
if length is not None and len(data) != length:
raise LauncherUpdateError(f"{label} response was incomplete")
if not data:
raise LauncherUpdateError(f"{label} response was empty")
return data
@@ -483,12 +567,14 @@ def _verify_manifest_signature(
],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=10,
check=False,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise LauncherUpdateError(f"could not verify update signature: {exc}") from exc
if result.returncode != 0 or "Verified OK" not in result.stdout:
if result.returncode != 0:
raise LauncherUpdateError("launcher update manifest signature is invalid")
@@ -585,6 +671,7 @@ def _stage_and_install_release(
f"staged launcher version is {staged_version}, expected {manifest.version}"
)
_self_check_release(staging, manifest.version)
_validate_release_directory(staging)
staging.chmod(0o755)
destination = paths.launcher_releases / manifest.version
if destination.exists() or destination.is_symlink():
@@ -739,9 +826,11 @@ def _self_check_release(release: Path, expected_version: str) -> None:
)
try:
result = subprocess.run(
[sys.executable, "-I", "-c", check, str(release), expected_version],
[sys.executable, "-I", "-B", "-c", check, str(release), expected_version],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=15,
check=False,
)
@@ -853,6 +942,13 @@ def _signature_url(manifest_url: str) -> str:
return urlunsplit((parts.scheme, parts.netloc, parts.path + ".sig", parts.query, ""))
def _resource_url(value: str) -> str:
parts = urlsplit(value)
return urlunsplit(
(parts.scheme.casefold(), parts.netloc.casefold(), parts.path, parts.query, "")
)
def _require_https_url(value: str, label: str) -> None:
parts = urlsplit(value)
if (
@@ -872,6 +968,18 @@ def _parse_version(value: str, label: str) -> tuple[int, int, int]:
return int(major), int(minor), int(patch)
def _parse_published_at(value: str) -> datetime:
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise LauncherUpdateError(
f"manifest published_at must be an ISO-8601 timestamp: {value!r}"
) from exc
if parsed.tzinfo is None or parsed.utcoffset() is None:
raise LauncherUpdateError("manifest published_at must include a timezone")
return parsed
def _required_string(value: dict[str, object], key: str) -> str:
item = value.get(key)
if not isinstance(item, str) or not item: