Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions packaging/flatpak/files/io.github.PTCGLLinux.metainfo.xml
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,14 @@
<url type="bugtracker">https://github.com/mattmcbeardface/ptcgl-linux/issues</url>

<releases>
<release version="0.1.3" date="2026-09-29">
<release version="0.1.4" date="2026-10-08">
<description>
<p>Metadata update.</p>
<p>Installer and automatic update compatibility fixes.</p>
<ul>
<li>Update the displayed developer name.</li>
<li>Fix the working directory used by the game's built-in updater.</li>
<li>Support updated official Pokémon installers without hardcoded MSI checksums.</li>
<li>Verify installer signatures and publisher identity before installation.</li>
<li>Improve download validation, interrupted-download handling, and installer cache safety.</li>
</ul>
</description>
</release>
Expand Down
12 changes: 12 additions & 0 deletions packaging/flatpak/io.github.PTCGLLinux.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,18 @@ modules:
url: https://github.com/Open-Wine-Components/umu-launcher/releases/download/1.4.4/umu-launcher-1.4.4-zipapp.tar
sha256: eb590691841f7fad3fc3ad8fd5db4ccb87849fe7948e62b28ece7a4ee48cc851

- name: osslsigncode
buildsystem: cmake-ninja
config-opts:
- -DCMAKE_BUILD_TYPE=Release
- -DCMAKE_INSTALL_PREFIX=/app
- -DCMAKE_INSTALL_DATADIR=share
- -DBASH_COMPLETION_USER_DIR=/app/share
sources:
- type: archive
url: https://github.com/mtrojnar/osslsigncode/archive/refs/tags/2.14.tar.gz
sha256: 0f033fd6069387d2e489fbd2187e62f624764eb8c2758ee94e3e793e5150b5c5

- name: ptcgl-linux
buildsystem: simple

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "ptcgl-linux"
version = "0.1.3"
version = "0.1.4"
description = "Linux launcher and compatibility environment for Pokémon TCG Live"
requires-python = ">=3.11"
authors = [
Expand Down
2 changes: 1 addition & 1 deletion src/ptcgl_linux/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""ptcgl-linux."""

__version__ = "0.1.3"
__version__ = "0.1.4"
144 changes: 125 additions & 19 deletions src/ptcgl_linux/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@

from __future__ import annotations

import fcntl
import os
import subprocess
import tempfile
import urllib.error
import urllib.request
from contextlib import contextmanager
from pathlib import Path

from .artifacts import ArtifactError, download_verified, verify_sha256
from .paths import cache_home
from .installer_security import SignatureError, verify_pokemon_installer
from .proton import install_proton
from .runtime import RuntimePaths, runtime_paths, vc_runtime_paths
from .umu import install_umu
Expand All @@ -18,10 +23,7 @@
"https://installer.studio-prod.pokemon.com/installer/"
"PokemonTCGLiveInstaller.msi"
)
PTCGL_INSTALLER_SHA256 = (
"cf970aded232fffc52263932bb0dc5627"
"642a8f489c73485f3d7b5d0c1e5127b"
)
PTCGL_INSTALLER_MAX_BYTES = 1024 * 1024 * 1024


class InstallError(RuntimeError):
Expand Down Expand Up @@ -163,38 +165,142 @@ def install_vcrun2019(paths: RuntimePaths) -> None:
)


@contextmanager
def _installer_cache_lock(cache: Path):
"""Serialize installer acquisition across processes."""

lock_path = cache / ".pokemon-installer.lock"

with lock_path.open("a+b") as lock:
os.chmod(lock_path, 0o600)
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)

try:
yield
finally:
fcntl.flock(lock.fileno(), fcntl.LOCK_UN)


class _RejectRedirects(urllib.request.HTTPRedirectHandler):
"""Never follow an installer download to another URL."""

def redirect_request(self, req, fp, code, msg, headers, newurl):
raise InstallError("official installer endpoint redirected")


def acquire_ptcgl_installer(
*,
artifact_cache: Path | None = None,
) -> Path:
"""Acquire the pinned official Pokémon TCG Live MSI."""
"""Download and authenticate the current official Pokémon MSI."""

if artifact_cache is None:
artifact_cache = cache_home() / "artifacts"

artifact_cache.mkdir(parents=True, exist_ok=True)
os.chmod(artifact_cache, 0o700)

installer = artifact_cache / PTCGL_INSTALLER_NAME
with _installer_cache_lock(artifact_cache):
return _acquire_ptcgl_installer_locked(artifact_cache)

if installer.exists():
if verify_sha256(installer, PTCGL_INSTALLER_SHA256):
os.chmod(installer, 0o600)
return installer

installer.unlink()
def _acquire_ptcgl_installer_locked(
artifact_cache: Path,
) -> Path:
"""Acquire and publish the MSI while holding the cache lock."""

installer = artifact_cache / PTCGL_INSTALLER_NAME
temporary: Path | None = None

try:
return download_verified(
PTCGL_INSTALLER_URL,
installer,
PTCGL_INSTALLER_SHA256,
)
except ArtifactError as exc:
# Only the configured HTTPS endpoint is permitted.
if PTCGL_INSTALLER_URL != (
"https://installer.studio-prod.pokemon.com/installer/"
"PokemonTCGLiveInstaller.msi"
):
raise InstallError("invalid official installer endpoint")

opener = urllib.request.build_opener(_RejectRedirects())

with tempfile.NamedTemporaryFile(
mode="wb",
dir=artifact_cache,
prefix=".pokemon-installer-",
suffix=".tmp",
delete=False,
) as target:
temporary = Path(target.name)
os.chmod(temporary, 0o600)

with opener.open(
PTCGL_INSTALLER_URL,
timeout=60,
) as response:
total = 0
content_length = response.headers.get("Content-Length")
expected = (
int(content_length)
if content_length and content_length.isdigit()
else None
)

if expected is not None and expected > PTCGL_INSTALLER_MAX_BYTES:
raise InstallError(
"official installer exceeds maximum size"
)

while True:
chunk = response.read(1024 * 1024)
if not chunk:
break

total += len(chunk)

if total > PTCGL_INSTALLER_MAX_BYTES:
raise InstallError(
"official installer exceeds maximum size"
)

target.write(chunk)

if expected is not None and total != expected:
raise InstallError(
"official installer download is incomplete"
)

target.flush()
os.fsync(target.fileno())

if total < 65536:
raise InstallError("official installer is unexpectedly small")

# MSI uses the OLE Compound File binary container.
with temporary.open("rb") as stream:
if stream.read(8) != bytes.fromhex("D0CF11E0A1B11AE1"):
raise InstallError("download is not a valid MSI container")

verify_pokemon_installer(temporary)

# Publish only after every verification succeeds.
os.replace(temporary, installer)
temporary = None
os.chmod(installer, 0o600)

return installer

except (
urllib.error.URLError,
OSError,
SignatureError,
) as exc:
raise InstallError(
"unable to acquire Pokémon TCG Live installer"
f"unable to acquire verified Pokémon installer: {exc}"
) from exc

finally:
if temporary is not None:
temporary.unlink(missing_ok=True)


def install_game(
paths: RuntimePaths,
Expand Down
89 changes: 89 additions & 0 deletions src/ptcgl_linux/installer_security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Verify the authenticity of Pokémon TCG Live installers."""

from __future__ import annotations

import re
import shutil
import subprocess
from pathlib import Path


class SignatureError(RuntimeError):
"""Installer authenticity could not be established."""


EXPECTED_PUBLISHER = r"The Pokemon Company International\, Inc."


def verify_pokemon_installer(path: Path) -> None:
"""Require a trusted Authenticode signature from Pokémon's publisher."""

verifier = shutil.which("osslsigncode")

if verifier is None:
raise SignatureError(
"osslsigncode is unavailable; cannot authenticate installer"
)

try:
result = subprocess.run(
[verifier, "verify", "-in", str(path)],
capture_output=True,
text=True,
timeout=120,
check=False,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise SignatureError(
"installer signature verification could not complete"
) from exc

output = result.stdout + "\n" + result.stderr

if result.returncode != 0:
raise SignatureError(
"installer signature or certificate validation failed"
)

required = (
"Signature verification: ok",
"Signature CRL verification: ok",
"Timestamp Server Signature verification: ok",
"Timestamp Server Signature CRL verification: ok",
"Number of verified signatures: 1",
"Succeeded",
)

if not all(item in output for item in required):
raise SignatureError(
"installer verification did not confirm all security checks"
)

# Read the primary signer's certificate, not the chain certificates
# or the timestamp authority's certificate.
match = re.search(
r"^Signer's certificate:\s*\n"
r"\s*-+\s*\n"
r"\s*Signer #0:\s*\n"
r"\s*Subject:\s*(.+)$",
output,
re.MULTILINE,
)

if match is None:
raise SignatureError("installer signer identity is unavailable")

subject = match.group(1).strip()

# Certificate DN commas may be escaped. Match complete CN/O fields,
# not arbitrary text elsewhere in the verification output.
fields = re.split(r"(?<!\\),", subject)
fields = [field.strip() for field in fields]

expected_cn = "CN=" + EXPECTED_PUBLISHER
expected_org = "O=" + EXPECTED_PUBLISHER

if expected_cn not in fields or expected_org not in fields:
raise SignatureError(
"installer is not signed by the expected Pokémon publisher"
)
1 change: 1 addition & 0 deletions src/ptcgl_linux/launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ def launch_game(
str(paths.game),
],
env=env,
cwd=paths.game.parent,
check=False,
)
except OSError as exc:
Expand Down
47 changes: 47 additions & 0 deletions tests/test_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,53 @@ def create_prefix(paths: RuntimePaths) -> None:


class InstallerTests(unittest.TestCase):
def test_acquire_accepts_changed_official_installer(self) -> None:
from ptcgl_linux.installer import acquire_ptcgl_installer

class FakeResponse:
def __init__(self, payload):
self.payload = payload
self.position = 0
self.headers = {"Content-Length": str(len(payload))}

def __enter__(self):
return self

def __exit__(self, *args):
return False

def read(self, size):
chunk = self.payload[self.position:self.position + size]
self.position += len(chunk)
return chunk

magic = bytes.fromhex("D0CF11E0A1B11AE1")
first = magic + b"A" * 65536
second = magic + b"B" * 65536

with tempfile.TemporaryDirectory() as tmp:
cache = Path(tmp)

for payload in (first, second):
with (
patch(
"ptcgl_linux.installer.urllib.request.build_opener"
) as build_opener,
patch(
"ptcgl_linux.installer.verify_pokemon_installer"
) as verify,
):
build_opener.return_value.open.return_value = (
FakeResponse(payload)
)

result = acquire_ptcgl_installer(
artifact_cache=cache,
)

verify.assert_called_once()
self.assertEqual(result.read_bytes(), payload)

def test_initialize_prefix_uses_umu(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
paths = make_paths(Path(tmp))
Expand Down
Loading
Loading