diff --git a/Cargo.lock b/Cargo.lock index 0b9841a..3bf510d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -249,6 +249,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "arboard" version = "3.6.1" @@ -287,6 +296,7 @@ dependencies = [ "postgresql_embedded", "rand 0.9.2", "rfd", + "self_update", "serde", "serde_json", "sha2", @@ -2658,6 +2668,19 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + [[package]] name = "console-api" version = "0.9.0" @@ -3187,6 +3210,33 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.112", +] + [[package]] name = "darling" version = "0.20.11" @@ -3304,6 +3354,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.112", +] + [[package]] name = "derive_builder" version = "0.20.2" @@ -4192,6 +4253,31 @@ dependencies = [ "emath", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "signature", + "subtle", + "zeroize", +] + [[package]] name = "ego-tree" version = "0.10.0" @@ -4264,6 +4350,12 @@ dependencies = [ "syn 2.0.112", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -4462,6 +4554,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "field-offset" version = "0.3.6" @@ -5958,6 +6056,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width", + "web-time", +] + [[package]] name = "indoc" version = "2.0.7" @@ -7093,6 +7204,12 @@ dependencies = [ "libc", ] +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + [[package]] name = "objc" version = "0.2.7" @@ -8373,6 +8490,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -8773,8 +8899,10 @@ dependencies = [ "bytes", "cookie", "cookie_store", + "futures-channel", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", @@ -9377,12 +9505,47 @@ dependencies = [ "smallvec", ] +[[package]] +name = "self-replace" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ec815b5eab420ab893f63393878d89c90fdd94c0bcc44c07abb8ad95552fb7" +dependencies = [ + "fastrand", + "tempfile", + "windows-sys 0.52.0", +] + [[package]] name = "self_cell" version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" +[[package]] +name = "self_update" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d832c086ece0dacc29fb2947bb4219b8f6e12fe9e40b7108f9e57c4224e47b5c" +dependencies = [ + "either", + "flate2", + "hyper", + "indicatif", + "log", + "quick-xml 0.37.5", + "regex", + "reqwest 0.12.28", + "self-replace", + "semver", + "serde_json", + "tar", + "tempfile", + "urlencoding", + "zip", + "zipsign-api", +] + [[package]] name = "semver" version = "1.0.27" @@ -11662,7 +11825,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.38.4", "quote", ] @@ -12992,12 +13155,53 @@ dependencies = [ "syn 2.0.112", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap 2.12.1", + "memchr", + "thiserror 2.0.18", + "time", + "zopfli", +] + +[[package]] +name = "zipsign-api" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dba6063ff82cdbd9a765add16d369abe81e520f836054e997c2db217ceca40c0" +dependencies = [ + "base64 0.22.1", + "ed25519-dalek", + "thiserror 2.0.18", +] + [[package]] name = "zmij" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3280a1b827474fcd5dbef4b35a674deb52ba5c312363aef9135317df179d81b" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index 7da5b03..3979453 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,6 +65,7 @@ regex = "1.12.3" rfd = { version = "0.17.2", default-features = false } rust_decimal = { version = "1.40.0", features = ["db-postgres", "serde"] } scraper = "0.25.0" +self_update = { version = "0.42", default-features = false, features = ["archive-tar", "archive-zip", "compression-flate2", "compression-zip-deflate", "rustls"] } serde = { version = "1.0.228", features = ["derive"] } sha2 = "0.10" serde_json = "1.0.149" diff --git a/arenabuddy/arenabuddy/Cargo.toml b/arenabuddy/arenabuddy/Cargo.toml index 4d59c83..430ab2d 100644 --- a/arenabuddy/arenabuddy/Cargo.toml +++ b/arenabuddy/arenabuddy/Cargo.toml @@ -25,6 +25,7 @@ notify = { workspace = true } open = { workspace = true } postgresql_embedded = { workspace = true, features = ["bundled"] } rand = { workspace = true } +self_update = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } sha2 = { workspace = true } diff --git a/arenabuddy/arenabuddy/assets/tailwind.css b/arenabuddy/arenabuddy/assets/tailwind.css index 9fe7068..811bd73 100644 --- a/arenabuddy/arenabuddy/assets/tailwind.css +++ b/arenabuddy/arenabuddy/assets/tailwind.css @@ -18,6 +18,7 @@ --color-amber-600: oklch(66.6% 0.179 58.318); --color-yellow-400: oklch(85.2% 0.199 91.936); --color-yellow-600: oklch(68.1% 0.162 75.834); + --color-yellow-700: oklch(55.4% 0.135 66.442); --color-green-100: oklch(96.2% 0.044 156.743); --color-green-400: oklch(79.2% 0.209 151.711); --color-green-600: oklch(62.7% 0.194 149.214); @@ -515,6 +516,13 @@ margin-inline-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse))); } } + .space-x-4 { + :where(& > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc(calc(var(--spacing) * 4) * var(--tw-space-x-reverse)); + margin-inline-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse))); + } + } .space-x-6 { :where(& > :not(:last-child)) { --tw-space-x-reverse: 0; @@ -641,6 +649,9 @@ .bg-white { background-color: var(--color-white); } + .bg-yellow-600 { + background-color: var(--color-yellow-600); + } .bg-gradient-to-r { --tw-gradient-position: to right in oklab; background-image: linear-gradient(var(--tw-gradient-stops)); @@ -843,6 +854,9 @@ .text-purple-800 { color: var(--color-purple-800); } + .text-red-400 { + color: var(--color-red-400); + } .text-red-600 { color: var(--color-red-600); } @@ -974,6 +988,13 @@ } } } + .hover\:bg-yellow-700 { + &:hover { + @media (hover: hover) { + background-color: var(--color-yellow-700); + } + } + } .hover\:text-blue-400 { &:hover { @media (hover: hover) { diff --git a/arenabuddy/arenabuddy/src/app/pages.rs b/arenabuddy/arenabuddy/src/app/pages.rs index f87bfb0..d8b10d6 100644 --- a/arenabuddy/arenabuddy/src/app/pages.rs +++ b/arenabuddy/arenabuddy/src/app/pages.rs @@ -6,7 +6,7 @@ use crate::{ debug_logs::DebugLogs, draft_details::DraftDetails, drafts::Drafts, error_logs::ErrorLogs, match_details::MatchDetails, matches::Matches, }, - backend::SharedAuthState, + backend::{SharedAuthState, SharedUpdateState}, }; fn open_github() { @@ -82,8 +82,11 @@ fn PageNotFound(route: Vec) -> Element { #[component] fn Layout() -> Element { let auth_state = use_context::(); + let update_state = use_context::(); let mut login_status = use_signal(|| None::); let mut login_loading = use_signal(|| false); + let mut update_version = use_signal(|| None::); + let mut update_msg = use_signal(|| None::<(String, &'static str)>); // (message, color class) // Check current auth state on render let auth_state_effect = auth_state.clone(); @@ -95,6 +98,44 @@ fn Layout() -> Element { }); }); + // Poll update state + let update_state_effect = update_state.clone(); + use_effect(move || { + let update_state = update_state_effect.clone(); + spawn(async move { + let state = update_state.lock().await; + match &*state { + crate::backend::update::UpdateStatus::Available { version } => { + update_version.set(Some(version.clone())); + update_msg.set(None); + } + crate::backend::update::UpdateStatus::RestartRequired => { + update_version.set(None); + update_msg.set(Some(("Update installed — restart to apply".into(), "text-green-400"))); + } + crate::backend::update::UpdateStatus::Updating => { + update_version.set(None); + update_msg.set(Some(("Updating...".into(), "text-yellow-400"))); + } + crate::backend::update::UpdateStatus::Error(e) => { + update_version.set(None); + update_msg.set(Some((format!("Update error: {e}"), "text-red-400"))); + } + _ => { + update_version.set(None); + update_msg.set(None); + } + } + }); + }); + + let on_update = move |_| { + let update_state = update_state.clone(); + spawn(async move { + crate::backend::update::apply_update(update_state).await; + }); + }; + let on_login = move |_| { let auth_state = auth_state.clone(); spawn(async move { @@ -169,16 +210,30 @@ fn Layout() -> Element { } } } - div { class: "text-white", - if let Some(username) = login_status() { - span { class: "text-green-400 text-sm", "Logged in as {username}" } - } else if login_loading() { - span { class: "text-yellow-400 text-sm", "Logging in..." } - } else { - button { - class: "bg-indigo-600 hover:bg-indigo-700 text-white text-sm px-3 py-1 rounded transition-colors duration-200", - onclick: on_login, - "Login with Discord" + div { class: "flex items-center space-x-4", + if let Some(version) = update_version() { + div { class: "flex items-center space-x-2", + span { class: "text-yellow-400 text-sm", "v{version} available" } + button { + class: "bg-yellow-600 hover:bg-yellow-700 text-white text-xs px-2 py-1 rounded transition-colors duration-200", + onclick: on_update, + "Update now" + } + } + } else if let Some((msg, color)) = update_msg() { + span { class: "{color} text-sm", "{msg}" } + } + div { class: "text-white", + if let Some(username) = login_status() { + span { class: "text-green-400 text-sm", "Logged in as {username}" } + } else if login_loading() { + span { class: "text-yellow-400 text-sm", "Logging in..." } + } else { + button { + class: "bg-indigo-600 hover:bg-indigo-700 text-white text-sm px-3 py-1 rounded transition-colors duration-200", + onclick: on_login, + "Login with Discord" + } } } } diff --git a/arenabuddy/arenabuddy/src/backend/launch.rs b/arenabuddy/arenabuddy/src/backend/launch.rs index cba057c..4e8f476 100644 --- a/arenabuddy/arenabuddy/src/backend/launch.rs +++ b/arenabuddy/arenabuddy/src/backend/launch.rs @@ -17,7 +17,7 @@ use tracingx::{ use crate::{ Error, Result, app::App, - backend::{Service, new_shared_auth_state, service::AppService}, + backend::{Service, new_shared_auth_state, new_shared_update_state, service::AppService}, }; pub fn launch() -> Result<()> { @@ -37,6 +37,12 @@ pub fn launch() -> Result<()> { info!("Restored auth session for {}", saved.user.username); *auth_state.blocking_lock() = Some(saved); } + let update_state = new_shared_update_state(); + let update_state2 = update_state.clone(); + background.spawn(async move { + crate::backend::update::check_for_update(update_state2).await; + }); + let service2 = service.clone(); let auth_state2 = auth_state.clone(); background.spawn(async move { @@ -59,6 +65,7 @@ pub fn launch() -> Result<()> { ) .with_context(service) .with_context(auth_state) + .with_context(update_state) .launch(App); Ok(()) } diff --git a/arenabuddy/arenabuddy/src/backend/mod.rs b/arenabuddy/arenabuddy/src/backend/mod.rs index cffc7f2..b3d6ef2 100644 --- a/arenabuddy/arenabuddy/src/backend/mod.rs +++ b/arenabuddy/arenabuddy/src/backend/mod.rs @@ -3,7 +3,9 @@ pub(crate) mod grpc_writer; pub(crate) mod ingest; mod launch; mod service; +pub(crate) mod update; pub use auth::{SharedAuthState, new_shared_auth_state}; pub use launch::launch; +pub use update::{SharedUpdateState, new_shared_update_state}; pub type Service = service::AppService; diff --git a/arenabuddy/arenabuddy/src/backend/update.rs b/arenabuddy/arenabuddy/src/backend/update.rs new file mode 100644 index 0000000..771eaef --- /dev/null +++ b/arenabuddy/arenabuddy/src/backend/update.rs @@ -0,0 +1,101 @@ +use std::sync::Arc; + +use self_update::{self, cargo_crate_version, update::ReleaseUpdate}; +use tokio::sync::Mutex; +use tracingx::{error, info}; + +#[derive(Clone, Debug)] +pub enum UpdateStatus { + Checking, + Available { version: String }, + UpToDate, + Updating, + RestartRequired, + Error(String), +} + +pub type SharedUpdateState = Arc>; + +pub fn new_shared_update_state() -> SharedUpdateState { + Arc::new(Mutex::new(UpdateStatus::Checking)) +} + +fn build_updater() -> Result, self_update::errors::Error> { + self_update::backends::github::Update::configure() + .repo_owner("gazure") + .repo_name("monorepo") + .bin_name("arenabuddy") + .current_version(cargo_crate_version!()) + .no_confirm(true) + .show_download_progress(false) + .build() +} + +pub async fn check_for_update(state: SharedUpdateState) { + let result = tokio::task::spawn_blocking(|| { + let updater = build_updater()?; + let latest = updater.get_latest_release()?; + let current = cargo_crate_version!(); + info!("Current version: {current}, latest release: {}", latest.version); + Ok::<_, self_update::errors::Error>(latest.version) + }) + .await; + + let mut s = state.lock().await; + match result { + Ok(Ok(latest_version)) => { + let current = cargo_crate_version!(); + if latest_version.trim_start_matches('v') > current { + info!("Update available: {latest_version}"); + *s = UpdateStatus::Available { + version: latest_version, + }; + } else { + info!("Already up to date"); + *s = UpdateStatus::UpToDate; + } + } + Ok(Err(e)) => { + error!("Failed to check for updates: {e}"); + *s = UpdateStatus::Error(e.to_string()); + } + Err(e) => { + error!("Update check task panicked: {e}"); + *s = UpdateStatus::Error(e.to_string()); + } + } +} + +pub async fn apply_update(state: SharedUpdateState) { + { + *state.lock().await = UpdateStatus::Updating; + } + + let result = tokio::task::spawn_blocking(|| { + let updater = build_updater()?; + let status = updater.update()?; + Ok::<_, self_update::errors::Error>(status) + }) + .await; + + let mut s = state.lock().await; + match result { + Ok(Ok(status)) => { + if status.updated() { + info!("Updated to {}", status.version()); + *s = UpdateStatus::RestartRequired; + } else { + info!("Already up to date: {}", status.version()); + *s = UpdateStatus::UpToDate; + } + } + Ok(Err(e)) => { + error!("Failed to apply update: {e}"); + *s = UpdateStatus::Error(e.to_string()); + } + Err(e) => { + error!("Update task panicked: {e}"); + *s = UpdateStatus::Error(e.to_string()); + } + } +}