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:
commit
eb67f403c4
30 changed files with 1454 additions and 87 deletions
|
|
@ -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",
|
||||
|
|
|
|||
475
native/hydra-native/Cargo.lock
generated
475
native/hydra-native/Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
42
native/hydra-native/LICENSE-UNRAR
Normal file
42
native/hydra-native/LICENSE-UNRAR
Normal 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
|
||||
251
native/hydra-native/src/in_place_extraction.rs
Normal file
251
native/hydra-native/src/in_place_extraction.rs
Normal 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:#}")),
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
124
src/main/services/archive-extraction.ts
Normal file
124
src/main/services/archive-extraction.ts
Normal 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();
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
82
src/renderer/src/helpers/catalogue-images.test.ts
Normal file
82
src/renderer/src/helpers/catalogue-images.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
73
src/renderer/src/helpers/catalogue-images.ts
Normal file
73
src/renderer/src/helpers/catalogue-images.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
85
src/renderer/src/hooks/use-catalogue-image.ts
Normal file
85
src/renderer/src/hooks/use-catalogue-image.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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>) =>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ const buildForm = (preferences: UserPreferences | null) => ({
|
|||
)
|
||||
: "",
|
||||
deleteArchiveFilesAfterExtractionByDefault:
|
||||
preferences?.deleteArchiveFilesAfterExtractionByDefault ?? false,
|
||||
preferences?.deleteArchiveFilesAfterExtractionByDefault ?? true,
|
||||
torrentNetworkInterface: preferences?.torrentNetworkInterface ?? "",
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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 & {
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue