Merge branch 'main' of https://github.com/Polyfrost/OneLauncher
This commit is contained in:
commit
087365e877
52 changed files with 1848 additions and 307 deletions
148
Cargo.lock
generated
148
Cargo.lock
generated
|
|
@ -517,7 +517,7 @@ version = "0.7.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener 5.4.1",
|
||||
"event-listener-strategy",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
|
|
@ -586,7 +586,7 @@ version = "3.4.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener 5.4.1",
|
||||
"event-listener-strategy",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
|
@ -604,7 +604,7 @@ dependencies = [
|
|||
"async-task",
|
||||
"blocking",
|
||||
"cfg-if",
|
||||
"event-listener",
|
||||
"event-listener 5.4.1",
|
||||
"futures-lite",
|
||||
"rustix 1.1.4",
|
||||
]
|
||||
|
|
@ -704,6 +704,15 @@ dependencies = [
|
|||
"tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-watch"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a078faf4e27c0c6cc0efb20e5da59dcccc04968ebf2801d8e0b2195124cdcdb2"
|
||||
dependencies = [
|
||||
"event-listener 2.5.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async_zip"
|
||||
version = "0.0.18"
|
||||
|
|
@ -1949,6 +1958,12 @@ dependencies = [
|
|||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "2.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.1"
|
||||
|
|
@ -1966,7 +1981,7 @@ version = "0.5.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener 5.4.1",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
|
|
@ -2113,8 +2128,8 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "freya"
|
||||
version = "0.4.0-rc.23"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
|
||||
version = "0.4.0"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"freya-android",
|
||||
|
|
@ -2136,8 +2151,8 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "freya-android"
|
||||
version = "0.4.0-rc.23"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
|
||||
version = "0.4.0"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
|
||||
dependencies = [
|
||||
"freya-components",
|
||||
"freya-core",
|
||||
|
|
@ -2149,8 +2164,8 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "freya-animation"
|
||||
version = "0.4.0-rc.23"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
|
||||
version = "0.4.0"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
|
||||
dependencies = [
|
||||
"async-io",
|
||||
"easer",
|
||||
|
|
@ -2159,8 +2174,8 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "freya-clipboard"
|
||||
version = "0.4.0-rc.23"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
|
||||
version = "0.4.1"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
|
||||
dependencies = [
|
||||
"copypasta",
|
||||
"freya-core",
|
||||
|
|
@ -2168,8 +2183,8 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "freya-components"
|
||||
version = "0.4.0-rc.23"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
|
||||
version = "0.4.0"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"anyhow",
|
||||
|
|
@ -2188,19 +2203,20 @@ dependencies = [
|
|||
"freya-sdk",
|
||||
"open",
|
||||
"paste",
|
||||
"reqwest 0.12.28",
|
||||
"rustc-hash",
|
||||
"thiserror 2.0.18",
|
||||
"torin",
|
||||
"tracing",
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "freya-core"
|
||||
version = "0.4.0-rc.23"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
|
||||
version = "0.4.0"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"async-broadcast",
|
||||
"async-watch",
|
||||
"bitflags 2.12.1",
|
||||
"bytes 1.11.1",
|
||||
"cursor-icon",
|
||||
|
|
@ -2224,8 +2240,8 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "freya-edit"
|
||||
version = "0.4.0-rc.23"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
|
||||
version = "0.4.0"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
|
||||
dependencies = [
|
||||
"bitflags 2.12.1",
|
||||
"freya-clipboard",
|
||||
|
|
@ -2238,24 +2254,24 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "freya-engine"
|
||||
version = "0.4.0-rc.23"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
|
||||
version = "0.4.0"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
|
||||
dependencies = [
|
||||
"freya-skia-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "freya-icons"
|
||||
version = "0.4.0-rc.23"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
|
||||
version = "0.4.0"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
|
||||
dependencies = [
|
||||
"bytes 1.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "freya-markdown"
|
||||
version = "0.4.0-rc.23"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
|
||||
version = "0.4.0"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
|
||||
dependencies = [
|
||||
"freya-components",
|
||||
"freya-core",
|
||||
|
|
@ -2265,8 +2281,8 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "freya-performance-plugin"
|
||||
version = "0.4.0-rc.23"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
|
||||
version = "0.4.0"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
|
||||
dependencies = [
|
||||
"freya-core",
|
||||
"freya-engine",
|
||||
|
|
@ -2275,8 +2291,8 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "freya-query"
|
||||
version = "0.4.0-rc.23"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
|
||||
version = "0.4.0"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
|
||||
dependencies = [
|
||||
"async-io",
|
||||
"freya-core",
|
||||
|
|
@ -2285,16 +2301,16 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "freya-radio"
|
||||
version = "0.4.0-rc.23"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
|
||||
version = "0.4.0"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
|
||||
dependencies = [
|
||||
"freya-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "freya-router"
|
||||
version = "0.4.0-rc.23"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
|
||||
version = "0.4.0"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
|
||||
dependencies = [
|
||||
"freya-core",
|
||||
"freya-router-macro",
|
||||
|
|
@ -2305,8 +2321,8 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "freya-router-macro"
|
||||
version = "0.4.0-rc.23"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
|
||||
version = "0.4.0"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
@ -2316,8 +2332,8 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "freya-sdk"
|
||||
version = "0.4.0-rc.23"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
|
||||
version = "0.4.0"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
|
||||
dependencies = [
|
||||
"async-io",
|
||||
"freya-core",
|
||||
|
|
@ -2367,8 +2383,8 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "freya-winit"
|
||||
version = "0.4.0-rc.23"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
|
||||
version = "0.4.0"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_winit",
|
||||
|
|
@ -3008,6 +3024,7 @@ dependencies = [
|
|||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3045,7 +3062,7 @@ dependencies = [
|
|||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.62.2",
|
||||
"windows-core 0.56.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4500,7 +4517,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "oneclient_app"
|
||||
version = "2.1.0"
|
||||
version = "2.1.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
|
|
@ -4538,7 +4555,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "oneclient_auth"
|
||||
version = "2.1.0"
|
||||
version = "2.1.1"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"chrono",
|
||||
|
|
@ -4561,7 +4578,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "oneclient_cluster"
|
||||
version = "2.1.0"
|
||||
version = "2.1.1"
|
||||
dependencies = [
|
||||
"bytes 1.11.1",
|
||||
"chrono",
|
||||
|
|
@ -4584,7 +4601,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "oneclient_common"
|
||||
version = "2.1.0"
|
||||
version = "2.1.1"
|
||||
dependencies = [
|
||||
"directories",
|
||||
"interfrost",
|
||||
|
|
@ -4595,7 +4612,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "oneclient_content"
|
||||
version = "2.1.0"
|
||||
version = "2.1.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"async_zip",
|
||||
|
|
@ -4621,7 +4638,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "oneclient_core"
|
||||
version = "2.1.0"
|
||||
version = "2.1.1"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"async-stream",
|
||||
|
|
@ -4685,7 +4702,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "oneclient_db"
|
||||
version = "2.1.0"
|
||||
version = "2.1.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
|
|
@ -4699,7 +4716,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "oneclient_discord"
|
||||
version = "2.1.0"
|
||||
version = "2.1.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"discord-rich-presence",
|
||||
|
|
@ -4711,7 +4728,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "oneclient_events"
|
||||
version = "2.1.0"
|
||||
version = "2.1.1"
|
||||
dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
|
|
@ -4721,7 +4738,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "oneclient_java"
|
||||
version = "2.1.0"
|
||||
version = "2.1.1"
|
||||
dependencies = [
|
||||
"astral-tokio-tar",
|
||||
"async-tempfile",
|
||||
|
|
@ -4743,7 +4760,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "oneclient_macro"
|
||||
version = "2.1.0"
|
||||
version = "2.1.1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
@ -4752,7 +4769,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "oneclient_mc"
|
||||
version = "2.1.0"
|
||||
version = "2.1.1"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"chrono",
|
||||
|
|
@ -4774,7 +4791,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "oneclient_net"
|
||||
version = "2.1.0"
|
||||
version = "2.1.1"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-trait",
|
||||
|
|
@ -4796,7 +4813,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "oneclient_polyplus"
|
||||
version = "2.1.0"
|
||||
version = "2.1.1"
|
||||
dependencies = [
|
||||
"async-tungstenite",
|
||||
"bytes 1.11.1",
|
||||
|
|
@ -4927,8 +4944,8 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
|||
|
||||
[[package]]
|
||||
name = "pathgraph"
|
||||
version = "0.4.0-rc.23"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
|
||||
version = "0.4.0"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
|
|
@ -5073,7 +5090,7 @@ checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
|
|||
|
||||
[[package]]
|
||||
name = "polyio"
|
||||
version = "2.1.0"
|
||||
version = "2.1.1"
|
||||
dependencies = [
|
||||
"astral-tokio-tar",
|
||||
"async-compression",
|
||||
|
|
@ -5352,8 +5369,8 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
|||
|
||||
[[package]]
|
||||
name = "ragnarok"
|
||||
version = "0.4.0-rc.23"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
|
||||
version = "0.4.0"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
|
||||
dependencies = [
|
||||
"euclid",
|
||||
"hotpath",
|
||||
|
|
@ -5606,6 +5623,7 @@ dependencies = [
|
|||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http 1.4.1",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
|
|
@ -5635,6 +5653,7 @@ dependencies = [
|
|||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.4.2",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -6508,7 +6527,7 @@ dependencies = [
|
|||
"crc",
|
||||
"crossbeam-queue",
|
||||
"either",
|
||||
"event-listener",
|
||||
"event-listener 5.4.1",
|
||||
"futures-core",
|
||||
"futures-intrusive",
|
||||
"futures-io",
|
||||
|
|
@ -7122,8 +7141,8 @@ checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
|
|||
|
||||
[[package]]
|
||||
name = "torin"
|
||||
version = "0.4.0-rc.23"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
|
||||
version = "0.4.0"
|
||||
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#f3140a08462d907d607b91a8aba3b8f9fe3420e4"
|
||||
dependencies = [
|
||||
"euclid",
|
||||
"itertools 0.14.0",
|
||||
|
|
@ -7410,7 +7429,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"flate2",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"rustls",
|
||||
|
|
@ -8616,7 +8634,7 @@ dependencies = [
|
|||
"async-trait",
|
||||
"blocking",
|
||||
"enumflags2",
|
||||
"event-listener",
|
||||
"event-listener 5.4.1",
|
||||
"futures-core",
|
||||
"futures-lite",
|
||||
"hex",
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ homepage = "https://polyfrost.org/projects/oneclient"
|
|||
license = "GPL-3.0-only"
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/Polyfrost/OneLauncher"
|
||||
version = "2.1.0"
|
||||
version = "2.1.1"
|
||||
|
||||
|
||||
[workspace.dependencies]
|
||||
|
|
@ -147,12 +147,6 @@ freya = { git = "https://github.com/marc2332/freya.git", branch = "fix/blur" }
|
|||
opt-level = 3
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
# Keep the *shipped* binary stripped and small, but still emit debug info into
|
||||
# separate files (macOS: .dSYM, Linux: .dwp, Windows: .pdb) so Sentry can
|
||||
# symbolicate crash reports. CI uploads those files with `sentry-cli debug-files
|
||||
# upload`; they are never shipped to users. Without this, release stack traces
|
||||
# are just raw addresses. Use "full" instead of "line-tables-only" if you also
|
||||
# want inlined-frame / variable info (larger debug files, slower builds).
|
||||
strip = true
|
||||
panic = "abort"
|
||||
debug = "line-tables-only"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use freya::prelude::*;
|
|||
use oneclient_common::VersionKey;
|
||||
use oneclient_common::domain::GameLoader;
|
||||
|
||||
use crate::components::DynamicArt;
|
||||
use crate::components::{ART_PREVIEW_EDGE, DynamicArt};
|
||||
use crate::theme::colors;
|
||||
use crate::ui::border_all_color;
|
||||
|
||||
|
|
@ -20,7 +20,7 @@ impl ClusterLandscapeArt {
|
|||
selected: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
art: DynamicArt::for_version(major, key, loader).max_edge(512),
|
||||
art: DynamicArt::for_version(major, key, loader).max_edge(ART_PREVIEW_EDGE),
|
||||
selected,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,12 +9,18 @@ use crate::AppAssets;
|
|||
use crate::hooks::{loaded_image, use_cached_image, use_version_metadata};
|
||||
use crate::layout::HOME_BACKGROUND_ASSET;
|
||||
|
||||
/// The size the cluster cards ask for, and so the one variant of a version's
|
||||
/// art that is usually already cached by the time anything else wants it.
|
||||
/// Full-screen art stands in with it while its own download is in flight.
|
||||
pub const ART_PREVIEW_EDGE: u32 = 512;
|
||||
|
||||
#[derive(PartialEq, Clone)]
|
||||
pub struct DynamicArt {
|
||||
major: Option<u32>,
|
||||
key: Option<VersionKey>,
|
||||
loader: Option<GameLoader>,
|
||||
max_edge: u32,
|
||||
preview_edge: Option<u32>,
|
||||
}
|
||||
|
||||
impl DynamicArt {
|
||||
|
|
@ -24,6 +30,7 @@ impl DynamicArt {
|
|||
key,
|
||||
loader,
|
||||
max_edge: DEFAULT_IMAGE_EDGE,
|
||||
preview_edge: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -34,6 +41,7 @@ impl DynamicArt {
|
|||
key: parsed.and_then(|p| p.key()),
|
||||
loader: Some(cluster.mc_loader),
|
||||
max_edge: DEFAULT_IMAGE_EDGE,
|
||||
preview_edge: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -43,6 +51,7 @@ impl DynamicArt {
|
|||
key: None,
|
||||
loader: None,
|
||||
max_edge: DEFAULT_IMAGE_EDGE,
|
||||
preview_edge: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -52,8 +61,27 @@ impl DynamicArt {
|
|||
self
|
||||
}
|
||||
|
||||
/// Stand in with a smaller variant of the same art until the full-size one
|
||||
/// has downloaded.
|
||||
///
|
||||
/// A full-size background is a few hundred KB and the cluster cards have
|
||||
/// usually already cached the small one, so this puts the right picture on
|
||||
/// screen right away and sharpens it later, instead of sitting on the
|
||||
/// built-in fallback for the length of a download.
|
||||
#[must_use]
|
||||
pub fn preview_edge(mut self, preview_edge: u32) -> Self {
|
||||
self.preview_edge = Some(preview_edge);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn use_bytes(&self) -> (String, Bytes) {
|
||||
use_art_bytes(self.major, self.key, self.loader, self.max_edge)
|
||||
use_art_bytes(
|
||||
self.major,
|
||||
self.key,
|
||||
self.loader,
|
||||
self.max_edge,
|
||||
self.preview_edge,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -62,6 +90,7 @@ pub fn use_art_bytes(
|
|||
key: Option<VersionKey>,
|
||||
loader: Option<GameLoader>,
|
||||
max_edge: u32,
|
||||
preview_edge: Option<u32>,
|
||||
) -> (String, Bytes) {
|
||||
let fallback = use_memo(|| AppAssets::get_bytes(HOME_BACKGROUND_ASSET).unwrap_or_default());
|
||||
|
||||
|
|
@ -69,13 +98,24 @@ pub fn use_art_bytes(
|
|||
|
||||
let image_query = use_cached_image(art_url.clone(), max_edge);
|
||||
|
||||
match loaded_image(art_url.as_deref(), &image_query) {
|
||||
Some((url, bytes)) => (format!("{max_edge}|{url}"), bytes),
|
||||
None => (
|
||||
// Subscribed unconditionally to keep the hook order stable. Without a
|
||||
// preview size it is the same query as the one above, so it costs nothing.
|
||||
let preview_edge = preview_edge.unwrap_or(max_edge);
|
||||
let preview_query = use_cached_image(art_url.clone(), preview_edge);
|
||||
|
||||
let full = loaded_image(art_url.as_deref(), &image_query)
|
||||
.map(|(url, bytes)| (format!("{max_edge}|{url}"), bytes));
|
||||
let preview = || {
|
||||
loaded_image(art_url.as_deref(), &preview_query)
|
||||
.map(|(url, bytes)| (format!("{preview_edge}|{url}"), bytes))
|
||||
};
|
||||
|
||||
full.or_else(preview).unwrap_or_else(|| {
|
||||
(
|
||||
format!("{max_edge}|{HOME_BACKGROUND_ASSET}"),
|
||||
fallback.read().clone(),
|
||||
),
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
impl Component for DynamicArt {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ pub struct Icon {
|
|||
pub icon: IconType,
|
||||
pub size_px: f32,
|
||||
pub color: Option<Color>,
|
||||
pub rotation: Option<f32>,
|
||||
}
|
||||
|
||||
impl Icon {
|
||||
|
|
@ -23,6 +24,7 @@ impl Icon {
|
|||
icon,
|
||||
size_px: 24.,
|
||||
color,
|
||||
rotation: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -35,6 +37,12 @@ impl Icon {
|
|||
self.color = Some(color);
|
||||
self
|
||||
}
|
||||
|
||||
/// Rotation in degrees, clockwise.
|
||||
pub fn rotate(mut self, degrees: f32) -> Self {
|
||||
self.rotation = Some(degrees);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for Icon {
|
||||
|
|
@ -42,11 +50,13 @@ impl Component for Icon {
|
|||
let path = self.icon.path();
|
||||
let bytes = use_memo(move || AppAssets::get_bytes(path).unwrap_or_default());
|
||||
|
||||
svg(bytes.read().cloned())
|
||||
SvgViewer::new((path, bytes.read().cloned()))
|
||||
.show_loader(false)
|
||||
.width(Size::px(self.size_px))
|
||||
.height(Size::px(self.size_px))
|
||||
// .fill(color)
|
||||
.map(self.color, |svg, color| svg.color(color))
|
||||
.map(self.rotation, |svg, degrees| svg.rotation(degrees))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ pub use cluster_landscape_art::ClusterLandscapeArt;
|
|||
pub use cluster_update_popup::ClusterUpdatePopup;
|
||||
pub use context_menu::ContextMenu;
|
||||
pub use dropdown::Dropdown;
|
||||
pub use dynamic_art::DynamicArt;
|
||||
pub use dynamic_art::{ART_PREVIEW_EDGE, DynamicArt};
|
||||
pub use file_drop::FileDropOverlay;
|
||||
pub use icons::{Icon, IconType};
|
||||
pub use java_install_manager::JavaInstallManager;
|
||||
|
|
|
|||
|
|
@ -7,8 +7,12 @@ use freya::{
|
|||
use crate::{
|
||||
Route,
|
||||
components::{Avatar, Icon, IconType},
|
||||
hooks::{try_default_account, use_current_account, use_dispatch, use_notifications_snapshot},
|
||||
hooks::{
|
||||
settled_or_loading, try_default_account, use_active_cluster_id, use_clusters,
|
||||
use_current_account, use_dispatch, use_notifications_snapshot,
|
||||
},
|
||||
theme,
|
||||
utils::sort_clusters_for_home,
|
||||
};
|
||||
|
||||
const NAVBAR_INTRO_MS: u64 = 460;
|
||||
|
|
@ -32,7 +36,7 @@ impl Component for Navbar {
|
|||
.width(Size::fill())
|
||||
.height(Size::px(theme::NAVBAR_HEIGHT_PX))
|
||||
.position(Position::new_absolute().top(0.).left(0.))
|
||||
.layer(Layer::RelativeOverlay(2))
|
||||
.layer(Layer::OverlayLevel(2))
|
||||
.child(
|
||||
rect()
|
||||
.width(Size::fill())
|
||||
|
|
@ -68,7 +72,8 @@ fn navbar_left() -> impl IntoElement {
|
|||
fn navbar_logo() -> impl IntoElement {
|
||||
let bytes = use_memo(|| crate::AppAssets::get_bytes("logo.svg").unwrap_or_default());
|
||||
|
||||
svg(bytes.read().cloned())
|
||||
SvgViewer::new(("logo.svg", bytes.read().cloned()))
|
||||
.show_loader(false)
|
||||
.height(Size::px(44.))
|
||||
.width(Size::px(214.))
|
||||
.color(theme::colors::fg_primary())
|
||||
|
|
@ -76,13 +81,14 @@ fn navbar_logo() -> impl IntoElement {
|
|||
|
||||
fn navbar_center() -> impl IntoElement {
|
||||
let route = use_route::<Route>();
|
||||
let browse_target = browse_target();
|
||||
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::flex(1.0))
|
||||
.main_align(Alignment::Center)
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(64.)
|
||||
.spacing(48.)
|
||||
.child(NavLink {
|
||||
active: route == Route::Home {},
|
||||
target: Route::Home {},
|
||||
|
|
@ -93,6 +99,17 @@ fn navbar_center() -> impl IntoElement {
|
|||
target: Route::Clusters {},
|
||||
nav_label: "Versions",
|
||||
})
|
||||
.child(NavLink {
|
||||
active: matches!(
|
||||
route,
|
||||
Route::Browser {
|
||||
pick_cluster: true,
|
||||
..
|
||||
}
|
||||
),
|
||||
target: browse_target,
|
||||
nav_label: "Browse",
|
||||
})
|
||||
.child(NavLink {
|
||||
active: route == Route::Stats {},
|
||||
target: Route::Stats {},
|
||||
|
|
@ -100,6 +117,27 @@ fn navbar_center() -> impl IntoElement {
|
|||
})
|
||||
}
|
||||
|
||||
/// Browsing always happens for some cluster, so the navbar picks the one the
|
||||
/// shell has active and falls back to the most recently played one. With no
|
||||
/// clusters at all there is nothing to browse for, so it points at Versions.
|
||||
fn browse_target() -> Route {
|
||||
let clusters = settled_or_loading(&use_clusters()).unwrap_or_default();
|
||||
let active = *use_active_cluster_id().read();
|
||||
|
||||
let cluster_id = active
|
||||
.filter(|id| clusters.iter().any(|cluster| cluster.id == *id))
|
||||
.or_else(|| sort_clusters_for_home(clusters).first().map(|c| c.id));
|
||||
|
||||
match cluster_id {
|
||||
Some(cluster_id) => Route::Browser {
|
||||
cluster_id,
|
||||
package_type: "mod".to_string(),
|
||||
pick_cluster: true,
|
||||
},
|
||||
None => Route::Clusters {},
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct NavLink {
|
||||
active: bool,
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ pub fn window_controls() -> impl IntoElement {
|
|||
.main_align(Alignment::End)
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(8.)
|
||||
.layer(Layer::RelativeOverlay(u8::MAX))
|
||||
.layer(Layer::OverlayLevel(15))
|
||||
.child(
|
||||
navbar_button()
|
||||
.child(Icon::new(IconType::Minus))
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@ impl Component for OnboardingNavbar {
|
|||
fn logo() -> impl IntoElement {
|
||||
let bytes = use_memo(|| crate::AppAssets::get_bytes("logo.svg").unwrap_or_default());
|
||||
|
||||
svg(bytes.read().cloned())
|
||||
SvgViewer::new(("logo.svg", bytes.read().cloned()))
|
||||
.show_loader(false)
|
||||
.height(Size::px(36.))
|
||||
.width(Size::px(170.))
|
||||
.color(colors::fg_primary())
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ impl Component for OverlayPopup {
|
|||
.position(Position::new_global().top(0.).left(0.))
|
||||
.width(Size::window_percent(100.))
|
||||
.height(Size::window_percent(100.))
|
||||
.layer(Layer::RelativeOverlay(10))
|
||||
.layer(Layer::OverlayLevel(10))
|
||||
.background(Color::from_argb(alpha, 0, 0, 0))
|
||||
.on_press(move |_| {
|
||||
if let Some(on_close) = scrim_close.as_ref() {
|
||||
|
|
@ -99,7 +99,7 @@ impl Component for OverlayPopup {
|
|||
.a11y_focusable(true)
|
||||
.a11y_auto_focus(true)
|
||||
.a11y_role(AccessibilityRole::Dialog)
|
||||
.layer(Layer::RelativeOverlay(12))
|
||||
.layer(Layer::OverlayLevel(12))
|
||||
.on_global_key_down(move |e: Event<KeyboardEventData>| {
|
||||
if e.key == Key::Named(NamedKey::Escape)
|
||||
&& let Some(on_close) = key_close.as_ref()
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ pub struct Pagination {
|
|||
page: State<usize>,
|
||||
total_pages: usize,
|
||||
window: usize,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl Pagination {
|
||||
|
|
@ -26,6 +27,7 @@ impl Pagination {
|
|||
page,
|
||||
total_pages,
|
||||
window: 5,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -34,17 +36,26 @@ impl Pagination {
|
|||
self.window = window.max(1);
|
||||
self
|
||||
}
|
||||
|
||||
/// Greys out every control without unmounting the pager, for callers that
|
||||
/// are still loading the page the user just asked for.
|
||||
pub fn enabled(mut self, enabled: bool) -> Self {
|
||||
self.enabled = enabled;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for Pagination {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let page = self.page;
|
||||
let total_pages = self.total_pages;
|
||||
let interactive = self.enabled;
|
||||
let current = *page.read();
|
||||
let last = total_pages.saturating_sub(1);
|
||||
|
||||
let icon_btn = move |target: usize, enabled: bool, icon: IconType| {
|
||||
let icon_btn = move |target: usize, available: bool, icon: IconType| {
|
||||
let mut page = page;
|
||||
let enabled = available && interactive;
|
||||
Button::new()
|
||||
.secondary()
|
||||
.icon()
|
||||
|
|
@ -85,6 +96,7 @@ impl Component for Pagination {
|
|||
.width(Size::px(32.))
|
||||
.height(Size::px(32.))
|
||||
.padding(Gaps::new_symmetric(6., 0.))
|
||||
.enabled(interactive)
|
||||
.on_press(move |_| page.set(target))
|
||||
.child(
|
||||
label()
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use freya::prelude::*;
|
|||
use freya::router::RouterContext;
|
||||
use oneclient_core::clusters::Cluster;
|
||||
|
||||
use crate::components::{DynamicArt, Icon, IconType};
|
||||
use crate::components::{ART_PREVIEW_EDGE, DynamicArt, Icon, IconType};
|
||||
use crate::hooks::{settled_or_loading, use_active_cluster_id, use_clusters};
|
||||
use crate::routes::Route;
|
||||
use crate::theme::colors;
|
||||
|
|
@ -18,6 +18,8 @@ const MAX_CARD_WIDTH_PX: f32 = 480.0;
|
|||
const CARD_MS: u64 = 460;
|
||||
const STAGGER_MS: u64 = 85;
|
||||
const CARD_RISE_PX: f32 = 48.0;
|
||||
/// Items the intro staggers individually; past this they rise together.
|
||||
const INTRO_ITEMS: usize = 6;
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub struct RecentsRow;
|
||||
|
|
@ -31,17 +33,22 @@ impl Component for RecentsRow {
|
|||
|
||||
let sorted: Vec<Cluster> = sort_clusters_for_home(clusters);
|
||||
let slots = *visible_slots.read();
|
||||
let display = sorted.into_iter().take(slots).collect::<Vec<_>>();
|
||||
let count = display.len();
|
||||
|
||||
let items = count + 1;
|
||||
// Budgeted off the clusters we have, never off the slots we can show. An
|
||||
// offset is the one thing a card must not change on a frame where the row
|
||||
// is re-measured: the layout engine treats an offset-only change as "inner
|
||||
// layout" and hands back the card's *cached* area instead of measuring it,
|
||||
// so a rerun triggered by the slot count would freeze the surviving cards
|
||||
// at their pre-resize widths while the new ones come in at the new width.
|
||||
let items = intro_items(sorted.len());
|
||||
|
||||
let display = sorted.into_iter().take(slots).collect::<Vec<_>>();
|
||||
|
||||
let intro = use_animation_with_dependencies(&items, |conf, items| {
|
||||
conf.on_creation(OnCreation::Run);
|
||||
conf.on_change(OnChange::Rerun);
|
||||
let total = CARD_MS + (items.saturating_sub(1) as u64) * STAGGER_MS;
|
||||
AnimNum::new(0., 1.)
|
||||
.time(total.max(1))
|
||||
.time(intro_ms(*items).max(1))
|
||||
.function(Function::Linear)
|
||||
});
|
||||
let progress = intro.get().value();
|
||||
|
|
@ -51,8 +58,7 @@ impl Component for RecentsRow {
|
|||
.height(Size::px(ROW_HEIGHT_PX))
|
||||
.content(Content::Flex)
|
||||
.on_sized(move |event: Event<SizedEventData>| {
|
||||
let width = event.data().area.width();
|
||||
let next = recent_card_slots_for_width(width).max(1);
|
||||
let next = recent_card_slots_for_width(event.data().area.width());
|
||||
if next != *visible_slots.peek() {
|
||||
*visible_slots.write() = next;
|
||||
}
|
||||
|
|
@ -69,17 +75,28 @@ impl Component for RecentsRow {
|
|||
.into_element()
|
||||
}))
|
||||
.child(OtherVersionsTile {
|
||||
index: count,
|
||||
index: items.saturating_sub(1),
|
||||
items,
|
||||
progress,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Items to stagger for `clusters` recents, counting the "other versions" tile.
|
||||
fn intro_items(clusters: usize) -> usize {
|
||||
(clusters + 1).min(INTRO_ITEMS)
|
||||
}
|
||||
|
||||
fn intro_ms(items: usize) -> u64 {
|
||||
CARD_MS + (items.saturating_sub(1) as u64) * STAGGER_MS
|
||||
}
|
||||
|
||||
fn stagger_eased(progress: f32, index: usize, items: usize) -> f32 {
|
||||
let total = CARD_MS as f32 + (items.saturating_sub(1) as f32) * STAGGER_MS as f32;
|
||||
let elapsed = progress * total;
|
||||
let local = ((elapsed - index as f32 * STAGGER_MS as f32) / CARD_MS as f32).clamp(0., 1.);
|
||||
let elapsed = progress * intro_ms(items) as f32;
|
||||
// Anything past the budget shares the last slot, so every item still lands on
|
||||
// a finished 1.0 rather than freezing part-way with a leftover offset.
|
||||
let start = index.min(items.saturating_sub(1)) as f32 * STAGGER_MS as f32;
|
||||
let local = ((elapsed - start) / CARD_MS as f32).clamp(0., 1.);
|
||||
1.0 - (1.0 - local).powi(3)
|
||||
}
|
||||
|
||||
|
|
@ -122,7 +139,6 @@ impl Component for ClusterCard {
|
|||
rect()
|
||||
.key(self.cluster.id)
|
||||
.width(Size::flex(1.0))
|
||||
.min_width(Size::px(MIN_CARD_WIDTH_PX))
|
||||
.max_width(Size::px(MAX_CARD_WIDTH_PX))
|
||||
.height(Size::fill())
|
||||
.offset_y(rise)
|
||||
|
|
@ -174,7 +190,7 @@ impl Component for ClusterCard {
|
|||
.height(Size::fill())
|
||||
.position(Position::new_absolute())
|
||||
.layer(Layer::Relative(1))
|
||||
.child(DynamicArt::for_cluster(&self.cluster).max_edge(512)),
|
||||
.child(DynamicArt::for_cluster(&self.cluster).max_edge(ART_PREVIEW_EDGE)),
|
||||
)
|
||||
.child(
|
||||
rect()
|
||||
|
|
@ -284,12 +300,74 @@ impl Component for OtherVersionsTile {
|
|||
}
|
||||
}
|
||||
|
||||
/// How many cards fit beside the "other versions" tile at `row_width_px`.
|
||||
///
|
||||
/// `n` cards sit in `n` gaps' worth of spacing — `n - 1` between the cards and
|
||||
/// one before the tile — so `n * (MIN + GAP) + MORE` has to fit. One card is
|
||||
/// always shown: below that width it just renders narrower than the minimum,
|
||||
/// which is why the card carries no `min_width`. With one, a stale count during
|
||||
/// a live resize used to push the row past its bounds and leave a card cut off
|
||||
/// or stretched until the next measurement landed.
|
||||
fn recent_card_slots_for_width(row_width_px: f32) -> usize {
|
||||
if row_width_px <= MORE_TILE_WIDTH_PX + CARD_GAP_PX {
|
||||
return 0;
|
||||
if !row_width_px.is_finite() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
let available = row_width_px - MORE_TILE_WIDTH_PX;
|
||||
let slot = MIN_CARD_WIDTH_PX + CARD_GAP_PX;
|
||||
(available / slot).floor() as usize
|
||||
(available / slot).floor().max(1.0) as usize
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn row_fits(slots: usize, row_width_px: f32) -> bool {
|
||||
let cards = slots as f32 * MIN_CARD_WIDTH_PX;
|
||||
let gaps = slots as f32 * CARD_GAP_PX;
|
||||
cards + gaps + MORE_TILE_WIDTH_PX <= row_width_px
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots_fill_the_row_without_overflowing_it() {
|
||||
for width in (200..3200).step_by(4) {
|
||||
let width = width as f32;
|
||||
let slots = recent_card_slots_for_width(width);
|
||||
|
||||
assert!(slots >= 1, "{width}px left no room for a card");
|
||||
if slots > 1 {
|
||||
assert!(row_fits(slots, width), "{slots} cards overflow {width}px");
|
||||
}
|
||||
assert!(
|
||||
!row_fits(slots + 1, width),
|
||||
"{width}px had room for {} cards",
|
||||
slots + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_item_settles_when_the_intro_ends() {
|
||||
// A card left with a leftover offset keeps changing it, and an offset-only
|
||||
// change is what makes the layout engine reuse a card's cached width.
|
||||
for clusters in 0..24 {
|
||||
let items = intro_items(clusters);
|
||||
for index in 0..=clusters {
|
||||
assert_eq!(
|
||||
stagger_eased(1.0, index, items),
|
||||
1.0,
|
||||
"item {index} of {clusters} never finished rising"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_intro_runs_the_same_length_whatever_fits_on_screen() {
|
||||
// The window width decides how many cards show, so anything the intro is
|
||||
// measured against has to be independent of it.
|
||||
assert_eq!(intro_items(3), intro_items(3));
|
||||
assert_eq!(intro_ms(intro_items(30)), intro_ms(INTRO_ITEMS));
|
||||
assert_eq!(intro_ms(intro_items(0)), CARD_MS);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,7 +89,8 @@ impl Component for CurtainFade {
|
|||
.vertical()
|
||||
.spacing(14.)
|
||||
.child(
|
||||
svg(logo.read().cloned())
|
||||
SvgViewer::new(("logo.svg", logo.read().cloned()))
|
||||
.show_loader(false)
|
||||
.width(Size::px(288.))
|
||||
.height(Size::px(60.))
|
||||
.color(colors::fg_primary()),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,14 @@ use crate::theme::colors;
|
|||
const BAR_HEIGHT: f32 = 34.;
|
||||
const AMBER: Color = Color::from_rgb(191, 122, 26);
|
||||
|
||||
/// `Layer::RelativeOverlay(n)` resolves to `n * (i16::MAX / 16)`, so any `n`
|
||||
/// past 16 pins to `i16::MAX`. Children resolve to `parent + 1`, which saturates
|
||||
/// to the *same* layer — and a layer is an unordered set, so the bar's own
|
||||
/// background painted over its contents about as often as not. That is what
|
||||
/// made the message and the close button come and go. 14 keeps the bar above
|
||||
/// every popup while leaving its children room to sit in front of it.
|
||||
const BAR_LAYER: u8 = 14;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
enum Issue {
|
||||
NoInternet,
|
||||
|
|
@ -121,49 +129,83 @@ impl Component for StatusBanner {
|
|||
});
|
||||
let p = intro.get().value();
|
||||
|
||||
let content = rect()
|
||||
let mut close_hover = use_state(|| false);
|
||||
|
||||
// Pressing close unmounts the banner from under the pointer, so there is
|
||||
// no `leave` to put the cursor back.
|
||||
use_drop(|| Cursor::set(CursorIcon::default()));
|
||||
|
||||
let close = issue.closeable().then(|| {
|
||||
rect()
|
||||
.center()
|
||||
.width(Size::px(22.))
|
||||
.height(Size::px(22.))
|
||||
.corner_radius(CornerRadius::new_all(6.))
|
||||
.background(if *close_hover.read() {
|
||||
Color::WHITE.with_a(46)
|
||||
} else {
|
||||
Color::TRANSPARENT
|
||||
})
|
||||
.on_pointer_enter(move |_| {
|
||||
close_hover.set(true);
|
||||
Cursor::set(CursorIcon::Pointer);
|
||||
})
|
||||
.on_pointer_leave(move |_| {
|
||||
close_hover.set(false);
|
||||
Cursor::set(CursorIcon::default());
|
||||
})
|
||||
.on_press(move |_| {
|
||||
dismissed.write().insert(issue);
|
||||
})
|
||||
.child(Icon::new(IconType::XClose).size(14.).color(Color::WHITE))
|
||||
.into_element()
|
||||
});
|
||||
|
||||
// The message is centred by a flex spacer on each side, and the close
|
||||
// button rides the end of the trailing one. It used to be absolutely
|
||||
// positioned, which pinned it to the top of the content box — inside the
|
||||
// bar's horizontal padding, so it sat high and well short of the edge.
|
||||
let leading = rect().width(Size::flex(1.0)).height(Size::fill());
|
||||
|
||||
let message = rect()
|
||||
.horizontal()
|
||||
.height(Size::fill())
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(8.)
|
||||
.layer(Layer::OverlayLevel(u8::MAX - 20))
|
||||
.child(Icon::new(issue.icon()).size(15.).color(Color::WHITE))
|
||||
.child(
|
||||
label()
|
||||
.text(issue.message())
|
||||
.font_size(12.)
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.max_lines(1)
|
||||
.color(Color::WHITE),
|
||||
);
|
||||
|
||||
let close = issue.closeable().then(|| {
|
||||
rect()
|
||||
.position(Position::new_absolute().right(10.))
|
||||
.center()
|
||||
.width(Size::px(20.))
|
||||
.height(Size::px(20.))
|
||||
.corner_radius(CornerRadius::new_all(6.))
|
||||
.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
|
||||
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
|
||||
.on_press(move |_| {
|
||||
dismissed.write().insert(issue);
|
||||
})
|
||||
.child(Icon::new(IconType::XClose).size(14.).color(Color::WHITE))
|
||||
});
|
||||
let trailing = rect()
|
||||
.width(Size::flex(1.0))
|
||||
.height(Size::fill())
|
||||
.horizontal()
|
||||
.main_align(Alignment::End)
|
||||
.cross_align(Alignment::Center)
|
||||
.layer(Layer::Relative(1))
|
||||
.maybe_child(close);
|
||||
|
||||
rect()
|
||||
.width(Size::window_percent(100.))
|
||||
.height(Size::px(BAR_HEIGHT))
|
||||
.position(Position::new_global().bottom(0.).left(0.))
|
||||
.layer(Layer::RelativeOverlay(u8::MAX - 20))
|
||||
.layer(Layer::OverlayLevel(BAR_LAYER))
|
||||
.background(issue.background())
|
||||
.opacity(p)
|
||||
.child(
|
||||
rect()
|
||||
.width(Size::fill())
|
||||
.height(Size::fill())
|
||||
.center()
|
||||
.padding(Gaps::new_symmetric(0., 16.))
|
||||
.child(content)
|
||||
.maybe_child(close),
|
||||
)
|
||||
.horizontal()
|
||||
.content(Content::Flex)
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(8.)
|
||||
.padding(Gaps::new_symmetric(0., 10.))
|
||||
.child(leading)
|
||||
.child(message)
|
||||
.child(trailing)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ use crate::{
|
|||
const TOAST_W: f32 = 300.;
|
||||
const TRAVEL: f32 = 320.;
|
||||
const TTL_MS: f32 = 5000.;
|
||||
/// Toasts sit below the navbar rather than on top of it: the startup/bundle-sync
|
||||
/// progress toast is on screen exactly when Home first appears, and at any
|
||||
/// smaller offset its card covered the nav links and account controls.
|
||||
const TOP_PX: f32 = crate::theme::NAVBAR_HEIGHT_PX + 12.;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Variant {
|
||||
|
|
@ -73,8 +77,8 @@ impl Component for Toasts {
|
|||
let mut stack = rect()
|
||||
.vertical()
|
||||
.spacing(12.)
|
||||
.position(Position::new_global().top(64.).right(24.))
|
||||
.layer(Layer::RelativeOverlay(3));
|
||||
.position(Position::new_global().top(TOP_PX).right(24.))
|
||||
.layer(Layer::OverlayLevel(3));
|
||||
|
||||
for entry in entries {
|
||||
let id = entry.id;
|
||||
|
|
|
|||
|
|
@ -661,28 +661,73 @@ impl Actions {
|
|||
version_id: impl Into<String>,
|
||||
) {
|
||||
let (project_id, version_id) = (project_id.into(), version_id.into());
|
||||
let actions = self.clone();
|
||||
|
||||
self.station
|
||||
.clone()
|
||||
.write_channel(AppChannel::Installs)
|
||||
.installs
|
||||
.begin(cluster_id, provider, project_id.clone());
|
||||
|
||||
spawn_forever(async move {
|
||||
let Ok(state) = launcher::state() else { return };
|
||||
let events = state.services.events.clone();
|
||||
match crate::install::install_package(
|
||||
let finish = |actions: &Actions| {
|
||||
actions
|
||||
.station
|
||||
.clone()
|
||||
.write_channel(AppChannel::Installs)
|
||||
.installs
|
||||
.finish(cluster_id, provider, &project_id);
|
||||
};
|
||||
|
||||
let Ok(state) = launcher::state() else {
|
||||
finish(&actions);
|
||||
return;
|
||||
};
|
||||
|
||||
let install = crate::install::install_package(
|
||||
&state,
|
||||
provider,
|
||||
&project_id,
|
||||
&version_id,
|
||||
cluster_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(name) => {
|
||||
events.notify("Installed").body(format!("Added {name}")).send();
|
||||
events.signal(oneclient_events::Signal::ClustersChanged);
|
||||
}
|
||||
Err(err) => events
|
||||
.notify("Install failed")
|
||||
.body(err.to_string())
|
||||
.error()
|
||||
.send(),
|
||||
.await;
|
||||
|
||||
// The download already raised a progress notification; the outcome
|
||||
// replaces it in place rather than arriving as a second one.
|
||||
let spec = match &install.result {
|
||||
Ok(name) => NotificationSpec {
|
||||
title: "Installed".to_string(),
|
||||
body: format!("Added {name}"),
|
||||
level: Level::Info,
|
||||
icon: Some(IconType::Download01),
|
||||
progress: None,
|
||||
actions: Vec::new(),
|
||||
},
|
||||
Err(err) => NotificationSpec {
|
||||
title: "Install failed".to_string(),
|
||||
body: err.to_string(),
|
||||
level: Level::Error,
|
||||
icon: None,
|
||||
progress: None,
|
||||
actions: Vec::new(),
|
||||
},
|
||||
};
|
||||
|
||||
let session_id = install.session_id.unwrap_or_else(uuid::Uuid::nil);
|
||||
actions.with_engine(|app| {
|
||||
app.notifications
|
||||
.finish_grouped_as_actions(&mut app.inbox, session_id, Some(spec));
|
||||
});
|
||||
|
||||
if install.result.is_ok() {
|
||||
state
|
||||
.services
|
||||
.events
|
||||
.signal(oneclient_events::Signal::ClustersChanged);
|
||||
}
|
||||
|
||||
finish(&actions);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,9 @@ pub use queries::{
|
|||
};
|
||||
|
||||
use crate::notifications::NotificationSnapshot;
|
||||
use crate::state::{AppChannel, GameState, LauncherInit, LoginProgress, SettingsState};
|
||||
use crate::state::{
|
||||
AppChannel, GameState, InstallState, LauncherInit, LoginProgress, SettingsState,
|
||||
};
|
||||
use freya::prelude::*;
|
||||
use freya::radio::use_radio;
|
||||
|
||||
|
|
@ -102,6 +104,10 @@ pub fn use_game_snapshot() -> GameState {
|
|||
use_radio(AppChannel::Game).read().game.clone()
|
||||
}
|
||||
|
||||
pub fn use_installs_snapshot() -> InstallState {
|
||||
use_radio(AppChannel::Installs).read().installs.clone()
|
||||
}
|
||||
|
||||
pub fn use_microsoft_login_status() -> Option<LoginProgress> {
|
||||
use_radio(AppChannel::MicrosoftLogin)
|
||||
.read()
|
||||
|
|
|
|||
|
|
@ -79,9 +79,16 @@ impl MutationCapability for ClusterMutation {
|
|||
let content = &state.services.content();
|
||||
let result = match keys {
|
||||
ClusterAction::ToggleArtifact { cluster_id, hash } => {
|
||||
oneclient_core::toggle_artifact_enabled(*cluster_id, hash, content)
|
||||
.await
|
||||
.map(|_| ())
|
||||
// A live session holds the cluster's files open, so the toggle
|
||||
// is recorded now and lands in the folder at the next launch.
|
||||
oneclient_core::toggle_artifact_enabled(
|
||||
*cluster_id,
|
||||
hash,
|
||||
state.games.is_active(*cluster_id),
|
||||
content,
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
ClusterAction::RemoveArtifact { cluster_id, hash } => {
|
||||
oneclient_core::remove_artifact_from_cluster(*cluster_id, hash, true, content).await
|
||||
|
|
|
|||
|
|
@ -152,20 +152,45 @@ pub async fn combined_cluster_update_spec(
|
|||
})
|
||||
}
|
||||
|
||||
/// An install's outcome together with the progress session it ran under.
|
||||
///
|
||||
/// The session is detached rather than finished so the caller can convert that
|
||||
/// same notification into the "Installed" / "Install failed" result, instead of
|
||||
/// leaving a finished progress card and raising a second notification beside it.
|
||||
/// `session_id` is `None` when the install never got as far as downloading.
|
||||
pub struct PackageInstall {
|
||||
pub session_id: Option<uuid::Uuid>,
|
||||
pub result: anyhow::Result<String>,
|
||||
}
|
||||
|
||||
pub async fn install_package(
|
||||
state: &Arc<LauncherState>,
|
||||
provider: oneclient_content::packages::ProviderId,
|
||||
project_id: &str,
|
||||
version_id: &str,
|
||||
cluster_id: i64,
|
||||
) -> anyhow::Result<String> {
|
||||
let provider_impl = state.services.packages.get(provider)?;
|
||||
let project = provider_impl
|
||||
.get_project(project_id, &state.services.content())
|
||||
.await?;
|
||||
let version = provider_impl
|
||||
.get_version(project_id, version_id, &state.services.content())
|
||||
.await?;
|
||||
) -> PackageInstall {
|
||||
let lookup = async {
|
||||
let provider_impl = state.services.packages.get(provider)?;
|
||||
let project = provider_impl
|
||||
.get_project(project_id, &state.services.content())
|
||||
.await?;
|
||||
let version = provider_impl
|
||||
.get_version(project_id, version_id, &state.services.content())
|
||||
.await?;
|
||||
anyhow::Ok((project, version))
|
||||
}
|
||||
.await;
|
||||
|
||||
let (project, version) = match lookup {
|
||||
Ok(found) => found,
|
||||
Err(err) => {
|
||||
return PackageInstall {
|
||||
session_id: None,
|
||||
result: Err(err),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let session = oneclient_events::GroupedProgressSession::start(
|
||||
&state.services.events,
|
||||
|
|
@ -191,7 +216,9 @@ pub async fn install_package(
|
|||
.await;
|
||||
|
||||
child.finish();
|
||||
session.finish();
|
||||
result?;
|
||||
Ok(project.name)
|
||||
|
||||
PackageInstall {
|
||||
session_id: Some(session.detach()),
|
||||
result: result.map(|_| project.name).map_err(anyhow::Error::from),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,11 +10,13 @@ use skia_safe::sampling_options::CubicResampler;
|
|||
use crate::Route;
|
||||
use crate::components::Button;
|
||||
use crate::components::{
|
||||
AppNavbar, DynamicArt, FileDropOverlay, Icon, IconType, OverlayPopup, ScrollArea,
|
||||
ART_PREVIEW_EDGE, AppNavbar, DynamicArt, FileDropOverlay, Icon, IconType, OverlayPopup,
|
||||
ScrollArea,
|
||||
};
|
||||
use crate::layout::AnimatedAppOutlet;
|
||||
use crate::theme;
|
||||
use crate::use_settings_snapshot;
|
||||
use oneclient_core::clusters::Cluster;
|
||||
use oneclient_db::models::ClusterId;
|
||||
|
||||
use crate::hooks::{
|
||||
|
|
@ -259,6 +261,66 @@ pub(crate) fn appshell_overlay() -> Rect {
|
|||
|
||||
pub const HOME_BACKGROUND_ASSET: &str = "backgrounds/CavesAndCliffs.jpg";
|
||||
|
||||
/// The cluster the home background is drawn from: the active one, or the first
|
||||
/// in the list before anything is active.
|
||||
fn home_cluster(clusters: &[Cluster], active: Option<ClusterId>) -> Option<&Cluster> {
|
||||
active
|
||||
.and_then(|id| clusters.iter().find(|c| c.id == id))
|
||||
.or_else(|| clusters.first())
|
||||
}
|
||||
|
||||
fn home_art(cluster: Option<&Cluster>) -> DynamicArt {
|
||||
cluster
|
||||
.map_or_else(DynamicArt::fallback, DynamicArt::for_cluster)
|
||||
.preview_edge(ART_PREVIEW_EDGE)
|
||||
}
|
||||
|
||||
/// Loads the home background art as soon as the launcher is up, rather than
|
||||
/// when Home mounts.
|
||||
///
|
||||
/// The art is three async hops deep — the cluster list, then the version
|
||||
/// manifest holding its url, then the image itself — and none of them start
|
||||
/// until something subscribes. Mounted at the root, they overlap the rest of
|
||||
/// startup while the splash curtain is still up; left to Home, they run after
|
||||
/// it has lifted and show as the built-in fallback lingering on screen.
|
||||
///
|
||||
/// The queries are global and outlive this component's subscription, so Home
|
||||
/// finds them already settled and renders the real art on its first frame.
|
||||
#[derive(PartialEq, Clone, Copy)]
|
||||
pub struct HomeArtPrefetch;
|
||||
|
||||
impl Component for HomeArtPrefetch {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
// Before the launcher is ready every query errors out, and a settled
|
||||
// error is not retried until a new subscriber mounts. Waiting means the
|
||||
// inner component's hooks run once, when they can actually succeed.
|
||||
if !use_launcher().ready {
|
||||
return rect().into_element();
|
||||
}
|
||||
HomeArtWarm.into_element()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Clone, Copy)]
|
||||
struct HomeArtWarm;
|
||||
|
||||
impl Component for HomeArtWarm {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let clusters_query = use_clusters();
|
||||
let art = {
|
||||
let reader = clusters_query.read();
|
||||
let state = reader.state();
|
||||
// No active cluster this early, so this is the first in the list —
|
||||
// the same one `AppHomeBackground` picks.
|
||||
let clusters = state.ok().map_or(&[][..], Vec::as_slice);
|
||||
home_art(home_cluster(clusters, None))
|
||||
};
|
||||
let _ = art.use_bytes();
|
||||
|
||||
rect().into_element()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Clone, Copy)]
|
||||
pub struct AppHomeBackground;
|
||||
|
||||
|
|
@ -290,12 +352,9 @@ impl Component for AppHomeBackground {
|
|||
let reader = clusters_query.read();
|
||||
let state = reader.state();
|
||||
let clusters = state.ok().map_or(&[][..], Vec::as_slice);
|
||||
let chosen = (*active_id.read())
|
||||
.and_then(|id| clusters.iter().find(|c| c.id == id))
|
||||
.or_else(|| clusters.first());
|
||||
let chosen = home_cluster(clusters, *active_id.read());
|
||||
let dep = chosen.map(|c| c.id);
|
||||
let art = chosen.map_or_else(DynamicArt::fallback, DynamicArt::for_cluster);
|
||||
(art, dep)
|
||||
(home_art(chosen), dep)
|
||||
};
|
||||
|
||||
let (_, art_bytes) = art.use_bytes();
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ mod settings_shell;
|
|||
|
||||
pub use animated_outlet::AnimatedAppOutlet;
|
||||
pub(crate) use app_shell::gradient_overlay_radial;
|
||||
pub use app_shell::{AppShell, HOME_BACKGROUND_ASSET};
|
||||
pub use app_shell::{AppShell, HOME_BACKGROUND_ASSET, HomeArtPrefetch};
|
||||
pub use cluster_shell::{ClusterShell, cluster_content};
|
||||
pub use onboarding_shell::OnboardingShell;
|
||||
pub use root_layout::RootLayout;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use crate::components::{
|
|||
StatusBar, Toasts, UpdatePromptOverlay,
|
||||
};
|
||||
use crate::hooks::{SplashState, use_provide_splash};
|
||||
use crate::layout::HomeArtPrefetch;
|
||||
use crate::routes::Route;
|
||||
use crate::theme;
|
||||
use crate::theme::colors;
|
||||
|
|
@ -73,5 +74,6 @@ impl Component for RootLayout {
|
|||
.child(ClusterUpdatePopup)
|
||||
.child(StatusBar)
|
||||
.child(SplashCurtain)
|
||||
.child(HomeArtPrefetch)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,8 +78,8 @@ pub enum Route {
|
|||
ClusterSettings { cluster_id: i64 },
|
||||
#[end_layout]
|
||||
|
||||
#[route("/app/browser/:cluster_id/:package_type")]
|
||||
Browser { cluster_id: i64, package_type: String },
|
||||
#[route("/app/browser/:cluster_id/:package_type?:pick_cluster")]
|
||||
Browser { cluster_id: i64, package_type: String, pick_cluster: bool },
|
||||
#[route("/app/browser/:cluster_id/:package_type/package/:package_id")]
|
||||
BrowserPackage { cluster_id: i64, package_type: String, package_id: String },
|
||||
#[route("/app/accounts")]
|
||||
|
|
|
|||
|
|
@ -9,10 +9,11 @@
|
|||
//! station only requires `'static`, this owns [`NotificationState`] outright
|
||||
//! rather than cloning a snapshot out of it on every event.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use freya::radio::RadioChannel;
|
||||
use oneclient_common::domain::ProviderId;
|
||||
use oneclient_core::settings::LauncherSettings;
|
||||
use oneclient_events::LaunchStage;
|
||||
|
||||
|
|
@ -34,6 +35,8 @@ pub enum AppChannel {
|
|||
AccountSwitcher,
|
||||
/// Live progress of an in-flight Microsoft sign-in.
|
||||
MicrosoftLogin,
|
||||
/// Packages currently being installed into a cluster.
|
||||
Installs,
|
||||
}
|
||||
|
||||
impl RadioChannel<AppState> for AppChannel {}
|
||||
|
|
@ -57,6 +60,31 @@ pub struct AppState {
|
|||
pub game: GameState,
|
||||
pub account_switcher_open: bool,
|
||||
pub microsoft_login: Option<LoginProgress>,
|
||||
pub installs: InstallState,
|
||||
}
|
||||
|
||||
/// Package installs that are in flight, so the button that started one can stay
|
||||
/// disabled until it lands — and come back if it failed.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct InstallState {
|
||||
pending: HashSet<(i64, ProviderId, String)>,
|
||||
}
|
||||
|
||||
impl InstallState {
|
||||
pub fn begin(&mut self, cluster_id: i64, provider: ProviderId, project_id: String) {
|
||||
self.pending.insert((cluster_id, provider, project_id));
|
||||
}
|
||||
|
||||
pub fn finish(&mut self, cluster_id: i64, provider: ProviderId, project_id: &str) {
|
||||
self.pending
|
||||
.remove(&(cluster_id, provider, project_id.to_string()));
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_installing(&self, cluster_id: i64, provider: ProviderId, project_id: &str) -> bool {
|
||||
self.pending
|
||||
.contains(&(cluster_id, provider, project_id.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
use super::*;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use freya::router::RouterContext;
|
||||
use oneclient_content::packages::ProviderId;
|
||||
use oneclient_content::packages::types::ProjectSummary;
|
||||
|
|
@ -9,13 +11,26 @@ use crate::routes::Route;
|
|||
use crate::theme::colors;
|
||||
use crate::ui::border_all_color;
|
||||
|
||||
type InstalledMap = HashMap<(ProviderId, String), Installed>;
|
||||
|
||||
fn installed_for(installed: &InstalledMap, item: &ProjectSummary) -> Option<InstallSource> {
|
||||
installed
|
||||
.get(&(item.provider, item.id.clone()))
|
||||
.map(|installed| installed.source)
|
||||
}
|
||||
|
||||
pub(super) fn grid_row(
|
||||
row: Vec<ProjectSummary>,
|
||||
cluster_id: i64,
|
||||
package_type: &str,
|
||||
installed: &InstalledMap,
|
||||
) -> impl IntoElement {
|
||||
let package_type = package_type.to_string();
|
||||
let fill = GRID_COLUMNS - row.len();
|
||||
let installed: Vec<Option<InstallSource>> = row
|
||||
.iter()
|
||||
.map(|item| installed_for(installed, item))
|
||||
.collect();
|
||||
|
||||
rect()
|
||||
.horizontal()
|
||||
|
|
@ -23,9 +38,14 @@ pub(super) fn grid_row(
|
|||
.height(Size::px(CARD_H))
|
||||
.spacing(GRID_SPACING)
|
||||
.content(Content::Flex)
|
||||
.children(row.into_iter().map(move |item| {
|
||||
PackageCard::new(item, cluster_id, package_type.clone()).into_element()
|
||||
}))
|
||||
.children(
|
||||
row.into_iter()
|
||||
.zip(installed)
|
||||
.map(move |(item, installed)| {
|
||||
PackageCard::new(item, cluster_id, package_type.clone(), installed)
|
||||
.into_element()
|
||||
}),
|
||||
)
|
||||
.maybe(fill > 0, move |mut el| {
|
||||
for _ in 0..fill {
|
||||
el = el.child(rect().width(Size::flex(1.0)));
|
||||
|
|
@ -48,14 +68,21 @@ struct PackageCard {
|
|||
item: ProjectSummary,
|
||||
cluster_id: i64,
|
||||
package_type: String,
|
||||
installed: Option<InstallSource>,
|
||||
}
|
||||
|
||||
impl PackageCard {
|
||||
fn new(item: ProjectSummary, cluster_id: i64, package_type: String) -> Self {
|
||||
fn new(
|
||||
item: ProjectSummary,
|
||||
cluster_id: i64,
|
||||
package_type: String,
|
||||
installed: Option<InstallSource>,
|
||||
) -> Self {
|
||||
Self {
|
||||
item,
|
||||
cluster_id,
|
||||
package_type,
|
||||
installed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -151,7 +178,22 @@ impl Component for PackageCard {
|
|||
.color(colors::fg_secondary()),
|
||||
),
|
||||
)
|
||||
.child(downloads_row(self.item.downloads)),
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.cross_align(Alignment::Center)
|
||||
.content(Content::Flex)
|
||||
.child(downloads_row(self.item.downloads))
|
||||
.maybe_child(self.installed.map(|installed| {
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::flex(1.0))
|
||||
.main_align(Alignment::End)
|
||||
.child(installed_badge(installed, 10.))
|
||||
.into_element()
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -160,8 +202,10 @@ pub(super) fn list_row(
|
|||
item: ProjectSummary,
|
||||
cluster_id: i64,
|
||||
package_type: &str,
|
||||
installed: &InstalledMap,
|
||||
) -> impl IntoElement {
|
||||
ListRow {
|
||||
installed: installed_for(installed, &item),
|
||||
item,
|
||||
cluster_id,
|
||||
package_type: package_type.to_string(),
|
||||
|
|
@ -173,6 +217,7 @@ struct ListRow {
|
|||
item: ProjectSummary,
|
||||
cluster_id: i64,
|
||||
package_type: String,
|
||||
installed: Option<InstallSource>,
|
||||
}
|
||||
|
||||
impl Component for ListRow {
|
||||
|
|
@ -253,6 +298,10 @@ impl Component for ListRow {
|
|||
.color(colors::fg_secondary()),
|
||||
),
|
||||
)
|
||||
.maybe_child(
|
||||
self.installed
|
||||
.map(|installed| installed_badge(installed, 11.).into_element()),
|
||||
)
|
||||
.child(downloads_row(item.downloads))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,24 +4,31 @@ use freya::animation::{
|
|||
AnimNum, Ease, Function, OnChange, OnCreation, use_animation_with_dependencies,
|
||||
};
|
||||
use freya::prelude::*;
|
||||
use freya::router::RouterContext;
|
||||
use oneclient_common::parse_mc_version;
|
||||
use oneclient_content::packages::types::ProjectSummary;
|
||||
use oneclient_content::packages::types::SearchSort;
|
||||
use oneclient_content::packages::{ContentType, ProviderId};
|
||||
use oneclient_core::VersionMetadata;
|
||||
use oneclient_core::clusters::Cluster;
|
||||
use oneclient_core::settings::ViewLayout;
|
||||
|
||||
use crate::components::{
|
||||
Icon, IconType, Pagination, ScrollArea, Segment, SegmentedControl, TextInput,
|
||||
Dropdown, Icon, IconType, Pagination, ScrollArea, Segment, SegmentedControl, TextInput,
|
||||
};
|
||||
use crate::hooks::{
|
||||
BROWSE_PAGE_SIZE, BrowserUiState, category_list, content_type_for_slug, search_items,
|
||||
search_pending, search_total, use_browser_compat, use_browser_state_store, use_debounced,
|
||||
use_package_categories, use_package_search, use_view_state,
|
||||
BROWSE_PAGE_SIZE, BrowserUiState, bundles_with_status_items, category_list,
|
||||
cluster_content_items, content_type_for_slug, pick_version_metadata, search_items,
|
||||
search_pending, search_total, settled_or_loading, use_browser_compat, use_browser_state_store,
|
||||
use_bundles_with_status, use_cluster_content, use_clusters, use_debounced,
|
||||
use_package_categories, use_package_search, use_versions, use_view_state, versions_metadata,
|
||||
};
|
||||
use crate::routes::Route;
|
||||
use crate::theme::colors;
|
||||
use crate::hooks::use_cluster;
|
||||
|
||||
use super::{PackageBanner, Thumbnail};
|
||||
use crate::utils::abbreviate_number;
|
||||
use super::{InstallSource, Installed, PackageBanner, Thumbnail, installed_badge, installed_map};
|
||||
use crate::utils::{abbreviate_number, sort_clusters_for_home};
|
||||
|
||||
mod cards;
|
||||
mod sidebar;
|
||||
|
|
@ -57,6 +64,9 @@ fn encode_package_id(provider: ProviderId, id: &str) -> String {
|
|||
pub struct Browser {
|
||||
pub cluster_id: i64,
|
||||
pub package_type: String,
|
||||
/// Entered from the navbar rather than from a cluster's content page, so
|
||||
/// the cluster it browses for is picked here instead of being decided.
|
||||
pub pick_cluster: bool,
|
||||
}
|
||||
|
||||
impl Component for Browser {
|
||||
|
|
@ -140,11 +150,29 @@ impl Component for Browser {
|
|||
let categories_query = use_package_categories(provider_id, content_type);
|
||||
let all_categories = category_list(&categories_query);
|
||||
|
||||
let installed = installed_map(
|
||||
cluster_content_items(&use_cluster_content(cluster_id, content_type)),
|
||||
&bundles_with_status_items(&use_bundles_with_status(cluster_id)),
|
||||
);
|
||||
|
||||
let packages = search_items(&search);
|
||||
let total = search_total(&search);
|
||||
let pending = search_pending(&search);
|
||||
let current_page = *page.read();
|
||||
let total_pages = total.div_ceil(BROWSE_PAGE_SIZE).max(1);
|
||||
|
||||
// A page switch re-runs the search, which reports no total until it
|
||||
// settles. Holding the last count keeps the pager in place — disabled
|
||||
// while the page loads — instead of dropping out and back in.
|
||||
let live_pages = (total > 0).then(|| total.div_ceil(BROWSE_PAGE_SIZE).max(1));
|
||||
let mut settled_pages = use_state(|| live_pages);
|
||||
if !pending {
|
||||
settled_pages.set_if_modified(live_pages);
|
||||
}
|
||||
let pages = if pending {
|
||||
*settled_pages.read()
|
||||
} else {
|
||||
live_pages
|
||||
};
|
||||
|
||||
let mode = *view_mode.read();
|
||||
|
||||
|
|
@ -171,11 +199,11 @@ impl Component for Browser {
|
|||
packages.chunks(GRID_COLUMNS).map(|c| c.to_vec()).collect();
|
||||
|
||||
sa.lazy(rows.len(), CARD_H, GRID_SPACING, move |i| {
|
||||
grid_row(rows[i].clone(), cluster_id, &pkg).into_element()
|
||||
grid_row(rows[i].clone(), cluster_id, &pkg, &installed).into_element()
|
||||
})
|
||||
}
|
||||
ViewLayout::List => sa.lazy(packages.len(), LIST_ROW_H, LIST_SPACING, move |i| {
|
||||
list_row(packages[i].clone(), cluster_id, &pkg).into_element()
|
||||
list_row(packages[i].clone(), cluster_id, &pkg, &installed).into_element()
|
||||
}),
|
||||
}
|
||||
} else if pending {
|
||||
|
|
@ -205,6 +233,10 @@ impl Component for Browser {
|
|||
.child(page_title(
|
||||
&package_type,
|
||||
cluster.as_ref().map(|c| c.name.clone()),
|
||||
self.pick_cluster.then(|| ClusterPicker {
|
||||
cluster_id,
|
||||
package_type: package_type.clone(),
|
||||
}),
|
||||
))
|
||||
.child(controls(provider, query, view_mode))
|
||||
.child(
|
||||
|
|
@ -233,13 +265,21 @@ impl Component for Browser {
|
|||
.child(results)
|
||||
.opacity(fade_opacity),
|
||||
)
|
||||
.maybe(total > 0, |el| el.child(Pagination::new(page, total_pages))),
|
||||
.maybe_child(pages.map(|pages| {
|
||||
Pagination::new(page, pages)
|
||||
.enabled(!pending)
|
||||
.into_element()
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn page_title(package_type: &str, cluster_name: Option<String>) -> impl IntoElement {
|
||||
fn page_title(
|
||||
package_type: &str,
|
||||
cluster_name: Option<String>,
|
||||
picker: Option<ClusterPicker>,
|
||||
) -> impl IntoElement {
|
||||
rect()
|
||||
.vertical()
|
||||
.spacing(2.)
|
||||
|
|
@ -250,12 +290,89 @@ fn page_title(package_type: &str, cluster_name: Option<String>) -> impl IntoElem
|
|||
.font_weight(FontWeight::BOLD)
|
||||
.color(colors::fg_primary()),
|
||||
)
|
||||
.maybe_child(cluster_name.map(|name| {
|
||||
label()
|
||||
.text(format!("for {name}"))
|
||||
.font_size(14.)
|
||||
.color(colors::fg_secondary())
|
||||
}))
|
||||
.maybe_child(match picker {
|
||||
Some(picker) => Some(picker.into_element()),
|
||||
// Without the picker the cluster is fixed, so it's just a subtitle.
|
||||
None => cluster_name.map(|name| {
|
||||
label()
|
||||
.text(format!("for {name}"))
|
||||
.font_size(14.)
|
||||
.color(colors::fg_secondary())
|
||||
.into_element()
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/// The version's display name from the manifest ("Tricky Trials"), falling back
|
||||
/// to the bare version while the manifest hasn't arrived or doesn't cover it.
|
||||
fn version_name(metadata: &[VersionMetadata], cluster: &Cluster) -> String {
|
||||
parse_mc_version(&cluster.mc_version)
|
||||
.and_then(|parsed| {
|
||||
pick_version_metadata(
|
||||
metadata,
|
||||
parsed.major,
|
||||
parsed.key(),
|
||||
Some(cluster.mc_loader),
|
||||
)
|
||||
})
|
||||
.map(|m| m.name)
|
||||
.unwrap_or_else(|| cluster.mc_version.clone())
|
||||
}
|
||||
|
||||
/// Switches which cluster the browser installs into. The choice lives in the
|
||||
/// route, so going back and forward keeps the cluster that was being browsed.
|
||||
#[derive(PartialEq)]
|
||||
struct ClusterPicker {
|
||||
cluster_id: i64,
|
||||
package_type: String,
|
||||
}
|
||||
|
||||
impl Component for ClusterPicker {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let clusters =
|
||||
sort_clusters_for_home(settled_or_loading(&use_clusters()).unwrap_or_default());
|
||||
let metadata = versions_metadata(&use_versions()).unwrap_or_default();
|
||||
|
||||
let labels: Vec<String> = clusters
|
||||
.iter()
|
||||
.map(|c| format!("{} · {}", c.name, version_name(&metadata, c)))
|
||||
.collect();
|
||||
let ids: Vec<i64> = clusters.iter().map(|c| c.id).collect();
|
||||
|
||||
let selected = ids
|
||||
.iter()
|
||||
.position(|id| *id == self.cluster_id)
|
||||
.and_then(|idx| labels.get(idx).cloned())
|
||||
.unwrap_or_else(|| "Select a version".to_string());
|
||||
|
||||
let package_type = self.package_type.clone();
|
||||
|
||||
rect()
|
||||
.horizontal()
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(8.)
|
||||
.margin(Gaps::new(4., 0., 0., 0.))
|
||||
.child(
|
||||
label()
|
||||
.text("for")
|
||||
.font_size(14.)
|
||||
.color(colors::fg_secondary()),
|
||||
)
|
||||
.child(
|
||||
Dropdown::new(selected, labels)
|
||||
.width(Size::px(240.))
|
||||
.height(Size::px(28.))
|
||||
.on_select(move |idx: usize| {
|
||||
if let Some(cluster_id) = ids.get(idx).copied() {
|
||||
let _ = RouterContext::get().replace(Route::Browser {
|
||||
cluster_id,
|
||||
package_type: package_type.clone(),
|
||||
pick_cluster: true,
|
||||
});
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn controls(
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ mod package;
|
|||
pub use index::Browser;
|
||||
pub use package::BrowserPackage;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use freya::prelude::*;
|
||||
use oneclient_content::packages::ProviderId;
|
||||
use oneclient_core::{BundleFileKind, BundleWithUpdateStatus, LinkedArtifactInfo};
|
||||
|
||||
use crate::components::{Icon, IconType};
|
||||
use crate::hooks::{loaded_image, use_cached_image};
|
||||
|
|
@ -12,6 +16,118 @@ use crate::theme::colors;
|
|||
|
||||
const BANNER_BG: Color = Color::from_rgb(21, 28, 34);
|
||||
|
||||
/// How a browsed package is already present in the cluster.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub(crate) enum InstallSource {
|
||||
/// Installed on its own.
|
||||
Manual,
|
||||
/// Comes from one of the cluster's bundles.
|
||||
Bundled,
|
||||
}
|
||||
|
||||
impl InstallSource {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Manual => "Installed",
|
||||
Self::Bundled => "Bundled",
|
||||
}
|
||||
}
|
||||
|
||||
/// Bundled reads blue rather than green: the package is there, but the
|
||||
/// cluster's bundle put it there, not the user.
|
||||
fn color(self) -> Color {
|
||||
match self {
|
||||
Self::Manual => colors::success(),
|
||||
Self::Bundled => colors::code_info(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A browsed package the cluster already has.
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub(crate) struct Installed {
|
||||
pub source: InstallSource,
|
||||
/// Which version is in there, when the provider recorded one. Used to mark
|
||||
/// the matching row in a package's version list, never shown as text.
|
||||
pub version_id: Option<String>,
|
||||
}
|
||||
|
||||
impl Installed {
|
||||
pub fn is_version(&self, version_id: &str) -> bool {
|
||||
self.version_id.as_deref() == Some(version_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Indexes what the cluster already has by the project it came from, so a
|
||||
/// search result can tell at a glance whether it's in there and how it got
|
||||
/// there. Local files have no project to match a search result against and
|
||||
/// are left out, as are a bundle's external (non-provider) files.
|
||||
pub(crate) fn installed_map(
|
||||
content: Vec<LinkedArtifactInfo>,
|
||||
bundles: &[BundleWithUpdateStatus],
|
||||
) -> HashMap<(ProviderId, String), Installed> {
|
||||
let mut map: HashMap<(ProviderId, String), Installed> = content
|
||||
.into_iter()
|
||||
.filter_map(|item| {
|
||||
Some((
|
||||
(item.provider?, item.project_id?),
|
||||
Installed {
|
||||
source: InstallSource::Manual,
|
||||
version_id: item.version_id,
|
||||
},
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Bundle membership wins: a bundle's files land in the cluster as ordinary
|
||||
// content, so they'd otherwise read as hand-installed. The version on disk
|
||||
// is the truth when there is one; the manifest's pin is the fallback.
|
||||
for bundle in bundles {
|
||||
for (file, _status) in &bundle.files {
|
||||
if let BundleFileKind::Managed {
|
||||
provider,
|
||||
project_id,
|
||||
version_id,
|
||||
..
|
||||
} = &file.kind
|
||||
{
|
||||
map.entry((*provider, project_id.clone()))
|
||||
.and_modify(|installed| installed.source = InstallSource::Bundled)
|
||||
.or_insert_with(|| Installed {
|
||||
source: InstallSource::Bundled,
|
||||
version_id: Some(version_id.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
map
|
||||
}
|
||||
|
||||
pub(crate) fn installed_badge(installed: InstallSource, font_size: f32) -> impl IntoElement {
|
||||
let color = installed.color();
|
||||
|
||||
rect()
|
||||
.horizontal()
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(4.)
|
||||
.padding(Gaps::new_symmetric(2., 8.))
|
||||
.corner_radius(CornerRadius::new_all(999.))
|
||||
.background(color.with_a(38))
|
||||
.child(
|
||||
Icon::new(IconType::CheckCircle)
|
||||
.size(font_size)
|
||||
.color(color),
|
||||
)
|
||||
.child(
|
||||
label()
|
||||
.text(installed.label())
|
||||
.font_size(font_size)
|
||||
.max_lines(1)
|
||||
.color(color),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub(crate) struct Thumbnail {
|
||||
icon_url: Option<String>,
|
||||
|
|
|
|||
|
|
@ -3,14 +3,16 @@ use oneclient_content::packages::{ContentType, ProviderId};
|
|||
|
||||
use crate::components::ScrollArea;
|
||||
use crate::hooks::{
|
||||
content_type_for_slug, project_detail, use_browser_compat, use_dispatch, use_link_confirm,
|
||||
use_package_project, use_package_versions, version_list, versions_total,
|
||||
bundles_with_status_items, cluster_content_items, content_type_for_slug, project_detail,
|
||||
use_browser_compat, use_bundles_with_status, use_cluster_content, use_dispatch,
|
||||
use_installs_snapshot, use_link_confirm, use_package_project, use_package_versions,
|
||||
version_list, versions_total,
|
||||
};
|
||||
use crate::theme::colors;
|
||||
use crate::ui::border_all_color;
|
||||
use crate::hooks::use_cluster;
|
||||
|
||||
use super::{PackageBanner, Thumbnail};
|
||||
use super::{InstallSource, Installed, PackageBanner, Thumbnail, installed_badge, installed_map};
|
||||
use crate::utils::abbreviate_number;
|
||||
|
||||
mod panels;
|
||||
|
|
@ -136,6 +138,14 @@ impl Component for BrowserPackage {
|
|||
*versions_page.read(),
|
||||
);
|
||||
|
||||
let installing = use_installs_snapshot().is_installing(cluster_id, provider, &project_id);
|
||||
|
||||
let installed = installed_map(
|
||||
cluster_content_items(&use_cluster_content(cluster_id, content_type)),
|
||||
&bundles_with_status_items(&use_bundles_with_status(cluster_id)),
|
||||
)
|
||||
.remove(&(provider, project_id.clone()));
|
||||
|
||||
let project = project_detail(&project_query);
|
||||
let versions = version_list(&versions_query);
|
||||
let total_versions = versions_total(&versions_query);
|
||||
|
|
@ -160,6 +170,8 @@ impl Component for BrowserPackage {
|
|||
project.id.clone(),
|
||||
cluster_id,
|
||||
dispatch.clone(),
|
||||
installed.clone(),
|
||||
installing,
|
||||
)
|
||||
.into_element(),
|
||||
(Some(_), _) => gallery_panel(gallery).into_element(),
|
||||
|
|
@ -178,6 +190,8 @@ impl Component for BrowserPackage {
|
|||
cluster_id,
|
||||
dispatch,
|
||||
confirm,
|
||||
installed,
|
||||
installing,
|
||||
))
|
||||
.child(
|
||||
rect()
|
||||
|
|
|
|||
|
|
@ -157,6 +157,7 @@ impl Component for GalleryTile {
|
|||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn versions_panel(
|
||||
versions: Vec<VersionSummary>,
|
||||
total_versions: usize,
|
||||
|
|
@ -165,6 +166,8 @@ pub(super) fn versions_panel(
|
|||
project_id: String,
|
||||
cluster_id: i64,
|
||||
dispatch: Actions,
|
||||
installed: Option<Installed>,
|
||||
installing: bool,
|
||||
) -> impl IntoElement {
|
||||
let current = *versions_page.read();
|
||||
let total_pages = total_versions.div_ceil(VERSIONS_PAGE_SIZE).max(1);
|
||||
|
|
@ -187,12 +190,18 @@ pub(super) fn versions_panel(
|
|||
.width(Size::fill())
|
||||
.spacing(8.)
|
||||
.children(versions.into_iter().map(move |v| {
|
||||
let tag = installed
|
||||
.as_ref()
|
||||
.filter(|installed| installed.is_version(&v.version_id))
|
||||
.map(|installed| installed.source);
|
||||
version_row(
|
||||
v,
|
||||
provider,
|
||||
project_id.clone(),
|
||||
cluster_id,
|
||||
dispatch.clone(),
|
||||
tag,
|
||||
installing,
|
||||
)
|
||||
.into_element()
|
||||
}))
|
||||
|
|
@ -258,12 +267,15 @@ fn version_pager(current: usize, total_pages: usize, page: State<usize>) -> impl
|
|||
.into_element()
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn version_row(
|
||||
v: VersionSummary,
|
||||
provider: ProviderId,
|
||||
project_id: String,
|
||||
cluster_id: i64,
|
||||
dispatch: Actions,
|
||||
installed: Option<InstallSource>,
|
||||
installing: bool,
|
||||
) -> impl IntoElement {
|
||||
let version_id = v.version_id.clone();
|
||||
let mut chips: Vec<String> = v.loaders.iter().map(|l| l.to_string()).collect();
|
||||
|
|
@ -311,10 +323,12 @@ fn version_row(
|
|||
.color(colors::fg_secondary()),
|
||||
),
|
||||
)
|
||||
.maybe_child(installed.map(|installed| installed_badge(installed, 11.).into_element()))
|
||||
.child(
|
||||
Button::new()
|
||||
.secondary()
|
||||
.small()
|
||||
.enabled(installed.is_none() && !installing)
|
||||
.on_press(move |_| {
|
||||
dispatch.install_package(
|
||||
cluster_id,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use crate::components::{Button, Icon, IconType};
|
|||
use crate::theme::colors;
|
||||
use crate::ui::border_all_color;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn sidebar(
|
||||
project: Option<ProjectDetail>,
|
||||
latest_version: Option<String>,
|
||||
|
|
@ -15,6 +16,8 @@ pub(super) fn sidebar(
|
|||
cluster_id: i64,
|
||||
dispatch: Actions,
|
||||
confirm: State<Option<String>>,
|
||||
installed: Option<Installed>,
|
||||
installing: bool,
|
||||
) -> impl IntoElement {
|
||||
let Some(project) = project else {
|
||||
return rect()
|
||||
|
|
@ -24,7 +27,13 @@ pub(super) fn sidebar(
|
|||
};
|
||||
|
||||
let project_id = project.id.clone();
|
||||
let can_install = latest_version.is_some();
|
||||
// Nothing to do when the cluster already has exactly this version, and
|
||||
// nothing to do twice while the install that was just started is running.
|
||||
let have_latest = match (&installed, &latest_version) {
|
||||
(Some(installed), Some(latest)) => installed.is_version(latest),
|
||||
_ => false,
|
||||
};
|
||||
let can_install = latest_version.is_some() && !have_latest && !installing;
|
||||
|
||||
rect()
|
||||
.vertical()
|
||||
|
|
@ -54,22 +63,12 @@ pub(super) fn sidebar(
|
|||
.max_lines(2)
|
||||
.color(colors::fg_primary()),
|
||||
)
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(4.)
|
||||
.child(Icon::new(project.provider).size(12.))
|
||||
.child(
|
||||
label()
|
||||
.text(format!(
|
||||
"{} on {}",
|
||||
project.content_type, project.provider
|
||||
))
|
||||
.font_size(11.)
|
||||
.color(colors::fg_secondary()),
|
||||
),
|
||||
)
|
||||
.child(ProviderTag {
|
||||
text: format!("{} on {}", project.content_type, project.provider),
|
||||
provider: project.provider,
|
||||
url: provider_project_url(&project),
|
||||
confirm,
|
||||
})
|
||||
.maybe(!project.summary.is_empty(), |el| {
|
||||
el.child(
|
||||
label()
|
||||
|
|
@ -101,6 +100,11 @@ pub(super) fn sidebar(
|
|||
),
|
||||
),
|
||||
)
|
||||
.maybe_child(
|
||||
installed
|
||||
.as_ref()
|
||||
.map(|installed| installed_badge(installed.source, 12.).into_element()),
|
||||
)
|
||||
.child(
|
||||
Button::new()
|
||||
.primary()
|
||||
|
|
@ -117,7 +121,13 @@ pub(super) fn sidebar(
|
|||
}
|
||||
})
|
||||
.child(Icon::new(IconType::Download01).size(14.))
|
||||
.text("Install latest"),
|
||||
.text(if installing {
|
||||
"Installing..."
|
||||
} else if have_latest {
|
||||
"Latest installed"
|
||||
} else {
|
||||
"Install latest"
|
||||
}),
|
||||
)
|
||||
.maybe(
|
||||
!project.members.is_empty() || !project.author.is_empty(),
|
||||
|
|
@ -130,11 +140,87 @@ pub(super) fn sidebar(
|
|||
.into_element()
|
||||
}
|
||||
|
||||
/// The package's own page on the provider's site. Modrinth doesn't hand one
|
||||
/// out, so it's rebuilt from the slug; CurseForge already lists it as "Website".
|
||||
fn provider_project_url(project: &ProjectDetail) -> Option<String> {
|
||||
match project.provider {
|
||||
ProviderId::Modrinth => Some(format!(
|
||||
"{}{}/{}",
|
||||
ProviderId::Modrinth.website(),
|
||||
project.content_type.modrinth_type(),
|
||||
project.slug
|
||||
)),
|
||||
ProviderId::CurseForge => project
|
||||
.links
|
||||
.iter()
|
||||
.find(|(label, _)| label == "Website")
|
||||
.map(|(_, url)| url.clone()),
|
||||
ProviderId::Local => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// "Mod on Modrinth", linking out to the package's page there when the provider
|
||||
/// has one. Plain text otherwise, so a local package doesn't look clickable.
|
||||
#[derive(PartialEq)]
|
||||
struct ProviderTag {
|
||||
text: String,
|
||||
provider: ProviderId,
|
||||
url: Option<String>,
|
||||
confirm: State<Option<String>>,
|
||||
}
|
||||
|
||||
impl Component for ProviderTag {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let a11y_id = use_a11y();
|
||||
let focus = use_focus(a11y_id);
|
||||
let focused = focus().is_focused();
|
||||
|
||||
let url = self.url.clone();
|
||||
let interactive = url.is_some();
|
||||
let mut confirm = self.confirm;
|
||||
|
||||
rect()
|
||||
.horizontal()
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(4.)
|
||||
.child(Icon::new(self.provider).size(12.))
|
||||
.child(
|
||||
label()
|
||||
.text(self.text.clone())
|
||||
.font_size(11.)
|
||||
.color(if interactive {
|
||||
colors::code_info()
|
||||
} else {
|
||||
colors::fg_secondary()
|
||||
}),
|
||||
)
|
||||
.maybe(interactive, |el| {
|
||||
let url = url.clone().unwrap_or_default();
|
||||
el.a11y_id(a11y_id)
|
||||
.a11y_focusable(true)
|
||||
.a11y_role(AccessibilityRole::Link)
|
||||
.corner_radius(CornerRadius::new_all(6.))
|
||||
.maybe(focused, |el| {
|
||||
el.border(border_all_color(1., colors::brand()))
|
||||
})
|
||||
.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
|
||||
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
|
||||
.on_all_press(move |_| confirm.set(Some(url.clone())))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn card(title: &str, rows: Vec<Element>) -> impl IntoElement {
|
||||
card_spaced(title, rows, 10.)
|
||||
}
|
||||
|
||||
/// `card` with the gap between rows chosen by the caller: a list of one-line
|
||||
/// links reads better tight than the multi-line author and detail rows do.
|
||||
fn card_spaced(title: &str, rows: Vec<Element>, spacing: f32) -> impl IntoElement {
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::fill())
|
||||
.spacing(10.)
|
||||
.spacing(spacing)
|
||||
.padding(Gaps::new_all(16.))
|
||||
.corner_radius(CornerRadius::new_all(12.))
|
||||
.background(PANEL_BG)
|
||||
|
|
@ -310,7 +396,7 @@ fn links_card(links: &[(String, String)], confirm: State<Option<String>>) -> imp
|
|||
.iter()
|
||||
.map(|(lbl, url)| link_row(lbl.clone(), url.clone(), confirm))
|
||||
.collect();
|
||||
card("Links", rows)
|
||||
card_spaced("Links", rows, 2.)
|
||||
}
|
||||
|
||||
fn link_row(label_text: String, url: String, confirm: State<Option<String>>) -> Element {
|
||||
|
|
@ -344,10 +430,10 @@ impl Component for LinkRow {
|
|||
.cross_align(Alignment::Center)
|
||||
.spacing(8.)
|
||||
.corner_radius(CornerRadius::new_all(6.))
|
||||
.padding(Gaps::new_all(4.))
|
||||
.padding(Gaps::new_symmetric(2., 4.))
|
||||
.a11y_id(a11y_id)
|
||||
.a11y_focusable(true)
|
||||
.a11y_role(AccessibilityRole::Button)
|
||||
.a11y_role(AccessibilityRole::Link)
|
||||
.maybe(focused, |el| {
|
||||
el.border(border_all_color(1., colors::brand()))
|
||||
})
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ impl Component for ClusterSettings {
|
|||
}
|
||||
.into_element(),
|
||||
)
|
||||
.child(text_row(cluster_id, TextField::JvmArgs, &profile, &global))
|
||||
.child(section_header("GAME"))
|
||||
.child(
|
||||
ToggleRow {
|
||||
|
|
@ -420,6 +421,7 @@ impl Component for ResolutionRow {
|
|||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum TextField {
|
||||
JvmArgs,
|
||||
Pre,
|
||||
Wrapper,
|
||||
Post,
|
||||
|
|
@ -428,6 +430,12 @@ enum TextField {
|
|||
impl TextField {
|
||||
fn meta(self) -> (IconType, &'static str, &'static str, &'static str) {
|
||||
match self {
|
||||
Self::JvmArgs => (
|
||||
IconType::Terminal,
|
||||
"JVM Arguments",
|
||||
"Extra arguments passed to Java. Separate them with spaces; quote values containing spaces.",
|
||||
"-XX:+UseG1GC",
|
||||
),
|
||||
Self::Pre => (
|
||||
IconType::FilePlus02,
|
||||
"Pre-Launch Command",
|
||||
|
|
@ -451,6 +459,7 @@ impl TextField {
|
|||
|
||||
fn value(self, profile: &GameSettingsProfile) -> Option<String> {
|
||||
match self {
|
||||
Self::JvmArgs => profile.launch_args.clone(),
|
||||
Self::Pre => profile.hook_pre.clone(),
|
||||
Self::Wrapper => profile.hook_wrapper.clone(),
|
||||
Self::Post => profile.hook_post.clone(),
|
||||
|
|
@ -460,6 +469,7 @@ impl TextField {
|
|||
fn patch(self, value: Patch<String>) -> ProfileUpdate {
|
||||
let mut u = ProfileUpdate::default();
|
||||
match self {
|
||||
Self::JvmArgs => u.launch_args = value,
|
||||
Self::Pre => u.hook_pre = value,
|
||||
Self::Wrapper => u.hook_wrapper = value,
|
||||
Self::Post => u.hook_post = value,
|
||||
|
|
|
|||
|
|
@ -456,7 +456,7 @@ impl Component for LogPicker {
|
|||
.position(Position::new_global().top(0.).left(0.))
|
||||
.width(Size::window_percent(100.))
|
||||
.height(Size::window_percent(100.))
|
||||
.layer(Layer::RelativeOverlay(10))
|
||||
.layer(Layer::OverlayLevel(10))
|
||||
.on_press(move |_| open.set(false)),
|
||||
)
|
||||
.child(
|
||||
|
|
@ -464,7 +464,7 @@ impl Component for LogPicker {
|
|||
.margin(Gaps::new(4., 0., 0., 0.))
|
||||
.width(Size::px(320.))
|
||||
.max_height(Size::px(360.))
|
||||
.layer(Layer::RelativeOverlay(12))
|
||||
.layer(Layer::OverlayLevel(12))
|
||||
.vertical()
|
||||
.spacing(4.)
|
||||
.padding(6.)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use oneclient_core::{BundleFileKind, BundleWithUpdateStatus, LinkedArtifactInfo}
|
|||
use oneclient_db::models::OverrideType;
|
||||
|
||||
use crate::components::{CardLayout, PackageEntry};
|
||||
use crate::hooks::{package_meta_batch, use_package_meta_batch, use_view_state};
|
||||
use crate::hooks::{package_meta_batch, use_game_snapshot, use_package_meta_batch, use_view_state};
|
||||
|
||||
mod views;
|
||||
use views::{ContentBox, ContentKind, EnabledFilter, SortMode, toolbar_bar};
|
||||
|
|
@ -324,6 +324,10 @@ impl Component for PackageManager {
|
|||
let content_type = self.content_type;
|
||||
|
||||
let tabs = build_tabs(&self.categories, &items);
|
||||
// Minecraft reads its content once at startup, so a toggle made now is
|
||||
// recorded but cannot reach the session already playing. Say so rather
|
||||
// than letting the switch look like it did nothing.
|
||||
let session_live = use_game_snapshot().is_active(cluster_id);
|
||||
let active = use_state(|| 0usize);
|
||||
let active_idx = (*active.read()).min(tabs.len().saturating_sub(1));
|
||||
|
||||
|
|
@ -377,6 +381,7 @@ impl Component for PackageManager {
|
|||
enabled_filter,
|
||||
layout,
|
||||
))
|
||||
.maybe_child(session_live.then(|| views::running_notice(noun_plural)))
|
||||
.child(ContentBox::new(
|
||||
filtered,
|
||||
noun_plural,
|
||||
|
|
|
|||
|
|
@ -175,6 +175,38 @@ pub(super) fn toolbar_bar(
|
|||
.into_element()
|
||||
}
|
||||
|
||||
/// Shown while this cluster is being played. Enabling or disabling still takes
|
||||
/// effect — it is stored, and the folder is brought in line at the next launch
|
||||
/// — but the session already running cannot pick it up.
|
||||
pub(super) fn running_notice(noun_plural: &'static str) -> Element {
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.cross_align(Alignment::Center)
|
||||
.content(Content::Flex)
|
||||
.spacing(10.)
|
||||
.margin(Gaps::new(8., 0., 0., 0.))
|
||||
.padding(Gaps::new_symmetric(9., 12.))
|
||||
.corner_radius(CornerRadius::new_all(10.))
|
||||
.background(colors::brand().with_a(30))
|
||||
.border(crate::ui::border_all_color(1., colors::brand().with_a(90)))
|
||||
.child(
|
||||
Icon::new(IconType::InfoCircle)
|
||||
.size(16.)
|
||||
.color(colors::brand()),
|
||||
)
|
||||
.child(
|
||||
label()
|
||||
.text(format!(
|
||||
"Minecraft is running. Changes to your {noun_plural} are saved, and take effect the next time you launch this version."
|
||||
))
|
||||
.font_size(12.)
|
||||
.width(Size::flex(1.0))
|
||||
.color(colors::fg_secondary()),
|
||||
)
|
||||
.into_element()
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct FilterButton {
|
||||
sort: State<Option<String>>,
|
||||
|
|
@ -313,7 +345,7 @@ impl Component for FilterPopover {
|
|||
.layer(Layer::Overlay)
|
||||
.child(
|
||||
rect()
|
||||
.layer(Layer::RelativeOverlay(10))
|
||||
.layer(Layer::OverlayLevel(10))
|
||||
.position(Position::new_global().top(0.).left(0.))
|
||||
.width(Size::window_percent(100.))
|
||||
.height(Size::window_percent(100.))
|
||||
|
|
@ -322,7 +354,7 @@ impl Component for FilterPopover {
|
|||
.child(
|
||||
rect()
|
||||
.width(Size::fill())
|
||||
.layer(Layer::RelativeOverlay(12))
|
||||
.layer(Layer::OverlayLevel(12))
|
||||
.margin(Gaps::new(6., 0., 0., -(FILTER_PANEL_W - FILTER_BTN_W)))
|
||||
.child(panel),
|
||||
)
|
||||
|
|
@ -437,6 +469,7 @@ fn browse_button(cluster_id: i64, package_type: &'static str) -> impl IntoElemen
|
|||
let _ = RouterContext::get().push(Route::Browser {
|
||||
cluster_id,
|
||||
package_type: package_type.to_string(),
|
||||
pick_cluster: false,
|
||||
});
|
||||
})
|
||||
.child(Icon::new(IconType::SearchMd).size(14.))
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@ use freya::router::RouterContext;
|
|||
use oneclient_db::console::{ConsoleQueryResult, run_console_query};
|
||||
|
||||
use oneclient_auth::preview_samples;
|
||||
use oneclient_net::status::{self, ServiceStatus};
|
||||
|
||||
use crate::components::{Button, Icon, IconType, TextInput, login_dialog, toggle};
|
||||
use crate::components::{Button, Dropdown, Icon, IconType, TextInput, login_dialog, toggle};
|
||||
use crate::hooks::use_dispatch;
|
||||
use crate::notifications::{
|
||||
ClusterUpdateItem, ClusterUpdateSummary, NotificationAction, NotificationActionKind,
|
||||
|
|
@ -76,6 +77,11 @@ impl Component for Debug {
|
|||
vec![LauncherUpdateSimulator.into_element()],
|
||||
))
|
||||
.child(divider())
|
||||
.child(section(
|
||||
"Connectivity Status Bar",
|
||||
vec![StatusBarSimulator.into_element()],
|
||||
))
|
||||
.child(divider())
|
||||
.child(section(
|
||||
"Auth Error Guidance",
|
||||
vec![AuthGuidancePreview.into_element()],
|
||||
|
|
@ -468,55 +474,140 @@ impl Component for LauncherUpdateSimulator {
|
|||
}
|
||||
}
|
||||
|
||||
/// Pins `oneclient_net::status` so the connectivity banner can be looked at
|
||||
/// without unplugging anything. Each preset is one banner the bar knows how to
|
||||
/// draw; the bar itself picks the first active issue.
|
||||
#[derive(PartialEq)]
|
||||
struct StatusBarSimulator;
|
||||
|
||||
const STATUS_PRESETS: [(&str, IconType, ServiceStatus); 4] = [
|
||||
(
|
||||
"No internet",
|
||||
IconType::Globe01,
|
||||
ServiceStatus {
|
||||
online: false,
|
||||
mc_auth_up: false,
|
||||
polyfrost_up: false,
|
||||
},
|
||||
),
|
||||
(
|
||||
"Minecraft auth down",
|
||||
IconType::AlertTriangle,
|
||||
ServiceStatus {
|
||||
online: true,
|
||||
mc_auth_up: false,
|
||||
polyfrost_up: true,
|
||||
},
|
||||
),
|
||||
(
|
||||
"Polyfrost down",
|
||||
IconType::AlertCircle,
|
||||
ServiceStatus {
|
||||
online: true,
|
||||
mc_auth_up: true,
|
||||
polyfrost_up: false,
|
||||
},
|
||||
),
|
||||
(
|
||||
"Both services down",
|
||||
IconType::AlertTriangle,
|
||||
ServiceStatus {
|
||||
online: true,
|
||||
mc_auth_up: false,
|
||||
polyfrost_up: false,
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
impl Component for StatusBarSimulator {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let mut pinned = use_state(status::forced);
|
||||
|
||||
let mut row = rect().horizontal().width(Size::fill()).spacing(12.);
|
||||
for (text, icon, forced) in STATUS_PRESETS {
|
||||
row = row.child(
|
||||
Button::new()
|
||||
.secondary()
|
||||
.child(Icon::new(icon).size(16.))
|
||||
.text(text)
|
||||
.on_press(move |_| {
|
||||
status::force(Some(forced));
|
||||
pinned.set(Some(forced));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
let current = *pinned.read();
|
||||
let state_text = match current {
|
||||
Some(_) => "Pinned. Probing still runs, but its result is not published.".to_string(),
|
||||
None => "Not pinned; showing the real probe result.".to_string(),
|
||||
};
|
||||
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::fill())
|
||||
.spacing(10.)
|
||||
.child(
|
||||
label()
|
||||
.text("Forces what the connectivity prober reports, so the real status bar renders its banner. Dismissing a banner is remembered until the issue clears.")
|
||||
.font_size(13.)
|
||||
.color(colors::fg_secondary()),
|
||||
)
|
||||
.child(row)
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(12.)
|
||||
.child(
|
||||
Button::new()
|
||||
.primary()
|
||||
.child(Icon::new(IconType::RefreshCw01).size(16.))
|
||||
.text("Clear + recheck")
|
||||
.enabled(current.is_some())
|
||||
.on_press(move |_| {
|
||||
status::force(None);
|
||||
pinned.set(None);
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
label()
|
||||
.text(state_text)
|
||||
.font_size(13.)
|
||||
.color(colors::fg_secondary()),
|
||||
),
|
||||
)
|
||||
.into_element()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct AuthGuidancePreview;
|
||||
|
||||
impl Component for AuthGuidancePreview {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let selected = use_state(|| None::<Option<usize>>);
|
||||
let samples = preview_samples();
|
||||
// Index 0 is the no-error state; the rest line up with `samples`, so a
|
||||
// dropdown holds all ~16 cases in one row instead of six rows of
|
||||
// full-width buttons.
|
||||
let mut choice = use_state(|| 0usize);
|
||||
let mut open = use_state(|| false);
|
||||
|
||||
let mut controls = rect().vertical().width(Size::fill()).spacing(10.).child(
|
||||
label()
|
||||
.text("Each button opens the real Microsoft sign-in popup exactly as users see it, in the chosen error state.")
|
||||
.font_size(13.)
|
||||
.color(colors::fg_secondary()),
|
||||
);
|
||||
let mut options: Vec<String> = vec!["No error (in progress)".to_string()];
|
||||
options.extend(samples.iter().map(|s| s.label.to_string()));
|
||||
|
||||
let mut labels: Vec<(&'static str, Option<usize>)> = vec![("No error (in progress)", None)];
|
||||
labels.extend(samples.iter().enumerate().map(|(i, s)| (s.label, Some(i))));
|
||||
let index = (*choice.read()).min(options.len() - 1);
|
||||
let selected_label = options[index].clone();
|
||||
|
||||
for chunk in labels.chunks(3) {
|
||||
let mut row = rect().horizontal().width(Size::fill()).spacing(12.);
|
||||
for (text, target) in chunk.iter().copied() {
|
||||
let mut sel = selected;
|
||||
let mut button = Button::new()
|
||||
.secondary()
|
||||
.text(text)
|
||||
.on_press(move |_| sel.set(Some(target)));
|
||||
if text.contains("No error") {
|
||||
button = button.child(Icon::new(IconType::CheckCircle).size(16.));
|
||||
} else {
|
||||
button = button.child(Icon::new(IconType::AlertTriangle).size(16.));
|
||||
}
|
||||
row = row.child(rect().width(Size::flex(1.0)).child(button));
|
||||
}
|
||||
for _ in chunk.len()..3 {
|
||||
row = row.child(rect().width(Size::flex(1.0)));
|
||||
}
|
||||
controls = controls.child(row);
|
||||
}
|
||||
|
||||
let current = *selected.read();
|
||||
let popup = current.map(|choice| {
|
||||
let (error, guidance) = match choice {
|
||||
let popup = open().then(|| {
|
||||
let (error, guidance) = match index.checked_sub(1) {
|
||||
None => (None, None),
|
||||
Some(i) => {
|
||||
let s = &samples[i];
|
||||
(Some(s.message.clone()), s.guidance.clone())
|
||||
}
|
||||
};
|
||||
let mut sel = selected;
|
||||
login_dialog(
|
||||
"https://login.live.com/oauth20_authorize (preview)".to_string(),
|
||||
"ABCD-EFGH".to_string(),
|
||||
|
|
@ -525,11 +616,42 @@ impl Component for AuthGuidancePreview {
|
|||
None,
|
||||
error,
|
||||
guidance,
|
||||
move || sel.set(None),
|
||||
move || open.set(false),
|
||||
)
|
||||
});
|
||||
|
||||
controls.maybe_child(popup).into_element()
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::fill())
|
||||
.spacing(10.)
|
||||
.child(
|
||||
label()
|
||||
.text("Opens the real Microsoft sign-in popup exactly as users see it, in the chosen error state.")
|
||||
.font_size(13.)
|
||||
.color(colors::fg_secondary()),
|
||||
)
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(12.)
|
||||
.child(
|
||||
Dropdown::new(selected_label, options)
|
||||
.width(Size::px(300.))
|
||||
.height(Size::px(34.))
|
||||
.on_select(move |idx: usize| choice.set(idx)),
|
||||
)
|
||||
.child(
|
||||
Button::new()
|
||||
.primary()
|
||||
.child(Icon::new(IconType::Eye).size(16.))
|
||||
.text("Open preview")
|
||||
.on_press(move |_| open.set(true)),
|
||||
),
|
||||
)
|
||||
.maybe_child(popup)
|
||||
.into_element()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
use freya::animation::{
|
||||
AnimNum, Ease, Function, OnChange, OnCreation, use_animation_with_dependencies,
|
||||
};
|
||||
use freya::prelude::*;
|
||||
|
||||
use super::settings_page;
|
||||
|
|
@ -71,6 +74,10 @@ impl Component for SettingsChangelog {
|
|||
}
|
||||
}
|
||||
|
||||
/// Chevron angle for a collapsed card: `ChevronDown` swung a quarter turn
|
||||
/// anticlockwise points right.
|
||||
const CHEVRON_CLOSED_DEG: f32 = -90.;
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct ReleaseCard {
|
||||
version: String,
|
||||
|
|
@ -84,6 +91,24 @@ impl Component for ReleaseCard {
|
|||
let mut open = use_state(|| self.initially_open);
|
||||
let is_open = *open.read();
|
||||
|
||||
// One chevron that swings between pointing right (collapsed) and down
|
||||
// (expanded), so the arrow tracks the section instead of cutting to a
|
||||
// different glyph that did not follow the state.
|
||||
let swing = use_animation_with_dependencies(&is_open, |conf, open| {
|
||||
conf.on_creation(OnCreation::Run);
|
||||
conf.on_change(OnChange::Rerun);
|
||||
let (from, to) = if *open {
|
||||
(CHEVRON_CLOSED_DEG, 0.)
|
||||
} else {
|
||||
(0., CHEVRON_CLOSED_DEG)
|
||||
};
|
||||
AnimNum::new(from, to)
|
||||
.time(180)
|
||||
.ease(Ease::Out)
|
||||
.function(Function::Cubic)
|
||||
});
|
||||
let chevron_deg = swing.get().value();
|
||||
|
||||
let title = if self.current {
|
||||
format!("{} (Currently Installed)", self.version)
|
||||
} else {
|
||||
|
|
@ -122,14 +147,7 @@ impl Component for ReleaseCard {
|
|||
.font_weight(FontWeight::SEMI_BOLD)
|
||||
.color(colors::fg_primary()),
|
||||
)
|
||||
.child(
|
||||
Icon::new(if is_open {
|
||||
IconType::ChevronDown
|
||||
} else {
|
||||
IconType::ChevronRight
|
||||
})
|
||||
.size(18.),
|
||||
),
|
||||
.child(Icon::new(IconType::ChevronDown).size(18.).rotate(chevron_deg)),
|
||||
)
|
||||
.maybe_child(is_open.then(|| {
|
||||
rect()
|
||||
|
|
|
|||
|
|
@ -38,6 +38,10 @@ impl Component for SettingsMinecraft {
|
|||
let v = profile.mem_max.map(|m| m.to_string()).unwrap_or_default();
|
||||
move || v
|
||||
});
|
||||
let jvm_args = use_state({
|
||||
let v = profile.launch_args.clone().unwrap_or_default();
|
||||
move || v
|
||||
});
|
||||
let pre_launch_command = use_state({
|
||||
let v = profile.hook_pre.clone().unwrap_or_default();
|
||||
move || v
|
||||
|
|
@ -58,6 +62,7 @@ impl Component for SettingsMinecraft {
|
|||
&width.read(),
|
||||
&height.read(),
|
||||
&memory.read(),
|
||||
&jvm_args.read(),
|
||||
&pre_launch_command.read(),
|
||||
&wrapper_command.read(),
|
||||
&post_exit_command.read(),
|
||||
|
|
@ -89,6 +94,14 @@ impl Component for SettingsMinecraft {
|
|||
"The amount of memory in megabytes allocated for the game.",
|
||||
memory_field(memory),
|
||||
))
|
||||
.child(settings_row(
|
||||
IconType::Terminal,
|
||||
"JVM Arguments",
|
||||
"Extra arguments passed to Java. Separate them with spaces; quote values containing spaces.",
|
||||
TextInput::new(jvm_args)
|
||||
.placeholder("-XX:+UseG1GC")
|
||||
.width(Size::px(220.)),
|
||||
))
|
||||
.child(section_header("PROCESS"))
|
||||
.child(settings_row(
|
||||
IconType::FilePlus02,
|
||||
|
|
@ -118,11 +131,13 @@ impl Component for SettingsMinecraft {
|
|||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_update(
|
||||
fullscreen: bool,
|
||||
width: &str,
|
||||
height: &str,
|
||||
memory: &str,
|
||||
jvm_args: &str,
|
||||
pre: &str,
|
||||
wrapper: &str,
|
||||
post: &str,
|
||||
|
|
@ -144,6 +159,7 @@ fn build_update(
|
|||
force_fullscreen: Patch::Set(fullscreen),
|
||||
resolution,
|
||||
mem_max,
|
||||
launch_args: command_patch(jvm_args),
|
||||
hook_pre: command_patch(pre),
|
||||
hook_wrapper: command_patch(wrapper),
|
||||
hook_post: command_patch(post),
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use std::time::Duration;
|
|||
|
||||
use oneclient_core::clusters::Cluster;
|
||||
|
||||
use crate::components::DynamicArt;
|
||||
use crate::components::{ART_PREVIEW_EDGE, DynamicArt};
|
||||
use crate::hooks::use_settings_snapshot;
|
||||
use crate::layout::gradient_overlay_radial;
|
||||
use crate::theme::colors;
|
||||
|
|
@ -102,10 +102,15 @@ impl Component for LoadingBackdrop {
|
|||
let pan_x = cx * sw * PARALLAX_STRENGTH;
|
||||
let pan_y = cy * sh * PARALLAX_STRENGTH;
|
||||
|
||||
// Left at the default edge on purpose: this is the same full-screen art
|
||||
// the home background shows next, so asking for the same variant means
|
||||
// the onboarding download is the one home renders instead of it having
|
||||
// to fetch the same picture again at a different size.
|
||||
let art = match clusters.get(current) {
|
||||
Some(cluster) => DynamicArt::for_cluster(cluster).max_edge(1280),
|
||||
None => DynamicArt::fallback().max_edge(1280),
|
||||
};
|
||||
Some(cluster) => DynamicArt::for_cluster(cluster),
|
||||
None => DynamicArt::fallback(),
|
||||
}
|
||||
.preview_edge(ART_PREVIEW_EDGE);
|
||||
|
||||
let fade = use_animation_with_dependencies(¤t, |conf, _| {
|
||||
conf.on_creation(OnCreation::Run);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use super::*;
|
|||
|
||||
use freya::router::RouterContext;
|
||||
|
||||
use crate::components::{Button, DynamicArt, Icon, IconType, ScrollArea};
|
||||
use crate::components::{ART_PREVIEW_EDGE, Button, DynamicArt, Icon, IconType, ScrollArea};
|
||||
use crate::hooks::ClusterBundles;
|
||||
use crate::routes::Route;
|
||||
use crate::theme::colors;
|
||||
|
|
@ -189,7 +189,7 @@ fn prefetch_art(items: &[ClusterBundles]) -> impl IntoElement {
|
|||
rect()
|
||||
.width(Size::px(1.))
|
||||
.height(Size::px(1.))
|
||||
.child(DynamicArt::for_cluster(&cb.cluster).max_edge(512))
|
||||
.child(DynamicArt::for_cluster(&cb.cluster).max_edge(ART_PREVIEW_EDGE))
|
||||
.into_element()
|
||||
}))
|
||||
.into_element()
|
||||
|
|
|
|||
|
|
@ -137,7 +137,8 @@ impl Component for Startup {
|
|||
.vertical()
|
||||
.spacing(14.)
|
||||
.child(
|
||||
svg(logo.read().cloned())
|
||||
SvgViewer::new(("logo.svg", logo.read().cloned()))
|
||||
.show_loader(false)
|
||||
.width(Size::px(288.))
|
||||
.height(Size::px(60.))
|
||||
.color(colors::fg_primary()),
|
||||
|
|
|
|||
|
|
@ -465,12 +465,16 @@ pub async fn on_user_remove_artifact(
|
|||
/// storage half and knows nothing about bundles; this adds the bookkeeping that
|
||||
/// only makes sense once bundles exist.
|
||||
#[tracing::instrument(level = "debug", skip(ctx))]
|
||||
///
|
||||
/// `live` says the cluster is currently being played; see
|
||||
/// [`PackageStore::set_artifact_enabled`].
|
||||
pub async fn toggle_artifact_enabled(
|
||||
cluster_id: i64,
|
||||
hash: &str,
|
||||
live: bool,
|
||||
ctx: &ContentCtx,
|
||||
) -> ContentResult<bool> {
|
||||
let enabled = PackageStore::set_artifact_enabled(cluster_id, hash, ctx).await?;
|
||||
let enabled = PackageStore::set_artifact_enabled(cluster_id, hash, live, ctx).await?;
|
||||
|
||||
if enabled {
|
||||
on_user_enable_artifact(cluster_id, hash, ctx).await?;
|
||||
|
|
|
|||
|
|
@ -295,6 +295,15 @@ async fn check_bundle_updates_inner(
|
|||
)
|
||||
});
|
||||
|
||||
let addition_eligible_bundles = addition_eligible_bundles(
|
||||
ctx,
|
||||
&bundle_packages,
|
||||
&all_linked,
|
||||
&candidate_keys_by_bundle,
|
||||
overrides,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut additions_available = Vec::new();
|
||||
let mut planned_addition_keys = all_installed_managed_keys.clone();
|
||||
for hash in all_installed_external_hashes {
|
||||
|
|
@ -302,7 +311,7 @@ async fn check_bundle_updates_inner(
|
|||
}
|
||||
|
||||
for archive in &archives {
|
||||
if !subscribed_bundles.contains(&archive.manifest.name) {
|
||||
if !addition_eligible_bundles.contains(&archive.manifest.name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -547,7 +556,7 @@ async fn reconcile_update(
|
|||
) -> ContentResult<()> {
|
||||
let file_id = update.new_file.kind.package_id();
|
||||
if should_be_disabled(&update.bundle_name, &file_id, overrides) {
|
||||
toggle_artifact_enabled(update.cluster_id, hash, ctx).await?;
|
||||
toggle_artifact_enabled(update.cluster_id, hash, false, ctx).await?;
|
||||
}
|
||||
|
||||
if hash == update.installed_hash {
|
||||
|
|
@ -566,7 +575,7 @@ async fn reconcile_addition(
|
|||
) -> ContentResult<()> {
|
||||
let file_id = addition.new_file.kind.package_id();
|
||||
if should_be_disabled(&addition.bundle_name, &file_id, overrides) {
|
||||
toggle_artifact_enabled(addition.cluster_id, hash, ctx).await?;
|
||||
toggle_artifact_enabled(addition.cluster_id, hash, false, ctx).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
|
@ -771,10 +780,55 @@ fn bundle_package_key(
|
|||
}
|
||||
}
|
||||
|
||||
/// Bundles that may still take on files the catalog has newly added.
|
||||
///
|
||||
/// Subscription is deliberately looser than this: a bundle whose mods are all
|
||||
/// installed-but-disabled still wants version updates and still owns its files.
|
||||
/// New content is different. Removing or disabling everything a bundle ships is
|
||||
/// how a user opts out of that bundle, and an absent per-file override is not
|
||||
/// consent for a file that did not exist when they opted out — so gating
|
||||
/// additions on subscription alone let one catalog addition quietly reinstate a
|
||||
/// bundle the user had emptied.
|
||||
///
|
||||
/// A bundle qualifies while any of it is still live here: a tracked artifact
|
||||
/// that is enabled, an enabled artifact that can only have come from this bundle
|
||||
/// (the untracked/migrated case), or an explicit opt-in override.
|
||||
#[tracing::instrument(level = "debug", skip_all)]
|
||||
async fn installed_bundle_keys(
|
||||
async fn addition_eligible_bundles(
|
||||
ctx: &ContentCtx,
|
||||
linked: &[LinkedArtifactInfo],
|
||||
bundle_packages: &[BundleTrackedArtifactRow],
|
||||
all_linked: &[LinkedArtifactInfo],
|
||||
candidate_keys_by_bundle: &HashMap<String, HashSet<String>>,
|
||||
overrides: &[ClusterBundleOverrideRow],
|
||||
) -> ContentResult<HashSet<String>> {
|
||||
let (live_managed_keys, _) =
|
||||
installed_bundle_keys(ctx, all_linked.iter().filter(|item| item.enabled)).await?;
|
||||
|
||||
let mut eligible: HashSet<String> = bundle_packages
|
||||
.iter()
|
||||
.filter(|bp| bp.enabled != 0)
|
||||
.filter_map(|bp| bp.bundle_name.clone())
|
||||
.collect();
|
||||
|
||||
eligible.extend(infer_bundle_names_from_unique_installed_keys(
|
||||
candidate_keys_by_bundle,
|
||||
&live_managed_keys,
|
||||
));
|
||||
|
||||
eligible.extend(
|
||||
overrides
|
||||
.iter()
|
||||
.filter(|o| OverrideType::parse(&o.override_type) == Some(OverrideType::Enabled))
|
||||
.map(|o| o.bundle_name.clone()),
|
||||
);
|
||||
|
||||
Ok(eligible)
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip_all)]
|
||||
async fn installed_bundle_keys<'a>(
|
||||
ctx: &ContentCtx,
|
||||
linked: impl IntoIterator<Item = &'a LinkedArtifactInfo>,
|
||||
) -> ContentResult<(HashSet<String>, HashSet<String>)> {
|
||||
let mut managed = HashSet::new();
|
||||
let mut external = HashSet::new();
|
||||
|
|
|
|||
|
|
@ -62,19 +62,53 @@ pub async fn link_or_copy(src: &Path, dest: &Path) -> ContentResult<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether the cluster folder is currently holding `file_name`, following the
|
||||
/// same symlink-tolerant rule as [`unlink_cluster_file`].
|
||||
pub async fn cluster_file_present(
|
||||
cluster: &ClusterRow,
|
||||
content_type: ContentType,
|
||||
file_name: &str,
|
||||
) -> bool {
|
||||
let Ok(clusters) = paths::clusters_dir() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let path = clusters
|
||||
.join(&cluster.folder_name)
|
||||
.join(content_type.folder_name())
|
||||
.join(file_name);
|
||||
|
||||
polyio::symlink_metadata(&path).await.is_ok()
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip(cluster))]
|
||||
pub async fn unlink_cluster_file(
|
||||
cluster: &ClusterRow,
|
||||
content_type: ContentType,
|
||||
file_name: &str,
|
||||
) -> ContentResult<()> {
|
||||
let path = paths::clusters_dir()?
|
||||
let dir = paths::clusters_dir()?
|
||||
.join(&cluster.folder_name)
|
||||
.join(content_type.folder_name())
|
||||
.join(file_name);
|
||||
.join(content_type.folder_name());
|
||||
|
||||
if path.exists() {
|
||||
polyio::remove_file(&path).await?;
|
||||
}
|
||||
// Both spellings: the database always stores the bare name, but the folder
|
||||
// can still be holding a `<name>.disabled` left by an older launcher or by
|
||||
// the user renaming it by hand. Removing a package has to take both.
|
||||
remove_entry(&dir.join(file_name)).await?;
|
||||
remove_entry(&dir.join(format!("{file_name}.disabled"))).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Deletes `path` if anything is there, symlink or not.
|
||||
///
|
||||
/// `Path::exists` resolves symlinks, so a link whose artifact was evicted from
|
||||
/// the cache reads as absent and the dead link survives every unlink; the
|
||||
/// package then keeps showing up in the folder it was removed from.
|
||||
async fn remove_entry(path: &Path) -> ContentResult<()> {
|
||||
if polyio::symlink_metadata(path).await.is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
polyio::remove_file(path).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,6 +157,7 @@ impl PackageStore {
|
|||
content_type,
|
||||
file_name: artifact.file_name,
|
||||
project_id: release.as_ref().map(|r| r.project_id.clone()),
|
||||
version_id: release.as_ref().map(|r| r.version_id.clone()),
|
||||
display_name: release.as_ref().map(|r| r.display_name.clone()),
|
||||
display_version: release.as_ref().map(|r| r.display_version.clone()),
|
||||
provider: release
|
||||
|
|
@ -172,6 +173,12 @@ impl PackageStore {
|
|||
/// Flips an artifact's enabled flag and relinks it on disk, returning the
|
||||
/// new state.
|
||||
///
|
||||
/// `live` says a session is currently playing this cluster. Minecraft reads
|
||||
/// its mods once at startup and holds the jars open, so the on-disk half
|
||||
/// cannot take effect and, on Windows, cannot even be performed. The flag is
|
||||
/// recorded either way and [`Self::reconcile_cluster_links`] settles the
|
||||
/// folder at the next launch, so the choice survives instead of failing.
|
||||
///
|
||||
/// Storage only: bundle override bookkeeping is composed on top by
|
||||
/// [`crate::bundles::toggle_artifact_enabled`], because that is the layer that
|
||||
/// knows about bundles. Calling up into `bundles` from here would make the two
|
||||
|
|
@ -179,6 +186,7 @@ impl PackageStore {
|
|||
pub async fn set_artifact_enabled(
|
||||
cluster_id: i64,
|
||||
hash: &str,
|
||||
live: bool,
|
||||
ctx: &ContentCtx,
|
||||
) -> ContentResult<bool> {
|
||||
let cluster = Self::get_cluster(cluster_id, ctx).await?;
|
||||
|
|
@ -201,15 +209,8 @@ impl PackageStore {
|
|||
.trim_end_matches(".disabled")
|
||||
.to_string();
|
||||
|
||||
if enabled {
|
||||
link::link_artifact_to_cluster(&artifact, &cluster, Some(&file_name)).await?;
|
||||
} else {
|
||||
link::unlink_cluster_file(&cluster, content_type, &link.cluster_file_name).await?;
|
||||
if link.cluster_file_name != file_name {
|
||||
link::unlink_cluster_file(&cluster, content_type, &file_name).await?;
|
||||
}
|
||||
}
|
||||
|
||||
// The database is the source of truth, so it goes first: a folder that
|
||||
// cannot be touched right now must not cost the user their choice.
|
||||
artifact_dao::update_cluster_artifact(
|
||||
&ctx.db,
|
||||
cluster_id,
|
||||
|
|
@ -219,9 +220,80 @@ impl PackageStore {
|
|||
)
|
||||
.await?;
|
||||
|
||||
let applied = apply_enabled_to_disk(
|
||||
&artifact,
|
||||
&cluster,
|
||||
content_type,
|
||||
&link.cluster_file_name,
|
||||
&file_name,
|
||||
enabled,
|
||||
)
|
||||
.await;
|
||||
|
||||
match applied {
|
||||
Ok(()) => {}
|
||||
Err(err) if live => tracing::info!(
|
||||
cluster_id,
|
||||
hash,
|
||||
error = %err,
|
||||
"cluster folder is in use by a running game; the change applies at the next launch"
|
||||
),
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
|
||||
Ok(enabled)
|
||||
}
|
||||
|
||||
/// Makes the cluster folder match the database: every enabled artifact
|
||||
/// linked in, every disabled one gone.
|
||||
///
|
||||
/// Toggles made while the cluster was being played only reached the
|
||||
/// database, so this is what lands them. Files already in the right state
|
||||
/// are left alone, and failures are logged rather than returned: one
|
||||
/// unwritable file must not stop a launch.
|
||||
#[tracing::instrument(level = "debug", skip(ctx))]
|
||||
pub async fn reconcile_cluster_links(cluster_id: i64, ctx: &ContentCtx) -> ContentResult<()> {
|
||||
let cluster = Self::get_cluster(cluster_id, ctx).await?;
|
||||
|
||||
for link in artifact_dao::list_cluster_artifacts(&ctx.db, cluster_id).await? {
|
||||
let Some(artifact) = artifact_dao::get_artifact_by_hash(&ctx.db, &link.hash).await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(content_type) = ContentType::from_repr(artifact.content_type as u8) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let enabled = link.enabled != 0;
|
||||
let present =
|
||||
link::cluster_file_present(&cluster, content_type, &link.cluster_file_name).await;
|
||||
if enabled == present {
|
||||
continue;
|
||||
}
|
||||
|
||||
let result = apply_enabled_to_disk(
|
||||
&artifact,
|
||||
&cluster,
|
||||
content_type,
|
||||
&link.cluster_file_name,
|
||||
&link.cluster_file_name,
|
||||
enabled,
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Err(err) = result {
|
||||
tracing::warn!(
|
||||
cluster_id,
|
||||
file = %link.cluster_file_name,
|
||||
error = %err,
|
||||
"failed to reconcile cluster link"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip(ctx))]
|
||||
pub async fn import_local_file(
|
||||
path: &Path,
|
||||
|
|
@ -320,6 +392,30 @@ async fn row_to_cached(
|
|||
})
|
||||
}
|
||||
|
||||
/// Brings one artifact's presence in the cluster folder in line with `enabled`.
|
||||
///
|
||||
/// `current_file_name` is what the folder may be holding, `file_name` the name
|
||||
/// the link should carry; they differ only while a legacy `.disabled` spelling
|
||||
/// is being cleaned up.
|
||||
async fn apply_enabled_to_disk(
|
||||
artifact: &ArtifactRow,
|
||||
cluster: &ClusterRow,
|
||||
content_type: ContentType,
|
||||
current_file_name: &str,
|
||||
file_name: &str,
|
||||
enabled: bool,
|
||||
) -> ContentResult<()> {
|
||||
if enabled {
|
||||
return link::link_artifact_to_cluster(artifact, cluster, Some(file_name)).await;
|
||||
}
|
||||
|
||||
link::unlink_cluster_file(cluster, content_type, current_file_name).await?;
|
||||
if current_file_name != file_name {
|
||||
link::unlink_cluster_file(cluster, content_type, file_name).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_compatible(
|
||||
project: &ProjectDetail,
|
||||
version: &VersionDetail,
|
||||
|
|
|
|||
|
|
@ -200,6 +200,7 @@ pub struct LinkedArtifactInfo {
|
|||
pub content_type: ContentType,
|
||||
pub file_name: String,
|
||||
pub project_id: Option<String>,
|
||||
pub version_id: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
pub display_version: Option<String>,
|
||||
pub provider: Option<ProviderId>,
|
||||
|
|
|
|||
|
|
@ -213,6 +213,19 @@ pub async fn launch_cluster(
|
|||
tracing::warn!(cluster_id, error = %err, "failed to write allowed_symlinks.txt");
|
||||
}
|
||||
|
||||
// Settle any enable/disable made while a previous session held these files
|
||||
// open. Shared clusters get their game dir rebuilt from the database below
|
||||
// anyway; this is what makes the cluster folder itself — and so a dedicated
|
||||
// cluster's game dir — agree with what the UI has been showing.
|
||||
if let Err(err) = oneclient_content::packages::PackageStore::reconcile_cluster_links(
|
||||
cluster_id,
|
||||
&state.services.content(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(cluster_id, error = %err, "failed to reconcile cluster content links");
|
||||
}
|
||||
|
||||
if !dedicated {
|
||||
if let Err(err) = crate::game::sync_shared_content(&state.services, &cluster, &cwd).await {
|
||||
tracing::warn!(cluster_id, error = %err, "failed to sync shared content");
|
||||
|
|
|
|||
|
|
@ -126,6 +126,21 @@ pub async fn import_manual_content(
|
|||
continue;
|
||||
}
|
||||
|
||||
// The shared dir is rebuilt from the database on every launch, so
|
||||
// anything here that the launcher already has in its artifact cache
|
||||
// is a leftover from a session whose cleanup never ran — a crash, or
|
||||
// a bundle update that dropped the package in between. Importing it
|
||||
// would re-link the package the update just removed, and the next
|
||||
// launch would put it back in the game. Drop it instead; the cache
|
||||
// still holds the file if it is ever wanted again.
|
||||
if is_cached_artifact(services, &path).await {
|
||||
tracing::debug!(file = name, "discarding stale launcher content in shared dir");
|
||||
if let Err(err) = polyio::remove_file(&path).await {
|
||||
tracing::warn!(file = name, error = %err, "failed to discard stale shared content");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
match PackageStore::import_local_file(&path, content_type, cluster.id, &services.content()).await {
|
||||
Ok(_) => {
|
||||
tracing::debug!(file = name, "registered manually-added shared content")
|
||||
|
|
@ -140,6 +155,21 @@ pub async fn import_manual_content(
|
|||
}
|
||||
}
|
||||
|
||||
/// Whether `path` is a copy of something already in the launcher's artifact
|
||||
/// cache, i.e. content the launcher itself put in the shared dir rather than
|
||||
/// something the user dropped there.
|
||||
async fn is_cached_artifact(services: &LauncherServices, path: &Path) -> bool {
|
||||
let Ok(hash) = polyio::sha1_file(path).await else {
|
||||
return false;
|
||||
};
|
||||
|
||||
artifact_dao::get_artifact_by_hash(&services.db, &polyio::normalize_hash(&hash))
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some()
|
||||
}
|
||||
|
||||
fn has_content_extension(content_type: ContentType, name: &str) -> bool {
|
||||
let lower = name.to_lowercase();
|
||||
match content_type {
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ impl LauncherState {
|
|||
clusters,
|
||||
metadata: Mutex::new(MetadataStore::new()),
|
||||
bundles: Arc::new(BundlesManager::new()),
|
||||
versions: Arc::new(VersionsManager::new()),
|
||||
versions: Arc::new(VersionsManager::from_cache().await),
|
||||
images: ImageCacheStore::new(),
|
||||
games: crate::game::GameProcessManager::new(),
|
||||
discord,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,36 @@ impl VersionsManager {
|
|||
}
|
||||
}
|
||||
|
||||
/// Seeds the manifest from the last synced copy on disk.
|
||||
///
|
||||
/// Without this the manifest is empty until the network answers, and
|
||||
/// everything read off it — a version's art, name, description — waits on a
|
||||
/// request that, for a returning user, ends in a 304 and the very same file.
|
||||
/// The UI would rather render slightly stale metadata now and pick up the
|
||||
/// sync when it lands.
|
||||
#[must_use]
|
||||
pub async fn from_cache() -> Self {
|
||||
let manifest = Self::cached_manifest()
|
||||
.await
|
||||
.unwrap_or_else(VersionsManifest::default);
|
||||
|
||||
Self {
|
||||
manifest: RwLock::new(manifest),
|
||||
}
|
||||
}
|
||||
|
||||
async fn cached_manifest() -> Option<VersionsManifest> {
|
||||
let path = paths::caches_dir().ok()?.join("versions.json");
|
||||
let bytes = polyio::read(&path).await.ok()?;
|
||||
match serde_json::from_slice(&bytes) {
|
||||
Ok(manifest) => Some(manifest),
|
||||
Err(err) => {
|
||||
tracing::warn!("cached versions manifest is unreadable: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip(self, services))]
|
||||
pub async fn sync(&self, services: &LauncherServices) -> LauncherResult<bool> {
|
||||
let Some((manifest, changed)) = Self::fetch_manifest(services).await? else {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,23 @@ fn managed_file(enabled: bool) -> BundleFile {
|
|||
}
|
||||
}
|
||||
|
||||
/// A file the catalog added after the cluster was last synced: nothing on the
|
||||
/// cluster tracks it, so it has no override either.
|
||||
fn newly_shipped_file() -> BundleFile {
|
||||
BundleFile {
|
||||
enabled: true,
|
||||
hidden: false,
|
||||
path: "mods/newcomer.jar".to_string(),
|
||||
size: 1,
|
||||
kind: BundleFileKind::Managed {
|
||||
provider: ProviderId::Modrinth,
|
||||
project_id: "newcomer".to_string(),
|
||||
version_id: "v1".to_string(),
|
||||
sha1: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn manifest(files: Vec<BundleFile>) -> BundleManifest {
|
||||
BundleManifest {
|
||||
name: BUNDLE.to_string(),
|
||||
|
|
@ -196,3 +213,150 @@ async fn user_disabled_mod_is_not_treated_as_a_removal() {
|
|||
check.removals_available
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn live_bundle_takes_on_new_catalog_files() {
|
||||
let state = oneclient_core::dev::ephemeral_state().await.unwrap();
|
||||
oneclient_core::dev::seed_bundle_archive(
|
||||
&state,
|
||||
manifest(vec![managed_file(true), newly_shipped_file()]),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let cluster_id = cluster_with_tracked_mod(&state).await;
|
||||
|
||||
let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services.content())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
check.additions_available.len(),
|
||||
1,
|
||||
"a bundle the user still has enabled should pick up newly shipped files"
|
||||
);
|
||||
assert_eq!(
|
||||
check.additions_available[0].new_file.kind.package_id(),
|
||||
"newcomer"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn emptied_bundle_does_not_take_on_new_catalog_files() {
|
||||
let state = oneclient_core::dev::ephemeral_state().await.unwrap();
|
||||
oneclient_core::dev::seed_bundle_archive(
|
||||
&state,
|
||||
manifest(vec![managed_file(true), newly_shipped_file()]),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let cluster_id = cluster_with_tracked_mod(&state).await;
|
||||
|
||||
// The user disabled everything this bundle shipped, which is how you opt out
|
||||
// of it. The newcomer has no override of its own precisely because it did not
|
||||
// exist yet, and that absence must not read as consent.
|
||||
artifact_dao::update_cluster_artifact(&state.services.db, cluster_id, HASH, "sodium.jar", 0)
|
||||
.await
|
||||
.unwrap();
|
||||
bundle_dao::save_override(
|
||||
&state.services.db,
|
||||
cluster_id,
|
||||
BUNDLE,
|
||||
PROJECT_ID,
|
||||
OverrideType::Disabled,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services.content())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
check.additions_available.is_empty(),
|
||||
"a bundle whose content the user disabled must not reinstate itself: {:?}",
|
||||
check
|
||||
.additions_available
|
||||
.iter()
|
||||
.map(|a| a.new_file.kind.package_id())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removed_bundle_content_does_not_take_on_new_catalog_files() {
|
||||
let state = oneclient_core::dev::ephemeral_state().await.unwrap();
|
||||
oneclient_core::dev::seed_bundle_archive(
|
||||
&state,
|
||||
manifest(vec![managed_file(true), newly_shipped_file()]),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let cluster_id = cluster_with_tracked_mod(&state).await;
|
||||
|
||||
oneclient_content::bundles::remove_artifact_from_cluster(
|
||||
cluster_id,
|
||||
HASH,
|
||||
true,
|
||||
&state.services.content(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services.content())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
check.additions_available.is_empty(),
|
||||
"a bundle the user emptied by removal must not reinstate itself: {:?}",
|
||||
check
|
||||
.additions_available
|
||||
.iter()
|
||||
.map(|a| a.new_file.kind.package_id())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn opting_a_single_file_in_keeps_the_bundle_live() {
|
||||
let state = oneclient_core::dev::ephemeral_state().await.unwrap();
|
||||
oneclient_core::dev::seed_bundle_archive(
|
||||
&state,
|
||||
manifest(vec![managed_file(true), newly_shipped_file()]),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let cluster_id = cluster_with_tracked_mod(&state).await;
|
||||
|
||||
artifact_dao::update_cluster_artifact(&state.services.db, cluster_id, HASH, "sodium.jar", 0)
|
||||
.await
|
||||
.unwrap();
|
||||
bundle_dao::save_override(
|
||||
&state.services.db,
|
||||
cluster_id,
|
||||
BUNDLE,
|
||||
PROJECT_ID,
|
||||
OverrideType::Disabled,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
bundle_dao::save_override(
|
||||
&state.services.db,
|
||||
cluster_id,
|
||||
BUNDLE,
|
||||
"newcomer",
|
||||
OverrideType::Enabled,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services.content())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
check.additions_available.len(),
|
||||
1,
|
||||
"an explicit opt-in is consent, even with the rest of the bundle disabled"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,14 +52,49 @@ pub fn java_arguments(
|
|||
}
|
||||
|
||||
parsed.push(format!("-Xmx{mem_max}M"));
|
||||
|
||||
if !custom_args.is_empty() {
|
||||
parsed.push(custom_args);
|
||||
}
|
||||
parsed.extend(split_custom_args(&custom_args));
|
||||
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
/// Splits a user-entered JVM argument string into individual arguments.
|
||||
///
|
||||
/// Whitespace-separated, with double quotes for values that contain spaces
|
||||
/// (`-Dfoo="a b"`), which is what people expect from every other launcher.
|
||||
/// Pushing the whole string as one argv entry, as this used to, means the JVM
|
||||
/// sees a single unrecognised option as soon as there is more than one.
|
||||
fn split_custom_args(raw: &str) -> Vec<String> {
|
||||
let mut args = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut quoted = false;
|
||||
let mut started = false;
|
||||
|
||||
for ch in raw.chars() {
|
||||
match ch {
|
||||
'"' => {
|
||||
quoted = !quoted;
|
||||
started = true;
|
||||
}
|
||||
c if c.is_whitespace() && !quoted => {
|
||||
if started {
|
||||
args.push(std::mem::take(&mut current));
|
||||
started = false;
|
||||
}
|
||||
}
|
||||
c => {
|
||||
current.push(c);
|
||||
started = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if started {
|
||||
args.push(current);
|
||||
}
|
||||
|
||||
args
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn minecraft_arguments(
|
||||
version_updated: bool,
|
||||
|
|
@ -415,3 +450,35 @@ where
|
|||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::split_custom_args;
|
||||
|
||||
#[test]
|
||||
fn blank_input_contributes_nothing() {
|
||||
assert!(split_custom_args("").is_empty());
|
||||
assert!(split_custom_args(" \t ").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_flag_becomes_its_own_argument() {
|
||||
assert_eq!(
|
||||
split_custom_args("-XX:+UseG1GC -Xms2G"),
|
||||
vec!["-XX:+UseG1GC", "-Xms2G"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quotes_hold_a_value_with_spaces_together() {
|
||||
assert_eq!(
|
||||
split_custom_args(r#"-Dname="My Server" -Xss1M"#),
|
||||
vec!["-Dname=My Server", "-Xss1M"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_quoted_value_survives() {
|
||||
assert_eq!(split_custom_args(r#"-Dempty="""#), vec!["-Dempty="]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use std::sync::OnceLock;
|
||||
use std::sync::{OnceLock, RwLock};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
|
|
@ -34,6 +34,9 @@ struct Connectivity {
|
|||
rx: watch::Receiver<ServiceStatus>,
|
||||
notify: Notify,
|
||||
started: AtomicBool,
|
||||
/// Debug-only pin. While set, probe results are recorded but never
|
||||
/// published, so the forced status survives a recheck.
|
||||
forced: RwLock<Option<ServiceStatus>>,
|
||||
}
|
||||
|
||||
fn connectivity() -> &'static Connectivity {
|
||||
|
|
@ -45,10 +48,34 @@ fn connectivity() -> &'static Connectivity {
|
|||
rx,
|
||||
notify: Notify::new(),
|
||||
started: AtomicBool::new(false),
|
||||
forced: RwLock::new(None),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Pins the reported status to `status`, or releases the pin with `None` and
|
||||
/// re-probes. Exposed for the debug page: the connectivity banner is otherwise
|
||||
/// only reachable by actually taking a service down.
|
||||
pub fn force(status: Option<ServiceStatus>) {
|
||||
let conn = connectivity();
|
||||
*conn.forced.write().unwrap_or_else(|e| e.into_inner()) = status;
|
||||
|
||||
match status {
|
||||
Some(status) => {
|
||||
let _ = conn.tx.send(status);
|
||||
}
|
||||
None => request_recheck(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn forced() -> Option<ServiceStatus> {
|
||||
*connectivity()
|
||||
.forced
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
pub fn subscribe() -> watch::Receiver<ServiceStatus> {
|
||||
connectivity().rx.clone()
|
||||
}
|
||||
|
|
@ -78,7 +105,10 @@ pub fn start(client: RequestClient) {
|
|||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let _ = conn.tx.send(check_service_status(&client).await);
|
||||
let probed = check_service_status(&client).await;
|
||||
if forced().is_none() {
|
||||
let _ = conn.tx.send(probed);
|
||||
}
|
||||
conn.notify.notified().await;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue