Merge branch 'feat/screenshots-native-input' into feat/screenshots-capture
# Conflicts: # src/shared/index.ts
This commit is contained in:
commit
afe1a78499
195 changed files with 12533 additions and 1930 deletions
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "hydralauncher",
|
||||
"version": "4.1.6",
|
||||
"version": "4.3.0",
|
||||
"description": "Hydra",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "Los Broxas",
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export enum Feature {
|
|||
AllDebrid = "ALLDEBRID",
|
||||
Nimbus = "NIMBUS",
|
||||
NimbusPreview = "NIMBUS_PREVIEW",
|
||||
MultipartHosterDownloads = "MULTIPART_HOSTER_DOWNLOADS",
|
||||
}
|
||||
|
||||
const FEATURES_STORAGE_KEY = "features";
|
||||
|
|
|
|||
|
|
@ -744,7 +744,8 @@ export function useBigPictureDownloadsPageData() {
|
|||
|
||||
await globalThis.window.electron.deleteGameFolder(
|
||||
game.shop,
|
||||
game.objectId
|
||||
game.objectId,
|
||||
{ keepEpicUpdateFiles: true }
|
||||
);
|
||||
},
|
||||
[lastPacket?.gameId]
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" })}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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}}",
|
||||
|
|
|
|||
|
|
@ -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}}",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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}}",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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}}",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
23
src/main/events/library/get-game-review-playtime.ts
Normal file
23
src/main/events/library/get-game-review-playtime.ts
Normal 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);
|
||||
|
|
@ -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 () => [] }) },
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}),
|
||||
};
|
||||
};
|
||||
39
src/main/services/download/download-filename.test.ts
Normal file
39
src/main/services/download/download-filename.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
53
src/main/services/download/download-parts.test.ts
Normal file
53
src/main/services/download/download-parts.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
45
src/main/services/download/download-parts.ts
Normal file
45
src/main/services/download/download-parts.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
27
src/main/services/download/multipart-hoster-feature.ts
Normal file
27
src/main/services/download/multipart-hoster-feature.ts
Normal 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.");
|
||||
}
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue