# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
from __future__ import annotations
import json
import logging
import os
from pathlib import Path
from typing import ClassVar
from extra_platforms import LINUX_LIKE
from ..capabilities import version_not_implemented
from ..manager import PackageManager
from ..version import parse_version
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Iterator
from ..package import Package
MARKER = ".protonplus"
"""The record ProtonPlus writes into the directory of every release it installs.
A JSON object naming the launcher, the runner and the release tag. ProtonPlus
builds its own inventory from these files (`src/utils/metadata.vala`), and
defaults a missing field to an empty string.
"""
GROUP_DIRECTORIES = (
"compatibilitytools.d",
"dxvk",
"runners",
"runners/wine",
"runtime/dxvk",
"runtime/vkd3d",
"Runners",
"tools/proton",
"tools/wine",
)
"""Where a launcher keeps its runners, relative to the launcher's own directory.
Mirrors `get_group_directory()` in `src/models/launcher.vala` of ProtonPlus
`0.6.8`: `compatibilitytools.d` for Steam and Faugus Launcher, `runners/wine`,
`runtime/dxvk` and `runtime/vkd3d` for Lutris, `tools/proton` and `tools/wine`
for Heroic Games Launcher, `runners` and `dxvk` for Bottles, and `Runners` for
WineZGUI.
"""
def _launcher_directories() -> tuple[Path, ...]:
"""Every directory ProtonPlus looks for a launcher in.
Mirrors the launcher definitions under `src/models/launchers/` of ProtonPlus
`0.6.8`, native, Flatpak and Snap installations alike, with the XDG base
directories resolved the way GLib resolves them. A launcher ProtonPlus
learns later stays out of the inventory until this list learns it too.
"""
home = Path.home()
data = Path(os.environ.get("XDG_DATA_HOME") or home / ".local" / "share")
config = Path(os.environ.get("XDG_CONFIG_HOME") or home / ".config")
state = Path(os.environ.get("XDG_STATE_HOME") or home / ".local" / "state")
flatpak = home / ".var" / "app"
faugus = flatpak / "io.github.Faugus.faugus-launcher"
return (
# Steam.
data / "Steam",
home / ".local" / "share" / "Steam",
home / ".steam" / "steam",
home / ".steam" / "root",
home / ".steam" / "debian-installation",
flatpak / "com.valvesoftware.Steam" / "data" / "Steam",
home / "snap" / "steam" / "common" / ".steam" / "root",
# Lutris.
data / "lutris",
home / ".local" / "share" / "lutris",
flatpak / "net.lutris.Lutris" / "data" / "lutris",
# Heroic Games Launcher.
config / "heroic",
home / ".config" / "heroic",
flatpak / "com.heroicgameslauncher.hgl" / "config" / "heroic",
# Bottles.
data / "bottles",
home / ".local" / "share" / "bottles",
flatpak / "com.usebottles.bottles" / "data" / "bottles",
# Faugus Launcher.
config / "faugus-launcher",
data / "faugus-launcher",
state / "faugus-launcher",
home / ".config" / "faugus-launcher",
home / ".local" / "share" / "faugus-launcher",
faugus / "config" / "faugus-launcher",
faugus / "data" / "faugus-launcher",
# WineZGUI.
data / "winezgui",
home / ".local" / "share" / "winezgui",
flatpak / "io.github.fastrizwaan.WineZGUI" / "data" / "winezgui",
)
[docs]
class ProtonPlus(PackageManager):
"""ProtonPlus, installing Proton, Wine, DXVK and VKD3D builds into game launchers.
A package is one runner on one launcher, identified as
`<launcher_id>/<runner_id>`: `lutris-system/dxvk-doitsujin` is DXVK
(doitsujin) as the natively installed Lutris sees it. `protonplus list` names
the launchers ProtonPlus detects. ProtonPlus has no catalog command, but
`protonplus install <launcher_id> <runner_id>` answers an unknown runner ID
with the list of the ones that launcher accepts.
The inventory reads the `.protonplus` record ProtonPlus writes into every
release it installs, which is also what ProtonPlus builds its own listing
from. `protonplus list <launcher_id>` prints directory names only, with
neither runner nor version, and mixes in the launcher's own directories. The
records are looked for in the launcher directories ProtonPlus checks as of
`0.6.8`, which is an implementation detail rather than a documented contract:
a release missing from the inventory after a ProtonPlus upgrade points there
first. A runner kept at several releases is listed once, at its newest.
`install` fetches the newest release into a rolling `<runner title> Latest`
directory, the one release that `upgrade` moves forward. A release installed
at a chosen version from ProtonPlus itself is listed too, but `upgrade --all`
passes it over, and upgrading a runner that holds no rolling release fails
with ProtonPlus's own `This compatibility tool is not installed.` error: run
`mpm install` on it first. `remove` deletes every release of the runner on
that launcher.
`mpm` runs the `protonplus` command. The AppImage build carries a longer file
name: link it as `protonplus` into a directory on `PATH`.
"""
# `outdated`: ProtonPlus compares releases only inside `update`, which applies
# the newer one at once. `search`: the only runner listing is the error an
# unknown runner ID triggers, not a command to build an operation on.
operation_notes: ClassVar = {
"outdated": "ProtonPlus compares releases only while `update` applies them.",
"search": "ProtonPlus has no catalog, only an error listing the runner IDs.",
}
name = "ProtonPlus"
repository_url = "https://github.com/Vysp3r/ProtonPlus"
platforms = LINUX_LIKE
requirement = ">=0.6.0"
"""The first release writing `.protonplus` records that name the launcher and the
runner, and taking the `latest` and `all` arguments of `install`, `uninstall`
and `update`."""
version_cli_options = ("version",)
version_regexes = (r"ProtonPlus\s+(?P<version>\S+)",)
"""
```{code-block} shell-session
$ protonplus version
ProtonPlus 0.6.8
```
"""
@staticmethod
def _split(package_id: str) -> tuple[str, str]:
"""Split a package ID into the launcher and runner IDs ProtonPlus takes."""
launcher_id, _, runner_id = package_id.partition("/")
return launcher_id, runner_id
@staticmethod
def _records() -> Iterator[dict[str, str]]:
"""Yield every `.protonplus` record naming both its launcher and runner.
A launcher reached through a symlink, like `~/.steam/root` pointing into
`~/.local/share/Steam`, is read once.
"""
seen: set[Path] = set()
for base in _launcher_directories():
for group in GROUP_DIRECTORIES:
for marker in sorted((base / group).glob(f"*/{MARKER}")):
resolved = marker.resolve()
if resolved in seen:
continue
seen.add(resolved)
try:
record = json.loads(marker.read_text(encoding="UTF-8"))
except (OSError, ValueError) as ex:
logging.debug(f"Skip unreadable {marker}: {ex}")
continue
if not (
isinstance(record, dict)
and record.get("launcher_id")
and record.get("provider_id")
):
logging.debug(f"Skip {marker}: it names no launcher or runner.")
continue
yield record
@property
def installed(self) -> Iterator[Package]:
"""Fetch installed packages.
Each release carries its record, like this one ProtonPlus wrote for the
rolling DXVK release of Lutris, in
`~/.local/share/lutris/runtime/dxvk/DXVK (doitsujin) Latest/.protonplus`:
```{code-block} json
{"runner_endpoint":"https://api.github.com/repos/doitsujin/dxvk/releases","runner_title":"DXVK (doitsujin)","tag":"v3.1.1","provider_id":"dxvk-doitsujin","tool_id":"lutris-system/dxvk/dxvk-doitsujin","launcher_id":"lutris-system","variant_id":"standard","release_id":"381956359"}
```
A record naming no launcher or no runner is skipped, since no command
could address its release.
```{todo}
Read the inventory from `protonplus list` and drop the record scan, with
{data}`GROUP_DIRECTORIES` and the launcher directory table, once
[Vysp3r/ProtonPlus#1313](https://github.com/Vysp3r/ProtonPlus/issues/1313)
makes `list` print the runner ID and tag of each release.
```
"""
tags: dict[str, list[str]] = {}
for record in self._records():
package_id = f"{record['launcher_id']}/{record['provider_id']}"
found = tags.setdefault(package_id, [])
if record.get("tag"):
found.append(record["tag"])
for package_id, found in sorted(tags.items()):
newest = max(found, key=parse_version) if found else None
yield self.package(id=package_id, installed_version=newest)
[docs]
@version_not_implemented
def install(self, package_id: str, version: str | None = None) -> str:
"""Install one package.
`latest` fetches the newest release into the rolling `<runner title>
Latest` directory. Without it ProtonPlus lists the releases and waits for
a number on its standard input, so a pinned version is not supported.
The block below illustrates rather than captures: the package ID splits
into two arguments, so the corpus cannot rebuild this command from a
stand-in package id.
```{code-block} console
$ protonplus install lutris-system dxvk-doitsujin latest
```
"""
launcher_id, runner_id = self._split(package_id)
return self.run_cli("install", launcher_id, runner_id, "latest")
[docs]
def upgrade_all_cli(self) -> tuple[str, ...]:
"""Generate the CLI to upgrade the rolling release of every runner.
```{code-block} shell-session
$ protonplus update all
```
"""
return self.build_cli("update", "all")
[docs]
@version_not_implemented
def upgrade_one_cli(
self, package_id: str, version: str | None = None
) -> tuple[str, ...]:
"""Generate the CLI to upgrade the rolling release of one runner.
The block below illustrates rather than captures: the package ID splits
into two arguments, so the corpus cannot rebuild this command from a
stand-in package id.
```{code-block} console
$ protonplus update lutris-system dxvk-doitsujin
```
"""
launcher_id, runner_id = self._split(package_id)
return self.build_cli("update", launcher_id, runner_id)
[docs]
def remove(self, package_id: str) -> str:
"""Remove every release of one runner from its launcher.
The block below illustrates rather than captures: the package ID splits
into two arguments, so the corpus cannot rebuild this command from a
stand-in package id.
```{code-block} console
$ protonplus uninstall lutris-system dxvk-doitsujin all
```
"""
launcher_id, runner_id = self._split(package_id)
return self.run_cli("uninstall", launcher_id, runner_id, "all")