Merge branch 'feat/screenshots-native-input' into feat/screenshots-capture

# Conflicts:
#	src/shared/index.ts
This commit is contained in:
Moyasee 2026-10-29 20:11:18 +02:00
commit afe1a78499
195 changed files with 12533 additions and 1930 deletions

View file

@ -72,7 +72,9 @@ jobs:
- name: Install Linux native build prerequisites
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y build-essential cmake ninja-build pkg-config curl zip unzip tar autoconf automake libtool
run: |
rustup target add x86_64-pc-windows-gnu
sudo apt-get update && sudo apt-get install -y build-essential cmake ninja-build pkg-config curl zip unzip tar autoconf automake libtool gcc-mingw-w64-x86-64
- name: Install dependencies
run: yarn --frozen-lockfile

View file

@ -1,6 +1,6 @@
{
"name": "hydralauncher",
"version": "4.1.6",
"version": "4.3.0",
"description": "Hydra",
"main": "./out/main/index.js",
"author": "Los Broxas",

View file

@ -20,7 +20,15 @@ sys.dont_write_bytecode = True
PROTOCOL_VERSION = 1
LEGENDARY_VERSION = "0.21.1"
LEGENDARY_REQUEST_TIMEOUT_SECONDS = 15
CAPABILITIES = ["inspect-target", "install-target", "access-only", "refresh-session"]
CAPABILITIES = ["inspect-target", "install-target", "access-only", "refresh-session", "install-components"]
SDL_MAX_PACKS = 64
SDL_MAX_TAGS = 64
SDL_ID_MAX_LENGTH = 64
SDL_NAME_MAX_LENGTH = 128
INSTALL_TAG_MAX_LENGTH = 128
IDENTITY = re.compile(r"[A-Za-z0-9_.-]{1,256}")
SDL_ID = re.compile(r"[A-Za-z0-9_.-]{1,%d}" % SDL_ID_MAX_LENGTH)
INSTALL_TAG = re.compile(r"[A-Za-z0-9_.-]{0,%d}" % INSTALL_TAG_MAX_LENGTH)
class ManagedError(Exception):
@ -71,16 +79,25 @@ def bootstrap(archive):
raise ManagedError("runtime-mismatch")
def target_from_request(request):
target = request.get("target")
def target_from_request(request, key="target"):
target = request.get(key)
if not isinstance(target, dict) or target.get("platform") != "Windows":
raise ManagedError("unsupported-platform")
for name in ("artifactId", "namespace", "catalogItemId"):
if not isinstance(target.get(name), str) or not re.fullmatch(r"[A-Za-z0-9_.-]{1,256}", target[name]):
if not isinstance(target.get(name), str) or not IDENTITY.fullmatch(target[name]):
raise ManagedError("invalid-target")
return target
def base_from_request(request):
if request.get("base") is None:
return None
base = target_from_request(request, "base")
if base["artifactId"] == request["target"]["artifactId"] or (base["namespace"], base["catalogItemId"]) == (request["target"]["namespace"], request["target"]["catalogItemId"]):
raise ManagedError("invalid-target")
return base
def initialize_access(core):
# Never call login/resume_session: neither verification nor token rotation
# belongs to this child. The parent owns the refresh generation and lease.
@ -122,6 +139,7 @@ def error_code(error):
"external-launcher-required": "external-launcher-required",
"unsupported-platform": "not-installable",
"update-required": "update-required",
"base-not-installed": "installation-missing",
}.get(str(error), "invalid-response")
status = getattr(getattr(error, "response", None), "status_code", None)
if status in (401, 403):
@ -135,9 +153,13 @@ def error_code(error):
return "invalid-response"
def constrain_core(core, game, target, manifest):
def constrain_core(core, game, target, manifest, base_game=None):
def exact_game(app_name, *args, **kwargs):
if app_name != target["artifactId"] or kwargs.get("platform", "Windows") != "Windows":
if kwargs.get("platform", "Windows") != "Windows":
raise ManagedError("invalid-target")
if base_game is not None and app_name == base_game.app_name:
return base_game
if app_name != target["artifactId"]:
raise ManagedError("invalid-target")
return game
@ -159,17 +181,79 @@ def constrain_core(core, game, target, manifest):
core.get_cdn_manifest = exact_manifest
def validate_metadata(metadata, target):
def validate_metadata(metadata, target, base=None):
if not isinstance(metadata, dict) or metadata.get("namespace") != target["namespace"] or metadata.get("id") != target["catalogItemId"]:
raise ManagedError("invalid-metadata")
paths = [category.get("path", "") for category in metadata.get("categories", [])]
if target["namespace"] == "ue" or "mainGameItem" in metadata or not any(path == "games" or path.startswith("games/") for path in paths) or any(path == "addons" or path.startswith("addons/") for path in paths):
if base is not None:
validate_dlc_metadata(metadata, paths, base)
elif target["namespace"] == "ue" or "mainGameItem" in metadata or not any(path == "games" or path.startswith("games/") for path in paths) or any(path == "addons" or path.startswith("addons/") for path in paths):
raise ManagedError("unsupported-game")
attributes = metadata.get("customAttributes", {})
if any(attributes.get(name, {}).get("value") not in (None, "", "No", "false", False) for name in ("ThirdPartyManagedApp", "ThirdPartyManagedProvider", "partnerLinkType")):
raise ManagedError("external-launcher-required")
def validate_dlc_metadata(metadata, paths, base):
main = metadata.get("mainGameItem")
releases = main.get("releaseInfo") if isinstance(main, dict) else None
if not isinstance(main, dict) or main.get("id") != base["catalogItemId"] or main.get("namespace", base["namespace"]) != base["namespace"] or not isinstance(releases, list) or not releases or not isinstance(releases[0], dict) or releases[0].get("appId") != base["artifactId"]:
raise ManagedError("unsupported-game")
if base["namespace"] == "ue" or "addons" not in paths or "addons/launchable" in paths:
raise ManagedError("unsupported-game")
def sdl_definition(core, artifact):
from legendary.utils.selective_dl import get_sdl_appname, games
name = get_sdl_appname(artifact)
if name is None:
return None
try:
data = core.lgdapi.get_sdl_config(name)
except Exception:
data = None
if not valid_sdl(data):
data = games.get(name)
return data if valid_sdl(data) else None
def valid_sdl(data):
if not isinstance(data, dict) or not data or len(data) > SDL_MAX_PACKS + 1:
return False
for key, pack in data.items():
if not isinstance(key, str) or not SDL_ID.fullmatch(key) or not isinstance(pack, dict):
return False
tags, name = pack.get("tags"), pack.get("name")
if not isinstance(tags, list) or not tags or len(tags) > SDL_MAX_TAGS or not all(isinstance(tag, str) and INSTALL_TAG.fullmatch(tag) for tag in tags):
return False
if not isinstance(name, str) or not name.strip() or len(name) > SDL_NAME_MAX_LENGTH:
return False
return any(key != "__required" for key in data)
def selective_packs(definition, parsed):
required = set(definition.get("__required", {}).get("tags", []))
chunks = {chunk.guid_num: chunk.file_size for chunk in parsed.chunk_data_list.elements}
files = parsed.file_manifest_list.elements
base_files = [file for file in files if not file.install_tags or required.intersection(file.install_tags)]
optional_files = [file for file in files if file.install_tags and not required.intersection(file.install_tags)]
base_chunks = {part.guid_num for file in base_files for part in file.chunk_parts}
def sizes(selected):
used = {part.guid_num for file in selected for part in file.chunk_parts} - base_chunks
return sum(chunks.get(guid, 0) for guid in used), sum(file.file_size for file in selected)
base_download = sum(chunks.get(guid, 0) for guid in base_chunks)
packs = []
for key, pack in definition.items():
if key == "__required":
continue
tags = set(pack["tags"])
download, disk = sizes([file for file in optional_files if tags.intersection(file.install_tags)])
packs.append({"id": key, "name": pack["name"].strip(), "tags": list(pack["tags"]), "download_size": download, "disk_size": disk})
return {"required_tags": sorted(required), "download_size": base_download, "disk_size": sum(file.file_size for file in base_files), "packs": packs}
def receipt_path(path, workspace):
resolved = private_path(path).resolve()
root = path_identity(workspace.resolve())
@ -200,12 +284,13 @@ def inspect_target(core, request, workspace):
from legendary.models.game import Game, GameAsset
target = target_from_request(request)
base = base_from_request(request)
# Both calls concern one authorized item. No assets/library/achievement call.
with ThreadPoolExecutor(max_workers=2) as executor:
metadata_future = executor.submit(core.egs.get_game_info, target["namespace"], target["catalogItemId"])
descriptor_future = executor.submit(core.egs.get_game_manifest, target["namespace"], target["catalogItemId"], target["artifactId"], target["platform"])
metadata, descriptor = metadata_future.result(), descriptor_future.result()
validate_metadata(metadata, target)
validate_metadata(metadata, target, base)
elements = descriptor.get("elements", [])
if len(elements) != 1:
raise ManagedError("invalid-manifest")
@ -264,9 +349,23 @@ def inspect_target(core, request, workspace):
state_file.write_text(json.dumps(dict(target=target, sourceHash=source_hash, version=parsed.meta.build_version)), encoding="utf-8")
os.chmod(state_file, 0o600)
info = dict(game=dict(app_name=game.app_name, version=parsed.meta.build_version, is_dlc=game.is_dlc, external_activation=game.third_party_store or game.partner_link_type, cloud_saves_supported=game.supports_cloud_saves or game.supports_mac_cloud_saves), manifest=dict(is_preloaded=False, download_size=sum(chunk.file_size for chunk in parsed.chunk_data_list.elements), disk_size=sum(file.file_size for file in parsed.file_manifest_list.elements)), install=None)
if base is None and (definition := sdl_definition(core, target["artifactId"])):
info["sdl"] = selective_packs(definition, parsed)
return dict(protocolVersion=PROTOCOL_VERSION, info=info, metadata=game.__dict__, receipt=receipt), game, (data, base_urls, False, secrets)
def installed_base_game(core, base):
if base is None:
return None
game = core.lgd.get_game_meta(base["artifactId"])
metadata = getattr(game, "metadata", None)
if game is None or not isinstance(metadata, dict) or metadata.get("namespace") != base["namespace"] or metadata.get("id") != base["catalogItemId"] or "Windows" not in (game.asset_infos or {}):
raise ManagedError("invalid-target")
if not core.is_installed(base["artifactId"]):
raise ManagedError("base-not-installed")
return game
def run_install(core, request, game, manifest):
import legendary.cli as cli
@ -279,10 +378,14 @@ def run_install(core, request, game, manifest):
raise ManagedError("invalid-target")
# Prevent a request from overriding the validated manifest, identity, API
# context or transport. Args come from the trusted main process only.
forbidden = ("--manifest", "--old-manifest", "--delta-manifest", "--base-url", "--disable-https", "--config-file", "-c", "--debug", "-v", "--with-dlcs")
forbidden = ("--manifest", "--old-manifest", "--delta-manifest", "--base-url", "--disable-https", "--config-file", "-c", "--debug", "-v", "--with-dlcs", "--reset-sdl")
if any(arg.split("=", 1)[0] in forbidden for arg in args):
raise ManagedError("invalid-request")
constrain_core(core, game, target, manifest)
for arg in args:
if arg.startswith("--install-tag") and (not arg.startswith("--install-tag=") or not INSTALL_TAG.fullmatch(arg.split("=", 1)[1])):
raise ManagedError("invalid-request")
base_game = installed_base_game(core, base_from_request(request))
constrain_core(core, game, target, manifest, base_game)
original_cli = cli.LegendaryCLI
class TargetedCLI(original_cli):

View file

@ -88,6 +88,51 @@ def manifest_bytes(version="1", encrypted=False, app_name="Example"):
return manifest.write(), secrets
def tagged_manifest_bytes():
manifest = Manifest()
manifest.meta = ManifestMeta()
manifest.meta.app_name = "Example"
manifest.meta.build_version = "1"
manifest.meta.launch_exe = "game.exe"
manifest.chunk_data_list = CDL()
manifest.file_manifest_list = FML()
manifest.custom_fields = CustomFields()
chunks = {}
for index, size in enumerate((100, 20, 30, 40, 50), start=1):
chunk = ChunkInfo()
chunk.guid = (index, 0, 0, 0)
chunk.file_size = size
chunk.sha_hash = bytes(20)
chunks[index] = chunk
manifest.chunk_data_list.elements = list(chunks.values())
manifest.chunk_data_list.count = len(chunks)
for name, tags, guids, size in (("game.exe", [], [1], 1000), ("core.pak", ["chunk0"], [1, 2], 200), ("de.pak", ["voice_de"], [3, 1], 300), ("shared.pak", ["voice_de", "voice_fr"], [4], 400), ("fr.pak", ["voice_fr"], [5], 500)):
file = FileManifest()
file.filename = name
file.hash = bytes(20)
file.install_tags = tags
file.file_size = size
file.chunk_parts = []
for guid in guids:
part = ChunkPart()
part.guid = chunks[guid].guid
part.offset = 0
part.size = size // len(guids)
file.chunk_parts.append(part)
manifest.file_manifest_list.elements.append(file)
manifest.file_manifest_list.count = len(manifest.file_manifest_list.elements)
return manifest.write()
SDL = {
"__required": dict(tags=["chunk0"], name="Core"),
"de": dict(tags=["voice_de"], name="Deutsch"),
"fr": dict(tags=["voice_fr"], name="Français"),
}
DLC = dict(artifactId="ExampleDlc", namespace="namespace", catalogItemId="dlc", platform="Windows")
DLC_METADATA = dict(id="dlc", namespace="namespace", title="Example DLC", categories=[dict(path="addons")], mainGameItem=dict(id="item", namespace="namespace", releaseInfo=[dict(appId="Example")]))
class Fixture:
def __init__(self, data, secrets=None):
self.data = data
@ -332,6 +377,90 @@ class AdapterTests(unittest.TestCase):
with patch.object(cli, "main", invoke_cli), patch.object(cli, "LegendaryCLI", cli.LegendaryCLI):
managed.run_install(self.fixture.core, self.request, game, manifest)
def test_selective_packs_report_sizes_beyond_required_files(self):
from legendary.utils import selective_dl
self.fixture = Fixture(tagged_manifest_bytes())
self.fixture.core.lgdapi = SimpleNamespace(get_sdl_config=lambda name: copy.deepcopy(SDL))
with patch.dict(selective_dl.games, {"Example": {}}):
sdl = self.inspect()[0]["info"]["sdl"]
self.assertEqual(sdl["required_tags"], ["chunk0"])
self.assertEqual((sdl["download_size"], sdl["disk_size"]), (120, 1200))
packs = {pack["id"]: pack for pack in sdl["packs"]}
self.assertEqual(sorted(packs), ["de", "fr"])
self.assertEqual((packs["de"]["download_size"], packs["de"]["disk_size"]), (70, 700))
self.assertEqual((packs["fr"]["download_size"], packs["fr"]["disk_size"]), (90, 900))
self.assertEqual(packs["fr"]["name"], "Français")
self.assertEqual(packs["de"]["tags"], ["voice_de"])
def test_selective_definition_falls_back_and_rejects_malformed_data(self):
from legendary.utils import selective_dl
def unavailable(name):
raise ValueError("offline")
core = SimpleNamespace(lgdapi=SimpleNamespace(get_sdl_config=unavailable))
self.assertIsNone(managed.sdl_definition(core, "Example"))
with patch.dict(selective_dl.games, {"Example": copy.deepcopy(SDL)}):
self.assertEqual(managed.sdl_definition(core, "Example"), SDL)
core.lgdapi.get_sdl_config = lambda name: {"de": dict(tags=["--manifest=x"], name="Bad")}
self.assertEqual(managed.sdl_definition(core, "Example"), SDL)
for value in (None, {}, {"__required": SDL["__required"]}, {"de": dict(tags=[], name="Empty")}, {"de": dict(tags=["voice"], name="")}, {"../x": dict(tags=["voice"], name="Path")}, {f"p{i}": dict(tags=["t"], name="n") for i in range(66)}):
self.assertFalse(managed.valid_sdl(value))
def test_dlc_target_must_belong_to_the_base_game(self):
base = dict(TARGET)
request = dict(target=DLC, base=base)
self.assertEqual(managed.base_from_request(request), base)
self.assertIsNone(managed.base_from_request(dict(target=DLC)))
for invalid in (dict(TARGET, artifactId="ExampleDlc"), dict(TARGET, catalogItemId="dlc"), dict(TARGET, artifactId="../x"), dict(TARGET, platform="Mac")):
with self.assertRaises(managed.ManagedError):
managed.base_from_request(dict(target=DLC, base=invalid))
managed.validate_metadata(copy.deepcopy(DLC_METADATA), DLC, base)
for change in (dict(mainGameItem=dict(id="other", releaseInfo=[dict(appId="Example")])), dict(mainGameItem=dict(id="item", releaseInfo=[dict(appId="Other")])), dict(mainGameItem=dict(id="item", releaseInfo=[])), dict(categories=[dict(path="games")]), dict(categories=[dict(path="addons"), dict(path="addons/launchable")])):
with self.assertRaisesRegex(managed.ManagedError, "unsupported-game"):
managed.validate_metadata({**copy.deepcopy(DLC_METADATA), **change}, DLC, base)
with self.assertRaisesRegex(managed.ManagedError, "unsupported-game"):
managed.validate_metadata(copy.deepcopy(DLC_METADATA), DLC)
external = copy.deepcopy(DLC_METADATA)
external["customAttributes"] = dict(partnerLinkType=dict(value="ubisoft"))
with self.assertRaisesRegex(managed.ManagedError, "external-launcher-required"):
managed.validate_metadata(external, DLC, base)
def test_dlc_install_resolves_only_its_installed_base_game(self):
from legendary.models.game import Game, GameAsset
base_game = Game(app_name="Example", app_title="Example", metadata=dict(namespace="namespace", id="item"))
base_game.asset_infos = {"Windows": GameAsset(app_name="Example", namespace="namespace", catalog_item_id="item", build_version="1", label_name="Live")}
installed = {"Example"}
core = SimpleNamespace(lgd=SimpleNamespace(get_game_meta=lambda name: base_game if name == "Example" else None), is_installed=lambda name: name in installed)
self.assertIs(managed.installed_base_game(core, dict(TARGET)), base_game)
self.assertIsNone(managed.installed_base_game(core, None))
with self.assertRaisesRegex(managed.ManagedError, "invalid-target"):
managed.installed_base_game(core, dict(TARGET, catalogItemId="other"))
installed.clear()
with self.assertRaisesRegex(managed.ManagedError, "base-not-installed"):
managed.installed_base_game(core, dict(TARGET))
self.assertEqual(managed.error_code(managed.ManagedError("base-not-installed")), "installation-missing")
dlc = Game(app_name="ExampleDlc", app_title="DLC", metadata=copy.deepcopy(DLC_METADATA))
constrained = SimpleNamespace(egs=SimpleNamespace())
managed.constrain_core(constrained, dlc, DLC, ("manifest",), base_game)
self.assertIs(constrained.get_game("ExampleDlc"), dlc)
self.assertIs(constrained.get_game("Example"), base_game)
with self.assertRaisesRegex(managed.ManagedError, "invalid-target"):
constrained.get_game("Unrelated")
def test_install_tags_are_the_only_selection_arguments_accepted(self):
import legendary.cli as cli
_, game, manifest = self.inspect()
captured = []
for tags, accepted in ((["--install-tag=", "--install-tag=voice_de"], True), (["--install-tag", "voice_de"], False), (["--install-tag=../x"], False), (["--reset-sdl"], False)):
self.request.update(operation="install", args=["-y", "install", "Example", *tags])
if not accepted:
with self.assertRaisesRegex(managed.ManagedError, "invalid-request"):
managed.run_install(self.fixture.core, self.request, game, manifest)
continue
with patch.object(cli, "main", side_effect=lambda: captured.append(list(sys.argv))), patch.object(cli, "LegendaryCLI", cli.LegendaryCLI):
managed.run_install(self.fixture.core, self.request, game, manifest)
self.assertIn("--install-tag=voice_de", captured[0])
self.assertIn("--disable-sdl", captured[0])
def test_receipt_outside_private_workspace_is_rejected(self):
with self.assertRaisesRegex(managed.ManagedError, "invalid-receipt"):
managed.receipt_path(self.workspace.parent / "outside.manifest", self.workspace)

View file

@ -47,6 +47,7 @@ test(
"install-target",
"access-only",
"refresh-session",
"install-components",
]);
const result = await run(
binary,
@ -57,7 +58,7 @@ test(
],
{ windowsHide: true, timeout: 20_000 }
);
assert.match(result.stderr, /Ran 22 tests/);
assert.match(result.stderr, /Ran 27 tests/);
assert.match(result.stderr, /\bOK\b/);
}
);

View file

@ -16,6 +16,7 @@ import {
resolveDownloadDirectories,
findLibraryGamePresentation,
getOfficialStoreMembers,
getRepackUriParts,
isEpicMemberInCurrentScope,
} from "@shared";
import { DOWNLOADER_NAME, IS_DESKTOP } from "../../../constants";
@ -1684,6 +1685,7 @@ function DownloadGameOptions({
title: game.title,
shop: game.shop,
uri: selectedUri,
parts: getRepackUriParts(option, selectedUri),
downloadPath: selectedDownloadPath,
downloader: resolvedDownloader,
automaticallyExtract: automaticExtractionEnabled,

View file

@ -3,7 +3,9 @@ import type { LibraryGame } from "@types";
import { formatBytes } from "@shared";
import { useEpicInstall } from "@renderer/hooks/use-epic-install";
import {
Accordion,
Button,
Checkbox,
DropdownSelect,
Input,
Modal,
@ -31,6 +33,7 @@ export function EpicInstallModal({
!install.option ||
!install.result?.connectionId ||
!install.path;
const summary = install.extrasSummary;
return (
<Modal
visible={!!game}
@ -61,16 +64,65 @@ export function EpicInstallModal({
<p>
{t("epic_install_sizes", {
download:
install.option.downloadSizeInBytes == null
install.choice.downloadSizeInBytes == null
? "—"
: formatBytes(install.option.downloadSizeInBytes),
: formatBytes(install.choice.downloadSizeInBytes),
disk:
install.option.diskSizeInBytes == null
install.choice.diskSizeInBytes == null
? "—"
: formatBytes(install.option.diskSizeInBytes),
: formatBytes(install.choice.diskSizeInBytes),
})}
</p>
)}
{install.extras.length > 0 && (
<Accordion
key={game?.objectId}
title={t("epic_extras_title")}
hint={[
t("epic_extras_selected", {
selected: summary.selected,
total: summary.total,
}),
...(summary.selected > 0
? [`+${formatBytes(summary.selectedDownloadSizeInBytes)}`]
: []),
].join(" · ")}
focusId="epic-install-extras"
>
<VerticalFocusGroup>
{summary.total > 0 && (
<Checkbox
block
label={t("epic_select_all")}
secondaryText={`${summary.selected}/${summary.total}`}
checked={summary.selected === summary.total}
disabled={install.starting}
onChange={install.setAllExtras}
/>
)}
{install.extras.map((extra) => (
<Checkbox
key={extra.key}
block
label={extra.name}
secondaryText={
extra.installed
? t("epic_dlc_installed")
: t("epic_selection_size", {
size: formatBytes(extra.downloadSizeInBytes),
})
}
checked={extra.checked}
disabled={extra.installed || install.starting}
onChange={(checked) => install.toggleExtra(extra, checked)}
/>
))}
</VerticalFocusGroup>
</Accordion>
)}
{install.insufficientSpace && (
<p role="alert">{t("epic_not_enough_space")}</p>
)}
<Input label={t("download_path")} value={install.path} readOnly />
<Button
variant="secondary"

View file

@ -1,129 +1,315 @@
import {
CheckCircleIcon,
EyeClosedIcon,
LockIcon,
MagnifyingGlassIcon,
MedalIcon,
UsersIcon,
} from "@phosphor-icons/react";
import type { UserAchievement } from "@types";
import {
formatAchievementUnlockDateTime,
formatAchievementUnlockPercentage,
getAchievementKey,
} from "@shared";
import cn from "classnames";
import { useDate } from "../../../../hooks";
import type { CSSProperties } from "react";
import { useTranslation } from "react-i18next";
import { FocusItem, Tooltip, Typography } from "../../../common";
import type { FocusOverrides } from "../../../../services";
import { getAchievementRowId } from "../navigation";
export interface AchievementRowProps {
achievement: UserAchievement;
concealed?: boolean;
revealed?: boolean;
showPoints?: boolean;
stealFocusOnAppear?: boolean;
navigationOrder?: number;
navigationOverrides?: FocusOverrides;
onSouvenirActivate?: (achievement: UserAchievement) => void;
onReveal?: (achievement: UserAchievement) => void;
virtualRow?: VirtualRowProps;
}
export interface VirtualRowProps {
"data-index": number;
ref: (element: Element | null) => void;
style: CSSProperties;
}
function AchievementRowIcon({
achievement,
concealed,
}: Readonly<{ achievement: UserAchievement; concealed: boolean }>) {
if (concealed) {
return (
<span className="game-achievements-row__icon game-achievements-row__icon--concealed">
<EyeClosedIcon size={28} />
<img src={achievement.icon} alt="" aria-hidden="true" hidden />
</span>
);
}
return (
<img
src={achievement.icon}
width={56}
height={56}
alt={achievement.displayName}
loading="lazy"
className={cn("game-achievements-row__icon", {
"game-achievements-row__icon--locked": !achievement.unlocked,
})}
/>
);
}
function AchievementRowTitle({
achievement,
}: Readonly<{ achievement: UserAchievement }>) {
const { t } = useTranslation("achievement");
return (
<Typography className="game-achievements-row__title">
{achievement.hidden && !achievement.unlocked ? (
<EyeClosedIcon
size={16}
className="game-achievements-row__hidden-icon"
/>
) : null}
{achievement.displayName}
{achievement.hardcoreUnlockTime != null ? (
<span className="game-achievements-row__hardcore-badge">
{t("hardcore")}
</span>
) : null}
</Typography>
);
}
function AchievementRowInfo({
achievement,
concealed,
unlockedAt,
}: Readonly<{
achievement: UserAchievement;
concealed: boolean;
unlockedAt?: string;
}>) {
const { t } = useTranslation("achievement");
if (concealed) {
return (
<div className="game-achievements-row__info">
<Typography className="game-achievements-row__title">
{t("hidden_achievement")}
</Typography>
<Typography className="game-achievements-row__hidden-note">
{t("select_to_reveal")}
</Typography>
</div>
);
}
return (
<div className="game-achievements-row__info">
<AchievementRowTitle achievement={achievement} />
{achievement.description ? (
<Typography className="game-achievements-row__description">
{achievement.description}
</Typography>
) : null}
{unlockedAt ? (
<Typography className="game-achievements-row__unlock-time">
{t("unlocked_at", { date: unlockedAt })}
</Typography>
) : null}
</div>
);
}
function AchievementRowSouvenir({
achievement,
onActivate,
}: Readonly<{
achievement: UserAchievement;
onActivate?: (achievement: UserAchievement) => void;
}>) {
const { t } = useTranslation("achievement");
return (
<span className="game-achievements-row__souvenir">
<img
src={achievement.imageUrl!}
alt={t("souvenir_alt", { achievement: achievement.displayName })}
loading="lazy"
className="game-achievements-row__souvenir-image"
/>
{onActivate ? (
<>
<span className="game-achievements-row__souvenir-overlay">
<MagnifyingGlassIcon size={20} />
</span>
<button
type="button"
className="game-achievements-row__souvenir-button"
onClick={() => onActivate(achievement)}
aria-label={t("view_souvenir")}
tabIndex={-1}
/>
</>
) : null}
</span>
);
}
function AchievementRowStats({
achievement,
showPoints,
}: Readonly<{ achievement: UserAchievement; showPoints: boolean }>) {
const { t, i18n } = useTranslation("achievement");
const unlockPercentage =
achievement.globalUnlockPercentage == null
? null
: formatAchievementUnlockPercentage(
achievement.globalUnlockPercentage,
i18n.language
);
const points = showPoints ? achievement.points : undefined;
if (!unlockPercentage && points == null) return null;
return (
<div className="game-achievements-row__stats">
{unlockPercentage ? (
<span
className="game-achievements-row__percentage"
title={t("unlock_percentage_tooltip", {
percentage: unlockPercentage,
})}
>
<UsersIcon size={16} weight="fill" />
<span>{unlockPercentage}</span>
</span>
) : null}
{points == null ? null : (
<div
className="game-achievements-row__points"
title={t("achievement_earn_points", { points })}
>
<MedalIcon size={16} weight="fill" />
<span>{points}</span>
</div>
)}
</div>
);
}
function AchievementRowStatus({
unlocked,
unlockedAt,
}: Readonly<{ unlocked: boolean; unlockedAt?: string }>) {
return (
<div className="game-achievements-row__status">
{unlocked ? (
<Tooltip
content={unlockedAt ?? ""}
position="left"
active={unlockedAt != undefined}
>
<CheckCircleIcon size={28} weight="fill" />
</Tooltip>
) : (
<LockIcon size={24} />
)}
</div>
);
}
export function AchievementRow({
achievement,
concealed = false,
revealed = false,
showPoints = false,
stealFocusOnAppear = false,
navigationOrder,
navigationOverrides,
onSouvenirActivate,
onReveal,
virtualRow,
}: Readonly<AchievementRowProps>) {
const { formatDateTime } = useDate();
const { t, i18n } = useTranslation("achievement");
const unlockedAt =
achievement.unlockTime != null
? formatDateTime(achievement.unlockTime)
: undefined;
achievement.unlockTime == null
? undefined
: formatAchievementUnlockDateTime(achievement.unlockTime, i18n.language);
const hasSouvenir = Boolean(achievement.unlocked && achievement.imageUrl);
const canViewSouvenir = hasSouvenir && onSouvenirActivate !== undefined;
const revealAchievement =
concealed && onReveal ? () => onReveal(achievement) : undefined;
const viewSouvenir =
hasSouvenir && onSouvenirActivate
? () => onSouvenirActivate(achievement)
: undefined;
return (
<FocusItem
id={getAchievementRowId(achievement.name)}
actions={
canViewSouvenir
? { primary: () => onSouvenirActivate(achievement) }
: { primary: "off" }
}
id={getAchievementRowId(getAchievementKey(achievement))}
actions={{ primary: revealAchievement ?? viewSouvenir ?? "off" }}
navigationOrder={navigationOrder}
navigationOverrides={navigationOverrides}
stealFocusOnAppear={stealFocusOnAppear}
asChild
>
<li className="game-achievements-row">
<img
src={achievement.icon}
width={56}
height={56}
alt={achievement.displayName}
loading="lazy"
className={cn("game-achievements-row__icon", {
"game-achievements-row__icon--locked": !achievement.unlocked,
})}
<li
{...virtualRow}
className={cn("game-achievements-row", {
"game-achievements-row--unlocked": achievement.unlocked,
"game-achievements-row--concealed": concealed,
"game-achievements-row--revealed": revealed,
})}
>
<AchievementRowIcon achievement={achievement} concealed={concealed} />
<AchievementRowInfo
achievement={achievement}
concealed={concealed}
unlockedAt={unlockedAt}
/>
<div className="game-achievements-row__info">
<Typography className="game-achievements-row__title">
{achievement.displayName}
</Typography>
{achievement.hidden ? (
<Typography className="game-achievements-row__hidden-note">
Hidden achievement
</Typography>
) : achievement.description ? (
<Typography className="game-achievements-row__description">
{achievement.description}
</Typography>
) : null}
</div>
<div className="game-achievements-row__meta">
{hasSouvenir ? (
<span className="game-achievements-row__souvenir">
<img
src={achievement.imageUrl!}
alt={`${achievement.displayName} souvenir`}
loading="lazy"
className="game-achievements-row__souvenir-image"
/>
{canViewSouvenir ? (
<>
<span className="game-achievements-row__souvenir-overlay">
<MagnifyingGlassIcon size={20} />
</span>
<button
type="button"
className="game-achievements-row__souvenir-button"
onClick={() => onSouvenirActivate(achievement)}
aria-label={`${achievement.displayName} souvenir`}
tabIndex={-1}
/>
</>
) : null}
</span>
<AchievementRowSouvenir
achievement={achievement}
onActivate={onSouvenirActivate}
/>
) : null}
{achievement.points != undefined ? (
<div
className="game-achievements-row__points"
title={`Earn ${achievement.points} points with this achievement`}
>
<MedalIcon size={16} weight="fill" />
<span>{achievement.points}</span>
</div>
) : null}
<AchievementRowStats
achievement={achievement}
showPoints={showPoints}
/>
<div className="game-achievements-row__status">
{achievement.unlocked ? (
<Tooltip
content={unlockedAt ?? ""}
position="left"
active={unlockedAt != undefined}
>
<CheckCircleIcon size={24} />
</Tooltip>
) : (
<LockIcon size={24} />
)}
</div>
<AchievementRowStatus
unlocked={achievement.unlocked}
unlockedAt={unlockedAt}
/>
</div>
{revealAchievement ? (
<button
type="button"
className="game-achievements-row__reveal-button"
onClick={revealAchievement}
aria-label={t("select_to_reveal")}
tabIndex={-1}
/>
) : null}
</li>
</FocusItem>
);

View file

@ -1,29 +0,0 @@
import { MedalIcon } from "@phosphor-icons/react";
export interface AvailablePointsBarProps {
earnedPoints: number;
totalPoints: number;
}
export function AvailablePointsBar({
earnedPoints,
totalPoints,
}: Readonly<AvailablePointsBarProps>) {
if (totalPoints <= 0) return null;
const formatter = new Intl.NumberFormat("en");
return (
<div className="game-achievements-page__points-bar">
<span className="game-achievements-page__points-label">
Earned points
</span>
<div className="game-achievements-page__points-value">
<MedalIcon size={16} weight="fill" />
<span>
{formatter.format(earnedPoints)} / {formatter.format(totalPoints)}
</span>
</div>
</div>
);
}

View file

@ -1,6 +1,7 @@
export * from "./achievement-row";
export * from "./available-points-bar";
export * from "./hero";
export * from "./navigation";
export * from "./set-header";
export * from "./souvenir-viewer";
export * from "./user-summary";
export * from "./toolbar";

View file

@ -1,6 +1,13 @@
export const GAME_ACHIEVEMENTS_PAGE_REGION_ID = "game-achievements-page";
export const GAME_ACHIEVEMENTS_LIST_REGION_ID = "game-achievements-list";
export const GAME_ACHIEVEMENTS_TOOLBAR_REGION_ID = "game-achievements-toolbar";
export const GAME_ACHIEVEMENTS_SUMMARY_FOCUS_ID = "game-achievements-summary";
export const GAME_ACHIEVEMENTS_SEARCH_INPUT_ID = "game-achievements-search";
export const GAME_ACHIEVEMENTS_SORT_SELECT_ID = "game-achievements-sort";
export const GAME_ACHIEVEMENTS_STATUS_SELECT_ID = "game-achievements-status";
export const getAchievementRowId = (achievementName: string) =>
`achievement-row-${achievementName}`;
export const getAchievementSetId = (setKey: string) =>
`achievement-set-${setKey}`;

View file

@ -0,0 +1,92 @@
import { CaretDownIcon } from "@phosphor-icons/react";
import type { AchievementSet } from "@types";
import cn from "classnames";
import { useTranslation } from "react-i18next";
import { FocusItem } from "../../../common";
import type { FocusOverrides } from "../../../../services";
import { getAchievementSetId } from "../navigation";
import type { VirtualRowProps } from "../achievement-row";
export interface AchievementSetHeaderProps {
setKey: string;
set: AchievementSet | null;
expanded: boolean;
unlockedCount: number;
totalCount: number;
navigationOrder?: number;
navigationOverrides?: FocusOverrides;
spaced?: boolean;
virtualRow?: VirtualRowProps;
onToggle: () => void;
}
const formatPercentage = (percentage: number) =>
percentage === 0 || Number.isInteger(percentage)
? `${percentage}%`
: `${percentage.toFixed(1)}%`;
export function AchievementSetHeader({
setKey,
set,
expanded,
unlockedCount,
totalCount,
navigationOrder,
navigationOverrides,
spaced = false,
virtualRow,
onToggle,
}: Readonly<AchievementSetHeaderProps>) {
const { t } = useTranslation("achievement");
const percentage = totalCount > 0 ? (unlockedCount / totalCount) * 100 : 0;
let name = t("achievement_set_other");
if (set) name = set.name ?? t("achievement_set_base_game");
return (
<FocusItem
id={getAchievementSetId(setKey)}
actions={{ primary: onToggle }}
navigationOrder={navigationOrder}
navigationOverrides={navigationOverrides}
asChild
>
<li
{...virtualRow}
className={cn("game-achievements-set", {
"game-achievements-set--collapsed": !expanded,
"game-achievements-set--spaced": spaced,
})}
>
<button
type="button"
className="game-achievements-set__button"
aria-expanded={expanded}
onClick={onToggle}
tabIndex={-1}
>
<CaretDownIcon size={20} className="game-achievements-set__caret" />
<span className="game-achievements-set__name">{name}</span>
<span className="game-achievements-set__progress">
<span className="game-achievements-set__count">
{unlockedCount} / {totalCount}
</span>
<span className="game-achievements-set__bar">
<span
className="game-achievements-set__bar-fill"
style={{ width: `${percentage}%` }}
/>
</span>
<span className="game-achievements-set__percentage">
{formatPercentage(percentage)}
</span>
</span>
</button>
</li>
</FocusItem>
);
}

View file

@ -0,0 +1,173 @@
import {
ClockIcon,
LockIcon,
LockOpenIcon,
MagnifyingGlassIcon,
MedalIcon,
SortAscendingIcon,
SortDescendingIcon,
TrophyIcon,
} from "@phosphor-icons/react";
import type { AchievementSortOption, AchievementStatusFilter } from "@shared";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
DropdownSelect,
type DropdownSelectOption,
HorizontalFocusGroup,
Input,
} from "../../../common";
import type { FocusOverrideTarget } from "../../../../services";
import {
GAME_ACHIEVEMENTS_SEARCH_INPUT_ID,
GAME_ACHIEVEMENTS_SORT_SELECT_ID,
GAME_ACHIEVEMENTS_STATUS_SELECT_ID,
GAME_ACHIEVEMENTS_SUMMARY_FOCUS_ID,
GAME_ACHIEVEMENTS_TOOLBAR_REGION_ID,
} from "../navigation";
const STATUS_ICONS: Record<AchievementStatusFilter, JSX.Element> = {
all: <TrophyIcon size={22} />,
unlocked: <LockOpenIcon size={22} />,
locked: <LockIcon size={22} />,
};
const SORT_ICONS: Record<AchievementSortOption, JSX.Element> = {
most_common: <SortDescendingIcon size={22} />,
most_rare: <SortAscendingIcon size={22} />,
recently_unlocked: <ClockIcon size={22} />,
};
export interface GameAchievementsToolbarProps {
search: string;
onSearchChange: (search: string) => void;
sort: AchievementSortOption;
onSortChange: (sort: AchievementSortOption) => void;
status: AchievementStatusFilter;
onStatusChange: (status: AchievementStatusFilter) => void;
statusCounts: Record<AchievementStatusFilter, number>;
firstAchievementRowId: string | null;
points: { earned: number; total: number } | null;
}
export function GameAchievementsToolbar({
search,
onSearchChange,
sort,
onSortChange,
status,
onStatusChange,
statusCounts,
firstAchievementRowId,
points,
}: Readonly<GameAchievementsToolbarProps>) {
const { t, i18n } = useTranslation("achievement");
const numberFormatter = new Intl.NumberFormat(i18n.language);
const sortOptions = useMemo(
() =>
[
{ value: "most_common", label: t("sort_most_common") },
{ value: "most_rare", label: t("sort_most_rare") },
{ value: "recently_unlocked", label: t("sort_recently_unlocked") },
] satisfies Array<DropdownSelectOption<AchievementSortOption>>,
[t]
);
const statusOptions = useMemo(
() =>
(["all", "unlocked", "locked"] as const).map((filter) => ({
value: filter,
label: t(`filter_${filter}`, { count: statusCounts[filter] }),
})) satisfies Array<DropdownSelectOption<AchievementStatusFilter>>,
[statusCounts, t]
);
const upOverride: FocusOverrideTarget = {
type: "item",
itemId: GAME_ACHIEVEMENTS_SUMMARY_FOCUS_ID,
};
const downOverride: FocusOverrideTarget = firstAchievementRowId
? { type: "item", itemId: firstAchievementRowId }
: { type: "block" };
return (
<div className="game-achievements-page__toolbar">
<HorizontalFocusGroup
className="game-achievements-page__toolbar-controls"
regionId={GAME_ACHIEVEMENTS_TOOLBAR_REGION_ID}
>
<div className="game-achievements-page__toolbar-search">
<Input
focusId={GAME_ACHIEVEMENTS_SEARCH_INPUT_ID}
focusNavigationOverrides={{
left: { type: "block" },
right: {
type: "item",
itemId: GAME_ACHIEVEMENTS_STATUS_SELECT_ID,
},
up: upOverride,
down: downOverride,
}}
type="text"
placeholder={t("search_achievements")}
iconLeft={<MagnifyingGlassIcon size={24} />}
value={search}
onChange={(event) => onSearchChange(event.target.value)}
/>
</div>
<DropdownSelect
className="game-achievements-page__toolbar-select"
hideLabel
leadingIcon={STATUS_ICONS[status]}
ariaLabel={t("filter_achievements")}
focusId={GAME_ACHIEVEMENTS_STATUS_SELECT_ID}
focusNavigationOverrides={{
left: { type: "item", itemId: GAME_ACHIEVEMENTS_SEARCH_INPUT_ID },
right: { type: "item", itemId: GAME_ACHIEVEMENTS_SORT_SELECT_ID },
up: upOverride,
down: downOverride,
}}
value={status}
options={statusOptions}
onValueChange={onStatusChange}
/>
<DropdownSelect
className="game-achievements-page__toolbar-select"
hideLabel
leadingIcon={SORT_ICONS[sort]}
ariaLabel={t("sort_achievements")}
focusId={GAME_ACHIEVEMENTS_SORT_SELECT_ID}
focusNavigationOverrides={{
left: { type: "item", itemId: GAME_ACHIEVEMENTS_STATUS_SELECT_ID },
right: { type: "block" },
up: upOverride,
down: downOverride,
}}
value={sort}
options={sortOptions}
onValueChange={onSortChange}
/>
<div className="game-achievements-page__toolbar-points">
<span className="game-achievements-page__toolbar-points-label">
{t("earned_points")}
</span>
<MedalIcon size={18} weight="fill" />
{points ? (
<span>
{numberFormatter.format(points.earned)} /{" "}
{numberFormatter.format(points.total)}
</span>
) : (
<span className="game-achievements-page__toolbar-points-locked">
??? / ???
</span>
)}
</div>
</HorizontalFocusGroup>
</div>
);
}

View file

@ -1,7 +1,10 @@
import type { GameShop, UserAchievement } from "@types";
import cn from "classnames";
import { EyeIcon } from "@phosphor-icons/react/dist/ssr";
import { EyeClosedIcon, EyeIcon } from "@phosphor-icons/react/dist/ssr";
import { isAchievementConcealed } from "@shared";
import { useTranslation } from "react-i18next";
import { Link, useParams } from "react-router-dom";
import { useUserPreferences } from "../../../../hooks";
import type { FocusOverrides } from "../../../../services";
import { getBigPictureGameAchievementsPath } from "../../../../helpers";
import { FocusItem, Typography } from "../../../common";
@ -20,6 +23,10 @@ export function AchievementsBox({
focusNavigationOrder,
}: Readonly<AchievementsBoxProps>) {
const { shop, objectId } = useParams<{ shop: GameShop; objectId: string }>();
const { t } = useTranslation("achievement");
const userPreferences = useUserPreferences();
const showHiddenAchievements =
userPreferences?.showHiddenAchievementsDescription ?? false;
const unlockedCount = achievements.filter(
(achievement) => achievement.unlocked
).length;
@ -48,30 +55,48 @@ export function AchievementsBox({
</Typography>
</div>
{achievements.slice(0, 6).map((achievement) => (
<div key={achievement.name} className="game-page__achievement">
<img
src={achievement.icon}
width={56}
height={56}
className={cn("game-page__achievement-icon", {
"game-page__achievement-icon--locked": !achievement.unlocked,
})}
alt={achievement.displayName}
loading="lazy"
/>
{achievements.slice(0, 6).map((achievement) => {
const concealed = isAchievementConcealed(
achievement,
showHiddenAchievements
);
<div className="game-page__achievement-info">
<Typography className="game-page__achievement-name">
{achievement.displayName}
</Typography>
return (
<div key={achievement.name} className="game-page__achievement">
{concealed ? (
<div className="game-page__achievement-icon-locked">
<EyeClosedIcon size={24} />
</div>
) : (
<img
src={achievement.icon}
width={56}
height={56}
className={cn("game-page__achievement-icon", {
"game-page__achievement-icon--locked":
!achievement.unlocked,
})}
alt={achievement.displayName}
loading="lazy"
/>
)}
<Typography className="game-page__achievement-description">
{achievement.description}
</Typography>
<div className="game-page__achievement-info">
<Typography className="game-page__achievement-name">
{concealed
? t("hidden_achievement")
: achievement.displayName}
</Typography>
{concealed ? null : (
<Typography className="game-page__achievement-description">
{achievement.description}
</Typography>
)}
</div>
</div>
</div>
))}
);
})}
<div className="game-page__achievement-view-all">
<div className="game-page__achievement-icon-locked">

View file

@ -25,4 +25,5 @@ export const DOWNLOADER_NAME: Record<Downloader, string> = {
[Downloader.AllDebrid]: "AllDebrid",
[Downloader.ArchiveOrg]: "Archive.org",
[Downloader.Epic]: "Epic Games",
[Downloader.FileKeeper]: "FileKeeper",
};

View file

@ -7,6 +7,7 @@ import {
filterDownloadableRepacks,
} from "@shared";
import { orderBy } from "lodash-es";
import { Feature, useFeature } from "../use-feature.hook";
export type DownloadOptionsEmptyStateReason =
| "no-configured-sources"
@ -120,7 +121,8 @@ async function fetchDownloadOptions(
game: Pick<Game, "objectId" | "shop">,
signal: { cancelled: boolean },
setters: DownloadStateSetters,
knownGameSourcesEmptyStateReason: DownloadOptionsEmptyStateReason | null
knownGameSourcesEmptyStateReason: DownloadOptionsEmptyStateReason | null,
multipartHosterDownloads: boolean
) {
resetDownloadOptionsState(signal, setters);
@ -166,7 +168,7 @@ async function fetchDownloadOptions(
needsAuth: false,
});
const options = Array.isArray(response)
? filterDownloadableRepacks(response)
? filterDownloadableRepacks(response, multipartHosterDownloads)
: [];
setDownloadOptionsSuccessState(signal, setters, options);
@ -197,6 +199,10 @@ export function useGameDownloadOptions(
) {
const shouldLoadDownloadOptions =
visible && IS_DESKTOP && game.shop !== "custom";
const { isFeatureEnabled } = useFeature();
const multipartHosterDownloads = isFeatureEnabled(
Feature.MultipartHosterDownloads
);
const knownGameSourcesEmptyStateReason = getKnownGameSourcesEmptyStateReason(
game.downloadSources
);
@ -249,7 +255,8 @@ export function useGameDownloadOptions(
setIsLoading,
setEmptyStateReason,
},
knownGameSourcesEmptyStateReason
knownGameSourcesEmptyStateReason,
multipartHosterDownloads
);
return () => {
@ -260,6 +267,7 @@ export function useGameDownloadOptions(
game.objectId,
game.shop,
knownGameSourcesEmptyStateReason,
multipartHosterDownloads,
shouldLoadDownloadOptions,
]);

View file

@ -7,6 +7,7 @@ export enum Feature {
AllDebrid = "ALLDEBRID",
Nimbus = "NIMBUS",
NimbusPreview = "NIMBUS_PREVIEW",
MultipartHosterDownloads = "MULTIPART_HOSTER_DOWNLOADS",
}
const FEATURES_STORAGE_KEY = "features";

View file

@ -744,7 +744,8 @@ export function useBigPictureDownloadsPageData() {
await globalThis.window.electron.deleteGameFolder(
game.shop,
game.objectId
game.objectId,
{ keepEpicUpdateFiles: true }
);
},
[lastPacket?.gameId]

View file

@ -1,26 +1,59 @@
import type { GameShop, UserAchievement } from "@types";
import { useCallback, useMemo, useState } from "react";
import {
buildAchievementListEntries,
filterAndSortAchievements,
getAchievementKey,
getAchievementStatusCounts,
getVisibleSetGroups,
groupAchievementsBySet,
isAchievementConcealed,
type AchievementListEntry,
type AchievementSortOption,
type AchievementStatusFilter,
} from "@shared";
import { useCallback, useMemo, useRef, useState } from "react";
import { useVirtualList } from "../../../../shared/use-virtual-list";
import {
useCollapsedAchievementSets,
useRevealedAchievements,
} from "../../../../shared/use-achievement-list-state";
import { useTranslation } from "react-i18next";
import cn from "classnames";
import { useNavigate, useParams } from "react-router-dom";
import { VerticalFocusGroup } from "../../components";
import {
AchievementRow,
AvailablePointsBar,
AchievementSetHeader,
GAME_ACHIEVEMENTS_LIST_REGION_ID,
GAME_ACHIEVEMENTS_PAGE_REGION_ID,
GAME_ACHIEVEMENTS_SUMMARY_FOCUS_ID,
GAME_ACHIEVEMENTS_SEARCH_INPUT_ID,
GameAchievementsHero,
GameAchievementsSouvenirViewer,
GameAchievementsToolbar,
UserAchievementsSummary,
getAchievementRowId,
getAchievementSetId,
} from "../../components/pages/game-achievements";
import {
useGameDetails,
useHeaderTitle,
useNavigationActions,
useNavigationScreenActions,
useUserDetails,
useUserPreferences,
} from "../../hooks";
import "./styles.scss";
const ESTIMATED_ROW_HEIGHT = 92;
const ESTIMATED_SET_HEADER_HEIGHT = 72;
const ROW_GAP = 4;
const OVERSCAN = 6;
const getListEntryId = (entry: AchievementListEntry<UserAchievement>) =>
entry.type === "set"
? getAchievementSetId(entry.group.key)
: getAchievementRowId(getAchievementKey(entry.achievement));
export default function GameAchievements() {
const { shop, objectId } = useParams<{ shop: GameShop; objectId: string }>();
const navigate = useNavigate();
@ -29,8 +62,52 @@ export default function GameAchievements() {
shop!,
true
);
const { userDetails } = useUserDetails();
const { t } = useTranslation("achievement");
const { userDetails, hasActiveSubscription } = useUserDetails();
const userPreferences = useUserPreferences();
const showHiddenAchievements =
userPreferences?.showHiddenAchievementsDescription ?? false;
const [souvenir, setSouvenir] = useState<UserAchievement | null>(null);
const [search, setSearch] = useState("");
const [status, setStatus] = useState<AchievementStatusFilter>("all");
const [sort, setSort] = useState<AchievementSortOption>("most_common");
const { revealedNames, lastRevealedName, revealAchievement } =
useRevealedAchievements();
const { collapsedSetKeys, toggleSet } = useCollapsedAchievementSets();
const isConcealed = useCallback(
(
achievement: Pick<
UserAchievement,
"name" | "canonicalAchievementId" | "hidden" | "unlocked"
>
) =>
isAchievementConcealed(achievement, showHiddenAchievements) &&
!revealedNames.has(getAchievementKey(achievement)),
[revealedNames, showHiddenAchievements]
);
const handleReveal = useCallback(
(achievement: UserAchievement) =>
revealAchievement(getAchievementKey(achievement)),
[revealAchievement]
);
const statusCounts = useMemo(
() => getAchievementStatusCounts(achievements),
[achievements]
);
const visibleAchievements = useMemo(
() =>
filterAndSortAchievements(achievements, {
search,
status,
sort,
isConcealed,
}),
[achievements, isConcealed, search, sort, status]
);
const unlockedCount = useMemo(
() => achievements.filter((a) => a.unlocked).length,
@ -42,10 +119,39 @@ export default function GameAchievements() {
[achievements]
);
const firstAchievementRowId = achievements[0]
? getAchievementRowId(achievements[0].name)
const setGroups = useMemo(
() => groupAchievementsBySet(achievements),
[achievements]
);
const listEntries = useMemo(
() =>
buildAchievementListEntries(
setGroups ? getVisibleSetGroups(setGroups, visibleAchievements) : null,
visibleAchievements,
collapsedSetKeys
),
[collapsedSetKeys, setGroups, visibleAchievements]
);
const firstAchievementRowId = listEntries[0]
? getListEntryId(listEntries[0])
: null;
const scrollRef = useRef<HTMLDivElement | null>(null);
const { listRef, virtualizer, getRowProps } = useVirtualList({
count: listEntries.length,
scrollRef,
getItemKey: (index) => getListEntryId(listEntries[index]),
estimateSize: (index) =>
listEntries[index]?.type === "set"
? ESTIMATED_SET_HEADER_HEIGHT
: ESTIMATED_ROW_HEIGHT,
gap: ROW_GAP,
overscan: OVERSCAN,
});
const earnedPoints = useMemo(
() =>
achievements.reduce(
@ -62,8 +168,22 @@ export default function GameAchievements() {
[]
);
const { moveFocus } = useNavigationActions();
const [hasNavigated, setHasNavigated] = useState(false);
const screenActions = useMemo(
() => ({
direction: hasNavigated
? undefined
: Object.fromEntries(
(["up", "down", "left", "right"] as const).map((direction) => [
direction,
() => {
setHasNavigated(true);
moveFocus(direction);
},
])
),
press: {
b: () => {
if (souvenir) {
@ -75,7 +195,7 @@ export default function GameAchievements() {
},
},
}),
[navigate, souvenir]
[hasNavigated, moveFocus, navigate, souvenir]
);
useNavigationScreenActions(screenActions);
@ -92,7 +212,12 @@ export default function GameAchievements() {
return (
<VerticalFocusGroup regionId={GAME_ACHIEVEMENTS_PAGE_REGION_ID} asChild>
<div className="game-achievements-page">
<div
ref={scrollRef}
className={cn("game-achievements-page", {
"game-achievements-page--pristine": !hasNavigated,
})}
>
<GameAchievementsHero shopDetails={shopDetails} game={game} />
<div className="game-achievements-page__content">
@ -103,40 +228,99 @@ export default function GameAchievements() {
stealFocusOnAppear
navigationOverrides={{
up: { type: "block" },
down: firstAchievementRowId
? { type: "item", itemId: firstAchievementRowId }
: { type: "block" },
down: { type: "item", itemId: GAME_ACHIEVEMENTS_SEARCH_INPUT_ID },
}}
/>
<section className="game-achievements-page__list-section">
<AvailablePointsBar
earnedPoints={earnedPoints}
totalPoints={totalPoints}
/>
<GameAchievementsToolbar
search={search}
onSearchChange={setSearch}
sort={sort}
onSortChange={setSort}
status={status}
onStatusChange={setStatus}
statusCounts={statusCounts}
firstAchievementRowId={firstAchievementRowId}
points={
hasActiveSubscription
? { earned: earnedPoints, total: totalPoints }
: null
}
/>
<section className="game-achievements-page__list-section">
<VerticalFocusGroup
regionId={GAME_ACHIEVEMENTS_LIST_REGION_ID}
asChild
>
<ul className="game-achievements-page__list">
{achievements.map((achievement, index) => (
<AchievementRow
key={achievement.name}
achievement={achievement}
navigationOverrides={
index === 0
? {
up: {
type: "item",
itemId: GAME_ACHIEVEMENTS_SUMMARY_FOCUS_ID,
},
}
: undefined
}
onSouvenirActivate={handleSouvenirActivate}
/>
))}
<ul
ref={listRef}
className={cn("game-achievements-page__list", {
"game-achievements-page__list--virtual":
listEntries.length > 0,
})}
style={
listEntries.length > 0
? { height: virtualizer.getTotalSize() }
: undefined
}
>
{listEntries.length === 0 ? (
<li className="game-achievements-page__empty">
{t("no_achievements_found")}
</li>
) : null}
{virtualizer.getVirtualItems().map((item) => {
const entry = listEntries[item.index];
const virtualRow = getRowProps(item.index, item.start);
const navigationOverrides =
item.index === 0
? {
up: {
type: "item" as const,
itemId: GAME_ACHIEVEMENTS_SEARCH_INPUT_ID,
},
}
: undefined;
if (entry.type === "set") {
return (
<AchievementSetHeader
key={item.key}
setKey={entry.group.key}
set={entry.group.set}
expanded={entry.expanded}
unlockedCount={entry.group.unlockedCount}
totalCount={entry.group.totalCount}
navigationOrder={item.index}
navigationOverrides={navigationOverrides}
spaced={item.index > 0}
virtualRow={virtualRow}
onToggle={() => toggleSet(entry.group.key)}
/>
);
}
const { achievement } = entry;
return (
<AchievementRow
key={item.key}
achievement={achievement}
concealed={isConcealed(achievement)}
revealed={
getAchievementKey(achievement) === lastRevealedName
}
showPoints={hasActiveSubscription}
navigationOrder={item.index}
navigationOverrides={navigationOverrides}
virtualRow={virtualRow}
onSouvenirActivate={handleSouvenirActivate}
onReveal={handleReveal}
/>
);
})}
</ul>
</VerticalFocusGroup>
</section>

View file

@ -6,6 +6,7 @@
min-height: calc(100% + var(--big-picture-header-height));
overflow-x: hidden;
overflow-y: auto;
scrollbar-gutter: stable;
position: relative;
background: var(--background);
@ -56,6 +57,10 @@
gap: calc(var(--spacing-unit) * 6);
}
&--pristine &__summary[data-focused="true"] {
outline: none;
}
&__summary {
position: relative;
display: flex;
@ -166,30 +171,60 @@
transition: width 0.4s ease;
}
&__points-bar {
&__toolbar {
display: flex;
flex-direction: column;
gap: calc(var(--spacing-unit) * 4);
}
&__toolbar-controls {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
background-color: var(--secondary);
border-radius: 12px 12px 0 0;
padding: 16px;
gap: calc(var(--spacing-unit) * 3);
}
&__points-label {
color: var(--text-secondary);
font-size: 14px;
line-height: 1;
&__toolbar-search {
width: 480px;
max-width: 100%;
}
&__points-value {
&__toolbar-points {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
color: var(--text);
font-size: 14px;
font-weight: 500;
line-height: 1;
font-size: 16px;
font-weight: 600;
white-space: nowrap;
}
&__toolbar-points-label {
color: var(--text-secondary);
font-weight: 400;
}
&__toolbar-points-locked {
color: var(--alert);
}
&__toolbar-select {
flex-shrink: 0;
width: fit-content;
.dropdown-select__trigger-anchor,
.dropdown-select__trigger {
width: max-content;
}
}
&__empty {
padding: calc(var(--spacing-unit) * 12) 16px;
background-color: var(--secondary);
border-radius: 12px;
color: var(--text-secondary);
font-size: 16px;
text-align: center;
}
&__list-section {
@ -206,6 +241,16 @@
margin: 0;
padding: 0;
&--virtual {
position: relative;
display: block;
}
> *:first-child .game-achievements-row {
border-top-left-radius: 12px;
border-top-right-radius: 12px;
}
> *:last-child .game-achievements-row {
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
@ -245,6 +290,55 @@
opacity: 0.55;
}
&__icon--concealed {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
color: var(--text-secondary);
background-color: rgba(255, 255, 255, 0.04);
border: 1px dashed rgba(255, 255, 255, 0.2);
box-shadow: none;
}
&--revealed &__icon,
&--revealed &__info {
animation: game-achievement-reveal 0.25s ease-out;
}
&--concealed &__title {
color: var(--text-secondary);
}
&--unlocked &__status {
color: var(--primary);
}
&__hidden-icon {
flex-shrink: 0;
color: var(--text-secondary);
}
&__hardcore-badge {
display: inline-flex;
align-items: center;
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
color: var(--alert);
background-color: var(--alert-background);
}
&__unlock-time {
color: var(--text-secondary);
font-size: 12px;
line-height: 1.25;
}
&__souvenir {
position: relative;
display: flex;
@ -287,6 +381,14 @@
}
}
&__reveal-button {
position: absolute;
inset: 0;
border: 0;
background: transparent;
cursor: pointer;
}
&__info {
flex: 1;
display: flex;
@ -329,22 +431,162 @@
flex-shrink: 0;
}
&__points {
display: flex;
&__stats {
display: grid;
grid-template-columns: auto auto;
justify-content: start;
align-items: center;
gap: 6px;
color: var(--text);
font-size: 14px;
font-weight: 500;
gap: 8px;
width: 96px;
font-variant-numeric: tabular-nums;
}
&__percentage,
&__points {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
align-items: center;
justify-items: start;
font-weight: 600;
line-height: 1;
white-space: nowrap;
svg {
justify-self: center;
}
}
&__percentage {
color: var(--text);
font-size: 16px;
}
&__points {
color: var(--text-secondary);
font-size: 14px;
}
&__status {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
width: 28px;
height: 28px;
color: var(--text-secondary);
}
}
.game-achievements-set {
position: relative;
display: flex;
outline: none;
&--spaced {
padding-top: calc(var(--spacing-unit) * 4);
}
&[data-focused="true"] {
z-index: 1;
}
&[data-focused="true"] &__button {
outline: 2px solid #fff;
outline-offset: 4px;
}
&__button {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
min-height: 56px;
padding: 12px 16px;
border: 0;
border-radius: 12px;
background: transparent;
color: var(--text);
font: inherit;
text-align: left;
cursor: pointer;
}
&__caret {
flex-shrink: 0;
color: var(--text-secondary);
transition: transform 0.15s ease;
}
&--collapsed &__caret {
transform: rotate(-90deg);
}
&__name {
flex: 1;
min-width: 0;
font-size: 20px;
font-weight: 700;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__progress {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 12px;
font-size: 16px;
font-variant-numeric: tabular-nums;
}
&__count {
color: var(--text-secondary);
white-space: nowrap;
}
&__bar {
position: relative;
width: 200px;
height: 6px;
border-radius: 300px;
background-color: rgba(255, 255, 255, 0.12);
overflow: hidden;
}
&__bar-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
border-radius: 300px;
background-color: var(--text);
transition: width 0.4s ease;
}
&__percentage {
min-width: 6ch;
color: var(--text);
font-weight: 500;
text-align: right;
}
}
@keyframes game-achievement-reveal {
from {
opacity: 0;
transform: translateY(4px);
}
}
@media (prefers-reduced-motion: reduce) {
.game-achievements-row--revealed .game-achievements-row__icon,
.game-achievements-row--revealed .game-achievements-row__info {
animation: none;
}
.game-achievements-set__caret {
transition: none;
}
}

View file

@ -9,9 +9,16 @@ import {
import type { GameShop, ShopAssets, LibraryGame } from "@types";
import {
hasCloudSaveExecutableSelection,
getEpicLaunchOptions,
getInstalledStoreMembers,
getOfficialStoreMembers,
isEpicMemberInCurrentScope,
getEpicMemberDownload,
isEpicUpdateInterrupted,
isLibraryGameDownloading,
} from "@shared";
import { useEpicManagedConnection } from "@renderer/hooks/use-epic-managed";
import { useEpicGameUpdate } from "@renderer/hooks/use-epic-game-update";
import { useFinishedDownloadStep } from "@renderer/hooks/use-finished-download-step";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@ -90,6 +97,7 @@ import {
} from "../../components/pages/game/navigation";
import { NavigationService, type FocusOverrideTarget } from "../../services";
import { useNavigationStore } from "../../stores";
import { useBigPictureDownloadsStore } from "../../stores/downloads.store";
import { extractGenreNames } from "./game-metadata";
import "./game.scss";
import { EpicCloudSaves } from "../../components/pages/game/epic-cloud-saves";
@ -509,7 +517,8 @@ function SimilarGamesEmptyState({
export default function Game() {
const { t } = useTranslation("big_picture");
const { showErrorToast, showSuccessToast } = useBigPictureToast();
const { showErrorToast, showSuccessToast, showWarningToast } =
useBigPictureToast();
const { shop, objectId } = useParams<{ shop: GameShop; objectId: string }>();
const navigate = useNavigate();
@ -603,9 +612,33 @@ export default function Game() {
const installedMembers = getInstalledStoreMembers(members);
const epicMember = getOfficialStoreMembers(members).epic;
const [showStoreSelection, setShowStoreSelection] = useState(false);
const [localSaveRetry, setLocalSaveRetry] = useState<LibraryGame | null>(
null
const [showEpicUpdate, setShowEpicUpdate] = useState(false);
const lastPacket = useBigPictureDownloadsStore((state) => state.lastPacket);
const { installationAvailable: epicInstallationAvailable, connection } =
useEpicManagedConnection();
const installedEpicMember =
epicMember?.epicInstallation && epicMember.executablePath
? epicMember
: null;
const epicDownload = getEpicMemberDownload(installedEpicMember, downloadGame);
const epicUpdate = useEpicGameUpdate(
installedEpicMember,
epicInstallationAvailable &&
isEpicMemberInCurrentScope(installedEpicMember, connection),
epicDownload?.status
);
const epicUpdateInterrupted =
!!epicDownload && isEpicUpdateInterrupted(epicDownload);
const epicUpdateInProgress =
!!epicDownload?.epic?.update &&
(isLibraryGameDownloading({ download: epicDownload }) ||
!!epicDownload.queued);
const refreshEpicUpdate = epicUpdate.refresh;
const requestEpicUpdate = epicUpdate.start;
const [localSaveRetry, setLocalSaveRetry] = useState<{
member: LibraryGame;
skipUpdateCheck: boolean;
} | null>(null);
const [epicSaveConflict, setEpicSaveConflict] = useState<LibraryGame | null>(
null
);
@ -629,8 +662,64 @@ export default function Game() {
stop?.();
};
}, [shop, objectId, invalidateLaunch]);
const offerEpicLaunchRecovery = useCallback(
async (
member: LibraryGame,
generation: number,
skipUpdateCheck: boolean
) => {
if (typeof window.electron.getEpicCloudSaveState !== "function") return;
const state = await window.electron
.getEpicCloudSaveState(member.objectId)
.catch(() => null);
if (generation !== launchGeneration.current) return;
if (state?.status === "conflict") {
setEpicSaveConflict(member);
return;
}
if (
canRetryEpicLaunchWithLocalSaves(state) ||
state?.canRunOffline === true
) {
setLocalSaveRetry({ member, skipUpdateCheck });
setCanRetryOffline(state?.canRunOffline === true);
setCanRetryLocalSaves(canRetryEpicLaunchWithLocalSaves(state));
}
},
[]
);
const reportLaunchFailure = useCallback(
async (
member: LibraryGame,
error: unknown,
generation: number,
skipUpdateCheck: boolean
) => {
const code =
member.shop === "epic" ? getEpicLaunchError(error) : undefined;
if (code === "save-conflict") {
setEpicSaveConflict(member);
return;
}
if (code === "update-required") void refreshEpicUpdate(true);
const translation = code ? getEpicManagedError(code) : null;
showErrorToast(
translation
? t(translation.key, { ns: translation.ns })
: t("epic_launch_failed", { ns: "game_details" })
);
if (member.shop === "epic")
await offerEpicLaunchRecovery(member, generation, skipUpdateCheck);
},
[showErrorToast, t, refreshEpicUpdate, offerEpicLaunchRecovery]
);
const launchMember = useCallback(
async (member: LibraryGame, localOnce = false, offline = false) => {
async (
member: LibraryGame,
localOnce = false,
offline = false,
skipUpdateCheck = false
) => {
if (!member.executablePath) return;
setShowStoreSelection(false);
setLocalSaveRetry(null);
@ -644,51 +733,80 @@ export default function Game() {
member.objectId,
member.executablePath,
member.launchOptions,
offline
? { epicOffline: true }
: localOnce
? { epicSavePolicy: "local-once" }
: undefined
getEpicLaunchOptions(localOnce, offline, skipUpdateCheck)
);
} catch (error) {
if (generation !== launchGeneration.current) return;
const code =
member.shop === "epic" ? getEpicLaunchError(error) : undefined;
if (code === "save-conflict") {
setEpicSaveConflict(member);
return;
}
const translation = code ? getEpicManagedError(code) : null;
showErrorToast(
translation
? t(translation.key, { ns: translation.ns })
: t("epic_launch_failed", { ns: "game_details" })
);
if (
member.shop === "epic" &&
typeof window.electron.getEpicCloudSaveState === "function"
) {
const state = await window.electron
.getEpicCloudSaveState(member.objectId)
.catch(() => null);
if (generation !== launchGeneration.current) return;
if (state?.status === "conflict") {
setEpicSaveConflict(member);
return;
}
if (
canRetryEpicLaunchWithLocalSaves(state) ||
state?.canRunOffline === true
) {
setLocalSaveRetry(member);
setCanRetryOffline(state?.canRunOffline === true);
setCanRetryLocalSaves(canRetryEpicLaunchWithLocalSaves(state));
}
}
await reportLaunchFailure(member, error, generation, skipUpdateCheck);
}
},
[showErrorToast, t]
[reportLaunchFailure]
);
const playEpicMember = useCallback(
(member: LibraryGame) => {
setShowStoreSelection(false);
if (epicUpdateInProgress) {
showWarningToast(t("epic_updating", { ns: "game_details" }));
return;
}
if (epicUpdate.update || epicUpdateInterrupted) {
setShowEpicUpdate(true);
return;
}
void launchMember(member);
},
[
epicUpdate.update,
epicUpdateInterrupted,
epicUpdateInProgress,
launchMember,
showWarningToast,
t,
]
);
const startEpicUpdate = useCallback(
async (resume = false) => {
setShowEpicUpdate(false);
const result = await requestEpicUpdate(lastPacket !== null, resume);
if (result.ok) {
await updateGame();
globalThis.window.dispatchEvent(new Event("library-update"));
return;
}
const translation = getEpicManagedError(result.error);
showErrorToast(t("epic_update_failed", { ns: "game_details" }), {
message: t(translation.key, { ns: translation.ns }),
});
},
[requestEpicUpdate, lastPacket, updateGame, showErrorToast, t]
);
const resumeEpicUpdate = useCallback(async () => {
if (!installedEpicMember) return;
if (epicDownload?.status !== "paused" && epicDownload?.status !== "error") {
await startEpicUpdate(true);
return;
}
setShowEpicUpdate(false);
try {
const resumed = await window.electron.resumeGameDownload(
installedEpicMember.shop,
installedEpicMember.objectId,
"queueIfActive"
);
if (resumed === false)
showErrorToast(t("epic_update_failed", { ns: "game_details" }));
} catch {
showErrorToast(t("epic_update_failed", { ns: "game_details" }));
}
await updateGame();
}, [
installedEpicMember,
epicDownload?.status,
startEpicUpdate,
showErrorToast,
t,
updateGame,
]);
const { launchSettings, customizationSettings, cloudSettings } =
useGameSettingsModalState({
game,
@ -1102,7 +1220,7 @@ export default function Game() {
return;
}
if (game.shop === "epic" && game.epicInstallation) {
await launchMember(game);
playEpicMember(game);
return;
}
await openGame();
@ -1128,9 +1246,9 @@ export default function Game() {
}, [
game,
installedMembers,
launchMember,
launchClassicsWithErrorHandling,
openGame,
playEpicMember,
]);
const handleDiscSelectionConfirm = useCallback(
@ -1623,7 +1741,11 @@ export default function Game() {
<Button
key={member.id}
variant="primary"
onClick={() => void launchMember(member)}
onClick={() =>
member.shop === "epic"
? playEpicMember(member)
: void launchMember(member)
}
>
{t(member.shop === "steam" ? "play_on_steam" : "play_on_epic", {
ns: "game_details",
@ -1632,6 +1754,55 @@ export default function Game() {
))}
</HorizontalFocusGroup>
</Modal>
<Modal
visible={showEpicUpdate}
title={t(
epicUpdateInterrupted ? "epic_update_resume" : "epic_update",
{ ns: "game_details" }
)}
description={
epicUpdate.update?.publishedVersion
? t("epic_update_available", {
ns: "game_details",
version: epicUpdate.update.publishedVersion,
})
: undefined
}
onClose={() => setShowEpicUpdate(false)}
>
<HorizontalFocusGroup>
{epicUpdateInterrupted ? (
<Button
variant="primary"
disabled={epicUpdate.starting}
onClick={() => void resumeEpicUpdate()}
>
{t("epic_update_resume", { ns: "game_details" })}
</Button>
) : (
<>
<Button
variant="primary"
disabled={epicUpdate.starting}
onClick={() => void startEpicUpdate()}
>
{t("epic_update", { ns: "game_details" })}
</Button>
<Button
variant="secondary"
disabled={epicUpdate.starting}
onClick={() => {
setShowEpicUpdate(false);
const member = installedEpicMember ?? game;
if (member) void launchMember(member, false, false, true);
}}
>
{t("epic_play_without_update", { ns: "game_details" })}
</Button>
</>
)}
</HorizontalFocusGroup>
</Modal>
<Modal
visible={!!localSaveRetry}
title={t(
@ -1653,7 +1824,13 @@ export default function Game() {
<Button
variant="primary"
onClick={() => {
if (localSaveRetry) void launchMember(localSaveRetry, true);
if (localSaveRetry)
void launchMember(
localSaveRetry.member,
true,
false,
localSaveRetry.skipUpdateCheck
);
}}
>
{t("epic_play_local_saves", { ns: "game_details" })}
@ -1664,7 +1841,7 @@ export default function Game() {
variant="secondary"
onClick={() => {
if (localSaveRetry)
void launchMember(localSaveRetry, false, true);
void launchMember(localSaveRetry.member, false, true);
}}
>
{t("epic_play_offline", { ns: "game_details" })}

View file

@ -15,11 +15,13 @@ import type {
import {
buildUserSouvenirsPath,
getSouvenirKey,
isAchievementConcealed,
normalizeProfileSouvenir,
SOUVENIRS_PAGE_SIZE,
} from "@shared";
import { useCallback, useEffect, useRef, useState } from "react";
import { getGameIdentityKey } from "../../helpers";
import { useUserPreferences } from "../../hooks";
const PROFILE_RECENT_ACHIEVEMENT_GROUP_LIMIT = 2;
const PROFILE_RECENT_ACHIEVEMENTS_PER_GAME = 2;
@ -69,7 +71,8 @@ function normalizeUserProfile(profile: UserProfile): UserProfile {
}
function getComparedAchievement(
achievement: ProfileComparedAchievement
achievement: ProfileComparedAchievement,
showHiddenAchievements: boolean
): ProfileRecentAchievement | null {
if (
!achievement.targetStat.unlocked ||
@ -82,7 +85,15 @@ function getComparedAchievement(
key: `${achievement.displayName}-${achievement.targetStat.unlockTime}`,
icon: achievement.icon,
displayName: achievement.displayName,
description: achievement.description,
description: isAchievementConcealed(
{
hidden: achievement.hidden,
unlocked: Boolean(achievement.ownerStat?.unlocked),
},
showHiddenAchievements
)
? ""
: achievement.description,
unlockTime: achievement.targetStat.unlockTime,
};
}
@ -246,7 +257,8 @@ async function fetchAchievementGames(
async function fetchGameAchievements(
game: UserGame,
targetUserId: string,
isOwnProfile: boolean
isOwnProfile: boolean,
showHiddenAchievements: boolean
): Promise<AchievementGame> {
if (isOwnProfile) {
const achievements =
@ -276,7 +288,9 @@ async function fetchGameAchievements(
return {
game,
achievements: ensureArray(comparison.achievements)
.map(getComparedAchievement)
.map((achievement) =>
getComparedAchievement(achievement, showHiddenAchievements)
)
.filter(
(achievement): achievement is ProfileRecentAchievement =>
achievement !== null
@ -286,11 +300,19 @@ async function fetchGameAchievements(
async function fetchRecentAchievementGroups(
targetUserId: string,
isOwnProfile: boolean
isOwnProfile: boolean,
showHiddenAchievements: boolean
) {
const games = await fetchAchievementGames(targetUserId);
const settled = await Promise.allSettled(
games.map((game) => fetchGameAchievements(game, targetUserId, isOwnProfile))
games.map((game) =>
fetchGameAchievements(
game,
targetUserId,
isOwnProfile,
showHiddenAchievements
)
)
);
const achievementsByGame = settled
.filter(
@ -562,6 +584,8 @@ export function useRecentAchievements(
) {
const [groups, setGroups] = useState<ProfileRecentAchievementGroup[]>([]);
const [refreshKey, setRefreshKey] = useState(0);
const showHiddenAchievements =
useUserPreferences()?.showHiddenAchievementsDescription ?? false;
useEffect(() => {
return globalThis.window.electron.onAchievementUnlocked(() => {
@ -577,7 +601,11 @@ export function useRecentAchievements(
let isMounted = true;
fetchRecentAchievementGroups(targetUserId, isOwnProfile)
fetchRecentAchievementGroups(
targetUserId,
isOwnProfile,
showHiddenAchievements
)
.then((recentGroups) => {
if (isMounted) setGroups(recentGroups);
})
@ -588,7 +616,13 @@ export function useRecentAchievements(
return () => {
isMounted = false;
};
}, [hasActiveSubscription, isOwnProfile, refreshKey, targetUserId]);
}, [
hasActiveSubscription,
isOwnProfile,
refreshKey,
showHiddenAchievements,
targetUserId,
]);
return groups;
}

View file

@ -576,7 +576,20 @@
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "تشغيل — Offline",
"epic_prerequisites_required": "اسمح بمتطلبات اللعبة وحاول مجددًا.",
"epic_update_required": "حدّث اللعبة عبر خيار Epic Games باستخدام المجلد نفسه.",
"epic_update_required": "يتوفر إصدار جديد. استخدم «تحديث» لتثبيته.",
"epic_update": "تحديث",
"epic_update_resume": "استئناف التحديث",
"epic_updating": "جارٍ التحديث…",
"epic_play_without_update": "اللعب دون تحديث",
"epic_update_available": "الإصدار {{version}} متاح",
"epic_update_failed": "تعذّر تحديث اللعبة",
"epic_extras_title": "محتوى إضافي",
"epic_extras_selected": "المحدد: {{selected}} من {{total}}",
"epic_select_all": "تحديد الكل",
"epic_dlc_installed": "مثبت",
"epic_selection_size": "تنزيل {{size}}",
"epic_not_enough_space": "لا توجد مساحة كافية على القرص لهذا الاختيار.",
"epic_selection_invalid": "تغيّر الاختيار. أعد فتح خيارات التنزيل وحاول مجددًا.",
"epic_retry": "حاول مرة أخرى"
},
"activation": {

View file

@ -572,7 +572,20 @@
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Гуляць — Offline",
"epic_prerequisites_required": "Дазвольце ўсталяванне неабходных кампанентаў гульні і паспрабуйце зноў.",
"epic_update_required": "Абнавіце гульню праз варыянт Epic Games, выкарыстоўваючы тую ж папку.",
"epic_update_required": "Даступная новая версія. Націсніце «Абнавіць», каб усталяваць яе.",
"epic_update": "Абнавіць",
"epic_update_resume": "Працягнуць абнаўленне",
"epic_updating": "Абнаўленне…",
"epic_play_without_update": "Гуляць без абнаўлення",
"epic_update_available": "Даступная версія {{version}}",
"epic_update_failed": "Не ўдалося абнавіць гульню",
"epic_extras_title": "Дадатковы змест",
"epic_extras_selected": "Выбрана: {{selected}} з {{total}}",
"epic_select_all": "Выбраць усё",
"epic_dlc_installed": "Усталявана",
"epic_selection_size": "{{size}} для спампоўкі",
"epic_not_enough_space": "Недастаткова месца на дыску для гэтага выбару.",
"epic_selection_invalid": "Выбар змяніўся. Адкрыйце параметры спампоўкі зноў і паспрабуйце яшчэ раз.",
"epic_retry": "Паспрабаваць яшчэ раз"
},
"activation": {

View file

@ -731,7 +731,20 @@
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Играй — Офлайн",
"epic_prerequisites_required": "Разрешете необходимите компоненти на играта и опитайте отново.",
"epic_update_required": "Обновете играта чрез опцията Epic Games, като използвате същата папка.",
"epic_update_required": "Налична е нова версия. Използвайте „Актуализирай“, за да я инсталирате.",
"epic_update": "Актуализирай",
"epic_update_resume": "Продължи актуализацията",
"epic_updating": "Актуализиране…",
"epic_play_without_update": "Играй без актуализиране",
"epic_update_available": "Налична е версия {{version}}",
"epic_update_failed": "Играта не можа да бъде актуализирана",
"epic_extras_title": "Допълнително съдържание",
"epic_extras_selected": "Избрани: {{selected}} от {{total}}",
"epic_select_all": "Избери всички",
"epic_dlc_installed": "Инсталирано",
"epic_selection_size": "{{size}} за изтегляне",
"epic_not_enough_space": "Няма достатъчно място на диска за този избор.",
"epic_selection_invalid": "Изборът се промени. Отвори отново опциите за изтегляне и опитай пак.",
"epic_retry": "Опитайте отново"
},
"activation": {

View file

@ -575,7 +575,20 @@
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Inicia — Offline",
"epic_prerequisites_required": "Autoritza els requisits previs del joc i torna-ho a provar.",
"epic_update_required": "Actualitza el joc amb l’opció Epic Games fent servir la mateixa carpeta.",
"epic_update_required": "Hi ha una versió nova disponible. Fes servir Actualitza per instal·lar-la.",
"epic_update": "Actualitza",
"epic_update_resume": "Reprèn l'actualització",
"epic_updating": "S'està actualitzant…",
"epic_play_without_update": "Juga sense actualitzar",
"epic_update_available": "Versió {{version}} disponible",
"epic_update_failed": "No s'ha pogut actualitzar el joc",
"epic_extras_title": "Contingut addicional",
"epic_extras_selected": "Seleccionats: {{selected}} de {{total}}",
"epic_select_all": "Selecciona-ho tot",
"epic_dlc_installed": "Instal·lat",
"epic_selection_size": "{{size}} de baixada",
"epic_not_enough_space": "No hi ha prou espai al disc per a aquesta selecció.",
"epic_selection_invalid": "La selecció ha canviat. Torna a obrir les opcions de baixada i torna-ho a provar.",
"epic_retry": "Torna-ho a provar"
},
"activation": {

View file

@ -576,7 +576,20 @@
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Hrát — Offline",
"epic_prerequisites_required": "Povolte požadované součásti hry a zkuste to znovu.",
"epic_update_required": "Aktualizujte hru přes možnost Epic Games do stejné složky.",
"epic_update_required": "Je k dispozici nová verze. Nainstalujte ji pomocí Aktualizovat.",
"epic_update": "Aktualizovat",
"epic_update_resume": "Pokračovat v aktualizaci",
"epic_updating": "Aktualizace…",
"epic_play_without_update": "Hrát bez aktualizace",
"epic_update_available": "Je dostupná verze {{version}}",
"epic_update_failed": "Hru se nepodařilo aktualizovat",
"epic_extras_title": "Doplňkový obsah",
"epic_extras_selected": "Vybráno: {{selected}} z {{total}}",
"epic_select_all": "Vybrat vše",
"epic_dlc_installed": "Nainstalováno",
"epic_selection_size": "{{size}} ke stažení",
"epic_not_enough_space": "Pro tento výběr není dost místa na disku.",
"epic_selection_invalid": "Výběr se změnil. Znovu otevři možnosti stahování a zkus to znovu.",
"epic_retry": "Zkusit znovu"
},
"activation": {

View file

@ -576,7 +576,20 @@
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Spil — Offline",
"epic_prerequisites_required": "Godkend spillets forudsætninger, og prøv igen.",
"epic_update_required": "Opdater spillet via Epic Games-indstillingen i den samme mappe.",
"epic_update_required": "En ny version er tilgængelig. Brug Opdater for at installere den.",
"epic_update": "Opdater",
"epic_update_resume": "Genoptag opdatering",
"epic_updating": "Opdaterer…",
"epic_play_without_update": "Spil uden at opdatere",
"epic_update_available": "Version {{version}} er tilgængelig",
"epic_update_failed": "Spillet kunne ikke opdateres",
"epic_extras_title": "Ekstra indhold",
"epic_extras_selected": "Valgt: {{selected}} af {{total}}",
"epic_select_all": "Vælg alle",
"epic_dlc_installed": "Installeret",
"epic_selection_size": "{{size}} download",
"epic_not_enough_space": "Der er ikke nok diskplads til dette valg.",
"epic_selection_invalid": "Valget er ændret. Åbn downloadindstillingerne igen, og prøv igen.",
"epic_retry": "Prøv igen"
},
"activation": {

View file

@ -576,7 +576,20 @@
"epic_compatibility_automatic": "Automatisch",
"epic_play_offline": "Spielen — Offline",
"epic_prerequisites_required": "Genehmige die Spielvoraussetzungen und versuche es erneut.",
"epic_update_required": "Aktualisiere das Spiel über die Epic Games-Option im selben Ordner.",
"epic_update_required": "Eine neue Version ist verfügbar. Installiere sie mit Aktualisieren.",
"epic_update": "Aktualisieren",
"epic_update_resume": "Aktualisierung fortsetzen",
"epic_updating": "Wird aktualisiert…",
"epic_play_without_update": "Ohne Aktualisierung spielen",
"epic_update_available": "Version {{version}} verfügbar",
"epic_update_failed": "Das Spiel konnte nicht aktualisiert werden",
"epic_extras_title": "Zusatzinhalte",
"epic_extras_selected": "Ausgewählt: {{selected}} von {{total}}",
"epic_select_all": "Alle auswählen",
"epic_dlc_installed": "Installiert",
"epic_selection_size": "{{size}} Download",
"epic_not_enough_space": "Nicht genug Speicherplatz für diese Auswahl.",
"epic_selection_invalid": "Die Auswahl hat sich geändert. Öffne die Download-Optionen erneut und versuche es noch einmal.",
"epic_retry": "Erneut versuchen"
},
"activation": {

View file

@ -777,7 +777,20 @@
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Παίξτε — Εκτός σύνδεσης",
"epic_prerequisites_required": "Εγκρίνετε τα προαπαιτούμενα του παιχνιδιού και δοκιμάστε ξανά.",
"epic_update_required": "Ενημερώστε το παιχνίδι μέσω της επιλογής Epic Games στον ίδιο φάκελο.",
"epic_update_required": "Υπάρχει διαθέσιμη νέα έκδοση. Χρησιμοποιήστε την Ενημέρωση για να την εγκαταστήσετε.",
"epic_update": "Ενημέρωση",
"epic_update_resume": "Συνέχιση ενημέρωσης",
"epic_updating": "Ενημέρωση…",
"epic_play_without_update": "Παιχνίδι χωρίς ενημέρωση",
"epic_update_available": "Η έκδοση {{version}} είναι διαθέσιμη",
"epic_update_failed": "Δεν ήταν δυνατή η ενημέρωση του παιχνιδιού",
"epic_extras_title": "Επιπλέον περιεχόμενο",
"epic_extras_selected": "Επιλεγμένα: {{selected}} από {{total}}",
"epic_select_all": "Επιλογή όλων",
"epic_dlc_installed": "Εγκατεστημένο",
"epic_selection_size": "{{size}} λήψη",
"epic_not_enough_space": "Δεν υπάρχει αρκετός χώρος στον δίσκο για αυτή την επιλογή.",
"epic_selection_invalid": "Η επιλογή άλλαξε. Ανοίξτε ξανά τις επιλογές λήψης και δοκιμάστε πάλι.",
"epic_retry": "Δοκιμάστε ξανά"
},
"activation": {

View file

@ -1103,7 +1103,20 @@
"epic_play_offline_title": "Play offline",
"epic_play_offline_description": "Hydra couldn't prepare this Epic launch, so cloud saves weren't restored. You can still start the game in Epic's offline mode.",
"epic_prerequisites_required": "The game's dependencies could not be prepared. Try again.",
"epic_update_required": "Update the game through the Epic Games option using the same folder.",
"epic_update_required": "A new version is available. Use Update to install it.",
"epic_update": "Update",
"epic_update_resume": "Resume update",
"epic_updating": "Updating…",
"epic_play_without_update": "Play without updating",
"epic_update_available": "Version {{version}} is available",
"epic_update_failed": "Couldn't update the game",
"epic_extras_title": "Extra content",
"epic_extras_selected": "Selected: {{selected}} of {{total}}",
"epic_select_all": "Select all",
"epic_dlc_installed": "Installed",
"epic_selection_size": "{{size}} download",
"epic_not_enough_space": "Not enough disk space for this selection.",
"epic_selection_invalid": "The selection changed. Reopen the download options and try again.",
"epic_retry": "Try again"
},
"activation": {
@ -1367,7 +1380,7 @@
"launch_big_picture_in_library_page": "Launch Big Picture in the Library page",
"disable_nsfw_alert": "Disable NSFW alert",
"seed_after_download_complete": "Seed after download complete",
"show_hidden_achievement_description": "Show hidden achievement descriptions before unlocking them",
"show_hidden_achievement_description": "Show hidden achievements before unlocking them",
"account": "Account",
"hydra_cloud": "Hydra Cloud",
"cloud_gifts": "Hydra Cloud gifts",
@ -2368,7 +2381,34 @@
"available_points": "Available points:",
"how_to_earn_achievements_points": "How to earn achievement points?",
"view_souvenir": "View souvenir",
"souvenir_alt": "Screenshot taken when {{achievement}} was unlocked"
"souvenir_alt": "Screenshot taken when {{achievement}} was unlocked",
"search_achievements": "Search achievements",
"sort_achievements": "Sort achievements",
"sort_most_common": "Most common",
"sort_most_rare": "Most rare",
"filter_achievements": "Filter achievements",
"filter_all": "All ({{count}})",
"filter_unlocked": "Unlocked ({{count}})",
"filter_locked": "Locked ({{count}})",
"hidden_achievement": "Hidden achievement",
"click_to_reveal": "Click to reveal",
"select_to_reveal": "Select to reveal",
"no_achievements_found": "No achievements match your search",
"unlocked": "Unlocked",
"all_achievements": "All achievements",
"locked": "Locked",
"unlock_percentage_tooltip": "{{percentage}} of players unlocked this achievement",
"compare_unlocked_by": "{{name}} unlocked at {{date}}",
"compare_locked_for": "{{name}} hasn't unlocked this yet",
"global_stats": "Global stats",
"hydra_points": "Hydra points",
"compare_with_me": "Compare with me",
"stop_comparing": "Stop comparing",
"achievement_set_base_game": "Base game",
"achievement_set_other": "Other",
"sort_recently_unlocked": "Recently unlocked",
"compare_filter_both": "Unlocked by both",
"compare_filter_neither": "Locked for both"
},
"hydra_cloud": {
"subscription_tour_title": "Hydra Cloud Subscription",
@ -2786,6 +2826,18 @@
"find_friends": "Find friends",
"sign_in_hint": "Sign in to see your position, your games and your friends' ranking.",
"review_score": "{{score}}/5",
"report_cheater": "Report {{name}} for cheating",
"report_cheater_title": "Report for cheating",
"report_cheater_description": "{{name}} will be reported for cheating on this ranking (#{{rank}}, {{value}}). A moderator will review it.",
"report_cheater_details": "Details (optional)",
"report_cheater_details_placeholder": "What looks wrong about this record?",
"report_cheater_confirm": "Report",
"report_cheater_cancel": "Cancel",
"report_cheater_sent": "Report sent. Thanks for helping keep the rankings fair.",
"report_cheater_rate_limited": "You've sent too many reports. Try again later.",
"report_cheater_failed": "Couldn't send the report. Try again.",
"review_likes_one": "{{count}} upvote",
"review_likes_other": "{{count}} upvotes",
"review_top_week": "Most upvoted review of the week",
"review_top_all": "Most upvoted review of all time",
"review_top_of": "Most upvoted review by {{name}}",

View file

@ -1056,7 +1056,20 @@
"epic_play_offline_title": "Jugar sin conexión",
"epic_play_offline_description": "Hydra no pudo preparar el inicio en Epic, así que no se restauraron los guardados en la nube. Aún puedes iniciar el juego en el modo sin conexión de Epic.",
"epic_prerequisites_required": "Autoriza los requisitos previos del juego e inténtalo de nuevo.",
"epic_update_required": "Actualiza el juego desde la opción Epic Games usando la misma carpeta.",
"epic_update_required": "Hay una nueva versión disponible. Usa Actualizar para instalarla.",
"epic_update": "Actualizar",
"epic_update_resume": "Reanudar actualización",
"epic_updating": "Actualizando…",
"epic_play_without_update": "Jugar sin actualizar",
"epic_update_available": "Versión {{version}} disponible",
"epic_update_failed": "No se pudo actualizar el juego",
"epic_extras_title": "Contenido adicional",
"epic_extras_selected": "Seleccionados: {{selected}} de {{total}}",
"epic_select_all": "Seleccionar todo",
"epic_dlc_installed": "Instalado",
"epic_selection_size": "{{size}} de descarga",
"epic_not_enough_space": "No hay suficiente espacio en disco para esta selección.",
"epic_selection_invalid": "La selección cambió. Vuelve a abrir las opciones de descarga e inténtalo de nuevo.",
"epic_retry": "Intentar de nuevo"
},
"activation": {
@ -2152,7 +2165,34 @@
"available_points": "Puntos disponibles:",
"how_to_earn_achievements_points": "¿Como conseguir puntos por logros?",
"view_souvenir": "Ver recuerdo",
"souvenir_alt": "Captura tomada al desbloquear {{achievement}}"
"souvenir_alt": "Captura tomada al desbloquear {{achievement}}",
"search_achievements": "Buscar logros",
"sort_achievements": "Ordenar logros",
"sort_most_common": "Más comunes",
"sort_most_rare": "Más raros",
"filter_achievements": "Filtrar logros",
"filter_all": "Todos ({{count}})",
"filter_unlocked": "Desbloqueados ({{count}})",
"filter_locked": "Bloqueados ({{count}})",
"hidden_achievement": "Logro oculto",
"click_to_reveal": "Haz clic para revelar",
"select_to_reveal": "Selecciona para revelar",
"no_achievements_found": "Ningún logro coincide con tu búsqueda",
"unlocked": "Desbloqueado",
"all_achievements": "Todos los logros",
"locked": "Bloqueado",
"unlock_percentage_tooltip": "{{percentage}} de los jugadores desbloquearon este logro",
"compare_unlocked_by": "{{name}} lo desbloqueó el {{date}}",
"compare_locked_for": "{{name}} aún no lo ha desbloqueado",
"global_stats": "Estadísticas globales",
"hydra_points": "Puntos Hydra",
"compare_with_me": "Comparar conmigo",
"stop_comparing": "Dejar de comparar",
"achievement_set_base_game": "Juego base",
"achievement_set_other": "Otros",
"sort_recently_unlocked": "Desbloqueados recientemente",
"compare_filter_both": "Desbloqueados por ambos",
"compare_filter_neither": "Bloqueados para ambos"
},
"hydra_cloud": {
"subscription_tour_title": "Suscripción Hydra Cloud",
@ -2514,6 +2554,18 @@
"find_friends": "Buscar amigos",
"sign_in_hint": "Inicia sesión para ver tu posición, tus juegos y la clasificación de tus amigos.",
"review_score": "{{score}}/5",
"report_cheater": "Denunciar a {{name}} por trampas",
"report_cheater_title": "Denunciar por trampas",
"report_cheater_description": "{{name}} será denunciado por trampas en este ranking (#{{rank}}, {{value}}). Un moderador lo revisará.",
"report_cheater_details": "Detalles (opcional)",
"report_cheater_details_placeholder": "¿Qué parece incorrecto en este récord?",
"report_cheater_confirm": "Denunciar",
"report_cheater_cancel": "Cancelar",
"report_cheater_sent": "Denuncia enviada. Gracias por ayudar a mantener los rankings justos.",
"report_cheater_rate_limited": "Enviaste demasiadas denuncias. Inténtalo más tarde.",
"report_cheater_failed": "No se pudo enviar la denuncia. Inténtalo de nuevo.",
"review_likes_one": "{{count}} voto positivo",
"review_likes_other": "{{count}} votos positivos",
"review_top_week": "Reseña más votada de la semana",
"review_top_all": "Reseña más votada de la historia",
"review_top_of": "Reseña más votada de {{name}}",

View file

@ -576,7 +576,20 @@
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Mängi — Offline",
"epic_prerequisites_required": "Luba mängu vajalikud komponendid ja proovi uuesti.",
"epic_update_required": "Uuenda mängu Epic Gamesi valiku kaudu samasse kausta.",
"epic_update_required": "Saadaval on uus versioon. Selle paigaldamiseks kasuta nuppu Uuenda.",
"epic_update": "Uuenda",
"epic_update_resume": "Jätka uuendamist",
"epic_updating": "Uuendamine…",
"epic_play_without_update": "Mängi uuendamata",
"epic_update_available": "Versioon {{version}} on saadaval",
"epic_update_failed": "Mängu ei õnnestunud uuendada",
"epic_extras_title": "Lisasisu",
"epic_extras_selected": "Valitud: {{selected}} / {{total}}",
"epic_select_all": "Vali kõik",
"epic_dlc_installed": "Paigaldatud",
"epic_selection_size": "{{size}} allalaadimist",
"epic_not_enough_space": "Selle valiku jaoks pole piisavalt kettaruumi.",
"epic_selection_invalid": "Valik muutus. Ava allalaadimise valikud uuesti ja proovi uuesti.",
"epic_retry": "Proovi uuesti"
},
"activation": {

View file

@ -572,7 +572,20 @@
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "بازی — Offline",
"epic_prerequisites_required": "پیش‌نیازهای بازی را تأیید کنید و دوباره تلاش کنید.",
"epic_update_required": "بازی را از گزینهٔ Epic Games با همان پوشه به‌روزرسانی کنید.",
"epic_update_required": "نسخهٔ جدیدی در دسترس است. برای نصب آن از «به‌روزرسانی» استفاده کنید.",
"epic_update": "به‌روزرسانی",
"epic_update_resume": "ادامهٔ به‌روزرسانی",
"epic_updating": "در حال به‌روزرسانی…",
"epic_play_without_update": "بازی بدون به‌روزرسانی",
"epic_update_available": "نسخهٔ {{version}} در دسترس است",
"epic_update_failed": "به‌روزرسانی بازی ممکن نشد",
"epic_extras_title": "محتوای اضافی",
"epic_extras_selected": "انتخاب‌شده: {{selected}} از {{total}}",
"epic_select_all": "انتخاب همه",
"epic_dlc_installed": "نصب‌شده",
"epic_selection_size": "{{size}} دانلود",
"epic_not_enough_space": "فضای دیسک برای این انتخاب کافی نیست.",
"epic_selection_invalid": "انتخاب تغییر کرد. گزینه‌های دانلود را دوباره باز کنید و دوباره تلاش کنید.",
"epic_retry": "تلاش دوباره"
},
"activation": {

View file

@ -576,7 +576,20 @@
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Pelaa — Offline",
"epic_prerequisites_required": "Hyväksy pelin edellyttämät osat ja yritä uudelleen.",
"epic_update_required": "Päivitä peli Epic Games -vaihtoehdolla samaan kansioon.",
"epic_update_required": "Uusi versio on saatavilla. Asenna se valitsemalla Päivitä.",
"epic_update": "Päivitä",
"epic_update_resume": "Jatka päivitystä",
"epic_updating": "Päivitetään…",
"epic_play_without_update": "Pelaa päivittämättä",
"epic_update_available": "Versio {{version}} on saatavilla",
"epic_update_failed": "Pelin päivittäminen epäonnistui",
"epic_extras_title": "Lisäsisältö",
"epic_extras_selected": "Valittu: {{selected}}/{{total}}",
"epic_select_all": "Valitse kaikki",
"epic_dlc_installed": "Asennettu",
"epic_selection_size": "{{size}} ladattavaa",
"epic_not_enough_space": "Levytilaa ei ole tarpeeksi tälle valinnalle.",
"epic_selection_invalid": "Valinta muuttui. Avaa latausasetukset uudelleen ja yritä uudestaan.",
"epic_retry": "Yritä uudelleen"
},
"activation": {

View file

@ -1044,7 +1044,20 @@
"epic_compatibility_automatic": "Automatique",
"epic_play_offline": "Jouer — Hors ligne",
"epic_prerequisites_required": "Autorisez les prérequis du jeu et réessayez.",
"epic_update_required": "Mettez le jeu à jour via l’option Epic Games en utilisant le même dossier.",
"epic_update_required": "Une nouvelle version est disponible. Utilisez Mettre à jour pour l’installer.",
"epic_update": "Mettre à jour",
"epic_update_resume": "Reprendre la mise à jour",
"epic_updating": "Mise à jour…",
"epic_play_without_update": "Jouer sans mettre à jour",
"epic_update_available": "Version {{version}} disponible",
"epic_update_failed": "Impossible de mettre à jour le jeu",
"epic_extras_title": "Contenu supplémentaire",
"epic_extras_selected": "Sélectionnés : {{selected}} sur {{total}}",
"epic_select_all": "Tout sélectionner",
"epic_dlc_installed": "Installé",
"epic_selection_size": "{{size}} à télécharger",
"epic_not_enough_space": "Espace disque insuffisant pour cette sélection.",
"epic_selection_invalid": "La sélection a changé. Rouvrez les options de téléchargement et réessayez.",
"epic_retry": "Réessayer"
},
"activation": {

View file

@ -598,7 +598,20 @@
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Játék — Offline",
"epic_prerequisites_required": "Engedélyezd a játék előfeltételeit, és próbáld újra.",
"epic_update_required": "Frissítsd a játékot az Epic Games lehetőséggel ugyanabba a mappába.",
"epic_update_required": "Új verzió érhető el. A telepítéshez használd a Frissítés gombot.",
"epic_update": "Frissítés",
"epic_update_resume": "Frissítés folytatása",
"epic_updating": "Frissítés…",
"epic_play_without_update": "Játék frissítés nélkül",
"epic_update_available": "Elérhető a(z) {{version}} verzió",
"epic_update_failed": "Nem sikerült frissíteni a játékot",
"epic_extras_title": "Kiegészítő tartalom",
"epic_extras_selected": "Kiválasztva: {{selected}} / {{total}}",
"epic_select_all": "Összes kijelölése",
"epic_dlc_installed": "Telepítve",
"epic_selection_size": "{{size}} letöltés",
"epic_not_enough_space": "Nincs elég lemezterület ehhez a kiválasztáshoz.",
"epic_selection_invalid": "A kiválasztás megváltozott. Nyisd meg újra a letöltési beállításokat, és próbáld újra.",
"epic_retry": "Próbáld újra"
},
"activation": {

View file

@ -748,7 +748,20 @@
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Main — Offline",
"epic_prerequisites_required": "Izinkan prasyarat gim dan coba lagi.",
"epic_update_required": "Perbarui gim melalui opsi Epic Games menggunakan folder yang sama.",
"epic_update_required": "Versi baru tersedia. Gunakan Perbarui untuk memasangnya.",
"epic_update": "Perbarui",
"epic_update_resume": "Lanjutkan pembaruan",
"epic_updating": "Memperbarui…",
"epic_play_without_update": "Main tanpa memperbarui",
"epic_update_available": "Versi {{version}} tersedia",
"epic_update_failed": "Tidak dapat memperbarui game",
"epic_extras_title": "Konten tambahan",
"epic_extras_selected": "Dipilih: {{selected}} dari {{total}}",
"epic_select_all": "Pilih semua",
"epic_dlc_installed": "Terinstal",
"epic_selection_size": "{{size}} unduhan",
"epic_not_enough_space": "Ruang disk tidak cukup untuk pilihan ini.",
"epic_selection_invalid": "Pilihan berubah. Buka kembali opsi unduhan dan coba lagi.",
"epic_retry": "Coba lagi"
},
"activation": {

View file

@ -777,7 +777,20 @@
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Gioca — Offline",
"epic_prerequisites_required": "Autorizza i prerequisiti del gioco e riprova.",
"epic_update_required": "Aggiorna il gioco dall’opzione Epic Games usando la stessa cartella.",
"epic_update_required": "È disponibile una nuova versione. Usa Aggiorna per installarla.",
"epic_update": "Aggiorna",
"epic_update_resume": "Riprendi aggiornamento",
"epic_updating": "Aggiornamento…",
"epic_play_without_update": "Gioca senza aggiornare",
"epic_update_available": "Versione {{version}} disponibile",
"epic_update_failed": "Impossibile aggiornare il gioco",
"epic_extras_title": "Contenuti aggiuntivi",
"epic_extras_selected": "Selezionati: {{selected}} di {{total}}",
"epic_select_all": "Seleziona tutto",
"epic_dlc_installed": "Installato",
"epic_selection_size": "{{size}} da scaricare",
"epic_not_enough_space": "Spazio su disco insufficiente per questa selezione.",
"epic_selection_invalid": "La selezione è cambiata. Riapri le opzioni di download e riprova.",
"epic_retry": "Riprova"
},
"activation": {

View file

@ -598,7 +598,20 @@
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "プレイ — Offline",
"epic_prerequisites_required": "ゲームの前提コンポーネントを許可して再試行してください。",
"epic_update_required": "Epic Games のオプションから同じフォルダーにゲームを更新してください。",
"epic_update_required": "新しいバージョンがあります。「アップデート」でインストールしてください。",
"epic_update": "アップデート",
"epic_update_resume": "アップデートを再開",
"epic_updating": "アップデート中…",
"epic_play_without_update": "アップデートせずにプレイ",
"epic_update_available": "バージョン {{version}} が利用可能です",
"epic_update_failed": "ゲームをアップデートできませんでした",
"epic_extras_title": "追加コンテンツ",
"epic_extras_selected": "選択済み: {{selected}} / {{total}}",
"epic_select_all": "すべて選択",
"epic_dlc_installed": "インストール済み",
"epic_selection_size": "ダウンロード {{size}}",
"epic_not_enough_space": "この選択に必要なディスク容量が足りません。",
"epic_selection_invalid": "選択内容が変わりました。ダウンロードオプションを開き直して、もう一度お試しください。",
"epic_retry": "再試行"
},
"activation": {

View file

@ -575,7 +575,20 @@
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Ойнау — Offline",
"epic_prerequisites_required": "Ойынға қажетті құрамдастарға рұқсат беріп, қайталап көріңіз.",
"epic_update_required": "Ойынды Epic Games опциясы арқылы сол қалтаға жаңартыңыз.",
"epic_update_required": "Жаңа нұсқа қолжетімді. Оны орнату үшін «Жаңарту» түймесін басыңыз.",
"epic_update": "Жаңарту",
"epic_update_resume": "Жаңартуды жалғастыру",
"epic_updating": "Жаңартылуда…",
"epic_play_without_update": "Жаңартпай ойнау",
"epic_update_available": "{{version}} нұсқасы қолжетімді",
"epic_update_failed": "Ойынды жаңарту мүмкін болмады",
"epic_extras_title": "Қосымша мазмұн",
"epic_extras_selected": "Таңдалды: {{selected}} / {{total}}",
"epic_select_all": "Барлығын таңдау",
"epic_dlc_installed": "Орнатылған",
"epic_selection_size": "{{size}} жүктеу",
"epic_not_enough_space": "Бұл таңдау үшін дискіде орын жеткіліксіз.",
"epic_selection_invalid": "Таңдау өзгерді. Жүктеу параметрлерін қайта ашып, қайталап көріңіз.",
"epic_retry": "Қайта көру"
},
"activation": {

View file

@ -572,7 +572,20 @@
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "실행 — Offline",
"epic_prerequisites_required": "게임 필수 구성 요소를 허용하고 다시 시도하세요.",
"epic_update_required": "Epic Games 옵션에서 같은 폴더를 사용해 게임을 업데이트하세요.",
"epic_update_required": "새 버전을 사용할 수 있습니다. 업데이트를 눌러 설치하세요.",
"epic_update": "업데이트",
"epic_update_resume": "업데이트 재개",
"epic_updating": "업데이트 중…",
"epic_play_without_update": "업데이트 없이 플레이",
"epic_update_available": "버전 {{version}} 사용 가능",
"epic_update_failed": "게임을 업데이트할 수 없습니다",
"epic_extras_title": "추가 콘텐츠",
"epic_extras_selected": "선택됨: {{selected}}/{{total}}",
"epic_select_all": "모두 선택",
"epic_dlc_installed": "설치됨",
"epic_selection_size": "{{size}} 다운로드",
"epic_not_enough_space": "이 선택에 필요한 디스크 공간이 부족합니다.",
"epic_selection_invalid": "선택이 변경되었습니다. 다운로드 옵션을 다시 열고 다시 시도하세요.",
"epic_retry": "다시 시도"
},
"activation": {

View file

@ -576,7 +576,20 @@
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Spēlēt — Offline",
"epic_prerequisites_required": "Atļaujiet spēles nepieciešamos komponentus un mēģiniet vēlreiz.",
"epic_update_required": "Atjauniniet spēli ar Epic Games opciju tajā pašā mapē.",
"epic_update_required": "Ir pieejama jauna versija. Lai to instalētu, izmantojiet Atjaunināt.",
"epic_update": "Atjaunināt",
"epic_update_resume": "Turpināt atjaunināšanu",
"epic_updating": "Atjaunina…",
"epic_play_without_update": "Spēlēt bez atjaunināšanas",
"epic_update_available": "Pieejama versija {{version}}",
"epic_update_failed": "Neizdevās atjaunināt spēli",
"epic_extras_title": "Papildu saturs",
"epic_extras_selected": "Atlasīti: {{selected}} no {{total}}",
"epic_select_all": "Atlasīt visu",
"epic_dlc_installed": "Instalēts",
"epic_selection_size": "{{size}} lejupielāde",
"epic_not_enough_space": "Šai izvēlei nepietiek vietas diskā.",
"epic_selection_invalid": "Izvēle mainījās. Atver lejupielādes opcijas vēlreiz un mēģini vēlreiz.",
"epic_retry": "Mēģināt vēlreiz"
},
"activation": {

View file

@ -576,7 +576,20 @@
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Spil — Offline",
"epic_prerequisites_required": "Godkjenn spillets forutsetninger og prøv igjen.",
"epic_update_required": "Oppdater spillet via Epic Games-alternativet i den samme mappen.",
"epic_update_required": "En ny versjon er tilgjengelig. Bruk Oppdater for å installere den.",
"epic_update": "Oppdater",
"epic_update_resume": "Gjenoppta oppdatering",
"epic_updating": "Oppdaterer…",
"epic_play_without_update": "Spill uten å oppdatere",
"epic_update_available": "Versjon {{version}} er tilgjengelig",
"epic_update_failed": "Kunne ikke oppdatere spillet",
"epic_extras_title": "Ekstra innhold",
"epic_extras_selected": "Valgt: {{selected}} av {{total}}",
"epic_select_all": "Velg alle",
"epic_dlc_installed": "Installert",
"epic_selection_size": "{{size}} nedlasting",
"epic_not_enough_space": "Ikke nok diskplass for dette valget.",
"epic_selection_invalid": "Valget er endret. Åpne nedlastingsvalgene på nytt og prøv igjen.",
"epic_retry": "Prøv igjen"
},
"activation": {

View file

@ -573,7 +573,20 @@
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Speel — Offline",
"epic_prerequisites_required": "Sta de vereisten van het spel toe en probeer opnieuw.",
"epic_update_required": "Werk het spel bij via de Epic Games-optie met dezelfde map.",
"epic_update_required": "Er is een nieuwe versie beschikbaar. Gebruik Bijwerken om die te installeren.",
"epic_update": "Bijwerken",
"epic_update_resume": "Update hervatten",
"epic_updating": "Bijwerken…",
"epic_play_without_update": "Spelen zonder bij te werken",
"epic_update_available": "Versie {{version}} beschikbaar",
"epic_update_failed": "Kan het spel niet bijwerken",
"epic_extras_title": "Extra content",
"epic_extras_selected": "Geselecteerd: {{selected}} van {{total}}",
"epic_select_all": "Alles selecteren",
"epic_dlc_installed": "Geïnstalleerd",
"epic_selection_size": "{{size}} downloaden",
"epic_not_enough_space": "Onvoldoende schijfruimte voor deze selectie.",
"epic_selection_invalid": "De selectie is gewijzigd. Open de downloadopties opnieuw en probeer het nog eens.",
"epic_retry": "Opnieuw proberen"
},
"activation": {

View file

@ -786,7 +786,20 @@
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Graj — Offline",
"epic_prerequisites_required": "Zezwól na instalację składników wymaganych przez grę i spróbuj ponownie.",
"epic_update_required": "Zaktualizuj grę przez opcję Epic Games, używając tego samego folderu.",
"epic_update_required": "Dostępna jest nowa wersja. Użyj Aktualizuj, aby ją zainstalować.",
"epic_update": "Aktualizuj",
"epic_update_resume": "Wznów aktualizację",
"epic_updating": "Aktualizowanie…",
"epic_play_without_update": "Graj bez aktualizacji",
"epic_update_available": "Dostępna wersja {{version}}",
"epic_update_failed": "Nie udało się zaktualizować gry",
"epic_extras_title": "Dodatkowa zawartość",
"epic_extras_selected": "Wybrano: {{selected}} z {{total}}",
"epic_select_all": "Zaznacz wszystko",
"epic_dlc_installed": "Zainstalowano",
"epic_selection_size": "{{size}} do pobrania",
"epic_not_enough_space": "Za mało miejsca na dysku dla tego wyboru.",
"epic_selection_invalid": "Wybór się zmienił. Otwórz ponownie opcje pobierania i spróbuj jeszcze raz.",
"epic_retry": "Spróbuj ponownie"
},
"activation": {

View file

@ -1069,7 +1069,20 @@
"epic_play_offline_title": "Jogar offline",
"epic_play_offline_description": "O Hydra não conseguiu preparar a inicialização pela Epic, então os saves na nuvem não foram restaurados. Você ainda pode iniciar o jogo no modo offline da Epic.",
"epic_prerequisites_required": "Autorize os pré-requisitos do jogo e tente novamente.",
"epic_update_required": "Atualize o jogo pela opção Epic Games, usando a mesma pasta.",
"epic_update_required": "Uma nova versão está disponível. Use Atualizar para instalá-la.",
"epic_update": "Atualizar",
"epic_update_resume": "Retomar atualização",
"epic_updating": "Atualizando…",
"epic_play_without_update": "Jogar sem atualizar",
"epic_update_available": "Versão {{version}} disponível",
"epic_update_failed": "Não foi possível atualizar o jogo",
"epic_extras_title": "Conteúdo extra",
"epic_extras_selected": "Selecionados: {{selected}} de {{total}}",
"epic_select_all": "Selecionar todos",
"epic_dlc_installed": "Instalada",
"epic_selection_size": "{{size}} para baixar",
"epic_not_enough_space": "Não há espaço em disco suficiente para esta seleção.",
"epic_selection_invalid": "A seleção mudou. Abra as opções de download novamente e tente outra vez.",
"epic_retry": "Tentar novamente"
},
"activation": {
@ -1335,7 +1348,7 @@
"launch_big_picture_in_library_page": "Iniciar o Big Picture na página da Biblioteca",
"disable_nsfw_alert": "Desativar alerta de conteúdo inapropriado",
"seed_after_download_complete": "Semear após a conclusão do download",
"show_hidden_achievement_description": "Mostrar descrição de conquistas ocultas antes de desbloqueá-las",
"show_hidden_achievement_description": "Mostrar conquistas ocultas antes de desbloqueá-las",
"account": "Conta",
"no_users_blocked": "Você não bloqueou nenhum usuário",
"subscription_active_until": "Sua assinatura Hydra Cloud ficará ativa até {{date}}",
@ -2272,7 +2285,34 @@
"available_points": "Pontos disponíveis:",
"how_to_earn_achievements_points": "Como desbloquear pontos nas conquistas?",
"view_souvenir": "Ver lembrança",
"souvenir_alt": "Captura feita ao desbloquear {{achievement}}"
"souvenir_alt": "Captura feita ao desbloquear {{achievement}}",
"search_achievements": "Buscar conquistas",
"sort_achievements": "Ordenar conquistas",
"sort_most_common": "Mais comuns",
"sort_most_rare": "Mais raras",
"filter_achievements": "Filtrar conquistas",
"filter_all": "Todas ({{count}})",
"filter_unlocked": "Desbloqueadas ({{count}})",
"filter_locked": "Bloqueadas ({{count}})",
"hidden_achievement": "Conquista oculta",
"click_to_reveal": "Clique para revelar",
"select_to_reveal": "Selecione para revelar",
"no_achievements_found": "Nenhuma conquista corresponde à sua busca",
"unlocked": "Desbloqueada",
"all_achievements": "Todas as conquistas",
"locked": "Bloqueada",
"unlock_percentage_tooltip": "{{percentage}} dos jogadores desbloquearam esta conquista",
"compare_unlocked_by": "{{name}} desbloqueou em {{date}}",
"compare_locked_for": "{{name}} ainda não desbloqueou",
"global_stats": "Estatísticas globais",
"hydra_points": "Pontos Hydra",
"compare_with_me": "Comparar comigo",
"stop_comparing": "Parar de comparar",
"achievement_set_base_game": "Jogo base",
"achievement_set_other": "Outras",
"sort_recently_unlocked": "Desbloqueadas recentemente",
"compare_filter_both": "Desbloqueadas por ambos",
"compare_filter_neither": "Bloqueadas para ambos"
},
"hydra_cloud": {
"subscription_tour_title": "Assinatura Hydra Cloud",
@ -2790,6 +2830,18 @@
"find_friends": "Encontrar amigos",
"sign_in_hint": "Entre para ver sua posição, seus jogos e o ranking dos seus amigos.",
"review_score": "{{score}}/5",
"report_cheater": "Denunciar {{name}} por trapaça",
"report_cheater_title": "Denunciar por trapaça",
"report_cheater_description": "{{name}} será denunciado por trapaça neste ranking ({{rank}}º, {{value}}). Um moderador vai analisar.",
"report_cheater_details": "Detalhes (opcional)",
"report_cheater_details_placeholder": "O que parece errado neste recorde?",
"report_cheater_confirm": "Denunciar",
"report_cheater_cancel": "Cancelar",
"report_cheater_sent": "Denúncia enviada. Obrigado por ajudar a manter os rankings justos.",
"report_cheater_rate_limited": "Você enviou denúncias demais. Tente novamente mais tarde.",
"report_cheater_failed": "Não foi possível enviar a denúncia. Tente novamente.",
"review_likes_one": "{{count}} voto positivo",
"review_likes_other": "{{count}} votos positivos",
"review_top_week": "Review mais votada da semana",
"review_top_all": "Review mais votada de todos os tempos",
"review_top_of": "Review mais votada de {{name}}",

View file

@ -627,7 +627,20 @@
"epic_compatibility_automatic": "Automático",
"epic_play_offline": "Jogar — Offline",
"epic_prerequisites_required": "Autorize os pré-requisitos do jogo e tente novamente.",
"epic_update_required": "Atualize o jogo pela opção Epic Games, utilizando a mesma pasta.",
"epic_update_required": "Está disponível uma nova versão. Use Atualizar para a instalar.",
"epic_update": "Atualizar",
"epic_update_resume": "Retomar atualização",
"epic_updating": "A atualizar…",
"epic_play_without_update": "Jogar sem atualizar",
"epic_update_available": "Versão {{version}} disponível",
"epic_update_failed": "Não foi possível atualizar o jogo",
"epic_extras_title": "Conteúdo extra",
"epic_extras_selected": "Selecionados: {{selected}} de {{total}}",
"epic_select_all": "Selecionar todos",
"epic_dlc_installed": "Instalado",
"epic_selection_size": "{{size}} para transferir",
"epic_not_enough_space": "Não há espaço em disco suficiente para esta seleção.",
"epic_selection_invalid": "A seleção mudou. Volte a abrir as opções de transferência e tente novamente.",
"epic_retry": "Tentar novamente"
},
"activation": {

View file

@ -572,7 +572,20 @@
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Joacă — Offline",
"epic_prerequisites_required": "Autorizează componentele necesare jocului și încearcă din nou.",
"epic_update_required": "Actualizează jocul prin opțiunea Epic Games folosind același folder.",
"epic_update_required": "Este disponibilă o versiune nouă. Folosește Actualizează pentru a o instala.",
"epic_update": "Actualizează",
"epic_update_resume": "Reia actualizarea",
"epic_updating": "Se actualizează…",
"epic_play_without_update": "Joacă fără actualizare",
"epic_update_available": "Versiunea {{version}} este disponibilă",
"epic_update_failed": "Jocul nu a putut fi actualizat",
"epic_extras_title": "Conținut suplimentar",
"epic_extras_selected": "Selectate: {{selected}} din {{total}}",
"epic_select_all": "Selectează tot",
"epic_dlc_installed": "Instalat",
"epic_selection_size": "{{size}} de descărcat",
"epic_not_enough_space": "Nu există suficient spațiu pe disc pentru această selecție.",
"epic_selection_invalid": "Selecția s-a schimbat. Redeschide opțiunile de descărcare și încearcă din nou.",
"epic_retry": "Încearcă din nou"
},
"activation": {

View file

@ -1058,7 +1058,20 @@
"epic_play_offline_title": "Играть офлайн",
"epic_play_offline_description": "Hydra не удалось подготовить запуск через Epic, поэтому облачные сохранения не были восстановлены. Вы всё ещё можете запустить игру в офлайн-режиме Epic.",
"epic_prerequisites_required": "Разрешите установку необходимых компонентов игры и повторите попытку.",
"epic_update_required": "Обновите игру через вариант Epic Games, используя ту же папку.",
"epic_update_required": "Доступна новая версия. Нажмите «Обновить», чтобы установить её.",
"epic_update": "Обновить",
"epic_update_resume": "Продолжить обновление",
"epic_updating": "Обновление…",
"epic_play_without_update": "Играть без обновления",
"epic_update_available": "Доступна версия {{version}}",
"epic_update_failed": "Не удалось обновить игру",
"epic_extras_title": "Дополнительный контент",
"epic_extras_selected": "Выбрано: {{selected}} из {{total}}",
"epic_select_all": "Выбрать все",
"epic_dlc_installed": "Установлено",
"epic_selection_size": "{{size}} для загрузки",
"epic_not_enough_space": "Недостаточно места на диске для этого выбора.",
"epic_selection_invalid": "Выбор изменился. Откройте параметры загрузки снова и повторите попытку.",
"epic_retry": "Повторить"
},
"activation": {
@ -1306,7 +1319,7 @@
"launch_hydra_in_big_picture": "Запускать Hydra в режиме Big Picture",
"disable_nsfw_alert": "Отключить предупреждение о непристойном контенте",
"seed_after_download_complete": "Раздавать после завершения загрузки",
"show_hidden_achievement_description": "Показывать описание скрытых достижений перед их получением",
"show_hidden_achievement_description": "Показывать скрытые достижения до их получения",
"account": "Аккаунт",
"hydra_cloud": "Hydra Cloud",
"cloud_gifts": "Подарки Hydra Cloud",
@ -2282,7 +2295,34 @@
"how_to_earn_achievements_points": "Как заработать очки достижений?",
"achievements_not_sync": "Ваши достижения не будут синхронизированы с вашей учётной записью.",
"view_souvenir": "Посмотреть сувенир",
"souvenir_alt": "Скриншот, сделанный при получении достижения {{achievement}}"
"souvenir_alt": "Скриншот, сделанный при получении достижения {{achievement}}",
"search_achievements": "Поиск достижений",
"sort_achievements": "Сортировка достижений",
"sort_most_common": "Самые распространённые",
"sort_most_rare": "Самые редкие",
"filter_achievements": "Фильтр достижений",
"filter_all": "Все ({{count}})",
"filter_unlocked": "Полученные ({{count}})",
"filter_locked": "Не полученные ({{count}})",
"hidden_achievement": "Скрытое достижение",
"click_to_reveal": "Нажмите, чтобы открыть",
"select_to_reveal": "Выберите, чтобы открыть",
"no_achievements_found": "Нет достижений, соответствующих поиску",
"unlocked": "Получено",
"all_achievements": "Все достижения",
"locked": "Не получено",
"unlock_percentage_tooltip": "{{percentage}} игроков получили это достижение",
"compare_unlocked_by": "{{name}} получил(а) {{date}}",
"compare_locked_for": "{{name}} ещё не получил(а) это достижение",
"global_stats": "Общая статистика",
"hydra_points": "Очки Hydra",
"compare_with_me": "Сравнить со мной",
"stop_comparing": "Отменить сравнение",
"achievement_set_base_game": "Основная игра",
"achievement_set_other": "Другие",
"sort_recently_unlocked": "Недавно полученные",
"compare_filter_both": "Получены обоими",
"compare_filter_neither": "Не получены никем"
},
"hydra_cloud": {
"subscription_tour_title": "Подписка Hydra Cloud",
@ -2565,6 +2605,20 @@
"find_friends": "Найти друзей",
"sign_in_hint": "Войдите, чтобы видеть своё место, свои игры и рейтинг друзей.",
"review_score": "{{score}}/5",
"report_cheater": "Пожаловаться на {{name}} за читерство",
"report_cheater_title": "Жалоба на читерство",
"report_cheater_description": "На {{name}} будет отправлена жалоба за читерство в этом рейтинге (#{{rank}}, {{value}}). Модератор её проверит.",
"report_cheater_details": "Подробности (необязательно)",
"report_cheater_details_placeholder": "Что не так с этим рекордом?",
"report_cheater_confirm": "Пожаловаться",
"report_cheater_cancel": "Отмена",
"report_cheater_sent": "Жалоба отправлена. Спасибо, что помогаете сохранять рейтинги честными.",
"report_cheater_rate_limited": "Вы отправили слишком много жалоб. Попробуйте позже.",
"report_cheater_failed": "Не удалось отправить жалобу. Попробуйте ещё раз.",
"review_likes_one": "{{count}} голос «за»",
"review_likes_few": "{{count}} голоса «за»",
"review_likes_many": "{{count}} голосов «за»",
"review_likes_other": "{{count}} голоса «за»",
"review_top_week": "Самый популярный обзор недели",
"review_top_all": "Самый популярный обзор за всё время",
"review_top_of": "Самый популярный обзор {{name}}",

View file

@ -578,7 +578,20 @@
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Igraj — Offline",
"epic_prerequisites_required": "Dovolite zahtevane komponente igre in poskusite znova.",
"epic_update_required": "Posodobite igro prek možnosti Epic Games v isti mapi.",
"epic_update_required": "Na voljo je nova različica. Namestite jo z možnostjo Posodobi.",
"epic_update": "Posodobi",
"epic_update_resume": "Nadaljuj posodobitev",
"epic_updating": "Posodabljanje…",
"epic_play_without_update": "Igraj brez posodobitve",
"epic_update_available": "Na voljo je različica {{version}}",
"epic_update_failed": "Igre ni bilo mogoče posodobiti",
"epic_extras_title": "Dodatna vsebina",
"epic_extras_selected": "Izbrano: {{selected}} od {{total}}",
"epic_select_all": "Izberi vse",
"epic_dlc_installed": "Nameščeno",
"epic_selection_size": "{{size}} prenosa",
"epic_not_enough_space": "Za ta izbor ni dovolj prostora na disku.",
"epic_selection_invalid": "Izbor se je spremenil. Znova odpri možnosti prenosa in poskusi znova.",
"epic_retry": "Poskusi znova"
},
"activation": {

View file

@ -576,7 +576,20 @@
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Spela — Offline",
"epic_prerequisites_required": "Godkänn spelets förutsättningar och försök igen.",
"epic_update_required": "Uppdatera spelet via Epic Games-alternativet i samma mapp.",
"epic_update_required": "En ny version finns tillgänglig. Använd Uppdatera för att installera den.",
"epic_update": "Uppdatera",
"epic_update_resume": "Återuppta uppdatering",
"epic_updating": "Uppdaterar…",
"epic_play_without_update": "Spela utan att uppdatera",
"epic_update_available": "Version {{version}} är tillgänglig",
"epic_update_failed": "Det gick inte att uppdatera spelet",
"epic_extras_title": "Extra innehåll",
"epic_extras_selected": "Valda: {{selected}} av {{total}}",
"epic_select_all": "Markera alla",
"epic_dlc_installed": "Installerad",
"epic_selection_size": "{{size}} nedladdning",
"epic_not_enough_space": "Inte tillräckligt med diskutrymme för det här valet.",
"epic_selection_invalid": "Valet har ändrats. Öppna nedladdningsalternativen igen och försök på nytt.",
"epic_retry": "Försök igen"
},
"activation": {

View file

@ -576,7 +576,20 @@
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Oyna — Offline",
"epic_prerequisites_required": "Oyunun ön gereksinimlerine izin verin ve tekrar deneyin.",
"epic_update_required": "Oyunu aynı klasörü kullanarak Epic Games seçeneğinden güncelleyin.",
"epic_update_required": "Yeni bir sürüm mevcut. Yüklemek için Güncelle’yi kullanın.",
"epic_update": "Güncelle",
"epic_update_resume": "Güncellemeye devam et",
"epic_updating": "Güncelleniyor…",
"epic_play_without_update": "Güncellemeden oyna",
"epic_update_available": "{{version}} sürümü mevcut",
"epic_update_failed": "Oyun güncellenemedi",
"epic_extras_title": "Ek içerik",
"epic_extras_selected": "Seçilen: {{selected}} / {{total}}",
"epic_select_all": "Tümünü seç",
"epic_dlc_installed": "Yüklendi",
"epic_selection_size": "{{size}} indirme",
"epic_not_enough_space": "Bu seçim için yeterli disk alanı yok.",
"epic_selection_invalid": "Seçim değişti. İndirme seçeneklerini yeniden açıp tekrar deneyin.",
"epic_retry": "Tekrar dene"
},
"activation": {

View file

@ -576,7 +576,20 @@
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Грати — Offline",
"epic_prerequisites_required": "Дозвольте встановлення необхідних компонентів гри та спробуйте ще раз.",
"epic_update_required": "Оновіть гру через варіант Epic Games, використовуючи ту саму папку.",
"epic_update_required": "Доступна нова версія. Натисніть «Оновити», щоб установити її.",
"epic_update": "Оновити",
"epic_update_resume": "Продовжити оновлення",
"epic_updating": "Оновлення…",
"epic_play_without_update": "Грати без оновлення",
"epic_update_available": "Доступна версія {{version}}",
"epic_update_failed": "Не вдалося оновити гру",
"epic_extras_title": "Додатковий вміст",
"epic_extras_selected": "Вибрано: {{selected}} з {{total}}",
"epic_select_all": "Вибрати все",
"epic_dlc_installed": "Встановлено",
"epic_selection_size": "{{size}} для завантаження",
"epic_not_enough_space": "Недостатньо місця на диску для цього вибору.",
"epic_selection_invalid": "Вибір змінився. Відкрийте параметри завантаження знову та повторіть спробу.",
"epic_retry": "Спробувати ще раз"
},
"activation": {

View file

@ -576,7 +576,20 @@
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "O'ynash — Offline",
"epic_prerequisites_required": "O‘yin uchun zarur komponentlarga ruxsat bering va qayta urinib ko‘ring.",
"epic_update_required": "O‘yinni Epic Games opsiyasi orqali o‘sha papkaga yangilang.",
"epic_update_required": "Yangi versiya mavjud. Uni o‘rnatish uchun Yangilash tugmasidan foydalaning.",
"epic_update": "Yangilash",
"epic_update_resume": "Yangilashni davom ettirish",
"epic_updating": "Yangilanmoqda…",
"epic_play_without_update": "Yangilamasdan o‘ynash",
"epic_update_available": "{{version}} versiyasi mavjud",
"epic_update_failed": "O‘yinni yangilab bo‘lmadi",
"epic_extras_title": "Qo‘shimcha kontent",
"epic_extras_selected": "Tanlangan: {{selected}} / {{total}}",
"epic_select_all": "Barchasini tanlash",
"epic_dlc_installed": "O‘rnatilgan",
"epic_selection_size": "{{size}} yuklab olish",
"epic_not_enough_space": "Ushbu tanlov uchun diskda yetarli joy yo‘q.",
"epic_selection_invalid": "Tanlov o‘zgardi. Yuklab olish sozlamalarini qayta oching va yana urinib ko‘ring.",
"epic_retry": "Qayta urinish"
},
"activation": {

View file

@ -786,7 +786,20 @@
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "Chơi — Ngoại tuyến",
"epic_prerequisites_required": "Cho phép cài đặt các thành phần cần thiết của trò chơi và thử lại.",
"epic_update_required": "Cập nhật trò chơi qua tùy chọn Epic Games vào cùng thư mục.",
"epic_update_required": "Đã có phiên bản mới. Hãy dùng Cập nhật để cài đặt.",
"epic_update": "Cập nhật",
"epic_update_resume": "Tiếp tục cập nhật",
"epic_updating": "Đang cập nhật…",
"epic_play_without_update": "Chơi mà không cập nhật",
"epic_update_available": "Đã có phiên bản {{version}}",
"epic_update_failed": "Không thể cập nhật trò chơi",
"epic_extras_title": "Nội dung bổ sung",
"epic_extras_selected": "Đã chọn: {{selected}}/{{total}}",
"epic_select_all": "Chọn tất cả",
"epic_dlc_installed": "Đã cài",
"epic_selection_size": "Tải {{size}}",
"epic_not_enough_space": "Không đủ dung lượng đĩa cho lựa chọn này.",
"epic_selection_invalid": "Lựa chọn đã thay đổi. Hãy mở lại tùy chọn tải xuống và thử lại.",
"epic_retry": "Thử lại"
},
"activation": {

View file

@ -576,7 +576,20 @@
"epic_compatibility_automatic": "Automatic",
"epic_play_offline": "开始游戏 — Offline",
"epic_prerequisites_required": "请授权安装游戏所需组件并重试。",
"epic_update_required": "请通过 Epic Games 选项将游戏更新到同一文件夹。",
"epic_update_required": "有新版本可用。使用“更新”进行安装。",
"epic_update": "更新",
"epic_update_resume": "继续更新",
"epic_updating": "正在更新…",
"epic_play_without_update": "不更新直接游玩",
"epic_update_available": "版本 {{version}} 可用",
"epic_update_failed": "无法更新游戏",
"epic_extras_title": "额外内容",
"epic_extras_selected": "已选择:{{selected}} / {{total}}",
"epic_select_all": "全选",
"epic_dlc_installed": "已安装",
"epic_selection_size": "下载 {{size}}",
"epic_not_enough_space": "磁盘空间不足,无法安装此选择。",
"epic_selection_invalid": "所选内容已更改。请重新打开下载选项后重试。",
"epic_retry": "重试"
},
"activation": {

View file

@ -9,7 +9,9 @@ import {
cancelEpicSync,
syncEpicGameOnGamePage,
getEpicGameInstallOptions,
invalidateEpicGameInstallOptions,
startEpicGameDownload,
startEpicGameUpdate,
getEpicCloudSaveState,
setEpicCloudSaveSettings,
syncEpicCloudSaves,
@ -21,6 +23,7 @@ import {
parseEpicManagedObjectId,
parseEpicInstallOptionsRequestId,
parseEpicDownloadPayload,
parseEpicUpdatePayload,
parseEpicCloudSaveSettings,
parseEpicCloudSaveConflict,
} from "./managed-ipc-contract";
@ -102,6 +105,13 @@ registerEvent(
}
);
registerEvent(
"invalidateEpicGameInstallOptions",
(event, objectId: unknown) => {
assertTrustedSender(event, true);
return invalidateEpicGameInstallOptions(parseEpicManagedObjectId(objectId));
}
);
registerEvent("cancelEpicGameInstallOptions", (event, requestId: unknown) => {
assertTrustedSender(event, true);
const key = parseEpicInstallOptionsRequestId(requestId);
@ -119,6 +129,10 @@ registerEvent("startEpicGameDownload", (event, payload: unknown) => {
assertTrustedSender(event, true);
return startEpicGameDownload(parseEpicDownloadPayload(payload));
});
registerEvent("startEpicGameUpdate", (event, payload: unknown) => {
assertTrustedSender(event, true);
return startEpicGameUpdate(parseEpicUpdatePayload(payload));
});
registerEvent("getEpicCloudSaveState", (event, objectId: unknown) => {
assertTrustedSender(event, true);
return getEpicCloudSaveState(parseEpicManagedObjectId(objectId));

View file

@ -8,6 +8,7 @@ import {
parseEpicLaunchOptions,
parseEpicManagedObjectId,
parseEpicInstallOptionsRequestId,
parseEpicUpdatePayload,
} from "./managed-ipc-contract.js";
const payload = {
@ -51,6 +52,52 @@ describe("managed Epic IPC contracts", () => {
}
});
it("accepts a bounded component and DLC selection", () => {
const selected = {
...payload,
components: ["stw", "lang_de"],
dlcs: ["DlcArtifact"],
};
assert.deepEqual(parseEpicDownloadPayload(selected), selected);
assert.deepEqual(
parseEpicDownloadPayload({ ...payload, components: [], dlcs: [] }),
{ ...payload, components: [], dlcs: [] }
);
for (const overrides of [
{ components: "stw" },
{ components: ["stw", "stw"] },
{ components: ["../x"] },
{ components: [""] },
{ components: Array.from({ length: 65 }, (_, i) => `pack${i}`) },
{ dlcs: ["--with-dlcs"] },
{ dlcs: [1] },
{ dlcs: ["Dlc", "Dlc"] },
])
assert.throws(() =>
parseEpicDownloadPayload({ ...payload, ...overrides })
);
});
it("accepts update targets without letting the renderer choose a folder", () => {
const { downloadPath: _downloadPath, ...target } = payload;
assert.deepEqual(parseEpicUpdatePayload(target), target);
const prepared = {
...target,
preparationId: "ecb21ce2-bd02-4672-a2fb-ce13f481b00a",
addToQueueOnly: true,
};
assert.deepEqual(parseEpicUpdatePayload(prepared), prepared);
for (const overrides of [
{ downloadPath: os.tmpdir() },
{ artifactId: "--override-exe" },
{ platform: "Linux" },
{ addToQueueOnly: "true" },
{ installTags: ["voice_de_de"] },
])
assert.throws(() => parseEpicUpdatePayload({ ...target, ...overrides }));
assert.throws(() => parseEpicUpdatePayload(null));
});
it("separates save settings from a single-session launch choice", () => {
assert.deepEqual(
parseEpicCloudSaveSettings({ automaticSync: true, savePath: null }),
@ -74,6 +121,14 @@ describe("managed Epic IPC contracts", () => {
assert.throws(() =>
parseEpicLaunchOptions({ extraArguments: "--offline" })
);
assert.deepEqual(parseEpicLaunchOptions({ epicSkipUpdateCheck: true }), {
epicSkipUpdateCheck: true,
});
assert.deepEqual(
parseEpicLaunchOptions({ epicSkipUpdateCheck: false }),
{}
);
assert.throws(() => parseEpicLaunchOptions({ epicSkipUpdateCheck: "yes" }));
});
it("requires a bounded remote version for resolving a save conflict", () => {

View file

@ -4,6 +4,7 @@ import type {
EpicLaunchOptions,
ResolveEpicCloudSaveConflictPayload,
StartEpicGameDownloadPayload,
StartEpicGameUpdatePayload,
} from "../../../types/epic-managed.types";
import {
EpicIntegrationError,
@ -11,7 +12,10 @@ import {
isRecord,
} from "../../services/epic/auth-protocol.js";
import { isEpicObjectId } from "../../services/epic/library-normalizer.js";
import { isSafeEpicArtifact } from "../../services/epic/installation.js";
import {
isEpicSelectionId,
isSafeEpicArtifact,
} from "../../services/epic/installation.js";
import { isEpicRemoteSaveTimestamp } from "../../services/epic/cloud-save-protocol.js";
const MAX_EPIC_LOCAL_PATH_LENGTH = 32_768;
@ -43,26 +47,24 @@ export const parseEpicInstallOptionsRequestId = (value: unknown): string => {
return value;
};
export function parseEpicDownloadPayload(
value: unknown
): StartEpicGameDownloadPayload {
const EPIC_TARGET_PAYLOAD_FIELDS = [
"epicObjectId",
"connectionId",
"artifactId",
"platform",
"addToQueueOnly",
"preparationId",
];
function parseEpicTargetPayload(
value: Record<string, unknown>
): StartEpicGameUpdatePayload {
if (
!isRecord(value) ||
!hasOnlyFields(value, [
"epicObjectId",
"connectionId",
"artifactId",
"platform",
"downloadPath",
"addToQueueOnly",
"preparationId",
]) ||
!isEpicObjectId(value.epicObjectId) ||
typeof value.connectionId !== "string" ||
!EPIC_CONNECTION_ID_PATTERN.test(value.connectionId) ||
!isSafeEpicArtifact(value.artifactId) ||
(value.platform !== "Windows" && value.platform !== "Mac") ||
!isLocalPath(value.downloadPath) ||
(value.preparationId !== undefined &&
(typeof value.preparationId !== "string" ||
!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(
@ -78,7 +80,6 @@ export function parseEpicDownloadPayload(
connectionId: value.connectionId,
artifactId: value.artifactId,
platform: value.platform,
downloadPath: value.downloadPath,
...(value.preparationId !== undefined
? { preparationId: value.preparationId }
: {}),
@ -88,6 +89,56 @@ export function parseEpicDownloadPayload(
};
}
const MAX_EPIC_SELECTION_ITEMS = 64;
const parseSelection = (
value: unknown,
valid: (item: unknown) => item is string
): string[] | undefined => {
if (value === undefined) return undefined;
if (
!Array.isArray(value) ||
value.length > MAX_EPIC_SELECTION_ITEMS ||
!value.every(valid) ||
new Set(value).size !== value.length
)
return invalid();
return [...value];
};
export function parseEpicDownloadPayload(
value: unknown
): StartEpicGameDownloadPayload {
if (
!isRecord(value) ||
!hasOnlyFields(value, [
...EPIC_TARGET_PAYLOAD_FIELDS,
"downloadPath",
"components",
"dlcs",
]) ||
!isLocalPath(value.downloadPath)
) {
return invalid();
}
const target = parseEpicTargetPayload(value);
const components = parseSelection(value.components, isEpicSelectionId);
const dlcs = parseSelection(value.dlcs, isSafeEpicArtifact);
return {
...target,
downloadPath: value.downloadPath,
...(components ? { components } : {}),
...(dlcs ? { dlcs } : {}),
};
}
export function parseEpicUpdatePayload(
value: unknown
): StartEpicGameUpdatePayload {
if (!isRecord(value) || !hasOnlyFields(value, EPIC_TARGET_PAYLOAD_FIELDS))
return invalid();
return parseEpicTargetPayload(value);
}
export function parseEpicCloudSaveSettings(
value: unknown
): EpicCloudSaveSettings {
@ -140,10 +191,17 @@ export function parseEpicLaunchOptions(value: unknown): EpicLaunchOptions {
if (value === undefined) return {};
if (
!isRecord(value) ||
!hasOnlyFields(value, ["epicSavePolicy", "epicOffline"]) ||
!hasOnlyFields(value, [
"epicSavePolicy",
"epicOffline",
"epicSkipUpdateCheck",
]) ||
(value.epicSavePolicy !== undefined &&
value.epicSavePolicy !== "local-once") ||
(value.epicOffline !== undefined && typeof value.epicOffline !== "boolean")
(value.epicOffline !== undefined &&
typeof value.epicOffline !== "boolean") ||
(value.epicSkipUpdateCheck !== undefined &&
typeof value.epicSkipUpdateCheck !== "boolean")
) {
return invalid();
}
@ -152,5 +210,8 @@ export function parseEpicLaunchOptions(value: unknown): EpicLaunchOptions {
...(value.epicOffline !== undefined
? { epicOffline: value.epicOffline }
: {}),
...(value.epicSkipUpdateCheck
? { epicSkipUpdateCheck: value.epicSkipUpdateCheck }
: {}),
};
}

View file

@ -29,7 +29,7 @@ const deleteGameFolder = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string,
options: { deleteSourceArchive?: boolean } = {}
options: { deleteSourceArchive?: boolean; keepEpicUpdateFiles?: boolean } = {}
): Promise<void> => {
const gameKey = levelKeys.game(shop, objectId);
const download = await downloadsSublevel.get(gameKey);
@ -74,6 +74,10 @@ const deleteGameFolder = async (
await assertDownload();
if (!download.folderName || !download.epic)
throw new EpicManagedError("invalid-path");
if (options.keepEpicUpdateFiles && download.epic.update) {
await DownloadOrchestrator.syncAfterDownloadRemoved({ shop, objectId });
return;
}
const folderName = download.folderName;
await getEpicDownloadService().forgetInstallation(
download,

View file

@ -0,0 +1,23 @@
import { getContextualEpicGame } from "@main/services/epic/import-visibility";
import { HydraApi } from "@main/services/hydra-api";
import { registerEvent } from "../register-event";
import { gamesSublevel, levelKeys } from "@main/level";
import { getDisplayedPlayTimeInMilliseconds } from "@shared";
import type { GameShop } from "@types";
const getGameReviewPlaytime = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string
) => {
const recordIncludingRemoved = await gamesSublevel.get(
levelKeys.game(shop, objectId)
);
if (!recordIncludingRemoved) return 0;
return getDisplayedPlayTimeInMilliseconds(
getContextualEpicGame(recordIncludingRemoved, HydraApi.getAuthContext())
);
};
registerEvent("getGameReviewPlaytime", getGameReviewPlaytime);

View file

@ -87,12 +87,16 @@ function fixture(
get: async (gameKey: string) =>
(options.records ?? [[key, game]]).find(([k]) => k === gameKey)?.[1],
},
downloadsSublevel: { get: async () => download },
downloadsSublevel: {
getMany: async (gameKeys: string[]) => gameKeys.map(() => download),
},
gamesArtworkSelectionSublevel: {
get: async (gameKey: string) => selections.get(gameKey) ?? null,
getMany: async (gameKeys: string[]) =>
gameKeys.map((gameKey) => selections.get(gameKey) ?? null),
},
gamesShopAssetsSublevel: {
get: async (gameKey: string) => assets.get(gameKey) ?? null,
getMany: async (gameKeys: string[]) =>
gameKeys.map((gameKey) => assets.get(gameKey) ?? null),
},
gamesShopCacheSublevel: { iterator: () => ({ all: async () => [] }) },
},

View file

@ -5,7 +5,7 @@ import { logger } from "@main/services/logger";
import path from "node:path";
import fs from "node:fs";
import type { LibraryGame } from "@types";
import type { Download, Game, LibraryGame } from "@types";
import { registerEvent } from "../register-event";
import {
downloadsSublevel,
@ -44,159 +44,165 @@ export const lookupCachedPlatform = async (
return null;
};
// An Epic copy without its own definitions presents its linked Steam catalogue.
const resolveLibraryAchievementCount = async (game: Game) => {
const achievementCount = resolveAchievementCount(
game.shop,
game.objectId,
game.achievementCount
);
if (
game.shop !== "epic" ||
achievementCount !== 0 ||
game.catalogueGame?.shop !== "steam"
) {
return achievementCount;
}
const steamGame = await gamesSublevel
.get(levelKeys.game("steam", game.catalogueGame.objectId))
.catch(() => undefined);
return resolveAchievementCount(
"steam",
game.catalogueGame.objectId,
steamGame?.achievementCount
);
};
// Clears the installer size when the installer was deleted outside Hydra.
const resolveInstallerSize = (
key: string,
game: Game,
download: Download | undefined
) => {
if (!game.installerSizeInBytes || !download?.folderName) {
return game.installerSizeInBytes;
}
if (fs.existsSync(path.join(download.downloadPath, download.folderName))) {
return game.installerSizeInBytes;
}
updateGameRecord(key, { installerSizeInBytes: null }).catch((error) =>
logger.error(`Failed to clear missing installer size for ${key}`, error)
);
return null;
};
const getLibrary = async (
collection: "visible" | "hidden" | "all" = "visible"
): Promise<LibraryGame[]> => {
return gamesSublevel
.iterator()
.all()
.then((results) => {
return Promise.all(
results
.filter(([_key, game]) =>
belongsToLibraryCollection(game, collection)
)
.map(async ([key, storedGame]) => {
const game = getContextualEpicGame(
storedGame,
HydraApi.getAuthContext()
);
if (game.isDeleted) return null;
const download = await downloadsSublevel.get(key);
const catalogueKey = game.catalogueGame
? levelKeys.game(
game.catalogueGame.shop,
game.catalogueGame.objectId
)
: key;
const gameAssets = await gamesShopAssetsSublevel.get(catalogueKey);
const artworkSelection =
await gamesArtworkSelectionSublevel.get(key);
const composedAssets = composeAssetsWithArtwork(
game.shop === "epic" &&
game.catalogueGame?.shop !== "steam" &&
gameAssets &&
!gameAssets.epicArtworkUpdatedAtByType?.icon
? { ...gameAssets, iconUrl: null }
: (gameAssets ?? null),
artworkSelection
);
const unlockedAchievementCount = resolveUnlockedAchievementCount(
game.shop,
game.objectId,
game.unlockedAchievementCount
);
let achievementCount = resolveAchievementCount(
game.shop,
game.objectId,
game.achievementCount
);
// An Epic copy without its own definitions presents its linked Steam catalogue.
if (
game.shop === "epic" &&
achievementCount === 0 &&
game.catalogueGame?.shop === "steam"
) {
const steamGame = await gamesSublevel
.get(levelKeys.game("steam", game.catalogueGame.objectId))
.catch(() => undefined);
achievementCount = resolveAchievementCount(
"steam",
game.catalogueGame.objectId,
steamGame?.achievementCount
);
}
const authContext = HydraApi.getAuthContext();
const entries = (await gamesSublevel.iterator().all())
.filter(([_key, game]) => belongsToLibraryCollection(game, collection))
.map(
([key, storedGame]) =>
[key, getContextualEpicGame(storedGame, authContext)] as const
)
.filter(([_key, game]) => !game.isDeleted);
const keys = entries.map(([key]) => key);
const catalogueKeys = entries.map(([key, game]) =>
game.catalogueGame
? levelKeys.game(game.catalogueGame.shop, game.catalogueGame.objectId)
: key
);
// Batched reads: one round trip per sublevel instead of three per game.
const [downloads, shopAssets, artworkSelections] = await Promise.all([
downloadsSublevel.getMany(keys),
gamesShopAssetsSublevel.getMany(catalogueKeys),
gamesArtworkSelectionSublevel.getMany(keys),
]);
// Verify installer still exists, clear if deleted externally
let installerSizeInBytes = game.installerSizeInBytes;
if (installerSizeInBytes && download?.folderName) {
const installerPath = path.join(
download.downloadPath,
download.folderName
);
return Promise.all(
entries.map(async ([key, game], index) => {
const download = downloads[index];
const gameAssets = shopAssets[index];
const artworkSelection = artworkSelections[index];
const composedAssets = composeAssetsWithArtwork(
game.shop === "epic" &&
game.catalogueGame?.shop !== "steam" &&
gameAssets &&
!gameAssets.epicArtworkUpdatedAtByType?.icon
? { ...gameAssets, iconUrl: null }
: (gameAssets ?? null),
artworkSelection
);
const unlockedAchievementCount = resolveUnlockedAchievementCount(
game.shop,
game.objectId,
game.unlockedAchievementCount
);
const achievementCount = await resolveLibraryAchievementCount(game);
const installerSizeInBytes = resolveInstallerSize(key, game, download);
if (!fs.existsSync(installerPath)) {
installerSizeInBytes = null;
updateGameRecord(key, { installerSizeInBytes: null }).catch(
(error) =>
logger.error(
`Failed to clear missing installer size for ${key}`,
error
)
);
}
}
if (
game.shop === "launchbox" &&
(!game.platform || game.platform === null)
) {
const cachedPlatform = await lookupCachedPlatform(key);
if (cachedPlatform) {
game.platform = cachedPlatform;
updateGameRecord(key, { platform: game.platform }).catch(() => {});
}
}
if (
game.shop === "launchbox" &&
(!game.platform || game.platform === null)
) {
const cachedPlatform = await lookupCachedPlatform(key);
if (cachedPlatform) {
game.platform = cachedPlatform;
updateGameRecord(key, { platform: game.platform }).catch(
() => {}
);
}
}
// Verify installed folder still exists, clear if deleted externally
let installedSizeInBytes = game.installedSizeInBytes;
if (installedSizeInBytes && game.executablePath) {
const executableDir = path.dirname(game.executablePath);
// Verify installed folder still exists, clear if deleted externally
let installedSizeInBytes = game.installedSizeInBytes;
if (installedSizeInBytes && game.executablePath) {
const executableDir = path.dirname(game.executablePath);
if (!fs.existsSync(executableDir)) {
installedSizeInBytes = null;
updateGameRecord(key, {
installerSizeInBytes,
installedSizeInBytes: null,
}).catch((error) =>
logger.error(
`Failed to clear missing installed size for ${key}`,
error
)
);
}
}
if (!fs.existsSync(executableDir)) {
installedSizeInBytes = null;
updateGameRecord(key, {
installerSizeInBytes,
installedSizeInBytes: null,
}).catch((error) =>
logger.error(
`Failed to clear missing installed size for ${key}`,
error
)
);
}
}
return {
id: key,
...game,
installerSizeInBytes,
installedSizeInBytes,
download: download ?? null,
// Spread composed assets last to ensure all image URLs are properly set
...composedAssets,
shop: game.shop,
objectId: game.objectId,
unlockedAchievementCount,
achievementCount,
...(game.shop === "epic" &&
game.catalogueGame?.shop !== "steam" && {
iconUrl: composedAssets?.iconUrl ?? null,
}),
title: composedAssets?.title || game.title,
platform: game.platform ?? null,
// Preserve custom image URLs from game if they exist
customIconUrl: game.customIconUrl,
customLogoImageUrl: game.customLogoImageUrl,
customHeroImageUrl: game.customHeroImageUrl,
customCoverImageUrl: game.customCoverImageUrl,
};
})
).then((games) => {
const library = games.filter(
(game): game is NonNullable<typeof game> => game !== null
);
if (HydraApi.isLoggedIn()) epicLibraryArtworkQueue.schedule(library);
return library;
});
});
return {
id: key,
...game,
installerSizeInBytes,
installedSizeInBytes,
download: download ?? null,
// Spread composed assets last to ensure all image URLs are properly set
...composedAssets,
shop: game.shop,
objectId: game.objectId,
unlockedAchievementCount,
achievementCount,
...(game.shop === "epic" &&
game.catalogueGame?.shop !== "steam" && {
iconUrl: composedAssets?.iconUrl ?? null,
}),
title: composedAssets?.title || game.title,
platform: game.platform ?? null,
// Preserve custom image URLs from game if they exist
customIconUrl: game.customIconUrl,
customLogoImageUrl: game.customLogoImageUrl,
customHeroImageUrl: game.customHeroImageUrl,
customCoverImageUrl: game.customCoverImageUrl,
};
})
).then((library) => {
if (HydraApi.isLoggedIn()) epicLibraryArtworkQueue.schedule(library);
return library;
});
};
registerEvent("getLibrary", (_event, includeConcealed = false) =>
getLibrary(includeConcealed ? "all" : "visible")
registerEvent(
"getLibrary",
(_event, includeConcealed = false) =>
getLibrary(includeConcealed ? "all" : "visible"),
{ jsonSafeResult: true }
);
registerEvent("getHiddenLibrary", () =>
HydraApi.isLoggedIn() ? getLibrary("hidden") : Promise.resolve([])
registerEvent(
"getHiddenLibrary",
() => (HydraApi.isLoggedIn() ? getLibrary("hidden") : Promise.resolve([])),
{ jsonSafeResult: true }
);

View file

@ -22,6 +22,7 @@ import "./uninstall-game-on-steam";
import "./is-mangohud-available";
import "./is-winetricks-available";
import "./get-game-by-object-id";
import "./get-game-review-playtime";
import "./has-game-installer";
import "./has-download-folder";
import "./has-source-archive";

View file

@ -1,8 +1,11 @@
import { registerEvent } from "../register-event";
import { mergeWithRemoteGames } from "@main/services";
import { mergeWithRemoteGamesUnlessFresh } from "@main/services";
// Opening the library repeatedly should not refetch every remote page each time.
const REMOTE_LIBRARY_FRESH_MS = 60_000;
const refreshLibraryAssets = async () => {
if (!(await mergeWithRemoteGames())) {
if (!(await mergeWithRemoteGamesUnlessFresh(REMOTE_LIBRARY_FRESH_MS))) {
throw new Error("library/refresh-failed");
}
};

View file

@ -1,12 +1,18 @@
import { ipcMain } from "electron";
interface RegisterEventOptions {
/** Skip the JSON round trip for results that are already plain JSON data. */
jsonSafeResult?: boolean;
}
export const registerEvent = (
name: string,
listener: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => any
listener: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => any,
{ jsonSafeResult = false }: RegisterEventOptions = {}
) => {
ipcMain.handle(name, async (event: Electron.IpcMainInvokeEvent, ...args) => {
return Promise.resolve(listener(event, ...args)).then((result) => {
if (!result) return result;
if (!result || jsonSafeResult) return result;
return JSON.parse(JSON.stringify(result));
});
});

View file

@ -11,6 +11,7 @@ import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
import { parseBytes } from "@shared";
import {
getGlobalTrackers,
getMultipartDownloadParts,
handleDownloadError,
isKnownDownloadError,
prepareGameEntry,
@ -27,6 +28,7 @@ const addGameToQueue = async (
downloadPath,
downloader,
uri,
parts,
automaticallyExtract,
automaticallyDeleteArchiveFiles,
fileSize,
@ -51,6 +53,7 @@ const addGameToQueue = async (
downloadPath,
downloader,
uri,
parts: await getMultipartDownloadParts(uri, parts, downloader),
folderName: null,
fileSize: selectedFilesSize ?? parsedFileSize,
shouldSeed: false,

View file

@ -11,6 +11,7 @@ import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
import { parseBytes } from "@shared";
import {
getGlobalTrackers,
getMultipartDownloadParts,
handleDownloadError,
isKnownDownloadError,
prepareGameEntry,
@ -27,6 +28,7 @@ const startGameDownload = async (
downloadPath,
downloader,
uri,
parts,
automaticallyExtract,
automaticallyDeleteArchiveFiles,
fileSize,
@ -54,6 +56,7 @@ const startGameDownload = async (
downloadPath,
downloader,
uri,
parts: await getMultipartDownloadParts(uri, parts, downloader),
folderName: null,
shouldSeed: false,
timestamp: Date.now(),

View file

@ -4,6 +4,24 @@ import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import { db, levelKeys } from "@main/level";
import { AchievementWatcherManager } from "@main/services/achievements/achievement-watcher-manager";
import { getGameAchievementData } from "@main/services/achievements/get-game-achievement-data";
import { withComparedAchievementDefinitions } from "@main/services/achievements/compared-achievement-definitions";
const ACHIEVEMENT_DEFINITIONS_TIMEOUT_MS = 5_000;
const getAchievementDefinitions = (objectId: string, shop: GameShop) => {
let timeout: NodeJS.Timeout | undefined;
return Promise.race([
getGameAchievementData(objectId, shop, true).catch(() => []),
new Promise<[]>((resolve) => {
timeout = setTimeout(
() => resolve([]),
ACHIEVEMENT_DEFINITIONS_TIMEOUT_MS
);
}),
]).finally(() => clearTimeout(timeout));
};
const getComparedUnlockedAchievements = async (
_event: Electron.IpcMainInvokeEvent,
@ -20,52 +38,32 @@ const getComparedUnlockedAchievements = async (
}
);
const showHiddenAchievementsDescription =
userPreferences?.showHiddenAchievementsDescription || false;
const [achievements, definitions] = await Promise.all([
HydraApi.get<ComparedAchievements>(
`/users/${userId}/games/achievements/compare`,
{
shop,
objectId,
language: userPreferences?.language ?? "en",
}
),
getAchievementDefinitions(objectId, shop),
]);
return HydraApi.get<ComparedAchievements>(
`/users/${userId}/games/achievements/compare`,
{
shop,
objectId,
language: userPreferences?.language ?? "en",
const sortedAchievements = achievements.achievements.sort((a, b) => {
if (a.targetStat.unlocked && !b.targetStat.unlocked) return -1;
if (!a.targetStat.unlocked && b.targetStat.unlocked) return 1;
if (a.targetStat.unlocked && b.targetStat.unlocked) {
return b.targetStat.unlockTime! - a.targetStat.unlockTime!;
}
).then((achievements) => {
const sortedAchievements = achievements.achievements
.sort((a, b) => {
if (a.targetStat.unlocked && !b.targetStat.unlocked) return -1;
if (!a.targetStat.unlocked && b.targetStat.unlocked) return 1;
if (a.targetStat.unlocked && b.targetStat.unlocked) {
return b.targetStat.unlockTime! - a.targetStat.unlockTime!;
}
return Number(a.hidden) - Number(b.hidden);
})
.map((achievement) => {
if (!achievement.hidden) return achievement;
if (!achievement.ownerStat) {
return {
...achievement,
description: "",
};
}
if (!showHiddenAchievementsDescription && achievement.hidden) {
return {
...achievement,
description: "",
};
}
return achievement;
});
return {
...achievements,
achievements: sortedAchievements,
} as ComparedAchievements;
return Number(a.hidden) - Number(b.hidden);
});
return withComparedAchievementDefinitions(
{ ...achievements, achievements: sortedAchievements },
definitions
);
};
registerEvent(

View file

@ -2,7 +2,10 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { it } from "node:test";
import ts from "typescript";
import { presentCombinedAchievements } from "../../services/achievements/combined-achievement-presentation.ts";
import {
createCombinedDefinitionLookup,
presentCombinedAchievements,
} from "../../services/achievements/combined-achievement-presentation.ts";
import { mergeUnlockedAchievementLists } from "../../services/achievements/merge-unlocked-achievements.ts";
import {
achievementSyncCoordinator,
@ -151,6 +154,7 @@ for (const scenario of [
},
"@main/services/achievements/combined-achievement-presentation": {
presentCombinedAchievements,
createCombinedDefinitionLookup,
},
};
const output = ts.transpileModule(

View file

@ -177,7 +177,7 @@ for (const linked of [false, true]) {
);
assert.equal(rows[0].description, "visible");
assert.equal(rows[0].icongray, "gray2");
assert.equal(rows[1].description, undefined);
assert.equal(rows[1].description, "secret");
assert.equal(rows[1].icongray, "icon");
}
});

View file

@ -126,6 +126,7 @@ for (const ownerChanged of [false, true]) {
CombinedAchievementsRequests: { fetch: passthroughCombinedRequest },
},
"@main/services/achievements/combined-achievement-presentation": {
createCombinedDefinitionLookup: () => () => undefined,
presentCombinedAchievements() {
assert.ok(combinedAvailable);
assert.ok(

View file

@ -15,12 +15,22 @@ import { AchievementSouvenirStore } from "@main/services/achievements/achievemen
import { fetchRemoteUserGameAchievements } from "@main/services/achievements/get-achievement-souvenirs";
import { mergeUnlockedAchievementLists } from "@main/services/achievements/merge-unlocked-achievements";
import {
createCombinedDefinitionLookup,
presentCombinedAchievements,
type CombinedAchievements,
} from "@main/services/achievements/combined-achievement-presentation";
const COMBINED_ACHIEVEMENTS_TIMEOUT_MS = 5_000;
const indexByUpperCaseName = <T extends { name: string }>(items: T[]) => {
const byName = new Map<string, T>();
for (const item of items) {
const key = item.name.toUpperCase();
if (!byName.has(key)) byName.set(key, item);
}
return byName;
};
const captureAchievementView = (shop: GameShop, objectId: string) => {
const context = HydraApi.getAuthContext();
return achievementSyncCoordinator.captureCurrent(
@ -52,9 +62,6 @@ export const getUnlockedAchievements = async (
assertCurrent();
const showHiddenAchievementsDescription =
userPreferences?.showHiddenAchievementsDescription || false;
const achievementsData = await getGameAchievementData(
objectId,
shop,
@ -159,7 +166,12 @@ export const getUnlockedAchievements = async (
(sourceShop, sourceObjectId) =>
AchievementMemoryStore.get(sourceShop, sourceObjectId)
?.unlockedAchievements ?? [],
showHiddenAchievementsDescription
createCombinedDefinitionLookup((sourceShop, sourceObjectId) =>
sourceShop === shop && sourceObjectId === objectId
? achievementsData
: (AchievementMemoryStore.get(sourceShop, sourceObjectId)
?.achievements ?? [])
)
);
}
}
@ -181,35 +193,28 @@ export const getUnlockedAchievements = async (
if (steamDefinitions.length > 0) catalogueAchievements = steamDefinitions;
}
}
const unlockedByName = indexByUpperCaseName(unlockedAchievements);
const remoteByName = indexByUpperCaseName(remote.achievements);
return catalogueAchievements
.map((achievementData) => {
const unlockedAchievementData = unlockedAchievements.find(
(localAchievement) => {
return (
localAchievement.name.toUpperCase() ==
achievementData.name.toUpperCase()
);
}
);
const upperCaseName = achievementData.name.toUpperCase();
const unlockedAchievementData = unlockedByName.get(upperCaseName);
const icongray = achievementData.icongray.endsWith("/")
? achievementData.icon
: achievementData.icongray;
if (unlockedAchievementData) {
const remoteAchievement = remoteByName.get(upperCaseName);
return {
...achievementData,
unlocked: true,
unlockTime: unlockedAchievementData.unlockTime,
source: remote.achievements.find(
(item) =>
item.name.toUpperCase() === achievementData.name.toUpperCase()
)?.source,
unlockedOn: remote.achievements.find(
(item) =>
item.name.toUpperCase() === achievementData.name.toUpperCase()
)?.unlockedOn,
imageUrl: souvenirs.get(achievementData.name.toUpperCase()) ?? null,
source: remoteAchievement?.source,
unlockedOn: remoteAchievement?.unlockedOn,
imageUrl: souvenirs.get(upperCaseName) ?? null,
};
}
@ -218,10 +223,6 @@ export const getUnlockedAchievements = async (
unlocked: false,
unlockTime: null,
icongray: icongray,
description:
!achievementData.hidden || showHiddenAchievementsDescription
? achievementData.description
: undefined,
};
})
.sort((a, b) => {

View file

@ -6,6 +6,9 @@ import {
levelKeys,
} from "@main/level";
import { DownloadOrchestrator } from "@main/services/download-orchestrator";
import { getDownloadParts } from "@main/services/download/download-parts";
import { assertMultipartHosterDownloadsEnabled } from "@main/services/download/multipart-hoster-feature";
import { type Downloader, MULTIPART_DOWNLOADERS } from "@shared";
import { canDiscardDownload, type GameShop } from "../../types";
interface PrepareGameEntryParams {
@ -15,6 +18,20 @@ interface PrepareGameEntryParams {
shop: GameShop;
}
export const getMultipartDownloadParts = async (
uri: string,
parts: unknown,
downloader: Downloader
): Promise<string[] | undefined> => {
const downloadParts = getDownloadParts(uri, parts);
if (!downloadParts) return undefined;
if (!MULTIPART_DOWNLOADERS.has(downloader)) {
throw new Error("This downloader does not support multi-part downloads.");
}
await assertMultipartHosterDownloadsEnabled();
return downloadParts;
};
export const clearFinishedDownload = async (
shop: GameShop,
objectId: string

View file

@ -3,7 +3,8 @@ import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { GameShop, type Game, type UserPreferences } from "@types";
import { db, gamesSublevel, levelKeys } from "@main/level";
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
import { isEpicUpdateInterrupted } from "@shared";
import { updateGameExecutablePath } from "./update-executable-path";
import {
clearCloudSaveLaunchGuard,
@ -68,6 +69,7 @@ export interface LaunchGameOptions {
launchOptions?: string | null;
epicSavePolicy?: "local-once";
epicOffline?: boolean;
epicSkipUpdateCheck?: boolean;
}
const LAUNCH_DELAY_IN_MS = 2_000;
@ -916,6 +918,11 @@ const launchGameWithCloudSaveChecks = async (
winePrefixPath: compatibilityContext?.winePrefixPath,
epicSavePolicy: options.epicSavePolicy,
epicOffline: options.epicOffline,
epicSkipUpdateCheck:
options.epicSkipUpdateCheck &&
!isEpicUpdateInterrupted(
(await downloadsSublevel.get(gameKey)) ?? null
),
onStatus: createUmuStatusReporter(gameKey),
// Runs once the Linux prefix exists, so Hydra restores into it and
// records its upload guard against the prepared environment.

View file

@ -203,8 +203,8 @@ const initializeApp = async () => {
logger.error("Failed to load app state during startup", error);
}
// Optional integration failures must not interrupt launcher startup.
await initializeEpicIntegration().catch(() => {
logger.warn("Epic integration initialization unavailable");
await initializeEpicIntegration().catch((error) => {
logger.warn("Epic integration initialization unavailable", error);
});
// Suspend can outlive the 60s stall watchdog; reconnect right away instead

View file

@ -133,7 +133,11 @@ export const loadState = async () => {
await HydraApi.setupApi().then(async () => {
void ScreenshotInputService.refreshFeatureFlag();
await initializeEpicIntegration();
try {
await initializeEpicIntegration();
} catch (err) {
logger.warn("Epic integration initialization failed on startup", err);
}
uploadGamesBatch();
void migrateDownloadSources();

View file

@ -97,6 +97,7 @@ async function fixture(
export const WindowManager = { mainWindow: { webContents: { send: (channel, achievements) => state.updates.push({channel, achievements}) } } };
export const getUnlockedAchievements = async () => [];
export const presentCombinedAchievements = () => [];
export const createCombinedDefinitionLookup = () => () => undefined;
export const publishNewAchievementNotification = () => {};
export const registerEvent = (name, handler) => state.events.set(name, handler);
export const gamesSublevel = {get: async () => state.game};

View file

@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
createCombinedDefinitionLookup,
presentCombinedAchievements,
type CombinedAchievementVariant,
type CombinedAchievements,
@ -32,8 +33,7 @@ describe("combined achievement presentation", () => {
it("shows one Steam entry when both stores unlocked an equivalent achievement", () => {
const rows = presentCombinedAchievements(
response(variant("epic", 1), variant("steam", 2)),
noLocal,
false
noLocal
);
assert.equal(rows.length, 1);
assert.equal(rows[0].source, "steam");
@ -44,8 +44,7 @@ describe("combined achievement presentation", () => {
it("shows Epic when only Epic unlocked it, without claiming Steam unlocked it", () => {
const rows = presentCombinedAchievements(
response(variant("steam", null), variant("epic", 1)),
noLocal,
false
noLocal
);
assert.equal(rows[0].source, "epic");
assert.equal(rows[0].sources?.length, 1);
@ -57,10 +56,7 @@ describe("combined achievement presentation", () => {
canonicalAchievementId: "other",
variants: [variant("epic", 2, "Other")],
});
assert.equal(
presentCombinedAchievements(combined, noLocal, false).length,
2
);
assert.equal(presentCombinedAchievements(combined, noLocal).length, 2);
});
it("incorporates unsynced local unlocks only from their exact store identity", () => {
const local = [{ name: "FIRST_WIN", unlockTime: 3 }];
@ -69,10 +65,8 @@ describe("combined achievement presentation", () => {
variant("epic", 1)
);
const before = JSON.stringify(combined);
const rows = presentCombinedAchievements(
combined,
(shop, id) => (shop === "steam" && id === "10" ? local : []),
false
const rows = presentCombinedAchievements(combined, (shop, id) =>
shop === "steam" && id === "10" ? local : []
);
assert.equal(rows[0].source, "steam");
assert.equal(rows[0].sources?.length, 2);
@ -82,20 +76,75 @@ describe("combined achievement presentation", () => {
it("never matches same-name local achievements from a different game", () => {
const rows = presentCombinedAchievements(
response(variant("steam", null)),
(_, id) => (id === "20" ? [{ name: "First Win", unlockTime: 3 }] : []),
false
(_, id) => (id === "20" ? [{ name: "First Win", unlockTime: 3 }] : [])
);
assert.equal(rows[0].unlocked, false);
});
it("keeps hidden locked descriptions hidden and preserves original display names", () => {
it("keeps the description of hidden locked achievements so they can be revealed", () => {
const combined = response({ ...variant("steam", null), hidden: true });
const [row] = presentCombinedAchievements(combined, noLocal);
assert.equal(row.description, "Ganhe uma vez");
assert.equal(row.displayName, "Primeira vitória");
});
it("looks definitions up by name per store copy, reading each copy once", () => {
const reads: string[] = [];
const lookup = createCombinedDefinitionLookup((shop, objectId) => {
reads.push(`${shop}:${objectId}`);
return shop === "steam"
? [
{
name: "First_Win",
displayName: "First Win",
icon: "",
icongray: "",
hidden: false,
globalUnlockPercentage: 33,
},
]
: [];
});
assert.equal(
presentCombinedAchievements(combined, noLocal, false)[0].description,
undefined
lookup("steam", "10", "FIRST_WIN")?.globalUnlockPercentage,
33
);
assert.equal(
presentCombinedAchievements(combined, noLocal, true)[0].description,
"Ganhe uma vez"
lookup("steam", "10", "first_win")?.globalUnlockPercentage,
33
);
assert.equal(lookup("steam", "10", "Other"), undefined);
assert.equal(lookup("epic", "sandbox:item", "FIRST_WIN"), undefined);
assert.deepEqual(reads, ["steam:10", "epic:sandbox:item"]);
});
it("takes the unlock rate and Steam set from the catalogue definitions", () => {
const steamSet = { id: 2, name: "Single Player", order: 0 };
const rows = presentCombinedAchievements(
response(variant("steam", null), variant("epic", 1)),
noLocal,
(shop) =>
shop === "steam"
? { globalUnlockPercentage: 40, set: steamSet }
: { globalUnlockPercentage: 12, set: null }
);
assert.equal(rows[0].source, "epic");
assert.equal(rows[0].globalUnlockPercentage, 12);
assert.deepEqual(rows[0].set, steamSet);
});
it("falls back to another store's rate and leaves both empty without definitions", () => {
const withFallback = presentCombinedAchievements(
response(variant("steam", 1), variant("epic", null)),
noLocal,
(shop) =>
shop === "epic" ? { globalUnlockPercentage: 7, set: null } : undefined
);
assert.equal(withFallback[0].globalUnlockPercentage, 7);
assert.equal(withFallback[0].set, null);
const withoutDefinitions = presentCombinedAchievements(
response(variant("steam", 1)),
noLocal
);
assert.equal(withoutDefinitions[0].globalUnlockPercentage, null);
assert.equal(withoutDefinitions[0].set, null);
});
});

View file

@ -1,4 +1,40 @@
import type { UnlockedAchievement, UserAchievement } from "@types";
import type {
SteamAchievement,
UnlockedAchievement,
UserAchievement,
} from "@types";
export type CombinedAchievementDefinitionLookup = (
shop: "steam" | "epic",
objectId: string,
name: string
) => Pick<SteamAchievement, "globalUnlockPercentage" | "set"> | undefined;
export const createCombinedDefinitionLookup = (
getDefinitions: (
shop: "steam" | "epic",
objectId: string
) => readonly SteamAchievement[]
): CombinedAchievementDefinitionLookup => {
const definitionsBySource = new Map<string, Map<string, SteamAchievement>>();
return (shop, objectId, name) => {
const sourceKey = `${shop}:${objectId}`;
let definitions = definitionsBySource.get(sourceKey);
if (!definitions) {
definitions = new Map(
getDefinitions(shop, objectId).map((definition) => [
definition.name.toUpperCase(),
definition,
])
);
definitionsBySource.set(sourceKey, definitions);
}
return definitions.get(name.toUpperCase());
};
};
export interface CombinedAchievementVariant {
shop: "steam" | "epic";
@ -29,13 +65,39 @@ export function presentCombinedAchievements(
shop: "steam" | "epic",
objectId: string
) => readonly UnlockedAchievement[],
revealHidden: boolean
findDefinition: CombinedAchievementDefinitionLookup = () => undefined
): UserAchievement[] {
const localUnlocksBySource = new Map<
string,
Map<string, UnlockedAchievement>
>();
const findLocalUnlock = (
shop: "steam" | "epic",
objectId: string,
name: string
) => {
const sourceKey = `${shop}:${objectId}`;
let unlocks = localUnlocksBySource.get(sourceKey);
if (!unlocks) {
unlocks = new Map();
for (const unlock of localUnlocks(shop, objectId)) {
const key = unlock.name.toUpperCase();
if (!unlocks.has(key)) unlocks.set(key, unlock);
}
localUnlocksBySource.set(sourceKey, unlocks);
}
return unlocks.get(name.toUpperCase());
};
return combined.achievements
.flatMap(({ canonicalAchievementId, variants }) => {
const states = variants.map((variant) => {
const local = localUnlocks(variant.shop, variant.objectId).find(
(unlock) => unlock.name.toUpperCase() === variant.name.toUpperCase()
const local = findLocalUnlock(
variant.shop,
variant.objectId,
variant.name
);
return {
...variant,
@ -47,17 +109,28 @@ export function presentCombinedAchievements(
const selected =
choices.find((row) => row.shop === "steam") ?? choices[0];
if (!selected) return [];
const definitions = [
selected,
...states.filter((row) => row !== selected),
].map((row) => ({
shop: row.shop,
definition: findDefinition(row.shop, row.objectId, row.name),
}));
return [
{
...selected,
globalUnlockPercentage:
definitions.find(
({ definition }) => definition?.globalUnlockPercentage != null
)?.definition?.globalUnlockPercentage ?? null,
set:
definitions.find(
({ shop, definition }) => shop === "steam" && definition?.set
)?.definition?.set ?? null,
canonicalAchievementId,
source: selected.shop,
sourceObjectId: selected.objectId,
unlocked: selected.unlockTime !== null,
description:
selected.unlockTime !== null || !selected.hidden || revealHidden
? selected.description
: undefined,
icongray: selected.icongray.endsWith("/")
? selected.icon
: selected.icongray,

View file

@ -0,0 +1,88 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { ComparedAchievements } from "@types";
import { withComparedAchievementDefinitions } from "./compared-achievement-definitions.ts";
const row = (
icon: string,
displayName: string
): ComparedAchievements["achievements"][number] => ({
hidden: false,
icon,
displayName,
description: "",
targetStat: { unlocked: false, unlockTime: 0 },
});
const singlePlayer = { id: 2, name: "Single Player", order: 0 };
const cooperative = { id: 1, name: "Cooperative", order: 1 };
describe("compared achievement definitions", () => {
it("matches rows to catalogue definitions by icon", () => {
const result = withComparedAchievementDefinitions(
{ achievements: [row("a.png", "Wake Up Call")] },
[
{
icon: "a.png",
displayName: "Hora de acordar",
globalUnlockPercentage: 80.5,
set: singlePlayer,
},
]
);
assert.equal(result.achievements[0].globalUnlockPercentage, 80.5);
assert.deepEqual(result.achievements[0].set, singlePlayer);
});
it("uses the display name when several definitions share an icon", () => {
const result = withComparedAchievementDefinitions(
{
achievements: [row("shared.png", "Second"), row("shared.png", "First")],
},
[
{
icon: "shared.png",
displayName: "First",
globalUnlockPercentage: 50,
set: singlePlayer,
},
{
icon: "shared.png",
displayName: "Second",
globalUnlockPercentage: 5,
set: cooperative,
},
]
);
assert.equal(result.achievements[0].globalUnlockPercentage, 5);
assert.deepEqual(result.achievements[0].set, cooperative);
assert.equal(result.achievements[1].globalUnlockPercentage, 50);
});
it("leaves unmatched rows empty and keeps values the API already sent", () => {
const result = withComparedAchievementDefinitions(
{
achievements: [
row("missing.png", "Missing"),
{ ...row("a.png", "Kept"), globalUnlockPercentage: 9, set: null },
],
},
[
{
icon: "a.png",
displayName: "Kept",
globalUnlockPercentage: 70,
set: singlePlayer,
},
]
);
assert.equal(result.achievements[0].globalUnlockPercentage, null);
assert.equal(result.achievements[0].set, null);
assert.equal(result.achievements[1].globalUnlockPercentage, 9);
assert.deepEqual(result.achievements[1].set, singlePlayer);
});
});

View file

@ -0,0 +1,50 @@
import type { ComparedAchievements, SteamAchievement } from "@types";
type ComparedAchievement = ComparedAchievements["achievements"][number];
type Definition = Pick<
SteamAchievement,
"icon" | "displayName" | "globalUnlockPercentage" | "set"
>;
const findDefinition = (
achievement: ComparedAchievement,
definitionsByIcon: Map<string, Definition[]>
) => {
const candidates = definitionsByIcon.get(achievement.icon) ?? [];
if (candidates.length === 1) return candidates[0];
return candidates.find(
(definition) => definition.displayName === achievement.displayName
);
};
export const withComparedAchievementDefinitions = <
T extends Pick<ComparedAchievements, "achievements">,
>(
compared: T,
definitions: readonly Definition[]
): T => {
const definitionsByIcon = new Map<string, Definition[]>();
for (const definition of definitions) {
const group = definitionsByIcon.get(definition.icon) ?? [];
group.push(definition);
definitionsByIcon.set(definition.icon, group);
}
return {
...compared,
achievements: compared.achievements.map((achievement) => {
const definition = findDefinition(achievement, definitionsByIcon);
return {
...achievement,
globalUnlockPercentage:
achievement.globalUnlockPercentage ??
definition?.globalUnlockPercentage ??
null,
set: achievement.set ?? definition?.set ?? null,
};
}),
};
};

View file

@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { getContentDispositionFilename } from "./download-filename.ts";
describe("getContentDispositionFilename", () => {
it("reads quoted and plain filenames", () => {
assert.equal(
getContentDispositionFilename('attachment; filename="Game.part1.rar"'),
"Game.part1.rar"
);
assert.equal(
getContentDispositionFilename("attachment; filename=Game.part2.rar"),
"Game.part2.rar"
);
});
it("prefers the encoded filename", () => {
assert.equal(
getContentDispositionFilename(
"attachment; filename=\"fallback.rar\"; filename*=UTF-8''Caf%C3%A9.part1.rar"
),
"Café.part1.rar"
);
});
it("keeps only the base name and replaces unsafe characters", () => {
assert.equal(
getContentDispositionFilename('attachment; filename="../dir/a:b.rar"'),
"a_b.rar"
);
});
it("returns undefined without a filename", () => {
assert.equal(getContentDispositionFilename(null), undefined);
assert.equal(getContentDispositionFilename("attachment"), undefined);
assert.equal(getContentDispositionFilename('filename=""'), undefined);
});
});

View file

@ -1,6 +1,56 @@
import path from "node:path";
const isMagnetUri = (value: string) =>
value.trim().toLowerCase().startsWith("magnet:");
const decodeFilenameValue = (value: string): string | undefined => {
const normalized = value.trim();
if (!normalized) return undefined;
const sanitize = (name: string) =>
path
.basename(name)
.replaceAll(/[<>:"/\\|?*]/g, "_")
.split("")
.filter((char) => char.charCodeAt(0) >= 32)
.join("")
.trim();
try {
const decoded = decodeURIComponent(normalized);
const sanitized = sanitize(decoded);
return sanitized || undefined;
} catch {
const sanitized = sanitize(normalized);
return sanitized || undefined;
}
};
export const getContentDispositionFilename = (
header: string | null
): string | undefined => {
if (!header) return undefined;
const filenameStarMatch = /filename\*\s*=\s*([^;]+)/i.exec(header);
if (filenameStarMatch?.[1]) {
const rawValue = filenameStarMatch[1].trim().replace(/^["']|["']$/g, "");
const encodedPart = rawValue.includes("''")
? rawValue.split("''").slice(1).join("''")
: rawValue;
const decoded = decodeFilenameValue(encodedPart);
if (decoded) return decoded;
}
const filenameMatch = /filename\s*=\s*([^;]+)/i.exec(header);
if (filenameMatch?.[1]) {
const rawValue = filenameMatch[1].trim().replace(/^["']|["']$/g, "");
const decoded = decodeFilenameValue(rawValue);
if (decoded) return decoded;
}
return undefined;
};
const getFilenameHintFromHash = (
value: string | undefined
): string | undefined => {

View file

@ -38,6 +38,7 @@ import {
FuckingFastApi,
VikingFileApi,
RootzApi,
FileKeeperApi,
} from "../hosters";
import { TorrentService } from "../torrent-service";
import {
@ -48,7 +49,10 @@ import {
isVerifyingStatus,
} from "./types";
import { calculateETA, getDirSize } from "./helpers";
import { extractDownloadFilename } from "./download-filename";
import {
extractDownloadFilename,
getContentDispositionFilename,
} from "./download-filename";
import { RealDebridClient } from "./real-debrid";
import path from "node:path";
import fs from "node:fs";
@ -83,6 +87,11 @@ import {
setDownloadLayoutQueues,
} from "../download-layout-state";
import { shouldFinalizeDownload } from "./download-completion";
import {
estimateMultipartTotalBytes,
getMultipartFolderName,
} from "./download-parts";
import { assertMultipartHosterDownloadsEnabled } from "./multipart-hoster-feature";
import { describeErrorCause } from "@main/helpers/download-error-handler";
import {
DISK_SPACE_CHECK_INTERVAL_MS,
@ -139,11 +148,14 @@ interface JsBatchEntry {
fileIndex?: number;
sourcePath?: string;
chunks?: number;
headers?: Record<string, string>;
}
interface JsBatchState {
generation?: number;
provider: "allDebrid" | "torBox" | "realDebrid";
provider: "allDebrid" | "torBox" | "realDebrid" | "hoster";
/** Resolves each hoster part with the single-link downloader options. */
hosterDownload?: Download;
downloadId: string;
savePath: string;
entries: JsBatchEntry[];
@ -210,6 +222,7 @@ export class DownloadManager {
PreparedJsDownload
>();
private static readonly PREPARED_JS_DOWNLOAD_TTL_MS = 120_000;
private static readonly HOSTER_FILENAME_PROBE_TIMEOUT_MS = 15_000;
public static hasActiveDownload() {
return this.downloadingGameId !== null;
@ -578,7 +591,22 @@ export class DownloadManager {
status.status === "complete";
batchFilesTotal = batch.entries.length;
if (batch.provider === "torBox" || batch.provider === "realDebrid") {
if (batch.provider === "hoster") {
const activeEntry = batch.entries[batch.activeIndex];
if (
activeEntry &&
batch.activeIndex === batch.currentIndex &&
status.fileSize > 0
) {
activeEntry.size = status.fileSize;
}
batch.totalBytes = this.estimateHosterPartsBytes(batch);
}
if (
batch.provider === "torBox" ||
batch.provider === "realDebrid" ||
batch.provider === "hoster"
) {
files = batch.entries.map((entry, index) => {
const previous = download.files?.find(
(file) => file.index === (entry.fileId ?? entry.fileIndex)
@ -1681,6 +1709,8 @@ export class DownloadManager {
return this.getVikingFileDownloadOptions(download, resumingFilename);
case Downloader.Rootz:
return this.getRootzDownloadOptions(download, resumingFilename);
case Downloader.FileKeeper:
return this.getFileKeeperDownloadOptions(download, resumingFilename);
case Downloader.ArchiveOrg:
return this.getArchiveOrgDownloadOptions(download, resumingFilename);
default:
@ -1693,6 +1723,9 @@ export class DownloadManager {
entry: JsBatchEntry,
reusePreparedLink = false
): Promise<string | undefined> {
if (batch.provider === "hoster") {
return this.resolveHosterPartUrl(batch, entry, reusePreparedLink);
}
this.assertSafeBatchPath(batch.savePath, entry.filename);
const url = entry.url;
if (batch.provider === "torBox") {
@ -1735,11 +1768,13 @@ export class DownloadManager {
): JsHttpDownloaderOptions {
const torBoxParallel = batch.provider === "torBox" && !entry.isZip;
const resourceId = this.getBatchResourceId(batch, entry);
const filePath = path.join(batch.savePath, entry.filename);
const options: JsHttpDownloaderOptions = {
url,
savePath: batch.savePath,
headers: entry.headers,
allowParallelRanges:
batch.provider === "allDebrid"
batch.provider === "allDebrid" || batch.provider === "hoster"
? undefined
: !entry.isZip &&
(batch.provider === "torBox" ||
@ -1760,15 +1795,18 @@ export class DownloadManager {
? TORBOX_MAX_PARALLEL_RANGES
: undefined,
preserveFilename: true,
// New hoster parts record their source before writing; legacy partials stay unidentified.
requireResourceIdentity:
batch.provider === "realDebrid" &&
(entry.isZip === true ||
readResourceIdentity(path.join(batch.savePath, entry.filename)) !==
null),
(batch.provider === "realDebrid" &&
(entry.isZip === true || readResourceIdentity(filePath) !== null)) ||
(batch.provider === "hoster" &&
(!fs.existsSync(filePath) ||
readResourceIdentity(filePath) !== null)),
resourceId,
beforeComplete:
batch.provider === "realDebrid"
? (filePath) => saveCompletedResourceIdentity(filePath, resourceId)
batch.provider === "realDebrid" || batch.provider === "hoster"
? (completedPath) =>
saveCompletedResourceIdentity(completedPath, resourceId)
: undefined,
expectedSize:
(batch.provider === "torBox" || batch.provider === "realDebrid") &&
@ -1805,6 +1843,13 @@ export class DownloadManager {
entry: JsBatchEntry,
options: JsHttpDownloaderOptions
) {
if (batch.provider === "hoster") {
return async () => {
const link = await this.resolveHosterPartUrl(batch, entry);
options.headers = entry.headers;
return link;
};
}
const { torrentId } = batch;
const { fileId } = entry;
if (
@ -1866,6 +1911,13 @@ export class DownloadManager {
entry: JsBatchEntry,
status: JsHttpDownloaderStatus
): void {
if (batch.provider === "hoster") {
entry.size = status.bytesDownloaded;
batch.completedBytes += status.bytesDownloaded;
batch.currentIndex += 1;
batch.totalBytes = this.estimateHosterPartsBytes(batch);
return;
}
const expectedSize = entry.size ?? 0;
if (entry.isZip && expectedSize !== status.bytesDownloaded) {
batch.totalBytes += status.bytesDownloaded - expectedSize;
@ -1958,6 +2010,7 @@ export class DownloadManager {
const batch = this.jsBatch;
const downloader = this.jsDownloader;
if (!(downloader instanceof JsHttpDownloader)) break;
if (batch.provider === "hoster") this.skipCompletedHosterParts(batch);
const entry = batch.entries[batch.currentIndex];
if (!entry) break;
if (!(await this.runJsBatchEntry(batch, downloader, entry))) break;
@ -2334,6 +2387,24 @@ export class DownloadManager {
);
}
private static async getFileKeeperDownloadOptions(
download: Download,
resumingFilename?: string
) {
await assertMultipartHosterDownloadsEnabled();
const downloadUrl = await FileKeeperApi.getDownloadUrl(download.uri);
const filename = this.resolveFilename(
resumingFilename,
download.uri,
downloadUrl
);
return this.buildDownloadOptions(
downloadUrl,
download.downloadPath,
filename
);
}
private static async getDownloadPayload(download: Download) {
const downloadId = levelKeys.game(download.shop, download.objectId);
@ -2940,27 +3011,253 @@ export class DownloadManager {
}
}
private static estimateHosterPartsBytes(batch: JsBatchState): number {
return estimateMultipartTotalBytes(
batch.entries.map((entry) => entry.size),
batch.hosterDownload?.fileSize ?? null
);
}
private static async resolveHosterPartUrl(
batch: JsBatchState,
entry: JsBatchEntry,
reusePreparedLink = false
): Promise<string> {
const prepared = entry.preparedLink;
entry.preparedLink = undefined;
const age = prepared ? Date.now() - prepared.resolvedAt : Infinity;
if (
!reusePreparedLink ||
!prepared ||
age < 0 ||
age >= this.PREPARED_JS_DOWNLOAD_TTL_MS
) {
const options = await this.getJsDownloadOptions({
...batch.hosterDownload!,
uri: entry.url!,
folderName: null,
});
if (!options) throw new Error("The download link is unavailable.");
entry.headers = options.headers;
this.assignHosterPartFilename(
batch,
entry,
entry.filename ? undefined : await this.resolvePartFilename(options)
);
return options.url;
}
this.assignHosterPartFilename(batch, entry, undefined);
return prepared.url;
}
/** Links without a file name in the URL, such as PixelDrain's, name it in Content-Disposition. */
private static async resolvePartFilename(
options: JsDownloadOptions
): Promise<string | undefined> {
if (options.filename) return options.filename;
const response = await fetch(options.url, {
headers: { ...options.headers, Range: "bytes=0-0" },
signal: AbortSignal.timeout(this.HOSTER_FILENAME_PROBE_TIMEOUT_MS),
});
await response.body?.cancel();
if (!response.ok) return undefined;
return getContentDispositionFilename(
response.headers.get("content-disposition")
);
}
/** Parts without a file name in their link take the resolved name. */
private static assignHosterPartFilename(
batch: JsBatchState,
entry: JsBatchEntry,
resolvedFilename: string | undefined
) {
if (!entry.filename) {
if (!resolvedFilename) {
throw new Error("A download part has no file name.");
}
entry.filename = `${batch.rootFolderName}/${this.sanitizeFilename(resolvedFilename)}`;
this.assertUniqueHosterPartFilenames(batch.entries);
}
this.assertSafeBatchPath(batch.savePath, entry.filename);
}
private static assertUniqueHosterPartFilenames(entries: JsBatchEntry[]) {
const filenames = entries
.map((entry) => entry.filename.toLowerCase())
.filter(Boolean);
if (new Set(filenames).size !== filenames.length) {
throw new Error("Two download parts have the same file name.");
}
}
private static async prepareHosterPartsBatch(
download: Download,
downloadId: string,
preparedOptions: JsDownloadOptions | null
): Promise<JsBatchState> {
await assertMultipartHosterDownloadsEnabled();
const parts = download.parts!;
const savedPaths = new Map(
download.files?.map((file) => [file.index, file.path])
);
const filenameHints = parts.map((uri) => this.extractFilename(uri));
let firstPartOptions = preparedOptions;
let firstPartFilename = filenameHints[0];
let rootFolderName = download.folderName;
if (!rootFolderName) {
if (!firstPartFilename && !firstPartOptions) {
firstPartOptions = await this.getJsDownloadOptions({
...download,
folderName: null,
});
}
if (!firstPartFilename && firstPartOptions) {
firstPartFilename = await this.resolvePartFilename(firstPartOptions);
}
if (!firstPartFilename) {
throw new Error("The first download part has no file name.");
}
rootFolderName = this.sanitizeFilename(
getMultipartFolderName(firstPartFilename)
);
}
const entries: JsBatchEntry[] = parts.map((uri, index) => {
const hint = filenameHints[index];
return {
url: uri,
filename:
savedPaths.get(index) ||
(hint ? `${rootFolderName}/${this.sanitizeFilename(hint)}` : ""),
fileIndex: index,
resourceId: uri,
};
});
if (firstPartOptions) {
entries[0].preparedLink = {
url: firstPartOptions.url,
resolvedAt: Date.now(),
};
entries[0].headers = firstPartOptions.headers;
if (!entries[0].filename) {
firstPartFilename ??= await this.resolvePartFilename(firstPartOptions);
if (firstPartFilename) {
entries[0].filename = `${rootFolderName}/${this.sanitizeFilename(firstPartFilename)}`;
}
}
}
this.assertUniqueHosterPartFilenames(entries);
const batchState: JsBatchState = {
provider: "hoster",
hosterDownload: download,
downloadId,
savePath: download.downloadPath,
entries,
sourceUri: download.uri,
rootFolderName,
currentIndex: 0,
activeIndex: -1,
completedBytes: 0,
totalBytes: 0,
lastSpeedUpdate: Date.now(),
bytesAtLastSpeedUpdate: null,
batchSpeed: 0,
};
this.restoreHosterPartsProgress(download, batchState);
return batchState;
}
/**
* Saved files decide progress. A part counts as done only with a completion
* receipt for its own link, so retries skip it without asking the hoster.
*/
private static restoreHosterPartsProgress(
download: Download,
batchState: JsBatchState
) {
const previousSizes = new Map(
download.files?.map((file) => [file.index, file.size])
);
download.files = batchState.entries.map((entry, index) => {
const file: DownloadFile = {
index,
path: entry.filename,
size: previousSizes.get(index) ?? 0,
bytesDownloaded: 0,
completed: false,
};
if (file.size > 0) entry.size = file.size;
if (!entry.filename) return file;
this.assertSafeBatchPath(batchState.savePath, entry.filename);
const filePath = path.join(batchState.savePath, entry.filename);
if (!fs.existsSync(filePath)) return file;
file.bytesDownloaded = getRangeDownloadedBytes(filePath);
const identity = getResourceIdentityState(
filePath,
this.getBatchResourceId(batchState, entry)
);
if (identity === "changed") {
throw new Error(
"The completed download file changed. Keeping the saved file."
);
}
if (identity === "complete") {
file.size = file.bytesDownloaded;
file.completed = true;
entry.size = file.size;
}
return file;
});
this.skipCompletedHosterParts(batchState);
batchState.totalBytes = this.estimateHosterPartsBytes(batchState);
}
/** Finished parts are skipped before their links are resolved, so expired links cannot stop a retry. */
private static skipCompletedHosterParts(batch: JsBatchState) {
const files = batch.hosterDownload?.files;
// The last part still runs, so the downloader reports the batch as complete.
while (batch.currentIndex < batch.entries.length - 1) {
const file = files?.[batch.currentIndex];
if (!file?.completed) break;
batch.completedBytes += file.size;
batch.currentIndex++;
}
}
private static async startBatchDownload(
download: Download,
downloadId: string,
myGeneration: number,
signal: AbortSignal
signal: AbortSignal,
preparedOptions: JsDownloadOptions | null = null
) {
let batchState: JsBatchState;
switch (download.downloader) {
case Downloader.TorBox:
batchState = await this.prepareTorBoxBatch(download, downloadId);
break;
case Downloader.AllDebrid:
batchState = await this.prepareAllDebridBatch(download, downloadId);
break;
default:
batchState = await this.prepareRealDebridBatch(
download,
downloadId,
myGeneration,
signal
);
if (download.parts) {
batchState = await this.prepareHosterPartsBatch(
download,
downloadId,
preparedOptions
);
} else {
switch (download.downloader) {
case Downloader.TorBox:
batchState = await this.prepareTorBoxBatch(download, downloadId);
break;
case Downloader.AllDebrid:
batchState = await this.prepareAllDebridBatch(download, downloadId);
break;
default:
batchState = await this.prepareRealDebridBatch(
download,
downloadId,
myGeneration,
signal
);
}
}
if (!this.isCurrentStart(downloadId, myGeneration, signal)) {
logger.log(
@ -2969,13 +3266,17 @@ export class DownloadManager {
return;
}
if (batchState.provider === "torBox") {
if (batchState.provider === "torBox" || batchState.provider === "hoster") {
const record = await downloadsSublevel.get(downloadId);
if (!record || signal.aborted || this.startGeneration !== myGeneration)
return;
await downloadsSublevel.put(downloadId, {
...record,
files: download.files,
folderName:
batchState.provider === "hoster"
? (batchState.rootFolderName ?? null)
: record.folderName,
});
if (signal.aborted || this.startGeneration !== myGeneration) return;
}
@ -3113,6 +3414,7 @@ export class DownloadManager {
this.usingJsDownloader = true;
try {
const isBatch =
Boolean(download.parts) ||
download.downloader === Downloader.AllDebrid ||
download.downloader === Downloader.TorBox ||
(download.downloader === Downloader.RealDebrid &&
@ -3122,7 +3424,8 @@ export class DownloadManager {
download,
downloadId,
myGeneration,
signal
signal,
preparedOptions
);
} else {
await this.startSingleJsDownload(

View file

@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
estimateMultipartTotalBytes,
getDownloadParts,
getMultipartFolderName,
} from "./download-parts.ts";
describe("getDownloadParts", () => {
it("accepts ordered parts that start with the download URI", () => {
assert.deepEqual(getDownloadParts("a", ["a", "b"]), ["a", "b"]);
assert.equal(getDownloadParts("a", undefined), undefined);
});
it("rejects parts that cannot be one download", () => {
for (const parts of [["a"], ["b", "a"], ["a", "a"], ["a", ""], "a"]) {
assert.throws(() => getDownloadParts("a", parts));
}
});
});
describe("getMultipartFolderName", () => {
it("removes the first volume suffix", () => {
assert.equal(
getMultipartFolderName("Example_Game_--_v1.0_--_.part1.rar"),
"Example_Game_--_v1.0"
);
assert.equal(getMultipartFolderName("Game.part001.rar"), "Game");
assert.equal(getMultipartFolderName("Game.7z.001"), "Game");
assert.equal(getMultipartFolderName("Game.zip"), "Game");
assert.equal(getMultipartFolderName("folder/Game.part1.rar"), "Game");
assert.equal(getMultipartFolderName("_.part1.rar"), "download");
});
});
describe("estimateMultipartTotalBytes", () => {
it("uses the fallback until a part size is known", () => {
assert.equal(estimateMultipartTotalBytes([undefined, undefined], 900), 900);
assert.equal(estimateMultipartTotalBytes([undefined], null), 0);
});
it("estimates unknown parts from the largest known part", () => {
assert.equal(
estimateMultipartTotalBytes([500, undefined, undefined], 9000),
1500
);
});
it("returns the exact total once every part is known", () => {
assert.equal(estimateMultipartTotalBytes([500, 500, 120], 9000), 1120);
});
});

View file

@ -0,0 +1,45 @@
const FIRST_VOLUME_SUFFIXES = [/\.part0*1\.rar$/i, /\.(?:7z|zip|rar)\.0*1$/i];
/** Returns ordered part URIs, or undefined for a single-link download. */
export function getDownloadParts(
uri: string,
parts: unknown
): string[] | undefined {
if (parts === undefined || parts === null) return undefined;
if (
!Array.isArray(parts) ||
parts.length < 2 ||
parts[0] !== uri ||
parts.some((part) => typeof part !== "string" || !part) ||
new Set(parts).size !== parts.length
) {
throw new Error("The multi-part download links are invalid.");
}
return parts;
}
/** Names the folder that holds every part, from the first part's filename. */
export function getMultipartFolderName(firstPartFilename: string): string {
const filename = firstPartFilename.split(/[\\/]/).at(-1) ?? "";
let base = filename;
for (const suffix of FIRST_VOLUME_SUFFIXES) base = base.replace(suffix, "");
if (base === filename) base = filename.replace(/\.[^.]+$/, "");
return base.replace(/[\s._-]+$/, "") || "download";
}
/**
* Split archives use one volume size, so unknown parts are estimated from the
* largest known part. Only the last part is smaller.
*/
export function estimateMultipartTotalBytes(
sizes: (number | undefined)[],
fallback: number | null
): number {
const known = sizes.filter(
(size): size is number => typeof size === "number" && size > 0
);
const knownBytes = known.reduce((sum, size) => sum + size, 0);
if (known.length === sizes.length) return knownBytes;
if (known.length === 0) return fallback ?? 0;
return knownBytes + (sizes.length - known.length) * Math.max(...known);
}

View file

@ -12,6 +12,7 @@ import { Downloader } from "../../../shared/constants.js";
import * as downloadContract from "../../../types/download-contract.js";
import * as downloaderTypes from "./types.js";
import * as completion from "./download-completion.js";
import * as downloadParts from "./download-parts.js";
import * as queueVerify from "./download-queue-verify.js";
import { withDownloadActivation } from "./download-activation.ts";
import { deferred } from "../epic/store-test-helpers.js";
@ -134,6 +135,10 @@ function fixture() {
"../../types": downloadContract,
"./types": downloaderTypes,
"./download-completion": completion,
"./download-parts": downloadParts,
"./multipart-hoster-feature": {
assertMultipartHosterDownloadsEnabled: async () => {},
},
"./download-queue-verify": queueVerify,
"node:path": path,
"node:fs": fs,

View file

@ -1,4 +1,5 @@
import { DownloadThrottle } from "./download-throttle";
import { getContentDispositionFilename } from "./download-filename";
import fs from "node:fs";
import type { FileHandle } from "node:fs/promises";
import path from "node:path";
@ -1223,50 +1224,9 @@ export class JsHttpDownloader {
}
private parseContentDisposition(response: Response): string | undefined {
const header = response.headers.get("content-disposition");
if (!header) return undefined;
const filenameStarMatch = /filename\*\s*=\s*([^;]+)/i.exec(header);
if (filenameStarMatch?.[1]) {
const rawValue = filenameStarMatch[1].trim().replace(/^["']|["']$/g, "");
const encodedPart = rawValue.includes("''")
? rawValue.split("''").slice(1).join("''")
: rawValue;
const decoded = this.decodeFilenameValue(encodedPart);
if (decoded) return decoded;
}
const filenameMatch = /filename\s*=\s*([^;]+)/i.exec(header);
if (filenameMatch?.[1]) {
const rawValue = filenameMatch[1].trim().replace(/^["']|["']$/g, "");
const decoded = this.decodeFilenameValue(rawValue);
if (decoded) return decoded;
}
return undefined;
}
private decodeFilenameValue(value: string): string | undefined {
const normalized = value.trim();
if (!normalized) return undefined;
const sanitize = (name: string) =>
path
.basename(name)
.replaceAll(/[<>:"/\\|?*]/g, "_")
.split("")
.filter((char) => char.charCodeAt(0) >= 32)
.join("")
.trim();
try {
const decoded = decodeURIComponent(normalized);
const sanitized = sanitize(decoded);
return sanitized || undefined;
} catch {
const sanitized = sanitize(normalized);
return sanitized || undefined;
}
return getContentDispositionFilename(
response.headers.get("content-disposition")
);
}
private resetRecoveryState(): void {

View file

@ -0,0 +1,27 @@
import { MULTIPART_HOSTER_DOWNLOADS_FEATURE } from "@shared";
import { HydraApi } from "../hydra-api";
import { logger } from "../logger";
const FEATURES_REQUEST_TIMEOUT_MS = 10_000;
// Fails closed: without an answer from the API, the feature stays off.
export const isMultipartHosterDownloadsEnabled = () =>
HydraApi.get<string[]>("/features", null, {
needsAuth: false,
timeout: FEATURES_REQUEST_TIMEOUT_MS,
})
.then(
(features) =>
Array.isArray(features) &&
features.includes(MULTIPART_HOSTER_DOWNLOADS_FEATURE)
)
.catch((error) => {
logger.warn("[DownloadManager] Could not load feature flags", error);
return false;
});
export const assertMultipartHosterDownloadsEnabled = async () => {
if (!(await isMultipartHosterDownloadsEnabled())) {
throw new Error("Multi-part hoster downloads are turned off.");
}
};

View file

@ -25,6 +25,7 @@ export const EPIC_SNAPSHOT_MAX_CHUNK_BYTES = 1024 * 1024;
export const EPIC_ACHIEVEMENT_MAX_GAME_BYTES =
EPIC_SNAPSHOT_MAX_CHUNK_BYTES / 2;
export const EPIC_MAX_ARTIFACTS = 16;
export const EPIC_MAX_DLCS_PER_GAME = 64;
export const EPIC_ID_MAX_LENGTH = 255;
export const EPIC_GAME_NAME_MAX_LENGTH = 256;
export const EPIC_MAX_PLAYTIME_SECONDS = 2_147_483_647;
@ -56,6 +57,9 @@ export const EPIC_LAUNCH_DETECTION_TIMEOUT_MS = 60_000;
export const EPIC_INSTALL_OPTIONS_CACHE_MS = 5 * 60_000;
export const EPIC_INSTALL_OPTIONS_CACHE_MAX_ENTRIES = 32;
export const EPIC_INSTALL_OPTIONS_TIMEOUT_MS = 90_000;
export const EPIC_DLC_INSPECTION_RESERVE_MS = 30_000;
export const EPIC_DLC_INSPECTION_BUDGET_MS =
EPIC_INSTALL_OPTIONS_TIMEOUT_MS - EPIC_DLC_INSPECTION_RESERVE_MS;
// Hashing a large store install can take a long time on slow disks.
export const EPIC_IMPORT_VERIFY_TIMEOUT_MS = 60 * 60_000;
export const EPIC_INSTALL_LIBRARY_CACHE_MS = EPIC_INSTALL_OPTIONS_CACHE_MS;

View file

@ -4,14 +4,19 @@ import os from "node:os";
import path from "node:path";
import { test, type TestContext } from "node:test";
import type { Download, Game } from "../../../types/level.types";
import type { EpicSnapshotDlc } from "../../../types/epic-integration.types";
import { Downloader } from "../../../shared/constants.js";
import {
EpicDownloadService,
EpicDownloadProgressParser,
epicInstallArgs,
epicInstallTags,
getReusableEpicDiskBytes,
quoteWindowsEpicArgument,
runEpicPrerequisite,
withInstalledEpicDlcs,
} from "./download.js";
import { EPIC_DLC_INSPECTION_BUDGET_MS } from "./constants.js";
import { EpicInstallationService, epicManagedTarget } from "./installation.js";
import {
EpicManagedError,
@ -28,6 +33,8 @@ import {
} from "./store-test-helpers.js";
const MIB = 1024 * 1024;
const COMPLETION_POLL_ATTEMPTS = 500;
const COMPLETION_POLL_INTERVAL_MS = 5;
const auth = {
environment: "https://api.example.test",
userId: "managed-test",
@ -101,6 +108,7 @@ async function fixture(
let game = structuredClone(initial);
let current = true;
let remoteOwned = true;
let ownedDlcs: EpicSnapshotDlc[] = [];
let freeSpace = 100 * 1024 * MIB;
let diskRequests = 0;
let download: Download | undefined;
@ -205,6 +213,7 @@ async function fixture(
epicImport: {
connectionId: connection.connectionId,
artifactIds: ["Artifact1"],
dlcs: ownedDlcs,
},
}
: null,
@ -270,6 +279,9 @@ async function fixture(
disown: () => {
remoteOwned = false;
},
setOwnedDlcs: (value: EpicSnapshotDlc[]) => {
ownedDlcs = value;
},
setFreeSpace: (value: number) => {
freeSpace = value;
},
@ -951,3 +963,561 @@ test("file removal holds the workspace and blocks restarting the same download",
release.resolve();
await removal;
});
test("install arguments repeat Legendary's install tags because the workspace forgets them", () => {
const base = {
artifactId: "Artifact1",
platform: "Windows",
basePath: "/games",
folderName: "EpicGame",
};
assert.deepEqual(epicInstallArgs(base), [
"-y",
"install",
"Artifact1",
"--platform",
"Windows",
"--base-path",
"/games",
"--game-folder",
"EpicGame",
"--skip-dlcs",
"--skip-sdl",
]);
assert.deepEqual(
epicInstallArgs({ ...base, installTags: ["", "voice_de_de"] }).slice(-2),
["--install-tag=", "--install-tag=voice_de_de"]
);
assert.deepEqual(epicInstallTags(undefined), []);
assert.deepEqual(epicInstallTags(null), []);
assert.deepEqual(epicInstallTags(["", "chunk0", "chunk0"]), ["", "chunk0"]);
for (const value of [
"chunk0",
[1],
["--manifest=/tmp/x"],
["a b"],
Array.from({ length: 257 }, (_, index) => `tag${index}`),
])
assert.throws(() => epicInstallTags(value), {
code: "installation-incomplete",
});
});
test("update progress adopts Legendary's delta as the total and keeps it on resume", () => {
const update = new EpicDownloadProgressParser(0);
update.line("[cli] INFO: Download size: 4.00 MiB");
assert.equal(update.state.fileSize, 4 * MIB);
assert.equal(update.state.bytesDownloaded, 0);
update.line("[DLManager] INFO: = Progress: 50.00%, ETA: 00:00:10");
assert.equal(update.state.progress, 0.5);
const resumed = new EpicDownloadProgressParser(4 * MIB, 2 * MIB, 0.5);
resumed.line("[cli] INFO: Download size: 2.00 MiB");
assert.equal(resumed.state.fileSize, 4 * MIB);
assert.equal(resumed.state.bytesDownloaded, 2 * MIB);
const empty = new EpicDownloadProgressParser(0);
empty.line(
"[cli] INFO: Download size is 0, the game is either already up to date or has not changed."
);
assert.equal(empty.state.fileSize, 0);
assert.equal(empty.state.preparing, false);
});
test("an Epic update reuses the install command in the installed folder with its install tags", async (t) => {
const previousVersion = info.game.version;
t.after(() => {
info.game.version = previousVersion;
});
const f = await fixture(t, async (directory, options, destination) => {
const update = info.game.version !== "build-1";
options.onLine?.(
`[cli] INFO: Download size: ${update ? "0.50" : "2.00"} MiB`
);
options.onLine?.("[DLManager] INFO: = Progress: 100.00%, ETA: 00:00:00");
await fs.mkdir(destination, { recursive: true });
await fs.writeFile(path.join(destination, "Game.exe"), info.game.version);
await fs.writeFile(
path.join(directory, "installed.json"),
JSON.stringify({
Artifact1: {
app_name: "Artifact1",
platform: "Windows",
install_path: destination,
executable: "Game.exe",
install_size: 4 * MIB,
version: info.game.version,
install_tags: ["", "voice_de_de"],
},
})
);
});
const waitForComplete = async () => {
for (let attempt = 0; attempt < COMPLETION_POLL_ATTEMPTS; attempt++) {
if (f.getDownload()?.status === "complete") return;
await new Promise((resolve) =>
setTimeout(resolve, COMPLETION_POLL_INTERVAL_MS)
);
}
assert.fail("download did not complete");
};
const releaseRuntimeSlot = () => f.service.stop();
t.mock.method(
f.installation,
"captureLocal",
(id: string, signal: AbortSignal) => f.installation.capture(id, signal)
);
const { downloadPath: _downloadPath, ...target } = f.payload;
assert.deepEqual(await f.service.startUpdate(target), {
ok: false,
error: "installation-missing",
});
assert.deepEqual(await f.service.start(f.payload), { ok: true });
await f.service.startRuntime(f.getDownload()!, (error) =>
f.failed.resolve(error)
);
await waitForComplete();
await releaseRuntimeSlot();
const firstInstall = f.commands.filter((args) => args.includes("install"));
assert.equal(firstInstall.length, 1);
assert.equal(
firstInstall[0].some((arg) => arg.startsWith("--install-tag")),
false
);
assert.equal(f.getDownload()?.epic?.update, undefined);
info.game.version = "build-2";
const options = await f.service.getOptions(objectId);
assert.equal(options.options[0].installed, true);
assert.equal(options.options[0].publishedVersion, "build-2");
assert.equal(options.options[0].installedVersion, "build-1");
assert.deepEqual(
await f.service.startUpdate({ ...target, artifactId: "Other" }),
{ ok: false, error: "invalid-artifact" }
);
assert.deepEqual(
await f.service.startUpdate({
...target,
preparationId: options.options[0].preparationId,
}),
{ ok: true }
);
const queued = f.getDownload()!;
assert.equal(queued.downloadPath, f.base);
assert.equal(queued.fileSize, 0);
assert.deepEqual(queued.epic?.update, {
fromVersion: "build-1",
toVersion: "build-2",
});
await f.service.startRuntime(queued, (error) => f.failed.resolve(error));
await waitForComplete();
const updateInstall = f.commands.filter((args) => args.includes("install"));
assert.equal(updateInstall.length, 2);
assert.deepEqual(updateInstall[1].slice(-2), [
"--install-tag=",
"--install-tag=voice_de_de",
]);
assert.equal(f.getDownload()?.fileSize, 0.5 * MIB);
assert.equal(f.getDownload()?.epic?.update, undefined);
const updated = await f.service.getOptions(objectId);
assert.equal(updated.options[0].installedVersion, "build-2");
});
const sdl = {
required_tags: ["chunk0"],
download_size: 1 * MIB,
disk_size: 2 * MIB,
packs: [
{
id: "de",
name: "Deutsch",
tags: ["voice_de"],
download_size: 0.5 * MIB,
disk_size: 1 * MIB,
},
{
id: "fr",
name: "Français",
tags: ["voice_fr"],
download_size: 0.25 * MIB,
disk_size: 0.5 * MIB,
},
],
};
const dlcInfo = (artifactId: string, version = "dlc-1") => ({
info: {
game: { app_name: artifactId, version, is_dlc: true },
manifest: { is_preloaded: false, download_size: 1 * MIB, disk_size: MIB },
},
metadata: {
app_name: artifactId,
metadata: {
namespace: "Namespace1",
id: `${artifactId}Item`,
categories: [{ path: "addons" }],
mainGameItem: {
id: "Item1",
namespace: "Namespace1",
releaseInfo: [{ appId: "Artifact1" }],
},
},
asset_infos: {
Windows: {
app_name: artifactId,
namespace: "Namespace1",
catalog_item_id: `${artifactId}Item`,
},
},
},
});
const ownedDlc = (artifactId: string): EpicSnapshotDlc => ({
artifactId,
namespace: "Namespace1",
catalogItemId: `${artifactId}Item`,
name: `DLC ${artifactId}`,
});
async function selectionFixture(
t: TestContext,
onInstall?: (
args: string[],
options: { onLine?: (line: string) => void }
) => boolean
) {
const installed: Record<string, unknown> = {};
const f = await fixture(t, async (directory, options, destination) => {
const args = f.commands[f.commands.length - 1];
if (onInstall?.(args, options) === false) return;
const artifact = args[args.indexOf("install") + 1];
options.onLine?.("[cli] INFO: Download size: 1.00 MiB");
await fs.mkdir(destination, { recursive: true });
await fs.writeFile(path.join(destination, "Game.exe"), "fixture-game");
installed[artifact] =
artifact === "Artifact1"
? {
app_name: "Artifact1",
platform: "Windows",
install_path: destination,
executable: "Game.exe",
install_size: 4 * MIB,
version: "build-1",
install_tags: args
.filter((arg) => arg.startsWith("--install-tag="))
.map((arg) => arg.slice("--install-tag=".length)),
}
: {
app_name: artifact,
platform: "Windows",
install_path: destination,
executable: "",
install_size: MIB,
version: "dlc-1",
is_dlc: true,
};
await fs.writeFile(
path.join(directory, "installed.json"),
JSON.stringify(installed)
);
});
t.mock.method(
f.runtime,
"inspect",
async (target, _id, signal, _cached, base) => {
f.runtime.assertCurrent(target, signal);
if (!base) return { info: { ...info, sdl }, metadata };
if (target.artifactId === "Broken")
throw new EpicManagedError("not-installable");
return dlcInfo(target.artifactId);
}
);
const waitForStatus = async (status: Download["status"]) => {
for (let attempt = 0; attempt < COMPLETION_POLL_ATTEMPTS; attempt++) {
if (f.getDownload()?.status === status) return;
await new Promise((resolve) =>
setTimeout(resolve, COMPLETION_POLL_INTERVAL_MS)
);
}
assert.fail(`download did not reach ${status}`);
};
return { ...f, installed, waitForStatus };
}
test("a component selection installs only its tags and sizes the download by it", async (t) => {
const f = await selectionFixture(t);
const preview = await f.service.getOptions(objectId);
assert.deepEqual(
preview.options[0].components?.map(({ id }) => id),
["de", "fr"]
);
assert.equal(preview.options[0].requiredDownloadSizeInBytes, 1 * MIB);
assert.equal(preview.options[0].requiredDiskSizeInBytes, 2 * MIB);
for (const components of [["xx"], ["de", "de"]])
assert.deepEqual(await f.service.start({ ...f.payload, components }), {
ok: false,
error: "invalid-selection",
});
assert.deepEqual(
await f.service.start({ ...f.payload, components: ["de"] }),
{ ok: true }
);
const queued = f.getDownload()!;
assert.equal(queued.fileSize, 1.5 * MIB);
assert.equal(queued.epic?.diskSizeInBytes, 3 * MIB);
assert.deepEqual(queued.epic?.selection, {
components: ["de"],
installTags: ["", "chunk0", "voice_de"],
dlcs: [],
});
await f.service.startRuntime(queued, (error) => f.failed.resolve(error));
await f.waitForStatus("complete");
const install = f.commands.find((args) => args.includes("install"))!;
assert.deepEqual(
install.filter((arg) => arg.startsWith("--install-tag")),
["--install-tag=", "--install-tag=chunk0", "--install-tag=voice_de"]
);
assert.equal(f.getDownload()?.fileSize, 1.5 * MIB);
});
test("selecting every component or none of the API is the complete game", async (t) => {
const f = await selectionFixture(t);
for (const components of [["de", "fr"], undefined]) {
assert.deepEqual(
await f.service.start({
...f.payload,
...(components ? { components } : {}),
}),
{ ok: true }
);
assert.equal(f.getDownload()?.fileSize, 2 * MIB);
assert.deepEqual(f.getDownload()?.epic?.selection?.installTags, []);
}
assert.deepEqual(await f.service.start({ ...f.payload, components: [] }), {
ok: true,
});
assert.equal(f.getDownload()?.fileSize, 1 * MIB);
assert.deepEqual(f.getDownload()?.epic?.selection?.installTags, [
"",
"chunk0",
]);
});
test("owned DLC is offered, installed after the game and forgotten with it", async (t) => {
const f = await selectionFixture(t);
f.setOwnedDlcs([ownedDlc("Dlc1"), ownedDlc("Broken")]);
const preview = await f.service.getOptions(objectId);
assert.deepEqual(preview.options[0].dlcs, [
{
artifactId: "Dlc1",
name: "DLC Dlc1",
downloadSizeInBytes: 1 * MIB,
diskSizeInBytes: MIB,
installed: false,
},
]);
assert.deepEqual(await f.service.start({ ...f.payload, dlcs: ["Broken"] }), {
ok: false,
error: "invalid-selection",
});
assert.deepEqual(await f.service.start({ ...f.payload, dlcs: ["Dlc1"] }), {
ok: true,
});
assert.equal(f.getDownload()?.fileSize, 3 * MIB);
assert.equal(f.getDownload()?.epic?.diskSizeInBytes, 5 * MIB);
await f.service.startRuntime(f.getDownload()!, (error) =>
f.failed.resolve(error)
);
await f.waitForStatus("complete");
const installs = f.commands
.filter((args) => args.includes("install"))
.map((args) => args[args.indexOf("install") + 1]);
assert.deepEqual(installs, ["Artifact1", "Dlc1"]);
assert.equal(f.getDownload()?.fileSize, 3 * MIB);
assert.equal(f.getDownload()?.bytesDownloaded, 3 * MIB);
await f.service.stop();
const after = await f.service.getOptions(objectId);
assert.equal(after.options[0].dlcs?.[0].installed, true);
await f.service.forgetInstallation(f.getDownload()!);
const target = epicManagedTarget(
await f.installation.capture(objectId, new AbortController().signal),
"Artifact1",
"Windows"
);
assert.deepEqual(
JSON.parse(
await fs.readFile(
path.join(f.runtime.workspace(target), "installed.json"),
"utf8"
)
),
{}
);
});
test("a failed DLC keeps the game bound and resumes with only the DLC", async (t) => {
let failDlc = true;
const f = await selectionFixture(t, (args, options) => {
if (!args.includes("Dlc1") || !failDlc) return true;
options.onLine?.("[cli] ERROR: DLC transfer failed");
return false;
});
f.setOwnedDlcs([ownedDlc("Dlc1")]);
assert.deepEqual(await f.service.start({ ...f.payload, dlcs: ["Dlc1"] }), {
ok: true,
});
await f.service.startRuntime(f.getDownload()!, (error) =>
f.failed.resolve(error)
);
assert.deepEqual(
await f.failed.promise,
new EpicManagedError("process-failed")
);
assert.ok(f.getGame().executablePath);
assert.equal(f.getGame().epicInstallation?.artifactId, "Artifact1");
await f.service.stop();
failDlc = false;
const before = f.commands.length;
await f.service.startRuntime(
{
...f.getDownload()!,
status: "paused",
bytesDownloaded: 2.5 * MIB,
progress: 2.5 / 3,
},
() => {}
);
await f.waitForStatus("complete");
assert.equal(f.getDownload()?.fileSize, 3 * MIB);
assert.deepEqual(
f.commands
.slice(before)
.filter((args) => args.includes("install"))
.map((args) => args[args.indexOf("install") + 1]),
["Dlc1"]
);
});
test("a resumed install keeps the tags it started with when the pack definition changes", async (t) => {
const f = await selectionFixture(t);
assert.deepEqual(
await f.service.start({ ...f.payload, components: ["de"] }),
{ ok: true }
);
t.mock.method(f.runtime, "inspect", async (target, _id, signal) => {
f.runtime.assertCurrent(target, signal);
return {
info: {
...info,
sdl: { ...sdl, packs: sdl.packs.filter((pack) => pack.id !== "de") },
},
metadata,
};
});
await f.service.startRuntime(f.getDownload()!, (error) =>
f.failed.resolve(error)
);
await f.waitForStatus("complete");
const install = f.commands.find((args) => args.includes("install"))!;
assert.deepEqual(
install.filter((arg) => arg.startsWith("--install-tag")),
["--install-tag=", "--install-tag=chunk0", "--install-tag=voice_de"]
);
assert.equal(f.getDownload()?.fileSize, 1.5 * MIB);
});
test("installed DLC joins the sized selection so updates check its space", () => {
const prepared = {
dlcs: [
{ artifactId: "selected" },
{ artifactId: "installed" },
{ artifactId: "absent" },
],
} as Parameters<typeof withInstalledEpicDlcs>[0];
const installed = new Map([
["installed", "1.0"],
["unknown", "1.0"],
]);
assert.deepEqual(withInstalledEpicDlcs(prepared, ["selected"], installed), [
"selected",
"installed",
]);
assert.deepEqual(withInstalledEpicDlcs(prepared, undefined, installed), [
"installed",
]);
assert.deepEqual(withInstalledEpicDlcs(prepared, ["installed"], installed), [
"installed",
]);
assert.equal(
withInstalledEpicDlcs(prepared, undefined, new Map()),
undefined
);
});
test("a stalled DLC inspection still offers the game and the DLC inspected before it", async (t) => {
const f = await selectionFixture(t);
f.setOwnedDlcs([ownedDlc("Dlc1"), ownedDlc("Stalled"), ownedDlc("Dlc2")]);
const entered = deferred<void>();
t.mock.method(
f.runtime,
"inspect",
async (target, _id, signal, _cached, base) => {
f.runtime.assertCurrent(target, signal);
if (!base) return { info: { ...info, sdl }, metadata };
if (target.artifactId !== "Stalled") return dlcInfo(target.artifactId);
entered.resolve();
return await new Promise<never>((_resolve, reject) =>
signal.addEventListener("abort", () => reject(signal.reason), {
once: true,
})
);
}
);
t.mock.timers.enable({ apis: ["setTimeout"] });
const pending = f.service.getOptions(objectId);
await entered.promise;
t.mock.timers.tick(EPIC_DLC_INSPECTION_BUDGET_MS);
const preview = await pending;
assert.equal(preview.error, undefined);
assert.deepEqual(
preview.options[0].dlcs?.map(({ artifactId }) => artifactId),
["Dlc1"]
);
});
test("a selected DLC that cannot be inspected at start fails the run instead of being dropped", async (t) => {
const f = await selectionFixture(t);
f.setOwnedDlcs([ownedDlc("Dlc1"), ownedDlc("Dlc2")]);
assert.deepEqual(
await f.service.start({ ...f.payload, dlcs: ["Dlc1", "Dlc2"] }),
{ ok: true }
);
let inspectionFails = true;
t.mock.method(
f.runtime,
"inspect",
async (target, _id, signal, _cached, base) => {
f.runtime.assertCurrent(target, signal);
if (!base) return { info: { ...info, sdl }, metadata };
if (target.artifactId === "Dlc2" && inspectionFails)
throw new EpicManagedError("process-failed");
return dlcInfo(target.artifactId);
}
);
await assert.rejects(
f.service.startRuntime(f.getDownload()!, () => {}),
new EpicManagedError("process-failed")
);
assert.deepEqual(f.getDownload()?.epic?.selection?.dlcs, ["Dlc1", "Dlc2"]);
assert.equal(
f.commands.some((args) => args.includes("install")),
false
);
inspectionFails = false;
await f.service.startRuntime(f.getDownload()!, () => {});
await f.waitForStatus("complete");
assert.deepEqual(
f.commands
.filter((args) => args.includes("install"))
.map((args) => args[args.indexOf("install") + 1]),
["Artifact1", "Dlc1", "Dlc2"]
);
});

View file

@ -6,6 +6,8 @@ import type { Download, Game } from "../../../types/level.types";
import type { DownloadProgress } from "../../../types/download.types";
import type {
StartEpicGameDownloadPayload,
StartEpicGameUpdatePayload,
EpicInstallSelection,
EpicManagedOperationResult,
} from "../../../types/epic-managed.types";
import {
@ -13,7 +15,10 @@ import {
MINIMUM_FREE_DISK_SPACE_BYTES,
} from "../../../shared/constants.js";
import { isRecord } from "./auth-protocol.js";
import { EPIC_PREREQUISITE_TIMEOUT_MS } from "./constants.js";
import {
EPIC_INSTALL_OPTIONS_TIMEOUT_MS,
EPIC_PREREQUISITE_TIMEOUT_MS,
} from "./constants.js";
import { markEpicTiming, withEpicTiming } from "./timing.js";
import {
EpicManagedError,
@ -25,13 +30,20 @@ import {
type LegendaryManagedRuntime,
} from "./legendary-managed-runtime.js";
import {
epicManagedTarget,
forgetEpicInstalledEntries,
isPathContained,
readManagedJson,
writeManagedJson,
type EpicInstallationService,
type EpicInstalledGame,
type EpicOwnedGame,
type EpicPreparedDlc,
type EpicPreparedInstallation,
EPIC_MAX_INSTALL_TAGS,
isEpicInstallTag,
isEpicSelectionId,
isSafeEpicArtifact,
} from "./installation.js";
const MIB_BYTES = 1024 * 1024;
@ -82,6 +94,9 @@ export class EpicDownloadProgressParser {
const downloadSize = /Download size:\s*([\d.]+)\s*MiB/i.exec(line);
if (downloadSize) {
const remaining = Number(downloadSize[1]) * MIB_BYTES;
const updateDeltaPending = this.state.fileSize <= 0;
if (Number.isFinite(remaining) && updateDeltaPending)
this.state.fileSize = Math.round(remaining);
if (Number.isFinite(remaining))
this.reusedBytes = Math.max(0, this.state.fileSize - remaining);
this.state.bytesDownloaded = Math.max(
@ -146,7 +161,10 @@ interface ActiveEpicDownload {
controller: AbortController;
target: EpicManagedTarget;
download: Download;
parser: EpicDownloadProgressParser;
currentRunParser: EpicDownloadProgressParser;
finishedRunsBytes: number;
plannedTotalBytes: number | null;
selection: ResolvedEpicSelection;
done: Promise<void>;
completed: boolean;
version: string;
@ -174,6 +192,149 @@ export async function getReusableEpicDiskBytes(
export { quoteWindowsEpicArgument } from "./prerequisite-runtime.js";
export interface ResolvedEpicSelection {
selection: EpicInstallSelection;
dlcs: EpicPreparedDlc[];
gameDownloadSizeInBytes: number;
downloadSizeInBytes: number;
diskSizeInBytes: number;
}
const hasDuplicateIds = (values: string[] | undefined) =>
values !== undefined && new Set(values).size !== values.length;
export function installedEpicComponents(
prepared: EpicPreparedInstallation,
installTags: string[]
): string[] | undefined {
const selective = prepared.selective;
if (!selective || !installTags.length) return undefined;
const tags = new Set(installTags);
return selective.packs
.filter((pack) => pack.tags.every((tag) => tags.has(tag)))
.map((pack) => pack.id);
}
export function withoutInstalledEpicGame(
resolved: ResolvedEpicSelection
): ResolvedEpicSelection {
return {
...resolved,
selection: { ...resolved.selection, gameAlreadyInstalled: true },
gameDownloadSizeInBytes: 0,
downloadSizeInBytes:
resolved.downloadSizeInBytes - resolved.gameDownloadSizeInBytes,
};
}
export function withInstalledEpicDlcs(
prepared: EpicPreparedInstallation,
requested: string[] | undefined,
installed: ReadonlyMap<string, string>
): string[] | undefined {
const extra = (prepared.dlcs ?? [])
.filter(
(dlc) =>
installed.has(dlc.artifactId) &&
installed.get(dlc.artifactId) !== dlc.version &&
!requested?.includes(dlc.artifactId)
)
.map((dlc) => dlc.artifactId);
if (!extra.length) return requested;
return [...(requested ?? []), ...extra];
}
export function resolveEpicSelection(
prepared: EpicPreparedInstallation,
request: { components?: string[]; dlcs?: string[] }
): ResolvedEpicSelection {
if (hasDuplicateIds(request.components) || hasDuplicateIds(request.dlcs))
throw new EpicManagedError("invalid-selection");
const selective = prepared.selective;
const packs = (request.components ?? []).map((id) => {
const pack = selective?.packs.find((candidate) => candidate.id === id);
if (!pack) throw new EpicManagedError("invalid-selection");
return pack;
});
const dlcs = (request.dlcs ?? []).map((artifactId) => {
const dlc = prepared.dlcs?.find(
(candidate) => candidate.artifactId === artifactId
);
if (!dlc) throw new EpicManagedError("invalid-selection");
return dlc;
});
const skipsSomePack =
!!selective &&
request.components !== undefined &&
packs.length < selective.packs.length;
const installTags = skipsSomePack
? [
...new Set([
"",
...selective.requiredTags,
...packs.flatMap((pack) => pack.tags),
]),
]
: [];
const sum = (values: number[]) => values.reduce((a, b) => a + b, 0);
const gameDownloadSizeInBytes = skipsSomePack
? selective.downloadSizeInBytes +
sum(packs.map((pack) => pack.downloadSizeInBytes))
: prepared.downloadSizeInBytes;
const gameDiskSizeInBytes = skipsSomePack
? selective.diskSizeInBytes + sum(packs.map((pack) => pack.diskSizeInBytes))
: prepared.diskSizeInBytes;
return {
selection: {
...(request.components !== undefined
? { components: [...request.components] }
: {}),
installTags,
dlcs: dlcs.map((dlc) => dlc.artifactId),
},
dlcs,
gameDownloadSizeInBytes,
downloadSizeInBytes:
gameDownloadSizeInBytes + sum(dlcs.map((dlc) => dlc.downloadSizeInBytes)),
diskSizeInBytes:
gameDiskSizeInBytes + sum(dlcs.map((dlc) => dlc.diskSizeInBytes)),
};
}
export function epicInstallTags(value: unknown): string[] {
if (value === undefined || value === null) return [];
if (
!Array.isArray(value) ||
value.length > EPIC_MAX_INSTALL_TAGS ||
!value.every(isEpicInstallTag)
)
throw new EpicManagedError("installation-incomplete");
return [...new Set(value)];
}
export function epicInstallArgs(options: {
artifactId: string;
platform: string;
basePath: string;
folderName: string;
installTags?: string[];
}) {
return [
"-y",
"install",
options.artifactId,
"--platform",
options.platform,
"--base-path",
options.basePath,
"--game-folder",
options.folderName,
"--skip-dlcs",
"--skip-sdl",
...(options.installTags ?? []).map((tag) => `--install-tag=${tag}`),
];
}
async function runDirectEpicPrerequisite(
executable: string,
args: string[],
@ -231,6 +392,9 @@ export class EpicDownloadService {
getOptions(objectId: string, signal?: AbortSignal) {
return this.options.installation.getOptions(objectId, signal);
}
invalidateOptions(objectId: string) {
this.options.installation.invalidateOptions(objectId);
}
private id(objectId: string) {
return `epic:${objectId}`;
}
@ -298,6 +462,34 @@ export class EpicDownloadService {
if (this.enqueues.get(id) === operation) this.enqueues.delete(id);
}
}
async startUpdate(
payload: StartEpicGameUpdatePayload
): Promise<EpicManagedOperationResult> {
try {
const owned = await this.options.installation.captureLocal(
payload.epicObjectId,
AbortSignal.timeout(EPIC_INSTALL_OPTIONS_TIMEOUT_MS)
);
const installation = owned.game.epicInstallation;
if (!installation) throw new EpicManagedError("installation-missing");
if (
installation.artifactId !== payload.artifactId ||
installation.platform !== payload.platform
)
throw new EpicManagedError("invalid-artifact");
const installed = await this.options.installation.readInstalledForRepair(
epicManagedTarget(owned, payload.artifactId, payload.platform),
payload.epicObjectId
);
if (!installed) throw new EpicManagedError("installation-missing");
return await this.start({
...payload,
downloadPath: path.dirname(installed.installPath),
});
} catch (error) {
return { ok: false, error: epicManagedErrorCode(error) };
}
}
private async enqueue(
payload: StartEpicGameDownloadPayload,
controller: AbortController
@ -338,6 +530,33 @@ export class EpicDownloadService {
)
throw new EpicManagedError("invalid-path");
this.options.runtime.assertCurrent(prepared.target, controller.signal);
const update =
installed && installed.version !== prepared.version
? {
fromVersion: installed.version,
toVersion: prepared.version,
...(existing?.epic?.update?.filesChanged
? { filesChanged: true }
: {}),
}
: undefined;
const installedDlcs = installed
? await this.options.installation.readInstalledDlcs(
prepared.target,
installed.installPath
)
: new Map<string, string>();
const selection = resolveEpicSelection(prepared, {
components: installed
? installedEpicComponents(
prepared,
epicInstallTags(installed.raw.install_tags)
)
: payload.components,
dlcs: withInstalledEpicDlcs(prepared, payload.dlcs, installedDlcs),
});
const resolved =
installed && !update ? withoutInstalledEpicGame(selection) : selection;
const download: Download = {
shop: "epic",
objectId: payload.epicObjectId,
@ -347,7 +566,7 @@ export class EpicDownloadService {
downloader: Downloader.Epic,
progress: 0,
bytesDownloaded: 0,
fileSize: prepared.downloadSizeInBytes,
fileSize: update ? 0 : resolved.downloadSizeInBytes,
shouldSeed: false,
status: "paused",
queued: false,
@ -360,8 +579,10 @@ export class EpicDownloadService {
connectionId: owned.connection.connectionId,
artifactId: payload.artifactId,
platform: payload.platform,
diskSizeInBytes: prepared.diskSizeInBytes,
diskSizeInBytes: resolved.diskSizeInBytes,
phase: "preparing",
selection: resolved.selection,
...(update ? { update } : {}),
},
};
// Never create/promote the imported Game or send a Hydra download mutation.
@ -476,12 +697,30 @@ export class EpicDownloadService {
throw new EpicManagedError("stale-connection");
if (this.options.isGameRunning(owned.game))
throw new EpicManagedError("game-running");
const installedDlcs = download.folderName
? await this.options.installation.readInstalledDlcs(
epicManagedTarget(owned, metadata.artifactId, metadata.platform),
await fs
.realpath(path.resolve(download.downloadPath, download.folderName))
.catch(() =>
path.resolve(download.downloadPath, download.folderName!)
)
)
: new Map<string, string>();
const selectedDlcs = Array.isArray(metadata.selection?.dlcs)
? metadata.selection.dlcs
: [];
const neededDlcs = new Set([...selectedDlcs, ...installedDlcs.keys()]);
const requiredDlcs = new Set(
selectedDlcs.filter((artifactId) => !installedDlcs.has(artifactId))
);
const prepared = await withEpicTiming("download.runtime-prepare", () =>
this.options.installation.prepare(
owned,
metadata.artifactId,
metadata.platform,
controller.signal
controller.signal,
{ needed: neededDlcs, required: requiredDlcs }
)
);
await this.options.installation.persistPreparation(
@ -503,9 +742,21 @@ export class EpicDownloadService {
if (folder !== download.folderName)
throw new EpicManagedError("invalid-path");
const destination = path.resolve(base, folder);
await this.validateSpace(destination, base, prepared.diskSizeInBytes);
const installed = await this.options.installation.readInstalledForRepair(
prepared.target,
download.objectId,
destination
);
const selection = resumeEpicSelection(prepared, download);
const resolved =
selection.selection.gameAlreadyInstalled &&
installed?.version === prepared.version
? withoutInstalledEpicGame(selection)
: selection;
await this.validateSpace(destination, base, resolved.diskSizeInBytes);
if (controller.signal.aborted || !isCurrent())
throw new EpicManagedError("operation-cancelled");
const resume = epicRuntimeResume(download, prepared, resolved);
const active: ActiveEpicDownload = {
prepared,
id: this.id(download.objectId),
@ -513,19 +764,24 @@ export class EpicDownloadService {
target: prepared.target,
download: {
...download,
fileSize: prepared.downloadSizeInBytes,
fileSize: resume.fileSize,
bytesDownloaded: resume.previousBytes,
progress: resume.previousProgress,
epic: {
...metadata,
diskSizeInBytes: prepared.diskSizeInBytes,
diskSizeInBytes: resolved.diskSizeInBytes,
phase: "preparing",
error: undefined,
selection: resolved.selection,
...(metadata.update
? { update: { ...metadata.update, toVersion: prepared.version } }
: {}),
},
},
parser: new EpicDownloadProgressParser(
prepared.downloadSizeInBytes,
download.bytesDownloaded,
download.progress
),
currentRunParser: resume.parser,
finishedRunsBytes: 0,
plannedTotalBytes: metadata.update ? null : resume.fileSize,
selection: resolved,
done: Promise.resolve(),
completed: false,
version: prepared.version,
@ -556,6 +812,22 @@ export class EpicDownloadService {
if (this.active !== active || !active.isCurrent())
throw new EpicManagedError("operation-cancelled");
};
const onLine = (line: string) => {
if (
this.active !== active ||
controller.signal.aborted ||
!this.options.runtime.current(target)
)
return;
active.currentRunParser.line(line);
if (!transferStarted && !active.currentRunParser.state.preparing) {
transferStarted = true;
markEpicTiming("download.transfer-ready", { first: true });
}
active.download.epic!.phase = active.currentRunParser.state.preparing
? "preparing"
: "downloading";
};
try {
const existing = await this.options.installation.readInstalledForRepair(
target,
@ -564,46 +836,30 @@ export class EpicDownloadService {
);
assertActive();
if (!existing || existing.version !== active.version) {
const pendingUpdate = active.download.epic?.update;
if (pendingUpdate && !pendingUpdate.filesChanged) {
pendingUpdate.filesChanged = true;
await this.options.putDownload(active.id, active.download);
assertActive();
}
await this.options.runtime.streamInstall(
target,
owned.game.objectId,
active.prepared,
[
"-y",
"install",
target.artifactId,
"--platform",
target.platform,
"--base-path",
active.download.downloadPath,
"--game-folder",
active.download.folderName!,
"--skip-dlcs",
"--skip-sdl",
],
{
signal: controller.signal,
onLine: (line) => {
if (
this.active !== active ||
controller.signal.aborted ||
!this.options.runtime.current(target)
)
return;
active.parser.line(line);
if (!transferStarted && !active.parser.state.preparing) {
transferStarted = true;
markEpicTiming("download.transfer-ready", { first: true });
}
active.download.epic!.phase = active.parser.state.preparing
? "preparing"
: "downloading";
},
}
epicInstallArgs({
artifactId: target.artifactId,
platform: target.platform,
basePath: active.download.downloadPath,
folderName: active.download.folderName!,
installTags: existing
? epicInstallTags(existing.raw.install_tags)
: active.selection.selection.installTags,
}),
{ signal: controller.signal, onLine }
);
}
assertActive();
if (active.parser.state.failed)
if (active.currentRunParser.state.failed)
throw new EpicManagedError("process-failed");
active.download.epic!.phase = "finalizing";
const installed = await this.options.installation.readInstalled(
@ -623,6 +879,8 @@ export class EpicDownloadService {
controller.signal
);
assertActive();
await this.installDlcs(active, owned, installed, assertActive, onLine);
assertActive();
const current = await this.options.getDownload(active.id);
assertActive();
if (
@ -635,15 +893,23 @@ export class EpicDownloadService {
!epicOwnerMatches(current.epic.owner, target.owner)
)
throw new EpicManagedError("operation-cancelled");
active.parser.state.progress = 1;
active.parser.state.bytesDownloaded = active.parser.state.fileSize;
const total = epicDownloadTotal(active);
active.finishedRunsBytes = total;
active.plannedTotalBytes = total;
active.currentRunParser = new EpicDownloadProgressParser(0);
active.currentRunParser.state.progress = 1;
active.download = {
...current,
progress: 1,
bytesDownloaded: active.parser.state.fileSize,
fileSize: active.parser.state.fileSize,
bytesDownloaded: total,
fileSize: total,
status: "complete",
epic: { ...current.epic, phase: "finalizing", error: undefined },
epic: {
...current.epic,
phase: "finalizing",
error: undefined,
update: undefined,
},
};
await this.options.putDownload(active.id, active.download);
assertActive();
@ -655,6 +921,70 @@ export class EpicDownloadService {
throw error;
}
}
private async installDlcs(
active: ActiveEpicDownload,
owned: EpicOwnedGame,
installed: EpicInstalledGame,
assertActive: () => void,
onLine: (line: string) => void
) {
const { target, controller } = active;
const read = () =>
this.options.installation.readInstalledDlcs(
target,
installed.installPath
);
const current = await read();
assertActive();
const pending = (active.prepared.dlcs ?? []).filter(
(dlc) =>
(active.selection.selection.dlcs.includes(dlc.artifactId) ||
current.has(dlc.artifactId)) &&
current.get(dlc.artifactId) !== dlc.version
);
const update = !!active.download.epic?.update;
for (const dlc of pending) {
const previous = active.currentRunParser.state;
active.finishedRunsBytes +=
active.plannedTotalBytes === null
? previous.bytesDownloaded
: previous.fileSize;
const size =
update || current.has(dlc.artifactId) ? 0 : dlc.downloadSizeInBytes;
const resumed = Math.max(
0,
active.download.bytesDownloaded - active.finishedRunsBytes
);
active.currentRunParser = new EpicDownloadProgressParser(
size,
Math.min(resumed, size),
size > 0 ? Math.min(resumed, size) / size : 0
);
active.download.epic!.phase = "preparing";
await this.options.runtime.streamInstall(
target,
owned.game.objectId,
dlc,
epicInstallArgs({
artifactId: dlc.artifactId,
platform: target.platform,
basePath: active.download.downloadPath,
folderName: active.download.folderName!,
}),
{
signal: controller.signal,
onLine,
dlc: { target: dlc.target, objectId: dlc.objectId },
}
);
assertActive();
if (active.currentRunParser.state.failed)
throw new EpicManagedError("process-failed");
if ((await read()).get(dlc.artifactId) !== dlc.version)
throw new EpicManagedError("installation-incomplete");
assertActive();
}
}
private async prerequisites(
active: ActiveEpicDownload,
installed: EpicInstalledGame
@ -712,19 +1042,30 @@ export class EpicDownloadService {
!this.options.runtime.current(active.target)
)
return null;
const state = active.parser.state;
const state = active.currentRunParser.state;
const total = epicDownloadTotal(active);
const progress =
total > 0
? Math.min(
1,
(active.finishedRunsBytes + state.progress * state.fileSize) / total
)
: state.progress;
const current: Download = {
...download,
status: active.completed ? "complete" : "active",
bytesDownloaded: state.bytesDownloaded,
fileSize: state.fileSize,
progress: state.progress,
bytesDownloaded: Math.min(
total,
active.finishedRunsBytes + state.bytesDownloaded
),
fileSize: total,
progress,
epic: active.download.epic,
};
return {
gameId: active.id,
download: current,
progress: state.progress,
progress,
downloadSpeed: state.downloadSpeed,
timeRemaining: state.timeRemaining,
numPeers: 0,
@ -790,7 +1131,7 @@ export class EpicDownloadService {
throw new EpicManagedError("invalid-response");
await removeFiles?.();
if (isRecord(value)) {
delete value[download.epic!.artifactId];
forgetEpicInstalledEntries(value, download.epic!.artifactId);
await writeManagedJson(file, value);
}
await afterForget?.();
@ -802,6 +1143,96 @@ export class EpicDownloadService {
}
}
export function resumeEpicSelection(
prepared: EpicPreparedInstallation,
download: Download
): ResolvedEpicSelection {
const saved = download.epic?.selection;
if (!saved) return resolveEpicSelection(prepared, {});
if (
!Array.isArray(saved.installTags) ||
saved.installTags.length > EPIC_MAX_INSTALL_TAGS ||
!saved.installTags.every(isEpicInstallTag) ||
!Array.isArray(saved.dlcs) ||
!saved.dlcs.every(isSafeEpicArtifact) ||
(saved.components !== undefined &&
(!Array.isArray(saved.components) ||
!saved.components.every(isEpicSelectionId)))
)
throw new EpicManagedError("invalid-selection");
const dlcs = saved.dlcs.filter((artifactId) =>
prepared.dlcs?.some((dlc) => dlc.artifactId === artifactId)
);
let fresh: ResolvedEpicSelection | null = null;
try {
fresh = resolveEpicSelection(prepared, {
components: saved.components,
dlcs,
});
} catch (error) {
if (
!(error instanceof EpicManagedError) ||
error.code !== "invalid-selection"
)
throw error;
}
const preparedDlcs = (prepared.dlcs ?? []).filter((dlc) =>
dlcs.includes(dlc.artifactId)
);
const dlcDownload = preparedDlcs.reduce(
(total, dlc) => total + dlc.downloadSizeInBytes,
0
);
const savedDownload = download.fileSize ?? prepared.downloadSizeInBytes;
return {
selection: { ...saved, installTags: [...saved.installTags], dlcs },
dlcs: preparedDlcs,
gameDownloadSizeInBytes:
fresh?.gameDownloadSizeInBytes ??
Math.max(0, savedDownload - dlcDownload),
downloadSizeInBytes: fresh?.downloadSizeInBytes ?? savedDownload,
diskSizeInBytes:
fresh?.diskSizeInBytes ??
download.epic?.diskSizeInBytes ??
prepared.diskSizeInBytes,
};
}
function epicRuntimeResume(
download: Download,
prepared: EpicPreparedInstallation,
resolved: ResolvedEpicSelection
) {
const update = download.epic?.update;
const sameUpdate = update?.toVersion === prepared.version;
const restarted = !!update && !sameUpdate;
let fileSize = resolved.downloadSizeInBytes;
if (update) fileSize = sameUpdate ? (download.fileSize ?? 0) : 0;
const previousBytes = restarted ? 0 : download.bytesDownloaded;
const previousProgress = restarted ? 0 : download.progress;
const segmentSize = update ? fileSize : resolved.gameDownloadSizeInBytes;
const segmentProgress =
segmentSize > 0
? (previousProgress * Math.max(fileSize, segmentSize)) / segmentSize
: previousProgress;
return {
fileSize,
previousBytes,
previousProgress,
parser: new EpicDownloadProgressParser(
segmentSize,
previousBytes,
segmentProgress
),
};
}
const epicDownloadTotal = (active: ActiveEpicDownload) =>
Math.max(
active.plannedTotalBytes ?? 0,
active.finishedRunsBytes + active.currentRunParser.state.fileSize
);
let downloadService: EpicDownloadService | null = null;
export const setEpicDownloadService = (service: EpicDownloadService) => {
downloadService = service;

View file

@ -47,6 +47,7 @@ import type {
EpicCloudSaveSettings,
ResolveEpicCloudSaveConflictPayload,
StartEpicGameDownloadPayload,
StartEpicGameUpdatePayload,
} from "../../../types/epic-managed.types";
configureEpicTiming((entry) => logger.info("[epic-timing]", entry));
@ -568,6 +569,10 @@ export const getEpicGameInstallOptions = async (
return { options: [], error: "operation-cancelled" as const };
return managedDownloads!.getOptions(objectId, signal);
});
export const invalidateEpicGameInstallOptions = async (objectId: string) => {
await awaitManagedEpicReady();
managedDownloads!.invalidateOptions(objectId);
};
export const openManagedEpicWinetricks = async (
objectId: string
): Promise<boolean> => {
@ -622,6 +627,13 @@ export const startEpicGameDownload = async (
await withEpicTiming("integration.ready", () => awaitManagedEpicReady());
return managedDownloads!.start(payload);
});
export const startEpicGameUpdate = async (
payload: StartEpicGameUpdatePayload
) =>
withEpicTiming("request.start-update", async () => {
await withEpicTiming("integration.ready", () => awaitManagedEpicReady());
return managedDownloads!.startUpdate(payload);
});
export const getEpicCloudSaveState = async (objectId: string) => {
await awaitManagedEpicReady();
return managedSaves!.getState(objectId);

View file

@ -313,6 +313,8 @@ test("installation options prove exact alias identity and separate download/disk
diskSizeInBytes: 1000,
supportsCloudSaves: true,
installed: false,
publishedVersion: "build-1",
installedVersion: null,
},
],
});

View file

@ -9,6 +9,7 @@ import type {
EpicLocalOwner,
StartEpicGameDownloadPayload,
} from "../../../types/epic-managed.types";
import type { EpicSnapshotDlc } from "../../../types/epic-integration.types";
import type { EpicSyncContext } from "./sync-orchestrator";
import { isRecord } from "./auth-protocol.js";
import { isEpicObjectId } from "./library-normalizer.js";
@ -21,6 +22,8 @@ import {
EPIC_INSTALL_OPTIONS_CACHE_MS,
EPIC_INSTALL_OPTIONS_CACHE_MAX_ENTRIES,
EPIC_INSTALL_OPTIONS_TIMEOUT_MS,
EPIC_DLC_INSPECTION_BUDGET_MS,
EPIC_DLC_INSPECTION_RESERVE_MS,
EPIC_IMPORT_VERIFY_TIMEOUT_MS,
} from "./constants.js";
import {
@ -35,6 +38,34 @@ export interface EpicOwnedGame extends EpicSyncContext {
game: Game;
owner: EpicLocalOwner;
artifactIds: string[];
dlcs: EpicSnapshotDlc[];
}
export interface EpicSelectivePack {
id: string;
name: string;
tags: string[];
downloadSizeInBytes: number;
diskSizeInBytes: number;
}
type EpicDlcScope =
| { needed: ReadonlySet<string>; required: ReadonlySet<string> }
| { deadline: number };
export interface EpicSelectiveInstall {
requiredTags: string[];
downloadSizeInBytes: number;
diskSizeInBytes: number;
packs: EpicSelectivePack[];
}
export interface EpicPreparedDlc {
target: EpicManagedTarget;
objectId: string;
artifactId: string;
name: string;
version: string;
downloadSizeInBytes: number;
diskSizeInBytes: number;
metadata: Record<string, unknown>;
manifest?: unknown;
}
export interface EpicPreparedInstallation {
target: EpicManagedTarget;
@ -45,6 +76,8 @@ export interface EpicPreparedInstallation {
metadata: Record<string, unknown>;
manifest?: unknown;
preparationId: string;
selective?: EpicSelectiveInstall;
dlcs?: EpicPreparedDlc[];
}
interface EpicPreparationReceipt {
owned: EpicOwnedGame;
@ -77,7 +110,11 @@ interface InstallationOptions {
objectId: string,
signal: AbortSignal
) => Promise<{
epicImport: { connectionId: string; artifactIds: string[] } | null;
epicImport: {
connectionId: string;
artifactIds: string[];
dlcs?: EpicSnapshotDlc[];
} | null;
} | null>;
getGame: (objectId: string) => Promise<Game | undefined>;
updateGame: (
@ -309,6 +346,150 @@ export function parseEpicInstallInfo(
};
}
const EPIC_MAX_SELECTIVE_PACKS = 64;
const EPIC_MAX_PACK_TAGS = 64;
const EPIC_PACK_NAME_MAX_LENGTH = 128;
const EPIC_INSTALL_TAG_MAX_LENGTH = 128;
const EPIC_SELECTION_ID_MAX_LENGTH = 64;
const EPIC_INSTALL_TAG_PATTERN = new RegExp(
`^[A-Za-z0-9_.-]{0,${EPIC_INSTALL_TAG_MAX_LENGTH}}$`
);
const EPIC_SELECTION_ID_PATTERN = new RegExp(
`^[A-Za-z0-9_.-]{1,${EPIC_SELECTION_ID_MAX_LENGTH}}$`
);
export const EPIC_MAX_INSTALL_TAGS = 256;
export const isEpicInstallTag = (value: unknown): value is string =>
typeof value === "string" && EPIC_INSTALL_TAG_PATTERN.test(value);
export const isEpicSelectionId = (value: unknown): value is string =>
typeof value === "string" && EPIC_SELECTION_ID_PATTERN.test(value);
const isByteCount = (value: unknown): value is number =>
Number.isSafeInteger(value) && Number(value) >= 0;
export function parseEpicSelectiveInstall(
value: unknown
): EpicSelectiveInstall | undefined {
if (
!isRecord(value) ||
!Array.isArray(value.required_tags) ||
value.required_tags.length > EPIC_MAX_PACK_TAGS ||
!value.required_tags.every(isEpicInstallTag) ||
!isByteCount(value.download_size) ||
!isByteCount(value.disk_size) ||
!Array.isArray(value.packs) ||
!value.packs.length ||
value.packs.length > EPIC_MAX_SELECTIVE_PACKS
)
return undefined;
const packs: EpicSelectivePack[] = [];
for (const pack of value.packs) {
if (
!isRecord(pack) ||
!isEpicSelectionId(pack.id) ||
pack.id === "__required" ||
packs.some((existing) => existing.id === pack.id) ||
typeof pack.name !== "string" ||
!pack.name.trim() ||
pack.name.length > EPIC_PACK_NAME_MAX_LENGTH ||
!Array.isArray(pack.tags) ||
!pack.tags.length ||
pack.tags.length > EPIC_MAX_PACK_TAGS ||
!pack.tags.every(isEpicInstallTag) ||
!isByteCount(pack.download_size) ||
!isByteCount(pack.disk_size)
)
return undefined;
packs.push({
id: pack.id,
name: pack.name.trim(),
tags: [...pack.tags],
downloadSizeInBytes: pack.download_size,
diskSizeInBytes: pack.disk_size,
});
}
return {
requiredTags: [...value.required_tags],
downloadSizeInBytes: value.download_size,
diskSizeInBytes: value.disk_size,
packs,
};
}
export function validateEpicDlcMetadata(
value: unknown,
target: EpicManagedTarget,
dlc: EpicSnapshotDlc,
base: { artifactId: string; objectId: string }
): Record<string, unknown> {
const [baseNamespace, baseCatalogItemId] = base.objectId.split(":");
if (
!isRecord(value) ||
value.app_name !== target.artifactId ||
target.artifactId !== dlc.artifactId ||
!isRecord(value.metadata)
)
throw new EpicManagedError("invalid-artifact");
const metadata = value.metadata;
const main = metadata.mainGameItem;
const release =
isRecord(main) && Array.isArray(main.releaseInfo)
? main.releaseInfo[0]
: undefined;
if (
metadata.namespace !== dlc.namespace ||
metadata.id !== dlc.catalogItemId ||
!isRecord(main) ||
main.id !== baseCatalogItemId ||
(main.namespace !== undefined && main.namespace !== baseNamespace) ||
!isRecord(release) ||
release.appId !== base.artifactId ||
!Array.isArray(metadata.categories) ||
!metadata.categories.some(
(category) => isRecord(category) && category.path === "addons"
) ||
metadata.categories.some(
(category) => isRecord(category) && category.path === "addons/launchable"
)
)
throw new EpicManagedError("not-installable");
const asset = isRecord(value.asset_infos)
? value.asset_infos[target.platform]
: undefined;
if (
!isRecord(asset) ||
asset.app_name !== target.artifactId ||
asset.namespace !== dlc.namespace ||
asset.catalog_item_id !== dlc.catalogItemId
)
throw new EpicManagedError("invalid-artifact");
return value;
}
export function parseEpicDlcInstallInfo(
value: unknown,
artifactId: string
): { downloadSizeInBytes: number; diskSizeInBytes: number; version: string } {
const game = isRecord(value) ? value.game : undefined;
const manifest = isRecord(value) ? value.manifest : undefined;
if (
!isRecord(game) ||
game.app_name !== artifactId ||
game.is_dlc !== true ||
typeof game.version !== "string" ||
!game.version ||
!isRecord(manifest) ||
manifest.is_preloaded !== false ||
!isByteCount(manifest.download_size) ||
!isByteCount(manifest.disk_size) ||
Number(manifest.disk_size) === 0
)
throw new EpicManagedError("not-installable");
return {
downloadSizeInBytes: manifest.download_size,
diskSizeInBytes: manifest.disk_size,
version: game.version,
};
}
export class EpicInstallationService {
readonly runtime: LegendaryManagedRuntime;
private readonly optionsCache = new Map<
@ -421,6 +602,7 @@ export class EpicInstallationService {
game,
owner,
artifactIds: [game.epicInstallation.artifactId],
dlcs: [],
};
const target = {
...epicManagedTarget(
@ -476,6 +658,7 @@ export class EpicInstallationService {
artifactIds: [...new Set(owned.epicImport.artifactIds)].sort(
(left, right) => left.localeCompare(right, "en")
),
dlcs: owned.epicImport.dlcs ?? [],
owner: {
environment: context.auth.environment,
userId: context.auth.userId,
@ -543,7 +726,10 @@ export class EpicInstallationService {
owned: EpicOwnedGame,
artifactId: string,
platform: EpicInstallPlatform,
signal: AbortSignal
signal: AbortSignal,
dlcScope: EpicDlcScope = {
deadline: Date.now() + EPIC_DLC_INSPECTION_BUDGET_MS,
}
): Promise<EpicPreparedInstallation> {
const target = epicManagedTarget(owned, artifactId, platform);
this.assertAlias(owned, target);
@ -561,6 +747,9 @@ export class EpicInstallationService {
{ command: "info" }
);
this.runtime.assertCurrent(target, signal);
const selective = isRecord(inspection.info)
? parseEpicSelectiveInstall(inspection.info.sdl)
: undefined;
const prepared: EpicPreparedInstallation = {
target,
...parseEpicInstallInfo(inspection.info, artifactId),
@ -571,14 +760,34 @@ export class EpicInstallationService {
),
manifest: inspection.manifest,
preparationId: randomUUID(),
...(selective ? { selective } : {}),
};
const dlcs = await this.prepareDlcs(
owned,
target,
signal,
cached?.dlcs,
dlcScope
);
if (dlcs.length) prepared.dlcs = dlcs;
if ("needed" in dlcScope) {
this.folderNameFromMetadata(prepared.metadata, target);
return prepared;
}
if (
cached &&
cached.version === prepared.version &&
cached.downloadSizeInBytes === prepared.downloadSizeInBytes &&
cached.diskSizeInBytes === prepared.diskSizeInBytes &&
cached.supportsCloudSaves === prepared.supportsCloudSaves &&
JSON.stringify(cached.metadata) === JSON.stringify(prepared.metadata)
JSON.stringify(cached.metadata) === JSON.stringify(prepared.metadata) &&
JSON.stringify(cached.selective) === JSON.stringify(prepared.selective) &&
JSON.stringify(
cached.dlcs?.map(({ artifactId, version }) => [artifactId, version])
) ===
JSON.stringify(
prepared.dlcs?.map(({ artifactId, version }) => [artifactId, version])
)
)
prepared.preparationId = cached.preparationId;
else if (cached)
@ -595,6 +804,112 @@ export class EpicInstallationService {
this.preparations.delete(this.preparations.keys().next().value!);
return prepared;
}
private async prepareDlcs(
owned: EpicOwnedGame,
base: EpicManagedTarget,
signal: AbortSignal,
cached: EpicPreparedDlc[] | undefined,
dlcScope: EpicDlcScope
): Promise<EpicPreparedDlc[]> {
if (signal.aborted) throw abortError(signal);
if ("needed" in dlcScope) {
const owns = new Set(owned.dlcs.map((dlc) => dlc.artifactId));
if ([...dlcScope.required].some((artifactId) => !owns.has(artifactId)))
throw new EpicManagedError("not-installable");
const prepared: EpicPreparedDlc[] = [];
for (const dlc of owned.dlcs) {
if (!dlcScope.needed.has(dlc.artifactId)) continue;
const item = await this.prepareDlc(
owned,
base,
dlc,
signal,
cached,
dlcScope.required.has(dlc.artifactId)
);
if (item) prepared.push(item);
}
return prepared;
}
const budget = new AbortController();
const stop = () => budget.abort(signal.reason);
signal.addEventListener("abort", stop, { once: true });
const timer = setTimeout(
() => budget.abort(new EpicManagedError("timeout")),
Math.max(0, dlcScope.deadline - Date.now())
);
const prepared: EpicPreparedDlc[] = [];
try {
for (const dlc of owned.dlcs) {
let item: EpicPreparedDlc | null;
try {
item = await this.prepareDlc(owned, base, dlc, budget.signal, cached);
} catch (error) {
if (!signal.aborted && budget.signal.aborted) break;
throw error;
}
if (item) prepared.push(item);
}
} finally {
clearTimeout(timer);
signal.removeEventListener("abort", stop);
}
return prepared;
}
private async prepareDlc(
owned: EpicOwnedGame,
base: EpicManagedTarget,
dlc: EpicSnapshotDlc,
signal: AbortSignal,
cached?: EpicPreparedDlc[],
required = false
): Promise<EpicPreparedDlc | null> {
if (signal.aborted) throw abortError(signal);
const target = epicManagedTarget(owned, dlc.artifactId, base.platform);
const objectId = `${dlc.namespace}:${dlc.catalogItemId}`;
if (
!isSafeEpicArtifact(dlc.artifactId) ||
!isEpicObjectId(objectId) ||
owned.artifactIds.includes(dlc.artifactId)
)
return null;
try {
const inspection = await withEpicTiming(
"installation.dlc-info",
() =>
this.runtime.inspect(
target,
objectId,
signal,
cached?.find((item) => item.artifactId === dlc.artifactId),
{ target: base, objectId: owned.game.objectId }
),
{ command: "info" }
);
this.runtime.assertCurrent(target, signal);
return {
target,
objectId,
artifactId: dlc.artifactId,
name: dlc.name,
...parseEpicDlcInstallInfo(inspection.info, dlc.artifactId),
metadata: validateEpicDlcMetadata(inspection.metadata, target, dlc, {
artifactId: base.artifactId,
objectId: owned.game.objectId,
}),
manifest: inspection.manifest,
};
} catch (error) {
if (
required ||
signal.aborted ||
(error instanceof EpicManagedError &&
["operation-cancelled", "stale-connection"].includes(error.code))
)
throw error;
return null;
}
}
async resolvePreparation(
payload: StartEpicGameDownloadPayload,
signal: AbortSignal
@ -683,6 +998,12 @@ export class EpicInstallationService {
"Windows",
]);
}
invalidateOptions(objectId: string) {
const context = this.options.captureLocal?.();
if (!context) return;
const key = this.optionsKey(context, objectId);
if (this.optionsCache.get(key)?.settled) this.optionsCache.delete(key);
}
async getOptions(
objectId: string,
signal?: AbortSignal
@ -691,13 +1012,14 @@ export class EpicInstallationService {
const abort = () => controller.abort(signal?.reason);
signal?.addEventListener("abort", abort, { once: true });
if (signal?.aborted) abort();
const deadline = Date.now() + EPIC_INSTALL_OPTIONS_TIMEOUT_MS;
const timer = setTimeout(
() => controller.abort(new EpicManagedError("timeout")),
EPIC_INSTALL_OPTIONS_TIMEOUT_MS
);
try {
return await waitWithSignal(
this.getOptionsWithSignal(objectId, controller.signal),
this.getOptionsWithSignal(objectId, controller.signal, deadline),
controller.signal
);
} catch (error) {
@ -709,7 +1031,8 @@ export class EpicInstallationService {
}
private async getOptionsWithSignal(
objectId: string,
signal: AbortSignal
signal: AbortSignal,
deadline: number
): Promise<EpicGameInstallOptions> {
if (signal.aborted) throw abortError(signal);
this.assertPlatform("Windows");
@ -753,7 +1076,8 @@ export class EpicInstallationService {
key,
context,
EPIC_INSTALL_OPTIONS_CACHE_MS,
(requestSignal) => this.loadOptions(objectId, requestSignal, context),
(requestSignal) =>
this.loadOptions(objectId, requestSignal, deadline, context),
(result) => !result.error && result.options.length > 0
);
}
@ -774,22 +1098,42 @@ export class EpicInstallationService {
epicAccountId: context.connection.epicAccountId,
};
const options = await Promise.all(
result.options.map(async (option) => ({
...option,
...(reused
? {
installed: !!(await this.readInstalledForRepair(
{
...context,
owner,
artifactId: option.artifactId,
platform: option.platform,
},
objectId
)),
}
: {}),
}))
result.options.map(async (option) => {
if (!reused) return { ...option };
const installed = await this.readInstalledForRepair(
{
...context,
owner,
artifactId: option.artifactId,
platform: option.platform,
},
objectId
);
const installedDlcs = installed
? await this.readInstalledDlcs(
{
...context,
owner,
artifactId: option.artifactId,
platform: option.platform,
},
installed.installPath
)
: new Map<string, string>();
return {
...option,
installed: !!installed,
installedVersion: installed?.version ?? null,
...(option.dlcs
? {
dlcs: option.dlcs.map((dlc) => ({
...dlc,
installed: installedDlcs.has(dlc.artifactId),
})),
}
: {}),
};
})
);
if (!this.options.current(context) || signal.aborted)
throw new EpicManagedError("operation-cancelled");
@ -798,6 +1142,7 @@ export class EpicInstallationService {
private async loadOptions(
objectId: string,
signal: AbortSignal,
deadline: number,
expectedContext?: EpicSyncContext
): Promise<EpicGameInstallOptions> {
try {
@ -815,10 +1160,13 @@ export class EpicInstallationService {
count: owned.artifactIds.length,
});
let invalid: unknown;
const dlcDeadline = deadline - EPIC_DLC_INSPECTION_RESERVE_MS;
for (const artifactId of owned.artifactIds) {
try {
const prepared = await withEpicTiming("installation.prepare", () =>
this.prepare(owned, artifactId, "Windows", signal)
this.prepare(owned, artifactId, "Windows", signal, {
deadline: dlcDeadline,
})
);
const installed = await this.readInstalledForRepair(
prepared.target,
@ -832,6 +1180,17 @@ export class EpicInstallationService {
diskSizeInBytes: prepared.diskSizeInBytes,
supportsCloudSaves: prepared.supportsCloudSaves,
installed: !!installed,
publishedVersion: prepared.version,
installedVersion: installed?.version ?? null,
...epicSelectionOptions(
prepared,
installed
? await this.readInstalledDlcs(
prepared.target,
installed.installPath
)
: new Map()
),
});
} catch (error) {
if (
@ -974,6 +1333,44 @@ export class EpicInstallationService {
executablePath,
} as EpicInstalledGame;
}
async readInstalledDlcs(
target: EpicManagedTarget,
installPath: string
): Promise<Map<string, string>> {
const value = await readManagedJson(
path.join(this.runtime.workspace(target), "installed.json")
).catch(() => null);
if (!isRecord(value)) return new Map();
const candidates = Object.entries(value).flatMap(([artifactId, entry]) =>
artifactId !== target.artifactId &&
isSafeEpicArtifact(artifactId) &&
isRecord(entry) &&
entry.is_dlc === true &&
entry.app_name === artifactId &&
typeof entry.version === "string" &&
entry.version &&
entry.needs_verification !== true &&
typeof entry.install_path === "string"
? [
{
artifactId,
version: entry.version,
installPath: entry.install_path,
},
]
: []
);
const paths = await Promise.all(
candidates.map((candidate) =>
fs.realpath(candidate.installPath).catch(() => null)
)
);
return new Map(
candidates
.filter((_, index) => paths[index] === installPath)
.map(({ artifactId, version }) => [artifactId, version])
);
}
async confirmPrerequisites(
target: EpicManagedTarget,
installed: EpicInstalledGame,
@ -1191,8 +1588,60 @@ export class EpicInstallationService {
throw error;
}
if (!isRecord(value)) throw new EpicManagedError("invalid-response");
delete value[target.artifactId];
forgetEpicInstalledEntries(value, target.artifactId);
await writeManagedJson(file, value);
});
}
}
export function forgetEpicInstalledEntries(
installed: Record<string, unknown>,
artifactId: string
) {
const game = installed[artifactId];
const installPath =
isRecord(game) && typeof game.install_path === "string"
? path.resolve(game.install_path)
: null;
delete installed[artifactId];
if (!installPath) return;
for (const [key, entry] of Object.entries(installed))
if (
isRecord(entry) &&
entry.is_dlc === true &&
typeof entry.install_path === "string" &&
path.resolve(entry.install_path) === installPath
)
delete installed[key];
}
const epicSelectionOptions = (
prepared: EpicPreparedInstallation,
installedDlcs: Map<string, string>
): Partial<EpicGameInstallOption> => ({
...(prepared.selective
? {
components: prepared.selective.packs.map(
({ id, name, downloadSizeInBytes, diskSizeInBytes }) => ({
id,
name,
downloadSizeInBytes,
diskSizeInBytes,
})
),
requiredDownloadSizeInBytes: prepared.selective.downloadSizeInBytes,
requiredDiskSizeInBytes: prepared.selective.diskSizeInBytes,
}
: {}),
...(prepared.dlcs?.length
? {
dlcs: prepared.dlcs.map((dlc) => ({
artifactId: dlc.artifactId,
name: dlc.name,
downloadSizeInBytes: dlc.downloadSizeInBytes,
diskSizeInBytes: dlc.diskSizeInBytes,
installed: installedDlcs.has(dlc.artifactId),
})),
}
: {}),
});

View file

@ -1,4 +1,7 @@
import { parseEpicSyncRun } from "./sync-protocol.js";
import {
epicConnectionCapabilities,
parseEpicSyncRun,
} from "./sync-protocol.js";
import { randomUUID } from "node:crypto";
import type {
EpicAccountData,
@ -106,10 +109,9 @@ function validateEpicConnection(response: unknown): EpicConnection {
epicAccountId: response.epicAccountId,
displayName: response.displayName,
connectedAt: response.connectedAt,
...(isRecord(response.capabilities) &&
response.capabilities.achievementsSnapshotVersion === 1
? { capabilities: { achievementsSnapshotVersion: 1 as const } }
: {}),
...epicConnectionCapabilities(
isRecord(response.capabilities) ? response.capabilities : undefined
),
lastSyncedAt:
response.lastSyncedAt === undefined || response.lastSyncedAt === null
? null

View file

@ -28,6 +28,7 @@ async function fixture(t: TestContext, options: { linux?: boolean } = {}) {
const state = {
current: true,
authCalls: 0,
authArgs: [] as string[][],
offlineCalls: 0,
localCaptures: 0,
closes: 0,
@ -270,8 +271,9 @@ async function fixture(t: TestContext, options: { linux?: boolean } = {}) {
assertCurrent: () => {
if (!state.current) throw new EpicSaveFailure("operation-cancelled");
},
runJson: async () => {
runJson: async (_target: unknown, args: string[]) => {
state.authCalls++;
state.authArgs.push(args);
state.order.push("auth");
return session.installed.requires_ot
? { ...result(), egl_parameters: ["-epicovt=/private/token.ovt"] }
@ -477,6 +479,21 @@ test("an executable match without managed PID ancestry cannot report playtime",
assert.equal(f.state.reportsFinished, 1);
});
const plainArgs = (args: readonly string[]) => [...args];
test("launching an outdated build skips Legendary's version check only when asked", async (t) => {
const f = await fixture(t);
await f.exported.prepareManagedEpicLaunch(f.game, f.executable);
await f.exported.clearManagedEpicSessions();
await f.exported.prepareManagedEpicLaunch(f.game, f.executable, {
epicSkipUpdateCheck: true,
});
assert.deepEqual(f.state.authArgs.map(plainArgs), [
["launch", "Artifact", "--json", "--no-wine"],
["launch", "Artifact", "--json", "--no-wine", "--skip-version-check"],
]);
});
test("conflict prevents auth launch; offline uses only confirmed local capture and the offline command", async (t) => {
const f = await fixture(t);
f.state.conflict = true;

View file

@ -125,6 +125,7 @@ export function prepareManagedEpicLaunch(
winePrefixPath?: string | null;
epicSavePolicy?: "local-once";
epicOffline?: boolean;
epicSkipUpdateCheck?: boolean;
onStatus?: UmuStatusListener;
/** Called with the prepared prefix right before Epic's own save sync. */
beforeSaveSync?: (winePrefixPath: string | undefined) => Promise<void>;
@ -220,7 +221,13 @@ async function prepareLaunch(
})
: await dependencies.runtime.runJson(
session.target,
["launch", session.target.artifactId, "--json", "--no-wine"],
[
"launch",
session.target.artifactId,
"--json",
"--no-wine",
...(options.epicSkipUpdateCheck ? ["--skip-version-check"] : []),
],
{ signal: controller.signal }
);
let parameters = parseEpicLaunchParameters(

Some files were not shown because too many files have changed in this diff Show more