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

# Conflicts:
#	native/hydra-native/Cargo.lock
#	native/hydra-native/src/lib.rs
#	src/main/services/native-addon.ts
This commit is contained in:
Moyasee 2026-10-29 20:57:00 +02:00
commit eb67f403c4
30 changed files with 1454 additions and 87 deletions

View file

@ -1,4 +1,7 @@
module.exports = {
// Worktrees live inside the main checkout; without this, ESLint also loads
// the outer config and finds two copies of every plugin.
root: true,
extends: [
"eslint:recommended",
"plugin:react/recommended",

View file

@ -8,6 +8,17 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aes"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32"
dependencies = [
"cipher",
"cpubits",
"cpufeatures 0.3.0",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@ -19,9 +30,9 @@ dependencies = [
[[package]]
name = "anyhow"
version = "1.0.102"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arrayref"
@ -212,7 +223,7 @@ dependencies = [
"arrayvec",
"cc",
"cfg-if",
"constant_time_eq",
"constant_time_eq 0.4.2",
"cpufeatures 0.3.0",
]
@ -225,6 +236,25 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
"zeroize",
]
[[package]]
name = "block-padding"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b"
dependencies = [
"hybrid-array",
]
[[package]]
name = "blocking"
version = "1.7.0"
@ -250,9 +280,9 @@ dependencies = [
[[package]]
name = "bumpalo"
version = "3.20.2"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
@ -272,6 +302,24 @@ version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "bzip2"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c"
dependencies = [
"libbz2-rs-sys",
]
[[package]]
name = "cbc"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896"
dependencies = [
"cipher",
]
[[package]]
name = "cc"
version = "1.4.0"
@ -279,6 +327,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@ -294,6 +344,22 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "cipher"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
dependencies = [
"crypto-common 0.2.2",
"inout",
]
[[package]]
name = "cmov"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "color_quant"
version = "1.1.0"
@ -319,12 +385,27 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "constant_time_eq"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
[[package]]
name = "constant_time_eq"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9eb6feb175df65d0a3ccd3e73484e940d0ac664e8034f0519fd49a93db3071d9"
dependencies = [
"rustversion",
]
[[package]]
name = "convert_case"
version = "0.11.0"
@ -360,6 +441,12 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpubits"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
[[package]]
name = "cpufeatures"
version = "0.2.17"
@ -380,9 +467,9 @@ dependencies = [
[[package]]
name = "crc32fast"
version = "1.5.0"
version = "1.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055"
dependencies = [
"cfg-if",
]
@ -422,6 +509,15 @@ dependencies = [
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "ctor"
version = "0.6.3"
@ -438,6 +534,21 @@ version = "0.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]]
name = "ctutils"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0"
dependencies = [
"cmov",
]
[[package]]
name = "deflate64"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2"
[[package]]
name = "deranged"
version = "0.5.8"
@ -453,8 +564,21 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"block-buffer 0.10.4",
"crypto-common 0.1.7",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"const-oid",
"crypto-common 0.2.2",
"ctutils",
"zeroize",
]
[[package]]
@ -550,7 +674,7 @@ dependencies = [
"bitvec",
"cfg-if",
"libc",
"nix",
"nix 0.29.0",
]
[[package]]
@ -612,6 +736,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
"zlib-rs",
]
[[package]]
@ -635,6 +760,16 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs4"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e72ed92b67c146290f88e9c89d60ca163ea417a446f61ffd7b72df3e7f1dfd5"
dependencies = [
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "funty"
version = "2.0.0"
@ -780,10 +915,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"wasip2",
"wasip3",
"wasm-bindgen",
]
[[package]]
@ -849,6 +986,31 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "hatchzip-core"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5a32a608e9965b27dcecfe903335d8efd0fe269f4dd79678ed658dd187a18b2"
dependencies = [
"aes",
"anyhow",
"cbc",
"crc32fast",
"fs4",
"libc",
"libz-rs-sys",
"pbkdf2",
"rayon",
"serde",
"serde_json",
"sevenz-rust2",
"sha2 0.11.1",
"tar",
"unrar-ng-sys",
"windows-sys 0.61.2",
"zip",
]
[[package]]
name = "heck"
version = "0.5.0"
@ -867,6 +1029,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
dependencies = [
"digest 0.11.3",
]
[[package]]
name = "http"
version = "1.5.0"
@ -906,6 +1077,15 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "hybrid-array"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
dependencies = [
"typenum",
]
[[package]]
name = "hydra-native"
version = "0.1.0"
@ -916,6 +1096,7 @@ dependencies = [
"filetime",
"globetter",
"globset",
"hatchzip-core",
"image",
"indexmap",
"known-folders",
@ -930,7 +1111,7 @@ dependencies = [
"serde",
"serde_json",
"serde_yaml_ng",
"sha2",
"sha2 0.10.9",
"sysinfo",
"tempfile",
"time",
@ -1153,6 +1334,16 @@ dependencies = [
"serde_core",
]
[[package]]
name = "inout"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f"
dependencies = [
"block-padding",
"hybrid-array",
]
[[package]]
name = "ipnet"
version = "2.12.0"
@ -1214,6 +1405,16 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "jobserver"
version = "0.1.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
dependencies = [
"getrandom 0.4.2",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.91"
@ -1240,10 +1441,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.183"
name = "libbz2-rs-sys"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c"
[[package]]
name = "libc"
version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
[[package]]
name = "libloading"
@ -1255,6 +1462,15 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "libz-rs-sys"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434324ee61a8e4a05dd3fdf03d51c073ea7fdaf5a35b2ca33c4ca9f3f37def4a"
dependencies = [
"zlib-rs",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@ -1269,9 +1485,18 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "log"
version = "0.4.29"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "lzma-rust2"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1c46f3fcd2e888afdb01a07efc071cb21300e07c68d32c3e8b01b5c8cb1fcaa"
dependencies = [
"sha2 0.11.1",
]
[[package]]
name = "memchr"
@ -1405,6 +1630,18 @@ dependencies = [
"libc",
]
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nohash-hasher"
version = "0.2.0"
@ -1482,6 +1719,16 @@ version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "pbkdf2"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629"
dependencies = [
"digest 0.11.3",
"hmac",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@ -1505,6 +1752,12 @@ dependencies = [
"futures-io",
]
[[package]]
name = "pkg-config"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "png"
version = "0.18.1"
@ -1547,6 +1800,12 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppmd-rust"
version = "1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd4acc8d264ea385db64ccfdcdfd5909ee9eda0975666723d59f3bfec80b3611"
[[package]]
name = "prettyplease"
version = "0.2.37"
@ -1610,9 +1869,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
[[package]]
name = "rayon"
version = "1.11.0"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
@ -1862,9 +2121,9 @@ checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]]
name = "serde"
version = "1.0.228"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
@ -1872,29 +2131,29 @@ dependencies = [
[[package]]
name = "serde_core"
version = "1.0.228"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"syn 3.0.3",
]
[[package]]
name = "serde_json"
version = "1.0.151"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
@ -1927,6 +2186,37 @@ dependencies = [
"unsafe-libyaml",
]
[[package]]
name = "sevenz-rust2"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f0c68c0e8289bfd09e49ef433e8de02c7c464afc1d7f5a275c99749a880e301"
dependencies = [
"aes",
"bzip2",
"cbc",
"crc32fast",
"flate2",
"getrandom 0.4.2",
"js-sys",
"lzma-rust2",
"ppmd-rust",
"sha2 0.11.1",
"wasm-bindgen",
"zstd",
]
[[package]]
name = "sha1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"digest 0.11.3",
]
[[package]]
name = "sha2"
version = "0.10.9"
@ -1935,7 +2225,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
"digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"digest 0.11.3",
]
[[package]]
@ -1956,9 +2257,9 @@ dependencies = [
[[package]]
name = "simd-adler32"
version = "0.3.8"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "simd_cesu8"
@ -2093,6 +2394,17 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "tempfile"
version = "3.27.0"
@ -2133,6 +2445,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"js-sys",
"num-conv",
"powerfmt",
"serde_core",
@ -2343,6 +2656,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typed-path"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
[[package]]
name = "typenum"
version = "1.20.1"
@ -2393,6 +2712,17 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unrar-ng-sys"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72678d22d4d6aa9f0b905f5d6d5cd3e462c55631b526297d06fb1663532daa76"
dependencies = [
"cc",
"libc",
"winapi",
]
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
@ -3012,6 +3342,16 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "yoke"
version = "0.8.3"
@ -3165,12 +3505,87 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "zip"
version = "9.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704"
dependencies = [
"aes",
"bzip2",
"constant_time_eq 0.6.1",
"crc32fast",
"deflate64",
"flate2",
"getrandom 0.4.2",
"hmac",
"indexmap",
"lzma-rust2",
"memchr",
"nix 0.31.3",
"pbkdf2",
"ppmd-rust",
"sha1",
"time",
"typed-path",
"windows-sys 0.61.2",
"zeroize",
"zopfli",
"zstd",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zopfli"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zstd"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db3ae9bf90af6e40197bef767eeefc46ad6397420fe873830bd85e5fbbfb3c70"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "8.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ded520f16b29e1a9b771c722221b2b0a0398c2316d9bb3c42b458a5414d7434b"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.1.2+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3"
dependencies = [
"cc",
"pkg-config",
]
[[package]]
name = "zune-core"
version = "0.5.1"

View file

@ -12,12 +12,16 @@ blake3 = "=1.8.5"
filetime = "0.2"
globetter = "=0.2.0"
globset = "=0.4.15"
# In-place archive extraction. The `rar` feature compiles UnRAR's C++ source,
# which is under its own license (LICENSE-UNRAR, shipped with the addon):
# free for extracting RAR archives, not for building a RAR compressor.
hatchzip-core = { version = "=0.1.8", features = ["rar"] }
image = { version = "0.25.8", default-features = false, features = ["gif", "jpeg", "png", "webp"] }
indexmap = { version = "2.14", features = ["serde"] }
mime_guess = "2.0.5"
napi = { version = "3.5.2", default-features = false, features = ["napi8", "tokio_rt"] }
napi-derive = "3.3.2"
rayon = "=1.11.0"
rayon = "=1.12.0"
reqwest = { version = "0.13", default-features = false, features = ["stream", "charset", "http2", "system-proxy", "rustls-no-provider"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
serde = { version = "1.0.228", features = ["derive"] }

View file

@ -0,0 +1,42 @@
****** ***** ****** UnRAR - free utility for RAR archives
** ** ** ** ** ** ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
****** ******* ****** License for use and distribution of
** ** ** ** ** ** ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
** ** ** ** ** ** FREE portable version
~~~~~~~~~~~~~~~~~~~~~
The source code of UnRAR utility is freeware. This means:
1. All copyrights to RAR and the utility UnRAR are exclusively
owned by the author - Alexander Roshal.
2. UnRAR source code may be used in any software to handle
RAR archives without limitations free of charge, but cannot be
used to develop RAR (WinRAR) compatible archiver and to
re-create RAR compression algorithm, which is proprietary.
Distribution of modified UnRAR source code in separate form
or as a part of other software is permitted, provided that
full text of this paragraph, starting from "UnRAR source code"
words, is included in license, or in documentation if license
is not available, and in source code comments of resulting package.
3. The UnRAR utility may be freely distributed. It is allowed
to distribute UnRAR inside of other software packages.
4. THE RAR ARCHIVER AND THE UnRAR UTILITY ARE DISTRIBUTED "AS IS".
NO WARRANTY OF ANY KIND IS EXPRESSED OR IMPLIED. YOU USE AT
YOUR OWN RISK. THE AUTHOR WILL NOT BE LIABLE FOR DATA LOSS,
DAMAGES, LOSS OF PROFITS OR ANY OTHER KIND OF LOSS WHILE USING
OR MISUSING THIS SOFTWARE.
5. Installing and using the UnRAR utility signifies acceptance of
these terms and conditions of the license.
6. If you don't agree with terms of the license you must remove
UnRAR files from your storage devices and cease to use the
utility.
Thank you for your interest in RAR and UnRAR.
Alexander L. Roshal

View file

@ -0,0 +1,251 @@
//! In-place archive extraction through hatchzip-core. The archive is cut back
//! as its files come out, so extracting a download needs room for the output
//! only, not for the output and the archive side by side.
//!
//! Anything that stops a fresh extraction before its journal is written
//! leaves the archive untouched and reports `unavailable`, so the caller can
//! fall back to 7-Zip. Once the journal exists the archive is partly consumed:
//! errors report `failed`, and only another call (which resumes) can finish it.
//!
//! A journal lies next to the archive, where a download could have brought
//! one, so it is only followed when Hydra says it started that extraction.
use std::panic::{self, AssertUnwindSafe};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use hatchzip_core::engine::{self, Outcome, Progress, Run};
use hatchzip_core::session::{self, State};
use napi::bindgen_prelude::Error;
use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode};
use napi::Status;
use napi_derive::napi;
/// Free space the extraction never eats into, so the rest of the system keeps
/// working while a large archive unpacks.
const KEEP_FREE_BYTES: u64 = 1 << 30;
/// Most progress updates one extraction sends across the bridge.
const PROGRESS_UPDATE_STEPS: u64 = 1000;
// Hydra never pauses an extraction; a quit mid-way is resumed from the journal.
static STOP: AtomicBool = AtomicBool::new(false);
// UnRAR keeps state in globals and is not safe to drive from several threads,
// and two games can finish downloading at once, so extractions take turns.
static ONE_AT_A_TIME: Mutex<()> = Mutex::new(());
#[napi(object)]
pub struct InPlaceExtractionOptions {
pub archive_path: String,
pub output_path: String,
/// Tried in order when the archive is encrypted.
pub passwords: Vec<String>,
/// Allows starting a new in-place extraction.
pub allow_fresh: bool,
/// Hydra recorded starting an in-place extraction of this archive, so a
/// journal next to it is Hydra's own and what is left gets finished.
pub resume: bool,
}
#[napi(object)]
pub struct InPlaceExtractionResult {
/// `finished`, `unavailable` (archive untouched) or `failed` (archive
/// partly consumed, progress saved).
pub status: String,
pub message: Option<String>,
}
impl InPlaceExtractionResult {
fn finished() -> Self {
Self {
status: "finished".into(),
message: None,
}
}
fn unavailable(message: impl ToString) -> Self {
Self {
status: "unavailable".into(),
message: Some(message.to_string()),
}
}
fn failed(message: impl ToString) -> Self {
Self {
status: "failed".into(),
message: Some(message.to_string()),
}
}
}
/// Whether an in-place extraction journal lies next to the archive.
#[napi]
pub fn in_place_journal_exists(archive_path: String) -> bool {
journal_exists(Path::new(&archive_path))
}
fn journal_exists(archive_path: &Path) -> bool {
session::locate(archive_path).is_ok_and(|(_, paths)| paths.exists())
}
/// `on_progress` receives the extracted fraction, from 0 to 1.
#[napi]
pub async fn extract_archive_in_place(
options: InPlaceExtractionOptions,
on_progress: ThreadsafeFunction<f64, (), f64, Status, false>,
) -> napi::Result<InPlaceExtractionResult> {
tokio::task::spawn_blocking(move || {
let archive_path = PathBuf::from(&options.archive_path);
let on_progress = move |fraction| {
on_progress.call(fraction, ThreadsafeFunctionCallMode::NonBlocking);
};
// A panic still says whether the archive was touched, so the caller
// never hands a partly consumed one to 7-Zip.
panic::catch_unwind(AssertUnwindSafe(|| extract(options, on_progress))).unwrap_or_else(
|_| {
if journal_exists(&archive_path) {
InPlaceExtractionResult::failed("in-place extraction crashed")
} else {
InPlaceExtractionResult::unavailable("in-place extraction crashed")
}
},
)
})
.await
.map_err(|err| Error::from_reason(err.to_string()))
}
fn extract(
options: InPlaceExtractionOptions,
on_progress: impl Fn(f64) + Send + Sync + 'static,
) -> InPlaceExtractionResult {
let (primary, paths) = match session::locate(Path::new(&options.archive_path)) {
Ok(located) => located,
Err(err) => return InPlaceExtractionResult::unavailable(format!("{err:#}")),
};
if paths.exists() && !options.resume {
return InPlaceExtractionResult::unavailable(format!(
"{} was not created by Hydra",
paths.journal.display()
));
}
if !paths.exists() {
// Hydra started this run, and both the archive and its journal are
// gone, so every file is out.
if options.resume && !primary.exists() {
return InPlaceExtractionResult::finished();
}
if !options.allow_fresh {
return InPlaceExtractionResult::unavailable("in-place extraction was not requested");
}
}
let _turn = ONE_AT_A_TIME
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
// Before the journal exists nothing has been touched, so any error leaves
// the archive whole for another extractor.
let not_started = |message: String| {
if paths.exists() {
InPlaceExtractionResult::failed(message)
} else {
InPlaceExtractionResult::unavailable(message)
}
};
let mut passwords = options.passwords.into_iter();
let opened = session::open_encrypted(
&primary,
&paths,
Some(PathBuf::from(&options.output_path)),
true,
None,
&Arc::default(),
|_| passwords.next(),
);
let state = match opened {
Ok((state, _)) => state,
Err(err) => return not_started(format!("{err:#}")),
};
let (job, journal) = match state {
State::Orphan(journal) => {
return match engine::finish_orphaned(&journal, &paths) {
Ok(()) => InPlaceExtractionResult::finished(),
Err(err) => InPlaceExtractionResult::failed(format!("{err:#}")),
};
}
State::Resume(job, journal) => {
let plan =
match session::plan(&job, &journal.output, true, KEEP_FREE_BYTES, journal.keep) {
Ok(plan) => plan,
Err(err) => return InPlaceExtractionResult::failed(format!("{err:#}")),
};
if !plan.fits {
return InPlaceExtractionResult::failed(format!(
"not enough disk space to resume: {} required, {} available",
engine::human(plan.needed + plan.keep_free),
engine::human(plan.free)
));
}
if let Err(err) = session::clear_uncommitted(&job, &journal.output) {
return InPlaceExtractionResult::failed(format!("{err:#}"));
}
(job, journal)
}
State::Fresh(job, output) => {
if let Some(reason) = job.in_place_refusal() {
return InPlaceExtractionResult::unavailable(reason);
}
let plan = match session::plan(&job, &output, false, KEEP_FREE_BYTES, false) {
Ok(plan) => plan,
Err(err) => return InPlaceExtractionResult::unavailable(format!("{err:#}")),
};
if !plan.fits {
return InPlaceExtractionResult::unavailable(format!(
"not enough disk space: {} required, {} available",
engine::human(plan.needed + plan.keep_free),
engine::human(plan.free)
));
}
match session::begin(&job, &paths, output, false, None) {
Ok(journal) => (job, journal),
Err(err) => return not_started(format!("{err:#}")),
}
}
};
let total = journal.total_size.max(1);
let extracted = Arc::new(AtomicU64::new(
journal.total_size.saturating_sub(job.remaining_size()),
));
let reported_steps = Arc::new(AtomicU64::new(0));
on_progress(extracted.load(Ordering::Relaxed) as f64 / total as f64);
let run = Run {
job,
journal,
paths,
stop: &STOP,
progress: Progress {
on_bytes: Box::new(move |bytes| {
let done = extracted.fetch_add(bytes, Ordering::Relaxed) + bytes;
let steps = done.saturating_mul(PROGRESS_UPDATE_STEPS) / total;
if steps > reported_steps.fetch_max(steps, Ordering::Relaxed) {
on_progress(
steps.min(PROGRESS_UPDATE_STEPS) as f64 / PROGRESS_UPDATE_STEPS as f64,
);
}
}),
on_commit: Box::new(|_| {}),
},
keep_free: KEEP_FREE_BYTES,
};
match run.execute() {
Ok(Outcome::Finished) => InPlaceExtractionResult::finished(),
Ok(Outcome::Paused) => InPlaceExtractionResult::failed("extraction paused"),
Err(err) => InPlaceExtractionResult::failed(format!("{err:#}")),
}
}

View file

@ -1,6 +1,7 @@
mod active_window;
mod cloud_save;
mod constants;
mod in_place_extraction;
mod kde_focus;
mod linux_process;
mod screenshot_input;

View file

@ -106,6 +106,7 @@
"string-argv": "^0.3.2",
"sudo-prompt": "^9.2.1",
"tar": "^7.5.21",
"thumbhash": "0.1.1",
"tough-cookie": "^5.1.1",
"user-agents": "^1.1.387",
"uuid": "^14.0.0",

View file

@ -176,6 +176,11 @@ const build = async () => {
fs.mkdirSync(outputDir, { recursive: true });
fs.copyFileSync(sourceLibraryPath, outputNodePath);
// The addon embeds UnRAR, whose license must ship with it.
fs.copyFileSync(
path.join(path.dirname(manifestPath), "LICENSE-UNRAR"),
path.join(outputDir, "LICENSE-UNRAR")
);
await copySidecarLibrariesOnWindows();
await ensureDepsResolvableOnLinux();

View file

@ -555,7 +555,7 @@ function DownloadGameModalSession({
const [
deleteArchiveFilesAfterExtraction,
setDeleteArchiveFilesAfterExtraction,
] = useState(false);
] = useState(true);
const [isPreparingOptions, setIsPreparingOptions] = useState(false);
const stepFrameRef = useRef<HTMLDivElement | null>(null);
const activeStepRef = useRef<HTMLDivElement | null>(null);
@ -730,7 +730,7 @@ function DownloadGameModalSession({
if (!deleteArchiveTouchedRef.current) {
setDeleteArchiveFilesAfterExtraction(
userPreferences?.deleteArchiveFilesAfterExtractionByDefault ?? false
userPreferences?.deleteArchiveFilesAfterExtractionByDefault ?? true
);
}

View file

@ -38,7 +38,7 @@ const DEFAULT_FORM: DownloadsBehaviorForm = {
seedAfterDownloadComplete: false,
showDownloadSpeedInMegabytes: false,
extractFilesByDefault: true,
deleteArchiveFilesAfterExtractionByDefault: false,
deleteArchiveFilesAfterExtractionByDefault: true,
createStartMenuShortcut: true,
};
@ -53,7 +53,7 @@ const buildForm = (
preferences.showDownloadSpeedInMegabytes ?? false,
extractFilesByDefault: preferences.extractFilesByDefault ?? true,
deleteArchiveFilesAfterExtractionByDefault:
preferences.deleteArchiveFilesAfterExtractionByDefault ?? false,
preferences.deleteArchiveFilesAfterExtractionByDefault ?? true,
createStartMenuShortcut: preferences.createStartMenuShortcut ?? true,
}
: DEFAULT_FORM;

View file

@ -0,0 +1,124 @@
import type { ExtractionProgress } from "./7zip";
import type {
InPlaceExtractionOptions,
InPlaceExtractionResult,
} from "./native-addon";
export const ARCHIVE_PASSWORDS = ["online-fix.me", "steamrip.com"];
export interface ArchiveExtractors {
inPlaceEnabled: () => Promise<boolean>;
inPlace: (
options: InPlaceExtractionOptions,
onProgress: (fraction: number) => void
) => Promise<InPlaceExtractionResult>;
hasInPlaceJournal: (archivePath: string) => boolean;
sevenZip: (
options: { filePath: string; outputPath: string; passwords: string[] },
onProgress: (progress: ExtractionProgress) => void
) => Promise<{ success: boolean }>;
log: (message: string) => void;
}
export interface ArchiveExtractionRequest {
filePath: string;
outputPath: string;
// The archive is deleted afterwards anyway, so it may be consumed while
// extracting.
inPlace: boolean;
// Hydra recorded starting an in-place extraction of this archive before.
resume: boolean;
// Records that an in-place extraction of this archive may start. Runs
// before hatchzip touches the archive, so a retry knows to resume it.
recordInPlaceStart: () => Promise<void>;
// Drops that record once hatchzip reports the archive untouched.
forgetInPlaceStart: () => Promise<void>;
}
// In place, hatchzip cuts the archive back as files come out, so the disk
// never holds the archive and the game in full at once. An archive it refuses
// (a solid RAR, files already in the way, too little space) is untouched and
// goes to 7-Zip. One it has started is never handed to 7-Zip, since part of it
// is gone: hatchzip finishes it, whatever the feature flag says now. Only a
// new run asks the API for the flag, and with the flag off 7-Zip extracts all.
export const extractArchive = async (
extractors: ArchiveExtractors,
{
filePath,
outputPath,
inPlace,
resume,
recordInPlaceStart,
forgetInPlaceStart,
}: ArchiveExtractionRequest,
onProgress: (progress: ExtractionProgress) => void
): Promise<{ success: boolean }> => {
const extractWithSevenZip = () =>
extractors.sevenZip(
{ filePath, outputPath, passwords: ARCHIVE_PASSWORDS },
onProgress
);
const allowFresh = inPlace && !resume && (await extractors.inPlaceEnabled());
if (!resume && !allowFresh) {
return extractWithSevenZip();
}
if (allowFresh) {
// A journal Hydra did not create may have come with the download, and
// recording a start now would let a retry follow it.
if (extractors.hasInPlaceJournal(filePath)) {
extractors.log(
`Extracting ${filePath} with 7-Zip, not in place: it has a journal Hydra did not create`
);
return extractWithSevenZip();
}
await recordInPlaceStart();
}
let result: InPlaceExtractionResult;
try {
result = await extractors.inPlace(
{
archivePath: filePath,
outputPath,
passwords: ARCHIVE_PASSWORDS,
allowFresh,
resume,
},
(fraction) =>
onProgress({ percent: fraction * 100, fileCount: 0, file: "" })
);
} catch (error) {
// The addon did not run, so it touched nothing now. What an earlier run
// left of the archive still can't go to 7-Zip.
if (resume) throw error;
await forgetInPlaceStart();
extractors.log(
`Extracting ${filePath} with 7-Zip, in-place extraction could not run: ${error}`
);
return extractWithSevenZip();
}
if (result.status === "finished") {
extractors.log(`Extracted ${filePath} in place`);
return { success: true };
}
if (result.status === "failed") {
throw new Error(
`In-place extraction of ${filePath} stopped: ${result.message}`
);
}
// Nothing was started, so there is nothing for a retry to resume.
await forgetInPlaceStart();
extractors.log(
`Extracting ${filePath} with 7-Zip, not in place: ${result.message}`
);
return extractWithSevenZip();
};

View file

@ -18,6 +18,7 @@ import {
} from "@shared";
import type {
ClassicsDisc,
Download,
EmulatorSystem,
ExtractionFailure,
Game,
@ -32,6 +33,9 @@ import path from "node:path";
import pngToIco from "png-to-ico";
import sharp from "sharp";
import { ExtractionProgress, SevenZip } from "./7zip";
import { type ArchiveExtractors, extractArchive } from "./archive-extraction";
import { NativeAddon } from "./native-addon";
import { HydraApi } from "./hydra-api";
import * as emulators from "./emulators";
import * as retroarch from "./retroarch";
import { getPathType } from "./extraction-path";
@ -53,9 +57,52 @@ const MAX_ARCHIVE_EXTRACTION_PASSES = 2;
const INSTALLED_EXECUTABLE_MAX_DEPTH = 4;
const IN_PLACE_EXTRACTION_FEATURE = "IN_PLACE_ARCHIVE_EXTRACTION";
const FEATURES_REQUEST_TIMEOUT_MS = 10_000;
// Fails closed: without an answer from the API, archives go to 7-Zip.
const isInPlaceExtractionEnabled = () =>
HydraApi.get<string[]>("/features", null, {
needsAuth: false,
timeout: FEATURES_REQUEST_TIMEOUT_MS,
})
.then(
(features) =>
Array.isArray(features) &&
features.includes(IN_PLACE_EXTRACTION_FEATURE)
)
.catch((error) => {
logger.warn("[GameFilesManager] Could not load feature flags", error);
return false;
});
const archiveExtractors: Omit<ArchiveExtractors, "inPlaceEnabled"> = {
inPlace: (options, onProgress) =>
NativeAddon.extractArchiveInPlace(options, onProgress),
hasInPlaceJournal: (archivePath) => {
try {
return NativeAddon.inPlaceJournalExists(archivePath);
} catch {
// Without the addon the in-place call fails too, and 7-Zip extracts.
return false;
}
},
sevenZip: (options, onProgress) => SevenZip.extractFile(options, onProgress),
log: (message) => logger.info(`[GameFilesManager] ${message}`),
};
export class GameFilesManager {
private lastProgressUpdateTime = 0;
private lastProgressUpdateValue = 0;
private inPlaceExtractionEnabled: Promise<boolean> | null = null;
// The flag is read once per extraction run, so every archive of a download
// gets the same answer and a slow API only delays the first.
private readonly extractors: ArchiveExtractors = {
...archiveExtractors,
inPlaceEnabled: () =>
(this.inPlaceExtractionEnabled ??= isInPlaceExtractionEnabled()),
};
constructor(
private readonly shop: GameShop,
@ -176,21 +223,81 @@ export class GameFilesManager {
this.updateExtractionProgress(progress.percent / 100);
};
private async shouldDeleteArchives(download?: Download | null) {
const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences,
{ valueEncoding: "json" }
);
return (
download?.automaticallyDeleteArchiveFiles ??
userPreferences?.deleteArchiveFilesAfterExtractionByDefault ??
true
);
}
private async recordInPlaceStart(archivePath: string) {
const download = await downloadsSublevel.get(this.gameKey);
if (!download) {
throw new Error(`No download to record extracting ${archivePath} for`);
}
if (download.inPlaceArchives?.includes(archivePath)) return;
await downloadsSublevel.put(this.gameKey, {
...download,
inPlaceArchives: [...(download.inPlaceArchives ?? []), archivePath],
});
}
private async forgetInPlaceStart(archivePath: string) {
const download = await downloadsSublevel.get(this.gameKey);
if (!download?.inPlaceArchives?.includes(archivePath)) return;
const inPlaceArchives = download.inPlaceArchives.filter(
(startedArchive) => startedArchive !== archivePath
);
await downloadsSublevel.put(this.gameKey, {
...download,
inPlaceArchives: inPlaceArchives.length > 0 ? inPlaceArchives : undefined,
});
}
private async clearInPlaceArchives() {
const download = await downloadsSublevel.get(this.gameKey);
if (!download?.inPlaceArchives) return;
await downloadsSublevel.put(this.gameKey, {
...download,
inPlaceArchives: undefined,
});
}
private async extractArchivePass(
directoryPath: string,
filesToExtract: string[],
pass: number,
extractedFiles: Set<string>
extractedFiles: Set<string>,
inPlace: boolean,
startedInPlace: Set<string>
): Promise<boolean> {
let completedFiles = 0;
for (const file of filesToExtract) {
const filePath = path.join(directoryPath, file);
try {
const result = await SevenZip.extractFile(
const result = await extractArchive(
this.extractors,
{
filePath,
cwd: path.dirname(filePath),
passwords: ["online-fix.me", "steamrip.com"],
outputPath: path.dirname(filePath),
inPlace,
resume: startedInPlace.has(filePath),
recordInPlaceStart: () => this.recordInPlaceStart(filePath),
forgetInPlaceStart: () => this.forgetInPlaceStart(filePath),
},
(progress) => {
const passProgress =
@ -240,17 +347,9 @@ export class GameFilesManager {
.filter((archivePath) => fs.existsSync(archivePath));
if (archivePaths.length > 0) {
const [download, userPreferences] = await Promise.all([
downloadsSublevel.get(this.gameKey),
db.get<string, UserPreferences | null>(levelKeys.userPreferences, {
valueEncoding: "json",
}),
]);
const shouldDelete =
download?.automaticallyDeleteArchiveFiles ??
userPreferences?.deleteArchiveFilesAfterExtractionByDefault ??
false;
const shouldDelete = await this.shouldDeleteArchives(
await downloadsSublevel.get(this.gameKey)
);
if (shouldDelete) {
for (const archivePath of archivePaths) {
@ -294,9 +393,19 @@ export class GameFilesManager {
const compressedFiles = new Set<string>();
const extractedFiles = new Set<string>();
// Only multi-part hoster downloads, which need the feature to start, number their first volume part01.
const download = await downloadsSublevel.get(this.gameKey);
// Only multi-part hoster downloads, which need the feature to start, number their first volume part01.
const firstRarVolume = download?.parts ? /part0*1\.rar$/i : /part1\.rar$/i;
// Archives nested in an outer one are kept, so only top-level downloads
// that will be deleted anyway are consumed while extracting.
const inPlace =
!outerArchivePath && (await this.shouldDeleteArchives(download));
const startedInPlace = new Set(download?.inPlaceArchives ?? []);
// An archive consumed in place is no longer listed, but a run that
// stopped before cleaning up still has links and progress files to finish.
const unlistedInPlace = [...startedInPlace]
.map((archivePath) => path.basename(archivePath))
.filter((file) => startedInPlace.has(path.join(directoryPath, file)));
// Only process archives at the extraction root. Archives in game subfolders
// may be runtime assets and must remain intact.
@ -312,6 +421,7 @@ export class GameFilesManager {
return false;
}
archives = [...new Set([...archives, ...unlistedInPlace])];
archives.forEach((file) => compressedFiles.add(file));
const filesToExtract = archives.filter(
(file) =>
@ -328,7 +438,9 @@ export class GameFilesManager {
directoryPath,
filesToExtract,
pass,
extractedFiles
extractedFiles,
inPlace,
startedInPlace
))
) {
return false;
@ -341,6 +453,10 @@ export class GameFilesManager {
compressedFiles,
extractedFiles
);
// With an outer archive, the caller clears them once that is done too.
if (!outerArchivePath) await this.clearInPlaceArchives();
return true;
}
@ -979,12 +1095,6 @@ export class GameFilesManager {
}
const filePath = path.join(download.downloadPath, download.folderName);
if (!fs.existsSync(filePath)) {
await this.failMissingExtractionSource(filePath);
return false;
}
const archivePath = path.parse(download.folderName);
const extractedFolderName = path.join(archivePath.dir, archivePath.name);
const extractionPath = path.join(
@ -992,14 +1102,29 @@ export class GameFilesManager {
extractedFolderName
);
// An in-place run Hydra started may have consumed the archive already.
// hatchzip then finishes what is left of it, or reports it done, so a
// retry after a failed nested archive goes on from there.
const resume = download.inPlaceArchives?.includes(filePath) ?? false;
if (!resume && !fs.existsSync(filePath)) {
await this.failMissingExtractionSource(filePath);
return false;
}
this.updateExtractionProgress(0, true);
try {
const result = await SevenZip.extractFile(
const shouldDelete = await this.shouldDeleteArchives(download);
const result = await extractArchive(
this.extractors,
{
filePath,
outputPath: extractionPath,
passwords: ["online-fix.me", "steamrip.com"],
inPlace: shouldDelete,
resume,
recordInPlaceStart: () => this.recordInPlaceStart(filePath),
forgetInPlaceStart: () => this.forgetInPlaceStart(filePath),
},
this.handleProgress
);
@ -1014,20 +1139,11 @@ export class GameFilesManager {
return false;
}
if (fs.existsSync(extractionPath) && fs.existsSync(filePath)) {
const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences,
{ valueEncoding: "json" }
);
const shouldDelete =
download.automaticallyDeleteArchiveFiles ??
userPreferences?.deleteArchiveFilesAfterExtractionByDefault ??
false;
if (fs.existsSync(extractionPath)) {
if (shouldDelete) {
// Clears the installer size too when the archive went in place.
await deleteArchiveFile(filePath);
} else {
} else if (fs.existsSync(filePath)) {
WindowManager.sendToAppWindows("on-archive-deletion-prompt", [
filePath,
]);
@ -1038,6 +1154,7 @@ export class GameFilesManager {
...download,
folderName: extractedFolderName,
sourceArchiveName: download.folderName,
inPlaceArchives: undefined,
});
await this.setExtractionComplete();

View file

@ -342,6 +342,7 @@ const buildRemoteGameShopAssets = (
iconUrl: remoteGame.iconUrl,
logoPosition: remoteGame.logoPosition,
downloadSources: remoteGame.downloadSources,
images: { ...localShopAssets?.images, ...remoteGame.images },
};
return (remoteGame.catalogueGame ?? localGame?.catalogueGame)?.shop ===
"steam"
@ -458,6 +459,7 @@ const mergeRemoteGamesChunk = async (
logoImageUrl: assets.logoImageUrl,
logoPosition: assets.logoPosition,
downloadSources: assets.downloadSources,
images: assets.images,
},
{ sublevel: gamesShopAssetsSublevel }
);

View file

@ -87,6 +87,23 @@ type NativeForegroundWindowResponse = {
process_id?: number;
};
export type InPlaceExtractionOptions = {
archivePath: string;
outputPath: string;
passwords: string[];
allowFresh: boolean;
// Hydra recorded starting an in-place extraction of this archive.
resume: boolean;
};
// "unavailable" leaves the archive untouched; "failed" means part of it is
// already extracted and removed, and only another in-place call resumes it.
// The call itself only rejects when the addon could not run.
export type InPlaceExtractionResult = {
status: "finished" | "unavailable" | "failed";
message?: string | null;
};
export type NativeProcessPayload = ProcessPayload &
Pick<
EpicProcessNode,
@ -133,6 +150,11 @@ type HydraNativeModule = {
getWindowsWindowBounds?: (
windowHandle: string
) => NativeWindowBoundsResponse | null;
extractArchiveInPlace: (
options: InPlaceExtractionOptions,
onProgress: (fraction: number) => void
) => Promise<InPlaceExtractionResult>;
inPlaceJournalExists: (archivePath: string) => boolean;
buildLocalGameSnapshotPipeline: (
input: BuildLocalGameSnapshotPipelineInput
) => Promise<NativeLocalGameSnapshotPipelineResult>;
@ -577,6 +599,17 @@ export class NativeAddon {
});
}
public static extractArchiveInPlace(
options: InPlaceExtractionOptions,
onProgress: (fraction: number) => void
) {
return this.load().extractArchiveInPlace(options, onProgress);
}
public static inPlaceJournalExists(archivePath: string) {
return this.load().inPlaceJournalExists(archivePath);
}
public static buildLocalGameSnapshotPipeline(
input: BuildLocalGameSnapshotPipelineInput
) {

View file

@ -1,21 +1,30 @@
import { ImageIcon } from "@primer/octicons-react";
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { useEffect, useRef, useState } from "react";
import type { CatalogueImages } from "@types";
import {
isAnimatedCoverCandidate,
useAnimatedSourceWarmup,
useAppSelector,
useCatalogueImage,
useCoverPoster,
} from "@renderer/hooks";
import {
getCatalogueImageSrcSet,
pickCatalogueImageUrl,
} from "@renderer/helpers/catalogue-images";
import { getVerticalCoverCardImageSources } from "./vertical-cover-card-image-sources";
import "./vertical-cover-card.scss";
const COVER_DISPLAY_WIDTH = 300;
export interface VerticalCoverCardProps
extends ButtonHTMLAttributes<HTMLButtonElement> {
gameTitle: string;
coverImageUrls: readonly (string | null | undefined)[];
catalogueImages?: CatalogueImages;
children?: ReactNode;
useClassicsLayout?: boolean;
showTitleTooltip?: boolean;
@ -24,6 +33,7 @@ export interface VerticalCoverCardProps
export function VerticalCoverCard({
gameTitle,
coverImageUrls,
catalogueImages,
children,
useClassicsLayout = false,
showTitleTooltip = true,
@ -43,7 +53,13 @@ export function VerticalCoverCard({
);
const autoplayAnimatedArtwork =
userPreferences?.autoplayAnimatedArtwork ?? false;
const isAnimatedCover = isAnimatedCoverCandidate(coverImageUrl);
const catalogueImage = useCatalogueImage(
useClassicsLayout ? null : coverImageUrl,
catalogueImages
);
const coverVariants = catalogueImage.variants;
const isAnimatedCover =
!coverVariants && isAnimatedCoverCandidate(coverImageUrl);
const coverPoster = useCoverPoster(coverImageUrl, isAnimatedCover);
const shouldHoldFrame =
isAnimatedCover && !isHovered && !autoplayAnimatedArtwork;
@ -62,6 +78,8 @@ export function VerticalCoverCard({
}, [imageSourcesKey]);
const handleImageError = () => {
if (catalogueImage.markFailed()) return;
if (!coverImageUrl || failedImageSourcesRef.current.has(coverImageUrl)) {
return;
}
@ -109,11 +127,21 @@ export function VerticalCoverCard({
return (
<img
src={displayCoverUrl}
src={
coverVariants
? pickCatalogueImageUrl(coverVariants, COVER_DISPLAY_WIDTH)
: displayCoverUrl
}
srcSet={
coverVariants ? getCatalogueImageSrcSet(coverVariants) : undefined
}
sizes={coverVariants ? `auto, ${COVER_DISPLAY_WIDTH}px` : undefined}
style={catalogueImage.placeholderStyle}
alt={gameTitle}
className="vertical-cover-card__image"
loading="lazy"
decoding="async"
onLoad={catalogueImage.markLoaded}
onError={handleImageError}
/>
);

View file

@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
decodeCatalogueThumbHash,
findCatalogueImageVariants,
getCatalogueImageDensitySrcSet,
getCatalogueImageSrcSet,
pickCatalogueImageUrl,
} from "./catalogue-images.js";
const coverUrl =
"https://shared.steamstatic.com/store_item_assets/steam/apps/271590/library_600x900_2x.jpg";
const cover = {
base: "https://cdn.losbroxas.org/img/v1/bba708cf29c2a9192a9294f584475d12",
widths: [600, 200, 400],
thumbHash: "migKDQIZN4uJB6Zol4emhcFkz471",
};
describe("findCatalogueImageVariants", () => {
it("looks variants up by the original URL", () => {
assert.equal(
findCatalogueImageVariants({ [coverUrl]: cover }, ` ${coverUrl} `),
cover
);
});
it("ignores missing URLs and entries without widths", () => {
assert.equal(findCatalogueImageVariants({ [coverUrl]: cover }, null), null);
assert.equal(
findCatalogueImageVariants({ [coverUrl]: cover }, "https://x/y.jpg"),
null
);
assert.equal(
findCatalogueImageVariants(
{ [coverUrl]: { ...cover, widths: [] } },
coverUrl
),
null
);
});
});
describe("pickCatalogueImageUrl", () => {
it("picks the smallest width that covers the target", () => {
assert.equal(pickCatalogueImageUrl(cover, 250), `${cover.base}/w400.webp`);
assert.equal(pickCatalogueImageUrl(cover, 200), `${cover.base}/w200.webp`);
});
it("falls back to the largest width", () => {
assert.equal(pickCatalogueImageUrl(cover, 2000), `${cover.base}/w600.webp`);
});
});
describe("srcset builders", () => {
it("lists widths in ascending order", () => {
assert.equal(
getCatalogueImageSrcSet(cover),
`${cover.base}/w200.webp 200w, ${cover.base}/w400.webp 400w, ${cover.base}/w600.webp 600w`
);
});
it("picks one width per pixel density", () => {
assert.equal(
getCatalogueImageDensitySrcSet(cover, 300),
`${cover.base}/w400.webp 1x, ${cover.base}/w600.webp 2x`
);
});
});
describe("decodeCatalogueThumbHash", () => {
it("decodes a ThumbHash into a PNG data URL", () => {
const placeholder = decodeCatalogueThumbHash(cover.thumbHash);
assert.ok(placeholder?.startsWith("data:image/png;base64,"));
assert.equal(decodeCatalogueThumbHash(cover.thumbHash), placeholder);
});
it("returns null for empty or invalid hashes", () => {
assert.equal(decodeCatalogueThumbHash(null), null);
assert.equal(decodeCatalogueThumbHash("%%%"), null);
});
});

View file

@ -0,0 +1,73 @@
import { thumbHashToDataURL } from "thumbhash";
import type { CatalogueImages, CatalogueImageVariants } from "@types";
const MAX_CACHED_PLACEHOLDERS = 1000;
const placeholderCache = new Map<string, string | null>();
export const findCatalogueImageVariants = (
images: CatalogueImages | null | undefined,
url: string | null | undefined
): CatalogueImageVariants | null => {
const variants = url ? images?.[url.trim()] : undefined;
return variants?.base && variants.widths.length > 0 ? variants : null;
};
const sortedWidths = (variants: CatalogueImageVariants) =>
[...variants.widths].sort((a, b) => a - b);
export const getCatalogueImageUrl = (
variants: CatalogueImageVariants,
width: number
) => `${variants.base}/w${width}.webp`;
export const pickCatalogueImageUrl = (
variants: CatalogueImageVariants,
targetWidth: number
) => {
const widths = sortedWidths(variants);
const width =
widths.find((candidate) => candidate >= targetWidth) ?? widths.at(-1)!;
return getCatalogueImageUrl(variants, width);
};
export const getCatalogueImageSrcSet = (variants: CatalogueImageVariants) =>
sortedWidths(variants)
.map((width) => `${getCatalogueImageUrl(variants, width)} ${width}w`)
.join(", ");
export const getCatalogueImageDensitySrcSet = (
variants: CatalogueImageVariants,
displayWidth: number
) =>
[1, 2]
.map(
(density) =>
`${pickCatalogueImageUrl(variants, displayWidth * density)} ${density}x`
)
.join(", ");
export const decodeCatalogueThumbHash = (
thumbHash: string | null | undefined
): string | null => {
if (!thumbHash) return null;
const cached = placeholderCache.get(thumbHash);
if (cached !== undefined) return cached;
let placeholder: string | null = null;
try {
const bytes = Uint8Array.from(atob(thumbHash), (char) =>
char.charCodeAt(0)
);
placeholder = thumbHashToDataURL(bytes);
} catch {
placeholder = null;
}
if (placeholderCache.size >= MAX_CACHED_PLACEHOLDERS) {
const oldest = placeholderCache.keys().next().value;
if (oldest !== undefined) placeholderCache.delete(oldest);
}
placeholderCache.set(thumbHash, placeholder);
return placeholder;
};

View file

@ -28,6 +28,7 @@ export * from "./use-retroarch-scan";
export * from "./use-game-artwork-grid";
export * from "./game-artwork-utils";
export * from "./use-cover-poster";
export * from "./use-catalogue-image";
export * from "./use-processed-image";
export * from "./use-file-icons";
export * from "./use-drag-text-selection";

View file

@ -0,0 +1,85 @@
import { useCallback, useMemo, useState, useSyncExternalStore } from "react";
import type { CSSProperties } from "react";
import type { CatalogueImages, CatalogueImageVariants } from "@types";
import {
decodeCatalogueThumbHash,
findCatalogueImageVariants,
pickCatalogueImageUrl,
} from "@renderer/helpers/catalogue-images";
const subscribeToResize = (onResize: () => void) => {
globalThis.window.addEventListener("resize", onResize);
return () => globalThis.window.removeEventListener("resize", onResize);
};
/**
* Picks the copy that covers the window's width in device pixels. The
* component re-renders only when a resize changes the picked copy.
*/
export function useViewportCatalogueImageUrl(
variants: CatalogueImageVariants | null
) {
const getSnapshot = useCallback(
() =>
variants
? pickCatalogueImageUrl(
variants,
globalThis.window.innerWidth * globalThis.window.devicePixelRatio
)
: null,
[variants]
);
return useSyncExternalStore(subscribeToResize, getSnapshot);
}
/**
* Resolves the resized CDN copy of an artwork URL. Callers fall back to the
* original URL once `markFailed` returns true for a broken copy.
*/
export function useCatalogueImage(
url: string | null | undefined,
images: CatalogueImages | null | undefined
) {
const [failedUrl, setFailedUrl] = useState<string | null>(null);
const [loadedUrl, setLoadedUrl] = useState<string | null>(null);
const found = findCatalogueImageVariants(images, url);
const variants = found && failedUrl !== url ? found : null;
const isLoaded = Boolean(url) && loadedUrl === url;
const placeholder = useMemo(
() => decodeCatalogueThumbHash(variants?.thumbHash),
[variants?.thumbHash]
);
const placeholderStyle = useMemo<CSSProperties | undefined>(
() =>
placeholder && !isLoaded
? {
backgroundImage: `url("${placeholder}")`,
backgroundSize: "cover",
backgroundPosition: "center",
}
: undefined,
[placeholder, isLoaded]
);
const markLoaded = useCallback(() => {
if (url) setLoadedUrl(url);
}, [url]);
const markFailed = useCallback(() => {
if (!variants || !url) return false;
setFailedUrl(url);
return true;
}, [variants, url]);
return {
variants,
placeholder,
placeholderStyle,
isLoaded,
markLoaded,
markFailed,
};
}

View file

@ -306,7 +306,7 @@ export function DownloadSettingsModal({
deleteArchiveFilesAfterExtraction,
setDeleteArchiveFilesAfterExtraction,
] = useState(
userPreferences?.deleteArchiveFilesAfterExtractionByDefault ?? false
userPreferences?.deleteArchiveFilesAfterExtractionByDefault ?? true
);
const [selectedDownloader, setSelectedDownloader] =
useState<Downloader | null>(null);
@ -574,7 +574,7 @@ export function DownloadSettingsModal({
useEffect(() => {
if (visible) {
setDeleteArchiveFilesAfterExtraction(
userPreferences?.deleteArchiveFilesAfterExtractionByDefault ?? false
userPreferences?.deleteArchiveFilesAfterExtractionByDefault ?? true
);
setAutomaticExtractionEnabled(
userPreferences?.extractFilesByDefault ?? true

View file

@ -53,8 +53,18 @@ function renderCard(
useCoverPoster: () => undefined,
isAnimatedCoverCandidate: () => false,
useAnimatedSourceWarmup: () => {},
useCatalogueImage: () => ({
variants: null,
placeholderStyle: undefined,
markLoaded: () => {},
markFailed: () => false,
}),
useFormat: () => ({ numberFormatter: new Intl.NumberFormat("en") }),
},
"@renderer/helpers/catalogue-images": {
getCatalogueImageSrcSet: () => "",
pickCatalogueImageUrl: () => "",
},
"@shared": { getDisplayedPlayTimeInMilliseconds: () => 0 },
"./library-category": libraryCategory,
"@renderer/helpers": {

View file

@ -40,6 +40,13 @@ it("refreshes grouped progress on member changes and preserves known offline tot
useCoverPoster: () => undefined,
isAnimatedCoverCandidate: () => false,
useAnimatedSourceWarmup: () => {},
useCatalogueImage: () => ({
variants: null,
placeholderStyle: undefined,
markLoaded: () => {},
markFailed: () => false,
}),
useViewportCatalogueImageUrl: () => null,
},
"./library-category": {
getLibraryPresentationImportSources: () => ({
@ -53,6 +60,10 @@ it("refreshes grouped progress on member changes and preserves known offline tot
isGameReadyToPlay: () => false,
resolveClassicsBadge: () => ({}),
},
"@renderer/helpers/catalogue-images": {
getCatalogueImageDensitySrcSet: () => "",
pickCatalogueImageUrl: () => "",
},
"@renderer/components": {
SteamLibraryBadge: () => null,
AchievementProgress: (props: Record<string, unknown>) =>

View file

@ -8,7 +8,13 @@ import {
useCoverPoster,
useGameCard,
useAnimatedSourceWarmup,
useCatalogueImage,
useViewportCatalogueImageUrl,
} from "@renderer/hooks";
import {
getCatalogueImageDensitySrcSet,
pickCatalogueImageUrl,
} from "@renderer/helpers/catalogue-images";
import {
CLASSICS_PS_PLATFORM_LABELS,
isGameReadyToPlay,
@ -34,6 +40,7 @@ import "./library-game-card-large.scss";
// Cards scrolled past or remounted within this window never load, keeping scroll bursts off the API.
const ACHIEVEMENT_PROGRESS_SETTLE_MS = 250;
const LOGO_DISPLAY_WIDTH = 400;
interface LibraryGameCardLargeProps {
game: LibraryGame;
@ -299,26 +306,34 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({
achievementGameKey,
]);
const rawHeroSource = heroSources[heroIndex];
const heroImage = useCatalogueImage(rawHeroSource, game.images);
const heroVariantUrl = useViewportCatalogueImageUrl(heroImage.variants);
const { markLoaded: markHeroLoaded, markFailed: markHeroFailed } = heroImage;
useEffect(() => {
const currentUrl = heroSources[heroIndex];
const currentUrl = heroVariantUrl ?? heroSources[heroIndex];
if (!currentUrl) return;
const img = new Image();
img.src = normalizePathForCss(currentUrl);
img.onload = markHeroLoaded;
img.onerror = () => {
if (markHeroFailed()) return;
if (heroIndex < heroSources.length - 1) {
setHeroIndex((prev) => prev + 1);
}
};
return () => {
img.onload = null;
img.onerror = null;
};
}, [heroIndex, heroSources]);
}, [heroIndex, heroSources, heroVariantUrl, markHeroLoaded, markHeroFailed]);
const rawHeroSource = heroSources[heroIndex];
const isAnimatedHero = isAnimatedCoverCandidate(rawHeroSource);
const isAnimatedHero =
!heroImage.variants && isAnimatedCoverCandidate(rawHeroSource);
const heroPoster = useCoverPoster(rawHeroSource, isAnimatedHero);
const shouldHoldHeroFrame =
isAnimatedHero && !isCoverHovered && !autoplayAnimatedArtwork;
@ -329,7 +344,8 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({
isAnimatedHero && !autoplayAnimatedArtwork && Boolean(heroPoster)
);
let displayHeroSource: string | null | undefined = rawHeroSource;
let displayHeroSource: string | null | undefined =
heroVariantUrl ?? rawHeroSource;
if (isAwaitingHeroPoster) {
displayHeroSource = undefined;
} else if (shouldHoldHeroFrame && heroPoster) {
@ -346,14 +362,18 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({
? normalizePathForCss(displayHeroSource)
: "";
const heroPlaceholder = heroImage.isLoaded ? null : heroImage.placeholder;
const backgroundStyle = useMemo(
() =>
!usesAnimatedHeroLayer && displayHeroSource
? {
backgroundImage: `url("${normalizePathForCss(displayHeroSource)}")`,
backgroundImage: [displayHeroSource, heroPlaceholder]
.filter(Boolean)
.map((url) => `url("${normalizePathForCss(url)}")`)
.join(", "),
}
: {},
[usesAnimatedHeroLayer, displayHeroSource]
[usesAnimatedHeroLayer, displayHeroSource, heroPlaceholder]
);
const classicsForegroundUrl = useMemo(() => {
@ -363,6 +383,8 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({
}, [renderClassicsBlurred, activeHeroCandidate, displayHeroSource]);
const logoImage = game.customLogoImageUrl ?? game.logoImageUrl;
const logoCatalogueImage = useCatalogueImage(logoImage, game.images);
const logoVariants = logoCatalogueImage.variants;
const { label: classicsPlatformLabel, icon: classicsEmulatorIcon } =
resolveClassicsBadge(
@ -491,9 +513,22 @@ export const LibraryGameCardLarge = memo(function LibraryGameCardLarge({
<div className="library-game-card-large__logo-container">
{logoImage ? (
<img
src={logoImage}
src={
logoVariants
? pickCatalogueImageUrl(logoVariants, LOGO_DISPLAY_WIDTH)
: logoImage
}
srcSet={
logoVariants
? getCatalogueImageDensitySrcSet(
logoVariants,
LOGO_DISPLAY_WIDTH
)
: undefined
}
alt={game.title}
className="library-game-card-large__logo"
onError={logoCatalogueImage.markFailed}
/>
) : (
<h3 className="library-game-card-large__title">{game.title}</h3>

View file

@ -8,7 +8,12 @@ import {
isAnimatedCoverCandidate,
useAppSelector,
useAnimatedSourceWarmup,
useCatalogueImage,
} from "@renderer/hooks";
import {
getCatalogueImageSrcSet,
pickCatalogueImageUrl,
} from "@renderer/helpers/catalogue-images";
import {
CLASSICS_PS_PLATFORM_LABELS,
isGameReadyToPlay,
@ -31,6 +36,8 @@ import {
import "./library-game-card.scss";
import { logger } from "@renderer/logger";
const COVER_DISPLAY_WIDTH = 300;
interface LibraryGameCardProps {
game: LibraryGame;
onContextMenu: (
@ -117,7 +124,10 @@ export const LibraryGameCard = memo(function LibraryGameCard({
const isChosenCoverActive = Boolean(candidates[fallbackIndex]?.isChosenCover);
const rawActiveSource = sources[fallbackIndex];
const isAnimatedCover = isAnimatedCoverCandidate(rawActiveSource);
const catalogueImage = useCatalogueImage(rawActiveSource, game.images);
const coverVariants = catalogueImage.variants;
const isAnimatedCover =
!coverVariants && isAnimatedCoverCandidate(rawActiveSource);
const coverPoster = useCoverPoster(rawActiveSource, isAnimatedCover);
const [isCoverHovered, setIsCoverHovered] = useState(false);
const shouldHoldFrame =
@ -149,6 +159,8 @@ export const LibraryGameCard = memo(function LibraryGameCard({
const showReadyBadge = !hideReadySizeBadges && isInstalled;
const handleImageError = () => {
if (catalogueImage.markFailed()) return;
logger.warn(`Image failed to load for ${game.title}`, {
failedUrl: sources[fallbackIndex],
level: fallbackIndex,
@ -209,12 +221,23 @@ export const LibraryGameCard = memo(function LibraryGameCard({
return (
<img
src={displayImageSource}
src={
coverVariants
? pickCatalogueImageUrl(coverVariants, COVER_DISPLAY_WIDTH)
: displayImageSource
}
srcSet={
coverVariants ? getCatalogueImageSrcSet(coverVariants) : undefined
}
sizes={coverVariants ? `auto, ${COVER_DISPLAY_WIDTH}px` : undefined}
style={catalogueImage.placeholderStyle}
alt={game.title}
className={`library-game-card__game-image ${
isChosenCoverActive ? "library-game-card__game-image--contain" : ""
}`}
loading="lazy"
decoding="async"
onLoad={catalogueImage.markLoaded}
onError={handleImageError}
/>
);

View file

@ -123,6 +123,7 @@ export const UserLibraryGameCard = memo(function UserLibraryGameCard({
<VerticalCoverCard
gameTitle={game.title}
coverImageUrls={[coverImageUrl]}
catalogueImages={game.images}
useClassicsLayout={
game.shop === "launchbox" && !game.customLibraryImageUrl
}

View file

@ -34,7 +34,7 @@ const buildForm = (preferences: UserPreferences | null) => ({
)
: "",
deleteArchiveFilesAfterExtractionByDefault:
preferences?.deleteArchiveFilesAfterExtractionByDefault ?? false,
preferences?.deleteArchiveFilesAfterExtractionByDefault ?? true,
torrentNetworkInterface: preferences?.torrentNetworkInterface ?? "",
});

View file

@ -229,6 +229,7 @@ const projectGroup = (
logoPosition: presentationGame.logoPosition,
downloadSources: presentationGame.downloadSources,
selectedArtworkTypes: presentationGame.selectedArtworkTypes,
images: presentationGame.images,
achievementCount: operationalGame.achievementCount,
unlockedAchievementCount: operationalGame.unlockedAchievementCount,
customIconUrl: custom("customIconUrl"),

View file

@ -63,6 +63,15 @@ export interface ProtonVersion {
source?: "steam" | "compatibility_tools" | "unknown";
}
export interface CatalogueImageVariants {
base: string;
widths: number[];
thumbHash: string | null;
}
/** Resized CDN copies keyed by the original image URL. */
export type CatalogueImages = Record<string, CatalogueImageVariants>;
export interface ShopAssets {
objectId: string;
shop: GameShop;
@ -78,6 +87,7 @@ export interface ShopAssets {
epicArtworkUpdatedAtByType?: Partial<
Record<"icon" | "portrait" | "landscape", number>
>;
images?: CatalogueImages;
}
export type ShopDetails = SteamAppDetails & {

View file

@ -149,6 +149,10 @@ export interface Download {
installerPath?: string;
hasInstaller?: boolean;
sourceArchiveName?: string;
// Archives Hydra started extracting in place, kept until the whole download
// is extracted. Only these are resumed, since a progress file next to an
// archive may have come with the download.
inPlaceArchives?: string[];
automaticallyExtract: boolean;
automaticallyDeleteArchiveFiles: boolean;
fileIndices?: number[];

View file

@ -8913,6 +8913,11 @@ throttle-debounce@^2.1.0:
resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==
thumbhash@0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/thumbhash/-/thumbhash-0.1.1.tgz#bd2b8616fc043f2b17151dfce0cce1408e0ebbeb"
integrity sha512-kH5pKeIIBPQXAOni2AiY/Cu/NKdkFREdpH+TLdM0g6WA7RriCv0kPLgP731ady67MhTAqrVG/4mnEeibVuCJcg==
tiny-async-pool@1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz#c013e1b369095e7005db5595f95e646cca6ef8a5"