diff --git a/Cargo.lock b/Cargo.lock index 42a9a67d..a11570b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -431,6 +431,12 @@ dependencies = [ "x11rb", ] +[[package]] +name = "arc-swap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" + [[package]] name = "arg_enum_proc_macro" version = "0.3.4" @@ -2121,6 +2127,7 @@ dependencies = [ "freya-markdown", "freya-performance-plugin", "freya-query", + "freya-radio", "freya-router", "freya-sdk", "freya-winit", @@ -2276,6 +2283,14 @@ dependencies = [ "futures-util", ] +[[package]] +name = "freya-radio" +version = "0.4.0-rc.23" +source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9" +dependencies = [ + "freya-core", +] + [[package]] name = "freya-router" version = "0.4.0-rc.23" @@ -4496,9 +4511,18 @@ dependencies = [ "freya-skia-safe", "image", "mimalloc", + "oneclient_auth", + "oneclient_cluster", + "oneclient_common", + "oneclient_content", "oneclient_core", "oneclient_db", + "oneclient_events", + "oneclient_java", "oneclient_macro", + "oneclient_mc", + "oneclient_net", + "oneclient_polyplus", "open", "polyio", "regex", @@ -4512,6 +4536,89 @@ dependencies = [ "winresource", ] +[[package]] +name = "oneclient_auth" +version = "2.1.0" +dependencies = [ + "base64", + "chrono", + "md-5 0.10.6", + "oneclient_common", + "oneclient_events", + "oneclient_net", + "polyio", + "rand 0.10.1", + "reqwest 0.13.3", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "uuid 1.23.1", +] + +[[package]] +name = "oneclient_cluster" +version = "2.1.0" +dependencies = [ + "bytes 1.11.1", + "chrono", + "image", + "oneclient_common", + "oneclient_db", + "oneclient_net", + "polyio", + "reqwest 0.13.3", + "serde", + "serde_json", + "serde_repr", + "sqlx", + "strum", + "thiserror 2.0.18", + "tokio", + "tracing", + "trash", +] + +[[package]] +name = "oneclient_common" +version = "2.1.0" +dependencies = [ + "directories", + "interfrost", + "serde", + "strum", + "thiserror 2.0.18", +] + +[[package]] +name = "oneclient_content" +version = "2.1.0" +dependencies = [ + "async-trait", + "async_zip", + "chrono", + "futures-lite", + "futures-util", + "htmd", + "oneclient_common", + "oneclient_db", + "oneclient_events", + "oneclient_net", + "polyio", + "reqwest 0.13.3", + "serde", + "serde_json", + "sqlx", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "uuid 1.23.1", +] + [[package]] name = "oneclient_core" version = "2.1.0" @@ -4536,7 +4643,17 @@ dependencies = [ "indicatif", "interfrost", "md-5 0.10.6", + "oneclient_auth", + "oneclient_cluster", + "oneclient_common", + "oneclient_content", "oneclient_db", + "oneclient_discord", + "oneclient_events", + "oneclient_java", + "oneclient_mc", + "oneclient_net", + "oneclient_polyplus", "parking_lot", "polyio", "rand 0.10.1", @@ -4580,6 +4697,50 @@ dependencies = [ "tracing", ] +[[package]] +name = "oneclient_discord" +version = "2.1.0" +dependencies = [ + "chrono", + "discord-rich-presence", + "oneclient_common", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "oneclient_events" +version = "2.1.0" +dependencies = [ + "thiserror 2.0.18", + "tokio", + "tracing", + "uuid 1.23.1", +] + +[[package]] +name = "oneclient_java" +version = "2.1.0" +dependencies = [ + "astral-tokio-tar", + "async-tempfile", + "async-trait", + "async_zip", + "futures-lite", + "oneclient_common", + "oneclient_events", + "oneclient_net", + "polyio", + "reqwest 0.13.3", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", +] + [[package]] name = "oneclient_macro" version = "2.1.0" @@ -4589,6 +4750,73 @@ dependencies = [ "syn", ] +[[package]] +name = "oneclient_mc" +version = "2.1.0" +dependencies = [ + "base64", + "chrono", + "futures-util", + "interfrost", + "oneclient_common", + "oneclient_events", + "oneclient_net", + "polyio", + "reqwest 0.13.3", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "uuid 1.23.1", +] + +[[package]] +name = "oneclient_net" +version = "2.1.0" +dependencies = [ + "arc-swap", + "async-trait", + "bytes 1.11.1", + "futures-lite", + "futures-util", + "oneclient_common", + "oneclient_events", + "polyio", + "reqwest 0.13.3", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "uuid 1.23.1", +] + +[[package]] +name = "oneclient_polyplus" +version = "2.1.0" +dependencies = [ + "async-tungstenite", + "bytes 1.11.1", + "futures-util", + "oneclient_auth", + "oneclient_common", + "polyio", + "rand 0.10.1", + "reqwest 0.13.3", + "reqwest-websocket", + "serde", + "serde_json", + "sha1 0.10.6", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "uuid 1.23.1", +] + [[package]] name = "open" version = "5.4.0" @@ -4853,6 +5081,7 @@ dependencies = [ "async-tempfile", "async_zip", "bytes 1.11.1", + "digest 0.10.7", "dunce", "futures-lite", "futures-util", @@ -4860,6 +5089,7 @@ dependencies = [ "sanitize-filename", "serde", "serde_json", + "sha1 0.10.6", "thiserror 2.0.18", "tokio", "tokio-util", diff --git a/Cargo.toml b/Cargo.toml index 7e70e444..b46eb3cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,12 +21,21 @@ version = "2.1.0" [workspace.dependencies] # core polyio = { path = "./packages/polyio" } +oneclient_auth = { path = "./packages/oneclient_auth" } +oneclient_cluster = { path = "./packages/oneclient_cluster" } +oneclient_common = { path = "./packages/oneclient_common" } +oneclient_content = { path = "./packages/oneclient_content" } +oneclient_discord = { path = "./packages/oneclient_discord" } +oneclient_events = { path = "./packages/oneclient_events" } +oneclient_java = { path = "./packages/oneclient_java" } +oneclient_mc = { path = "./packages/oneclient_mc" } +oneclient_net = { path = "./packages/oneclient_net" } +oneclient_polyplus = { path = "./packages/oneclient_polyplus" } oneclient_core = { path = "./packages/oneclient_core" } oneclient_db = { path = "./packages/oneclient_db" } oneclient_macro = { path = "./packages/oneclient_macro" } # core (extra) -gamemode_rs = {path = "./packages/gamemode_rs"} interfrost = {version = "=1.3.2"} discord-rich-presence = {version = "=1.1.0"} @@ -107,6 +116,7 @@ uuid = {version = "=1.23.1", features = ["serde", "v4"]} sanitize-filename = { version = "=0.6.0" } regex = { version = "=1.12.3" } parking_lot = { version = "=0.12.5" } +arc-swap = { version = "=1.7.1" } dunce = { version = "=1.0.5" } junction = { version = "=2.0.0" } bytes = { version = "=1.11.1" } @@ -128,7 +138,7 @@ trash = {version = "=5.2.6"} # required for the SKSL player renderer freya-skia-safe = "=0.98.0" -freya = { git = "https://github.com/marc2332/freya.git", branch = "fix/blur", features = ["engine", "winit", "router", "remote-asset", "sdk", "markdown", "query", "titlebar"] } +freya = { git = "https://github.com/marc2332/freya.git", branch = "fix/blur", features = ["engine", "winit", "router", "remote-asset", "sdk", "markdown", "query", "radio", "titlebar"] } [patch.crates-io] freya = { git = "https://github.com/marc2332/freya.git", branch = "fix/blur" } diff --git a/packages/oneclient_app/Cargo.toml b/packages/oneclient_app/Cargo.toml index 1f0d6793..af9bfe4c 100644 --- a/packages/oneclient_app/Cargo.toml +++ b/packages/oneclient_app/Cargo.toml @@ -14,6 +14,15 @@ authors = { workspace = true } default = [] [dependencies] +oneclient_auth.workspace = true +oneclient_cluster.workspace = true +oneclient_common.workspace = true +oneclient_content.workspace = true +oneclient_events.workspace = true +oneclient_java.workspace = true +oneclient_mc.workspace = true +oneclient_polyplus.workspace = true +oneclient_net.workspace = true oneclient_core.workspace = true oneclient_db.workspace = true chrono.workspace = true @@ -62,6 +71,7 @@ formats = ["nsis", "app", "dmg", "deb", "appimage"] binaries = [{ path = "oneclient_app", main = true }] icons = [ "icons/32x32.png", + "icons/64x64.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", @@ -131,6 +141,7 @@ assets = [ { source = "target/release/oneclient_app", dest = "/usr/bin/oneclient_app", mode = "755" }, { source = "target/release/.cargo-packager/deb/*/data/usr/share/applications/oneclient_app.desktop", dest = "/usr/share/applications/", mode = "644" }, { source = "icons/32x32.png", dest = "/usr/share/icons/hicolor/32x32/apps/oneclient_app.png", mode = "644" }, + { source = "icons/64x64.png", dest = "/usr/share/icons/hicolor/64x64/apps/oneclient_app.png", mode = "644" }, { source = "icons/128x128.png", dest = "/usr/share/icons/hicolor/128x128/apps/oneclient_app.png", mode = "644" }, { source = "icons/512x512.png", dest = "/usr/share/icons/hicolor/512x512/apps/oneclient_app.png", mode = "644" }, ] diff --git a/packages/oneclient_app/examples/min_freya.rs b/packages/oneclient_app/examples/min_freya.rs deleted file mode 100644 index 34bcb9b6..00000000 --- a/packages/oneclient_app/examples/min_freya.rs +++ /dev/null @@ -1,29 +0,0 @@ -use freya::prelude::*; - -struct MinApp; - -impl App for MinApp { - fn render(&self) -> impl IntoElement { - rect() - .width(Size::fill()) - .height(Size::fill()) - .child(label().text("hello")) - } -} - -fn main() { - let cache_mb = std::env::var("ONECLIENT_GPU_CACHE_MB") - .ok() - .and_then(|v| v.parse::().ok()) - .unwrap_or(128); - - let config = LaunchConfig::new() - .with_window( - WindowConfig::new_app(MinApp) - .with_title("min_freya") - .with_size(1200., 800.), - ) - .with_gpu_resource_cache_limit(cache_mb * 1024 * 1024); - - launch(config); -} diff --git a/packages/oneclient_app/icons/32x32.png b/packages/oneclient_app/icons/32x32.png index 6c92d512..d190be46 100644 Binary files a/packages/oneclient_app/icons/32x32.png and b/packages/oneclient_app/icons/32x32.png differ diff --git a/packages/oneclient_app/icons/64x64.png b/packages/oneclient_app/icons/64x64.png new file mode 100644 index 00000000..6c92d512 Binary files /dev/null and b/packages/oneclient_app/icons/64x64.png differ diff --git a/packages/oneclient_app/src/bridge/commands.rs b/packages/oneclient_app/src/bridge/commands.rs deleted file mode 100644 index bed82dc9..00000000 --- a/packages/oneclient_app/src/bridge/commands.rs +++ /dev/null @@ -1,144 +0,0 @@ -use std::path::PathBuf; - -use uuid::Uuid; - -use oneclient_core::ApplyBundleUpdatesResult; -use oneclient_core::notification::UserChoice; -use oneclient_core::packages::{ContentType, ProviderId}; -use oneclient_core::settings::{GameSettingsProfile, LauncherSettings, ProfileUpdate}; -use oneclient_db::models::ClusterId; - -use crate::notifications::{ClusterUpdateSummary, NotificationSpec}; - -#[derive(Debug)] -pub enum BridgeCommand { - ReloadSettings, - SaveSettings, - SetSettings { - settings: LauncherSettings, - }, - RecordSeenVersion { - version: String, - }, - SetSeenChangelogVersion { - version: String, - }, - MarkOnboardingSeen, - AcceptTos { - terms_version: u32, - privacy_version: u32, - }, - - SaveGlobalProfile { - profile: GameSettingsProfile, - }, - UpdateGlobalProfile { - update: ProfileUpdate, - }, - - CreateSettingsProfile { - name: String, - }, - CreateProfileFromGlobal { - name: String, - mem_max: Option, - force_fullscreen: Option, - }, - UpsertNamedProfile { - profile: GameSettingsProfile, - }, - UpdateNamedProfile { - name: String, - update: ProfileUpdate, - }, - DeleteNamedProfile { - name: String, - }, - - UpdateClusterProfile { - cluster_id: ClusterId, - update: ProfileUpdate, - }, - CreateAndAssignClusterProfile { - cluster_id: ClusterId, - profile_name: String, - }, - SetClusterLoaderVersion { - cluster_id: ClusterId, - version: String, - }, - - InstallJavaRuntime { - vendor: oneclient_core::java::JavaVendor, - major: u32, - }, - AddCustomJavaRuntime { - path: PathBuf, - }, - RemoveJavaRuntime { - path: String, - }, - - ToggleNotificationCenter, - CloseNotificationCenter, - ToggleAccountSwitcher, - CloseAccountSwitcher, - ClearNotificationInbox, - DismissToast(u64), - PauseToasts, - ResumeToasts, - MarkNotificationRead(u64), - DismissNotification(u64), - AnswerPrompt(UserChoice), - OpenClusterUpdate(Vec), - CloseClusterUpdate, - SendNotification { - spec: NotificationSpec, - }, - SendTestProgress { - current: u64, - total: u64, - }, - - LaunchCluster { - cluster_id: ClusterId, - }, - KillCluster { - cluster_id: ClusterId, - }, - DismissGameError, - - ImportLocalFile { - cluster_id: ClusterId, - content_type: ContentType, - path: PathBuf, - }, - InstallPackage { - cluster_id: ClusterId, - provider: ProviderId, - project_id: String, - version_id: String, - }, - - InstallBundle { - cluster_id: ClusterId, - bundle_name: String, - skip_compatibility: bool, - }, - ApplyBundleUpdates { - cluster_id: ClusterId, - }, - SyncBundles, - /// Emitted by the background bundle sync task when it finishes; carries the - /// per-cluster changes so the loop can raise "view update" notifications. - BundleSyncComplete { - changed: Vec<(ClusterId, ApplyBundleUpdatesResult)>, - session_id: Uuid, - }, - - ImportLauncher { - source: oneclient_core::MigrationSource, - folder_name: String, - target: oneclient_core::ImportTarget, - }, -} diff --git a/packages/oneclient_app/src/bridge/mod.rs b/packages/oneclient_app/src/bridge/mod.rs deleted file mode 100644 index a6b5cce4..00000000 --- a/packages/oneclient_app/src/bridge/mod.rs +++ /dev/null @@ -1,64 +0,0 @@ -mod commands; -mod runtime; -mod snapshot; - -pub use commands::BridgeCommand; -pub use snapshot::{ - AsyncStatus, BridgeSnapshot, ClustersSnapshot, GameSnapshot, JavaSnapshot, LauncherInit, - ProfilesSnapshot, SettingsSnapshot, -}; - -use runtime::CoreBridgeRuntime; -use tokio::sync::{mpsc, watch}; - -#[derive(Clone)] -pub struct OneClientBridge { - pub snapshots: watch::Receiver, - commands: mpsc::UnboundedSender, -} - -impl OneClientBridge { - pub fn new() -> (Self, CoreBridgeHandle) { - let (snapshots_tx, snapshots_rx) = watch::channel(BridgeSnapshot::default()); - let (commands_tx, commands_rx) = mpsc::unbounded_channel(); - - ( - Self { - snapshots: snapshots_rx, - commands: commands_tx.clone(), - }, - CoreBridgeHandle { - snapshots_tx, - commands_rx, - commands_tx: commands_tx.clone(), - }, - ) - } - - pub fn send(&self, command: BridgeCommand) { - let _ = self.commands.send(command); - } -} - -pub struct CoreBridgeHandle { - snapshots_tx: watch::Sender, - commands_rx: mpsc::UnboundedReceiver, - commands_tx: mpsc::UnboundedSender, -} - -impl CoreBridgeHandle { - pub fn spawn_runtime(self) { - let runtime = CoreBridgeRuntime { - snapshots_tx: self.snapshots_tx, - commands_rx: self.commands_rx, - commands_tx: self.commands_tx, - }; - - tokio::spawn(runtime.run()); - } -} - -pub fn use_bridge_snapshot(bridge: &OneClientBridge) -> BridgeSnapshot { - freya::sdk::use_track_watcher(&bridge.snapshots); - bridge.snapshots.borrow().clone() -} diff --git a/packages/oneclient_app/src/bridge/runtime.rs b/packages/oneclient_app/src/bridge/runtime.rs deleted file mode 100644 index 2b40be24..00000000 --- a/packages/oneclient_app/src/bridge/runtime.rs +++ /dev/null @@ -1,1089 +0,0 @@ -use std::sync::Arc; - -use oneclient_core::notification::{Notification, NotificationLevel, NotificationService}; -use oneclient_core::packages::PackageStore; -use oneclient_core::settings::store::{ - self, create_profile_from_global, create_settings_profile, delete_named_profile, - save_global_profile, save_settings, update_named_profile, upsert_named_profile, -}; -use oneclient_core::{ClusterManager, LauncherState}; -use tokio::sync::{mpsc, watch}; - -use crate::components::IconType; -use crate::notifications::{ - ClusterUpdateItem, ClusterUpdateSummary, InboxEntry, MESSAGE_TOAST_TTL, NotificationAction, - NotificationActionKind, NotificationSpec, NotificationState, PendingPrompt, PendingPromptView, -}; - -use super::commands::BridgeCommand; -use super::snapshot::{AsyncStatus, BridgeSnapshot, LauncherInit}; -use oneclient_core::notification::LaunchStage; - -pub struct CoreBridgeRuntime { - pub snapshots_tx: watch::Sender, - pub commands_rx: mpsc::UnboundedReceiver, - pub commands_tx: mpsc::UnboundedSender, -} - -impl CoreBridgeRuntime { - pub async fn run(mut self) { - let mut snapshots = BridgeSnapshot::default(); - let mut notification_engine = NotificationState::default(); - let mut inbox: Vec = Vec::new(); - let mut notification_center_open = false; - let mut pending_prompt: Option = None; - - let mut armed_toasts: std::collections::HashMap = - std::collections::HashMap::new(); - // Hovering any toast pauses every toast's timer, including ones that arrive while hovering. - let mut toasts_paused = false; - - let mut game_flush_deadline: Option = None; - const GAME_LOG_FLUSH: std::time::Duration = std::time::Duration::from_millis(120); - - let (notifier_tx, mut notifier_rx) = mpsc::unbounded_channel(); - - if let Err(err) = Self::initialize_launcher(&mut snapshots, notifier_tx).await { - let message = err.to_string(); - snapshots.launcher.error = Some(message.clone()); - notification_engine.dispatch( - &mut inbox, - Notification::Message { - title: "Launcher failed to start".into(), - body: message, - level: NotificationLevel::Error, - }, - ); - sync_notifications( - &mut snapshots, - ¬ification_engine, - &inbox, - notification_center_open, - &pending_prompt, - ); - publish(&self.snapshots_tx, &snapshots); - tracing::error!("launcher init failed: {err:#}"); - } else { - sync_notifications( - &mut snapshots, - ¬ification_engine, - &inbox, - notification_center_open, - &pending_prompt, - ); - publish(&self.snapshots_tx, &snapshots); - - if let Err(err) = self.commands_tx.send(BridgeCommand::SyncBundles) { - tracing::error!("failed to queue startup bundle sync: {err}"); - } - } - - loop { - let next_deadline = armed_toasts - .values() - .filter_map(ToastTimer::deadline) - .min() - .into_iter() - .chain(game_flush_deadline) - .min(); - let timer = async { - match next_deadline { - Some(deadline) => tokio::time::sleep_until(deadline).await, - None => std::future::pending::<()>().await, - } - }; - - tokio::select! { - _ = timer => { - let now = tokio::time::Instant::now(); - let due: Vec = armed_toasts - .iter() - .filter(|(_, timer)| timer.deadline().is_some_and(|deadline| deadline <= now)) - .map(|(id, _)| *id) - .collect(); - let toasts_expired = !due.is_empty(); - for id in due { - notification_engine.expire_toast(&inbox, id); - armed_toasts.remove(&id); - } - - if game_flush_deadline.is_some_and(|d| d <= now) { - game_flush_deadline = None; - } - - if toasts_expired { - sync_notifications( - &mut snapshots, - ¬ification_engine, - &inbox, - notification_center_open, - &pending_prompt, - ); - } - publish(&self.snapshots_tx, &snapshots); - if toasts_expired { - reconcile_toasts(&snapshots, &inbox, &mut armed_toasts, toasts_paused); - } - } - notification = notifier_rx.recv() => { - let Some(first) = notification else { break }; - - let mut touched_engine = false; - let mut immediate = false; - let mut saw_game_log = false; - let mut process = |notification, - snapshots: &mut BridgeSnapshot, - engine: &mut NotificationState, - inbox: &mut Vec, - pending: &mut Option| { - match notification { - Notification::InvalidateClusters => { - bump_clusters_generation(snapshots); - immediate = true; - } - Notification::InvalidateJava => { - bump_java_generation(snapshots); - immediate = true; - } - Notification::SyncComplete => { - snapshots.launcher.fetching = false; - bump_clusters_generation(snapshots); - immediate = true; - } - Notification::GameStage { cluster_id, stage } => { - apply_game_stage(snapshots, cluster_id, stage); - immediate = true; - } - Notification::GameLog { cluster_id, line } => { - apply_game_log(snapshots, cluster_id, line); - saw_game_log = true; - } - Notification::GameFailed { cluster_id, message } => { - apply_game_failed(snapshots, cluster_id, message); - immediate = true; - } - Notification::MicrosoftLoginStatus(status) => { - snapshots.microsoft_login = status; - immediate = true; - } - other => { - let (_timers, prompt) = engine.dispatch(inbox, other); - if let Some(prompt) = prompt { - *pending = Some(prompt); - } - touched_engine = true; - } - } - }; - - process(first, &mut snapshots, &mut notification_engine, &mut inbox, &mut pending_prompt); - let mut budget = 1024; - while budget > 0 { - match notifier_rx.try_recv() { - Ok(next) => { - process(next, &mut snapshots, &mut notification_engine, &mut inbox, &mut pending_prompt); - budget -= 1; - } - Err(_) => break, - } - } - - if touched_engine { - sync_notifications( - &mut snapshots, - ¬ification_engine, - &inbox, - notification_center_open, - &pending_prompt, - ); - } - - if touched_engine || immediate { - publish(&self.snapshots_tx, &snapshots); - game_flush_deadline = None; - if touched_engine { - reconcile_toasts(&snapshots, &inbox, &mut armed_toasts, toasts_paused); - } - } else if saw_game_log && game_flush_deadline.is_none() { - game_flush_deadline = Some(tokio::time::Instant::now() + GAME_LOG_FLUSH); - } - } - command = self.commands_rx.recv() => { - let Some(command) = command else { break }; - match command { - BridgeCommand::PauseToasts => { - toasts_paused = true; - for timer in armed_toasts.values_mut() { - timer.pause(); - } - continue; - } - BridgeCommand::ResumeToasts => { - toasts_paused = false; - for timer in armed_toasts.values_mut() { - timer.resume(); - } - continue; - } - other => { - if let Err(err) = Self::dispatch( - other, - &mut snapshots, - &mut notification_engine, - &mut inbox, - &mut notification_center_open, - &mut pending_prompt, - &self.commands_tx, - ) - .await - { - tracing::error!("command failed: {err:#}"); - } - } - } - sync_notifications( - &mut snapshots, - ¬ification_engine, - &inbox, - notification_center_open, - &pending_prompt, - ); - publish(&self.snapshots_tx, &snapshots); - reconcile_toasts(&snapshots, &inbox, &mut armed_toasts, toasts_paused); - } - } - } - } - - async fn initialize_launcher( - snapshots: &mut BridgeSnapshot, - notifier_tx: mpsc::UnboundedSender, - ) -> anyhow::Result<()> { - let notifier = NotificationService::new(notifier_tx); - LauncherState::initialize(notifier).await?; - - oneclient_core::status::start(); - oneclient_core::plus::start(); - - let data_dir = oneclient_core::paths::launcher_dir() - .map(|p| p.display().to_string()) - .unwrap_or_default(); - - snapshots.launcher = LauncherInit { - ready: true, - fetching: true, - syncing_bundles: true, - error: None, - data_dir, - }; - refresh_settings(snapshots)?; - bump_profiles_generation(snapshots); - - let auto_update = LauncherState::get()?.settings.read().auto_update; - crate::updater::spawn_update_check(auto_update); - - Ok(()) - } - - async fn dispatch( - command: BridgeCommand, - snapshots: &mut BridgeSnapshot, - notification_engine: &mut NotificationState, - inbox: &mut Vec, - notification_center_open: &mut bool, - pending_prompt: &mut Option, - commands_tx: &mpsc::UnboundedSender, - ) -> anyhow::Result<()> { - match command { - BridgeCommand::ReloadSettings => { - snapshots.settings.status = AsyncStatus::Loading; - snapshots.settings.error = None; - let state = LauncherState::get()?; - let loaded = store::load_settings(Some(&state.services.notifier)).await; - let discord_enabled = loaded.discord_enabled; - { - let mut lock = state.settings.write(); - *lock = loaded; - } - state.discord.set_enabled(discord_enabled); - refresh_settings(snapshots)?; - } - BridgeCommand::SaveSettings => { - let state = LauncherState::get()?; - snapshots.settings.saving = true; - snapshots.settings.error = None; - let settings = state.settings.read().clone(); - if let Err(err) = save_settings(&settings).await { - snapshots.settings.error = Some(err.to_string()); - } - snapshots.settings.saving = false; - snapshots.settings.status = AsyncStatus::Ready; - } - BridgeCommand::SetSettings { settings } => { - mutate_settings(snapshots, |s| *s = settings)?; - let state = LauncherState::get()?; - let snapshot = state.settings.read().clone(); - state.discord.set_enabled(snapshot.discord_enabled); - if let Err(err) = save_settings(&snapshot).await { - snapshots.settings.error = Some(err.to_string()); - } - } - BridgeCommand::RecordSeenVersion { version } => { - mutate_settings(snapshots, |settings| { - if !settings.seen_versions.iter().any(|v| v == &version) { - settings.seen_versions.push(version); - } - })?; - } - BridgeCommand::SetSeenChangelogVersion { version } => { - let mut changed = false; - mutate_settings(snapshots, |settings| { - if settings.seen_changelog_version.as_deref() != Some(version.as_str()) { - settings.seen_changelog_version = Some(version); - changed = true; - } - })?; - - if changed { - let state = LauncherState::get()?; - let snapshot = state.settings.read().clone(); - if let Err(err) = save_settings(&snapshot).await { - snapshots.settings.error = Some(err.to_string()); - } - } - } - BridgeCommand::MarkOnboardingSeen => { - mutate_settings(snapshots, |settings| { - settings.seen_onboarding = true; - })?; - let state = LauncherState::get()?; - let snapshot = state.settings.read().clone(); - if let Err(err) = save_settings(&snapshot).await { - snapshots.settings.error = Some(err.to_string()); - } - } - BridgeCommand::AcceptTos { - terms_version, - privacy_version, - } => { - mutate_settings(snapshots, |settings| { - settings.accepted_tos_version = terms_version; - settings.accepted_privacy_version = privacy_version; - })?; - let state = LauncherState::get()?; - let snapshot = state.settings.read().clone(); - if let Err(err) = save_settings(&snapshot).await { - snapshots.settings.error = Some(err.to_string()); - } - } - - BridgeCommand::SaveGlobalProfile { profile } => { - let state = LauncherState::get()?; - save_global_profile(&state.settings, profile).await?; - refresh_settings(snapshots)?; - bump_profiles_generation(snapshots); - } - BridgeCommand::UpdateGlobalProfile { update } => { - let state = LauncherState::get()?; - let mut global = state.settings.read().global_game_settings.clone(); - update.apply(&mut global); - save_global_profile(&state.settings, global).await?; - refresh_settings(snapshots)?; - bump_profiles_generation(snapshots); - } - - BridgeCommand::CreateSettingsProfile { name } => { - let state = LauncherState::get()?; - let settings = state.settings.read().clone(); - create_settings_profile(&state.services.db, &settings, &name).await?; - bump_profiles_generation(snapshots); - } - BridgeCommand::CreateProfileFromGlobal { - name, - mem_max, - force_fullscreen, - } => { - let state = LauncherState::get()?; - let settings = state.settings.read().clone(); - create_profile_from_global( - &state.services.db, - &settings, - &name, - mem_max, - force_fullscreen, - ) - .await?; - bump_profiles_generation(snapshots); - } - BridgeCommand::UpsertNamedProfile { profile } => { - let state = LauncherState::get()?; - upsert_named_profile(&state.services.db, &profile).await?; - bump_profiles_generation(snapshots); - } - BridgeCommand::UpdateNamedProfile { name, update } => { - let state = LauncherState::get()?; - update_named_profile(&state.services.db, &name, update).await?; - bump_profiles_generation(snapshots); - } - BridgeCommand::DeleteNamedProfile { name } => { - let state = LauncherState::get()?; - delete_named_profile(&state.services.db, &name).await?; - bump_profiles_generation(snapshots); - } - - BridgeCommand::UpdateClusterProfile { cluster_id, update } => { - let state = LauncherState::get()?; - ClusterManager::update_profile(&state, cluster_id, update).await?; - bump_profiles_generation(snapshots); - } - BridgeCommand::CreateAndAssignClusterProfile { - cluster_id, - profile_name, - } => { - let state = LauncherState::get()?; - ClusterManager::create_and_assign_profile(&state, cluster_id, &profile_name) - .await?; - bump_profiles_generation(snapshots); - } - BridgeCommand::SetClusterLoaderVersion { - cluster_id, - version, - } => { - let state = LauncherState::get()?; - ClusterManager::update( - &state, - cluster_id, - oneclient_core::ClusterUpdate::default().loader_version(version), - ) - .await?; - let _ = ClusterManager::set_stage( - &state, - cluster_id, - oneclient_core::ClusterStage::NotReady, - ) - .await; - bump_clusters_generation(snapshots); - } - - BridgeCommand::ImportLauncher { - source, - folder_name, - target, - } => { - let state = LauncherState::get()?; - tokio::spawn(async move { - let notifier = state.services.notifier.clone(); - match oneclient_core::import_migration_game_dir(source, &folder_name, target) - .await - { - Ok(()) => { - notifier.send_info( - "Import complete", - &format!("Copied your files from {}.", source.display_name()), - ); - - notifier.invalidate_clusters(); - } - Err(err) => { - notifier.send_error("Import failed", &err.to_string()); - } - } - }); - } - BridgeCommand::InstallJavaRuntime { vendor, major } => { - let state = LauncherState::get()?; - tokio::spawn(async move { - let notifier = state.services.notifier.clone(); - match oneclient_core::java::JavaManager::install_runtime_from( - &state.services, - &vendor, - major, - ) - .await - { - Ok(_) => { - notifier.invalidate_java(); - } - Err(err) => { - notifier.send_error("Java install failed", &err.to_string()); - } - } - }); - } - BridgeCommand::AddCustomJavaRuntime { path } => { - let state = LauncherState::get()?; - tokio::spawn(async move { - let notifier = state.services.notifier.clone(); - match oneclient_core::java::JavaManager::add_custom_runtime( - &state.services.db, - path, - ) - .await - { - Ok(runtime) => { - notifier.send_info( - "Java added", - &format!("Java {} ({})", runtime.major, runtime.vendor), - ); - notifier.invalidate_java(); - } - Err(err) => { - notifier.send_error("Failed to add Java", &err.to_string()); - } - } - }); - } - BridgeCommand::RemoveJavaRuntime { path } => { - let state = LauncherState::get()?; - oneclient_core::java::JavaManager::remove_runtime(&state.services.db, &path) - .await?; - bump_java_generation(snapshots); - } - - BridgeCommand::ToggleNotificationCenter => { - *notification_center_open = - notification_engine.toggle_center(inbox, *notification_center_open); - } - BridgeCommand::CloseNotificationCenter => { - *notification_center_open = false; - } - BridgeCommand::ToggleAccountSwitcher => { - snapshots.account_switcher_open = !snapshots.account_switcher_open; - } - BridgeCommand::CloseAccountSwitcher => { - snapshots.account_switcher_open = false; - } - BridgeCommand::ClearNotificationInbox => { - inbox.clear(); - notification_engine.clear_inbox(); - } - BridgeCommand::DismissToast(entry_id) => { - notification_engine.dismiss_toast(inbox, entry_id); - } - BridgeCommand::PauseToasts | BridgeCommand::ResumeToasts => {} - BridgeCommand::MarkNotificationRead(entry_id) => { - notification_engine.mark_read(inbox, entry_id); - } - BridgeCommand::DismissNotification(entry_id) => { - notification_engine.dismiss_notification(inbox, entry_id); - } - BridgeCommand::AnswerPrompt(choice) => { - if let Some(prompt) = pending_prompt.take() - && let Some(reply_tx) = prompt.reply_tx - { - let _ = reply_tx.send(choice); - } - } - BridgeCommand::OpenClusterUpdate(summary) => { - notification_engine.open_cluster_update(summary); - *notification_center_open = false; - } - BridgeCommand::CloseClusterUpdate => { - notification_engine.close_cluster_update(); - } - BridgeCommand::SendNotification { spec } => { - notification_engine.push_custom(inbox, spec); - } - BridgeCommand::SendTestProgress { current, total } => { - let state = LauncherState::get()?; - let id = uuid::Uuid::from_u128(0x0CE0_0CE0_0CE0_0CE0_0CE0_0CE0_0CE0_0CE0); - state - .services - .notifier - .send_progress(&id, "Downloading assets", current, total); - } - - BridgeCommand::LaunchCluster { cluster_id } => { - let state = LauncherState::get()?; - tokio::spawn(async move { - let notifier = state.services.notifier.clone(); - // Renews a lapsed token before launching - let account = match oneclient_core::auth::default_account_for_launch().await { - Ok(account) => account, - Err(err) => { - notifier.game_failed(cluster_id, format!("{err:#}")); - return; - } - }; - match account { - Some(account) => { - if let Err(err) = - oneclient_core::launch_cluster(&state, cluster_id, &account, true) - .await - { - notifier.game_failed(cluster_id, format!("{err:#}")); - } - } - None => notifier.game_failed( - cluster_id, - "Add a Minecraft account before launching.".to_string(), - ), - } - }); - } - - BridgeCommand::KillCluster { cluster_id } => { - let state = LauncherState::get()?; - if !state.games.kill(cluster_id) { - tracing::debug!(cluster_id, "kill requested but no tracked process"); - } - } - BridgeCommand::DismissGameError => { - snapshots.game.error = None; - } - - BridgeCommand::ImportLocalFile { - cluster_id, - content_type, - path, - } => { - let state = LauncherState::get()?; - match oneclient_core::packages::PackageStore::import_local_file( - &path, - content_type, - cluster_id, - &state.services, - ) - .await - { - Ok(row) => { - state - .services - .notifier - .send_info("Imported", &format!("Added {}", row.file_name)); - bump_clusters_generation(snapshots); - } - Err(err) => state - .services - .notifier - .send_error("Import failed", &err.to_string()), - } - } - BridgeCommand::InstallPackage { - cluster_id, - provider, - project_id, - version_id, - } => { - let state = LauncherState::get()?; - tokio::spawn(async move { - let notifier = state.services.notifier.clone(); - match install_package(&state, provider, &project_id, &version_id, cluster_id) - .await - { - Ok(name) => { - notifier.send_info("Installed", &format!("Added {name}")); - notifier.invalidate_clusters(); - } - Err(err) => notifier.send_error("Install failed", &err.to_string()), - } - }); - } - - BridgeCommand::InstallBundle { - cluster_id, - bundle_name, - skip_compatibility, - } => { - let state = LauncherState::get()?; - tokio::spawn(async move { - let notifier = state.services.notifier.clone(); - match oneclient_core::install_bundle( - cluster_id, - &bundle_name, - skip_compatibility, - state.bundles.as_ref(), - &state.services, - ) - .await - { - Ok(_) => { - notifier.send_info("Installed", &format!("Added {bundle_name}")); - notifier.invalidate_clusters(); - } - Err(err) => notifier.send_error("Install failed", &err.to_string()), - } - }); - } - BridgeCommand::ApplyBundleUpdates { cluster_id } => { - let state = LauncherState::get()?; - let result = oneclient_core::apply_bundle_updates( - cluster_id, - state.bundles.as_ref(), - &state.services, - ) - .await?; - bump_clusters_generation(snapshots); - if let Some(spec) = - cluster_update_notification(cluster_id, &result, &state.services).await - { - notification_engine.push_custom(inbox, spec); - } - } - BridgeCommand::SyncBundles => { - // Run the cluster-bundle download in the background so the main - // app can load while it finishes. The launch buttons stay - // disabled via `syncing_bundles` until BundleSyncComplete lands. - snapshots.launcher.syncing_bundles = true; - let commands_tx = commands_tx.clone(); - tokio::spawn(async move { - let state = match LauncherState::get() { - Ok(state) => state, - Err(err) => { - tracing::error!("bundle sync: launcher not ready: {err:#}"); - return; - } - }; - if let Err(err) = state.bundles.sync(&state.services).await { - tracing::error!("bundle catalog sync failed: {err:#}"); - } - if let Err(err) = oneclient_core::clusters::apply_remote_migrations(&state).await - { - tracing::error!("cluster migrations failed: {err:#}"); - } - if let Err(err) = oneclient_core::clusters::ensure_from_bundles(&state).await { - tracing::error!("bundle cluster provisioning failed: {err:#}"); - } - // One shared grouped session for the whole batch, so every - // cluster's downloads surface as a single "update in progress" - // notification. `detach` hands that entry to the bridge, which - // converts it to the finished "view changes" state in place. - let session = oneclient_core::notification::GroupedProgressSession::start( - &state.services.notifier, - "Updating mods", - ); - let changed = oneclient_core::bundles::sync_all_cluster_bundles( - state.bundles.as_ref(), - &state.services, - Some(&session), - ) - .await; - let session_id = session.detach(); - let _ = commands_tx - .send(BridgeCommand::BundleSyncComplete { changed, session_id }); - }); - } - BridgeCommand::BundleSyncComplete { - changed, - session_id, - } => { - snapshots.launcher.syncing_bundles = false; - bump_clusters_generation(snapshots); - let state = LauncherState::get()?; - let spec = combined_cluster_update_spec(&changed, &state.services).await; - notification_engine.finish_grouped_as_actions(inbox, session_id, spec); - } - } - - Ok(()) - } -} - -fn item_from_bundle_file(file: &oneclient_core::BundleFile) -> ClusterUpdateItem { - match &file.kind { - oneclient_core::BundleFileKind::Managed { - provider, - project_id, - .. - } => ClusterUpdateItem { - provider: *provider, - project_id: Some(project_id.clone()), - fallback: file.display_name(), - }, - oneclient_core::BundleFileKind::External(_) => ClusterUpdateItem { - provider: oneclient_core::packages::ProviderId::Local, - project_id: None, - fallback: file.display_name(), - }, - } -} - -async fn cluster_update_summary( - cluster_id: i64, - result: &oneclient_core::ApplyBundleUpdatesResult, - services: &oneclient_core::LauncherServices, -) -> Option { - let updated: Vec = result - .updates_applied - .iter() - .map(|u| item_from_bundle_file(&u.new_file)) - .collect(); - let added: Vec = result - .additions_applied - .iter() - .map(|a| item_from_bundle_file(&a.new_file)) - .collect(); - let removed: Vec = result - .removals_applied - .iter() - .map(|r| ClusterUpdateItem { - provider: r.provider.unwrap_or(oneclient_core::packages::ProviderId::Local), - project_id: r.project_id.clone(), - fallback: r - .display_name - .clone() - .unwrap_or_else(|| r.package_id.clone()), - }) - .collect(); - - if updated.is_empty() && added.is_empty() && removed.is_empty() { - return None; - } - - let cluster_name = PackageStore::get_cluster(cluster_id, services) - .await - .map(|c| c.name) - .unwrap_or_else(|_| "Cluster".to_string()); - - Some(ClusterUpdateSummary { - cluster_id, - cluster_name, - updated, - added, - removed, - }) -} - -async fn cluster_update_notification( - cluster_id: i64, - result: &oneclient_core::ApplyBundleUpdatesResult, - services: &oneclient_core::LauncherServices, -) -> Option { - let summary = cluster_update_summary(cluster_id, result, services).await?; - let total = summary.total(); - let body = format!( - "{total} package{} changed in {}", - if total == 1 { "" } else { "s" }, - summary.cluster_name - ); - - Some(NotificationSpec { - title: "Cluster updated".to_string(), - body, - level: NotificationLevel::Info, - icon: Some(IconType::DownloadCloud02), - progress: None, - actions: vec![NotificationAction { - label: "View changes".to_string(), - kind: NotificationActionKind::OpenClusterUpdate(vec![summary]), - }], - }) -} - -/// Builds a single notification summarising a batch bundle sync. Every changed -/// cluster rides along on one "View changes" action so the notification keeps -/// exactly two buttons no matter how many clusters moved; the modal does the -/// per-cluster breakdown. `None` when nothing changed. -async fn combined_cluster_update_spec( - changed: &[(i64, oneclient_core::ApplyBundleUpdatesResult)], - services: &oneclient_core::LauncherServices, -) -> Option { - let mut summaries = Vec::new(); - let mut total_changes = 0usize; - - for (cluster_id, result) in changed { - if let Some(summary) = cluster_update_summary(*cluster_id, result, services).await { - total_changes += summary.total(); - summaries.push(summary); - } - } - - if summaries.is_empty() { - return None; - } - - let cluster_count = summaries.len(); - let body = format!( - "{total_changes} package{} updated across {cluster_count} cluster{}", - if total_changes == 1 { "" } else { "s" }, - if cluster_count == 1 { "" } else { "s" } - ); - - Some(NotificationSpec { - title: "Mods updated".to_string(), - body, - level: NotificationLevel::Info, - icon: Some(IconType::DownloadCloud02), - progress: None, - actions: vec![NotificationAction { - label: "View changes".to_string(), - kind: NotificationActionKind::OpenClusterUpdate(summaries), - }], - }) -} - -fn publish(tx: &watch::Sender, snapshots: &BridgeSnapshot) { - let _ = tx.send(snapshots.clone()); -} - -fn sync_notifications( - snapshots: &mut BridgeSnapshot, - engine: &NotificationState, - inbox: &[InboxEntry], - center_open: bool, - pending: &Option, -) { - let pending_view = pending.as_ref().map(|prompt| PendingPromptView { - title: prompt.title.clone(), - question: prompt.question.clone(), - kind: prompt.kind, - }); - snapshots.notifications = engine.snapshot(inbox, center_open, pending_view); -} - -/// A toast's time-to-live, which hovering the toast pauses and un-hovering resumes. -#[derive(Clone, Copy)] -enum ToastTimer { - Running(tokio::time::Instant), - Paused(std::time::Duration), -} - -impl ToastTimer { - fn armed(paused: bool) -> Self { - if paused { - Self::Paused(MESSAGE_TOAST_TTL) - } else { - Self::Running(tokio::time::Instant::now() + MESSAGE_TOAST_TTL) - } - } - - /// The instant this toast expires, or `None` while paused. - fn deadline(&self) -> Option { - match self { - Self::Running(deadline) => Some(*deadline), - Self::Paused(_) => None, - } - } - - fn pause(&mut self) { - if let Self::Running(deadline) = *self { - *self = Self::Paused(deadline.saturating_duration_since(tokio::time::Instant::now())); - } - } - - fn resume(&mut self) { - if let Self::Paused(remaining) = *self { - *self = Self::Running(tokio::time::Instant::now() + remaining); - } - } -} - -fn reconcile_toasts( - snapshots: &BridgeSnapshot, - inbox: &[InboxEntry], - armed: &mut std::collections::HashMap, - paused: bool, -) { - let want: std::collections::HashSet = snapshots - .notifications - .active_toast_entry_ids - .iter() - .filter(|id| inbox.iter().any(|e| e.id == **id && !e.is_loading)) - .copied() - .collect(); - - for id in &want { - armed.entry(*id).or_insert_with(|| ToastTimer::armed(paused)); - } - - armed.retain(|id, _| want.contains(id)); -} - -fn refresh_settings(snapshots: &mut BridgeSnapshot) -> anyhow::Result<()> { - let state = LauncherState::get()?; - snapshots.settings.settings = state.settings.read().clone(); - snapshots.settings.status = AsyncStatus::Ready; - snapshots.settings.error = None; - Ok(()) -} - -async fn install_package( - state: &Arc, - provider: oneclient_core::packages::ProviderId, - project_id: &str, - version_id: &str, - cluster_id: i64, -) -> anyhow::Result { - let provider_impl = state.services.packages.get(provider)?; - let project = provider_impl - .get_project(project_id, &state.services) - .await?; - let version = provider_impl - .get_version(project_id, version_id, &state.services) - .await?; - - let session = oneclient_core::notification::GroupedProgressSession::start( - &state.services.notifier, - format!("Installing {}", project.name), - ); - let size = version.primary_file().map(|f| f.size).unwrap_or(0); - let child = session.child( - project.name.clone(), - size, - oneclient_core::notification::TaskCategory::Packages, - ); - - let result = PackageStore::install_to_cluster( - provider, - &project, - &version, - cluster_id, - false, - false, - Some(&child), - &state.services, - ) - .await; - - child.finish(); - session.finish(); - result?; - Ok(project.name) -} - -fn apply_game_stage(snapshots: &mut BridgeSnapshot, cluster_id: i64, stage: LaunchStage) { - snapshots.game.stages.insert(cluster_id, stage); - if stage == LaunchStage::Checking { - snapshots.game.error = None; - snapshots.game.logs.insert(cluster_id, Arc::new(Vec::new())); - } -} - -fn apply_game_failed(snapshots: &mut BridgeSnapshot, cluster_id: i64, message: String) { - snapshots - .game - .stages - .insert(cluster_id, LaunchStage::Exited); - snapshots.game.error = Some(message); -} - -fn apply_game_log(snapshots: &mut BridgeSnapshot, cluster_id: i64, line: String) { - let buffer = snapshots.game.logs.entry(cluster_id).or_default(); - Arc::make_mut(buffer).push(Arc::from(line)); -} - -fn bump_profiles_generation(snapshots: &mut BridgeSnapshot) { - snapshots.profiles.generation = snapshots.profiles.generation.wrapping_add(1); -} - -fn bump_clusters_generation(snapshots: &mut BridgeSnapshot) { - snapshots.clusters.generation = snapshots.clusters.generation.wrapping_add(1); -} - -fn bump_java_generation(snapshots: &mut BridgeSnapshot) { - snapshots.java.generation = snapshots.java.generation.wrapping_add(1); -} - -fn mutate_settings( - snapshots: &mut BridgeSnapshot, - mutate: impl FnOnce(&mut oneclient_core::settings::LauncherSettings), -) -> anyhow::Result<()> { - let state = LauncherState::get()?; - { - let mut lock = state.settings.write(); - mutate(&mut lock); - snapshots.settings.settings = lock.clone(); - } - snapshots.settings.status = AsyncStatus::Ready; - Ok(()) -} diff --git a/packages/oneclient_app/src/bridge/snapshot.rs b/packages/oneclient_app/src/bridge/snapshot.rs deleted file mode 100644 index 179e89d1..00000000 --- a/packages/oneclient_app/src/bridge/snapshot.rs +++ /dev/null @@ -1,103 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use oneclient_core::notification::{LaunchStage, MicrosoftLoginStatus}; -use oneclient_core::settings::LauncherSettings; - -#[derive(Clone, Debug, Default, PartialEq)] -pub struct GameSnapshot { - pub stages: HashMap, - pub error: Option, - pub logs: HashMap>>>, -} - -impl GameSnapshot { - pub fn stage(&self, cluster_id: i64) -> Option { - self.stages.get(&cluster_id).copied() - } - - pub fn is_busy(&self, cluster_id: i64) -> bool { - self.stage(cluster_id).is_some_and(LaunchStage::is_busy) - } - - pub fn is_running(&self, cluster_id: i64) -> bool { - self.stage(cluster_id) == Some(LaunchStage::Running) - } - - pub fn is_active(&self, cluster_id: i64) -> bool { - matches!( - self.stage(cluster_id), - Some(s) if s != LaunchStage::Exited - ) - } - - pub fn logs_for(&self, cluster_id: i64) -> Arc>> { - self.logs.get(&cluster_id).cloned().unwrap_or_default() - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum AsyncStatus { - Idle, - Loading, - Ready, - Error, -} - -#[derive(Clone, Debug, Default)] -pub struct LauncherInit { - pub ready: bool, - pub fetching: bool, - /// True while the background cluster-bundle download is running. Launch is - /// disabled until it clears. - pub syncing_bundles: bool, - pub error: Option, - pub data_dir: String, -} - -#[derive(Clone, Debug)] -pub struct SettingsSnapshot { - pub settings: LauncherSettings, - pub status: AsyncStatus, - pub saving: bool, - pub error: Option, -} - -impl Default for SettingsSnapshot { - fn default() -> Self { - Self { - settings: LauncherSettings::default(), - status: AsyncStatus::Idle, - saving: false, - error: None, - } - } -} - -#[derive(Clone, Copy, Debug, Default)] -pub struct ProfilesSnapshot { - pub generation: u64, -} - -#[derive(Clone, Copy, Debug, Default)] -pub struct ClustersSnapshot { - pub generation: u64, -} - -#[derive(Clone, Copy, Debug, Default)] -pub struct JavaSnapshot { - pub generation: u64, -} - -#[derive(Clone, Debug, Default)] -pub struct BridgeSnapshot { - pub launcher: LauncherInit, - pub settings: SettingsSnapshot, - pub profiles: ProfilesSnapshot, - pub clusters: ClustersSnapshot, - pub java: JavaSnapshot, - pub notifications: crate::notifications::NotificationSnapshot, - pub game: GameSnapshot, - pub account_switcher_open: bool, - pub microsoft_login: Option, -} diff --git a/packages/oneclient_app/src/components/account_switcher.rs b/packages/oneclient_app/src/components/account_switcher.rs index 50d56779..6cc5b2e7 100644 --- a/packages/oneclient_app/src/components/account_switcher.rs +++ b/packages/oneclient_app/src/components/account_switcher.rs @@ -3,9 +3,10 @@ use freya::{ prelude::*, router::RouterContext, }; -use oneclient_core::auth::MinecraftAccount; +use oneclient_auth::MinecraftAccount; use crate::{ + ui::{divider}, Route, components::{Avatar, Button, Icon, IconType, OverlayPopup}, hooks::{ @@ -226,13 +227,6 @@ impl Component for AccountRow { } } -fn divider() -> impl IntoElement { - rect() - .width(Size::fill()) - .height(Size::px(1.)) - .background(colors::component_border()) -} - #[derive(PartialEq)] struct Footer; diff --git a/packages/oneclient_app/src/components/active_cluster_panel.rs b/packages/oneclient_app/src/components/active_cluster_panel.rs index 9508cb80..7e5cccaa 100644 --- a/packages/oneclient_app/src/components/active_cluster_panel.rs +++ b/packages/oneclient_app/src/components/active_cluster_panel.rs @@ -1,15 +1,11 @@ use freya::animation::*; use freya::prelude::*; -use freya::query::QueryStateData; use freya::router::RouterContext; use crate::components::{Button, Icon, IconType}; -use oneclient_core::parse_mc_version; +use oneclient_common::parse_mc_version; -use crate::hooks::{ - use_active_cluster_id, use_clusters, use_dispatch, use_game_snapshot, use_launcher, - use_version_metadata, -}; +use crate::hooks::{settled_or_loading, use_active_cluster_id, use_clusters, use_dispatch, use_game_snapshot, use_launcher, use_version_metadata}; use crate::routes::Route; use crate::theme::colors; use crate::utils::sort_clusters_for_home; @@ -27,16 +23,7 @@ impl Component for ActiveClusterPanel { let launcher = use_launcher(); let syncing = launcher.fetching || launcher.syncing_bundles; - let clusters = { - let reader = clusters_query.read(); - match &*reader.state() { - QueryStateData::Settled { res: Ok(list), .. } => list.clone(), - QueryStateData::Loading { - res: Some(Ok(list)), - } => list.clone(), - _ => Vec::new(), - } - }; + let clusters = settled_or_loading(&clusters_query).unwrap_or_default(); let sorted = sort_clusters_for_home(clusters); @@ -131,7 +118,7 @@ impl Component for ActiveClusterPanel { fn launch_button( cluster_id: i64, - dispatch: crate::BridgeDispatch, + dispatch: crate::Actions, state: (&'static str, bool), ) -> impl IntoElement { let (label, enabled) = state; diff --git a/packages/oneclient_app/src/components/cluster_landscape_art.rs b/packages/oneclient_app/src/components/cluster_landscape_art.rs index ab59eab6..9f5b7fdc 100644 --- a/packages/oneclient_app/src/components/cluster_landscape_art.rs +++ b/packages/oneclient_app/src/components/cluster_landscape_art.rs @@ -1,6 +1,6 @@ use freya::prelude::*; -use oneclient_core::VersionKey; -use oneclient_core::packages::domain::GameLoader; +use oneclient_common::VersionKey; +use oneclient_common::domain::GameLoader; use crate::components::DynamicArt; use crate::theme::colors; diff --git a/packages/oneclient_app/src/components/cluster_update_popup.rs b/packages/oneclient_app/src/components/cluster_update_popup.rs index 35081267..9f211727 100644 --- a/packages/oneclient_app/src/components/cluster_update_popup.rs +++ b/packages/oneclient_app/src/components/cluster_update_popup.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use freya::{prelude::*, router::RouterContext}; -use oneclient_core::packages::{CachedPackageMeta, ProviderId}; +use oneclient_content::packages::{CachedPackageMeta, ProviderId}; use crate::components::{Button, Icon, IconType, OverlayPopup, ScrollArea, TabBar, TabItem}; use crate::hooks::{package_meta_batch, use_dispatch, use_notifications_snapshot, use_package_meta_batch}; @@ -139,7 +139,7 @@ fn dialog( summaries: &[ClusterUpdateSummary], meta: &MetaMap, active: State, - dispatch: crate::BridgeDispatch, + dispatch: crate::Actions, ) -> impl IntoElement { rect() .vertical() @@ -164,7 +164,7 @@ fn content( summaries: &[ClusterUpdateSummary], meta: &MetaMap, active: State, - dispatch: crate::BridgeDispatch, + dispatch: crate::Actions, ) -> impl IntoElement { let dismiss = dispatch.clone(); let total: usize = summaries.iter().map(|s| s.total()).sum(); @@ -280,7 +280,7 @@ fn content( ) } -fn open_cluster(dispatch: &crate::BridgeDispatch, cluster_id: i64) { +fn open_cluster(dispatch: &crate::Actions, cluster_id: i64) { dispatch.close_cluster_update(); let _ = RouterContext::get().push(Route::ClusterOverview { cluster_id }); } @@ -289,7 +289,7 @@ fn change_list( tab: UpdateTab, groups: &[ClusterGroup], show_headers: bool, - dispatch: &crate::BridgeDispatch, + dispatch: &crate::Actions, ) -> impl IntoElement { let accent = tab.accent(); @@ -350,7 +350,7 @@ fn change_list( fn cluster_header( group: &ClusterGroup, first: bool, - dispatch: crate::BridgeDispatch, + dispatch: crate::Actions, ) -> impl IntoElement { let cluster_id = group.cluster_id; let count = group.names.len(); diff --git a/packages/oneclient_app/src/components/dynamic_art.rs b/packages/oneclient_app/src/components/dynamic_art.rs index 4edf7745..3cd2a0dc 100644 --- a/packages/oneclient_app/src/components/dynamic_art.rs +++ b/packages/oneclient_app/src/components/dynamic_art.rs @@ -1,13 +1,12 @@ use bytes::Bytes; use freya::prelude::*; -use freya::query::QueryStateData; use oneclient_core::clusters::Cluster; use oneclient_core::images::DEFAULT_IMAGE_EDGE; -use oneclient_core::packages::domain::GameLoader; -use oneclient_core::{VersionKey, parse_mc_version}; +use oneclient_common::domain::GameLoader; +use oneclient_common::{VersionKey, parse_mc_version}; use crate::AppAssets; -use crate::hooks::{use_cached_image, use_version_metadata}; +use crate::hooks::{loaded_image, use_cached_image, use_version_metadata}; use crate::layout::HOME_BACKGROUND_ASSET; #[derive(PartialEq, Clone)] @@ -70,17 +69,9 @@ pub fn use_art_bytes( let image_query = use_cached_image(art_url.clone(), max_edge); - let reader = image_query.read(); - let state = reader.state(); - match (&art_url, &*state) { - (Some(url), QueryStateData::Settled { res: Ok(bytes), .. }) - | ( - Some(url), - QueryStateData::Loading { - res: Some(Ok(bytes)), - }, - ) => (format!("{max_edge}|{url}"), bytes.clone()), - _ => ( + match loaded_image(art_url.as_deref(), &image_query) { + Some((url, bytes)) => (format!("{max_edge}|{url}"), bytes), + None => ( format!("{max_edge}|{HOME_BACKGROUND_ASSET}"), fallback.read().clone(), ), diff --git a/packages/oneclient_app/src/components/file_drop.rs b/packages/oneclient_app/src/components/file_drop.rs index 707dba00..af93d44a 100644 --- a/packages/oneclient_app/src/components/file_drop.rs +++ b/packages/oneclient_app/src/components/file_drop.rs @@ -15,15 +15,14 @@ use std::path::{Path, PathBuf}; use freya::animation::{AnimNum, Ease, Function, OnCreation, use_animation}; use freya::prelude::*; -use freya::query::QueryStateData; use freya::router::use_route; use oneclient_core::clusters::Cluster; -use oneclient_core::packages::ContentType; +use oneclient_content::packages::ContentType; use oneclient_db::models::ClusterId; use crate::Route; use crate::components::{Button, Dropdown, Icon, IconType, OverlayPopup, ScrollArea}; -use crate::hooks::{use_active_cluster_id, use_clusters, use_dispatch}; +use crate::hooks::{settled_or_loading, use_active_cluster_id, use_clusters, use_dispatch}; use crate::theme::colors; use crate::ui::border_all_color; @@ -41,7 +40,7 @@ const IMPORTABLE: [ContentType; 3] = [ ContentType::Shader, ]; -/// Names that give away a shaderpack. Only a starting guess — every row has a +/// Names that give away a shaderpack. Only a starting guess; every row has a /// dropdown, so a wrong sniff costs the user one click. const SHADER_HINTS: [&str; 5] = ["shader", "bsl", "seus", "complementary", "sildur"]; @@ -90,10 +89,6 @@ fn route_target(route: &Route) -> Option<(ClusterId, ContentType)> { } } -// --------------------------------------------------------------------------- -// Overlay -// --------------------------------------------------------------------------- - /// Renders the shell's drop affordance and, once files land, the prompt asking /// where they should go. /// @@ -197,10 +192,6 @@ fn icon_badge(icon: IconType, size: f32, icon_size: f32) -> impl IntoElement { .child(Icon::new(icon).size(icon_size).color(colors::brand())) } -// --------------------------------------------------------------------------- -// Prompt -// --------------------------------------------------------------------------- - #[derive(PartialEq)] struct DropPrompt { files: Vec, @@ -224,17 +215,7 @@ impl Component for DropPrompt { // the inferred type, so a second drop landing mid-prompt still gets one. let overrides = use_state(HashMap::::new); - let clusters: Vec = { - let reader = clusters_query.read(); - let state = reader.state(); - match &*state { - QueryStateData::Settled { res: Ok(list), .. } => list.clone(), - QueryStateData::Loading { - res: Some(Ok(list)), - } => list.clone(), - _ => Vec::new(), - } - }; + let clusters: Vec = settled_or_loading(&clusters_query).unwrap_or_default(); let body = if clusters.is_empty() { no_clusters_body(pending) @@ -343,7 +324,7 @@ fn prompt_body( route_type: Option, selected_cluster: State>, overrides: State>, - dispatch: crate::BridgeDispatch, + dispatch: crate::Actions, mut pending: State>, ) -> Element { // The remembered cluster may have been deleted while the prompt was open. diff --git a/packages/oneclient_app/src/components/icons.rs b/packages/oneclient_app/src/components/icons.rs index 7eed007e..1b79ac59 100644 --- a/packages/oneclient_app/src/components/icons.rs +++ b/packages/oneclient_app/src/components/icons.rs @@ -1,5 +1,5 @@ use freya::prelude::*; -use oneclient_core::packages::ProviderId; +use oneclient_content::packages::ProviderId; use crate::{AppAssets, theme::colors}; diff --git a/packages/oneclient_app/src/components/java_install_manager.rs b/packages/oneclient_app/src/components/java_install_manager.rs index 4a3c98c2..dc85da38 100644 --- a/packages/oneclient_app/src/components/java_install_manager.rs +++ b/packages/oneclient_app/src/components/java_install_manager.rs @@ -1,5 +1,5 @@ use freya::prelude::*; -use oneclient_core::java::{AvailableJava, JavaVendor}; +use oneclient_java::{AvailableJava, JavaVendor}; use crate::components::{Button, Dropdown, Icon, IconType, OverlayPopup, ScrollArea}; use crate::hooks::{provider_versions, use_provider_versions}; diff --git a/packages/oneclient_app/src/components/java_prompt.rs b/packages/oneclient_app/src/components/java_prompt.rs index eb4cb2eb..ec73736b 100644 --- a/packages/oneclient_app/src/components/java_prompt.rs +++ b/packages/oneclient_app/src/components/java_prompt.rs @@ -1,6 +1,6 @@ use freya::prelude::*; -use oneclient_core::java::JavaVendor; -use oneclient_core::notification::{PromptKind, UserChoice}; +use oneclient_java::{JAVA_CHOICE_DOWNLOAD, JAVA_CHOICE_FOLDER, JavaVendor}; +use oneclient_events::Answer; use crate::components::{Button, Icon, IconType, JavaInstallManager, OverlayPopup}; use crate::hooks::{use_dispatch, use_notifications_snapshot}; @@ -24,9 +24,23 @@ impl Component for JavaPromptOverlay { return rect().into_element(); }; - let PromptKind::JavaInstall { major } = prompt.kind else { + // Identified by the choices it offers rather than a prompt-kind enum. + if !prompt.has_choice(JAVA_CHOICE_DOWNLOAD) { return rect().into_element(); - }; + } + + // The vendor list needs a major to highlight. It is not on the prompt (the + // core only sends display text) so recover it from the folder choice's dialog + // title, which the core builds from the same number. + let major = prompt + .choice(JAVA_CHOICE_FOLDER) + .and_then(|choice| match &choice.input { + Some(oneclient_events::ChoiceInput::Folder { title }) => title + .split_whitespace() + .find_map(|word| word.parse::().ok()), + _ => None, + }) + .unwrap_or(0); // Download opens the install manager with the required major highlighted. if *show_manager.read() { @@ -35,7 +49,9 @@ impl Component for JavaPromptOverlay { .suggested(Some(major)) .on_install(move |(vendor, _row_major): (JavaVendor, u32)| { // The launch needs `major`; the manager only picks the vendor. - install.answer_prompt(UserChoice::Install { vendor, major }); + install.answer_prompt( + Answer::new(JAVA_CHOICE_DOWNLOAD).with_selection(vendor.to_string()), + ); show_manager.set(false); }) .on_close(move |()| show_manager.set(false)) @@ -54,13 +70,15 @@ impl Component for JavaPromptOverlay { .pick_folder() .await { - dispatch.answer_prompt(UserChoice::Folder(handle.path().to_path_buf())); + dispatch.answer_prompt( + Answer::new(JAVA_CHOICE_FOLDER).with_folder(handle.path()), + ); } }); }; OverlayPopup::new() - .on_close(move |_| close.answer_prompt(UserChoice::Cancel)) + .on_close(move |_| close.dismiss_prompt()) .child( rect() .width(Size::window_percent(100.)) @@ -112,7 +130,7 @@ impl Component for JavaPromptOverlay { Button::new() .secondary() .on_press(move |_| { - cancel.answer_prompt(UserChoice::Cancel) + cancel.dismiss_prompt() }) .text("Cancel"), ) diff --git a/packages/oneclient_app/src/components/local_image.rs b/packages/oneclient_app/src/components/local_image.rs index 84de8ccd..8d39fa8a 100644 --- a/packages/oneclient_app/src/components/local_image.rs +++ b/packages/oneclient_app/src/components/local_image.rs @@ -7,9 +7,8 @@ use freya::animation::{ use freya::elements::image::{AspectRatio, ImageCover, ImageHandle, image}; use freya::engine::prelude::{SkData, SkImage}; use freya::prelude::*; -use freya::query::QueryStateData; -use crate::hooks::use_local_image; +use crate::hooks::{settled_or_loading, use_local_image}; use crate::theme::colors; #[derive(PartialEq)] @@ -40,11 +39,7 @@ impl Component for LocalImage { fn render(&self) -> impl IntoElement { let query = use_local_image(self.path.clone(), self.max_edge); - let bytes: Option = match &*query.read().state() { - QueryStateData::Settled { res: Ok(b), .. } - | QueryStateData::Loading { res: Some(Ok(b)) } => Some(b.clone()), - _ => None, - }; + let bytes: Option = settled_or_loading(&query); let mut cache = use_state(|| None::<(usize, ImageHandle)>); let holder = bytes.and_then(|bytes| { diff --git a/packages/oneclient_app/src/components/microsoft_login.rs b/packages/oneclient_app/src/components/microsoft_login.rs index 542e4486..f27add27 100644 --- a/packages/oneclient_app/src/components/microsoft_login.rs +++ b/packages/oneclient_app/src/components/microsoft_login.rs @@ -2,8 +2,8 @@ use freya::prelude::*; use freya::query::{MutationCapability, MutationStateData, UseMutation}; use freya::text_edit::Clipboard; use oneclient_core::LauncherError; -use oneclient_core::auth::{AuthErrorGuidance, MicrosoftLoginSession}; -use oneclient_core::notification::MicrosoftLoginStatus; +use oneclient_auth::{AuthErrorGuidance, MicrosoftLoginSession}; +use crate::state::LoginProgress; use crate::components::{Button, Icon, IconType, OverlayPopup}; use crate::hooks::{ @@ -21,7 +21,7 @@ pub struct MicrosoftLogin { cancel: UseMutation, pending_login: State>, cancelled: State, - status: Option, + status: Option, locked: bool, pub pending: bool, pub error: Option, @@ -151,7 +151,7 @@ where fn microsoft_dialog( login: MicrosoftLoginSession, handle: MicrosoftLogin, - status: Option, + status: Option, error: Option, guidance: Option, ) -> impl IntoElement { @@ -174,7 +174,7 @@ pub(crate) fn login_dialog( user_code: String, verification_uri: String, locked: bool, - status: Option, + status: Option, error: Option, guidance: Option, on_close: impl FnMut() + Clone + 'static, @@ -268,7 +268,7 @@ fn dialog_divider() -> impl IntoElement { .into_element() } -fn status_row(status: Option, error: Option) -> impl IntoElement { +fn status_row(status: Option, error: Option) -> impl IntoElement { if let Some(error) = error { return rect() .horizontal() diff --git a/packages/oneclient_app/src/components/notifications.rs b/packages/oneclient_app/src/components/notifications.rs index 923e819c..f1a5fb35 100644 --- a/packages/oneclient_app/src/components/notifications.rs +++ b/packages/oneclient_app/src/components/notifications.rs @@ -2,13 +2,15 @@ use freya::{ animation::{AnimNum, Ease, OnCreation, use_animation}, prelude::*, }; -use oneclient_core::notification::NotificationLevel; +use oneclient_events::Level; use crate::{ + ui::{divider, relative_time}, components::{Button, ButtonVariant, Icon, IconType, OverlayPopup, ScrollArea}, hooks::{use_dispatch, use_notifications_snapshot}, - notifications::{InboxEntry, NotificationActionKind, TransferStats}, + notifications::{InboxEntry, NotificationActionKind}, theme::colors, + transfer::TransferStats, utils::{format_duration_hms, format_size}, }; @@ -328,7 +330,7 @@ fn phase_badge(phase: &str) -> impl IntoElement { .into_element() } -fn row_body(entry: &InboxEntry, dispatch: crate::BridgeDispatch) -> impl IntoElement { +fn row_body(entry: &InboxEntry, dispatch: crate::Actions) -> impl IntoElement { rect() .vertical() .width(Size::flex(1.0)) @@ -354,7 +356,7 @@ fn row_body(entry: &InboxEntry, dispatch: crate::BridgeDispatch) -> impl IntoEle .spacing(6.) .child( label() - .text(relative_time(entry)) + .text(relative_time(entry.created_at)) .font_size(10.) .color(colors::fg_secondary()), ) @@ -378,7 +380,7 @@ fn row_body(entry: &InboxEntry, dispatch: crate::BridgeDispatch) -> impl IntoEle .child(row_extra(entry, dispatch)) } -fn row_extra(entry: &InboxEntry, dispatch: crate::BridgeDispatch) -> impl IntoElement { +fn row_extra(entry: &InboxEntry, dispatch: crate::Actions) -> impl IntoElement { if let Some((current, total)) = entry.progress.filter(|_| entry.is_loading) { let frac = (current as f32 / total.max(1) as f32).clamp(0.0, 1.0); let bar = rect() @@ -475,7 +477,7 @@ fn transfer_footer(stats: TransferStats) -> Element { .into_element() } -fn run_action(dispatch: &crate::BridgeDispatch, kind: &NotificationActionKind) { +fn run_action(dispatch: &crate::Actions, kind: &NotificationActionKind) { match kind { NotificationActionKind::OpenClusterUpdate(summaries) => { dispatch.open_cluster_update(summaries.clone()); @@ -483,13 +485,6 @@ fn run_action(dispatch: &crate::BridgeDispatch, kind: &NotificationActionKind) { } } -fn divider() -> impl IntoElement { - rect() - .width(Size::fill()) - .height(Size::px(1.)) - .background(colors::component_border()) -} - #[derive(PartialEq)] struct Footer; @@ -527,27 +522,15 @@ fn icon_for(entry: &InboxEntry) -> IconType { return IconType::DownloadCloud02; } match entry.level { - NotificationLevel::Error => IconType::AlertTriangle, - NotificationLevel::Info => IconType::InfoCircle, + Level::Error => IconType::AlertTriangle, + Level::Info => IconType::InfoCircle, } } -fn level_color(level: &NotificationLevel) -> Color { +fn level_color(level: &Level) -> Color { match level { - NotificationLevel::Info => colors::fg_primary(), - NotificationLevel::Error => colors::danger(), + Level::Info => colors::fg_primary(), + Level::Error => colors::danger(), } } -fn relative_time(entry: &InboxEntry) -> String { - let secs = entry.created_at.elapsed().as_secs(); - if secs < 60 { - "Just now".to_string() - } else if secs < 3600 { - format!("{}m ago", secs / 60) - } else if secs < 86400 { - format!("{}h ago", secs / 3600) - } else { - format!("{}d ago", secs / 86400) - } -} diff --git a/packages/oneclient_app/src/components/package_row.rs b/packages/oneclient_app/src/components/package_row.rs index b139e59a..cfe9afbf 100644 --- a/packages/oneclient_app/src/components/package_row.rs +++ b/packages/oneclient_app/src/components/package_row.rs @@ -1,10 +1,9 @@ use freya::prelude::*; -use freya::query::QueryStateData; use freya::router::RouterContext; -use oneclient_core::packages::ProviderId; +use oneclient_content::packages::ProviderId; use crate::components::{Icon, IconType, toggle_controlled}; -use crate::hooks::{ClusterAction, use_cached_image, use_cluster_mutation}; +use crate::hooks::{ClusterAction, loaded_image, use_cached_image, use_cluster_mutation}; use crate::routes::Route; use crate::theme::colors; use crate::ui::border_all_color; @@ -391,17 +390,7 @@ fn remote_icon( icon_query: &freya::query::UseQuery, size: f32, ) -> Element { - let reader = icon_query.read(); - let loaded = match (icon_url, &*reader.state()) { - (Some(url), QueryStateData::Settled { res: Ok(bytes), .. }) - | ( - Some(url), - QueryStateData::Loading { - res: Some(Ok(bytes)), - }, - ) => Some((url.clone(), bytes.clone())), - _ => None, - }; + let loaded = loaded_image(icon_url.as_deref(), icon_query); match loaded { Some((url, bytes)) => ImageViewer::new((url, bytes)) diff --git a/packages/oneclient_app/src/components/recents_row.rs b/packages/oneclient_app/src/components/recents_row.rs index ee3db5d7..0a3121c0 100644 --- a/packages/oneclient_app/src/components/recents_row.rs +++ b/packages/oneclient_app/src/components/recents_row.rs @@ -1,11 +1,10 @@ use freya::animation::*; use freya::prelude::*; -use freya::query::QueryStateData; use freya::router::RouterContext; use oneclient_core::clusters::Cluster; use crate::components::{DynamicArt, Icon, IconType}; -use crate::hooks::{use_active_cluster_id, use_clusters}; +use crate::hooks::{settled_or_loading, use_active_cluster_id, use_clusters}; use crate::routes::Route; use crate::theme::colors; use crate::ui::{border_all, border_all_color}; @@ -28,16 +27,7 @@ impl Component for RecentsRow { let clusters_query = use_clusters(); let mut visible_slots = use_state(|| 1_usize); - let clusters = { - let reader = clusters_query.read(); - match &*reader.state() { - QueryStateData::Settled { res: Ok(list), .. } => list.clone(), - QueryStateData::Loading { - res: Some(Ok(list)), - } => list.clone(), - _ => Vec::new(), - } - }; + let clusters = settled_or_loading(&clusters_query).unwrap_or_default(); let sorted: Vec = sort_clusters_for_home(clusters); let slots = *visible_slots.read(); diff --git a/packages/oneclient_app/src/components/status_bar.rs b/packages/oneclient_app/src/components/status_bar.rs index ac800198..a7786e50 100644 --- a/packages/oneclient_app/src/components/status_bar.rs +++ b/packages/oneclient_app/src/components/status_bar.rs @@ -2,7 +2,7 @@ use std::collections::HashSet; use freya::animation::{AnimNum, Ease, Function, OnCreation, use_animation}; use freya::prelude::*; -use oneclient_core::status::{self, ServiceStatus}; +use oneclient_net::status::{self, ServiceStatus}; use crate::components::{Icon, IconType}; use crate::theme::colors; diff --git a/packages/oneclient_app/src/components/toasts.rs b/packages/oneclient_app/src/components/toasts.rs index 5fc46a60..5ee98859 100644 --- a/packages/oneclient_app/src/components/toasts.rs +++ b/packages/oneclient_app/src/components/toasts.rs @@ -7,9 +7,10 @@ use freya::{ }, prelude::*, }; -use oneclient_core::notification::NotificationLevel; +use oneclient_events::Level; use crate::{ + ui::{divider}, components::{Button, ButtonSize, Icon, IconType}, hooks::{use_dispatch, use_notifications_snapshot}, notifications::{InboxEntry, NotificationActionKind}, @@ -45,8 +46,8 @@ fn entry_icon(entry: &InboxEntry) -> (IconType, Color) { } match entry.level { - NotificationLevel::Error => (IconType::AlertTriangle, colors::danger()), - NotificationLevel::Info => (IconType::DownloadCloud02, colors::fg_primary()), + Level::Error => (IconType::AlertTriangle, colors::danger()), + Level::Info => (IconType::DownloadCloud02, colors::fg_primary()), } } @@ -218,8 +219,8 @@ impl Component for ToastCard { fn toast_shell(entry: &InboxEntry) -> Rect { let border_color = match entry.level { - NotificationLevel::Error => colors::danger(), - NotificationLevel::Info => colors::component_border(), + Level::Error => colors::danger(), + Level::Info => colors::component_border(), }; rect() .width(Size::px(TOAST_W)) @@ -246,8 +247,8 @@ fn icon_badge(entry: &InboxEntry) -> impl IntoElement { fn header(entry: &InboxEntry) -> impl IntoElement { let title_color = match entry.level { - NotificationLevel::Error => colors::danger(), - NotificationLevel::Info => colors::fg_primary(), + Level::Error => colors::danger(), + Level::Info => colors::fg_primary(), }; let body_color = colors::fg_secondary(); @@ -273,8 +274,8 @@ fn header(entry: &InboxEntry) -> impl IntoElement { fn ttl_bar(entry: &InboxEntry, pct: f32) -> impl IntoElement { let color = match entry.level { - NotificationLevel::Error => colors::danger(), - NotificationLevel::Info => colors::brand(), + Level::Error => colors::danger(), + Level::Info => colors::brand(), }; rect() .width(Size::fill()) @@ -289,7 +290,7 @@ fn ttl_bar(entry: &InboxEntry, pct: f32) -> impl IntoElement { ) } -fn close_button(dispatch: crate::BridgeDispatch, id: u64) -> impl IntoElement { +fn close_button(dispatch: crate::Actions, id: u64) -> impl IntoElement { rect() .position(Position::new_absolute().top(8.).right(8.)) .child( @@ -309,7 +310,7 @@ fn close_button(dispatch: crate::BridgeDispatch, id: u64) -> impl IntoElement { fn message_card( entry: &InboxEntry, - dispatch: crate::BridgeDispatch, + dispatch: crate::Actions, id: u64, ttl_pct: f32, ) -> Rect { @@ -329,7 +330,7 @@ fn message_card( .child(ttl_bar(entry, ttl_pct)) } -fn action_card(entry: &InboxEntry, dispatch: crate::BridgeDispatch, id: u64, ttl_pct: f32) -> Rect { +fn action_card(entry: &InboxEntry, dispatch: crate::Actions, id: u64, ttl_pct: f32) -> Rect { let action = entry.actions.first().cloned(); toast_shell(entry) @@ -357,7 +358,7 @@ fn action_card(entry: &InboxEntry, dispatch: crate::BridgeDispatch, id: u64, ttl fn action_row( action: Option, - dispatch: crate::BridgeDispatch, + dispatch: crate::Actions, id: u64, ) -> impl IntoElement { let dismiss = dispatch.clone(); @@ -396,7 +397,7 @@ fn action_row( ) } -fn run_action(dispatch: &crate::BridgeDispatch, kind: &NotificationActionKind) { +fn run_action(dispatch: &crate::Actions, kind: &NotificationActionKind) { match kind { NotificationActionKind::OpenClusterUpdate(summaries) => { dispatch.open_cluster_update(summaries.clone()); @@ -456,10 +457,3 @@ fn progress_card(entry: &InboxEntry) -> Rect { ), ) } - -fn divider() -> impl IntoElement { - rect() - .width(Size::fill()) - .height(Size::px(1.)) - .background(colors::component_border()) -} diff --git a/packages/oneclient_app/src/components/update_prompt.rs b/packages/oneclient_app/src/components/update_prompt.rs index 3bb1a5b9..e3927852 100644 --- a/packages/oneclient_app/src/components/update_prompt.rs +++ b/packages/oneclient_app/src/components/update_prompt.rs @@ -1,5 +1,7 @@ use freya::prelude::*; -use oneclient_core::notification::{PromptKind, UserChoice}; +use oneclient_events::Answer; + +use crate::updater::UPDATE_CHOICE_INSTALL; use crate::components::{Button, Icon, IconType, OverlayPopup}; use crate::hooks::{use_dispatch, use_notifications_snapshot}; @@ -20,16 +22,27 @@ impl Component for UpdatePromptOverlay { return rect().into_element(); }; - if prompt.kind != PromptKind::Update { + // Recognise the update prompt by the choice it offers rather than by a + // prompt-kind enum, so the event layer needn't enumerate every prompt. + if !prompt.has_choice(UPDATE_CHOICE_INSTALL) { return rect().into_element(); } + let dismiss_label = prompt + .dismiss + .clone() + .unwrap_or_else(|| "Not now".to_string()); + let install_label = prompt + .choice(UPDATE_CHOICE_INSTALL) + .map(|choice| choice.label.clone()) + .unwrap_or_else(|| "Download".to_string()); + let close = dispatch.clone(); let cancel = dispatch.clone(); let accept = dispatch.clone(); OverlayPopup::new() - .on_close(move |_| close.answer_prompt(UserChoice::Cancel)) + .on_close(move |_| close.dismiss_prompt()) .child( rect() .width(Size::window_percent(100.)) @@ -80,19 +93,19 @@ impl Component for UpdatePromptOverlay { .child( Button::new() .secondary() - .on_press(move |_| { - cancel.answer_prompt(UserChoice::Cancel) - }) - .text("Not now"), + .on_press(move |_| cancel.dismiss_prompt()) + .text(dismiss_label), ) .child( Button::new() .primary() .on_press(move |_| { - accept.answer_prompt(UserChoice::Accept) + accept.answer_prompt(Answer::new( + UPDATE_CHOICE_INSTALL, + )) }) .child(Icon::new(IconType::DownloadCloud02).size(14.)) - .text("Download"), + .text(install_label), ), ), ), diff --git a/packages/oneclient_app/src/components/version_card.rs b/packages/oneclient_app/src/components/version_card.rs index 1b945564..8695627a 100644 --- a/packages/oneclient_app/src/components/version_card.rs +++ b/packages/oneclient_app/src/components/version_card.rs @@ -1,5 +1,5 @@ use freya::prelude::*; -use oneclient_core::VersionKey; +use oneclient_common::VersionKey; use oneclient_core::clusters::Cluster; use crate::components::ClusterLandscapeArt; diff --git a/packages/oneclient_app/src/events.rs b/packages/oneclient_app/src/events.rs new file mode 100644 index 00000000..694cc82c --- /dev/null +++ b/packages/oneclient_app/src/events.rs @@ -0,0 +1,390 @@ +//! Drains the core's event stream into the radio station. +//! +//! An event drain, the toast expiry timers, and the game-log flush. It runs +//! under freya's `spawn`, not `tokio::spawn`, because radio state is `!Send`. +//! +//! The batching matters. During a download the core emits tens of thousands of +//! progress events, and a radio write wakes every subscriber to that channel, so +//! events are drained into a batch, folded, and written once per channel rather +//! than once per event. + +use std::collections::{HashMap, HashSet}; +use std::time::Duration; + +use freya::prelude::spawn_forever; +use freya::radio::RadioStation; +use oneclient_events::{Event, EventReceiver, GameEvent, LaunchStage, ProgressEvent, Signal}; +use tokio::sync::mpsc; + +use crate::hooks::PumpSignal; +use crate::notifications::{MESSAGE_TOAST_TTL, PendingPromptView}; +use crate::state::{AppChannel, AppState, LoginProgress}; + +/// How long game log lines accumulate before being written. +/// +/// A launch emits lines continuously; without this every line would wake the +/// log view. +const GAME_LOG_FLUSH: Duration = Duration::from_millis(120); + +/// Cap on how many queued events are folded into a single write. +const COALESCE_BUDGET: usize = 1024; + +pub struct EventPump { + pub events: EventReceiver, + pub signals: mpsc::UnboundedReceiver, + pub station: RadioStation, +} + +impl EventPump { + pub async fn run(mut self) { + let mut armed: HashMap = HashMap::new(); + // Hovering any toast pauses every toast, including ones that arrive + // while hovering. + let mut paused = false; + let mut game_flush: Option = None; + + loop { + let next_deadline = armed + .values() + .filter_map(ToastTimer::deadline) + .min() + .into_iter() + .chain(game_flush) + .min(); + + let timer = async { + match next_deadline { + Some(deadline) => tokio::time::sleep_until(deadline).await, + None => std::future::pending::<()>().await, + } + }; + + tokio::select! { + () = timer => { + let now = tokio::time::Instant::now(); + let due: Vec = armed + .iter() + .filter(|(_, t)| t.deadline().is_some_and(|d| d <= now)) + .map(|(id, _)| *id) + .collect(); + + if !due.is_empty() { + { + let mut guard = self.station.write_channel(AppChannel::Notifications); + let AppState { + notifications, inbox, .. + } = &mut **guard; + for id in &due { + notifications.expire_toast(inbox, *id); + } + } + for id in due { + armed.remove(&id); + } + reconcile(&self.station, &mut armed, paused); + } + + if game_flush.is_some_and(|d| d <= now) { + game_flush = None; + // Nothing to write: log lines were folded in as they + // arrived; this only ends the quiet period. + self.station.write_channel(AppChannel::Game); + } + } + + event = self.events.recv() => { + let Some(first) = event else { break }; + let mut batch = vec![first]; + while batch.len() < COALESCE_BUDGET { + match self.events.try_recv() { + Ok(next) => batch.push(next), + Err(_) => break, + } + } + + let folded = self.apply(batch); + if folded.touched_engine { + reconcile(&self.station, &mut armed, paused); + } + if folded.saw_game_log && game_flush.is_none() { + game_flush = Some(tokio::time::Instant::now() + GAME_LOG_FLUSH); + } + } + + signal = self.signals.recv() => { + let Some(signal) = signal else { break }; + match signal { + PumpSignal::Reconcile => reconcile(&self.station, &mut armed, paused), + PumpSignal::PauseToasts => { + paused = true; + for timer in armed.values_mut() { + timer.pause(); + } + } + PumpSignal::ResumeToasts => { + paused = false; + for timer in armed.values_mut() { + timer.resume(); + } + } + } + } + } + } + } + + /// Folds a batch of events into the station, one write per channel touched. + fn apply(&mut self, batch: Vec) -> Folded { + let mut folded = Folded::default(); + let mut engine_events = Vec::new(); + let mut stages: Vec<(i64, LaunchStage)> = Vec::new(); + let mut logs: Vec<(i64, String)> = Vec::new(); + let mut failed: Option<(i64, String)> = None; + let mut login: Option> = None; + let mut sync_complete = false; + + for event in batch { + match event { + Event::Signal(Signal::ClustersChanged) => folded.clusters = true, + Event::Signal(Signal::JavaChanged) => folded.java = true, + Event::Signal(Signal::SyncComplete) => { + sync_complete = true; + folded.clusters = true; + } + Event::Game(GameEvent::Stage { cluster_id, stage }) => { + stages.push((cluster_id, stage)); + } + Event::Game(GameEvent::Log { cluster_id, line }) => { + folded.saw_game_log = true; + logs.push((cluster_id, line)); + } + Event::Game(GameEvent::Failed { + cluster_id, + message, + }) => failed = Some((cluster_id, message)), + // The sign-in modal renders this inline rather than as a toast, + // so it is lifted out here and never reaches the engine. + Event::Progress(ProgressEvent::Update { + id, + ref label, + current, + total, + }) if id == oneclient_auth::MICROSOFT_LOGIN_PROGRESS => { + login = Some((current < total).then(|| LoginProgress { + label: label.clone(), + current, + total, + })); + } + other => engine_events.push(other), + } + } + + if !stages.is_empty() || !logs.is_empty() || failed.is_some() { + let mut guard = self.station.write_channel(AppChannel::Game); + for (cluster_id, stage) in stages { + guard.game.stages.insert(cluster_id, stage); + if stage == LaunchStage::Checking { + guard.game.error = None; + guard.game.logs.insert(cluster_id, std::sync::Arc::new(Vec::new())); + } + } + for (cluster_id, line) in logs { + let buffer = guard.game.logs.entry(cluster_id).or_default(); + std::sync::Arc::make_mut(buffer).push(std::sync::Arc::from(line)); + } + if let Some((cluster_id, message)) = failed { + guard.game.stages.insert(cluster_id, LaunchStage::Exited); + guard.game.error = Some(message); + } + } + + if sync_complete { + self.station + .write_channel(AppChannel::Launcher) + .launcher + .fetching = false; + } + + if let Some(progress) = login { + self.station + .write_channel(AppChannel::MicrosoftLogin) + .microsoft_login = progress; + } + + if !engine_events.is_empty() { + folded.touched_engine = true; + let mut guard = self.station.write_channel(AppChannel::Notifications); + let AppState { + notifications, + inbox, + prompt, + .. + } = &mut **guard; + for event in engine_events { + if let (_timers, Some(pending)) = notifications.dispatch(inbox, event) { + *prompt = Some(pending); + } + } + } + + if folded.clusters { + spawn_forever(async move { crate::hooks::invalidate_cluster_queries().await }); + } + if folded.java { + spawn_forever(async move { crate::hooks::invalidate_java_queries().await }); + } + + folded + } +} + +#[derive(Default)] +struct Folded { + touched_engine: bool, + saw_game_log: bool, + clusters: bool, + java: bool, +} + +/// Arms a timer for every toast that should have one, and drops timers for +/// toasts that have gone. +fn reconcile( + station: &RadioStation, + armed: &mut HashMap, + paused: bool, +) { + let state = station.peek(); + let want: HashSet = state + .notifications + .active_toast_entry_ids() + .into_iter() + .filter(|id| state.inbox.iter().any(|e| e.id == *id && !e.is_loading)) + .collect(); + + for id in &want { + armed.entry(*id).or_insert_with(|| ToastTimer::armed(paused)); + } + armed.retain(|id, _| want.contains(id)); +} + +/// A toast's time-to-live, which hovering pauses and un-hovering resumes. +enum ToastTimer { + Running(tokio::time::Instant), + Paused(Duration), +} + +impl ToastTimer { + fn armed(paused: bool) -> Self { + if paused { + Self::Paused(MESSAGE_TOAST_TTL) + } else { + Self::Running(tokio::time::Instant::now() + MESSAGE_TOAST_TTL) + } + } + + /// The instant this toast expires, or `None` while paused. + fn deadline(&self) -> Option { + match self { + Self::Running(deadline) => Some(*deadline), + Self::Paused(_) => None, + } + } + + fn pause(&mut self) { + if let Self::Running(deadline) = self { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + *self = Self::Paused(remaining); + } + } + + fn resume(&mut self) { + if let Self::Paused(remaining) = self { + let remaining = *remaining; + *self = Self::Running(tokio::time::Instant::now() + remaining); + } + } +} + +/// Builds the view the prompt overlays render. +#[must_use] +pub fn prompt_view(state: &AppState) -> Option { + state.prompt.as_ref().map(|prompt| PendingPromptView { + title: prompt.title.clone(), + question: prompt.question.clone(), + choices: prompt.choices.clone(), + dismiss: prompt.dismiss.clone(), + }) +} + +/// Everything the app needs before the UI can do anything: build the launcher, +/// publish the handle, then start the background work. +pub async fn start_launcher( + station: RadioStation, + events: oneclient_events::EventBus, +) -> Result<(), anyhow::Error> { + let state = crate::launcher::install(oneclient_core::LauncherState::new(events).await?); + + oneclient_net::status::start(state.services.requester.clone()); + oneclient_polyplus::start(std::sync::Arc::clone(&state.auth)); + oneclient_core::run_startup_tasks(&state); + + let data_dir = oneclient_common::paths::launcher_dir() + .map(|p| p.display().to_string()) + .unwrap_or_default(); + + let settings = state.settings.read().clone(); + let auto_update = settings.auto_update; + + let mut station = station; + { + let mut guard = station.write_channel(AppChannel::Launcher); + guard.launcher = crate::state::LauncherInit { + ready: true, + fetching: true, + syncing_bundles: true, + error: None, + data_dir, + }; + } + { + let mut guard = station.write_channel(AppChannel::Settings); + guard.settings.settings = settings; + guard.settings.status = crate::state::AsyncStatus::Ready; + guard.settings.error = None; + } + + crate::hooks::invalidate_profile_queries().await; + crate::updater::spawn_update_check(auto_update); + Ok(()) +} + +/// Records a startup failure where the user can see it. +pub fn report_startup_failure( + station: &RadioStation, + err: &anyhow::Error, +) { + let message = err.to_string(); + tracing::error!("launcher init failed: {err:#}"); + + let mut station = *station; + station + .write_channel(AppChannel::Launcher) + .launcher + .error = Some(message.clone()); + + let mut guard = station.write_channel(AppChannel::Notifications); + let AppState { + notifications, inbox, .. + } = &mut **guard; + notifications.dispatch( + inbox, + Event::Notification(oneclient_events::Notification::Message( + oneclient_events::Message { + title: "Launcher failed to start".into(), + body: message, + level: oneclient_events::Level::Error, + }, + )), + ); +} diff --git a/packages/oneclient_app/src/hooks/actions.rs b/packages/oneclient_app/src/hooks/actions.rs new file mode 100644 index 00000000..f80af977 --- /dev/null +++ b/packages/oneclient_app/src/hooks/actions.rs @@ -0,0 +1,872 @@ +//! Everything the UI can ask for. +//! +//! A method per operation, each free to return a result. Core operations go +//! onto freya's UI-thread executor, since radio state is `!Send` and anything +//! that writes it must run there; pure UI toggles write it immediately. +//! +//! Always `spawn_forever`, never `spawn`. Freya's `spawn` binds a task to the +//! calling component's scope and cancels it on unmount; these are started from +//! event handlers, so a launch kicked off from a panel that then re-renders +//! would have its log tail and exit watcher killed underneath it. The work is +//! app-scoped, not component-scoped. + +use std::path::PathBuf; + +use freya::prelude::spawn_forever; +use freya::radio::RadioStation; +use oneclient_cluster::{ClusterStage, ClusterUpdate, GameSettingsProfile, ProfileUpdate}; +use oneclient_common::domain::{ContentType, ProviderId}; +use oneclient_core::settings::LauncherSettings; +use oneclient_core::settings::store::{save_global_profile, save_settings_and_apply}; +use oneclient_db::models::ClusterId; +use oneclient_events::{Answer, Level}; +use tokio::sync::mpsc; + +use crate::components::IconType; +use crate::launcher; +use crate::notifications::{ + ClusterUpdateSummary, NotificationAction, NotificationSpec, PendingPrompt, +}; +use crate::state::{AppChannel, AppState, AsyncStatus}; + +/// Asks the event pump to do something it alone owns. +/// +/// The pump holds the toast timers, so an action that adds or removes a toast +/// has to tell it to re-arm; and hover-pause is a property of the timers, not of +/// any state a component reads. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PumpSignal { + /// The notification engine changed; re-arm toast timers. + Reconcile, + PauseToasts, + ResumeToasts, +} + +#[derive(Clone)] +pub struct Actions { + station: RadioStation, + pump: mpsc::UnboundedSender, +} + +impl Actions { + #[must_use] + pub fn new( + station: RadioStation, + pump: mpsc::UnboundedSender, + ) -> Self { + Self { station, pump } + } + + fn nudge(&self, signal: PumpSignal) { + let _ = self.pump.send(signal); + } + + /// Folds a change into the notification engine, then re-arms toast timers. + fn with_engine(&self, mutate: impl FnOnce(&mut AppState)) { + { + let mut guard = self.station.clone().write_channel(AppChannel::Notifications); + mutate(&mut guard); + } + self.nudge(PumpSignal::Reconcile); + } + + fn set_settings_error(&self, error: Option) { + self.station + .clone() + .write_channel(AppChannel::Settings) + .settings + .error = error; + } + + // --- launcher settings ------------------------------------------------- + + pub fn reload_settings(&self) { + let station = self.station; + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + { + let mut guard = station.clone().write_channel(AppChannel::Settings); + guard.settings.status = AsyncStatus::Loading; + guard.settings.error = None; + } + + let loaded = oneclient_core::settings::store::load_settings(Some( + &state.services.events, + )) + .await; + let discord_enabled = loaded.discord_enabled; + *state.settings.write() = loaded.clone(); + state.discord.set_enabled(discord_enabled); + + let mut guard = station.clone().write_channel(AppChannel::Settings); + guard.settings.settings = loaded; + guard.settings.status = AsyncStatus::Ready; + guard.settings.error = None; + }); + } + + pub fn save_settings(&self) { + let actions = self.clone(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + { + let mut guard = actions.station.clone().write_channel(AppChannel::Settings); + guard.settings.saving = true; + guard.settings.error = None; + } + + let settings = state.settings.read().clone(); + let result = save_settings_and_apply(&state.services, &settings).await; + + let mut guard = actions.station.clone().write_channel(AppChannel::Settings); + guard.settings.error = result.err().map(|err| err.to_string()); + guard.settings.saving = false; + guard.settings.status = AsyncStatus::Ready; + }); + } + + /// Applies a settings change in memory and publishes it, returning the + /// snapshot to persist. + fn mutate_settings(&self, mutate: impl FnOnce(&mut LauncherSettings)) -> Option { + let state = launcher::state().ok()?; + let updated = { + let mut lock = state.settings.write(); + mutate(&mut lock); + lock.clone() + }; + + let mut guard = self.station.clone().write_channel(AppChannel::Settings); + guard.settings.settings = updated.clone(); + guard.settings.status = AsyncStatus::Ready; + Some(updated) + } + + fn persist(&self, settings: LauncherSettings) { + let actions = self.clone(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + if let Err(err) = save_settings_and_apply(&state.services, &settings).await { + actions.set_settings_error(Some(err.to_string())); + } + }); + } + + pub fn set_settings(&self, settings: LauncherSettings) { + let Some(updated) = self.mutate_settings(|s| *s = settings) else { + return; + }; + if let Ok(state) = launcher::state() { + state.discord.set_enabled(updated.discord_enabled); + } + self.persist(updated); + } + + /// Not persisted: "seen" versions are only used to decide whether to show + /// the changelog this run, and the next real save carries them along. + pub fn record_seen_version(&self, version: impl Into) { + let version = version.into(); + self.mutate_settings(|settings| { + if !settings.seen_versions.iter().any(|v| v == &version) { + settings.seen_versions.push(version); + } + }); + } + + pub fn set_seen_changelog_version(&self, version: impl Into) { + let version = version.into(); + let mut changed = false; + let updated = self.mutate_settings(|settings| { + if settings.seen_changelog_version.as_deref() != Some(version.as_str()) { + settings.seen_changelog_version = Some(version); + changed = true; + } + }); + + if let (true, Some(updated)) = (changed, updated) { + self.persist(updated); + } + } + + pub fn mark_onboarding_seen(&self) { + if let Some(updated) = self.mutate_settings(|settings| settings.seen_onboarding = true) { + self.persist(updated); + } + } + + pub fn accept_tos(&self, terms_version: u32, privacy_version: u32) { + if let Some(updated) = self.mutate_settings(|settings| { + settings.accepted_tos_version = terms_version; + settings.accepted_privacy_version = privacy_version; + }) { + self.persist(updated); + } + } + + // --- settings profiles ------------------------------------------------- + + pub fn save_global_profile(&self, profile: GameSettingsProfile) { + let actions = self.clone(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + if let Err(err) = save_global_profile(&state.settings, profile).await { + tracing::error!("failed to save the global profile: {err:#}"); + return; + } + actions.mutate_settings(|_| {}); + super::invalidate_profile_queries().await; + }); + } + + pub fn update_global_profile(&self, update: ProfileUpdate) { + let actions = self.clone(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let mut global = state.settings.read().global_game_settings.clone(); + update.apply(&mut global); + if let Err(err) = save_global_profile(&state.settings, global).await { + tracing::error!("failed to update the global profile: {err:#}"); + return; + } + actions.mutate_settings(|_| {}); + super::invalidate_profile_queries().await; + }); + } + + pub fn create_settings_profile(&self, name: impl Into) { + let name = name.into(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let global = state.settings.read().global_game_settings.clone(); + match oneclient_cluster::profiles::create_settings_profile( + &state.services.db, + &global, + &name, + ) + .await + { + Ok(_) => super::invalidate_profile_queries().await, + Err(err) => tracing::error!("failed to create settings profile: {err:#}"), + } + }); + } + + pub fn create_profile_from_global( + &self, + name: impl Into, + mem_max: Option, + force_fullscreen: Option, + ) { + let name = name.into(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let global = state.settings.read().global_game_settings.clone(); + match oneclient_cluster::profiles::create_profile_from_global( + &state.services.db, + &global, + &name, + mem_max, + force_fullscreen, + ) + .await + { + Ok(_) => super::invalidate_profile_queries().await, + Err(err) => tracing::error!("failed to create profile: {err:#}"), + } + }); + } + + pub fn upsert_named_profile(&self, profile: GameSettingsProfile) { + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + match oneclient_cluster::profiles::upsert_named_profile(&state.services.db, &profile) + .await + { + Ok(_) => super::invalidate_profile_queries().await, + Err(err) => tracing::error!("failed to save profile: {err:#}"), + } + }); + } + + pub fn update_named_profile(&self, name: impl Into, update: ProfileUpdate) { + let name = name.into(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + match oneclient_cluster::profiles::update_named_profile( + &state.services.db, + &name, + update, + ) + .await + { + Ok(_) => super::invalidate_profile_queries().await, + Err(err) => tracing::error!("failed to update profile: {err:#}"), + } + }); + } + + pub fn delete_named_profile(&self, name: impl Into) { + let name = name.into(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + match oneclient_cluster::profiles::delete_named_profile(&state.services.db, &name).await + { + Ok(()) => super::invalidate_profile_queries().await, + Err(err) => tracing::error!("failed to delete profile: {err:#}"), + } + }); + } + + pub fn update_cluster_profile(&self, cluster_id: ClusterId, update: ProfileUpdate) { + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + match state.clusters.update_profile(cluster_id, update).await { + Ok(_) => super::invalidate_profile_queries().await, + Err(err) => tracing::error!("failed to update cluster profile: {err:#}"), + } + }); + } + + pub fn create_and_assign_cluster_profile( + &self, + cluster_id: ClusterId, + profile_name: impl Into, + ) { + let profile_name = profile_name.into(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let global = state.settings.read().global_game_settings.clone(); + match state + .clusters + .create_and_assign_profile(&global, cluster_id, &profile_name) + .await + { + Ok(_) => super::invalidate_profile_queries().await, + Err(err) => tracing::error!("failed to assign cluster profile: {err:#}"), + } + }); + } + + pub fn set_cluster_loader_version(&self, cluster_id: ClusterId, version: impl Into) { + let version = version.into(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + if let Err(err) = state + .clusters + .update(cluster_id, ClusterUpdate::default().loader_version(version)) + .await + { + tracing::error!("failed to set loader version: {err:#}"); + return; + } + // Best effort: the cluster is still usable if the stage reset fails, + // it just will not re-download on next launch. + let _ = state + .clusters + .set_stage(cluster_id, ClusterStage::NotReady) + .await; + super::invalidate_cluster_queries().await; + }); + } + + // --- migration --------------------------------------------------------- + + pub fn import_launcher( + &self, + source: oneclient_core::MigrationSource, + folder_name: impl Into, + target: oneclient_core::ImportTarget, + ) { + let folder_name = folder_name.into(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let events = state.services.events.clone(); + match oneclient_core::import_migration_game_dir(&state, source, &folder_name, target) + .await + { + Ok(()) => { + events + .notify("Import complete") + .body(format!("Copied your files from {}.", source.display_name())) + .send(); + events.signal(oneclient_events::Signal::ClustersChanged); + } + Err(err) => events + .notify("Import failed") + .body(err.to_string()) + .error() + .send(), + } + }); + } + + // --- java -------------------------------------------------------------- + + pub fn install_java_runtime(&self, vendor: oneclient_java::JavaVendor, major: u32) { + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let events = state.services.events.clone(); + match state.java.install_runtime_from(&vendor, major).await { + Ok(_) => events.signal(oneclient_events::Signal::JavaChanged), + Err(err) => events + .notify("Java install failed") + .body(err.to_string()) + .error() + .send(), + } + }); + } + + pub fn add_custom_java_runtime(&self, path: PathBuf) { + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let events = state.services.events.clone(); + match state.java.add_custom_runtime(path).await { + Ok(runtime) => { + events + .notify("Java added") + .body(format!("Java {} ({})", runtime.major, runtime.vendor)) + .send(); + events.signal(oneclient_events::Signal::JavaChanged); + } + Err(err) => events + .notify("Failed to add Java") + .body(err.to_string()) + .error() + .send(), + } + }); + } + + pub fn remove_java_runtime(&self, path: impl Into) { + let path = path.into(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + match state.java.remove_runtime(&path).await { + Ok(()) => super::invalidate_java_queries().await, + Err(err) => tracing::error!("failed to remove java runtime: {err:#}"), + } + }); + } + + // --- notifications (pure UI) ------------------------------------------- + + pub fn toggle_notification_center(&self) { + self.with_engine(|state| { + state.center_open = state + .notifications + .toggle_center(&mut state.inbox, state.center_open); + }); + } + + pub fn toggle_account_switcher(&self) { + let open = self.station.peek().account_switcher_open; + self.station + .clone() + .write_channel(AppChannel::AccountSwitcher) + .account_switcher_open = !open; + } + + pub fn close_account_switcher(&self) { + self.station + .clone() + .write_channel(AppChannel::AccountSwitcher) + .account_switcher_open = false; + } + + pub fn close_notification_center(&self) { + self.with_engine(|state| state.center_open = false); + } + + pub fn clear_notification_inbox(&self) { + self.with_engine(|state| { + state.inbox.clear(); + state.notifications.clear_inbox(); + }); + } + + pub fn dismiss_toast(&self, entry_id: u64) { + self.with_engine(|state| state.notifications.dismiss_toast(&mut state.inbox, entry_id)); + } + + pub fn mark_notification_read(&self, entry_id: u64) { + self.with_engine(|state| state.notifications.mark_read(&mut state.inbox, entry_id)); + } + + pub fn dismiss_notification(&self, entry_id: u64) { + self.with_engine(|state| { + state + .notifications + .dismiss_notification(&mut state.inbox, entry_id); + }); + } + + pub fn open_cluster_update(&self, summaries: Vec) { + self.with_engine(|state| { + state.notifications.open_cluster_update(summaries); + state.center_open = false; + }); + } + + pub fn close_cluster_update(&self) { + self.with_engine(|state| state.notifications.close_cluster_update()); + } + + /// Hovering any toast pauses every toast, including ones that arrive while + /// hovering, which is why this is a pump signal rather than a state flag. + pub fn pause_toasts(&self) { + self.nudge(PumpSignal::PauseToasts); + } + + pub fn resume_toasts(&self) { + self.nudge(PumpSignal::ResumeToasts); + } + + pub fn answer_prompt(&self, answer: Answer) { + self.reply_to_prompt(Some(answer)); + } + + pub fn dismiss_prompt(&self) { + self.reply_to_prompt(None); + } + + fn reply_to_prompt(&self, answer: Option) { + let taken = self + .station + .clone() + .write_channel(AppChannel::Notifications) + .prompt + .take(); + + if let Some(PendingPrompt { + reply_tx: Some(reply_tx), + .. + }) = taken + { + let _ = reply_tx.send(answer); + } + } + + pub fn notify(&self, title: impl Into) -> NotificationBuilder { + NotificationBuilder { + actions: self.clone(), + spec: NotificationSpec { + title: title.into(), + body: String::new(), + level: Level::Info, + icon: None, + progress: None, + actions: Vec::new(), + }, + } + } + + fn push_notification(&self, spec: NotificationSpec) { + self.with_engine(|state| { + state.notifications.push_custom(&mut state.inbox, spec); + }); + } + + /// Debug-only: drives a fake progress notification through the real path. + pub fn send_test_progress(&self, current: u64, total: u64) { + let Ok(state) = launcher::state() else { return }; + let id = uuid::Uuid::from_u128(0x0CE0_0CE0_0CE0_0CE0_0CE0_0CE0_0CE0_0CE0); + state + .services + .events + .progress(id, "Downloading assets", current, total); + } + + // --- game -------------------------------------------------------------- + + pub fn launch_cluster(&self, cluster_id: ClusterId) { + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let events = state.services.events.clone(); + + // Renews a lapsed token before launching. + let account = match state.auth.default_account_for_launch().await { + Ok(account) => account, + Err(err) => { + events.game_failed(cluster_id, format!("{err:#}")); + return; + } + }; + + match account { + Some(account) => { + if let Err(err) = + oneclient_core::launch_cluster(&state, cluster_id, &account, true).await + { + events.game_failed(cluster_id, format!("{err:#}")); + } + } + None => events.game_failed( + cluster_id, + "Add a Minecraft account before launching.".to_string(), + ), + } + }); + } + + pub fn kill_cluster(&self, cluster_id: ClusterId) { + let Ok(state) = launcher::state() else { return }; + if !state.games.kill(cluster_id) { + tracing::debug!(cluster_id, "kill requested but no tracked process"); + } + } + + pub fn dismiss_game_error(&self) { + self.station + .clone() + .write_channel(AppChannel::Game) + .game + .error = None; + } + + // --- content ----------------------------------------------------------- + + pub fn import_local_file(&self, cluster_id: ClusterId, content_type: ContentType, path: PathBuf) { + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let events = state.services.events.clone(); + match oneclient_content::packages::PackageStore::import_local_file( + &path, + content_type, + cluster_id, + &state.services.content(), + ) + .await + { + Ok(row) => { + events + .notify("Imported") + .body(format!("Added {}", row.file_name)) + .send(); + super::invalidate_cluster_queries().await; + } + Err(err) => events + .notify("Import failed") + .body(err.to_string()) + .error() + .send(), + } + }); + } + + pub fn install_package( + &self, + cluster_id: ClusterId, + provider: ProviderId, + project_id: impl Into, + version_id: impl Into, + ) { + let (project_id, version_id) = (project_id.into(), version_id.into()); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let events = state.services.events.clone(); + match 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(), + } + }); + } + + pub fn install_bundle( + &self, + cluster_id: ClusterId, + bundle_name: impl Into, + skip_compatibility: bool, + ) { + let bundle_name = bundle_name.into(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let events = state.services.events.clone(); + match oneclient_core::install_bundle( + cluster_id, + &bundle_name, + skip_compatibility, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + { + Ok(_) => { + events + .notify("Installed") + .body(format!("Added {bundle_name}")) + .send(); + events.signal(oneclient_events::Signal::ClustersChanged); + } + Err(err) => events + .notify("Install failed") + .body(err.to_string()) + .error() + .send(), + } + }); + } + + pub fn apply_bundle_updates(&self, cluster_id: ClusterId) { + let actions = self.clone(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let result = match oneclient_core::apply_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + { + Ok(result) => result, + Err(err) => { + state + .services + .events + .notify("Update failed") + .body(err.to_string()) + .error() + .send(); + return; + } + }; + + super::invalidate_cluster_queries().await; + if let Some(spec) = + crate::install::cluster_update_notification(cluster_id, &result, &state.services) + .await + { + actions.push_notification(spec); + } + }); + } + + /// Syncs the bundle catalog and brings every cluster up to date. + /// + /// `syncing_bundles` gates every launch button, so the flag must not be + /// raised unless this can actually clear it, hence the readiness check before + /// the write rather than inside the task. + pub fn sync_bundles(&self) { + let state = match launcher::state() { + Ok(state) => state, + Err(err) => { + tracing::error!("bundle sync skipped, launcher not ready: {err:#}"); + return; + } + }; + + let actions = self.clone(); + self.station + .clone() + .write_channel(AppChannel::Launcher) + .launcher + .syncing_bundles = true; + + spawn_forever(async move { + + if let Err(err) = state.bundles.sync(&state.services.content()).await { + tracing::error!("bundle catalog sync failed: {err:#}"); + } + if let Err(err) = oneclient_core::clusters::apply_remote_migrations(&state).await { + tracing::error!("cluster migrations failed: {err:#}"); + } + if let Err(err) = oneclient_core::clusters::ensure_from_bundles(&state).await { + tracing::error!("bundle cluster provisioning failed: {err:#}"); + } + + // One shared grouped session for the whole batch, so every cluster's + // downloads surface as a single "update in progress" notification. + // `detach` hands that entry over so it can be converted to the + // finished "view changes" state in place rather than replaced. + let session = oneclient_events::GroupedProgressSession::start( + &state.services.events, + "Updating mods", + ); + let changed = oneclient_content::bundles::sync_all_cluster_bundles( + state.bundles.as_ref(), + &state.services.content(), + Some(&session), + ) + .await; + let session_id = session.detach(); + + actions + .station + .clone() + .write_channel(AppChannel::Launcher) + .launcher + .syncing_bundles = false; + super::invalidate_cluster_queries().await; + + let spec = crate::install::combined_cluster_update_spec(&changed, &state.services).await; + actions.with_engine(|app| { + app.notifications + .finish_grouped_as_actions(&mut app.inbox, session_id, spec); + }); + }); + } +} + +#[must_use = "the notification is not raised until `.send()` is called"] +pub struct NotificationBuilder { + actions: Actions, + spec: NotificationSpec, +} + +impl NotificationBuilder { + pub fn body(mut self, body: impl Into) -> Self { + self.spec.body = body.into(); + self + } + + pub fn level(mut self, level: Level) -> Self { + self.spec.level = level; + self + } + + pub fn info(self) -> Self { + self.level(Level::Info) + } + + pub fn error(self) -> Self { + self.level(Level::Error) + } + + pub fn icon(mut self, icon: IconType) -> Self { + self.spec.icon = Some(icon); + self + } + + pub fn progress(mut self, current: u64, total: u64) -> Self { + self.spec.progress = Some((current, total)); + self + } + + pub fn action(mut self, action: NotificationAction) -> Self { + self.spec.actions.push(action); + self + } + + pub fn actions(mut self, actions: impl IntoIterator) -> Self { + self.spec.actions = actions.into_iter().collect(); + self + } + + pub fn send(self) { + self.actions.push_notification(self.spec); + } +} diff --git a/packages/oneclient_app/src/hooks/active_cluster.rs b/packages/oneclient_app/src/hooks/active_cluster.rs index 67f03dc7..4235eb52 100644 --- a/packages/oneclient_app/src/hooks/active_cluster.rs +++ b/packages/oneclient_app/src/hooks/active_cluster.rs @@ -1,7 +1,7 @@ use std::collections::{HashMap, HashSet}; use freya::prelude::*; -use oneclient_core::packages::ProviderId; +use oneclient_content::packages::ProviderId; use oneclient_db::models::ClusterId; #[derive(Clone)] @@ -17,7 +17,7 @@ pub fn use_active_cluster_id() -> State> { /// Drives the startup splash "curtain": a full-screen cover that stays up after /// the router leaves the Startup route until the home view's clusters/art have -/// settled, then fades out — so the app never reveals a half-populated home. +/// settled, then fades out, so the app never reveals a half-populated home. #[derive(Clone, Copy)] pub struct SplashState { /// The curtain is covering the screen (raised when heading into Home). diff --git a/packages/oneclient_app/src/hooks/dispatch.rs b/packages/oneclient_app/src/hooks/dispatch.rs deleted file mode 100644 index 7583c4a5..00000000 --- a/packages/oneclient_app/src/hooks/dispatch.rs +++ /dev/null @@ -1,326 +0,0 @@ -use oneclient_core::notification::{NotificationLevel, UserChoice}; -use oneclient_core::settings::{GameSettingsProfile, LauncherSettings, ProfileUpdate}; -use oneclient_db::models::ClusterId; - -use crate::bridge::{BridgeCommand, OneClientBridge}; -use crate::components::IconType; -use crate::notifications::{ClusterUpdateSummary, NotificationAction, NotificationSpec}; - -#[derive(Clone)] -pub struct BridgeDispatch { - bridge: OneClientBridge, -} - -impl BridgeDispatch { - pub(crate) fn new(bridge: OneClientBridge) -> Self { - Self { bridge } - } - - pub fn send(&self, command: BridgeCommand) { - self.bridge.send(command); - } - - pub fn reload_settings(&self) { - self.send(BridgeCommand::ReloadSettings); - } - - pub fn save_settings(&self) { - self.send(BridgeCommand::SaveSettings); - } - - pub fn set_settings(&self, settings: LauncherSettings) { - self.send(BridgeCommand::SetSettings { settings }); - } - - pub fn record_seen_version(&self, version: impl Into) { - self.send(BridgeCommand::RecordSeenVersion { - version: version.into(), - }); - } - - pub fn set_seen_changelog_version(&self, version: impl Into) { - self.send(BridgeCommand::SetSeenChangelogVersion { - version: version.into(), - }); - } - - pub fn mark_onboarding_seen(&self) { - self.send(BridgeCommand::MarkOnboardingSeen); - } - - pub fn accept_tos(&self, terms_version: u32, privacy_version: u32) { - self.send(BridgeCommand::AcceptTos { - terms_version, - privacy_version, - }); - } - - pub fn import_launcher( - &self, - source: oneclient_core::MigrationSource, - folder_name: impl Into, - target: oneclient_core::ImportTarget, - ) { - self.send(BridgeCommand::ImportLauncher { - source, - folder_name: folder_name.into(), - target, - }); - } - - pub fn save_global_profile(&self, profile: GameSettingsProfile) { - self.send(BridgeCommand::SaveGlobalProfile { profile }); - } - - pub fn update_global_profile(&self, update: ProfileUpdate) { - self.send(BridgeCommand::UpdateGlobalProfile { update }); - } - - pub fn create_settings_profile(&self, name: impl Into) { - self.send(BridgeCommand::CreateSettingsProfile { name: name.into() }); - } - - pub fn create_profile_from_global( - &self, - name: impl Into, - mem_max: Option, - force_fullscreen: Option, - ) { - self.send(BridgeCommand::CreateProfileFromGlobal { - name: name.into(), - mem_max, - force_fullscreen, - }); - } - - pub fn upsert_named_profile(&self, profile: GameSettingsProfile) { - self.send(BridgeCommand::UpsertNamedProfile { profile }); - } - - pub fn update_named_profile(&self, name: impl Into, update: ProfileUpdate) { - self.send(BridgeCommand::UpdateNamedProfile { - name: name.into(), - update, - }); - } - - pub fn delete_named_profile(&self, name: impl Into) { - self.send(BridgeCommand::DeleteNamedProfile { name: name.into() }); - } - - pub fn update_cluster_profile(&self, cluster_id: ClusterId, update: ProfileUpdate) { - self.send(BridgeCommand::UpdateClusterProfile { cluster_id, update }); - } - - pub fn create_and_assign_cluster_profile( - &self, - cluster_id: ClusterId, - profile_name: impl Into, - ) { - self.send(BridgeCommand::CreateAndAssignClusterProfile { - cluster_id, - profile_name: profile_name.into(), - }); - } - - pub fn set_cluster_loader_version(&self, cluster_id: ClusterId, version: impl Into) { - self.send(BridgeCommand::SetClusterLoaderVersion { - cluster_id, - version: version.into(), - }); - } - - pub fn install_java_runtime(&self, vendor: oneclient_core::java::JavaVendor, major: u32) { - self.send(BridgeCommand::InstallJavaRuntime { vendor, major }); - } - - pub fn add_custom_java_runtime(&self, path: std::path::PathBuf) { - self.send(BridgeCommand::AddCustomJavaRuntime { path }); - } - - pub fn remove_java_runtime(&self, path: impl Into) { - self.send(BridgeCommand::RemoveJavaRuntime { path: path.into() }); - } - - pub fn toggle_notification_center(&self) { - self.send(BridgeCommand::ToggleNotificationCenter); - } - - pub fn close_notification_center(&self) { - self.send(BridgeCommand::CloseNotificationCenter); - } - - pub fn toggle_account_switcher(&self) { - self.send(BridgeCommand::ToggleAccountSwitcher); - } - - pub fn close_account_switcher(&self) { - self.send(BridgeCommand::CloseAccountSwitcher); - } - - pub fn clear_notification_inbox(&self) { - self.send(BridgeCommand::ClearNotificationInbox); - } - - pub fn dismiss_toast(&self, entry_id: u64) { - self.send(BridgeCommand::DismissToast(entry_id)); - } - - pub fn pause_toasts(&self) { - self.send(BridgeCommand::PauseToasts); - } - - pub fn resume_toasts(&self) { - self.send(BridgeCommand::ResumeToasts); - } - - pub fn mark_notification_read(&self, entry_id: u64) { - self.send(BridgeCommand::MarkNotificationRead(entry_id)); - } - - pub fn dismiss_notification(&self, entry_id: u64) { - self.send(BridgeCommand::DismissNotification(entry_id)); - } - - pub fn answer_prompt(&self, choice: UserChoice) { - self.send(BridgeCommand::AnswerPrompt(choice)); - } - - pub fn open_cluster_update(&self, summaries: Vec) { - self.send(BridgeCommand::OpenClusterUpdate(summaries)); - } - - pub fn close_cluster_update(&self) { - self.send(BridgeCommand::CloseClusterUpdate); - } - - pub fn notify(&self, title: impl Into) -> NotificationBuilder { - NotificationBuilder { - dispatch: self.clone(), - spec: NotificationSpec { - title: title.into(), - body: String::new(), - level: NotificationLevel::Info, - icon: None, - progress: None, - actions: Vec::new(), - }, - } - } - - pub fn send_test_progress(&self, current: u64, total: u64) { - self.send(BridgeCommand::SendTestProgress { current, total }); - } - - pub fn launch_cluster(&self, cluster_id: ClusterId) { - self.send(BridgeCommand::LaunchCluster { cluster_id }); - } - - pub fn kill_cluster(&self, cluster_id: ClusterId) { - self.send(BridgeCommand::KillCluster { cluster_id }); - } - - pub fn dismiss_game_error(&self) { - self.send(BridgeCommand::DismissGameError); - } - - pub fn import_local_file( - &self, - cluster_id: ClusterId, - content_type: oneclient_core::packages::ContentType, - path: std::path::PathBuf, - ) { - self.send(BridgeCommand::ImportLocalFile { - cluster_id, - content_type, - path, - }); - } - - pub fn install_package( - &self, - cluster_id: ClusterId, - provider: oneclient_core::packages::ProviderId, - project_id: impl Into, - version_id: impl Into, - ) { - self.send(BridgeCommand::InstallPackage { - cluster_id, - provider, - project_id: project_id.into(), - version_id: version_id.into(), - }); - } - - pub fn install_bundle( - &self, - cluster_id: ClusterId, - bundle_name: impl Into, - skip_compatibility: bool, - ) { - self.send(BridgeCommand::InstallBundle { - cluster_id, - bundle_name: bundle_name.into(), - skip_compatibility, - }); - } - - pub fn apply_bundle_updates(&self, cluster_id: ClusterId) { - self.send(BridgeCommand::ApplyBundleUpdates { cluster_id }); - } - - pub fn sync_bundles(&self) { - self.send(BridgeCommand::SyncBundles); - } -} - -#[must_use = "the notification is not dispatched until `.send()` is called"] -pub struct NotificationBuilder { - dispatch: BridgeDispatch, - spec: NotificationSpec, -} - -impl NotificationBuilder { - pub fn body(mut self, body: impl Into) -> Self { - self.spec.body = body.into(); - self - } - - pub fn level(mut self, level: NotificationLevel) -> Self { - self.spec.level = level; - self - } - - pub fn info(self) -> Self { - self.level(NotificationLevel::Info) - } - - pub fn error(self) -> Self { - self.level(NotificationLevel::Error) - } - - pub fn icon(mut self, icon: IconType) -> Self { - self.spec.icon = Some(icon); - self - } - - pub fn progress(mut self, current: u64, total: u64) -> Self { - self.spec.progress = Some((current, total)); - self - } - - pub fn action(mut self, action: NotificationAction) -> Self { - self.spec.actions.push(action); - self - } - - pub fn actions(mut self, actions: impl IntoIterator) -> Self { - self.spec.actions = actions.into_iter().collect(); - self - } - - pub fn send(self) { - self.dispatch - .send(BridgeCommand::SendNotification { spec: self.spec }); - } -} diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index b323677e..277bd08e 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -1,6 +1,6 @@ mod active_cluster; mod debounce; -mod dispatch; +mod actions; mod queries; mod view_state; @@ -15,7 +15,7 @@ pub use active_cluster::{ use_provide_onboarding_selection, use_provide_splash, use_splash, }; -pub use dispatch::BridgeDispatch; +pub use actions::{Actions, NotificationBuilder, PumpSignal}; pub use queries::{ AddOfflineAccountKeys, BROWSE_PAGE_SIZE, BeginMicrosoftLoginMutation, CachedImageQuery, CancelMicrosoftLoginKeys, CancelMicrosoftLoginMutation, ClusterAction, ClusterBundles, @@ -27,18 +27,19 @@ pub use queries::{ category_list, changelog_error, changelog_groups, changelog_is_loading, cluster_content_items, content_type_for_slug, has_migration_data, invalidate_cluster_queries, invalidate_java_queries, invalidate_logs_queries, invalidate_profile_queries, invalidate_screenshots_queries, - java_runtimes, latest_changelog_version, loader_versions, login_code_already_handled, - migration_detection, + java_runtimes, latest_changelog_version, loaded_image, loader_versions, + login_code_already_handled, migration_detection, mutation_error, mutation_is_pending, onboarding_bundles_items, package_meta_batch, - pick_version_metadata, project_detail, provider_versions, reset_login_code_dedup, search_items, - search_pending, search_total, terms_document, terms_error, terms_is_loading, try_account, + pick_version_metadata, project_detail, provider_versions, query_error, query_is_busy, + query_is_loading, reset_login_code_dedup, search_items, search_pending, search_total, + settled_or_loading, terms_document, terms_error, terms_is_loading, try_account, try_accounts, try_cluster_analytics, try_cluster_logs, try_cluster_screenshots, try_default_account, try_game_profile, try_global_analytics, try_log_content, use_account, use_accounts, use_add_microsoft_account, use_add_offline_account, use_begin_microsoft_login, use_bundle_overrides, use_bundle_updates, use_bundles_with_status, use_cached_image, use_cancel_microsoft_login, use_changelog, use_cluster_analytics, use_cluster_content, use_cluster_logs, use_cluster_mutation, use_cluster_profile, use_cluster_screenshots, - use_cluster_settings, use_clusters, use_current_account, use_default_account, + use_cluster, use_cluster_settings, use_clusters, use_current_account, use_default_account, use_finish_microsoft_login, use_game_profile, use_global_analytics, use_java_runtimes, use_loader_versions, use_local_image, use_log_action, use_log_content, use_migration, use_named_profiles, use_onboarding_bundles, use_package_categories, use_package_meta_batch, @@ -48,97 +49,63 @@ pub use queries::{ use_version_metadata, use_versions, version_list, versions_metadata, versions_total, }; -use crate::{ - bridge::{ - BridgeSnapshot, ClustersSnapshot, GameSnapshot, JavaSnapshot, LauncherInit, - OneClientBridge, ProfilesSnapshot, SettingsSnapshot, use_bridge_snapshot, - }, - notifications::NotificationSnapshot, -}; +use crate::notifications::NotificationSnapshot; +use crate::state::{AppChannel, GameState, LauncherInit, LoginProgress, SettingsState}; use freya::prelude::*; +use freya::radio::use_radio; -pub fn use_provide_bridge(bridge: &OneClientBridge) { - let bridge = bridge.clone(); - use_provide_root_context(move || bridge.clone()); +/// Publishes the actions handle so components can reach it without prop +/// drilling. Provided once, at the root. +pub fn use_provide_actions(actions: &Actions) { + let actions = actions.clone(); + use_provide_root_context(move || actions.clone()); } -pub fn use_bridge() -> OneClientBridge { - consume_root_context::() -} - -pub fn use_snapshots() -> BridgeSnapshot { - let bridge = use_bridge(); - use_bridge_snapshot(&bridge) -} - -pub fn use_dispatch() -> BridgeDispatch { - let bridge = use_bridge(); - use_hook(move || BridgeDispatch::new(bridge.clone())) +pub fn use_dispatch() -> Actions { + consume_root_context::() } +/// Subscribes to one concern of the app state. +/// +/// Each of these wakes its component only when *that* channel is written, so a +/// toast timer tick does not re-render a component that reads only `data_dir`. pub fn use_launcher() -> LauncherInit { - use_snapshots().launcher + use_radio(AppChannel::Launcher).read().launcher.clone() } -pub fn use_settings_snapshot() -> SettingsSnapshot { - use_snapshots().settings -} - -pub fn use_profiles_snapshot() -> ProfilesSnapshot { - use_snapshots().profiles -} - -pub fn use_clusters_snapshot() -> ClustersSnapshot { - use_snapshots().clusters +pub fn use_settings_snapshot() -> SettingsState { + use_radio(AppChannel::Settings).read().settings.clone() } +/// Derives the render view from the engine. +/// +/// Built on read rather than published on write: during a download the engine +/// changes tens of thousands of times, and cloning the inbox each time was the +/// snapshot channel's main cost. pub fn use_notifications_snapshot() -> NotificationSnapshot { - use_snapshots().notifications + let radio = use_radio(AppChannel::Notifications); + let state = radio.read(); + state.notifications.snapshot( + &state.inbox, + state.center_open, + crate::events::prompt_view(&state), + ) } pub fn use_account_switcher_open() -> bool { - use_snapshots().account_switcher_open + use_radio(AppChannel::AccountSwitcher) + .read() + .account_switcher_open } -pub fn use_game_snapshot() -> GameSnapshot { - use_snapshots().game +pub fn use_game_snapshot() -> GameState { + use_radio(AppChannel::Game).read().game.clone() } -pub fn use_microsoft_login_status() -> Option { - use_snapshots().microsoft_login +pub fn use_microsoft_login_status() -> Option { + use_radio(AppChannel::MicrosoftLogin) + .read() + .microsoft_login + .clone() } -pub fn use_java_snapshot() -> JavaSnapshot { - use_snapshots().java -} - -#[derive(PartialEq)] -pub struct DataSync; - -impl Component for DataSync { - fn render(&self) -> impl IntoElement { - let clusters_pulse = use_clusters_snapshot().generation; - let profiles_pulse = use_profiles_snapshot().generation; - let java_pulse = use_java_snapshot().generation; - let mut last_clusters = use_state(|| clusters_pulse); - let mut last_profiles = use_state(|| profiles_pulse); - let mut last_java = use_state(|| java_pulse); - - if *last_clusters.peek() != clusters_pulse { - last_clusters.set(clusters_pulse); - spawn(async move { invalidate_cluster_queries().await }); - } - - if *last_profiles.peek() != profiles_pulse { - last_profiles.set(profiles_pulse); - spawn(async move { invalidate_profile_queries().await }); - } - - if *last_java.peek() != java_pulse { - last_java.set(java_pulse); - spawn(async move { invalidate_java_queries().await }); - } - - rect().width(Size::px(0.)).height(Size::px(0.)) - } -} diff --git a/packages/oneclient_app/src/hooks/queries/analytics.rs b/packages/oneclient_app/src/hooks/queries/analytics.rs index 9181aa50..cd80fd77 100644 --- a/packages/oneclient_app/src/hooks/queries/analytics.rs +++ b/packages/oneclient_app/src/hooks/queries/analytics.rs @@ -1,4 +1,4 @@ -use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query}; +use freya::query::{Query, QueryCapability, UseQuery, use_query}; use oneclient_core::LauncherError; use oneclient_core::game::{Analytics, cluster_analytics, global_analytics}; @@ -14,7 +14,8 @@ impl QueryCapability for GlobalAnalyticsQuery { type Keys = GlobalAnalyticsKeys; async fn run(&self, _keys: &Self::Keys) -> Result { - global_analytics().await + let state = crate::launcher::state()?; + global_analytics(&state.services.db).await } } @@ -23,13 +24,7 @@ pub fn use_global_analytics() -> UseQuery { } pub fn try_global_analytics(query: &UseQuery) -> Option { - match &*query.read().state() { - QueryStateData::Settled { res: Ok(value), .. } => Some(value.clone()), - QueryStateData::Loading { - res: Some(Ok(value)), - } => Some(value.clone()), - _ => None, - } + super::state::settled_or_loading(query) } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -46,7 +41,8 @@ impl QueryCapability for ClusterAnalyticsQuery { type Keys = ClusterAnalyticsKeys; async fn run(&self, keys: &Self::Keys) -> Result { - cluster_analytics(keys.cluster_id).await + let state = crate::launcher::state()?; + cluster_analytics(&state.services.db, keys.cluster_id).await } } @@ -58,11 +54,5 @@ pub fn use_cluster_analytics(cluster_id: i64) -> UseQuery } pub fn try_cluster_analytics(query: &UseQuery) -> Option { - match &*query.read().state() { - QueryStateData::Settled { res: Ok(value), .. } => Some(value.clone()), - QueryStateData::Loading { - res: Some(Ok(value)), - } => Some(value.clone()), - _ => None, - } + super::state::settled_or_loading(query) } diff --git a/packages/oneclient_app/src/hooks/queries/auth.rs b/packages/oneclient_app/src/hooks/queries/auth.rs index a00bfa5c..7e309ba5 100644 --- a/packages/oneclient_app/src/hooks/queries/auth.rs +++ b/packages/oneclient_app/src/hooks/queries/auth.rs @@ -1,11 +1,10 @@ use std::sync::Mutex; use freya::query::{ - Mutation, MutationCapability, MutationStateData, QueriesStorage, Query, QueryCapability, - QueryStateData, UseMutation, UseQuery, use_mutation, use_query, + Mutation, MutationCapability, MutationStateData, QueriesStorage, Query, QueryCapability, UseMutation, UseQuery, use_mutation, use_query, }; use oneclient_core::LauncherError; -use oneclient_core::auth::{self, AccountKind, MicrosoftLoginSession, MinecraftAccount}; +use oneclient_auth::{AccountKind, MicrosoftLoginSession, MinecraftAccount}; use uuid::Uuid; static HANDLED_LOGIN_CODE: Mutex> = Mutex::new(None); @@ -45,7 +44,7 @@ impl QueryCapability for ListAccountsQuery { type Keys = ListAccountsKeys; async fn run(&self, _keys: &Self::Keys) -> Result { - auth::list_accounts().await + Ok(crate::launcher::state()?.auth.list_accounts().await) } } @@ -58,12 +57,13 @@ impl QueryCapability for DefaultAccountQuery { type Keys = DefaultAccountKeys; async fn run(&self, keys: &Self::Keys) -> Result { - let account = auth::get_default_account().await?; + let state = crate::launcher::state()?; + let account = state.auth.default_account().await?; if account.is_some() || !keys.fallback { return Ok(account); } - let accounts = auth::list_accounts().await?; + let accounts = state.auth.list_accounts().await; Ok(accounts.into_iter().next()) } } @@ -77,7 +77,7 @@ impl QueryCapability for AccountQuery { type Keys = AccountKeys; async fn run(&self, keys: &Self::Keys) -> Result { - auth::get_account(keys.id).await + Ok(crate::launcher::state()?.auth.get_account(keys.id).await) } } @@ -101,30 +101,15 @@ pub fn use_account(id: Uuid) -> UseQuery { } pub fn try_accounts(query: &UseQuery) -> Option> { - settled_ok(query) + super::state::settled_or_loading(query) } pub fn try_default_account(query: &UseQuery) -> Option { - settled_ok(query).flatten() + super::state::settled_or_loading(query).flatten() } pub fn try_account(query: &UseQuery) -> Option { - settled_ok(query).flatten() -} - -fn settled_ok(query: &UseQuery) -> Option -where - Q: QueryCapability, - Q::Ok: Clone, -{ - let reader = query.read(); - match &*reader.state() { - QueryStateData::Settled { res: Ok(value), .. } => Some(value.clone()), - QueryStateData::Loading { - res: Some(Ok(value)), - } => Some(value.clone()), - _ => None, - } + super::state::settled_or_loading(query).flatten() } async fn invalidate_auth_queries(account_id: Option) { @@ -149,7 +134,7 @@ impl MutationCapability for BeginMicrosoftLoginMutation { type Keys = (); async fn run(&self, _keys: &Self::Keys) -> Result { - auth::begin_microsoft_login().await + Ok(crate::launcher::state()?.auth.begin_microsoft_login().await?) } } @@ -162,7 +147,7 @@ impl MutationCapability for FinishMicrosoftLoginMutation { type Keys = MicrosoftLoginSession; async fn run(&self, flow: &Self::Keys) -> Result { - auth::finish_microsoft_login(flow.clone()).await + Ok(crate::launcher::state()?.auth.finish_microsoft_login(flow.clone()).await?) } async fn on_settled(&self, _keys: &Self::Keys, result: &Result) { @@ -188,7 +173,7 @@ impl MutationCapability for CancelMicrosoftLoginMutation { type Keys = CancelMicrosoftLoginKeys; async fn run(&self, keys: &Self::Keys) -> Result { - auth::cancel_microsoft_login(&keys.state_token).await; + crate::launcher::state()?.auth.cancel_microsoft_login(&keys.state_token).await; Ok(()) } } @@ -202,8 +187,9 @@ impl MutationCapability for AddMicrosoftAccountMutation { type Keys = (); async fn run(&self, _keys: &Self::Keys) -> Result { - let flow = auth::begin_microsoft_login().await?; - auth::finish_microsoft_login(flow).await + let state = crate::launcher::state()?; + let flow = state.auth.begin_microsoft_login().await?; + Ok(state.auth.finish_microsoft_login(flow).await?) } async fn on_settled(&self, _keys: &Self::Keys, result: &Result) { @@ -227,7 +213,7 @@ impl MutationCapability for AddOfflineAccountMutation { type Keys = AddOfflineAccountKeys; async fn run(&self, keys: &Self::Keys) -> Result { - auth::add_offline_account(keys.username.clone()).await + Ok(crate::launcher::state()?.auth.add_offline_account(keys.username.clone()).await?) } async fn on_settled(&self, _keys: &Self::Keys, result: &Result) { @@ -251,7 +237,7 @@ impl MutationCapability for RemoveAccountMutation { type Keys = RemoveAccountKeys; async fn run(&self, keys: &Self::Keys) -> Result { - auth::remove_account(keys.id).await + Ok(crate::launcher::state()?.auth.remove_account(keys.id).await?) } async fn on_settled(&self, keys: &Self::Keys, result: &Result) { @@ -275,7 +261,7 @@ impl MutationCapability for SetDefaultAccountMutation { type Keys = SetDefaultAccountKeys; async fn run(&self, keys: &Self::Keys) -> Result { - auth::set_default_account(keys.id).await + Ok(crate::launcher::state()?.auth.set_default_account(keys.id).await?) } async fn on_settled(&self, keys: &Self::Keys, result: &Result) { @@ -299,7 +285,7 @@ impl MutationCapability for RefreshAccountMutation { type Keys = RefreshAccountKeys; async fn run(&self, keys: &Self::Keys) -> Result { - auth::refresh_account(keys.id).await + Ok(crate::launcher::state()?.auth.refresh_account(keys.id).await?) } async fn on_settled(&self, keys: &Self::Keys, result: &Result) { @@ -318,7 +304,7 @@ impl MutationCapability for RefreshAllAccountsMutation { type Keys = (); async fn run(&self, _keys: &Self::Keys) -> Result { - auth::refresh_all_accounts().await + Ok(crate::launcher::state()?.auth.refresh_all_accounts().await?) } async fn on_settled(&self, _keys: &Self::Keys, result: &Result) { diff --git a/packages/oneclient_app/src/hooks/queries/bundles.rs b/packages/oneclient_app/src/hooks/queries/bundles.rs index b8709748..f24b88cf 100644 --- a/packages/oneclient_app/src/hooks/queries/bundles.rs +++ b/packages/oneclient_app/src/hooks/queries/bundles.rs @@ -1,9 +1,9 @@ use std::collections::HashMap; -use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query}; -use oneclient_core::clusters::{Cluster, ClusterManager}; +use freya::query::{Query, QueryCapability, UseQuery, use_query}; +use oneclient_core::clusters::Cluster; use oneclient_core::{ - BundleArchive, BundleUpdateCheckResult, BundleWithUpdateStatus, LauncherError, LauncherState, + BundleArchive, BundleUpdateCheckResult, BundleWithUpdateStatus, LauncherError, get_bundles_with_update_status, list_cluster_bundle_overrides, }; use oneclient_db::models::ClusterId; @@ -26,14 +26,14 @@ impl QueryCapability for OnboardingBundlesQuery { type Keys = OnboardingBundlesKeys; async fn run(&self, _keys: &Self::Keys) -> Result { - let state = LauncherState::get()?; - let clusters = ClusterManager::list(&state).await?; + let state = crate::launcher::state()?; + let clusters = state.clusters.list().await?; let mut out = Vec::with_capacity(clusters.len()); for cluster in clusters { let archives = state .bundles - .archives_for(&state.services, &cluster.mc_version, cluster.mc_loader) + .archives_for(&state.services.content(), &cluster.mc_version, cluster.mc_loader) .await .unwrap_or_default(); out.push(ClusterBundles { cluster, archives }); @@ -49,14 +49,7 @@ pub fn use_onboarding_bundles() -> UseQuery { pub fn onboarding_bundles_items( query: &UseQuery, ) -> Option> { - let reader = query.read(); - match &*reader.state() { - QueryStateData::Settled { res: Ok(list), .. } => Some(list.clone()), - QueryStateData::Loading { - res: Some(Ok(list)), - } => Some(list.clone()), - _ => None, - } + super::state::settled_or_loading(query) } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -76,23 +69,22 @@ impl QueryCapability for BundlesWithStatusQuery { async fn run(&self, keys: &Self::Keys) -> Result { let _ = keys; - let state = LauncherState::get()?; - get_bundles_with_update_status(self.cluster_id, state.bundles.as_ref(), &state.services) - .await + let state = crate::launcher::state()?; + Ok( + get_bundles_with_update_status( + self.cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await?, + ) } } pub fn bundles_with_status_items( query: &UseQuery, ) -> Vec { - let reader = query.read(); - match &*reader.state() { - QueryStateData::Settled { res: Ok(list), .. } => list.clone(), - QueryStateData::Loading { - res: Some(Ok(list)), - } => list.clone(), - _ => Vec::new(), - } + super::state::settled_or_loading(query).unwrap_or_default() } pub fn use_bundles_with_status(cluster_id: ClusterId) -> UseQuery { @@ -119,8 +111,8 @@ impl QueryCapability for BundleOverridesQuery { async fn run(&self, keys: &Self::Keys) -> Result { let _ = keys; - let state = LauncherState::get()?; - let rows = list_cluster_bundle_overrides(self.cluster_id, &state.services).await?; + let state = crate::launcher::state()?; + let rows = list_cluster_bundle_overrides(self.cluster_id, &state.services.content()).await?; Ok(rows .into_iter() .map(|(bundle, pkg, ty)| ((bundle, pkg), ty)) @@ -138,12 +130,7 @@ pub fn use_bundle_overrides(cluster_id: ClusterId) -> UseQuery, ) -> HashMap<(String, String), String> { - let reader = query.read(); - match &*reader.state() { - QueryStateData::Settled { res: Ok(map), .. } => map.clone(), - QueryStateData::Loading { res: Some(Ok(map)) } => map.clone(), - _ => HashMap::new(), - } + super::state::settled_or_loading(query).unwrap_or_default() } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -163,13 +150,13 @@ impl QueryCapability for BundleUpdatesQuery { async fn run(&self, keys: &Self::Keys) -> Result { let _ = keys; - let state = LauncherState::get()?; - oneclient_core::check_bundle_updates( + let state = crate::launcher::state()?; + Ok(oneclient_core::check_bundle_updates( self.cluster_id, state.bundles.as_ref(), - &state.services, + &state.services.content(), ) - .await + .await?) } } diff --git a/packages/oneclient_app/src/hooks/queries/changelog.rs b/packages/oneclient_app/src/hooks/queries/changelog.rs index 0bf215c6..55d29431 100644 --- a/packages/oneclient_app/src/hooks/queries/changelog.rs +++ b/packages/oneclient_app/src/hooks/queries/changelog.rs @@ -1,10 +1,8 @@ -use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query}; +use freya::query::{Query, QueryCapability, UseQuery, use_query}; use oneclient_core::{ - ChangelogGroup, LauncherError, LauncherState, fetch_changelog, parse_changelog, + ChangelogGroup, LauncherError, fetch_changelog, parse_changelog, }; -use crate::hooks::use_settings_snapshot; - #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct ChangelogKeys { pub meta_url_base: String, @@ -19,34 +17,20 @@ impl QueryCapability for ChangelogQuery { type Keys = ChangelogKeys; async fn run(&self, _keys: &Self::Keys) -> Result { - let state = LauncherState::get()?; - let markdown = fetch_changelog(&state.services).await?; + let state = crate::launcher::state()?; + let markdown = fetch_changelog(&state.services.requester).await?; Ok(parse_changelog(&markdown)) } } pub fn use_changelog() -> UseQuery { - let settings = use_settings_snapshot().settings; - let meta_url_base = settings - .custom_meta_url_base - .clone() - .unwrap_or_default() - .trim() - .to_string(); + let meta_url_base = super::use_meta_url_key(); use_query(Query::new(ChangelogKeys { meta_url_base }, ChangelogQuery)) } pub fn changelog_groups(query: &UseQuery) -> Option> { - match &*query.read().state() { - QueryStateData::Settled { - res: Ok(groups), .. - } => Some(groups.clone()), - QueryStateData::Loading { - res: Some(Ok(groups)), - } => Some(groups.clone()), - _ => None, - } + super::state::settled_or_loading(query) } pub fn latest_changelog_version(query: &UseQuery) -> Option { @@ -54,15 +38,9 @@ pub fn latest_changelog_version(query: &UseQuery) -> Option) -> Option { - match &*query.read().state() { - QueryStateData::Settled { res: Err(err), .. } => Some(err.to_string()), - _ => None, - } + super::state::query_error(query) } pub fn changelog_is_loading(query: &UseQuery) -> bool { - matches!( - &*query.read().state(), - QueryStateData::Pending | QueryStateData::Loading { res: None } - ) + super::state::query_is_loading(query) } diff --git a/packages/oneclient_app/src/hooks/queries/cluster_content.rs b/packages/oneclient_app/src/hooks/queries/cluster_content.rs index 2159fcbb..33f62445 100644 --- a/packages/oneclient_app/src/hooks/queries/cluster_content.rs +++ b/packages/oneclient_app/src/hooks/queries/cluster_content.rs @@ -1,6 +1,6 @@ -use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query}; -use oneclient_core::packages::{ContentType, PackageStore}; -use oneclient_core::{LauncherError, LauncherState, LinkedArtifactInfo}; +use freya::query::{Query, QueryCapability, UseQuery, use_query}; +use oneclient_content::packages::{ContentType, PackageStore}; +use oneclient_core::{LauncherError, LinkedArtifactInfo}; use oneclient_db::models::ClusterId; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -21,8 +21,8 @@ impl QueryCapability for ClusterContentQuery { type Keys = ClusterContentKeys; async fn run(&self, _keys: &Self::Keys) -> Result { - let state = LauncherState::get()?; - let all = PackageStore::list_linked_artifacts(self.cluster_id, &state.services).await?; + let state = crate::launcher::state()?; + let all = PackageStore::list_linked_artifacts(self.cluster_id, &state.services.content()).await?; Ok(all .into_iter() .filter(|item| item.content_type == self.content_type) @@ -47,12 +47,5 @@ pub fn use_cluster_content( } pub fn cluster_content_items(query: &UseQuery) -> Vec { - let reader = query.read(); - match &*reader.state() { - QueryStateData::Settled { res: Ok(list), .. } => list.clone(), - QueryStateData::Loading { - res: Some(Ok(list)), - } => list.clone(), - _ => Vec::new(), - } + super::state::settled_or_loading(query).unwrap_or_default() } diff --git a/packages/oneclient_app/src/hooks/queries/clusters.rs b/packages/oneclient_app/src/hooks/queries/clusters.rs index 61f487f0..4b6cc0b2 100644 --- a/packages/oneclient_app/src/hooks/queries/clusters.rs +++ b/packages/oneclient_app/src/hooks/queries/clusters.rs @@ -1,7 +1,6 @@ use freya::query::{Query, QueryCapability, UseQuery, use_query}; use oneclient_core::LauncherError; -use oneclient_core::LauncherState; -use oneclient_core::clusters::{Cluster, ClusterManager}; +use oneclient_core::clusters::Cluster; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct ListClustersQuery; @@ -15,11 +14,21 @@ impl QueryCapability for ListClustersQuery { type Keys = ListClustersKeys; async fn run(&self, _keys: &Self::Keys) -> Result { - let state = LauncherState::get()?; - ClusterManager::list(&state).await + let state = crate::launcher::state()?; + Ok(state.clusters.list().await?) } } pub fn use_clusters() -> UseQuery { use_query(Query::new(ListClustersKeys, ListClustersQuery)) } + +/// The one cluster a route is about, pulled out of the list query. +/// +/// There is no per-cluster query, since the list is always loaded and small, so +/// this is a lookup, not a fetch. It is a hook because it subscribes to the +/// list on the caller's behalf. +pub fn use_cluster(cluster_id: i64) -> Option { + let clusters = super::state::settled_or_loading(&use_clusters()).unwrap_or_default(); + clusters.into_iter().find(|c| c.id == cluster_id) +} diff --git a/packages/oneclient_app/src/hooks/queries/generation.rs b/packages/oneclient_app/src/hooks/queries/generation.rs deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/oneclient_app/src/hooks/queries/image.rs b/packages/oneclient_app/src/hooks/queries/image.rs index 41b16178..be6488c6 100644 --- a/packages/oneclient_app/src/hooks/queries/image.rs +++ b/packages/oneclient_app/src/hooks/queries/image.rs @@ -2,7 +2,7 @@ use std::time::Duration; use bytes::Bytes; use freya::query::{Query, QueryCapability, UseQuery, use_query}; -use oneclient_core::{LauncherError, LauncherState}; +use oneclient_core::LauncherError; const IMAGE_STALE: Duration = Duration::from_secs(60 * 60); const IMAGE_CLEAN: Duration = Duration::from_secs(6 * 60 * 60); @@ -27,14 +27,24 @@ impl QueryCapability for CachedImageQuery { return Err(LauncherError::Minecraft("no image url".to_string())); } - let state = LauncherState::get()?; + let state = crate::launcher::state()?; state .images - .get(&state.services, &keys.url, keys.max_edge) + .get(&state.services.requester, &keys.url, keys.max_edge) .await } } +/// The bytes of a cached image, once they are there. +/// +/// The url comes back with them because callers key their image handle on it; +/// a stale url paired with fresh bytes renders the wrong picture for a frame. +pub fn loaded_image(url: Option<&str>, query: &UseQuery) -> Option<(String, Bytes)> { + let url = url?; + let bytes = super::state::settled_or_loading(query)?; + Some((url.to_string(), bytes)) +} + pub fn use_cached_image(url: Option, max_edge: u32) -> UseQuery { use_query( Query::new( diff --git a/packages/oneclient_app/src/hooks/queries/java.rs b/packages/oneclient_app/src/hooks/queries/java.rs index df34a5c4..ddb1a1c9 100644 --- a/packages/oneclient_app/src/hooks/queries/java.rs +++ b/packages/oneclient_app/src/hooks/queries/java.rs @@ -1,6 +1,5 @@ -use freya::query::{QueriesStorage, Query, QueryCapability, QueryStateData, UseQuery, use_query}; -use oneclient_core::LauncherState; -use oneclient_core::java::{AvailableJava, JavaManager, JavaRuntime, JavaVendor}; +use freya::query::{QueriesStorage, Query, QueryCapability, UseQuery, use_query}; +use oneclient_java::{AvailableJava, JavaRuntime, JavaVendor}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct ListJavaRuntimesQuery; @@ -14,8 +13,8 @@ impl QueryCapability for ListJavaRuntimesQuery { type Keys = ListJavaRuntimesKeys; async fn run(&self, _keys: &Self::Keys) -> Result { - let state = LauncherState::get().map_err(|e| e.to_string())?; - JavaManager::list_runtimes(&state.services.db) + let state = crate::launcher::state().map_err(|e| e.to_string())?; + state.java.list_runtimes() .await .map_err(|e| e.to_string()) } @@ -26,14 +25,7 @@ pub fn use_java_runtimes() -> UseQuery { } pub fn java_runtimes(query: &UseQuery) -> Vec { - let reader = query.read(); - match &*reader.state() { - QueryStateData::Settled { res: Ok(list), .. } => list.clone(), - QueryStateData::Loading { - res: Some(Ok(list)), - } => list.clone(), - _ => Vec::new(), - } + super::state::settled_or_loading(query).unwrap_or_default() } pub async fn invalidate_java_queries() { @@ -54,8 +46,8 @@ impl QueryCapability for ProviderVersionsQuery { type Keys = ProviderVersionsKeys; async fn run(&self, keys: &Self::Keys) -> Result { - let state = LauncherState::get().map_err(|e| e.to_string())?; - JavaManager::available_versions(&state.services, &keys.vendor) + let state = crate::launcher::state().map_err(|e| e.to_string())?; + state.java.available_versions(&keys.vendor) .await .map_err(|e| e.to_string()) } @@ -69,13 +61,8 @@ pub fn use_provider_versions(vendor: JavaVendor) -> UseQuery) -> (Vec, bool) { - let reader = query.read(); - match &*reader.state() { - QueryStateData::Settled { res: Ok(list), .. } => (list.clone(), false), - QueryStateData::Settled { res: Err(_), .. } => (Vec::new(), false), - QueryStateData::Loading { - res: Some(Ok(list)), - } => (list.clone(), true), - _ => (Vec::new(), true), - } + ( + super::state::settled_or_loading(query).unwrap_or_default(), + super::state::query_is_busy(query), + ) } diff --git a/packages/oneclient_app/src/hooks/queries/logs.rs b/packages/oneclient_app/src/hooks/queries/logs.rs index 4d51fdb3..2fe6ae85 100644 --- a/packages/oneclient_app/src/hooks/queries/logs.rs +++ b/packages/oneclient_app/src/hooks/queries/logs.rs @@ -1,12 +1,11 @@ use std::path::PathBuf; use freya::query::{ - Mutation, MutationCapability, QueriesStorage, Query, QueryCapability, QueryStateData, + Mutation, MutationCapability, QueriesStorage, Query, QueryCapability, UseMutation, UseQuery, use_mutation, use_query, }; -use oneclient_core::clusters::ClusterManager; use oneclient_core::{ - LauncherError, LauncherState, LogFileInfo, LogLevel, LogLine, MclogsUploadResponse, ReadOptions, + LauncherError, LogFileInfo, LogLevel, LogLine, MclogsUploadResponse, ReadOptions, }; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -23,9 +22,9 @@ impl QueryCapability for ClusterLogsQuery { type Keys = ClusterLogsKeys; async fn run(&self, keys: &Self::Keys) -> Result { - let state = LauncherState::get()?; - let cluster = ClusterManager::get(&state, keys.cluster_id).await?; - oneclient_core::list_cluster_logs(&cluster) + let state = crate::launcher::state()?; + let cluster = state.clusters.get(keys.cluster_id).await?; + Ok(oneclient_core::list_cluster_logs(&cluster)?) } } @@ -49,7 +48,7 @@ impl QueryCapability for LogContentQuery { if keys.path.as_os_str().is_empty() { return Ok(Vec::new()); } - oneclient_core::read_log_at( + Ok(oneclient_core::read_log_at( &keys.path, &ReadOptions { level_filter: keys.level, @@ -57,7 +56,7 @@ impl QueryCapability for LogContentQuery { max_lines: keys.max_lines, }, ) - .await + .await?) } } @@ -82,26 +81,12 @@ pub fn use_log_content( )) } -fn settled_ok(query: &UseQuery) -> Option -where - Q: QueryCapability, - Q::Ok: Clone, -{ - match &*query.read().state() { - QueryStateData::Settled { res: Ok(value), .. } => Some(value.clone()), - QueryStateData::Loading { - res: Some(Ok(value)), - } => Some(value.clone()), - _ => None, - } -} - pub fn try_cluster_logs(query: &UseQuery) -> Option> { - settled_ok(query) + super::state::settled_or_loading(query) } pub fn try_log_content(query: &UseQuery) -> Option> { - settled_ok(query) + super::state::settled_or_loading(query) } pub async fn invalidate_logs_queries() { @@ -123,8 +108,8 @@ impl MutationCapability for UploadLogMutation { type Keys = UploadLogKeys; async fn run(&self, keys: &Self::Keys) -> Result { - let state = LauncherState::get()?; - oneclient_core::upload_log_at(&state.services, &keys.path).await + let state = crate::launcher::state()?; + Ok(oneclient_core::upload_log_at(&state.services.requester, &keys.path).await?) } } @@ -149,7 +134,7 @@ impl MutationCapability for LogActionMutation { async fn run(&self, keys: &Self::Keys) -> Result { match keys { - LogAction::Delete { path } => oneclient_core::delete_log_at(path).await, + LogAction::Delete { path } => Ok(oneclient_core::delete_log_at(path).await?), } } diff --git a/packages/oneclient_app/src/hooks/queries/migration.rs b/packages/oneclient_app/src/hooks/queries/migration.rs index 6d436cae..1f6c023d 100644 --- a/packages/oneclient_app/src/hooks/queries/migration.rs +++ b/packages/oneclient_app/src/hooks/queries/migration.rs @@ -1,6 +1,6 @@ -use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query}; +use freya::query::{Query, QueryCapability, UseQuery, use_query}; use oneclient_core::{ - LauncherError, LauncherState, MigrationDetection, detect_migration, resolve_migration_chain, + LauncherError, MigrationDetection, detect_migration, resolve_migration_chain, }; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -22,7 +22,7 @@ impl QueryCapability for MigrationQuery { // remote cluster migrations run on startup (so before we even get to launcher migrations) // this pretty much tries to migrate the source instance to the latest version in the migration chain, // so that we can import it into the correct cluster - let rules = LauncherState::get()?.versions.migrations().await; + let rules = crate::launcher::state()?.versions.migrations().await; if !rules.is_empty() { for instance in &mut detection.instances { if instance.mc_version.is_empty() { @@ -45,12 +45,7 @@ pub fn use_migration() -> UseQuery { } pub fn migration_detection(query: &UseQuery) -> Option { - let reader = query.read(); - match &*reader.state() { - QueryStateData::Settled { res: Ok(opt), .. } => opt.clone(), - QueryStateData::Loading { res: Some(Ok(opt)) } => opt.clone(), - _ => None, - } + super::state::settled_or_loading(query).flatten() } pub fn has_migration_data(query: &UseQuery) -> bool { diff --git a/packages/oneclient_app/src/hooks/queries/mod.rs b/packages/oneclient_app/src/hooks/queries/mod.rs index 25beb33e..63810c7c 100644 --- a/packages/oneclient_app/src/hooks/queries/mod.rs +++ b/packages/oneclient_app/src/hooks/queries/mod.rs @@ -1,3 +1,20 @@ +use crate::hooks::use_settings_snapshot; + +/// The cache key for anything fetched from the metadata server. +/// +/// Not the url itself, just enough of the setting to notice when it changes, +/// so pointing the launcher at a different meta host refetches instead of +/// serving the old host's answer. The queries read the real base out of +/// settings when they run. +fn use_meta_url_key() -> String { + use_settings_snapshot() + .settings + .custom_meta_url_base + .unwrap_or_default() + .trim() + .to_string() +} + mod analytics; mod auth; mod bundles; @@ -14,6 +31,7 @@ mod player_profile; mod screenshots; mod settings_profiles; mod skin; +mod state; mod tos; mod version_metadata; mod versions; @@ -41,8 +59,8 @@ pub use changelog::{ changelog_error, changelog_groups, changelog_is_loading, latest_changelog_version, use_changelog, }; pub use cluster_content::{cluster_content_items, use_cluster_content}; -pub use clusters::use_clusters; -pub use image::{CachedImageQuery, use_cached_image}; +pub use clusters::{use_cluster, use_clusters}; +pub use image::{CachedImageQuery, loaded_image, use_cached_image}; pub use java::{ invalidate_java_queries, java_runtimes, provider_versions, use_java_runtimes, use_provider_versions, @@ -72,6 +90,7 @@ pub use settings_profiles::{ use_named_profiles, }; pub use skin::use_player_skin; +pub use state::{query_error, query_is_busy, query_is_loading, settled_or_loading}; pub use tos::{TermsQuery, terms_document, terms_error, terms_is_loading, use_terms}; pub use version_metadata::{pick_version_metadata, use_version_metadata}; pub use versions::{loader_versions, use_loader_versions, use_versions, versions_metadata}; diff --git a/packages/oneclient_app/src/hooks/queries/mutations.rs b/packages/oneclient_app/src/hooks/queries/mutations.rs index 3c539d2c..ba0ce185 100644 --- a/packages/oneclient_app/src/hooks/queries/mutations.rs +++ b/packages/oneclient_app/src/hooks/queries/mutations.rs @@ -1,8 +1,7 @@ use std::path::PathBuf; use freya::query::{Mutation, MutationCapability, QueriesStorage, UseMutation, use_mutation}; -use oneclient_core::LauncherState; -use oneclient_core::packages::{ContentType, PackageStore}; +use oneclient_content::packages::{ContentType, PackageStore}; use oneclient_db::models::{ClusterId, OverrideType}; use super::bundles::{BundleOverridesQuery, BundleUpdatesQuery, BundlesWithStatusQuery}; @@ -75,19 +74,20 @@ impl MutationCapability for ClusterMutation { type Keys = ClusterAction; async fn run(&self, keys: &ClusterAction) -> Result<(), String> { - let state = LauncherState::get().map_err(|e| e.to_string())?; + let state = crate::launcher::state().map_err(|e| e.to_string())?; let services = &state.services; + let content = &state.services.content(); let result = match keys { ClusterAction::ToggleArtifact { cluster_id, hash } => { - PackageStore::toggle_artifact_enabled(*cluster_id, hash, services) + oneclient_core::toggle_artifact_enabled(*cluster_id, hash, content) .await .map(|_| ()) } ClusterAction::RemoveArtifact { cluster_id, hash } => { - PackageStore::unlink_from_cluster(hash, *cluster_id, services).await + oneclient_core::remove_artifact_from_cluster(*cluster_id, hash, true, content).await } ClusterAction::RemoveBundlePackageFromDisk { cluster_id, hash } => { - PackageStore::unlink_from_cluster_system(hash, *cluster_id, services).await + oneclient_core::remove_artifact_from_cluster(*cluster_id, hash, false, content).await } ClusterAction::SetBundlePackageEnabled { cluster_id, @@ -108,7 +108,7 @@ impl MutationCapability for ClusterMutation { bundle_name, package_id, override_type, - services, + content, ) .await } @@ -116,23 +116,28 @@ impl MutationCapability for ClusterMutation { cluster_id, content_type, path, - } => PackageStore::import_local_file(path, *content_type, *cluster_id, services) + } => PackageStore::import_local_file(path, *content_type, *cluster_id, content) .await .map(|row| { services - .notifier - .send_info("Imported", &format!("Added {}", row.file_name)); + .events + .notify("Imported").body(format!("Added {}", row.file_name)).send(); }), ClusterAction::SetDedicatedDir { cluster_id, dedicated, } => { - oneclient_core::clusters::ClusterManager::set_dedicated_dir( - &state, - *cluster_id, - *dedicated, - ) - .await + state + .clusters + .set_dedicated_dir( + *cluster_id, + *dedicated, + state.games.is_active(*cluster_id), + ) + .await + .map_err(|err| oneclient_content::ContentError::InvalidData { + reason: err.to_string(), + }) } }; result.map_err(|e| e.to_string()) @@ -140,9 +145,9 @@ impl MutationCapability for ClusterMutation { async fn on_settled(&self, _keys: &ClusterAction, result: &Result<(), String>) { if let Err(err) = result - && let Ok(state) = LauncherState::get() + && let Ok(state) = crate::launcher::state() { - state.services.notifier.send_error("Action failed", err); + state.services.events.notify("Action failed").body(err).error().send(); } invalidate_cluster_queries().await; } diff --git a/packages/oneclient_app/src/hooks/queries/packages.rs b/packages/oneclient_app/src/hooks/queries/packages.rs index 0a86189e..0900bf57 100644 --- a/packages/oneclient_app/src/hooks/queries/packages.rs +++ b/packages/oneclient_app/src/hooks/queries/packages.rs @@ -1,11 +1,11 @@ -use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query}; -use oneclient_core::packages::domain::GameLoader; -use oneclient_core::packages::types::{ +use freya::query::{Query, QueryCapability, UseQuery, use_query}; +use oneclient_common::domain::GameLoader; +use oneclient_content::packages::types::{ DEFAULT_PAGE_SIZE, Page, ProjectDetail, ProjectSummary, SearchFilters, SearchSort, VersionSummary, }; -use oneclient_core::packages::{CachedPackageMeta, ContentType, ProviderId}; -use oneclient_core::{LauncherError, LauncherState}; +use oneclient_content::packages::{CachedPackageMeta, ContentType, ProviderId}; +use oneclient_core::LauncherError; pub const BROWSE_PAGE_SIZE: usize = DEFAULT_PAGE_SIZE; pub const VERSIONS_PAGE_SIZE: usize = 20; @@ -40,9 +40,9 @@ impl QueryCapability for PackageSearchQuery { #[tracing::instrument(name = "package_search", level = "debug", skip(self, keys), fields(provider = ?keys.provider, page = keys.page))] async fn run(&self, keys: &Self::Keys) -> Result { - let state = LauncherState::get()?; + let state = crate::launcher::state()?; let provider = state.services.packages.get(keys.provider)?; - provider + Ok(provider .search( &SearchFilters { query: (!keys.query.trim().is_empty()).then(|| keys.query.trim().to_string()), @@ -55,9 +55,9 @@ impl QueryCapability for PackageSearchQuery { offset: Some(keys.page * BROWSE_PAGE_SIZE), limit: Some(BROWSE_PAGE_SIZE), }, - &state.services, + &state.services.content(), ) - .await + .await?) } } @@ -88,32 +88,19 @@ pub fn use_package_search( } pub fn search_items(query: &UseQuery) -> Vec { - let reader = query.read(); - match &*reader.state() { - QueryStateData::Settled { res: Ok(page), .. } => page.items.clone(), - QueryStateData::Loading { - res: Some(Ok(page)), - } => page.items.clone(), - _ => Vec::new(), - } + super::state::settled_or_loading(query) + .map(|page| page.items) + .unwrap_or_default() } pub fn search_total(query: &UseQuery) -> usize { - let reader = query.read(); - match &*reader.state() { - QueryStateData::Settled { res: Ok(page), .. } => page.total, - QueryStateData::Loading { - res: Some(Ok(page)), - } => page.total, - _ => 0, - } + super::state::settled_or_loading(query) + .map(|page| page.total) + .unwrap_or(0) } pub fn search_pending(query: &UseQuery) -> bool { - matches!( - &*query.read().state(), - QueryStateData::Pending | QueryStateData::Loading { .. } - ) + super::state::query_is_busy(query) } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -131,11 +118,11 @@ impl QueryCapability for PackageProjectQuery { type Keys = PackageProjectKeys; async fn run(&self, keys: &Self::Keys) -> Result { - let state = LauncherState::get()?; + let state = crate::launcher::state()?; let provider = state.services.packages.get(keys.provider)?; - provider - .get_project_with_body(&keys.project_id, &state.services) - .await + Ok(provider + .get_project_with_body(&keys.project_id, &state.services.content()) + .await?) } } @@ -153,16 +140,7 @@ pub fn use_package_project( } pub fn project_detail(query: &UseQuery) -> Option { - let reader = query.read(); - match &*reader.state() { - QueryStateData::Settled { - res: Ok(detail), .. - } => Some(detail.clone()), - QueryStateData::Loading { - res: Some(Ok(detail)), - } => Some(detail.clone()), - _ => None, - } + super::state::settled_or_loading(query) } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -181,13 +159,13 @@ impl QueryCapability for PackageMetaBatchQuery { #[tracing::instrument(name = "package_meta_batch", level = "debug", skip(self, keys), fields(provider = ?keys.provider, ids = keys.project_ids.len()))] async fn run(&self, keys: &Self::Keys) -> Result { - let state = LauncherState::get()?; - oneclient_core::packages::fetch_package_meta( - &state.services, + let state = crate::launcher::state()?; + Ok(oneclient_content::packages::fetch_package_meta( + &state.services.content(), keys.provider, &keys.project_ids, ) - .await + .await?) } } @@ -209,12 +187,7 @@ pub fn use_package_meta_batch( pub fn package_meta_batch( query: &UseQuery, ) -> std::collections::HashMap { - let reader = query.read(); - match &*reader.state() { - QueryStateData::Settled { res: Ok(map), .. } => map.clone(), - QueryStateData::Loading { res: Some(Ok(map)) } => map.clone(), - _ => std::collections::HashMap::new(), - } + super::state::settled_or_loading(query).unwrap_or_default() } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -235,18 +208,18 @@ impl QueryCapability for PackageVersionsQuery { type Keys = PackageVersionsKeys; async fn run(&self, keys: &Self::Keys) -> Result { - let state = LauncherState::get()?; + let state = crate::launcher::state()?; let provider = state.services.packages.get(keys.provider)?; - provider + Ok(provider .list_versions( &keys.project_id, keys.game_version.as_deref(), keys.loader, keys.page * VERSIONS_PAGE_SIZE, VERSIONS_PAGE_SIZE, - &state.services, + &state.services.content(), ) - .await + .await?) } } @@ -270,25 +243,15 @@ pub fn use_package_versions( } pub fn version_list(query: &UseQuery) -> Vec { - let reader = query.read(); - match &*reader.state() { - QueryStateData::Settled { res: Ok(page), .. } => page.items.clone(), - QueryStateData::Loading { - res: Some(Ok(page)), - } => page.items.clone(), - _ => Vec::new(), - } + super::state::settled_or_loading(query) + .map(|page| page.items) + .unwrap_or_default() } pub fn versions_total(query: &UseQuery) -> usize { - let reader = query.read(); - match &*reader.state() { - QueryStateData::Settled { res: Ok(page), .. } => page.total, - QueryStateData::Loading { - res: Some(Ok(page)), - } => page.total, - _ => 0, - } + super::state::settled_or_loading(query) + .map(|page| page.total) + .unwrap_or(0) } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -307,11 +270,11 @@ impl QueryCapability for PackageCategoriesQuery { #[tracing::instrument(name = "package_categories", level = "debug", skip(self, keys), fields(provider = ?keys.provider))] async fn run(&self, keys: &Self::Keys) -> Result { - let state = LauncherState::get()?; + let state = crate::launcher::state()?; let provider = state.services.packages.get(keys.provider)?; - provider - .list_categories(keys.content_type, &state.services) - .await + Ok(provider + .list_categories(keys.content_type, &state.services.content()) + .await?) } } @@ -329,12 +292,5 @@ pub fn use_package_categories( } pub fn category_list(query: &UseQuery) -> Vec { - let reader = query.read(); - match &*reader.state() { - QueryStateData::Settled { res: Ok(list), .. } => list.clone(), - QueryStateData::Loading { - res: Some(Ok(list)), - } => list.clone(), - _ => Vec::new(), - } + super::state::settled_or_loading(query).unwrap_or_default() } diff --git a/packages/oneclient_app/src/hooks/queries/player_profile.rs b/packages/oneclient_app/src/hooks/queries/player_profile.rs index df42b8b4..8735d3d6 100644 --- a/packages/oneclient_app/src/hooks/queries/player_profile.rs +++ b/packages/oneclient_app/src/hooks/queries/player_profile.rs @@ -1,10 +1,8 @@ use std::time::Duration; use freya::query::{Query, QueryCapability, UseQuery, use_query}; -use oneclient_core::{ - LauncherError, LauncherState, - minecraft::{self, PlayerProfileView}, -}; +use oneclient_core::LauncherError; +use oneclient_mc::{self as minecraft, PlayerProfileView}; // image cache const PROFILE_STALE: Duration = Duration::from_secs(30 * 60); @@ -27,10 +25,10 @@ impl QueryCapability for FetchPlayerProfileQuery { async fn run(&self, keys: &Self::Keys) -> Result { let access_token = keys.access_token.clone(); - let state = LauncherState::get()?; + let state = crate::launcher::state()?; let client = &state.services.requester; - minecraft::fetch_player_profile_view(client, &keys.uuid, access_token.as_deref()).await + Ok(minecraft::fetch_player_profile_view(client, &keys.uuid, access_token.as_deref()).await?) } // TODO: Cache diff --git a/packages/oneclient_app/src/hooks/queries/screenshots.rs b/packages/oneclient_app/src/hooks/queries/screenshots.rs index 214aa564..89d63ed2 100644 --- a/packages/oneclient_app/src/hooks/queries/screenshots.rs +++ b/packages/oneclient_app/src/hooks/queries/screenshots.rs @@ -3,11 +3,10 @@ use std::sync::{Arc, OnceLock}; use bytes::Bytes; use freya::query::{ - Mutation, MutationCapability, QueriesStorage, Query, QueryCapability, QueryStateData, + Mutation, MutationCapability, QueriesStorage, Query, QueryCapability, UseMutation, UseQuery, use_mutation, use_query, }; -use oneclient_core::clusters::ClusterManager; -use oneclient_core::{LauncherError, LauncherState, ScreenshotInfo}; +use oneclient_core::{LauncherError, ScreenshotInfo}; use tokio::sync::Semaphore; static LOCAL_IMAGE_SEMAPHORE: OnceLock> = OnceLock::new(); @@ -26,9 +25,9 @@ impl QueryCapability for ClusterScreenshotsQuery { type Keys = ClusterScreenshotsKeys; async fn run(&self, keys: &Self::Keys) -> Result { - let state = LauncherState::get()?; - let cluster = ClusterManager::get(&state, keys.cluster_id).await?; - oneclient_core::list_cluster_screenshots(&cluster) + let state = crate::launcher::state()?; + let cluster = state.clusters.get(keys.cluster_id).await?; + Ok(oneclient_core::list_cluster_screenshots(&cluster)?) } } @@ -63,9 +62,11 @@ impl QueryCapability for LocalImageQuery { None }; - tokio::task::spawn_blocking(move || oneclient_core::load_screenshot(&path, max_edge)) - .await - .map_err(|e| LauncherError::Minecraft(e.to_string()))? + Ok( + tokio::task::spawn_blocking(move || oneclient_core::load_screenshot(&path, max_edge)) + .await + .map_err(|e| LauncherError::Minecraft(e.to_string()))??, + ) } } @@ -86,13 +87,7 @@ pub fn use_local_image(path: PathBuf, max_edge: u32) -> UseQuery, ) -> Option> { - match &*query.read().state() { - QueryStateData::Settled { res: Ok(value), .. } => Some(value.clone()), - QueryStateData::Loading { - res: Some(Ok(value)), - } => Some(value.clone()), - _ => None, - } + super::state::settled_or_loading(query) } pub async fn invalidate_screenshots_queries() { @@ -115,7 +110,7 @@ impl MutationCapability for ScreenshotActionMutation { async fn run(&self, keys: &Self::Keys) -> Result { match keys { - ScreenshotAction::Delete { path } => oneclient_core::delete_screenshot(path), + ScreenshotAction::Delete { path } => Ok(oneclient_core::delete_screenshot(path)?), } } diff --git a/packages/oneclient_app/src/hooks/queries/settings_profiles.rs b/packages/oneclient_app/src/hooks/queries/settings_profiles.rs index e684ddb1..329339e2 100644 --- a/packages/oneclient_app/src/hooks/queries/settings_profiles.rs +++ b/packages/oneclient_app/src/hooks/queries/settings_profiles.rs @@ -1,9 +1,8 @@ -use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query}; +use freya::query::{Query, QueryCapability, UseQuery, use_query}; use oneclient_core::settings::GameSettingsProfile; -use oneclient_core::settings::store::{ +use oneclient_cluster::profiles::{ get_profile_or_default, list_named_profiles, resolve_cluster_profile, }; -use oneclient_core::{ClusterManager, LauncherState}; use oneclient_db::models::ClusterId; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -18,7 +17,7 @@ impl QueryCapability for ListNamedProfilesQuery { type Keys = ListNamedProfilesKeys; async fn run(&self, _keys: &Self::Keys) -> Result { - let state = LauncherState::get().map_err(|e| e.to_string())?; + let state = crate::launcher::state().map_err(|e| e.to_string())?; list_named_profiles(&state.services.db) .await .map_err(|e| e.to_string()) @@ -44,9 +43,9 @@ impl QueryCapability for GameProfileQuery { async fn run(&self, keys: &Self::Keys) -> Result { let name = keys.name.clone(); - let state = LauncherState::get().map_err(|e| e.to_string())?; - let settings = state.settings.read().clone(); - get_profile_or_default(&state.services.db, &settings, name.as_deref()) + let state = crate::launcher::state().map_err(|e| e.to_string())?; + let global = state.settings.read().global_game_settings.clone(); + get_profile_or_default(&state.services.db, &global, name.as_deref()) .await .map_err(|e| e.to_string()) } @@ -76,9 +75,9 @@ impl QueryCapability for ClusterProfileQuery { async fn run(&self, keys: &Self::Keys) -> Result { let profile_name = keys.profile_name.clone(); - let state = LauncherState::get().map_err(|e| e.to_string())?; - let settings = state.settings.read().clone(); - resolve_cluster_profile(&state.services.db, &settings, profile_name.as_deref()) + let state = crate::launcher::state().map_err(|e| e.to_string())?; + let global = state.settings.read().global_game_settings.clone(); + resolve_cluster_profile(&state.services.db, &global, profile_name.as_deref()) .await .map_err(|e| e.to_string()) } @@ -110,11 +109,12 @@ impl QueryCapability for ClusterSettingsQuery { async fn run(&self, keys: &Self::Keys) -> Result { let cluster_id = keys.cluster_id; - let state = LauncherState::get().map_err(|e| e.to_string())?; - let cluster = ClusterManager::get(&state, cluster_id) + let state = crate::launcher::state().map_err(|e| e.to_string())?; + let cluster = state.clusters.get(cluster_id) .await .map_err(|e| e.to_string())?; - ClusterManager::resolve_settings(&state, &cluster) + let global = state.settings.read().global_game_settings.clone(); + state.clusters.resolve_settings(&global, &cluster) .await .map_err(|e| e.to_string()) } @@ -128,14 +128,5 @@ pub fn use_cluster_settings(cluster_id: ClusterId) -> UseQuery) -> Option { - let reader = query.read(); - match &*reader.state() { - QueryStateData::Settled { - res: Ok(profile), .. - } => Some(profile.clone()), - QueryStateData::Loading { - res: Some(Ok(profile)), - } => Some(profile.clone()), - _ => None, - } + super::state::settled_or_loading(query) } diff --git a/packages/oneclient_app/src/hooks/queries/skin.rs b/packages/oneclient_app/src/hooks/queries/skin.rs index f0d5bdfd..b233e878 100644 --- a/packages/oneclient_app/src/hooks/queries/skin.rs +++ b/packages/oneclient_app/src/hooks/queries/skin.rs @@ -1,18 +1,13 @@ use bytes::Bytes; use freya::prelude::*; -use freya::query::QueryStateData; use crate::AppAssets; pub fn use_player_skin(uuid: String) -> (Bytes, bool) { let profile = super::use_player_profile(uuid.clone(), None::); - let (skin_url, is_slim) = match &*profile.read().state() { - QueryStateData::Settled { - res: Ok(profile), .. - } => (profile.skin_url.clone(), profile.is_slim), - _ => (None, false), - }; + let (skin_url, is_slim) = crate::hooks::settled_or_loading(&profile) + .map_or((None, false), |profile| (profile.skin_url, profile.is_slim)); let skin_query = super::use_cached_image(skin_url.clone(), 256); @@ -22,17 +17,10 @@ pub fn use_player_skin(uuid: String) -> (Bytes, bool) { // No custom skin: pick alex (slim) or steve (classic) from the UUID. let default_slim = (java_string_hash(&uuid) & 1) == 1; - let reader = skin_query.read(); - match (&skin_url, &*reader.state()) { - (Some(_), QueryStateData::Settled { res: Ok(bytes), .. }) - | ( - Some(_), - QueryStateData::Loading { - res: Some(Ok(bytes)), - }, - ) => (bytes.clone(), is_slim), - _ if default_slim => (alex.read().clone(), true), - _ => (steve.read().clone(), false), + match crate::hooks::loaded_image(skin_url.as_deref(), &skin_query) { + Some((_, bytes)) => (bytes, is_slim), + None if default_slim => (alex.read().clone(), true), + None => (steve.read().clone(), false), } } diff --git a/packages/oneclient_app/src/hooks/queries/state.rs b/packages/oneclient_app/src/hooks/queries/state.rs new file mode 100644 index 00000000..db9924fd --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/state.rs @@ -0,0 +1,69 @@ +//! Reading a query's state without spelling out the match every time. +//! +//! `QueryStateData` has three variants and the useful cases straddle two of +//! them: a query that is refetching still holds the previous value, and the UI +//! wants to keep showing it rather than flash a spinner. Written out by hand +//! that is a five-line match repeated over a hundred times, with enough small +//! divergences (does a settled `Err` read as empty or as absent? does +//! "loading" include a refetch?) that the differences were impossible to see. + +use std::fmt::Display; + +use freya::query::{QueryCapability, QueryStateData, UseQuery}; + +/// The query's value, keeping the previous one visible across a refetch. +/// +/// `None` means there has never been a successful result: either the query +/// has not finished yet or the only outcome so far is an error. Callers that +/// treat "failed" the same as "empty" finish with `.unwrap_or_default()`. +pub fn settled_or_loading(query: &UseQuery) -> Option +where + Q: QueryCapability, + Q::Ok: Clone, +{ + let reader = query.read(); + let state = reader.state(); + state.ok().cloned() +} + +/// The error from a settled query, rendered for display. +/// +/// Not reported while loading: a refetch that is still in flight has not +/// failed yet, even if the previous attempt did. +pub fn query_error(query: &UseQuery) -> Option +where + Q: QueryCapability, + Q::Err: Display, +{ + let reader = query.read(); + let state = reader.state(); + match &*state { + QueryStateData::Settled { res: Err(err), .. } => Some(err.to_string()), + _ => None, + } +} + +/// Whether the query is working with nothing to show yet. +/// +/// A refetch that still holds a previous value is *not* loading by this +/// definition, since the UI has something to render and a spinner would be a +/// step backwards. Use [`query_is_busy`] where the distinction matters. +pub fn query_is_loading(query: &UseQuery) -> bool { + let reader = query.read(); + let state = reader.state(); + matches!( + &*state, + QueryStateData::Pending | QueryStateData::Loading { res: None } + ) +} + +/// Whether the query is working at all, including a refetch over a value that +/// is already on screen. +pub fn query_is_busy(query: &UseQuery) -> bool { + let reader = query.read(); + let state = reader.state(); + matches!( + &*state, + QueryStateData::Pending | QueryStateData::Loading { .. } + ) +} diff --git a/packages/oneclient_app/src/hooks/queries/tos.rs b/packages/oneclient_app/src/hooks/queries/tos.rs index bec73ab8..7add719c 100644 --- a/packages/oneclient_app/src/hooks/queries/tos.rs +++ b/packages/oneclient_app/src/hooks/queries/tos.rs @@ -1,7 +1,5 @@ -use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query}; -use oneclient_core::{LauncherError, LauncherState, TermsDocument, fetch_terms}; - -use crate::hooks::use_settings_snapshot; +use freya::query::{Query, QueryCapability, UseQuery, use_query}; +use oneclient_core::{LauncherError, TermsDocument, fetch_terms}; #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct TermsKeys { @@ -17,45 +15,25 @@ impl QueryCapability for TermsQuery { type Keys = TermsKeys; async fn run(&self, _keys: &Self::Keys) -> Result { - let state = LauncherState::get()?; - fetch_terms(&state.services).await + let state = crate::launcher::state()?; + fetch_terms(&state.services.requester).await } } pub fn use_terms() -> UseQuery { - let settings = use_settings_snapshot().settings; - let meta_url_base = settings - .custom_meta_url_base - .clone() - .unwrap_or_default() - .trim() - .to_string(); + let meta_url_base = super::use_meta_url_key(); use_query(Query::new(TermsKeys { meta_url_base }, TermsQuery)) } pub fn terms_document(query: &UseQuery) -> Option { - match &*query.read().state() { - QueryStateData::Settled { - res: Ok(document), .. - } => Some(document.clone()), - QueryStateData::Loading { - res: Some(Ok(document)), - } => Some(document.clone()), - _ => None, - } + super::state::settled_or_loading(query) } pub fn terms_error(query: &UseQuery) -> Option { - match &*query.read().state() { - QueryStateData::Settled { res: Err(err), .. } => Some(err.to_string()), - _ => None, - } + super::state::query_error(query) } pub fn terms_is_loading(query: &UseQuery) -> bool { - matches!( - &*query.read().state(), - QueryStateData::Pending | QueryStateData::Loading { res: None } - ) + super::state::query_is_loading(query) } diff --git a/packages/oneclient_app/src/hooks/queries/version_metadata.rs b/packages/oneclient_app/src/hooks/queries/version_metadata.rs index 29fb3eab..681cfc80 100644 --- a/packages/oneclient_app/src/hooks/queries/version_metadata.rs +++ b/packages/oneclient_app/src/hooks/queries/version_metadata.rs @@ -1,7 +1,6 @@ -use freya::query::QueryStateData; -use oneclient_core::VersionKey; +use oneclient_common::VersionKey; use oneclient_core::VersionMetadata; -use oneclient_core::packages::domain::GameLoader; +use oneclient_common::domain::GameLoader; use super::use_versions; @@ -51,15 +50,10 @@ pub fn use_version_metadata( let versions_query = use_versions(); let major = major?; + // Borrowed, not cloned: the manifest is long and this runs per render. let reader = versions_query.read(); let state = reader.state(); - let list = match &*state { - QueryStateData::Settled { res: Ok(list), .. } => list, - QueryStateData::Loading { - res: Some(Ok(list)), - } => list, - _ => return None, - }; + let list = state.ok()?; pick_version_metadata(list, major, key, loader) } diff --git a/packages/oneclient_app/src/hooks/queries/versions.rs b/packages/oneclient_app/src/hooks/queries/versions.rs index c218ea33..8d8f478e 100644 --- a/packages/oneclient_app/src/hooks/queries/versions.rs +++ b/packages/oneclient_app/src/hooks/queries/versions.rs @@ -1,6 +1,6 @@ use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query}; -use oneclient_core::packages::domain::GameLoader; -use oneclient_core::{LauncherError, LauncherState, VersionMetadata}; +use oneclient_common::domain::GameLoader; +use oneclient_core::{LauncherError, VersionMetadata}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct VersionsMetadataQuery; @@ -14,13 +14,13 @@ impl QueryCapability for VersionsMetadataQuery { type Keys = VersionsMetadataKeys; async fn run(&self, _keys: &Self::Keys) -> Result { - let state = LauncherState::get()?; - let metadata = state.versions.metadata().await; + let state = crate::launcher::state()?; + let metadata = state.versions.metadata(&state.services.requester.config().meta_url_base).await; if !metadata.is_empty() { return Ok(metadata); } state.versions.sync(&state.services).await?; - Ok(state.versions.metadata().await) + Ok(state.versions.metadata(&state.services.requester.config().meta_url_base).await) } } @@ -33,13 +33,12 @@ pub fn use_versions() -> UseQuery { /// callers that block on this don't wait forever when the network is down. pub fn versions_metadata(query: &UseQuery) -> Option> { let reader = query.read(); - match &*reader.state() { - QueryStateData::Settled { res: Ok(list), .. } => Some(list.clone()), + let state = reader.state(); + match &*state { + // A failed fetch reads as an empty list rather than `None`, so callers + // that block on this don't wait forever when the network is down. QueryStateData::Settled { res: Err(_), .. } => Some(Vec::new()), - QueryStateData::Loading { - res: Some(Ok(list)), - } => Some(list.clone()), - _ => None, + other => other.ok().cloned(), } } @@ -58,15 +57,15 @@ impl QueryCapability for LoaderVersionsQuery { type Keys = LoaderVersionsKeys; async fn run(&self, keys: &Self::Keys) -> Result { - let state = LauncherState::get()?; + let state = crate::launcher::state()?; let mut metadata = state.metadata.lock().await; - oneclient_core::get_loader_versions( + Ok(oneclient_core::get_loader_versions( &mut metadata, - &state.services, + &state.services.mc(), &keys.mc_version, keys.loader, ) - .await + .await?) } } @@ -81,12 +80,5 @@ pub fn use_loader_versions( } pub fn loader_versions(query: &UseQuery) -> Vec { - let reader = query.read(); - match &*reader.state() { - QueryStateData::Settled { res: Ok(list), .. } => list.clone(), - QueryStateData::Loading { - res: Some(Ok(list)), - } => list.clone(), - _ => Vec::new(), - } + super::state::settled_or_loading(query).unwrap_or_default() } diff --git a/packages/oneclient_app/src/install.rs b/packages/oneclient_app/src/install.rs new file mode 100644 index 00000000..7360ba16 --- /dev/null +++ b/packages/oneclient_app/src/install.rs @@ -0,0 +1,197 @@ +//! Shared helpers for content installs and the notifications they raise. +//! +//! Lifted out of the bridge runtime so the actions layer can use them without +//! the command loop existing. + +use std::sync::Arc; + +use oneclient_core::LauncherState; + +use oneclient_content::packages::PackageStore; +use oneclient_events::Level; + +use crate::components::IconType; +use crate::notifications::{ + ClusterUpdateItem, ClusterUpdateSummary, NotificationAction, NotificationActionKind, + NotificationSpec, +}; + +pub fn item_from_bundle_file(file: &oneclient_core::BundleFile) -> ClusterUpdateItem { + match &file.kind { + oneclient_core::BundleFileKind::Managed { + provider, + project_id, + .. + } => ClusterUpdateItem { + provider: *provider, + project_id: Some(project_id.clone()), + fallback: file.display_name(), + }, + oneclient_core::BundleFileKind::External(_) => ClusterUpdateItem { + provider: oneclient_content::packages::ProviderId::Local, + project_id: None, + fallback: file.display_name(), + }, + } +} + +async fn cluster_update_summary( + cluster_id: i64, + result: &oneclient_core::ApplyBundleUpdatesResult, + services: &oneclient_core::LauncherServices, +) -> Option { + let updated: Vec = result + .updates_applied + .iter() + .map(|u| item_from_bundle_file(&u.new_file)) + .collect(); + let added: Vec = result + .additions_applied + .iter() + .map(|a| item_from_bundle_file(&a.new_file)) + .collect(); + let removed: Vec = result + .removals_applied + .iter() + .map(|r| ClusterUpdateItem { + provider: r + .provider + .unwrap_or(oneclient_content::packages::ProviderId::Local), + project_id: r.project_id.clone(), + fallback: r + .display_name + .clone() + .unwrap_or_else(|| r.package_id.clone()), + }) + .collect(); + + if updated.is_empty() && added.is_empty() && removed.is_empty() { + return None; + } + + let cluster_name = + oneclient_content::packages::PackageStore::get_cluster(cluster_id, &services.content()) + .await + .map(|c| c.name) + .unwrap_or_else(|_| "Cluster".to_string()); + + Some(ClusterUpdateSummary { + cluster_id, + cluster_name, + updated, + added, + removed, + }) +} + +pub async fn cluster_update_notification( + cluster_id: i64, + result: &oneclient_core::ApplyBundleUpdatesResult, + services: &oneclient_core::LauncherServices, +) -> Option { + let summary = cluster_update_summary(cluster_id, result, services).await?; + let total = summary.total(); + let body = format!( + "{total} package{} changed in {}", + if total == 1 { "" } else { "s" }, + summary.cluster_name + ); + + Some(NotificationSpec { + title: "Cluster updated".to_string(), + body, + level: Level::Info, + icon: Some(IconType::DownloadCloud02), + progress: None, + actions: vec![NotificationAction { + label: "View changes".to_string(), + kind: NotificationActionKind::OpenClusterUpdate(vec![summary]), + }], + }) +} + +/// Builds a single notification summarising a batch bundle sync. Every changed +/// cluster rides along on one "View changes" action so the notification keeps +/// exactly two buttons no matter how many clusters moved; the modal does the +/// per-cluster breakdown. `None` when nothing changed. +pub async fn combined_cluster_update_spec( + changed: &[(i64, oneclient_core::ApplyBundleUpdatesResult)], + services: &oneclient_core::LauncherServices, +) -> Option { + let mut summaries = Vec::new(); + let mut total_changes = 0usize; + + for (cluster_id, result) in changed { + if let Some(summary) = cluster_update_summary(*cluster_id, result, services).await { + total_changes += summary.total(); + summaries.push(summary); + } + } + + if summaries.is_empty() { + return None; + } + + let cluster_count = summaries.len(); + let body = format!( + "{total_changes} package{} updated across {cluster_count} cluster{}", + if total_changes == 1 { "" } else { "s" }, + if cluster_count == 1 { "" } else { "s" } + ); + + Some(NotificationSpec { + title: "Mods updated".to_string(), + body, + level: Level::Info, + icon: Some(IconType::DownloadCloud02), + progress: None, + actions: vec![NotificationAction { + label: "View changes".to_string(), + kind: NotificationActionKind::OpenClusterUpdate(summaries), + }], + }) +} + +pub async fn install_package( + state: &Arc, + provider: oneclient_content::packages::ProviderId, + project_id: &str, + version_id: &str, + cluster_id: i64, +) -> anyhow::Result { + 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?; + + let session = oneclient_events::GroupedProgressSession::start( + &state.services.events, + format!("Installing {}", project.name), + ); + let size = version.primary_file().map(|f| f.size).unwrap_or(0); + let child = session.child( + project.name.clone(), + size, + oneclient_events::TaskCategory::Packages, + ); + + let result = PackageStore::install_to_cluster( + provider, + &project, + &version, + cluster_id, + false, + false, + Some(&child), + &state.services.content(), + ) + .await; + + child.finish(); + session.finish(); + result?; + Ok(project.name) +} diff --git a/packages/oneclient_app/src/launcher.rs b/packages/oneclient_app/src/launcher.rs new file mode 100644 index 00000000..ca6737af --- /dev/null +++ b/packages/oneclient_app/src/launcher.rs @@ -0,0 +1,38 @@ +//! The app's handle on the launcher core. +//! +//! The core keeps no process-wide `OnceCell`; a library holding ambient state +//! cannot be constructed twice, or once in a test. The handle lives here +//! instead, in the executable, which is the one place a global is defensible: +//! this *is* the composition root. +//! +//! It exists because `freya::query`'s `QueryCapability::run` and +//! `MutationCapability::run` take only their keys, with no context and no +//! hooks, so there is nowhere else to thread the state through. + +use std::sync::{Arc, OnceLock}; + +use oneclient_core::{LauncherError, LauncherResult, LauncherState}; + +static LAUNCHER: OnceLock> = OnceLock::new(); + +/// Installs the launcher handle. Called once, from the startup path. +/// +/// Returns the existing handle if one was already installed, rather than +/// failing: a second call is a bug in startup ordering, not something a user +/// can trigger, and silently keeping the first is safer than panicking. +pub fn install(state: Arc) -> Arc { + if let Err(existing) = LAUNCHER.set(Arc::clone(&state)) { + tracing::warn!("launcher state was already installed; keeping the first"); + return existing; + } + state +} + +/// The launcher handle, or [`LauncherError::NotInitialized`] before startup has +/// finished installing it. +pub fn state() -> LauncherResult> { + LAUNCHER + .get() + .cloned() + .ok_or(LauncherError::NotInitialized) +} diff --git a/packages/oneclient_app/src/layout/app_shell.rs b/packages/oneclient_app/src/layout/app_shell.rs index 75666288..13dee94e 100644 --- a/packages/oneclient_app/src/layout/app_shell.rs +++ b/packages/oneclient_app/src/layout/app_shell.rs @@ -23,7 +23,7 @@ use crate::hooks::{ use_provide_browser_state, use_splash, }; use crate::theme::colors; -use oneclient_core::notification::LaunchStage; +use oneclient_events::LaunchStage; use std::collections::HashMap; use std::path::PathBuf; @@ -206,7 +206,7 @@ impl Component for LaunchErrorDialog { } } -fn copy_error_button(message: &str, dispatch: crate::BridgeDispatch) -> impl IntoElement { +fn copy_error_button(message: &str, dispatch: crate::Actions) -> impl IntoElement { let message = message.to_string(); Button::new() .secondary() @@ -271,7 +271,7 @@ impl Component for AppHomeBackground { let splash = use_splash(); // Once the cluster list has settled and the startup fetch is done, the - // home view is populated — let the splash curtain fade out. + // home view is populated, so let the splash curtain fade out. let clusters_settled = matches!( &*clusters_query.read().state(), QueryStateData::Settled { .. } @@ -289,13 +289,7 @@ impl Component for AppHomeBackground { let (art, art_dep) = { let reader = clusters_query.read(); let state = reader.state(); - let clusters = match &*state { - QueryStateData::Settled { res: Ok(list), .. } => list.as_slice(), - QueryStateData::Loading { - res: Some(Ok(list)), - } => list.as_slice(), - _ => &[], - }; + 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()); diff --git a/packages/oneclient_app/src/layout/cluster_shell.rs b/packages/oneclient_app/src/layout/cluster_shell.rs index 41d7bf61..2d824316 100644 --- a/packages/oneclient_app/src/layout/cluster_shell.rs +++ b/packages/oneclient_app/src/layout/cluster_shell.rs @@ -1,14 +1,10 @@ use freya::animation::*; use freya::prelude::*; -use freya::query::QueryStateData; use freya::router::*; -use oneclient_core::clusters::Cluster; -use oneclient_core::parse_mc_version; +use oneclient_common::parse_mc_version; use crate::components::{Button, Icon, IconType, TabBar, TabItem}; -use crate::hooks::{ - use_clusters, use_dispatch, use_game_snapshot, use_launcher, use_version_metadata, -}; +use crate::hooks::{use_cluster, use_dispatch, use_game_snapshot, use_launcher, use_version_metadata}; use crate::routes::Route; use crate::theme::colors; use crate::ui::entrance_motion_layer; @@ -91,19 +87,6 @@ fn route_cluster(route: &Route) -> Option<(i64, ClusterViewShellTab)> { }) } -fn load_cluster(cluster_id: i64) -> Option { - let clusters_query = use_clusters(); - let reader = clusters_query.read(); - let list = match &*reader.state() { - QueryStateData::Settled { res: Ok(list), .. } - | QueryStateData::Loading { - res: Some(Ok(list)), - } => list.clone(), - _ => Vec::new(), - }; - list.into_iter().find(|c| c.id == cluster_id) -} - #[derive(PartialEq)] pub struct ClusterShell; @@ -117,7 +100,7 @@ impl Component for ClusterShell { let game = use_game_snapshot(); let launcher = use_launcher(); let syncing = launcher.fetching || launcher.syncing_bundles; - let cluster = load_cluster(cluster_id); + let cluster = use_cluster(cluster_id); let show_game_log = game.is_active(cluster_id); let launch_state = launch_button_state(&game, cluster_id, syncing); @@ -246,7 +229,7 @@ fn cluster_header( title: String, subtitle: String, cluster_id: i64, - dispatch: crate::BridgeDispatch, + dispatch: crate::Actions, launch_state: (&'static str, bool), running: bool, folder: Option, diff --git a/packages/oneclient_app/src/layout/root_layout.rs b/packages/oneclient_app/src/layout/root_layout.rs index 765de88f..0aeb2b59 100644 --- a/packages/oneclient_app/src/layout/root_layout.rs +++ b/packages/oneclient_app/src/layout/root_layout.rs @@ -26,7 +26,7 @@ impl Component for RootLayout { // Other platforms are borderless: Freya rounds, squared when maximized so // there's no gap to the screen edge. No reactive maximized signal exists, - // so mirror it — root_size changes on every maximize/restore/resize, which + // so mirror it: root_size changes on every maximize/restore/resize, which // re-runs the effect to re-query. #[cfg(not(target_os = "macos"))] let corner = { diff --git a/packages/oneclient_app/src/lib.rs b/packages/oneclient_app/src/lib.rs index 4f2b6b3c..4a7716d2 100644 --- a/packages/oneclient_app/src/lib.rs +++ b/packages/oneclient_app/src/lib.rs @@ -4,9 +4,13 @@ #![recursion_limit = "256"] mod assets; -mod bridge; mod components; pub mod hooks; +pub mod events; +mod install; +mod launcher; +pub mod state; +mod transfer; mod layout; mod notifications; pub mod platform; @@ -20,7 +24,8 @@ mod view; pub mod constants; pub use assets::AppAssets; -pub use bridge::*; +pub use events::EventPump; +pub use state::{AppChannel, AppState}; pub use components::ConfirmLinkOverlay; pub use hooks::*; pub use routes::{Route, router}; diff --git a/packages/oneclient_app/src/main.rs b/packages/oneclient_app/src/main.rs index 4488d147..023fe5aa 100644 --- a/packages/oneclient_app/src/main.rs +++ b/packages/oneclient_app/src/main.rs @@ -2,21 +2,58 @@ all(not(debug_assertions), target_os = "windows"), windows_subsystem = "windows" )] +#![recursion_limit = "256"] use freya::prelude::*; +use freya::radio::use_init_radio_station; +use oneclient_app::state::{AppChannel, AppState}; use oneclient_app::{ - ConfirmLinkOverlay, CoreBridgeHandle, DataSync, LinkConfirmState, OneClientBridge, constants, - router, theme, use_provide_link_confirm, + Actions, ConfirmLinkOverlay, EventPump, LinkConfirmState, constants, events, router, theme, + use_provide_actions, use_provide_link_confirm, }; use tokio::runtime::Builder; -struct OneClientApp { - bridge: OneClientBridge, -} +struct OneClientApp; impl App for OneClientApp { fn render(&self) -> impl IntoElement { - oneclient_app::use_provide_bridge(&self.bridge); + // The station holds all app state, and radio state is `!Send`, so + // everything that writes it runs on the UI thread. `spawn_forever`, not + // `spawn`: these must outlive any component scope. + let station = use_init_radio_station::(AppState::default); + + let actions = use_hook(move || { + let (signals_tx, signals_rx) = tokio::sync::mpsc::unbounded_channel(); + let (events_bus, events_rx) = oneclient_events::EventBus::channel(); + let actions = Actions::new(station, signals_tx); + + // Started first so nothing emitted during startup waits on a + // consumer, though the bus is unbounded either way. + spawn_forever( + EventPump { + events: events_rx, + signals: signals_rx, + station, + } + .run(), + ); + + let startup = actions.clone(); + spawn_forever(async move { + match events::start_launcher(station, events_bus).await { + // Chained rather than called alongside: `sync_bundles` needs + // the launcher handle, and it sets `syncing_bundles` before + // spawning its work, so firing it early would both fail and + // leave the flag stuck, disabling every launch button. + Ok(()) => startup.sync_bundles(), + Err(err) => events::report_startup_failure(&station, &err), + } + }); + + actions + }); + + use_provide_actions(&actions); let link_confirm = use_state(|| None::); use_provide_link_confirm(LinkConfirmState(link_confirm)); @@ -24,7 +61,6 @@ impl App for OneClientApp { rect() .width(Size::fill()) .height(Size::fill()) - .child(DataSync) .child(ConfirmLinkOverlay) .child(router()) } @@ -35,9 +71,9 @@ fn main() { builder.enable_all().max_blocking_threads(64); // Debug builds emit unoptimized async code, which can cause stack overflows in some cases. - // Default stack size is 2MB, so we'll increase it to 4MB for debug builds + // Default stack size is 2MB, so we'll increase it to 3MB for debug builds #[cfg(debug_assertions)] - builder.thread_stack_size(4 * 1024 * 1024); + builder.thread_stack_size(3 * 1024 * 1024); let rt = builder.build().unwrap(); let _tokio_guard = rt.enter(); @@ -53,13 +89,11 @@ fn main() { let _sentry_guard = oneclient_core::reporting::init(settings.crash_reporting); - let (bridge, handle): (OneClientBridge, CoreBridgeHandle) = OneClientBridge::new(); - handle.spawn_runtime(); #[cfg(target_os = "macos")] oneclient_app::platform::macos::loop_memory_collector(); - let window_config = WindowConfig::new_app(OneClientApp { bridge }) + let window_config = WindowConfig::new_app(OneClientApp) .with_title(constants::WINDOW_TITLE) .with_app_id(constants::WINDOW_APP_ID) .with_icon(LaunchConfig::window_icon(include_bytes!( diff --git a/packages/oneclient_app/src/notifications.rs b/packages/oneclient_app/src/notifications.rs index 630682a3..fa56cac1 100644 --- a/packages/oneclient_app/src/notifications.rs +++ b/packages/oneclient_app/src/notifications.rs @@ -1,15 +1,16 @@ use std::collections::HashMap; use std::time::{Duration, Instant}; -use oneclient_core::notification::{ - GroupedProgressEvent, Notification, NotificationLevel, PromptKind, TaskCategory, UserChoice, +use oneclient_events::{ + Answer, Choice, Event, GroupedProgressEvent, Level, Notification, ProgressEvent, TaskCategory, }; -use oneclient_core::packages::ProviderId; +use oneclient_content::packages::ProviderId; use oneclient_db::models::ClusterId; use tokio::sync::oneshot; use uuid::Uuid; use crate::components::IconType; +use crate::transfer::{TransferMeter, TransferStats}; pub const MESSAGE_TOAST_TTL: Duration = Duration::from_secs(5); @@ -64,7 +65,7 @@ impl ClusterUpdateSummary { #[derive(Clone, Debug, PartialEq)] pub struct InboxEntry { - pub level: NotificationLevel, + pub level: Level, pub id: u64, pub title: String, pub body: String, @@ -79,13 +80,7 @@ pub struct InboxEntry { pub transfer: Option, } -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct TransferStats { - pub speed_bps: f64, - pub eta_secs: Option, -} - -/// One row in the expandable task list — an aggregate over all children of a +/// One row in the expandable task list, an aggregate over all children of a /// single [`TaskCategory`] (e.g. all libraries collapse into one "Libraries" row). #[derive(Clone, Debug, PartialEq)] pub struct TaskView { @@ -101,7 +96,7 @@ pub struct TaskView { pub struct NotificationSpec { pub title: String, pub body: String, - pub level: NotificationLevel, + pub level: Level, pub icon: Option, pub progress: Option<(u64, u64)>, pub actions: Vec, @@ -128,15 +123,33 @@ pub struct ActiveToast { pub struct PendingPrompt { pub title: String, pub question: String, - pub kind: PromptKind, - pub reply_tx: Option>, + pub choices: Vec, + pub dismiss: Option, + /// Taken when answered, so a second answer cannot fire the oneshot twice. + pub reply_tx: Option>>, } +/// The renderable half of a [`PendingPrompt`]: everything except the reply +/// channel, which cannot be cloned into the snapshot. #[derive(Clone, Debug)] pub struct PendingPromptView { pub title: String, pub question: String, - pub kind: PromptKind, + pub choices: Vec, + pub dismiss: Option, +} + +impl PendingPromptView { + /// Whether this prompt offers a choice with the given id. Lets a + /// specialised overlay (the Java one) recognise a prompt it knows how to + /// render richly, without the event layer naming the subsystem. + pub fn has_choice(&self, id: &str) -> bool { + self.choices.iter().any(|choice| choice.id == id) + } + + pub fn choice(&self, id: &str) -> Option<&Choice> { + self.choices.iter().find(|choice| choice.id == id) + } } #[derive(Clone, Debug)] @@ -201,9 +214,8 @@ struct GroupedTasks { /// Expected counts/bytes announced up-front so totals don't climb mid-download. reserved_count: HashMap, reserved_units: HashMap, - /// (timestamp, completed bytes) of the last speed sample. - last_sample: Option<(Instant, u64)>, - speed_bps: f64, + /// Transfer rate and ETA for this group's downloads. + meter: TransferMeter, } impl GroupedTasks { @@ -285,6 +297,12 @@ impl NotificationState { } } + /// Entry ids with a toast currently showing. + #[must_use] + pub fn active_toast_entry_ids(&self) -> Vec { + self.active_toasts.iter().map(|t| t.entry_id).collect() + } + pub fn open_cluster_update(&mut self, summaries: Vec) { if summaries.is_empty() { return; @@ -300,54 +318,58 @@ impl NotificationState { inbox.iter().filter(|entry| !entry.read).count() } - /// Folds one notification into the engine state. Deliberately does not build - /// a [`NotificationSnapshot`]: during a download this runs tens of thousands + /// Folds one notification into the engine state. Does not build a + /// [`NotificationSnapshot`]: during a download this runs tens of thousands /// of times, and every snapshot deep-clones the whole inbox only for the /// caller to discard all but the last. Callers snapshot once, after draining. pub fn dispatch( &mut self, inbox: &mut Vec, - notification: Notification, + event: Event, ) -> (Vec, Option) { self.pending_timers.clear(); - match notification { - Notification::Message { title, body, level } => { - let entry_id = self.push_inbox(inbox, title, body, level, None, false); + match event { + Event::Notification(Notification::Message(message)) => { + let entry_id = self.push_inbox( + inbox, + message.title, + message.body, + message.level, + None, + false, + ); self.push_ephemeral_toast(entry_id, MESSAGE_TOAST_TTL); } - Notification::Progress { + // The Microsoft login renders its own progress in the sign-in + // modal, so it must not also become a toast. This is a front-end + // policy decision about a progress id the core merely names. + Event::Progress(ProgressEvent::Update { id, .. }) + if id == oneclient_auth::MICROSOFT_LOGIN_PROGRESS => {} + Event::Progress(ProgressEvent::Update { id, label, current, total, - } => { + }) => { self.handle_progress(inbox, id, label, current, total); } - Notification::ProgressComplete { id, title, body } => { + Event::Progress(ProgressEvent::Complete { id, title, body }) => { self.handle_progress_complete(inbox, id, title, body); } - Notification::GroupedProgress(event) => { + Event::Progress(ProgressEvent::Grouped(event)) => { self.handle_grouped_progress(inbox, event); } - Notification::InvalidateClusters => {} - Notification::InvalidateJava => {} - Notification::SyncComplete => {} - Notification::MicrosoftLoginStatus(_) => {} - Notification::GameStage { .. } - | Notification::GameLog { .. } - | Notification::GameFailed { .. } => {} - Notification::Prompt { - title, - question, - kind, - reply_tx, - } => { + // Signals and game events drive the snapshot directly in the + // runtime loop; they never become inbox entries. + Event::Signal(_) | Event::Game(_) => {} + Event::Notification(Notification::Prompt(request)) => { let pending_prompt = Some(PendingPrompt { - title, - question, - kind, - reply_tx: Some(reply_tx), + title: request.title, + question: request.body, + choices: request.choices, + dismiss: request.dismiss, + reply_tx: Some(request.reply), }); let timers = std::mem::take(&mut self.pending_timers); return (timers, pending_prompt); @@ -433,7 +455,7 @@ impl NotificationState { inbox: &mut Vec, title: String, body: String, - level: NotificationLevel, + level: Level, progress: Option<(u64, u64)>, is_loading: bool, ) -> u64 { @@ -562,7 +584,7 @@ impl NotificationState { inbox, label.clone(), body.clone(), - NotificationLevel::Info, + Level::Info, progress, !done, ); @@ -573,10 +595,10 @@ impl NotificationState { self.update_inbox_entry(inbox, entry_id, label, body, progress, !done); self.ensure_progress_toast(entry_id); - // NOTE: the id→entry mapping is intentionally kept even once `done`, so a - // later `ProgressComplete` can convert this same card into its finished state - // (see `handle_progress_complete`). The mapping is cleaned up when the entry is - // dismissed or the engine is reset. + // The id->entry mapping is kept even once `done`, so a later + // `ProgressComplete` can convert this same card into its finished state (see + // `handle_progress_complete`). It is cleaned up when the entry is dismissed + // or the engine is reset. } /// Convert an in-flight progress card into a finished message in place. Used so an @@ -594,7 +616,7 @@ impl NotificationState { self.ensure_progress_toast(entry_id); } else { let entry_id = - self.push_inbox(inbox, title, body, NotificationLevel::Info, None, false); + self.push_inbox(inbox, title, body, Level::Info, None, false); self.push_ephemeral_toast(entry_id, MESSAGE_TOAST_TTL); } } @@ -610,7 +632,7 @@ impl NotificationState { inbox, title.clone(), "Preparing...".to_string(), - NotificationLevel::Info, + Level::Info, None, true, ); @@ -806,34 +828,11 @@ impl NotificationState { let completed: u64 = tasks.iter().map(|t| t.current.min(t.total)).sum(); let total: u64 = tasks.iter().map(|t| t.total).sum::().max(1); - // Smooth the transfer rate with an EMA so the readout doesn't jitter. - let now = Instant::now(); - let transfer = match group.last_sample { - Some((t0, b0)) => { - let dt = now.duration_since(t0).as_secs_f64(); - if dt >= 0.25 { - let inst = completed.saturating_sub(b0) as f64 / dt; - group.speed_bps = if group.speed_bps <= 0.0 { - inst - } else { - group.speed_bps * 0.7 + inst * 0.3 - }; - group.last_sample = Some((now, completed)); - } - let speed = group.speed_bps; - (speed >= 1.0).then(|| { - let remaining = total.saturating_sub(completed); - TransferStats { - speed_bps: speed, - eta_secs: Some((remaining as f64 / speed) as u64), - } - }) - } - None => { - group.last_sample = Some((now, completed)); - None - } - }; + // Below 1 B/s the estimate is noise, not information. + let transfer = group + .meter + .sample(completed, total) + .filter(|stats| stats.speed_bps >= 1.0); let Some(entry) = inbox.iter_mut().find(|e| e.id == entry_id) else { return; diff --git a/packages/oneclient_app/src/state.rs b/packages/oneclient_app/src/state.rs new file mode 100644 index 00000000..11c865cc --- /dev/null +++ b/packages/oneclient_app/src/state.rs @@ -0,0 +1,132 @@ +//! The app's own state, held in a freya-radio station. +//! +//! One struct republished over a `watch` channel woke every consumer on every +//! change (a toast timer tick re-rendering components that only read +//! `data_dir`) and deep-cloned the whole notification inbox on publish, tens of +//! thousands of times during a download. +//! +//! [`AppChannel`] gives per-concern subscription instead, and because the +//! 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::sync::Arc; + +use freya::radio::RadioChannel; +use oneclient_core::settings::LauncherSettings; +use oneclient_events::LaunchStage; + +use crate::notifications::{InboxEntry, NotificationState, PendingPrompt}; + +/// What a component can subscribe to. Writing through a channel wakes only the +/// components that asked for it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum AppChannel { + /// Startup progress and the resolved data directory. + Launcher, + /// Launcher settings and their save state. + Settings, + /// Inbox, toasts and the pending prompt. + Notifications, + /// Per-cluster launch stage and live log lines. + Game, + /// Whether the account switcher popover is open. + AccountSwitcher, + /// Live progress of an in-flight Microsoft sign-in. + MicrosoftLogin, +} + +impl RadioChannel for AppChannel {} + +#[derive(Default)] +pub struct AppState { + pub launcher: LauncherInit, + pub settings: SettingsState, + /// The notification engine itself, not a snapshot of it. + /// + /// Both the event pump and UI actions fold into this, so it has to live where + /// both can reach it, and nothing has to clone the inbox per event. + pub notifications: NotificationState, + /// Held beside the engine because it folds events into the inbox by `&mut` + /// reference; keeping them together lets the engine stay a plain + /// state machine with no channels of its own. + pub inbox: Vec, + pub prompt: Option, + /// Whether the notification centre panel is open. + pub center_open: bool, + pub game: GameState, + pub account_switcher_open: bool, + pub microsoft_login: Option, +} + +#[derive(Clone, Debug, Default)] +pub struct LauncherInit { + pub ready: bool, + pub fetching: bool, + /// True while the background cluster-bundle download is running. Launch is + /// disabled until it clears. + pub syncing_bundles: bool, + pub error: Option, + pub data_dir: String, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum AsyncStatus { + #[default] + Idle, + Loading, + Ready, + Error, +} + +#[derive(Clone, Debug, Default)] +pub struct SettingsState { + pub settings: LauncherSettings, + pub status: AsyncStatus, + pub saving: bool, + pub error: Option, +} + +/// Live progress of an in-flight Microsoft sign-in, rendered inside the sign-in +/// modal rather than as a toast. UI state: the core reports this as ordinary +/// progress and has no opinion about where it is shown. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LoginProgress { + pub label: String, + pub current: u64, + pub total: u64, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct GameState { + pub stages: HashMap, + pub error: Option, + pub logs: HashMap>>>, +} + +impl GameState { + #[must_use] + pub fn stage(&self, cluster_id: i64) -> Option { + self.stages.get(&cluster_id).copied() + } + + #[must_use] + pub fn is_busy(&self, cluster_id: i64) -> bool { + self.stage(cluster_id).is_some_and(LaunchStage::is_busy) + } + + #[must_use] + pub fn is_running(&self, cluster_id: i64) -> bool { + self.stage(cluster_id) == Some(LaunchStage::Running) + } + + #[must_use] + pub fn is_active(&self, cluster_id: i64) -> bool { + matches!(self.stage(cluster_id), Some(s) if s != LaunchStage::Exited) + } + + #[must_use] + pub fn logs_for(&self, cluster_id: i64) -> Arc>> { + self.logs.get(&cluster_id).cloned().unwrap_or_default() + } +} diff --git a/packages/oneclient_app/src/transfer.rs b/packages/oneclient_app/src/transfer.rs new file mode 100644 index 00000000..168a1405 --- /dev/null +++ b/packages/oneclient_app/src/transfer.rs @@ -0,0 +1,133 @@ +//! Download speed and time-remaining estimation. +//! +//! One shared estimator, so the notification engine and the onboarding +//! installer show the same numbers arrived at the same way. + +use std::time::Instant; + +/// Weight given to the existing average when a new sample arrives. +/// +/// Downloads are bursty (a single asset finishing can spike the instantaneous +/// rate several-fold) so the average is kept sluggish on purpose. +const SMOOTHING: f64 = 0.7; + +/// An exponentially-weighted average of transfer rate, plus the ETA that falls +/// out of it. +#[derive(Debug, Clone, Copy, Default)] +pub struct TransferMeter { + /// Timestamp and byte count of the last sample. + last_sample: Option<(Instant, u64)>, + speed_bps: f64, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TransferStats { + pub speed_bps: f64, + /// `None` until there is enough signal to estimate, or when the total is + /// unknown. + pub eta_secs: Option, +} + +impl TransferMeter { + /// Records progress and returns the current estimate. + /// + /// `completed` and `total` are cumulative byte counts. Samples closer + /// together than a few hundred milliseconds are ignored rather than folded + /// in: the elapsed divisor gets small enough to swamp the average with + /// noise, which is what the fixed-tick version was working around by + /// sampling on a timer instead. + pub fn sample(&mut self, completed: u64, total: u64) -> Option { + let now = Instant::now(); + + let Some((last_at, last_completed)) = self.last_sample else { + self.last_sample = Some((now, completed)); + return None; + }; + + let elapsed = now.duration_since(last_at).as_secs_f64(); + if elapsed < 0.25 { + return self.stats(completed, total); + } + + let delta = completed.saturating_sub(last_completed) as f64; + let instant = delta / elapsed; + + self.speed_bps = if self.speed_bps <= 0.0 { + instant + } else { + self.speed_bps * SMOOTHING + instant * (1.0 - SMOOTHING) + }; + self.last_sample = Some((now, completed)); + + self.stats(completed, total) + } + + fn stats(&self, completed: u64, total: u64) -> Option { + if self.speed_bps <= 0.0 { + return None; + } + + let remaining = total.saturating_sub(completed); + Some(TransferStats { + speed_bps: self.speed_bps, + // No total means no estimate, which beats a countdown derived from a + // denominator that is still climbing. + eta_secs: (total > 0).then(|| (remaining as f64 / self.speed_bps) as u64), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_first_sample_only_establishes_a_baseline() { + let mut meter = TransferMeter::default(); + assert!(meter.sample(0, 1000).is_none()); + } + + #[test] + fn a_total_of_zero_yields_speed_without_an_eta() { + let mut meter = TransferMeter::default(); + meter.sample(0, 0); + std::thread::sleep(std::time::Duration::from_millis(300)); + + let stats = meter.sample(1_000, 0).expect("should have a rate by now"); + assert!(stats.speed_bps > 0.0); + assert_eq!(stats.eta_secs, None); + } + + #[test] + fn eta_falls_as_the_download_completes() { + let mut meter = TransferMeter::default(); + meter.sample(0, 10_000); + std::thread::sleep(std::time::Duration::from_millis(300)); + let early = meter.sample(1_000, 10_000).unwrap().eta_secs.unwrap(); + + std::thread::sleep(std::time::Duration::from_millis(300)); + let late = meter.sample(9_000, 10_000).unwrap().eta_secs.unwrap(); + + assert!(late < early, "eta should shrink: {early} -> {late}"); + } + + /// Rapid updates are the common case during an asset fan-out; they must not + /// divide by a near-zero elapsed and blow the average up. + #[test] + fn samples_taken_too_close_together_do_not_distort_the_average() { + let mut meter = TransferMeter::default(); + meter.sample(0, 10_000); + std::thread::sleep(std::time::Duration::from_millis(300)); + let settled = meter.sample(1_000, 10_000).unwrap().speed_bps; + + for i in 1..50 { + meter.sample(1_000 + i, 10_000); + } + + let after = meter.speed_bps; + assert!( + (after - settled).abs() < f64::EPSILON, + "burst of close samples changed the rate: {settled} -> {after}" + ); + } +} diff --git a/packages/oneclient_app/src/ui.rs b/packages/oneclient_app/src/ui.rs index 33feece1..4c0d4093 100644 --- a/packages/oneclient_app/src/ui.rs +++ b/packages/oneclient_app/src/ui.rs @@ -1,4 +1,3 @@ -#![allow(dead_code)] use std::time::Instant; use freya::prelude::*; @@ -25,39 +24,53 @@ pub fn border_all_color(width: f32, color: Color) -> Border { }) } -pub fn border_left(width: f32) -> Border { - Border::new() - .fill(theme::colors::component_border()) - .width(BorderWidth { - left: width, - ..Default::default() - }) -} - -pub fn border_right(width: f32) -> Border { - Border::new() - .fill(theme::colors::component_border()) - .width(BorderWidth { - right: width, - ..Default::default() - }) -} - pub fn fmt_date(ts: chrono::DateTime) -> String { ts.format("%Y-%m-%d %H:%M").to_string() } +/// How long ago, for a timestamp shown next to something that happened. +/// +/// Nothing re-renders on a timer, so the shortest band is a whole minute: a +/// seconds counter would freeze at whatever it read when the row was drawn and +/// quietly lie from then on. The version this replaced had a seconds band and +/// no callers. pub fn relative_time(created_at: Instant) -> String { let secs = created_at.elapsed().as_secs(); match secs { - 0..=9 => "Just now".to_string(), - 10..=59 => format!("{secs}s ago"), + 0..=59 => "Just now".to_string(), 60..=3599 => format!("{}m ago", secs / 60), 3600..=86_399 => format!("{}h ago", secs / 3600), _ => format!("{}d ago", secs / 86_400), } } +/// A one-pixel rule in the component-border colour. +/// +/// Returns the `Rect` rather than an `Element` so a caller that wants it inset +/// or rounded can say so without copying the whole thing out again. +pub fn divider() -> Rect { + rect() + .width(Size::fill()) + .height(Size::px(1.)) + .background(theme::colors::component_border()) +} + +/// A quiet line of text centred in a fixed block: "nothing here yet", or a +/// failure where a chart would otherwise be. +pub fn centered_note(text: &str) -> Element { + rect() + .width(Size::fill()) + .height(Size::px(240.)) + .center() + .child( + label() + .text(text.to_string()) + .font_size(14.) + .color(theme::colors::fg_secondary()), + ) + .into_element() +} + pub fn entrance_motion_layer( slide_x: f32, slide_y: f32, diff --git a/packages/oneclient_app/src/updater.rs b/packages/oneclient_app/src/updater.rs index fc5f9a74..88c473c7 100644 --- a/packages/oneclient_app/src/updater.rs +++ b/packages/oneclient_app/src/updater.rs @@ -1,10 +1,32 @@ use std::cell::Cell; use cargo_packager_updater::{Config, Update, check_update}; -use oneclient_core::LauncherState; -use oneclient_core::notification::{NotificationService, UserChoice}; +use oneclient_events::{Choice, EventBus, Prompt}; use uuid::Uuid; +/// Choice id for the update prompt, so the overlay can recognise it among any +/// other pending prompt. +pub const UPDATE_CHOICE_INSTALL: &str = "update.install"; + +/// The only affirmative answer the update prompt offers; dismissing means "no". +enum UpdateAnswer { + Install, +} + +/// The update prompt, shared by the real check and the debug simulation so the +/// two cannot drift. +fn update_prompt(version: &str) -> Prompt { + Prompt::new( + "Update available", + format!("OneClient {version} is ready to install. Download and install it now?"), + ) + .option( + Choice::primary(UPDATE_CHOICE_INSTALL, "Install"), + UpdateAnswer::Install, + ) + .dismiss("Not now") +} + pub const UPDATER_PUBKEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDFGODk3MkMyMjg0MjFDMDUKUldRRkhFSW93bktKSHpkWjNEMXNzaDVINVpCTU8xSnhuK2RnV0dTZ2FkcFJWbG1zUkhGYTNjaUkK"; pub const UPDATER_ENDPOINT: &str = @@ -22,8 +44,8 @@ pub fn spawn_update_check(auto_install: bool) { }); } -/// Debug-only: drive the full auto-update UX — prompt, download progress, then the -/// "restart to apply" notification — against a fake release, without hitting the +/// Debug-only: drives the full auto-update UX (prompt, download progress, then the +/// "restart to apply" notification) against a fake release, without hitting the /// network or touching disk. Mirrors [`run_check`] + [`download_and_install`] so the /// debug page exercises the real notification path. pub fn spawn_simulated_update() { @@ -38,38 +60,35 @@ async fn run_simulated_update() -> anyhow::Result<()> { const FAKE_VERSION: &str = "9.9.9"; const FAKE_TOTAL: u64 = 48 * 1024 * 1024; // arbitrary 48 MiB "download" - let notifier = LauncherState::get()?.services.notifier.clone(); + let events = crate::launcher::state()?.services.events.clone(); - match notifier.prompt_update(FAKE_VERSION).await? { - UserChoice::Accept => {} - _ => { - tracing::info!("user declined simulated update"); - return Ok(()); - } + if events.ask(update_prompt(FAKE_VERSION)).await?.is_none() { + tracing::info!("user declined simulated update"); + return Ok(()); } let progress_id = Uuid::new_v4(); let label = format!("Downloading OneClient {FAKE_VERSION}"); let mut downloaded = 0u64; - notifier.send_progress(&progress_id, &label, downloaded, FAKE_TOTAL); + events.progress(progress_id, &label, downloaded, FAKE_TOTAL); while downloaded < FAKE_TOTAL { downloaded = (downloaded + PROGRESS_STEP * 8).min(FAKE_TOTAL); - notifier.send_progress(&progress_id, &label, downloaded, FAKE_TOTAL); + events.progress(progress_id, &label, downloaded, FAKE_TOTAL); tokio::time::sleep(std::time::Duration::from_millis(50)).await; } - notifier.finish_progress( - &progress_id, + events.finish_progress( + progress_id, "Finished Downloading", - &format!("OneClient {FAKE_VERSION} is ready — restart to apply."), + format!("OneClient {FAKE_VERSION} is ready. Restart to apply."), ); Ok(()) } async fn run_check(auto_install: bool) -> anyhow::Result<()> { - let notifier = LauncherState::get()?.services.notifier.clone(); + let events = crate::launcher::state()?.services.events.clone(); // `check_update` performs a blocking HTTP request, so offload it to a thread pool. let Some(update) = tokio::task::spawn_blocking(check_for_update).await?? else { @@ -82,40 +101,35 @@ async fn run_check(auto_install: bool) -> anyhow::Result<()> { // The bundled self-updater can only replace an AppImage in place: on Linux // cargo-packager-updater rejects every format except AppImage and locates the // target via the `APPIMAGE` env var. deb/rpm installs run from a package-managed - // path (e.g. /usr/bin) we can't overwrite without root — attempting an install + // path (e.g. /usr/bin) we can't overwrite without root. Attempting an install // would fail with a permission error, or clobber the system binary if elevated. // Detect that case and point the user at the release page instead. if !can_self_update() { tracing::info!("install is not self-updatable (non-AppImage Linux); notifying only"); - notifier.send_info( - "Update available", - &format!( + events.notify("Update available") + .body(format!( "OneClient {} is available. Download the latest package from {} to update.", update.version, RELEASES_URL - ), - ); + )) + .send(); return Ok(()); } - if !auto_install { - match notifier.prompt_update(&update.version).await? { - UserChoice::Accept => {} - _ => { - tracing::info!("user declined update {}", update.version); - return Ok(()); - } + if !auto_install + && events.ask(update_prompt(&update.version)).await?.is_none() { + tracing::info!("user declined update {}", update.version); + return Ok(()); } - } - download_and_install(update, notifier).await + download_and_install(update, events).await } /// Whether the running install can be updated in place by the bundled updater. /// /// Windows (NSIS) and macOS (.app) installs are always self-updatable. On Linux /// only AppImage installs are: cargo-packager-updater replaces the file named by -/// the `APPIMAGE` env var and rejects every other format, so deb/rpm installs — -/// which run from a package-manager-owned path and never set `APPIMAGE` — are not. +/// the `APPIMAGE` env var and rejects every other format, so deb/rpm installs are +/// not: they run from a package-manager-owned path and never set `APPIMAGE`. fn can_self_update() -> bool { #[cfg(target_os = "linux")] { @@ -138,12 +152,12 @@ fn check_for_update() -> anyhow::Result> { Ok(check_update(current, config)?) } -async fn download_and_install(update: Update, notifier: NotificationService) -> anyhow::Result<()> { +async fn download_and_install(update: Update, events: EventBus) -> anyhow::Result<()> { let progress_id = Uuid::new_v4(); let version = update.version.clone(); let label = format!("Downloading OneClient {version}"); - notifier.send_progress(&progress_id, &label, 0, 0); + events.progress(progress_id, &label, 0, 0); tokio::task::spawn_blocking(move || -> anyhow::Result<()> { let downloaded = Cell::new(0u64); @@ -160,23 +174,23 @@ async fn download_and_install(update: Update, notifier: NotificationService) -> || now - last_sent.get() >= PROGRESS_STEP { last_sent.set(now); - notifier.send_progress(&progress_id, &label, now, total); + events.progress(progress_id, &label, now, total); } }, || {}, )?; let total = downloaded.get().max(1); - notifier.send_progress(&progress_id, &label, total, total); + events.progress(progress_id, &label, total, total); update.install(bytes)?; // Convert the same download card into its finished state instead of emitting a // second notification, so the user sees one notification through to completion. - notifier.finish_progress( - &progress_id, + events.finish_progress( + progress_id, "Finished Downloading", - &format!("OneClient {version} is ready — restart to apply."), + format!("OneClient {version} is ready. Restart to apply."), ); Ok(()) }) diff --git a/packages/oneclient_app/src/utils.rs b/packages/oneclient_app/src/utils.rs index 14273e7f..35e26303 100644 --- a/packages/oneclient_app/src/utils.rs +++ b/packages/oneclient_app/src/utils.rs @@ -3,15 +3,15 @@ use std::collections::BTreeMap; use chrono::{Datelike, NaiveDate}; use oneclient_core::clusters::Cluster; -use oneclient_core::packages::domain::GameLoader; -use oneclient_core::{ParsedMcVersion, VersionKey, format_mc_version, parse_mc_version}; +use oneclient_common::domain::GameLoader; +use oneclient_common::{ParsedMcVersion, VersionKey, format_mc_version, parse_mc_version}; pub type ClusterGroups = BTreeMap>; /// One entry on the versions page. The modern scheme puts a full release in the /// first two components (`26.1`, `26.2`), so each minor is its own line; legacy /// `1.x` versions keep the whole major (`1.21`) as one line. A line that holds a -/// single concrete version is presented as that version — see [`line_title`]. +/// single concrete version is presented as that version (see [`line_title`]). #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct ReleaseLine { pub major: u32, diff --git a/packages/oneclient_app/src/view/app/accounts.rs b/packages/oneclient_app/src/view/app/accounts.rs index 1148231b..cd11fdbd 100644 --- a/packages/oneclient_app/src/view/app/accounts.rs +++ b/packages/oneclient_app/src/view/app/accounts.rs @@ -2,7 +2,7 @@ use chrono::{DateTime, Utc}; use freya::animation::{AnimNum, Function, OnFinish, use_animation}; use freya::prelude::*; use freya::query::{MutationCapability, MutationStateData, UseMutation}; -use oneclient_core::auth::{AccountKind, MinecraftAccount}; +use oneclient_auth::{AccountKind, MinecraftAccount}; use uuid::Uuid; use crate::components::{ @@ -60,7 +60,7 @@ impl Component for Accounts { let offline_name = username.read().trim().to_string(); let offline_uuid = (!offline_name.is_empty()) - .then(|| oneclient_core::auth::offline_uuid(&offline_name).to_string()); + .then(|| oneclient_auth::offline_uuid(&offline_name).to_string()); let offline_error = mutation_err_text(&add_offline); diff --git a/packages/oneclient_app/src/view/app/analytics/servers.rs b/packages/oneclient_app/src/view/app/analytics/servers.rs index 0cd7125c..efa1db10 100644 --- a/packages/oneclient_app/src/view/app/analytics/servers.rs +++ b/packages/oneclient_app/src/view/app/analytics/servers.rs @@ -1,12 +1,11 @@ use freya::prelude::*; -use freya::query::QueryStateData; use oneclient_core::game::ServerStat; use crate::components::{ Button, Icon, IconType, OverlayPopup, PieChart, ScrollArea, ValueUnit, slice_color, }; -use crate::hooks::use_cached_image; +use crate::hooks::{loaded_image, use_cached_image}; use crate::theme::colors; use crate::ui::border_all_color; use crate::utils::{format_duration_hm, plural}; @@ -168,7 +167,7 @@ fn legend(values: &[i64], labels: &[String], active: Option) -> Element { } /// A Component (not a plain builder) because it mounts a `ScrollArea`, whose -/// hooks run in the caller's render scope — mounting it conditionally from +/// hooks run in the caller's render scope. Mounting it conditionally from /// `ServersSection` would change that scope's hook count and panic Freya. #[derive(PartialEq)] struct ServerDetailsPopup { @@ -372,17 +371,7 @@ impl Component for ServerIcon { fn render(&self) -> impl IntoElement { let size = self.size; let query = use_cached_image(self.url.clone(), SERVER_ICON_EDGE); - let reader = query.read(); - let loaded = match (&self.url, &*reader.state()) { - (Some(url), QueryStateData::Settled { res: Ok(bytes), .. }) - | ( - Some(url), - QueryStateData::Loading { - res: Some(Ok(bytes)), - }, - ) => Some((url.clone(), bytes.clone())), - _ => None, - }; + let loaded = loaded_image(self.url.as_deref(), &query); match loaded { Some((url, bytes)) => ImageViewer::new((url, bytes)) diff --git a/packages/oneclient_app/src/view/app/browser/index/cards.rs b/packages/oneclient_app/src/view/app/browser/index/cards.rs index c919849f..097dc238 100644 --- a/packages/oneclient_app/src/view/app/browser/index/cards.rs +++ b/packages/oneclient_app/src/view/app/browser/index/cards.rs @@ -1,8 +1,8 @@ use super::*; use freya::router::RouterContext; -use oneclient_core::packages::ProviderId; -use oneclient_core::packages::types::ProjectSummary; +use oneclient_content::packages::ProviderId; +use oneclient_content::packages::types::ProjectSummary; use crate::components::{Icon, IconType}; use crate::routes::Route; diff --git a/packages/oneclient_app/src/view/app/browser/index/mod.rs b/packages/oneclient_app/src/view/app/browser/index/mod.rs index a1e7660d..83e5295a 100644 --- a/packages/oneclient_app/src/view/app/browser/index/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/index/mod.rs @@ -4,9 +4,9 @@ use freya::animation::{ AnimNum, Ease, Function, OnChange, OnCreation, use_animation_with_dependencies, }; use freya::prelude::*; -use oneclient_core::packages::types::ProjectSummary; -use oneclient_core::packages::types::SearchSort; -use oneclient_core::packages::{ContentType, ProviderId}; +use oneclient_content::packages::types::ProjectSummary; +use oneclient_content::packages::types::SearchSort; +use oneclient_content::packages::{ContentType, ProviderId}; use oneclient_core::settings::ViewLayout; use crate::components::{ @@ -18,7 +18,7 @@ use crate::hooks::{ use_package_categories, use_package_search, use_view_state, }; use crate::theme::colors; -use crate::view::app::cluster::load_cluster; +use crate::hooks::use_cluster; use super::{PackageBanner, Thumbnail}; use crate::utils::abbreviate_number; @@ -96,7 +96,7 @@ impl Component for Browser { Duration::from_millis(SEARCH_DEBOUNCE_MS), ); - let cluster = load_cluster(cluster_id); + let cluster = use_cluster(cluster_id); let provider_id = *provider.read(); let compat = *compatible_only.read(); let cats = selected_categories.read().clone(); diff --git a/packages/oneclient_app/src/view/app/browser/mod.rs b/packages/oneclient_app/src/view/app/browser/mod.rs index 500af3b2..5fadb63e 100644 --- a/packages/oneclient_app/src/view/app/browser/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/mod.rs @@ -5,10 +5,9 @@ pub use index::Browser; pub use package::BrowserPackage; use freya::prelude::*; -use freya::query::QueryStateData; use crate::components::{Icon, IconType}; -use crate::hooks::use_cached_image; +use crate::hooks::{loaded_image, use_cached_image}; use crate::theme::colors; const BANNER_BG: Color = Color::from_rgb(21, 28, 34); @@ -48,17 +47,7 @@ impl Component for Thumbnail { let size = self.size; let radius = self.radius; let query = use_cached_image(self.icon_url.clone(), 256); - let reader = query.read(); - let loaded = match (&self.icon_url, &*reader.state()) { - (Some(url), QueryStateData::Settled { res: Ok(bytes), .. }) - | ( - Some(url), - QueryStateData::Loading { - res: Some(Ok(bytes)), - }, - ) => Some((url.clone(), bytes.clone())), - _ => None, - }; + let loaded = loaded_image(self.icon_url.as_deref(), &query); match loaded { Some((url, bytes)) => ImageViewer::new((url, bytes)) @@ -111,17 +100,7 @@ impl Component for PackageBanner { let h = self.height; let icon = h * 0.62; let query = use_cached_image(self.icon_url.clone(), 512); - let reader = query.read(); - let loaded = match (&self.icon_url, &*reader.state()) { - (Some(url), QueryStateData::Settled { res: Ok(bytes), .. }) - | ( - Some(url), - QueryStateData::Loading { - res: Some(Ok(bytes)), - }, - ) => Some((url.clone(), bytes.clone())), - _ => None, - }; + let loaded = loaded_image(self.icon_url.as_deref(), &query); let banner = rect() .width(Size::fill()) diff --git a/packages/oneclient_app/src/view/app/browser/package/mod.rs b/packages/oneclient_app/src/view/app/browser/package/mod.rs index b3cdbce6..95f91dfc 100644 --- a/packages/oneclient_app/src/view/app/browser/package/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/package/mod.rs @@ -1,5 +1,5 @@ use freya::prelude::*; -use oneclient_core::packages::{ContentType, ProviderId}; +use oneclient_content::packages::{ContentType, ProviderId}; use crate::components::ScrollArea; use crate::hooks::{ @@ -8,7 +8,7 @@ use crate::hooks::{ }; use crate::theme::colors; use crate::ui::border_all_color; -use crate::view::app::cluster::load_cluster; +use crate::hooks::use_cluster; use super::{PackageBanner, Thumbnail}; use crate::utils::abbreviate_number; @@ -108,7 +108,7 @@ impl Component for BrowserPackage { let dispatch = use_dispatch(); let confirm = use_link_confirm(); - let cluster = load_cluster(cluster_id); + let cluster = use_cluster(cluster_id); let compat = *compatible_only.read(); let (game_version, loader) = match (compat, &cluster) { (true, Some(c)) => ( diff --git a/packages/oneclient_app/src/view/app/browser/package/panels.rs b/packages/oneclient_app/src/view/app/browser/package/panels.rs index 42f88fc5..6296ef89 100644 --- a/packages/oneclient_app/src/view/app/browser/package/panels.rs +++ b/packages/oneclient_app/src/view/app/browser/package/panels.rs @@ -1,14 +1,13 @@ use super::*; -use freya::query::QueryStateData; -use oneclient_core::packages::ProviderId; -use oneclient_core::packages::types::{ +use oneclient_content::packages::ProviderId; +use oneclient_content::packages::types::{ GalleryImage, PackageBody, ProjectDetail, ReleaseType, VersionSummary, }; -use crate::BridgeDispatch; +use crate::Actions; use crate::components::{Button, Icon, IconType, Segment, SegmentedControl}; -use crate::hooks::{VERSIONS_PAGE_SIZE, use_cached_image}; +use crate::hooks::{VERSIONS_PAGE_SIZE, loaded_image, use_cached_image}; use crate::theme::colors; use crate::ui::border_all_color; use crate::utils::format_size; @@ -116,14 +115,7 @@ impl KeyExt for GalleryTile { impl Component for GalleryTile { fn render(&self) -> impl IntoElement { let query = use_cached_image(Some(self.image.url.clone()), GALLERY_EDGE); - let reader = query.read(); - let loaded = match &*reader.state() { - QueryStateData::Settled { res: Ok(bytes), .. } - | QueryStateData::Loading { - res: Some(Ok(bytes)), - } => Some((self.image.url.clone(), bytes.clone())), - _ => None, - }; + let loaded = loaded_image(Some(&self.image.url), &query); let preview = rect() .width(Size::fill()) @@ -172,7 +164,7 @@ pub(super) fn versions_panel( provider: ProviderId, project_id: String, cluster_id: i64, - dispatch: BridgeDispatch, + dispatch: Actions, ) -> impl IntoElement { let current = *versions_page.read(); let total_pages = total_versions.div_ceil(VERSIONS_PAGE_SIZE).max(1); @@ -271,7 +263,7 @@ fn version_row( provider: ProviderId, project_id: String, cluster_id: i64, - dispatch: BridgeDispatch, + dispatch: Actions, ) -> impl IntoElement { let version_id = v.version_id.clone(); let mut chips: Vec = v.loaders.iter().map(|l| l.to_string()).collect(); diff --git a/packages/oneclient_app/src/view/app/browser/package/sidebar.rs b/packages/oneclient_app/src/view/app/browser/package/sidebar.rs index 2b3b0173..9b0c7eca 100644 --- a/packages/oneclient_app/src/view/app/browser/package/sidebar.rs +++ b/packages/oneclient_app/src/view/app/browser/package/sidebar.rs @@ -1,9 +1,9 @@ use super::*; -use oneclient_core::packages::ProviderId; -use oneclient_core::packages::types::{ProjectDetail, ProjectMember}; +use oneclient_content::packages::ProviderId; +use oneclient_content::packages::types::{ProjectDetail, ProjectMember}; -use crate::BridgeDispatch; +use crate::Actions; use crate::components::{Button, Icon, IconType}; use crate::theme::colors; use crate::ui::border_all_color; @@ -13,7 +13,7 @@ pub(super) fn sidebar( latest_version: Option, provider: ProviderId, cluster_id: i64, - dispatch: BridgeDispatch, + dispatch: Actions, confirm: State>, ) -> impl IntoElement { let Some(project) = project else { diff --git a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs index 4d760ff9..3936d29e 100644 --- a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs +++ b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs @@ -1,7 +1,7 @@ use freya::prelude::*; -use oneclient_core::Patch; -use oneclient_core::java::JavaRuntime; -use oneclient_core::packages::domain::GameLoader; +use oneclient_common::Patch; +use oneclient_java::JavaRuntime; +use oneclient_common::domain::GameLoader; use oneclient_core::settings::{GameSettingsProfile, ProfileUpdate, Resolution}; use crate::components::{ @@ -16,7 +16,8 @@ use crate::layout::cluster_content; use crate::theme::colors; use crate::view::app::settings::{section_header, settings_row}; -use super::{cluster_not_found, load_cluster}; +use super::cluster_not_found; +use crate::hooks::use_cluster; #[derive(PartialEq)] pub struct ClusterSettings { @@ -28,7 +29,7 @@ impl Component for ClusterSettings { let cluster_id = self.cluster_id; let global = use_settings_snapshot().settings.global_game_settings; - let cluster = load_cluster(cluster_id); + let cluster = use_cluster(cluster_id); let profile_name = cluster .as_ref() .and_then(|c| c.setting_profile_name.clone()); diff --git a/packages/oneclient_app/src/view/app/cluster/logs.rs b/packages/oneclient_app/src/view/app/cluster/logs.rs index e1369056..3cdb9492 100644 --- a/packages/oneclient_app/src/view/app/cluster/logs.rs +++ b/packages/oneclient_app/src/view/app/cluster/logs.rs @@ -20,7 +20,8 @@ use crate::theme::colors; use crate::ui; use crate::utils::format_size; -use super::{cluster_not_found, load_cluster}; +use super::cluster_not_found; +use crate::hooks::use_cluster; const CARD_BG: Color = Color::from_rgb(26, 34, 41); const PICKER_ROW_H: f32 = 44.; const PICKER_ROW_SPACING: f32 = 2.; @@ -160,7 +161,7 @@ impl Component for ClusterLogs { None, ); - let Some(cluster) = load_cluster(cluster_id) else { + let Some(cluster) = use_cluster(cluster_id) else { return cluster_not_found(); }; let folder = cluster.dir().ok().map(|d| d.join("logs")); diff --git a/packages/oneclient_app/src/view/app/cluster/mod.rs b/packages/oneclient_app/src/view/app/cluster/mod.rs index 1cf1f0ec..4f845e2f 100644 --- a/packages/oneclient_app/src/view/app/cluster/mod.rs +++ b/packages/oneclient_app/src/view/app/cluster/mod.rs @@ -20,25 +20,9 @@ pub use shaders::ClusterShaders; pub use textures::ClusterTextures; use freya::prelude::*; -use freya::query::QueryStateData; -use oneclient_core::clusters::Cluster; -use crate::hooks::use_clusters; use crate::theme::colors; -pub(crate) fn load_cluster(cluster_id: i64) -> Option { - let clusters_query = use_clusters(); - let reader = clusters_query.read(); - let list = match &*reader.state() { - QueryStateData::Settled { res: Ok(list), .. } - | QueryStateData::Loading { - res: Some(Ok(list)), - } => list.clone(), - _ => Vec::new(), - }; - list.into_iter().find(|c| c.id == cluster_id) -} - pub(crate) fn cluster_not_found() -> Element { rect() .width(Size::fill()) diff --git a/packages/oneclient_app/src/view/app/cluster/mods.rs b/packages/oneclient_app/src/view/app/cluster/mods.rs index 9c8622e6..1d94d439 100644 --- a/packages/oneclient_app/src/view/app/cluster/mods.rs +++ b/packages/oneclient_app/src/view/app/cluster/mods.rs @@ -1,5 +1,5 @@ use freya::prelude::*; -use oneclient_core::packages::ContentType; +use oneclient_content::packages::ContentType; use crate::hooks::{ bundle_overrides_map, bundles_with_status_items, cluster_content_items, use_bundle_overrides, @@ -10,7 +10,8 @@ use crate::layout::cluster_content; use super::package_manager::{ PackageManager, bundle_categories, bundle_packages, use_content_meta, }; -use super::{cluster_not_found, load_cluster}; +use super::cluster_not_found; +use crate::hooks::use_cluster; #[derive(PartialEq)] pub struct ClusterMods { @@ -26,7 +27,7 @@ impl Component for ClusterMods { let content_items = cluster_content_items(&content); let meta = use_content_meta(&content_items, &bundle_items, ContentType::Mod); - let Some(_cluster) = load_cluster(self.cluster_id) else { + let Some(_cluster) = use_cluster(self.cluster_id) else { return cluster_not_found(); }; diff --git a/packages/oneclient_app/src/view/app/cluster/overview.rs b/packages/oneclient_app/src/view/app/cluster/overview.rs index bb2458fa..73fd5153 100644 --- a/packages/oneclient_app/src/view/app/cluster/overview.rs +++ b/packages/oneclient_app/src/view/app/cluster/overview.rs @@ -4,9 +4,11 @@ use crate::components::ScrollArea; use crate::hooks::{try_cluster_analytics, use_cluster_analytics}; use crate::layout::cluster_content; use crate::theme::colors; +use crate::ui::{centered_note}; use crate::view::app::{analytics_body, analytics_placeholder}; -use super::{cluster_not_found, load_cluster}; +use super::cluster_not_found; +use crate::hooks::use_cluster; #[derive(PartialEq)] pub struct ClusterOverview { @@ -17,7 +19,7 @@ impl Component for ClusterOverview { fn render(&self) -> impl IntoElement { let cluster_id = self.cluster_id; - let Some(cluster) = load_cluster(cluster_id) else { + let Some(cluster) = use_cluster(cluster_id) else { return cluster_content().child(cluster_not_found()).into_element(); }; @@ -69,17 +71,3 @@ fn overview_header(name: &str) -> Element { ) .into_element() } - -fn centered_note(text: &str) -> Element { - rect() - .width(Size::fill()) - .height(Size::px(240.)) - .center() - .child( - label() - .text(text.to_string()) - .font_size(14.) - .color(colors::fg_secondary()), - ) - .into_element() -} diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs index d68863d4..450257c1 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs @@ -1,7 +1,7 @@ use std::collections::{HashMap, HashSet}; use freya::prelude::*; -use oneclient_core::packages::{CachedPackageMeta, ContentType, ProviderId}; +use oneclient_content::packages::{CachedPackageMeta, ContentType, ProviderId}; use oneclient_core::{BundleFileKind, BundleWithUpdateStatus, LinkedArtifactInfo}; use oneclient_db::models::OverrideType; diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs index 02d1228f..89037eee 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs @@ -2,7 +2,7 @@ use super::*; use freya::animation::{AnimNum, Ease, OnCreation, use_animation}; use freya::router::RouterContext; -use oneclient_core::packages::ContentType; +use oneclient_content::packages::ContentType; use oneclient_core::settings::ViewLayout; use crate::components::{ @@ -12,7 +12,7 @@ use crate::components::{ use crate::hooks::use_dispatch; use crate::routes::Route; use crate::theme::colors; -use crate::{BridgeDispatch, utils}; +use crate::{Actions, utils}; #[derive(Clone, Copy, PartialEq)] pub(super) enum SortMode { @@ -404,7 +404,7 @@ impl Component for ChoiceRow { fn add_from_file_button( cluster_id: i64, content_type: ContentType, - dispatch: BridgeDispatch, + dispatch: Actions, ) -> impl IntoElement { Button::new() .primary() @@ -550,8 +550,8 @@ impl Component for ContentBox { let mut bottom_corners = CornerRadius::new_all(0.); bottom_corners.fill_bottom(12.); - // File drops are handled window-wide by the app shell, which prompts for - // a destination — this box deliberately doesn't claim them. + // File drops are handled window-wide by the app shell, which prompts for a + // destination, so this box does not claim them. rect() .vertical() .width(Size::fill()) @@ -680,7 +680,7 @@ fn external_empty(cluster_id: i64, package_type: &'static str) -> impl IntoEleme fn local_empty( cluster_id: i64, content_type: ContentType, - dispatch: BridgeDispatch, + dispatch: Actions, noun_plural: &'static str, ) -> impl IntoElement { let is_wayland = utils::is_wayland(); diff --git a/packages/oneclient_app/src/view/app/cluster/process_logs.rs b/packages/oneclient_app/src/view/app/cluster/process_logs.rs index df8b1f78..feec885a 100644 --- a/packages/oneclient_app/src/view/app/cluster/process_logs.rs +++ b/packages/oneclient_app/src/view/app/cluster/process_logs.rs @@ -5,7 +5,8 @@ use crate::hooks::use_game_snapshot; use crate::layout::cluster_content; use crate::theme::colors; -use super::{cluster_not_found, load_cluster}; +use super::cluster_not_found; +use crate::hooks::use_cluster; #[derive(PartialEq)] pub struct ProcessLogs { @@ -15,7 +16,7 @@ pub struct ProcessLogs { impl Component for ProcessLogs { fn render(&self) -> impl IntoElement { let game = use_game_snapshot(); - let Some(_cluster) = load_cluster(self.cluster_id) else { + let Some(_cluster) = use_cluster(self.cluster_id) else { return cluster_not_found(); }; diff --git a/packages/oneclient_app/src/view/app/cluster/screenshots.rs b/packages/oneclient_app/src/view/app/cluster/screenshots.rs index c9fd68e1..47db40c3 100644 --- a/packages/oneclient_app/src/view/app/cluster/screenshots.rs +++ b/packages/oneclient_app/src/view/app/cluster/screenshots.rs @@ -2,7 +2,6 @@ use std::collections::HashSet; use std::path::PathBuf; use freya::prelude::*; -use freya::query::QueryStateData; use oneclient_core::ScreenshotInfo; use oneclient_core::settings::ViewLayout; @@ -10,16 +9,14 @@ use crate::components::{ Button, ContextMenu, Icon, IconType, LocalImage, OverlayPopup, ScreenshotViewer, ScrollArea, Segment, SegmentedControl, open_folder_button, }; -use crate::hooks::{ - ScreenshotAction, try_cluster_screenshots, use_cluster_screenshots, use_dispatch, - use_screenshot_action, use_view_state, -}; +use crate::hooks::{ScreenshotAction, query_is_loading, try_cluster_screenshots, use_cluster_screenshots, use_dispatch, use_screenshot_action, use_view_state}; use crate::layout::cluster_content; use crate::theme::colors; use crate::ui::{border_all_color, fmt_date}; use crate::utils::{format_res, format_size}; -use super::{cluster_not_found, load_cluster}; +use super::cluster_not_found; +use crate::hooks::use_cluster; const THUMB_EDGE: u32 = 480; const MAX_COL_W: f32 = 400.; @@ -36,7 +33,7 @@ pub struct ClusterScreenshots { impl Component for ClusterScreenshots { fn render(&self) -> impl IntoElement { - let Some(cluster) = load_cluster(self.cluster_id) else { + let Some(cluster) = use_cluster(self.cluster_id) else { return cluster_not_found(); }; let folder = cluster.game_dir().ok().map(|d| d.join("screenshots")); @@ -66,10 +63,7 @@ impl Component for ClusterScreenshots { ); let content: Element = if shots.is_empty() { - empty_state(matches!( - &*query.read().state(), - QueryStateData::Loading { res: None } - )) + empty_state(query_is_loading(&query)) .into_element() } else { let mut items: Vec = Vec::new(); diff --git a/packages/oneclient_app/src/view/app/cluster/shaders.rs b/packages/oneclient_app/src/view/app/cluster/shaders.rs index a69000bc..28a1d3af 100644 --- a/packages/oneclient_app/src/view/app/cluster/shaders.rs +++ b/packages/oneclient_app/src/view/app/cluster/shaders.rs @@ -1,5 +1,5 @@ use freya::prelude::*; -use oneclient_core::packages::ContentType; +use oneclient_content::packages::ContentType; use crate::hooks::{ bundle_overrides_map, bundles_with_status_items, cluster_content_items, use_bundle_overrides, @@ -10,7 +10,8 @@ use crate::layout::cluster_content; use super::package_manager::{ PackageManager, bundle_categories, bundle_packages, use_content_meta, }; -use super::{cluster_not_found, load_cluster}; +use super::cluster_not_found; +use crate::hooks::use_cluster; #[derive(PartialEq)] pub struct ClusterShaders { @@ -26,7 +27,7 @@ impl Component for ClusterShaders { let content_items = cluster_content_items(&content); let meta = use_content_meta(&content_items, &bundle_items, ContentType::Shader); - let Some(_cluster) = load_cluster(self.cluster_id) else { + let Some(_cluster) = use_cluster(self.cluster_id) else { return cluster_not_found(); }; diff --git a/packages/oneclient_app/src/view/app/cluster/textures.rs b/packages/oneclient_app/src/view/app/cluster/textures.rs index 9f08ec8c..acae67f5 100644 --- a/packages/oneclient_app/src/view/app/cluster/textures.rs +++ b/packages/oneclient_app/src/view/app/cluster/textures.rs @@ -1,5 +1,5 @@ use freya::prelude::*; -use oneclient_core::packages::ContentType; +use oneclient_content::packages::ContentType; use crate::hooks::{ bundle_overrides_map, bundles_with_status_items, cluster_content_items, use_bundle_overrides, @@ -10,7 +10,8 @@ use crate::layout::cluster_content; use super::package_manager::{ PackageManager, bundle_categories, bundle_packages, use_content_meta, }; -use super::{cluster_not_found, load_cluster}; +use super::cluster_not_found; +use crate::hooks::use_cluster; #[derive(PartialEq)] pub struct ClusterTextures { @@ -26,7 +27,7 @@ impl Component for ClusterTextures { let content_items = cluster_content_items(&content); let meta = use_content_meta(&content_items, &bundle_items, ContentType::ResourcePack); - let Some(_cluster) = load_cluster(self.cluster_id) else { + let Some(_cluster) = use_cluster(self.cluster_id) else { return cluster_not_found(); }; diff --git a/packages/oneclient_app/src/view/app/clusters/page.rs b/packages/oneclient_app/src/view/app/clusters/page.rs index 7cc844c1..b8880b98 100644 --- a/packages/oneclient_app/src/view/app/clusters/page.rs +++ b/packages/oneclient_app/src/view/app/clusters/page.rs @@ -1,17 +1,13 @@ use freya::prelude::*; -use freya::query::QueryStateData; use freya::router::RouterContext; -use oneclient_core::VersionKey; +use oneclient_common::VersionKey; use oneclient_core::clusters::Cluster; -use oneclient_core::packages::domain::GameLoader; +use oneclient_common::domain::GameLoader; use crate::components::{ Button, ClusterLandscapeArt, Dropdown, Icon, IconType, ScrollArea, VersionCard, }; -use crate::hooks::{ - use_active_cluster_id, use_clusters, use_dispatch, use_game_snapshot, use_launcher, - use_version_metadata, -}; +use crate::hooks::{settled_or_loading, use_active_cluster_id, use_clusters, use_dispatch, use_game_snapshot, use_launcher, use_version_metadata}; use crate::routes::Route; use crate::theme::colors; use crate::ui::border_all_color; @@ -39,16 +35,7 @@ impl Component for Clusters { let mut selected_loader = use_state(|| None::); let mut grid_columns = use_state(|| 2_usize); - let clusters = { - let reader = clusters_query.read(); - match &*reader.state() { - QueryStateData::Settled { res: Ok(list), .. } => list.clone(), - QueryStateData::Loading { - res: Some(Ok(list)), - } => list.clone(), - _ => Vec::new(), - } - }; + let clusters = settled_or_loading(&clusters_query).unwrap_or_default(); let groups = group_clusters_by_release(&clusters); // Newest version first. @@ -98,7 +85,7 @@ impl Component for Clusters { if selected_version.read().is_none() { let preferred = active_cluster .as_ref() - .and_then(|c| oneclient_core::parse_mc_version(&c.mc_version)) + .and_then(|c| oneclient_common::parse_mc_version(&c.mc_version)) .and_then(|p| p.key()); *selected_version.write() = default_version_key(&clusters_for_line, preferred); } @@ -322,7 +309,7 @@ fn tags_row(tags: &[String]) -> Option { ) } -/// Only shown when the line holds more than one version — a single-version line +/// Only shown when the line holds more than one version; a single-version line /// already names it on the card and in the heading. fn version_rows( line: ReleaseLine, @@ -400,7 +387,7 @@ fn info_row(label_text: impl Into, control: impl IntoElement) -> Element fn play_button( cluster_id: i64, - dispatch: crate::BridgeDispatch, + dispatch: crate::Actions, state: (&'static str, bool), ) -> impl IntoElement { let (label, enabled) = state; diff --git a/packages/oneclient_app/src/view/app/debug.rs b/packages/oneclient_app/src/view/app/debug.rs index 86d9c2a0..7d757132 100644 --- a/packages/oneclient_app/src/view/app/debug.rs +++ b/packages/oneclient_app/src/view/app/debug.rs @@ -1,9 +1,8 @@ use freya::prelude::*; use freya::router::RouterContext; -use oneclient_core::LauncherState; use oneclient_db::console::{ConsoleQueryResult, run_console_query}; -use oneclient_core::auth::preview_samples; +use oneclient_auth::preview_samples; use crate::components::{Button, Icon, IconType, TextInput, login_dialog, toggle}; use crate::hooks::use_dispatch; @@ -266,7 +265,7 @@ impl Component for ClusterUpdateSimulator { ) .child( label() - .text("Presets — every shape the notification and modal can take.") + .text("Presets: every shape the notification and modal can take.") .font_size(13.) .color(colors::fg_secondary()), ) @@ -350,7 +349,7 @@ const CLUSTER_UPDATE_PRESETS: [(&str, IconType, ClusterUpdatePreset); 6] = [ }), ]; -fn cluster_update_preset_rows(dispatch: &crate::BridgeDispatch) -> Vec { +fn cluster_update_preset_rows(dispatch: &crate::Actions) -> Vec { CLUSTER_UPDATE_PRESETS .chunks(3) .map(|chunk| { @@ -398,7 +397,7 @@ fn cluster_update_items(names: &[impl AsRef]) -> Vec { /// Mirrors the copy the bridge builds for real syncs so the simulated /// notification is indistinguishable from the real one. -fn send_cluster_update(dispatch: &crate::BridgeDispatch, summaries: Vec) { +fn send_cluster_update(dispatch: &crate::Actions, summaries: Vec) { if summaries.is_empty() { return; } @@ -441,7 +440,7 @@ impl Component for LauncherUpdateSimulator { .spacing(10.) .child( label() - .text("Drives the real auto-update UX (prompt → download progress → \"restart to apply\") against a fake release — no network or disk. \"Check for Updates Now\" runs the real check against the release feed.") + .text("Drives the real auto-update UX (prompt → download progress → \"restart to apply\") against a fake release, with no network or disk. \"Check for Updates Now\" runs the real check against the release feed.") .font_size(13.) .color(colors::fg_secondary()), ) @@ -584,7 +583,7 @@ fn run_sql(query: State, mut result: State, mut running: Stat running.set(true); spawn(async move { - let res = match LauncherState::get() { + let res = match crate::launcher::state() { Ok(state) => run_console_query(&state.services.db, &sql) .await .map_err(|e| e.to_string()), @@ -616,7 +615,7 @@ fn sql_result(state: &SqlResult) -> Element { ) .into_element(), Some(Ok(res)) if !res.is_select => label() - .text(format!("OK — {} row(s) affected.", res.rows_affected)) + .text(format!("OK, {} row(s) affected.", res.rows_affected)) .font_size(13.) .color(colors::success()) .into_element(), @@ -705,13 +704,12 @@ fn split_csv(raw: &str) -> Vec { .collect() } +/// The debug page's rule: the shared one, but inset and rounded to sit +/// between its loose stacks of rows rather than butt up against them. fn divider() -> impl IntoElement { - rect() - .width(Size::fill()) - .height(Size::px(1.)) + crate::ui::divider() .margin(Gaps::new_symmetric(8., 0.)) .corner_radius(CornerRadius::new_all(1.)) - .background(colors::component_border()) .into_element() } diff --git a/packages/oneclient_app/src/view/app/mod.rs b/packages/oneclient_app/src/view/app/mod.rs index 7faa1664..c8fefbed 100644 --- a/packages/oneclient_app/src/view/app/mod.rs +++ b/packages/oneclient_app/src/view/app/mod.rs @@ -10,8 +10,8 @@ pub mod browser; pub mod cluster; pub mod settings; -use crate::bridge::GameSnapshot; -use oneclient_core::notification::LaunchStage; +use crate::state::GameState as GameSnapshot; +use oneclient_events::LaunchStage; pub fn launch_button_state( game: &GameSnapshot, diff --git a/packages/oneclient_app/src/view/app/settings/java.rs b/packages/oneclient_app/src/view/app/settings/java.rs index 10ab58d6..ab0cbf31 100644 --- a/packages/oneclient_app/src/view/app/settings/java.rs +++ b/packages/oneclient_app/src/view/app/settings/java.rs @@ -1,9 +1,9 @@ use freya::prelude::*; -use oneclient_core::java::{JavaRuntime, JavaVendor}; +use oneclient_java::{JavaRuntime, JavaVendor}; use super::settings_page; use crate::components::{Button, Icon, IconType, JavaInstallManager, ScrollArea}; -use crate::hooks::{BridgeDispatch, java_runtimes, use_dispatch, use_java_runtimes}; +use crate::hooks::{Actions, java_runtimes, use_dispatch, use_java_runtimes}; use crate::theme::colors; use crate::ui::border_all_color; use crate::view::app::settings::section_header; @@ -234,7 +234,7 @@ fn path_content_width(path: &str) -> f32 { (path.chars().count() as f32 * 7.0).max(1.0) } -fn remove_button(dispatch: BridgeDispatch, path: String) -> impl IntoElement { +fn remove_button(dispatch: Actions, path: String) -> impl IntoElement { Button::new() .ghost() .small() diff --git a/packages/oneclient_app/src/view/app/settings/minecraft.rs b/packages/oneclient_app/src/view/app/settings/minecraft.rs index bbd2041b..914361a6 100644 --- a/packages/oneclient_app/src/view/app/settings/minecraft.rs +++ b/packages/oneclient_app/src/view/app/settings/minecraft.rs @@ -1,5 +1,5 @@ use freya::prelude::*; -use oneclient_core::Patch; +use oneclient_common::Patch; use oneclient_core::settings::{ProfileUpdate, Resolution}; use super::settings_page; diff --git a/packages/oneclient_app/src/view/app/stats.rs b/packages/oneclient_app/src/view/app/stats.rs index cad698b0..744c0fd3 100644 --- a/packages/oneclient_app/src/view/app/stats.rs +++ b/packages/oneclient_app/src/view/app/stats.rs @@ -3,6 +3,7 @@ use freya::prelude::*; use crate::components::ScrollArea; use crate::hooks::{try_global_analytics, use_global_analytics}; use crate::theme::colors; +use crate::ui::{centered_note}; use crate::view::app::{analytics_body, analytics_placeholder}; #[derive(PartialEq)] @@ -66,17 +67,3 @@ fn header() -> Element { fn loading_state() -> Element { centered_note("Crunching your playtime…") } - -fn centered_note(text: &str) -> Element { - rect() - .width(Size::fill()) - .height(Size::px(240.)) - .center() - .child( - label() - .text(text.to_string()) - .font_size(14.) - .color(colors::fg_secondary()), - ) - .into_element() -} diff --git a/packages/oneclient_app/src/view/onboarding/account.rs b/packages/oneclient_app/src/view/onboarding/account.rs index 72982825..1d1f44f9 100644 --- a/packages/oneclient_app/src/view/onboarding/account.rs +++ b/packages/oneclient_app/src/view/onboarding/account.rs @@ -1,5 +1,5 @@ use freya::prelude::*; -use oneclient_core::auth::MinecraftAccount; +use oneclient_auth::MinecraftAccount; use crate::components::{Avatar, Button, Icon, IconType, use_microsoft_login}; use crate::hooks::{try_default_account, use_current_account}; diff --git a/packages/oneclient_app/src/view/onboarding/bundles/mod.rs b/packages/oneclient_app/src/view/onboarding/bundles/mod.rs index 7635a0a8..6fe924a7 100644 --- a/packages/oneclient_app/src/view/onboarding/bundles/mod.rs +++ b/packages/oneclient_app/src/view/onboarding/bundles/mod.rs @@ -1,18 +1,14 @@ use std::collections::{HashMap, HashSet}; use freya::prelude::*; -use freya::query::QueryStateData; use oneclient_core::clusters::Cluster; -use oneclient_core::packages::ProviderId; +use oneclient_content::packages::ProviderId; use oneclient_core::{BundleArchive, BundleFile, BundleFileKind}; use crate::components::ScrollArea; -use crate::hooks::{ - ClusterBundles, onboarding_bundles_items, package_meta_batch, use_onboarding_bundles, - use_onboarding_selection, use_package_meta_batch, -}; +use crate::hooks::{ClusterBundles, onboarding_bundles_items, package_meta_batch, query_error, query_is_loading, use_onboarding_bundles, use_onboarding_selection, use_package_meta_batch}; -type MetaMap = HashMap; +type MetaMap = HashMap; use crate::routes::Route; use crate::theme::colors; use crate::ui::border_all_color; @@ -212,17 +208,10 @@ impl Component for OnboardingBundles { let clusters = onboarding_bundles_items(&bundles_query).unwrap_or_default(); let bundles_loaded = onboarding_bundles_items(&bundles_query).is_some(); - let catalog_msg = { - let reader = bundles_query.read(); - match &*reader.state() { - QueryStateData::Settled { res: Err(err), .. } => { - format!("Couldn't load bundles: {err}") - } - QueryStateData::Pending | QueryStateData::Loading { res: None } => { - "Loading bundles...".to_string() - } - _ => "No optional bundles available".to_string(), - } + let catalog_msg = match query_error(&bundles_query) { + Some(err) => format!("Couldn't load bundles: {err}"), + None if query_is_loading(&bundles_query) => "Loading bundles...".to_string(), + None => "No optional bundles available".to_string(), }; let selected_set = selected.read().clone(); diff --git a/packages/oneclient_app/src/view/onboarding/bundles/row.rs b/packages/oneclient_app/src/view/onboarding/bundles/row.rs index ceb51314..ec165bcc 100644 --- a/packages/oneclient_app/src/view/onboarding/bundles/row.rs +++ b/packages/oneclient_app/src/view/onboarding/bundles/row.rs @@ -1,9 +1,8 @@ use freya::prelude::*; -use freya::query::QueryStateData; -use oneclient_core::packages::ProviderId; +use oneclient_content::packages::ProviderId; use crate::components::{Icon, provider_badge}; -use crate::hooks::use_cached_image; +use crate::hooks::{loaded_image, use_cached_image}; use crate::theme::colors; use crate::ui::border_all_color; use crate::utils::format_size; @@ -29,19 +28,7 @@ pub(super) struct OnboardingModCard { impl Component for OnboardingModCard { fn render(&self) -> impl IntoElement { let icon_query = use_cached_image(self.icon_url.clone(), 128); - let loaded = { - let reader = icon_query.read(); - match (&self.icon_url, &*reader.state()) { - (Some(url), QueryStateData::Settled { res: Ok(bytes), .. }) - | ( - Some(url), - QueryStateData::Loading { - res: Some(Ok(bytes)), - }, - ) => Some((url.clone(), bytes.clone())), - _ => None, - } - }; + let loaded = loaded_image(self.icon_url.as_deref(), &icon_query); let icon = match loaded { Some((url, bytes)) => ImageViewer::new((url, bytes)) diff --git a/packages/oneclient_app/src/view/onboarding/downloading/mod.rs b/packages/oneclient_app/src/view/onboarding/downloading/mod.rs index e1c555f2..e54a84df 100644 --- a/packages/oneclient_app/src/view/onboarding/downloading/mod.rs +++ b/packages/oneclient_app/src/view/onboarding/downloading/mod.rs @@ -4,8 +4,8 @@ use std::time::{Duration, Instant}; use freya::animation::*; use freya::prelude::*; use freya::router::RouterContext; -use oneclient_core::notification::{ - GroupedProgressEvent, GroupedProgressSession, Notification, NotificationService, TaskCategory, +use oneclient_events::{ + EventBus, GroupedProgressEvent, GroupedProgressSession, ProgressEvent, TaskCategory, }; use oneclient_core::{ BundleArchive, BundleFile, Cluster, ImportTarget, MigrationSource, SentryExclusion, @@ -16,13 +16,14 @@ use tokio::sync::mpsc; use uuid::Uuid; use crate::hooks::{ - BridgeDispatch, ClusterBundles, invalidate_cluster_queries, migration_detection, + Actions, ClusterBundles, invalidate_cluster_queries, migration_detection, onboarding_bundles_items, try_default_account, use_current_account, use_dispatch, use_migration, use_onboarding_bundles, use_onboarding_selection, use_settings_snapshot, use_versions, versions_metadata, }; use crate::routes::Route; use crate::theme::colors; +use crate::transfer::{TransferMeter, TransferStats}; use crate::view::onboarding::{matching_new_cluster_id, pkg_key}; mod backdrop; @@ -101,7 +102,7 @@ impl GroupedAgg { /// One lane per category, taken round-robin. /// /// Assets run 32-at-a-time and sort before everything alphabetically, so a - /// flat sorted list showed nothing but assets for the whole game download — + /// flat sorted list showed nothing but assets for the whole game download; /// libraries, natives and the client were downloading at the same time and /// never got a row. Round-robin gives every active category a slot before /// any category gets a second one. @@ -178,11 +179,6 @@ impl GroupedAgg { } } -#[derive(Clone, Copy, PartialEq, Default)] -struct Meter { - speed_bps: f64, -} - fn categorize(label: &str) -> &'static str { if label.starts_with("Assets index") { "asset index" @@ -225,7 +221,7 @@ struct InstallHandles { stage: State, activity: State, started_at: State>, - meter: State, + meter: State>, total_estimate: State, failures: State>, running: State, @@ -258,7 +254,7 @@ impl Component for OnboardingDownloading { let stage = use_state(DownloadStage::default); let activity = use_state(String::new); let started_at = use_state(|| None::); - let meter = use_state(Meter::default); + let meter = use_state(|| None::); let total_estimate = use_state(|| 0u64); let failures = use_state(Vec::::new); let running = use_state(|| false); @@ -309,7 +305,7 @@ impl Component for OnboardingDownloading { let stage_now = stage.read().clone(); let agg_now = agg.read().clone(); let activity_now = activity.read().clone(); - let meter_now = *meter.read(); + let transfer = *meter.read(); let total_estimate_now = *total_estimate.read(); let elapsed_secs = (*started_at.read()).map(|s| s.elapsed().as_secs()); let predownload = *predownload_state.read(); @@ -488,7 +484,7 @@ impl Component for OnboardingDownloading { stage: &stage_now, agg: &agg_now, activity: &activity_now, - speed_bps: meter_now.speed_bps, + transfer, total_estimate: total_estimate_now, elapsed_secs, done, @@ -645,37 +641,25 @@ fn run_install_batch(plans: Vec, predownload: bool, handles: Instal complete.set(false); failures.set(Vec::new()); agg.set(GroupedAgg::default()); - meter.set(Meter::default()); + meter.set(None); total_estimate.set(0); activity.set("Getting started...".to_string()); started_at.set(Some(Instant::now())); let total = plans.len(); progress.set((0, total)); - let (notif_tx, mut notif_rx) = mpsc::unbounded_channel::(); - let notifier = NotificationService::new(notif_tx); + let (events, mut notif_rx) = EventBus::channel(); let (ui_tx, mut ui_rx) = mpsc::unbounded_channel::(); spawn(async move { - let mut prev = 0u64; - let mut speed = 0f64; + let mut transfer = TransferMeter::default(); loop { tokio::time::sleep(Duration::from_millis(1000)).await; let downloaded = agg.peek().downloaded_bytes(); - let delta = downloaded.saturating_sub(prev); - let inst = delta as f64; - - speed = if speed <= 0.0 { - inst - } else { - speed * 0.6 + inst * 0.4 - }; - - prev = downloaded; - meter.set(Meter { speed_bps: speed }); + meter.set(transfer.sample(downloaded, *total_estimate.peek())); if !*running.peek() { break; @@ -689,12 +673,12 @@ fn run_install_batch(plans: Vec, predownload: bool, handles: Instal let Some(first) = notif_rx.recv().await else { break; }; - if let Notification::GroupedProgress(event) = first { + if let oneclient_events::Event::Progress(ProgressEvent::Grouped(event)) = first { apply_grouped(&mut local, event); } // Drain everything already queued in one pass. while let Ok(notification) = notif_rx.try_recv() { - if let Notification::GroupedProgress(event) = notification { + if let oneclient_events::Event::Progress(ProgressEvent::Grouped(event)) = notification { apply_grouped(&mut local, event); } } @@ -730,7 +714,7 @@ fn run_install_batch(plans: Vec, predownload: bool, handles: Instal let _ = ui_tx.send(InstallUiEvent::Activity( "Calculating download size...".to_string(), )); - if let Ok(state) = oneclient_core::LauncherState::get() { + if let Ok(state) = crate::launcher::state() { let mut sum = 0u64; for plan in plans.iter().filter(|p| p.predownload) { match oneclient_core::estimate_cluster_download( @@ -759,7 +743,7 @@ fn run_install_batch(plans: Vec, predownload: bool, handles: Instal })); let fetch_now = predownload && plan.predownload; - if let Err(err) = install_one(&plan, fetch_now, ¬ifier, &ui_tx).await { + if let Err(err) = install_one(&plan, fetch_now, &events, &ui_tx).await { // Environmental / expected failures (out of disk, offline, no Java // build for this version, ...) are surfaced to the user but aren't // crashes, so log them at warn! (breadcrumb) instead of error! @@ -785,7 +769,7 @@ fn run_install_batch(plans: Vec, predownload: bool, handles: Instal let _ = ui_tx.send(InstallUiEvent::Progress(index + 1, total)); } - drop(notifier); + drop(events); let _ = ui_tx.send(InstallUiEvent::Finished(failed)); }); } @@ -793,17 +777,17 @@ fn run_install_batch(plans: Vec, predownload: bool, handles: Instal async fn install_one( plan: &ClusterPlan, predownload: bool, - notifier: &NotificationService, + events: &EventBus, ui_tx: &mpsc::UnboundedSender, ) -> oneclient_core::LauncherResult<()> { - let state = oneclient_core::LauncherState::get()?; + let state = crate::launcher::state()?; if !plan.overrides.is_empty() { let _ = ui_tx.send(InstallUiEvent::Activity( "Saving your package choices...".to_string(), )); } - oneclient_core::set_bundle_package_overrides(plan.cluster_id, &plan.overrides, &state.services) + oneclient_core::set_bundle_package_overrides(plan.cluster_id, &plan.overrides, &state.services.content()) .await?; if !predownload { @@ -811,14 +795,14 @@ async fn install_one( } let session = - GroupedProgressSession::start(notifier, format!("Downloading {}", plan.mc_version)); + GroupedProgressSession::start(events, format!("Downloading {}", plan.mc_version)); let _ = ui_tx.send(InstallUiEvent::Activity(format!( "Downloading Minecraft {}...", plan.mc_version ))); - let prepared = oneclient_core::ClusterManager::prepare( + let prepared = oneclient_core::clusters::prepare_cluster_locked( &state, plan.cluster_id, false, @@ -837,7 +821,7 @@ async fn install_one( plan.cluster_id, state.bundles.as_ref(), Some(&session), - &state.services, + &state.services.content(), ) .await } else { @@ -913,7 +897,7 @@ fn apply_grouped(agg: &mut GroupedAgg, event: GroupedProgressEvent) { } fn finish_onboarding( - dispatch: BridgeDispatch, + dispatch: Actions, bundles_query: &freya::query::UseQuery, ) { let versions: Vec = onboarding_bundles_items(bundles_query) diff --git a/packages/oneclient_app/src/view/onboarding/downloading/progress.rs b/packages/oneclient_app/src/view/onboarding/downloading/progress.rs index 4cb23294..8a196d2e 100644 --- a/packages/oneclient_app/src/view/onboarding/downloading/progress.rs +++ b/packages/oneclient_app/src/view/onboarding/downloading/progress.rs @@ -94,7 +94,8 @@ pub(super) struct ProgressView<'a> { pub(super) stage: &'a DownloadStage, pub(super) agg: &'a GroupedAgg, pub(super) activity: &'a str, - pub(super) speed_bps: f64, + /// `None` until the meter has two samples to work from. + pub(super) transfer: Option, pub(super) total_estimate: u64, pub(super) elapsed_secs: Option, pub(super) done: usize, @@ -109,7 +110,7 @@ pub(super) fn progress_panel(view: ProgressView) -> impl IntoElement { stage, agg, activity, - speed_bps, + transfer, total_estimate, elapsed_secs, done, @@ -124,7 +125,7 @@ pub(super) fn progress_panel(view: ProgressView) -> impl IntoElement { "Done".to_string() } else if predownload && stage.total > 0 { format!( - "Version {} of {} — {}", + "Version {} of {}: {}", (stage.index + 1).min(stage.total), stage.total, stage.label @@ -151,7 +152,7 @@ pub(super) fn progress_panel(view: ProgressView) -> impl IntoElement { let stats = build_stats(StatsView { global, - speed_bps, + transfer, total_estimate, elapsed_secs, running, @@ -221,7 +222,7 @@ pub(super) fn progress_panel(view: ProgressView) -> impl IntoElement { struct StatsView { global: f32, - speed_bps: f64, + transfer: Option, total_estimate: u64, elapsed_secs: Option, running: bool, @@ -232,7 +233,7 @@ struct StatsView { fn build_stats(view: StatsView) -> Option { let StatsView { global, - speed_bps, + transfer, total_estimate, elapsed_secs, running, @@ -248,18 +249,18 @@ fn build_stats(view: StatsView) -> Option { if finished { parts.push("Complete".to_string()); } else { - if global > 2.0 && global < 100.0 { - let remaining = (elapsed as f64) * ((100.0 - global as f64) / global as f64); - parts.push(format!( + // Both readings come off the same smoothed rate, so the countdown and + // the throughput beside it always agree. + match transfer.and_then(|t| t.eta_secs) { + Some(remaining) => parts.push(format!( "~{} left", - format_duration_hms(remaining.round() as i64) - )); - } else { - parts.push("~— left".to_string()); + format_duration_hms(remaining as i64) + )), + None => parts.push("~— left".to_string()), } - if speed_bps >= 1.0 { - parts.push(format!("{}/s", format_size(speed_bps as u64))); + if let Some(speed) = transfer.map(|t| t.speed_bps).filter(|s| *s >= 1.0) { + parts.push(format!("{}/s", format_size(speed as u64))); } } diff --git a/packages/oneclient_app/src/view/onboarding/downloading/tips.rs b/packages/oneclient_app/src/view/onboarding/downloading/tips.rs index 75b15ad9..69d8e6b9 100644 --- a/packages/oneclient_app/src/view/onboarding/downloading/tips.rs +++ b/packages/oneclient_app/src/view/onboarding/downloading/tips.rs @@ -47,7 +47,7 @@ pub(super) fn use_onboarding_tip() -> String { use_hook(move || { spawn(async move { - let Ok(state) = oneclient_core::LauncherState::get() else { + let Ok(state) = crate::launcher::state() else { return; }; match state diff --git a/packages/oneclient_app/src/view/onboarding/migration.rs b/packages/oneclient_app/src/view/onboarding/migration.rs index 0a957600..3a0e8dd9 100644 --- a/packages/oneclient_app/src/view/onboarding/migration.rs +++ b/packages/oneclient_app/src/view/onboarding/migration.rs @@ -72,7 +72,7 @@ impl Component for OnboardingMigration { "Bring over your Minecraft files", "We found a Minecraft installation on this PC. We can copy your worlds, \ settings, screenshots and servers over. Mods, resource packs and shaders stay \ - behind — OneClient manages those for you." + behind; OneClient manages those for you." .to_string(), ) } else { @@ -97,9 +97,9 @@ impl Component for OnboardingMigration { import_choice_card( "Don't import files", if is_vanilla { - "Start fresh — nothing is copied over." + "Start fresh. Nothing is copied over." } else { - "Start fresh — only your bundle selection carries over." + "Start fresh. Only your bundle selection carries over." }, None, chosen_folder.is_none(), diff --git a/packages/oneclient_app/src/view/onboarding/terms.rs b/packages/oneclient_app/src/view/onboarding/terms.rs index 7b4d7468..b4eb2ecf 100644 --- a/packages/oneclient_app/src/view/onboarding/terms.rs +++ b/packages/oneclient_app/src/view/onboarding/terms.rs @@ -54,11 +54,11 @@ impl Component for OnboardingTerms { let terms_url = document .as_ref() .map(|doc| doc.terms_url().to_string()) - .unwrap_or_else(|| oneclient_core::constants::TOS_URL.to_string()); + .unwrap_or_else(|| oneclient_common::constants::TOS_URL.to_string()); let privacy_url = document .as_ref() .map(|doc| doc.privacy_url().to_string()) - .unwrap_or_else(|| oneclient_core::constants::PRIVACY_URL.to_string()); + .unwrap_or_else(|| oneclient_common::constants::PRIVACY_URL.to_string()); let privacy_body = document.as_ref().and_then(|doc| doc.privacy_body()); let tabs = privacy_body.is_some().then(|| tab_switcher(tab)); diff --git a/packages/oneclient_app/src/view/onboarding/test_support.rs b/packages/oneclient_app/src/view/onboarding/test_support.rs index 516df63c..3ce0fc03 100644 --- a/packages/oneclient_app/src/view/onboarding/test_support.rs +++ b/packages/oneclient_app/src/view/onboarding/test_support.rs @@ -1,6 +1,6 @@ use oneclient_core::clusters::{Cluster, ClusterStage}; -use oneclient_core::packages::domain::{ContentType, GameLoader}; -use oneclient_core::packages::types::ExternalFile; +use oneclient_common::domain::{ContentType, GameLoader}; +use oneclient_content::packages::types::ExternalFile; use oneclient_core::{Bundle, BundleArchive, BundleFile, BundleFileKind, BundleManifest}; use std::path::PathBuf; diff --git a/packages/oneclient_auth/Cargo.toml b/packages/oneclient_auth/Cargo.toml new file mode 100644 index 00000000..0b3973ae --- /dev/null +++ b/packages/oneclient_auth/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "oneclient_auth" +description = "Microsoft/Xbox/Minecraft authentication and the credentials store" +version.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true +documentation.workspace = true +readme.workspace = true +homepage.workspace = true +authors.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +oneclient_common.workspace = true +oneclient_events.workspace = true +oneclient_net.workspace = true +polyio.workspace = true + +reqwest.workspace = true +tokio.workspace = true + +base64.workspace = true +chrono.workspace = true +# Offline UUIDs are MD5 of "OfflinePlayer:", per Mojang. +md-5.workspace = true +rand.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +thiserror.workspace = true +tracing.workspace = true +url.workspace = true +uuid.workspace = true + +[dev-dependencies] +tokio = { workspace = true } diff --git a/packages/oneclient_core/src/auth/data.rs b/packages/oneclient_auth/src/data.rs similarity index 100% rename from packages/oneclient_core/src/auth/data.rs rename to packages/oneclient_auth/src/data.rs diff --git a/packages/oneclient_core/src/auth/diagnostics.rs b/packages/oneclient_auth/src/diagnostics.rs similarity index 100% rename from packages/oneclient_core/src/auth/diagnostics.rs rename to packages/oneclient_auth/src/diagnostics.rs diff --git a/packages/oneclient_core/src/auth/error.rs b/packages/oneclient_auth/src/error.rs similarity index 94% rename from packages/oneclient_core/src/auth/error.rs rename to packages/oneclient_auth/src/error.rs index 91873c38..9128a88d 100644 --- a/packages/oneclient_core/src/auth/error.rs +++ b/packages/oneclient_auth/src/error.rs @@ -1,6 +1,6 @@ use serde::Serialize; -use crate::http::RequestError; +use oneclient_net::RequestError; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] pub enum MinecraftAuthStep { @@ -87,11 +87,19 @@ pub fn friendly_xbox_error(code: u64) -> Option<&'static str> { }) } +pub type AuthResult = Result; + #[derive(Debug, thiserror::Error)] pub enum AuthError { #[error(transparent)] Minecraft(#[from] MinecraftAuthError), + #[error(transparent)] + Io(#[from] polyio::IOError), + + #[error(transparent)] + Paths(#[from] oneclient_common::PathsError), + #[error(transparent)] Request(#[from] RequestError), diff --git a/packages/oneclient_auth/src/lib.rs b/packages/oneclient_auth/src/lib.rs new file mode 100644 index 00000000..704e3751 --- /dev/null +++ b/packages/oneclient_auth/src/lib.rs @@ -0,0 +1,24 @@ +//! Microsoft/Xbox/Minecraft authentication and the launcher's credentials store. +//! +//! The whole surface is [`AuthService`], which owns the credentials store, the +//! in-flight browser logins and the per-account refresh guards. Construct one +//! in the composition layer and pass it down; nothing in here reaches for a +//! global or a database. Accounts are persisted to `auth.json`. + +mod data; +mod diagnostics; +mod error; +mod msa; +mod offline; +mod service; +mod store; + +pub use data::{ + AccountKind, BrowserLogin, DeviceCodeLogin, MicrosoftLoginSession, MinecraftAccount, +}; +pub use diagnostics::{AuthErrorGuidance, AuthErrorSample, diagnose_auth_error, preview_samples}; +pub use error::{AuthError, AuthResult, MinecraftAuthError, MinecraftAuthStep}; +pub use msa::PendingBrowserLogin; +pub use offline::{offline_account, offline_uuid, validate_offline_username}; +pub use service::{AuthService, MICROSOFT_LOGIN_PROGRESS}; +pub use store::CredentialsStore; diff --git a/packages/oneclient_core/src/auth/msa.rs b/packages/oneclient_auth/src/msa.rs similarity index 99% rename from packages/oneclient_core/src/auth/msa.rs rename to packages/oneclient_auth/src/msa.rs index dfc60698..328ba822 100644 --- a/packages/oneclient_core/src/auth/msa.rs +++ b/packages/oneclient_auth/src/msa.rs @@ -17,7 +17,7 @@ use uuid::Uuid; use super::data::{AccountKind, BrowserLogin, DeviceCodeLogin, MinecraftAccount}; use super::error::{friendly_xbox_error, MinecraftAuthError, MinecraftAuthStep}; -use crate::constants::{MICROSOFT_CLIENT_ID, MINECRAFT_SCOPES}; +use oneclient_common::constants::{MICROSOFT_CLIENT_ID, MINECRAFT_SCOPES}; const DEVICE_CODE_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode"; diff --git a/packages/oneclient_core/src/auth/offline.rs b/packages/oneclient_auth/src/offline.rs similarity index 91% rename from packages/oneclient_core/src/auth/offline.rs rename to packages/oneclient_auth/src/offline.rs index 5e9604e5..d5d0e962 100644 --- a/packages/oneclient_core/src/auth/offline.rs +++ b/packages/oneclient_auth/src/offline.rs @@ -4,7 +4,7 @@ use uuid::Uuid; use super::error::AuthError; use super::data::{AccountKind, MinecraftAccount}; -use crate::LauncherResult; +use crate::error::AuthResult; pub fn offline_uuid(username: &str) -> Uuid { let mut hasher = Md5::new(); @@ -18,13 +18,13 @@ pub fn offline_uuid(username: &str) -> Uuid { Uuid::from_bytes(bytes) } -pub fn validate_offline_username(username: &str) -> LauncherResult<()> { +pub fn validate_offline_username(username: &str) -> AuthResult<()> { let len = username.chars().count(); if !(3..=16).contains(&len) { return Err(AuthError::InvalidOfflineUsername { reason: "username must be 3-16 characters".into(), } - .into()); + ); } if !username @@ -34,7 +34,7 @@ pub fn validate_offline_username(username: &str) -> LauncherResult<()> { return Err(AuthError::InvalidOfflineUsername { reason: "username may only contain letters, digits, and underscores".into(), } - .into()); + ); } Ok(()) diff --git a/packages/oneclient_auth/src/service.rs b/packages/oneclient_auth/src/service.rs new file mode 100644 index 00000000..cb2287e3 --- /dev/null +++ b/packages/oneclient_auth/src/service.rs @@ -0,0 +1,283 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex as StdMutex}; + +use oneclient_events::EventBus; +use oneclient_net::RequestClient; +use tokio::sync::Mutex; +use uuid::Uuid; + +use crate::data::{MicrosoftLoginSession, MinecraftAccount}; +use crate::error::{AuthError, AuthResult, MinecraftAuthError}; +use crate::msa::{self, PendingBrowserLogin}; +use crate::store::{self, CredentialsStore}; + +/// Stable id for the Microsoft login's progress, so a front-end can recognise +/// it among everything else on the bus. +/// +/// Naming it here is the core stating an identity, not a display decision. It +/// says nothing about whether this is a toast or a modal, which is the +/// front-end's call. It exists because the login is driven by a mutation with +/// no place to own a private bus. +pub const MICROSOFT_LOGIN_PROGRESS: Uuid = + Uuid::from_u128(0x4D53_4141_5554_4800_0000_0000_0000_0001); + +/// Owns everything the authentication flows need. +/// +/// Constructed once by the composition layer and passed down, so nothing here +/// reaches for a global. No database; accounts live in `auth.json` beside the +/// launcher data. +pub struct AuthService { + store: Mutex, + /// Browser logins waiting on their redirect, keyed by CSRF state token. + pending_logins: Mutex>, + /// Serialises token renewal per account. + /// + /// Microsoft rotates the refresh token on every use, so two concurrent + /// renewals of one account would race: the loser spends an already-consumed + /// refresh token and the account gets signed out. Clusters can launch in + /// parallel, so this is reachable. The guard makes the second caller wait and + /// reuse the first caller's result instead of starting its own chain. + /// + /// A `std::sync::Mutex` because its critical section is one map lookup; the + /// `tokio::Mutex` inside is what is held across the handshake. + refresh_guards: StdMutex>>>, + net: RequestClient, + events: EventBus, +} + +impl AuthService { + /// Loads the credentials store from disk. A missing or unreadable file + /// yields an empty store rather than failing: a corrupt `auth.json` should + /// mean "sign in again", not "the launcher will not start". + pub async fn load(net: RequestClient, events: EventBus) -> AuthResult { + Ok(Self::with_store(CredentialsStore::load().await?, net, events)) + } + + #[must_use] + pub fn with_store(store: CredentialsStore, net: RequestClient, events: EventBus) -> Self { + Self { + store: Mutex::new(store), + pending_logins: Mutex::new(HashMap::new()), + refresh_guards: StdMutex::new(HashMap::new()), + net, + events, + } + } + + // --- Microsoft login --------------------------------------------------- + + #[tracing::instrument(skip_all)] + pub async fn begin_microsoft_login(&self) -> AuthResult { + tracing::info!("beginning Microsoft login"); + let client = self.net.http(); + + let (browser, pending) = msa::begin_browser_login().await?; + let device = msa::begin_device_login(client).await?; + + self.pending_logins + .lock() + .await + .insert(browser.state.clone(), pending); + + Ok(MicrosoftLoginSession { browser, device }) + } + + #[tracing::instrument(skip_all)] + pub async fn finish_microsoft_login( + &self, + session: MicrosoftLoginSession, + ) -> AuthResult { + tracing::info!("finishing Microsoft login"); + + let pending = self + .pending_logins + .lock() + .await + .remove(&session.browser.state) + .ok_or(AuthError::Minecraft( + MinecraftAuthError::BrowserLoginNotFound, + ))?; + + let events = self.events.clone(); + let result = msa::finish_dual_login( + self.net.http(), + pending, + &session.device, + |label, current, total| { + events.progress(MICROSOFT_LOGIN_PROGRESS, label, current, total); + }, + ) + .await + .map_err(AuthError::from); + + // Drives the card to 100%, which is how a front-end knows the flow is + // over and can clear whatever it was rendering. + self.events + .progress(MICROSOFT_LOGIN_PROGRESS, "Signed in", 1, 1); + + if let Err(err) = &result { + let mut chain = String::new(); + let mut source = std::error::Error::source(err); + while let Some(cause) = source { + chain.push_str(&format!("\n caused by: {cause}")); + source = cause.source(); + } + tracing::warn!("Microsoft login failed: {err}{chain}"); + } + + let account = result?; + tracing::info!(username = %account.username, "Microsoft login succeeded"); + self.store + .lock() + .await + .commit_account(account, &self.events) + .await + } + + #[tracing::instrument(level = "debug", skip_all)] + pub async fn cancel_microsoft_login(&self, state_token: &str) { + self.pending_logins.lock().await.remove(state_token); + } + + // --- account management ------------------------------------------------ + + #[tracing::instrument(skip(self), fields(username = %username))] + pub async fn add_offline_account(&self, username: String) -> AuthResult { + self.store + .lock() + .await + .add_offline_account_and_save(username) + .await + } + + pub async fn list_accounts(&self) -> Vec { + self.store.lock().await.list_accounts() + } + + pub async fn get_account(&self, id: Uuid) -> Option { + self.store.lock().await.get_account(id).cloned() + } + + #[tracing::instrument(level = "debug", skip_all)] + pub async fn default_account(&self) -> AuthResult> { + self.store.lock().await.default_account().await + } + + #[tracing::instrument(level = "debug", skip(self), fields(?id))] + pub async fn set_default_account(&self, id: Option) -> AuthResult<()> { + self.store.lock().await.set_default_user(id).await + } + + #[tracing::instrument(skip(self), fields(%id))] + pub async fn remove_account(&self, id: Uuid) -> AuthResult<()> { + self.store.lock().await.remove_account(id).await?; + Ok(()) + } + + pub async fn has_microsoft_account(&self) -> bool { + self.store.lock().await.has_microsoft_account() + } + + // --- token renewal ----------------------------------------------------- + + fn refresh_guard(&self, id: Uuid) -> Arc> { + Arc::clone( + self.refresh_guards + .lock() + .expect("refresh guard registry poisoned") + .entry(id) + .or_default(), + ) + } + + /// Clones an account out of the store, so no caller holds the store lock + /// past this call. + async fn account_snapshot(&self, id: Uuid) -> AuthResult { + self.store + .lock() + .await + .get_account(id) + .cloned() + .ok_or(AuthError::AccountNotFound(id)) + } + + /// Renews `id`'s access token if it has lapsed, returning a usable account. + /// + /// The store lock is not held across the Microsoft handshake, only around the + /// reads and the final write. Holding it across those six sequential round + /// trips stalls every other account read behind a multi-second network call. + #[tracing::instrument(level = "debug", skip(self), fields(%id))] + async fn renew_token(&self, id: Uuid, force: bool) -> AuthResult { + let existing = self.account_snapshot(id).await?; + if !existing.is_microsoft() || (!force && !existing.is_expired()) { + return Ok(existing); + } + + let guard = self.refresh_guard(id); + let _serialised = guard.lock().await; + + // Re-read under the guard: whoever held it may have just refreshed. + let existing = self.account_snapshot(id).await?; + if !existing.is_microsoft() || (!force && !existing.is_expired()) { + return Ok(existing); + } + + tracing::info!(username = %existing.username, "renewing Microsoft access token"); + match msa::refresh_microsoft_account(self.net.http(), &existing).await { + Ok(refreshed) => { + self.store + .lock() + .await + .commit_refreshed_account(refreshed.clone()) + .await?; + Ok(refreshed) + } + Err(err) => { + let err = AuthError::from(err); + if store::is_transient_auth_error(&err) { + tracing::warn!("keeping existing token after transient renewal failure: {err}"); + Ok(existing) + } else { + Err(err) + } + } + } + } + + #[tracing::instrument(level = "debug", skip(self), fields(%id))] + pub async fn refresh_account(&self, id: Uuid) -> AuthResult { + self.renew_token(id, true).await + } + + #[tracing::instrument(level = "debug", skip_all)] + pub async fn refresh_all_accounts(&self) -> AuthResult> { + let ids: Vec = self.store.lock().await.users.keys().copied().collect(); + let mut refreshed = Vec::with_capacity(ids.len()); + + for id in ids { + refreshed.push(self.renew_token(id, true).await?); + } + + Ok(refreshed) + } + + /// An account with a token good enough to launch with, renewing if needed. + #[tracing::instrument(level = "debug", skip(self), fields(%id))] + pub async fn account_for_launch(&self, id: Uuid) -> AuthResult { + let account = self.renew_token(id, false).await?; + + if account.is_offline() && !self.has_microsoft_account().await { + return Err(AuthError::OfflineRequiresMicrosoft); + } + + Ok(account) + } + + #[tracing::instrument(level = "debug", skip_all)] + pub async fn default_account_for_launch(&self) -> AuthResult> { + let Some(id) = self.store.lock().await.resolve_default_id().await? else { + return Ok(None); + }; + Ok(Some(self.account_for_launch(id).await?)) + } +} diff --git a/packages/oneclient_core/src/auth/store.rs b/packages/oneclient_auth/src/store.rs similarity index 69% rename from packages/oneclient_core/src/auth/store.rs rename to packages/oneclient_auth/src/store.rs index 91aa885c..74775de2 100644 --- a/packages/oneclient_core/src/auth/store.rs +++ b/packages/oneclient_auth/src/store.rs @@ -2,12 +2,12 @@ use std::collections::HashMap; use uuid::Uuid; -use super::error::AuthError; -use super::offline::{offline_account, validate_offline_username}; -use super::data::{AccountKind, MinecraftAccount}; -use crate::paths; -use crate::state::LauncherServices; -use crate::LauncherResult; +use oneclient_common::paths; +use oneclient_events::EventBus; + +use crate::data::{AccountKind, MinecraftAccount}; +use crate::error::{AuthError, AuthResult}; +use crate::offline::{offline_account, validate_offline_username}; #[derive(Debug, serde::Serialize, serde::Deserialize, Default)] pub struct CredentialsStore { @@ -17,7 +17,7 @@ pub struct CredentialsStore { impl CredentialsStore { #[tracing::instrument(level = "debug")] - pub async fn load() -> LauncherResult { + pub async fn load() -> AuthResult { let path = paths::auth_file()?; if !path.exists() { return Ok(Self::default()); @@ -33,14 +33,10 @@ impl CredentialsStore { } #[tracing::instrument(level = "debug", skip_all)] - pub async fn save(&self) -> LauncherResult<()> { + pub async fn save(&self) -> AuthResult<()> { let path = paths::auth_file()?; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - - polyio::write_json(&path, self).await?; + polyio::write_json_atomic(&path, self).await?; Ok(()) } @@ -62,8 +58,8 @@ impl CredentialsStore { pub async fn commit_account( &mut self, account: MinecraftAccount, - services: &LauncherServices, - ) -> LauncherResult { + events: &EventBus, + ) -> AuthResult { self.users.insert(account.id, account.clone()); if self.default_user.is_none() { @@ -72,13 +68,14 @@ impl CredentialsStore { self.save().await?; tracing::debug!("committed account to credentials store"); - services - .notifier - .send_info("Account added", &format!("Signed in as {}", account.username)); + events + .notify("Account added") + .body(format!("Signed in as {}", account.username)) + .send(); Ok(account) } - pub fn add_offline_account(&mut self, username: String) -> LauncherResult { + pub fn add_offline_account(&mut self, username: String) -> AuthResult { let account = self.insert_offline_account(username)?; Ok(account) } @@ -87,15 +84,15 @@ impl CredentialsStore { pub async fn add_offline_account_and_save( &mut self, username: String, - ) -> LauncherResult { + ) -> AuthResult { let account = self.insert_offline_account(username)?; self.save().await?; Ok(account) } - fn insert_offline_account(&mut self, username: String) -> LauncherResult { + fn insert_offline_account(&mut self, username: String) -> AuthResult { if !self.has_microsoft_account() { - return Err(AuthError::OfflineRequiresMicrosoft.into()); + return Err(AuthError::OfflineRequiresMicrosoft); } validate_offline_username(&username)?; @@ -105,7 +102,7 @@ impl CredentialsStore { .values() .any(|u| u.username.eq_ignore_ascii_case(&username)) { - return Err(AuthError::DuplicateUsername { username }.into()); + return Err(AuthError::DuplicateUsername { username }); } let account = offline_account(username); @@ -117,7 +114,7 @@ impl CredentialsStore { pub async fn commit_refreshed_account( &mut self, account: MinecraftAccount, - ) -> LauncherResult<()> { + ) -> AuthResult<()> { self.users.insert(account.id, account); self.save().await?; tracing::debug!("stored refreshed Microsoft account"); @@ -125,7 +122,7 @@ impl CredentialsStore { } #[tracing::instrument(level = "debug", skip(self), fields(%id))] - pub async fn remove_account(&mut self, id: Uuid) -> LauncherResult> { + pub async fn remove_account(&mut self, id: Uuid) -> AuthResult> { let removed = self.users.remove(&id); if self.default_user == Some(id) { @@ -137,11 +134,11 @@ impl CredentialsStore { } #[tracing::instrument(level = "debug", skip(self), fields(?id))] - pub async fn set_default_user(&mut self, id: Option) -> LauncherResult<()> { + pub async fn set_default_user(&mut self, id: Option) -> AuthResult<()> { if let Some(id) = id && !self.users.contains_key(&id) { - return Err(AuthError::AccountNotFound(id).into()); + return Err(AuthError::AccountNotFound(id)); } self.default_user = id; @@ -150,7 +147,7 @@ impl CredentialsStore { } #[tracing::instrument(level = "debug", skip_all)] - pub async fn resolve_default_id(&mut self) -> LauncherResult> { + pub async fn resolve_default_id(&mut self) -> AuthResult> { let id = self .default_user .or_else(|| self.users.keys().copied().next()); @@ -172,9 +169,9 @@ impl CredentialsStore { } /// The current account as stored. Never touches the network: callers that - /// need a usable token go through [`crate::auth::account_for_launch`]. + /// need a usable token go through [`crate::AuthService::account_for_launch`]. #[tracing::instrument(level = "debug", skip_all)] - pub async fn default_account(&mut self) -> LauncherResult> { + pub async fn default_account(&mut self) -> AuthResult> { let Some(id) = self.resolve_default_id().await? else { return Ok(None); }; @@ -183,18 +180,21 @@ impl CredentialsStore { }; if account.is_offline() && !self.has_microsoft_account() { - return Err(AuthError::OfflineRequiresMicrosoft.into()); + return Err(AuthError::OfflineRequiresMicrosoft); } Ok(Some(account)) } } -pub(super) fn is_transient_auth_error(err: &crate::LauncherError) -> bool { - match err { - crate::LauncherError::AuthError(super::error::AuthError::Minecraft( - super::error::MinecraftAuthError::RequestError { source, .. }, - )) => source.is_connect() || source.is_timeout(), - _ => false, - } +/// Whether a renewal failure was the network rather than a rejected token. +/// +/// A transient failure must keep the existing (possibly stale) token: throwing +/// it away because Wi-Fi dropped would sign the user out of a working account. +pub(crate) fn is_transient_auth_error(err: &AuthError) -> bool { + matches!( + err, + AuthError::Minecraft(crate::error::MinecraftAuthError::RequestError { source, .. }) + if source.is_connect() || source.is_timeout() + ) } diff --git a/packages/oneclient_core/tests/auth_offline.rs b/packages/oneclient_auth/tests/offline.rs similarity index 92% rename from packages/oneclient_core/tests/auth_offline.rs rename to packages/oneclient_auth/tests/offline.rs index 844e4ee0..191c469b 100644 --- a/packages/oneclient_core/tests/auth_offline.rs +++ b/packages/oneclient_auth/tests/offline.rs @@ -1,17 +1,15 @@ use chrono::{TimeDelta, Utc}; -use oneclient_core::auth::{ - offline_account, offline_uuid, AccountKind, CredentialsStore, -}; +use oneclient_auth::{AccountKind, CredentialsStore, offline_account, offline_uuid}; use uuid::Uuid; // fake launcher dir because yes fn isolate_launcher_dir() { - oneclient_core::paths::set_launcher_dir( + oneclient_common::paths::set_launcher_dir( std::env::temp_dir().join(format!("oneclient-auth-test-{}", Uuid::new_v4())), ); } -fn fake_microsoft_account(username: &str) -> oneclient_core::auth::MinecraftAccount { +fn fake_microsoft_account(username: &str) -> oneclient_auth::MinecraftAccount { let mut account = offline_account(username.to_string()); account.kind = AccountKind::Microsoft; account.access_token = "access".into(); diff --git a/packages/oneclient_cluster/Cargo.toml b/packages/oneclient_cluster/Cargo.toml new file mode 100644 index 00000000..4fc65951 --- /dev/null +++ b/packages/oneclient_cluster/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "oneclient_cluster" +description = "Cluster records, their settings profiles, and their on-disk files" +version.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true +documentation.workspace = true +readme.workspace = true +homepage.workspace = true +authors.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +oneclient_common.workspace = true +oneclient_db.workspace = true +oneclient_net.workspace = true +polyio.workspace = true + +tokio.workspace = true + +bytes.workspace = true +chrono.workspace = true +image.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_repr = { version = "=0.1.20" } +strum.workspace = true +serde_json.workspace = true +sqlx.workspace = true +thiserror.workspace = true +tracing.workspace = true +trash.workspace = true diff --git a/packages/oneclient_core/src/clusters/cluster.rs b/packages/oneclient_cluster/src/cluster.rs similarity index 88% rename from packages/oneclient_core/src/clusters/cluster.rs rename to packages/oneclient_cluster/src/cluster.rs index 39b07e13..256632a3 100644 --- a/packages/oneclient_core/src/clusters/cluster.rs +++ b/packages/oneclient_cluster/src/cluster.rs @@ -6,12 +6,12 @@ use oneclient_db::models::ClusterId; use oneclient_db::models::ClusterRow; use serde::{Deserialize, Serialize}; -use crate::packages::domain::GameLoader; -use crate::paths; -use crate::LauncherResult; +use oneclient_common::domain::GameLoader; +use oneclient_common::paths; +use crate::error::ClusterResult; -use super::error::ClusterError; -use super::stage::ClusterStage; +use crate::error::ClusterError; +use crate::stage::ClusterStage; pub const DEDICATED_MARKER: &str = ".dedicated_directory"; @@ -74,11 +74,11 @@ impl Cluster { }) } - pub fn dir(&self) -> LauncherResult { + pub fn dir(&self) -> ClusterResult { Ok(paths::clusters_dir()?.join(&self.folder_name)) } - pub fn dedicated_marker(&self) -> LauncherResult { + pub fn dedicated_marker(&self) -> ClusterResult { Ok(self.dir()?.join(DEDICATED_MARKER)) } @@ -86,11 +86,11 @@ impl Cluster { self.dedicated_marker().map(|p| p.exists()).unwrap_or(false) } - pub fn game_dir(&self) -> LauncherResult { + pub fn game_dir(&self) -> ClusterResult { if self.uses_dedicated_dir() { self.dir() } else { - paths::shared_minecraft_dir() + Ok(paths::shared_minecraft_dir()?) } } diff --git a/packages/oneclient_cluster/src/error.rs b/packages/oneclient_cluster/src/error.rs new file mode 100644 index 00000000..a4bb87de --- /dev/null +++ b/packages/oneclient_cluster/src/error.rs @@ -0,0 +1,75 @@ +use thiserror::Error; + +use crate::stage::ClusterStage; +use oneclient_common::domain::GameLoader; + +pub type ClusterResult = Result; + +#[derive(Debug, Error)] +pub enum ClusterError { + #[error(transparent)] + Io(#[from] polyio::IOError), + + #[error(transparent)] + StdIo(#[from] std::io::Error), + + #[error(transparent)] + Db(#[from] oneclient_db::DbError), + + #[error("Database execution failed: {0}")] + Sql(#[from] sqlx::Error), + + #[error(transparent)] + Paths(#[from] oneclient_common::PathsError), + + #[error("Unable to parse JSON: {0}")] + Json(#[from] serde_json::Error), + + #[error("invalid settings profile: {reason}")] + InvalidProfile { reason: String }, + + #[error(transparent)] + Logs(#[from] crate::logs::LogsError), + + #[error(transparent)] + Screenshots(#[from] crate::screenshots::ScreenshotsError), + + #[error(transparent)] + Request(#[from] oneclient_net::RequestError), + + #[error("cluster {0} not found")] + NotFound(i64), + + #[error("cluster folder '{0}' not found on disk")] + FolderNotFound(String), + + #[error("cluster is in {0} stage")] + Busy(ClusterStage), + + #[error("invalid Minecraft version '{0}'")] + InvalidVersion(String), + + #[error("cluster is missing a required Java version")] + MissingJavaVersion, + + #[error("loader {0} is not compatible with {1}")] + MismatchedLoader(GameLoader, GameLoader), + + #[error("setting profile '{0}' not found")] + ProfileNotFound(String), + + #[error("cluster has no settings profile assigned")] + NoProfile, + + #[error("cluster {0} is already running")] + AlreadyRunning(i64), + + #[error("cluster name is empty after sanitization")] + EmptyName, + + #[error("unknown loader id {0} in database")] + InvalidLoader(i64), + + #[error("unknown stage id {0} in database")] + InvalidStage(i64), +} diff --git a/packages/oneclient_cluster/src/lib.rs b/packages/oneclient_cluster/src/lib.rs new file mode 100644 index 00000000..5dfbf5e5 --- /dev/null +++ b/packages/oneclient_cluster/src/lib.rs @@ -0,0 +1,30 @@ +//! Cluster records and everything that belongs to one: the settings profile it +//! launches with, and the logs and screenshots it writes. +//! +//! Not the whole of cluster management, though. Preparing a cluster for launch, +//! provisioning from the remote catalog and applying version migrations all need +//! Java, Minecraft metadata, the package store and the versions manifest; they +//! compose this crate rather than living in it, and stay in `oneclient_core`. +//! +//! It carries no event bus: nothing here reports progress or asks questions. It +//! does take a network client, for the one thing that needs it: uploading a log +//! to mclo.gs. + +mod cluster; +mod error; +mod manager; +mod options; +mod profile; +mod stage; + +pub mod logs; +pub mod profiles; +pub mod screenshots; + +pub use cluster::{Cluster, ClusterLinkTarget}; +pub use error::{ClusterError, ClusterResult}; +pub use manager::ClusterManager; +pub use options::{ClusterUpdate, CreateClusterOptions}; +pub use profile::{GameSettingsProfile, SettingsOsExtra}; +pub use profiles::ProfileUpdate; +pub use stage::ClusterStage; diff --git a/packages/oneclient_core/src/logs/error.rs b/packages/oneclient_cluster/src/logs/error.rs similarity index 87% rename from packages/oneclient_core/src/logs/error.rs rename to packages/oneclient_cluster/src/logs/error.rs index 7541f339..838b9a53 100644 --- a/packages/oneclient_core/src/logs/error.rs +++ b/packages/oneclient_cluster/src/logs/error.rs @@ -12,7 +12,7 @@ pub enum LogsError { TooLarge, #[error(transparent)] - Request(#[from] crate::http::RequestError), + Request(#[from] oneclient_net::RequestError), #[error("mclo.gs upload failed: {0}")] Upload(String), diff --git a/packages/oneclient_core/src/logs/manage.rs b/packages/oneclient_cluster/src/logs/manage.rs similarity index 80% rename from packages/oneclient_core/src/logs/manage.rs rename to packages/oneclient_cluster/src/logs/manage.rs index 2b41edf7..9648f25f 100644 --- a/packages/oneclient_core/src/logs/manage.rs +++ b/packages/oneclient_cluster/src/logs/manage.rs @@ -2,25 +2,16 @@ use std::path::{Path, PathBuf}; use chrono::{DateTime, Utc}; -use crate::LauncherResult; -use crate::clusters::Cluster; -use crate::paths; +use crate::error::ClusterResult; +use crate::cluster::Cluster; +use oneclient_common::paths; use super::parse::parse_level; use super::{LogFileInfo, LogKind, LogLevel, LogLine, LogsError, ReadOptions}; -pub(super) fn ensure_allowed(path: &Path) -> LauncherResult { - let canon = std::fs::canonicalize(path).map_err(LogsError::Io)?; - - for root in [paths::logs_dir()?, paths::clusters_dir()?] { - if let Ok(root) = std::fs::canonicalize(&root) - && canon.starts_with(&root) - { - return Ok(canon); - } - } - - Err(LogsError::InvalidName(path.display().to_string()).into()) +pub(super) fn ensure_allowed(path: &Path) -> ClusterResult { + polyio::ensure_under(path, [paths::logs_dir()?, paths::clusters_dir()?])? + .ok_or_else(|| LogsError::InvalidName(path.display().to_string()).into()) } fn push_file(path: PathBuf, name: String, kind: LogKind, out: &mut Vec) { @@ -66,7 +57,7 @@ fn collect_dir( } #[tracing::instrument(level = "debug", skip(cluster), fields(cluster_id = cluster.id))] -pub fn list_cluster_logs(cluster: &Cluster) -> LauncherResult> { +pub fn list_cluster_logs(cluster: &Cluster) -> ClusterResult> { let mut out = Vec::new(); let dir = cluster.dir()?; @@ -107,7 +98,7 @@ pub fn list_cluster_logs(cluster: &Cluster) -> LauncherResult> Ok(out) } -pub fn cluster_output_log(cluster: &Cluster) -> LauncherResult { +pub fn cluster_output_log(cluster: &Cluster) -> ClusterResult { Ok(cluster.dir()?.join("cluster-output.log")) } @@ -145,7 +136,7 @@ pub(super) fn lines_from(content: &str, opts: &ReadOptions) -> Vec { lines } -pub(super) async fn read_file_string(path: &Path) -> LauncherResult { +pub(super) async fn read_file_string(path: &Path) -> ClusterResult { let is_gz = path .extension() .and_then(|e| e.to_str()) @@ -159,14 +150,14 @@ pub(super) async fn read_file_string(path: &Path) -> LauncherResult { } #[tracing::instrument(level = "debug", skip(opts))] -pub async fn read_log_at(path: &Path, opts: &ReadOptions) -> LauncherResult> { +pub async fn read_log_at(path: &Path, opts: &ReadOptions) -> ClusterResult> { let path = ensure_allowed(path)?; let content = read_file_string(&path).await?; Ok(lines_from(&content, opts)) } #[tracing::instrument(level = "debug")] -pub async fn delete_log_at(path: &Path) -> LauncherResult<()> { +pub async fn delete_log_at(path: &Path) -> ClusterResult<()> { let path = ensure_allowed(path)?; Ok(polyio::remove_file(&path).await?) diff --git a/packages/oneclient_core/src/logs/mclogs.rs b/packages/oneclient_cluster/src/logs/mclogs.rs similarity index 89% rename from packages/oneclient_core/src/logs/mclogs.rs rename to packages/oneclient_cluster/src/logs/mclogs.rs index 6f3e0b4e..baa51ca1 100644 --- a/packages/oneclient_core/src/logs/mclogs.rs +++ b/packages/oneclient_cluster/src/logs/mclogs.rs @@ -2,8 +2,8 @@ use std::path::Path; use serde::Deserialize; -use crate::http::RequestError; -use crate::{LauncherResult, LauncherServices}; +use oneclient_net::RequestError; +use crate::error::ClusterResult; use super::manage::{ensure_allowed, read_file_string}; use super::{LogsError, MclogsUploadResponse}; @@ -26,11 +26,11 @@ struct MclogsResponse { error: Option, } -#[tracing::instrument(skip(services))] +#[tracing::instrument(skip(net))] pub async fn upload_log_at( - services: &LauncherServices, + net: &oneclient_net::RequestClient, path: &Path, -) -> LauncherResult { +) -> ClusterResult { let path = ensure_allowed(path)?; let mut content = read_file_string(&path).await?; @@ -50,8 +50,7 @@ pub async fn upload_log_at( content = content[cut..].to_string(); } - let response = services - .requester + let response = net .http() .post(MCLOGS_URL) .form(&[("content", content.as_str())]) diff --git a/packages/oneclient_core/src/logs/mod.rs b/packages/oneclient_cluster/src/logs/mod.rs similarity index 100% rename from packages/oneclient_core/src/logs/mod.rs rename to packages/oneclient_cluster/src/logs/mod.rs diff --git a/packages/oneclient_core/src/logs/parse.rs b/packages/oneclient_cluster/src/logs/parse.rs similarity index 100% rename from packages/oneclient_core/src/logs/parse.rs rename to packages/oneclient_cluster/src/logs/parse.rs diff --git a/packages/oneclient_cluster/src/manager.rs b/packages/oneclient_cluster/src/manager.rs new file mode 100644 index 00000000..429ec8a9 --- /dev/null +++ b/packages/oneclient_cluster/src/manager.rs @@ -0,0 +1,370 @@ +use oneclient_db::dao::{cluster as cluster_dao, setting_profile as profile_dao}; +use oneclient_db::models::{ClusterId, ClusterPatch, NewCluster}; + +use oneclient_common::domain::ContentType; +use crate::profiles::{ + create_profile_from_global, resolve_cluster_profile, update_named_profile, +}; +use oneclient_common::patch::Patch; +use crate::profile::GameSettingsProfile; +use crate::profiles::ProfileUpdate; +use crate::error::ClusterResult; + +use oneclient_db::DbPool; +use tokio::sync::Mutex; + +use crate::cluster::Cluster; +use crate::error::ClusterError; +use crate::options::{ClusterUpdate, CreateClusterOptions}; +use crate::stage::ClusterStage; + +/// Cluster records: create, read, update, delete, and the settings profile each +/// one points at. +/// +/// An instance rather than a bag of associated functions, so the creation lock +/// lives with the thing it protects instead of on the launcher state. Matches +/// `BundlesManager`, `VersionsManager`, `JavaService` and `AuthService`. +pub struct ClusterManager { + db: DbPool, + /// Serialises creation so two concurrent creates cannot resolve to the same + /// folder name and race each other onto disk. + provisioning: Mutex<()>, +} + +impl ClusterManager { + #[must_use] + pub fn new(db: DbPool) -> Self { + Self { + db, + provisioning: Mutex::new(()), + } + } + + /// Holds the creation lock for the caller. + /// + /// Startup recovery scans the clusters directory and adopts orphan folders; + /// it must hold this across the whole scan, or a concurrent create (whose + /// folder lands on disk before its row commits) gets adopted into a duplicate + /// row. + pub async fn provisioning_guard(&self) -> tokio::sync::MutexGuard<'_, ()> { + self.provisioning.lock().await + } + + pub fn sanitize_name(name: &str) -> String { + let mut name = name.to_string(); + name.retain(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | ' ' | '.' | '(' | ')')); + name.trim().to_string() + } + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn get(&self, cluster_id: ClusterId) -> ClusterResult { + let row = cluster_dao::get_by_id(&self.db, cluster_id) + .await? + .ok_or(ClusterError::NotFound(cluster_id))?; + Cluster::try_from_row(row) + } + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn list(&self) -> ClusterResult> { + let rows = cluster_dao::list_all(&self.db).await?; + rows.into_iter() + .map(Cluster::try_from_row) + .collect::, _>>()} + + #[tracing::instrument(skip(self))] + pub async fn create( + &self, + global: &GameSettingsProfile, + options: CreateClusterOptions, + ) -> ClusterResult { + let _guard = self.provisioning.lock().await; + self.create_core(global, options).await + } + + /// Create a cluster for a version/loader that doesn't exist yet, and provision it. + #[tracing::instrument(level = "debug", skip(self))] + pub async fn create_provisioned( + &self, + global: &GameSettingsProfile, + options: CreateClusterOptions, + ) -> ClusterResult> { + let _guard = self.provisioning.lock().await; + if cluster_dao::find_by_version_loader( + &self.db, + &options.mc_version, + options.mc_loader as i64, + ) + .await? + .is_some() + { + return Ok(None); + } + self.create_core(global, options).await.map(Some) + } + + /// Folder-resolve + insert core shared by `create` and `create_provisioned`. + /// Callers MUST hold `self.provisioning`; this does not lock. + #[tracing::instrument(level = "debug", skip(self, global))] + async fn create_core( + &self, + global: &GameSettingsProfile, + options: CreateClusterOptions, + ) -> ClusterResult { + let name = Self::sanitize_name(&options.name); + if name.is_empty() { + return Err(ClusterError::EmptyName); + } + + let folder_name = resolve_unique_folder_name(&name).await?; + let cluster_path = oneclient_common::paths::clusters_dir()?.join(&folder_name); + + match create_inner(&self.db, global, &options, &name, &folder_name, &cluster_path).await { + Ok(cluster) => { + tracing::info!(cluster_id = cluster.id, name = %cluster.name, "created cluster"); + Ok(cluster) + } + Err(err) => { + tracing::warn!(name = %name, error = %err, "cluster creation failed, cleaning up directory"); + let _ = polyio::remove_dir_all(&cluster_path).await; + Err(err) + } + } + } + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn update( + &self, + cluster_id: ClusterId, + update: ClusterUpdate, + ) -> ClusterResult { + let _ = self.get(cluster_id).await?; + + if let Patch::Set(ref profile_name) = update.setting_profile_name { + ensure_profile_exists(&self.db, profile_name).await?; + } + + let name = update.name.as_deref().map(Self::sanitize_name); + if name.as_deref().is_some_and(str::is_empty) { + return Err(ClusterError::EmptyName); + } + + let patch = ClusterPatch { + name, + setting_profile_name: update.setting_profile_name.into_db_patch(), + mc_loader_version: update.mc_loader_version.into_db_patch(), + linked_modpack_hash: update.linked_modpack_hash.into_db_patch(), + }; + + let row = cluster_dao::update(&self.db, cluster_id, &patch).await?; + Cluster::try_from_row(row) + } + + #[tracing::instrument(skip(self))] + pub async fn delete( + &self, + cluster_id: ClusterId, + remove_files: bool, + ) -> ClusterResult<()> { + let cluster = self.get(cluster_id).await?; + + if !cluster_dao::delete_by_id(&self.db, cluster_id).await? { + return Err(ClusterError::NotFound(cluster_id)); + } + + if remove_files { + let path = cluster.dir()?; + if path.exists() { + polyio::remove_dir_all(&path).await?; + } + } + + tracing::info!(cluster_id, remove_files, "deleted cluster"); + Ok(()) + } + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn set_stage( + &self, + cluster_id: ClusterId, + stage: ClusterStage, + ) -> ClusterResult { + let row = cluster_dao::set_stage(&self.db, cluster_id, stage as i64).await?; + Cluster::try_from_row(row) + } + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn uses_dedicated_dir(&self, cluster_id: ClusterId) -> ClusterResult { + Ok(self.get(cluster_id).await?.uses_dedicated_dir()) + } + + #[tracing::instrument(level = "debug", skip(self))] + /// `is_running` is passed in rather than looked up: whether a game process + /// is alive belongs to the game lifecycle, not to cluster records. + pub async fn set_dedicated_dir( + &self, + cluster_id: ClusterId, + dedicated: bool, + is_running: bool, + ) -> ClusterResult<()> { + if is_running { + return Err(ClusterError::AlreadyRunning(cluster_id)); + } + + let cluster = self.get(cluster_id).await?; + let marker = cluster.dedicated_marker()?; + if dedicated { + polyio::create_dir_all(cluster.dir()?).await?; + polyio::write(&marker, b"").await.ok(); + } else if marker.exists() { + polyio::remove_file(&marker).await.ok(); + } + Ok(()) + } + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn add_playtime( + &self, + cluster_id: ClusterId, + duration: std::time::Duration, + ) -> ClusterResult { + let seconds = i64::try_from(duration.as_secs()).unwrap_or(i64::MAX); + let row = cluster_dao::add_playtime(&self.db, cluster_id, seconds).await?; + + Cluster::try_from_row(row) + } + + #[tracing::instrument(level = "debug", skip(self, global, cluster), fields(cluster_id = cluster.id))] + pub async fn resolve_settings( + &self, + global: &GameSettingsProfile, + cluster: &Cluster, + ) -> ClusterResult { + resolve_cluster_profile( + &self.db, + global, + cluster.setting_profile_name.as_deref(), + ) + .await + } + + #[tracing::instrument(level = "debug", skip(self, update))] + pub async fn update_profile( + &self, + cluster_id: ClusterId, + update: ProfileUpdate, + ) -> ClusterResult { + let cluster = self.get(cluster_id).await?; + let profile_name = cluster + .setting_profile_name + .ok_or(ClusterError::NoProfile)?; + + update_named_profile(&self.db, &profile_name, update).await + } + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn create_and_assign_profile( + &self, + global: &GameSettingsProfile, + cluster_id: ClusterId, + profile_name: &str, + ) -> ClusterResult { + let profile = create_profile_from_global( + &self.db, + global, + profile_name, + None, + None, + ) + .await?; + + self.update( + cluster_id, + ClusterUpdate::default().setting_profile(&profile.name), + ) + .await?; + + Ok(profile) + } +} + +#[tracing::instrument(level = "debug", skip(db, global, options))] +async fn create_inner( + db: &DbPool, + global: &GameSettingsProfile, + options: &CreateClusterOptions, + name: &str, + folder_name: &str, + cluster_path: &std::path::Path, +) -> ClusterResult { + polyio::create_dir_all(cluster_path).await?; + ensure_content_dirs(cluster_path).await?; + + let profile = create_profile_from_global( + db, + global, + name, + options.mem_max, + None, + ) + .await?; + + let row = cluster_dao::insert( + db, + &NewCluster { + name, + folder_name, + mc_version: &options.mc_version, + mc_loader: options.mc_loader as i64, + mc_loader_version: options.mc_loader_version.as_deref(), + setting_profile_name: Some(&profile.name), + stage: ClusterStage::NotReady as i64, + }, + ) + .await?; + + Cluster::try_from_row(row) +} + +#[tracing::instrument(level = "debug", skip(pool))] +async fn ensure_profile_exists(pool: &oneclient_db::DbPool, name: &str) -> ClusterResult<()> { + if profile_dao::get_by_name(pool, name).await?.is_none() { + return Err(ClusterError::ProfileNotFound(name.to_string())); + } + Ok(()) +} + +#[tracing::instrument(level = "debug")] +async fn resolve_unique_folder_name(name: &str) -> ClusterResult { + let cluster_dir = oneclient_common::paths::clusters_dir()?; + let mut folder_name = name.to_string(); + let mut path = cluster_dir.join(&folder_name); + + if path.exists() { + let mut which = 1; + loop { + let candidate = format!("{folder_name} ({which})"); + path = cluster_dir.join(&candidate); + if !path.exists() { + folder_name = candidate; + break; + } + which += 1; + } + } + + Ok(folder_name) +} + +#[tracing::instrument(level = "debug")] +async fn ensure_content_dirs(cluster_path: &std::path::Path) -> ClusterResult<()> { + for content_type in [ + ContentType::Mod, + ContentType::ResourcePack, + ContentType::Shader, + ContentType::DataPack, + ContentType::World, + ] { + polyio::create_dir_all(cluster_path.join(content_type.folder_name())).await?; + } + Ok(()) +} diff --git a/packages/oneclient_core/src/clusters/options.rs b/packages/oneclient_cluster/src/options.rs similarity index 94% rename from packages/oneclient_core/src/clusters/options.rs rename to packages/oneclient_cluster/src/options.rs index 9565a31f..f7bfd697 100644 --- a/packages/oneclient_core/src/clusters/options.rs +++ b/packages/oneclient_cluster/src/options.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; -use crate::packages::domain::GameLoader; -use crate::patch::Patch; +use oneclient_common::domain::GameLoader; +use oneclient_common::patch::Patch; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateClusterOptions { diff --git a/packages/oneclient_core/src/settings/profile.rs b/packages/oneclient_cluster/src/profile.rs similarity index 88% rename from packages/oneclient_core/src/settings/profile.rs rename to packages/oneclient_cluster/src/profile.rs index 9a50bddc..ecaa5fa8 100644 --- a/packages/oneclient_core/src/settings/profile.rs +++ b/packages/oneclient_cluster/src/profile.rs @@ -19,23 +19,9 @@ pub struct GameSettingsProfile { pub os_extra: Option, } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -pub struct Resolution { - pub width: u32, - pub height: u32, -} - -impl Default for Resolution { - fn default() -> Self { - Self::new(854, 480) - } -} - -impl Resolution { - pub const fn new(width: u32, height: u32) -> Self { - Self { width, height } - } -} +// `Resolution` moved to oneclient_common: the launch-argument builder needs it +// too, and that crate cannot depend on the launcher settings. +pub use oneclient_common::Resolution; cfg_select! { target_os = "windows" => { @@ -116,7 +102,7 @@ impl GameSettingsProfile { } } - pub fn from_row(row: SettingProfileRow) -> crate::LauncherResult { + pub fn from_row(row: SettingProfileRow) -> crate::ClusterResult { Ok(Self { name: row.name, java_path: row.java_path, @@ -138,7 +124,7 @@ impl GameSettingsProfile { }) } - pub fn into_row(&self) -> crate::LauncherResult { + pub fn into_row(&self) -> crate::ClusterResult { Ok(SettingProfileRow { name: self.name.clone(), java_path: self.java_path.clone(), diff --git a/packages/oneclient_cluster/src/profiles.rs b/packages/oneclient_cluster/src/profiles.rs new file mode 100644 index 00000000..4e6cbdd2 --- /dev/null +++ b/packages/oneclient_cluster/src/profiles.rs @@ -0,0 +1,174 @@ +//! Named settings profiles. +//! +//! A profile is a sparse override on the launcher's global baseline: every +//! `Option` field left `None` inherits the global value, which is why +//! [`ProfileUpdate`] carries `Patch::Clear`. Clearing a field means "go back to +//! inheriting" rather than "set to nothing". +//! +//! These take the global baseline as a `&GameSettingsProfile` parameter rather +//! than reading the launcher settings, so this crate does not depend on the +//! launcher config that embeds it. + +use oneclient_common::patch::Patch; +use oneclient_db::DbPool; + +use crate::error::{ClusterError, ClusterResult}; +use crate::profile::{GameSettingsProfile, SettingsOsExtra}; +use oneclient_common::Resolution; + +#[tracing::instrument(level = "debug", skip(pool, global))] +pub async fn get_profile_or_default( + pool: &DbPool, + global: &GameSettingsProfile, + name: Option<&str>, +) -> ClusterResult { + if let Some(name) = name + && let Some(row) = oneclient_db::dao::setting_profile::get_by_name(pool, name).await? + { + return GameSettingsProfile::from_row(row); + } + + Ok(global.clone()) +} + +#[tracing::instrument(level = "debug", skip(pool, global))] +/// Resolves the profile a cluster should launch with. +/// +/// A named profile is a sparse override on the global baseline: any field +/// left `None` inherits the global value. Falling back to the baseline whole +/// when the name is missing or unknown keeps a deleted profile from bricking a +/// cluster. +pub async fn resolve_cluster_profile( + pool: &DbPool, + global: &GameSettingsProfile, + profile_name: Option<&str>, +) -> ClusterResult { + let Some(name) = profile_name else { + return Ok(global.clone()); + }; + + let Some(row) = oneclient_db::dao::setting_profile::get_by_name(pool, name).await? else { + return Ok(global.clone()); + }; + + let mut profile = GameSettingsProfile::from_row(row)?; + if !profile.is_global() { + profile.merge_global(global); + } + + Ok(profile) +} + +#[tracing::instrument(level = "debug", skip_all)] +pub async fn upsert_named_profile( + pool: &DbPool, + profile: &GameSettingsProfile, +) -> ClusterResult { + if profile.is_global() { + return Err(ClusterError::InvalidProfile { + reason: "the Global profile is stored in settings.json".into(), + }); + } + + let row = profile.into_row()?; + let saved = oneclient_db::dao::setting_profile::upsert(pool, &row).await?; + GameSettingsProfile::from_row(saved) +} + +#[tracing::instrument(level = "debug", skip(pool, global))] +pub async fn create_profile_from_global( + pool: &DbPool, + global: &GameSettingsProfile, + name: &str, + mem_max: Option, + force_fullscreen: Option, +) -> ClusterResult { + let mut profile = global.clone(); + + profile.name = name.to_string(); + if let Some(mem) = mem_max { + profile.mem_max = Some(mem); + } + + if let Some(fullscreen) = force_fullscreen { + profile.force_fullscreen = Some(fullscreen); + } + + upsert_named_profile(pool, &profile).await +} + +#[tracing::instrument(level = "debug", skip(pool, global))] +pub async fn create_settings_profile( + pool: &DbPool, + global: &GameSettingsProfile, + name: &str, +) -> ClusterResult { + create_profile_from_global(pool, global, name, None, None).await +} + +#[tracing::instrument(level = "debug", skip(pool))] +pub async fn list_named_profiles(pool: &DbPool) -> ClusterResult> { + let rows = oneclient_db::dao::setting_profile::list_all(pool).await?; + rows.into_iter() + .map(GameSettingsProfile::from_row) + .collect() +} + +#[tracing::instrument(level = "debug", skip(pool))] +pub async fn delete_named_profile(pool: &DbPool, name: &str) -> ClusterResult<()> { + oneclient_db::dao::setting_profile::delete_by_name(pool, name).await?; + Ok(()) +} + +#[derive(Debug, Clone, Default)] +pub struct ProfileUpdate { + pub java_path: Patch, + pub resolution: Patch, + pub force_fullscreen: Patch, + pub mem_max: Patch, + pub launch_args: Patch, + pub launch_env: Patch, + pub hook_pre: Patch, + pub hook_wrapper: Patch, + pub hook_post: Patch, + pub os_extra: Patch, +} + +impl ProfileUpdate { + pub fn apply(&self, profile: &mut GameSettingsProfile) { + self.java_path.apply_to_option(&mut profile.java_path); + self.resolution.apply_to_option(&mut profile.resolution); + self.force_fullscreen + .apply_to_option(&mut profile.force_fullscreen); + self.mem_max.apply_to_option(&mut profile.mem_max); + self.launch_args + .apply_to_command_option(&mut profile.launch_args); + self.launch_env + .apply_to_command_option(&mut profile.launch_env); + self.hook_pre.apply_to_command_option(&mut profile.hook_pre); + self.hook_wrapper + .apply_to_command_option(&mut profile.hook_wrapper); + self.hook_post + .apply_to_command_option(&mut profile.hook_post); + self.os_extra.apply_to_option(&mut profile.os_extra); + } +} + +#[tracing::instrument(level = "debug", skip(pool, update))] +pub async fn update_named_profile( + pool: &DbPool, + name: &str, + update: ProfileUpdate, +) -> ClusterResult { + let Some(row) = oneclient_db::dao::setting_profile::get_by_name(pool, name).await? else { + return Err(ClusterError::InvalidProfile { + reason: format!("profile '{name}' not found"), + }); + }; + + let mut profile = GameSettingsProfile::from_row(row)?; + update.apply(&mut profile); + + upsert_named_profile(pool, &profile).await +} + diff --git a/packages/oneclient_core/src/screenshots/mod.rs b/packages/oneclient_cluster/src/screenshots/mod.rs similarity index 79% rename from packages/oneclient_core/src/screenshots/mod.rs rename to packages/oneclient_cluster/src/screenshots/mod.rs index db2b08f6..33ca08be 100644 --- a/packages/oneclient_core/src/screenshots/mod.rs +++ b/packages/oneclient_cluster/src/screenshots/mod.rs @@ -4,9 +4,9 @@ use bytes::Bytes; use chrono::{DateTime, Utc}; use thiserror::Error; -use crate::clusters::Cluster; -use crate::paths; -use crate::LauncherResult; +use crate::cluster::Cluster; +use oneclient_common::paths; +use crate::error::ClusterResult; #[derive(Debug, Error)] pub enum ScreenshotsError { @@ -32,20 +32,13 @@ fn is_image(name: &str) -> bool { lower.ends_with(".png") || lower.ends_with(".jpg") || lower.ends_with(".jpeg") } -fn ensure_in_clusters(path: &Path) -> LauncherResult { - let canon = std::fs::canonicalize(path).map_err(ScreenshotsError::Io)?; - for root in [paths::clusters_dir()?, paths::shared_minecraft_dir()?] { - if let Ok(root) = std::fs::canonicalize(root) - && canon.starts_with(&root) - { - return Ok(canon); - } - } - Err(ScreenshotsError::InvalidPath(path.display().to_string()).into()) +fn ensure_in_clusters(path: &Path) -> ClusterResult { + polyio::ensure_under(path, [paths::clusters_dir()?, paths::shared_minecraft_dir()?])? + .ok_or_else(|| ScreenshotsError::InvalidPath(path.display().to_string()).into()) } #[tracing::instrument(level = "debug", skip(cluster), fields(cluster_id = cluster.id))] -pub fn list_cluster_screenshots(cluster: &Cluster) -> LauncherResult> { +pub fn list_cluster_screenshots(cluster: &Cluster) -> ClusterResult> { let dir = cluster.game_dir()?.join("screenshots"); let mut out = Vec::new(); @@ -93,7 +86,7 @@ pub fn list_cluster_screenshots(cluster: &Cluster) -> LauncherResult) -> LauncherResult { +pub fn load_screenshot(path: &Path, max_edge: Option) -> ClusterResult { let path = ensure_in_clusters(path)?; let raw = std::fs::read(&path).map_err(ScreenshotsError::Io)?; let bytes = match max_edge { @@ -117,7 +110,7 @@ fn thumbnail(raw: &[u8], max_edge: u32) -> Option { } #[tracing::instrument(level = "debug")] -pub fn delete_screenshot(path: &Path) -> LauncherResult<()> { +pub fn delete_screenshot(path: &Path) -> ClusterResult<()> { let path = ensure_in_clusters(path)?; // TODO: maybe async? diff --git a/packages/oneclient_core/src/clusters/stage.rs b/packages/oneclient_cluster/src/stage.rs similarity index 100% rename from packages/oneclient_core/src/clusters/stage.rs rename to packages/oneclient_cluster/src/stage.rs diff --git a/packages/oneclient_common/Cargo.toml b/packages/oneclient_common/Cargo.toml new file mode 100644 index 00000000..7ba3aa7d --- /dev/null +++ b/packages/oneclient_common/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "oneclient_common" +description = "Shared vocabulary for OneClient: paths, constants, platform and content types" +version.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true +documentation.workspace = true +readme.workspace = true +homepage.workspace = true +authors.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +# Minecraft/modded metadata vocabulary (Os, loader format versions) +interfrost.workspace = true + +directories.workspace = true +serde.workspace = true +strum.workspace = true +thiserror.workspace = true diff --git a/packages/oneclient_core/src/constants.rs b/packages/oneclient_common/src/constants.rs similarity index 100% rename from packages/oneclient_core/src/constants.rs rename to packages/oneclient_common/src/constants.rs diff --git a/packages/oneclient_core/src/packages/domain.rs b/packages/oneclient_common/src/domain.rs similarity index 91% rename from packages/oneclient_core/src/packages/domain.rs rename to packages/oneclient_common/src/domain.rs index 89cf6d3f..dcef8f39 100644 --- a/packages/oneclient_core/src/packages/domain.rs +++ b/packages/oneclient_common/src/domain.rs @@ -219,3 +219,27 @@ impl Display for GameLoader { }) } } + +/// A game window size. +/// +/// Lives here rather than with the settings because both sides need it: the +/// settings profile stores it, and the launch-argument builder turns it into +/// `--width`/`--height`. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub struct Resolution { + pub width: u32, + pub height: u32, +} + +impl Default for Resolution { + fn default() -> Self { + Self::new(854, 480) + } +} + +impl Resolution { + #[must_use] + pub const fn new(width: u32, height: u32) -> Self { + Self { width, height } + } +} diff --git a/packages/oneclient_common/src/error.rs b/packages/oneclient_common/src/error.rs new file mode 100644 index 00000000..5b5fd1be --- /dev/null +++ b/packages/oneclient_common/src/error.rs @@ -0,0 +1,11 @@ +pub type PathsResult = Result; + +/// The only way path resolution can fail: the platform did not give us a data +/// directory to work in. It stays narrow because callers that hit this cannot +/// do anything except surface it, and keeping it separate from the +/// launcher-wide error keeps this crate a leaf. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum PathsError { + #[error("Could not resolve launcher data directory")] + DataDirUnavailable, +} diff --git a/packages/oneclient_common/src/lib.rs b/packages/oneclient_common/src/lib.rs new file mode 100644 index 00000000..65924480 --- /dev/null +++ b/packages/oneclient_common/src/lib.rs @@ -0,0 +1,22 @@ +//! The vocabulary every other OneClient crate is allowed to depend on. +//! +//! This crate is a leaf: it knows about the filesystem layout, the target +//! platform, Minecraft version strings and the content-type taxonomy, and +//! nothing else. It must never gain a dependency on the database, the network +//! stack, or any launcher subsystem. Everything above it depends on it, so a +//! back-edge here would recreate the monolith the split is undoing. + +pub mod constants; +pub mod domain; +pub mod os_ext; +pub mod paths; +pub mod patch; +pub mod version; + +mod error; + +pub use domain::{ContentType, GameLoader, HashAlgorithm, ProviderId, Resolution}; +pub use error::{PathsError, PathsResult}; +pub use os_ext::OsExt; +pub use patch::Patch; +pub use version::{ParsedMcVersion, VersionKey, format_mc_version, parse_mc_version}; diff --git a/packages/oneclient_core/src/os_ext.rs b/packages/oneclient_common/src/os_ext.rs similarity index 100% rename from packages/oneclient_core/src/os_ext.rs rename to packages/oneclient_common/src/os_ext.rs diff --git a/packages/oneclient_core/src/patch.rs b/packages/oneclient_common/src/patch.rs similarity index 100% rename from packages/oneclient_core/src/patch.rs rename to packages/oneclient_common/src/patch.rs diff --git a/packages/oneclient_core/src/paths.rs b/packages/oneclient_common/src/paths.rs similarity index 58% rename from packages/oneclient_core/src/paths.rs rename to packages/oneclient_common/src/paths.rs index f40ba41e..410d1e83 100644 --- a/packages/oneclient_core/src/paths.rs +++ b/packages/oneclient_common/src/paths.rs @@ -2,8 +2,8 @@ use directories::ProjectDirs; use std::path::{Path, PathBuf}; use std::sync::OnceLock; -use crate::error::LauncherError; -use crate::LauncherResult; +use crate::domain::{ContentType, ProviderId}; +use crate::error::{PathsError, PathsResult}; const QUALIFIER: &str = "org"; const ORGANIZATION: &str = "Polyfrost"; @@ -22,19 +22,19 @@ pub fn set_launcher_dir(dir: PathBuf) { let _ = LAUNCHER_DIR_OVERRIDE.set(dir); } -fn project_dirs() -> LauncherResult<&'static ProjectDirs> { +fn project_dirs() -> PathsResult<&'static ProjectDirs> { if let Some(dirs) = PROJECT_DIRS.get() { return Ok(dirs); } let dirs = ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION) - .ok_or(LauncherError::DataDirUnavailable)?; + .ok_or(PathsError::DataDirUnavailable)?; let _ = PROJECT_DIRS.set(dirs); - PROJECT_DIRS.get().ok_or(LauncherError::DataDirUnavailable) + PROJECT_DIRS.get().ok_or(PathsError::DataDirUnavailable) } -pub fn launcher_dir() -> LauncherResult<&'static Path> { +pub fn launcher_dir() -> PathsResult<&'static Path> { if let Some(dir) = LAUNCHER_DIR_OVERRIDE.get() { return Ok(dir); } @@ -42,88 +42,88 @@ pub fn launcher_dir() -> LauncherResult<&'static Path> { Ok(project_dirs()?.data_local_dir()) } -pub fn database_file() -> LauncherResult { +pub fn database_file() -> PathsResult { Ok(launcher_dir()?.join("user_data.db")) } -pub fn settings_file() -> LauncherResult { +pub fn settings_file() -> PathsResult { Ok(launcher_dir()?.join("settings.json")) } -pub fn auth_file() -> LauncherResult { +pub fn auth_file() -> PathsResult { Ok(launcher_dir()?.join("auth.json")) } -pub fn logs_dir() -> LauncherResult { +pub fn logs_dir() -> PathsResult { Ok(launcher_dir()?.join("logs")) } -pub fn java_dir() -> LauncherResult { +pub fn java_dir() -> PathsResult { Ok(launcher_dir()?.join("metadata").join("java")) } -pub fn clusters_dir() -> LauncherResult { +pub fn clusters_dir() -> PathsResult { Ok(launcher_dir()?.join("clusters")) } -pub fn shared_minecraft_dir() -> LauncherResult { +pub fn shared_minecraft_dir() -> PathsResult { Ok(launcher_dir()?.join(".minecraft")) } -pub fn packages_cache_dir() -> LauncherResult { +pub fn packages_cache_dir() -> PathsResult { Ok(launcher_dir()?.join("metadata").join("packages")) } -pub fn caches_dir() -> LauncherResult { +pub fn caches_dir() -> PathsResult { Ok(launcher_dir()?.join("metadata").join("caches")) } -pub fn bundles_dir() -> LauncherResult { +pub fn bundles_dir() -> PathsResult { Ok(caches_dir()?.join("bundles")) } -pub fn images_cache_dir() -> LauncherResult { +pub fn images_cache_dir() -> PathsResult { Ok(caches_dir()?.join("images")) } -pub fn profiles_cache_dir() -> LauncherResult { +pub fn profiles_cache_dir() -> PathsResult { Ok(caches_dir()?.join("profiles")) } -pub fn versions_dir() -> LauncherResult { +pub fn versions_dir() -> PathsResult { Ok(launcher_dir()?.join("metadata").join("versions")) } -pub fn libraries_dir() -> LauncherResult { +pub fn libraries_dir() -> PathsResult { Ok(launcher_dir()?.join("metadata").join("libraries")) } -pub fn natives_dir() -> LauncherResult { +pub fn natives_dir() -> PathsResult { Ok(launcher_dir()?.join("metadata").join("natives")) } -pub fn assets_dir() -> LauncherResult { +pub fn assets_dir() -> PathsResult { Ok(launcher_dir()?.join("metadata").join("assets")) } -pub fn assets_index_dir() -> LauncherResult { +pub fn assets_index_dir() -> PathsResult { Ok(assets_dir()?.join("indexes")) } -pub fn assets_object_dir() -> LauncherResult { +pub fn assets_object_dir() -> PathsResult { Ok(assets_dir()?.join("objects")) } -pub fn legacy_assets_dir() -> LauncherResult { +pub fn legacy_assets_dir() -> PathsResult { Ok(launcher_dir()?.join("metadata").join("resources")) } pub fn package_version_dir( - content_type: crate::packages::domain::ContentType, - provider: crate::packages::domain::ProviderId, + content_type: ContentType, + provider: ProviderId, project_id: &str, version_id: &str, -) -> LauncherResult { +) -> PathsResult { Ok(packages_cache_dir()? .join(content_type.folder_name()) .join(provider.dir_name()) diff --git a/packages/oneclient_core/src/version.rs b/packages/oneclient_common/src/version.rs similarity index 100% rename from packages/oneclient_core/src/version.rs rename to packages/oneclient_common/src/version.rs diff --git a/packages/oneclient_content/Cargo.toml b/packages/oneclient_content/Cargo.toml new file mode 100644 index 00000000..ae88b929 --- /dev/null +++ b/packages/oneclient_content/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "oneclient_content" +description = "Mod providers, the artifact store, and Polyfrost bundles" +version.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true +documentation.workspace = true +readme.workspace = true +homepage.workspace = true +authors.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +oneclient_common.workspace = true +oneclient_db.workspace = true +oneclient_events.workspace = true +oneclient_net.workspace = true +polyio.workspace = true + +async-trait.workspace = true +async_zip.workspace = true +futures-lite.workspace = true +futures-util.workspace = true +tokio.workspace = true + +# HTML package descriptions -> markdown +htmd.workspace = true + +chrono.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +sqlx.workspace = true +thiserror.workspace = true +tracing.workspace = true +url.workspace = true +uuid.workspace = true + +[dev-dependencies] +tokio = { workspace = true } +polyio = { workspace = true, features = ["testing"] } diff --git a/packages/oneclient_core/src/bundles/error.rs b/packages/oneclient_content/src/bundles/error.rs similarity index 100% rename from packages/oneclient_core/src/bundles/error.rs rename to packages/oneclient_content/src/bundles/error.rs diff --git a/packages/oneclient_core/src/bundles/install.rs b/packages/oneclient_content/src/bundles/install.rs similarity index 72% rename from packages/oneclient_core/src/bundles/install.rs rename to packages/oneclient_content/src/bundles/install.rs index 34407b98..fb29da51 100644 --- a/packages/oneclient_core/src/bundles/install.rs +++ b/packages/oneclient_content/src/bundles/install.rs @@ -4,18 +4,18 @@ use oneclient_db::dao::cluster_bundle as bundle_dao; use oneclient_db::models::ClusterRow; use oneclient_db::models::OverrideType; -use crate::LauncherError; -use crate::LauncherResult; +use crate::error::ContentError; +use crate::error::ContentResult; use crate::bundles::error::BundleError; use crate::bundles::manager::BundlesManager; use crate::bundles::overrides; use crate::bundles::types::{BundleArchive, BundleFile, BundleFileKind}; -use crate::notification::{GroupedProgressChild, GroupedProgressSession, TaskCategory, TaskPhase}; -use crate::packages::domain::{ContentType, GameLoader}; +use oneclient_events::{GroupedProgressChild, GroupedProgressSession, TaskCategory, TaskPhase}; +use oneclient_common::domain::{ContentType, GameLoader}; use crate::packages::error::PackageError; use crate::packages::store::{PackageStore, unlink_cluster_file}; use crate::packages::types::ExternalFile; -use crate::state::LauncherServices; +use crate::ctx::ContentCtx; fn is_base62(s: &str) -> bool { !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric()) @@ -44,26 +44,26 @@ async fn clear_suppressing_override( cluster_id: i64, bundle_name: &str, package_id: &str, - services: &LauncherServices, -) -> LauncherResult<()> { - let overrides = bundle_dao::list_overrides(&services.db, cluster_id).await?; + ctx: &ContentCtx, +) -> ContentResult<()> { + let overrides = bundle_dao::list_overrides(&ctx.db, cluster_id).await?; if let Some(OverrideType::Enabled) = find_override(&overrides, bundle_name, package_id) { return Ok(()); } - bundle_dao::remove_override(&services.db, cluster_id, bundle_name, package_id).await?; + bundle_dao::remove_override(&ctx.db, cluster_id, bundle_name, package_id).await?; Ok(()) } -#[tracing::instrument(level = "debug", skip(file, child, services), fields(package = %file.display_name()))] +#[tracing::instrument(level = "debug", skip(file, child, ctx), fields(package = %file.display_name()))] pub async fn install_package_from_bundle( file: &BundleFile, cluster_id: i64, bundle_name: &str, skip_compatibility: bool, child: Option<&GroupedProgressChild>, - services: &LauncherServices, -) -> LauncherResult { - let cluster = PackageStore::get_cluster(cluster_id, services).await?; + ctx: &ContentCtx, +) -> ContentResult { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; let hash = match &file.kind { BundleFileKind::Managed { provider, @@ -72,7 +72,7 @@ pub async fn install_package_from_bundle( sha1, } => { let project = crate::packages::cached_project_detail( - services, + ctx, *provider, project_id, file.content_type(), @@ -80,14 +80,14 @@ pub async fn install_package_from_bundle( .await; let version = if is_base62(version_id) { - crate::packages::get_version_cached(services, *provider, project_id, version_id) + crate::packages::get_version_cached(ctx, *provider, project_id, version_id) .await? } else if let Ok(Some((_, version))) = - services.packages.lookup_version(sha1, services).await + ctx.providers.lookup_version(sha1, ctx).await { version } else { - crate::packages::get_version_cached(services, *provider, project_id, version_id) + crate::packages::get_version_cached(ctx, *provider, project_id, version_id) .await? }; @@ -99,18 +99,18 @@ pub async fn install_package_from_bundle( skip_compatibility, false, child, - services, + ctx, ) .await?; artifact.hash } BundleFileKind::External(ext) => { - install_external(ext, &cluster, skip_compatibility, child, services).await? + install_external(ext, &cluster, skip_compatibility, child, ctx).await? } }; bundle_dao::track_bundle_artifact( - &services.db, + &ctx.db, cluster_id, &hash, bundle_name, @@ -122,77 +122,77 @@ pub async fn install_package_from_bundle( Ok(hash) } -#[tracing::instrument(level = "debug", skip(ext, cluster, child, services), fields(file = %ext.name))] +#[tracing::instrument(level = "debug", skip(ext, cluster, child, ctx), fields(file = %ext.name))] async fn install_external( ext: &ExternalFile, cluster: &ClusterRow, skip_compatibility: bool, child: Option<&GroupedProgressChild>, - services: &LauncherServices, -) -> LauncherResult { - let artifact = crate::packages::store::download_external(ext, false, child, services).await?; - PackageStore::link_artifact(&artifact, cluster, Some(&ext.name), services).await?; + ctx: &ContentCtx, +) -> ContentResult { + let artifact = crate::packages::store::download_external(ext, false, child, ctx).await?; + PackageStore::link_artifact(&artifact, cluster, Some(&ext.name), ctx).await?; let _ = skip_compatibility; Ok(artifact.hash) } -#[tracing::instrument(level = "debug", skip(archive, services), fields(bundle = %archive.manifest.name))] +#[tracing::instrument(level = "debug", skip(archive, ctx), fields(bundle = %archive.manifest.name))] pub async fn extract_bundle_overrides_for_cluster( archive: &BundleArchive, cluster_id: i64, - services: &LauncherServices, -) -> LauncherResult<()> { - let cluster = PackageStore::get_cluster(cluster_id, services).await?; + ctx: &ContentCtx, +) -> ContentResult<()> { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; overrides::sync_bundle_overrides(&archive.bundle.path, &archive.manifest.name, &cluster, None) .await?; Ok(()) } -#[tracing::instrument(skip(bundles, services))] +#[tracing::instrument(skip(bundles, ctx))] pub async fn install_bundle( cluster_id: i64, bundle_name: &str, skip_compatibility: bool, bundles: &BundlesManager, - services: &LauncherServices, -) -> LauncherResult> { - let cluster = PackageStore::get_cluster(cluster_id, services).await?; + ctx: &ContentCtx, +) -> ContentResult> { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; let loader = GameLoader::from_repr(cluster.mc_loader as u8).ok_or_else(|| { - LauncherError::InvalidSettingsProfile { + ContentError::InvalidData { reason: format!("unknown loader {}", cluster.mc_loader), } })?; let archive = bundles - .archives_for(services, &cluster.mc_version, loader) + .archives_for(ctx, &cluster.mc_version, loader) .await? .into_iter() .find(|a| a.manifest.name == bundle_name) .ok_or(BundleError::NotFound(bundle_name.to_string()))?; - install_enabled_bundle_files(&archive, cluster_id, skip_compatibility, None, services).await + install_enabled_bundle_files(&archive, cluster_id, skip_compatibility, None, ctx).await } -#[tracing::instrument(level = "debug", skip(archive, progress, services), fields(bundle = %archive.manifest.name))] +#[tracing::instrument(level = "debug", skip(archive, progress, ctx), fields(bundle = %archive.manifest.name))] pub async fn install_enabled_bundle_files( archive: &BundleArchive, cluster_id: i64, skip_compatibility: bool, progress: Option<&GroupedProgressSession>, - services: &LauncherServices, -) -> LauncherResult> { - extract_bundle_overrides_for_cluster(archive, cluster_id, services).await?; + ctx: &ContentCtx, +) -> ContentResult> { + extract_bundle_overrides_for_cluster(archive, cluster_id, ctx).await?; - let overrides = bundle_dao::list_overrides(&services.db, cluster_id).await?; + let overrides = bundle_dao::list_overrides(&ctx.db, cluster_id).await?; let bundle_name = archive.manifest.name.clone(); let mut installed = Vec::new(); - let cluster = PackageStore::get_cluster(cluster_id, services).await?; - let linked = PackageStore::list_linked_artifacts(cluster_id, services).await?; + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?; let mut linked_projects: std::collections::HashSet<&str> = std::collections::HashSet::new(); let mut linked_hashes: std::collections::HashSet<&str> = std::collections::HashSet::new(); - let clusters_dir = crate::paths::clusters_dir()?; + let clusters_dir = oneclient_common::paths::clusters_dir()?; for info in &linked { let path = clusters_dir .join(&cluster.folder_name) @@ -244,7 +244,7 @@ pub async fn install_enabled_bundle_files( let c = p.child( format!("Mod {}", file.display_name()), file.size.max(1), - crate::notification::TaskCategory::Packages, + oneclient_events::TaskCategory::Packages, ); c.set_phase(TaskPhase::Downloading); c @@ -256,7 +256,7 @@ pub async fn install_enabled_bundle_files( bundle_name, skip_compatibility, child.as_ref(), - services, + ctx, ) .await; @@ -286,24 +286,24 @@ pub async fn install_enabled_bundle_files( /// also costs a provider API call, and those are rate limited per-minute. pub(crate) const BUNDLE_INSTALL_CONCURRENCY: usize = 6; -#[tracing::instrument(level = "debug", skip(bundles, services))] +#[tracing::instrument(level = "debug", skip(bundles, ctx))] pub async fn enabled_bundle_bytes( cluster_id: i64, bundles: &BundlesManager, - services: &LauncherServices, -) -> LauncherResult { - let cluster = PackageStore::get_cluster(cluster_id, services).await?; + ctx: &ContentCtx, +) -> ContentResult { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; let loader = GameLoader::from_repr(cluster.mc_loader as u8).ok_or_else(|| { - LauncherError::InvalidSettingsProfile { + ContentError::InvalidData { reason: format!("unknown loader {}", cluster.mc_loader), } })?; let archives = bundles - .archives_for(services, &cluster.mc_version, loader) + .archives_for(ctx, &cluster.mc_version, loader) .await?; - let overrides = bundle_dao::list_overrides(&services.db, cluster_id).await?; - let linked = PackageStore::list_linked_artifacts(cluster_id, services).await?; + let overrides = bundle_dao::list_overrides(&ctx.db, cluster_id).await?; + let linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?; let mut linked_projects: std::collections::HashSet<&str> = std::collections::HashSet::new(); let mut linked_hashes: std::collections::HashSet<&str> = std::collections::HashSet::new(); @@ -338,96 +338,96 @@ pub async fn enabled_bundle_bytes( Ok(total) } -#[tracing::instrument(level = "debug", skip(services))] +#[tracing::instrument(level = "debug", skip(ctx))] pub async fn set_bundle_package_override( cluster_id: i64, bundle_name: &str, package_id: &str, override_type: Option, - services: &LauncherServices, -) -> LauncherResult<()> { + ctx: &ContentCtx, +) -> ContentResult<()> { match override_type { Some(ty) => { - bundle_dao::save_override(&services.db, cluster_id, bundle_name, package_id, ty).await?; + bundle_dao::save_override(&ctx.db, cluster_id, bundle_name, package_id, ty).await?; } None => { - bundle_dao::remove_override(&services.db, cluster_id, bundle_name, package_id).await?; + bundle_dao::remove_override(&ctx.db, cluster_id, bundle_name, package_id).await?; } } Ok(()) } -#[tracing::instrument(level = "debug", skip(services))] +#[tracing::instrument(level = "debug", skip(ctx))] pub async fn set_bundle_package_enabled( cluster_id: i64, bundle_name: &str, package_id: &str, enabled: bool, - services: &LauncherServices, -) -> LauncherResult<()> { + ctx: &ContentCtx, +) -> ContentResult<()> { let override_type = if enabled { None } else { Some(OverrideType::Disabled) }; - set_bundle_package_override(cluster_id, bundle_name, package_id, override_type, services).await + set_bundle_package_override(cluster_id, bundle_name, package_id, override_type, ctx).await } -#[tracing::instrument(level = "debug", skip(overrides, services), fields(count = overrides.len()))] +#[tracing::instrument(level = "debug", skip(overrides, ctx), fields(count = overrides.len()))] pub async fn set_bundle_package_overrides( cluster_id: i64, overrides: &[(String, String, OverrideType)], - services: &LauncherServices, -) -> LauncherResult<()> { - bundle_dao::save_overrides(&services.db, cluster_id, overrides).await?; + ctx: &ContentCtx, +) -> ContentResult<()> { + bundle_dao::save_overrides(&ctx.db, cluster_id, overrides).await?; Ok(()) } -#[tracing::instrument(level = "debug", skip(services))] +#[tracing::instrument(level = "debug", skip(ctx))] pub async fn set_bundle_package_opt_in( cluster_id: i64, bundle_name: &str, package_id: &str, opted_in: bool, - services: &LauncherServices, -) -> LauncherResult<()> { + ctx: &ContentCtx, +) -> ContentResult<()> { let override_type = if opted_in { Some(OverrideType::Enabled) } else { None }; - set_bundle_package_override(cluster_id, bundle_name, package_id, override_type, services).await + set_bundle_package_override(cluster_id, bundle_name, package_id, override_type, ctx).await } -#[tracing::instrument(level = "debug", skip(services))] +#[tracing::instrument(level = "debug", skip(ctx))] pub async fn list_cluster_bundle_overrides( cluster_id: i64, - services: &LauncherServices, -) -> LauncherResult> { - let rows = bundle_dao::list_overrides(&services.db, cluster_id).await?; + ctx: &ContentCtx, +) -> ContentResult> { + let rows = bundle_dao::list_overrides(&ctx.db, cluster_id).await?; Ok(rows .into_iter() .map(|o| (o.bundle_name, o.package_id, o.override_type)) .collect()) } -#[tracing::instrument(skip(bundles, progress, services))] +#[tracing::instrument(skip(bundles, progress, ctx))] pub async fn install_cluster_bundles( cluster_id: i64, bundles: &BundlesManager, progress: Option<&GroupedProgressSession>, - services: &LauncherServices, -) -> LauncherResult<()> { - let cluster = PackageStore::get_cluster(cluster_id, services).await?; + ctx: &ContentCtx, +) -> ContentResult<()> { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; let loader = GameLoader::from_repr(cluster.mc_loader as u8).ok_or_else(|| { - LauncherError::InvalidSettingsProfile { + ContentError::InvalidData { reason: format!("unknown loader {}", cluster.mc_loader), } })?; let archives = bundles - .archives_for(services, &cluster.mc_version, loader) + .archives_for(ctx, &cluster.mc_version, loader) .await?; tracing::info!( cluster_id, @@ -437,7 +437,7 @@ pub async fn install_cluster_bundles( ); for archive in &archives { let installed = - install_enabled_bundle_files(archive, cluster_id, true, progress, services).await?; + install_enabled_bundle_files(archive, cluster_id, true, progress, ctx).await?; tracing::info!( cluster_id, bundle = %archive.manifest.name, @@ -449,45 +449,67 @@ pub async fn install_cluster_bundles( Ok(()) } -#[tracing::instrument(level = "debug", skip(services))] +#[tracing::instrument(level = "debug", skip(ctx))] pub async fn on_user_remove_artifact( cluster_id: i64, hash: &str, - services: &LauncherServices, -) -> LauncherResult<()> { - handle_user_artifact_action(cluster_id, hash, services, OverrideType::Removed).await + ctx: &ContentCtx, +) -> ContentResult<()> { + handle_user_artifact_action(cluster_id, hash, ctx, OverrideType::Removed).await +} + +#[tracing::instrument(level = "debug", skip(ctx))] +/// Flips an artifact's enabled state and updates the bundle override records. +/// +/// The composition point between the two layers: `PackageStore` does the +/// storage half and knows nothing about bundles; this adds the bookkeeping that +/// only makes sense once bundles exist. +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn toggle_artifact_enabled( + cluster_id: i64, + hash: &str, + ctx: &ContentCtx, +) -> ContentResult { + let enabled = PackageStore::set_artifact_enabled(cluster_id, hash, ctx).await?; + + if enabled { + on_user_enable_artifact(cluster_id, hash, ctx).await?; + } else { + on_user_disable_artifact(cluster_id, hash, ctx).await?; + } + + Ok(enabled) } -#[tracing::instrument(level = "debug", skip(services))] pub async fn on_user_disable_artifact( cluster_id: i64, hash: &str, - services: &LauncherServices, -) -> LauncherResult<()> { - handle_user_artifact_action(cluster_id, hash, services, OverrideType::Disabled).await + ctx: &ContentCtx, +) -> ContentResult<()> { + handle_user_artifact_action(cluster_id, hash, ctx, OverrideType::Disabled).await } -#[tracing::instrument(level = "debug", skip(services))] +#[tracing::instrument(level = "debug", skip(ctx))] pub async fn on_user_enable_artifact( cluster_id: i64, hash: &str, - services: &LauncherServices, -) -> LauncherResult<()> { - if let Some(tracked) = bundle_dao::get_bundle_tracked(&services.db, cluster_id, hash).await? + ctx: &ContentCtx, +) -> ContentResult<()> { + if let Some(tracked) = bundle_dao::get_bundle_tracked(&ctx.db, cluster_id, hash).await? && let (Some(bundle_name), Some(package_id)) = (tracked.bundle_name, tracked.package_id) { - clear_suppressing_override(cluster_id, &bundle_name, &package_id, services).await?; + clear_suppressing_override(cluster_id, &bundle_name, &package_id, ctx).await?; } Ok(()) } -#[tracing::instrument(level = "debug", skip(services))] +#[tracing::instrument(level = "debug", skip(ctx))] async fn handle_user_artifact_action( cluster_id: i64, hash: &str, - services: &LauncherServices, + ctx: &ContentCtx, override_type: OverrideType, -) -> LauncherResult<()> { - let Some(tracked) = bundle_dao::get_bundle_tracked(&services.db, cluster_id, hash).await? +) -> ContentResult<()> { + let Some(tracked) = bundle_dao::get_bundle_tracked(&ctx.db, cluster_id, hash).await? else { return Ok(()); }; @@ -497,7 +519,7 @@ async fn handle_user_artifact_action( }; bundle_dao::save_override( - &services.db, + &ctx.db, cluster_id, &bundle_name, &package_id, @@ -508,26 +530,26 @@ async fn handle_user_artifact_action( Ok(()) } -#[tracing::instrument(level = "debug", skip(services))] +#[tracing::instrument(level = "debug", skip(ctx))] pub async fn remove_artifact_from_cluster( cluster_id: i64, hash: &str, record_override: bool, - services: &LauncherServices, -) -> LauncherResult<()> { - let bundle_data = bundle_dao::get_bundle_tracked(&services.db, cluster_id, hash).await?; - let cluster = PackageStore::get_cluster(cluster_id, services).await?; - let artifact = artifact_dao::get_artifact_by_hash(&services.db, hash) + ctx: &ContentCtx, +) -> ContentResult<()> { + let bundle_data = bundle_dao::get_bundle_tracked(&ctx.db, cluster_id, hash).await?; + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let artifact = artifact_dao::get_artifact_by_hash(&ctx.db, hash) .await? .ok_or(PackageError::ArtifactMissing(hash.to_string()))?; let content_type = ContentType::from_repr(artifact.content_type as u8).ok_or_else(|| { - LauncherError::InvalidSettingsProfile { + ContentError::InvalidData { reason: format!("unknown content type {}", artifact.content_type), } })?; - if let Some(link) = artifact_dao::list_cluster_artifacts(&services.db, cluster_id) + if let Some(link) = artifact_dao::list_cluster_artifacts(&ctx.db, cluster_id) .await? .into_iter() .find(|l| l.hash == hash) @@ -535,7 +557,7 @@ pub async fn remove_artifact_from_cluster( unlink_cluster_file(&cluster, content_type, &link.cluster_file_name).await?; } - artifact_dao::unlink_cluster_artifact(&services.db, cluster_id, hash).await?; + artifact_dao::unlink_cluster_artifact(&ctx.db, cluster_id, hash).await?; if let Some(tracked) = bundle_data && let (Some(bundle_name), Some(package_id)) = @@ -543,7 +565,7 @@ pub async fn remove_artifact_from_cluster( { if record_override { bundle_dao::save_override( - &services.db, + &ctx.db, cluster_id, &bundle_name, &package_id, @@ -552,7 +574,7 @@ pub async fn remove_artifact_from_cluster( .await?; } else { let replacement_exists = bundle_dao::has_bundle_mapping( - &services.db, + &ctx.db, cluster_id, &bundle_name, &package_id, @@ -563,7 +585,7 @@ pub async fn remove_artifact_from_cluster( cluster_id, &bundle_name, &package_id, - services, + ctx, ) .await?; } @@ -576,7 +598,7 @@ pub async fn remove_artifact_from_cluster( #[cfg(test)] mod tests { use super::*; - use crate::packages::domain::ContentType; + use oneclient_common::domain::ContentType; use oneclient_db::models::ClusterBundleOverrideRow; fn file(enabled: bool) -> BundleFile { diff --git a/packages/oneclient_content/src/bundles/manager.rs b/packages/oneclient_content/src/bundles/manager.rs new file mode 100644 index 00000000..28b39c5b --- /dev/null +++ b/packages/oneclient_content/src/bundles/manager.rs @@ -0,0 +1,329 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +use chrono::Utc; +use oneclient_db::dao::bundle as bundle_dao; +use oneclient_db::models::{BundleRow, NewBundle}; +use tokio::sync::RwLock; + +use crate::bundles::error::BundleError; +use crate::bundles::manifest::{BundleManifest as RemoteBundleManifest, RemoteBundleRef}; +use crate::bundles::types::BundleArchive; +use crate::bundles::polymrpack; +use oneclient_net::RequestError; +use oneclient_common::domain::GameLoader; +use oneclient_common::paths; +use oneclient_net::{EtagPolicy, fetch_cached}; +use crate::ctx::ContentCtx; +use crate::error::{ContentError, ContentResult}; + +#[derive(Debug, Clone)] +pub struct Bundle { + pub remote_path: String, + pub mc_version: String, + pub loader: GameLoader, + pub file_name: String, + pub name: String, + pub version_id: String, + pub category: String, + pub loader_version: String, + pub path: PathBuf, + pub hidden: bool, +} + +impl Bundle { + fn try_from_row(row: BundleRow) -> ContentResult { + let loader = GameLoader::from_repr(row.mc_loader as u8) + .ok_or(BundleError::InvalidLoader(row.mc_loader))?; + + let launcher_dir = paths::launcher_dir()?; + let path = launcher_dir.join(&row.disk_path); + + Ok(Self { + remote_path: row.remote_path, + mc_version: row.mc_version, + loader, + file_name: row.file_name, + name: row.name.unwrap_or_default(), + version_id: row.version_id.unwrap_or_default(), + category: row.category.unwrap_or_default(), + loader_version: row.loader_version.unwrap_or_default(), + path, + hidden: row.hidden != 0, + }) + } +} + +pub struct BundlesManager { + manifest: RwLock, + pub(crate) archive_cache: RwLock>, +} + +impl BundlesManager { + pub fn new() -> Self { + Self { + manifest: RwLock::new(RemoteBundleManifest::default()), + archive_cache: RwLock::new(HashMap::new()), + } + } + + #[tracing::instrument(skip_all)] + pub async fn sync(&self, ctx: &ContentCtx) -> ContentResult { + let Some(fetched) = Self::fetch_manifest(ctx).await? else { + tracing::debug!( + "skipping bundle sync because no remote or cached manifest is available" + ); + return Ok(false); + }; + let FetchedManifest { + manifest, + changed: manifest_changed, + etag, + } = fetched; + *self.manifest.write().await = manifest.clone(); + self.archive_cache.write().await.clear(); + + if !manifest_changed { + tracing::debug!("bundles manifest unchanged; skipping per-bundle remote checks"); + return Ok(false); + } + + let remote_paths: Vec = manifest + .remote_paths() + .into_iter() + .map(|entry| entry.remote_path) + .collect(); + + bundle_dao::hide_bundles_not_in(&ctx.db, &remote_paths).await?; + + let bundles_root = paths::bundles_dir()?; + polyio::create_dir_all(&bundles_root.join("bundles")).await?; + + let mut synced_all = true; + for entry in manifest.remote_paths() { + if let Err(err) = self.sync_bundle(&entry, ctx, &bundles_root).await { + synced_all = false; + tracing::warn!( + remote_path = %entry.remote_path, + error = %err, + "failed to sync bundle" + ); + } + } + + if synced_all { + if let Some(etag) = etag { + let manifest_path = paths::bundles_dir()?.join("metadata.json"); + oneclient_net::commit_etag(&manifest_path, &etag).await; + } + } else { + tracing::warn!( + "bundle catalog incomplete; not caching the manifest etag so the next \ + sync retries the missing bundles" + ); + } + + Ok(true) + } + + /// Seeds the in-memory manifest cache for an archive already on disk. + /// + /// For callers that put a bundle there themselves (the dev helpers) and would + /// otherwise pay a re-read on first use. + pub async fn cache_archive_manifest( + &self, + path: std::path::PathBuf, + manifest: crate::bundles::types::BundleManifest, + ) { + self.archive_cache.write().await.insert(path, manifest); + } + + #[tracing::instrument(level = "debug", skip(self, ctx))] + pub async fn archives_for( + &self, + ctx: &ContentCtx, + mc_version: &str, + loader: GameLoader, + ) -> ContentResult> { + let mut archives = Vec::new(); + for bundle in self.list_for(ctx, mc_version, loader).await? { + let manifest = self.manifest_for_archive(&bundle.path).await?; + archives.push(BundleArchive { bundle, manifest }); + } + Ok(archives) + } + + #[tracing::instrument(level = "debug", skip(self))] + async fn manifest_for_archive( + &self, + path: &Path, + ) -> ContentResult { + if let Some(manifest) = self.archive_cache.read().await.get(path) { + return Ok(manifest.clone()); + } + let manifest = polymrpack::read_manifest_from_archive(path).await?; + self.archive_cache + .write() + .await + .insert(path.to_path_buf(), manifest.clone()); + Ok(manifest) + } + + #[tracing::instrument(level = "debug", skip(self, ctx))] + pub async fn list_for( + &self, + ctx: &ContentCtx, + mc_version: &str, + loader: GameLoader, + ) -> ContentResult> { + let rows = + bundle_dao::list_visible_for_version_loader(&ctx.db, mc_version, loader as i64) + .await?; + + rows.into_iter() + .map(Bundle::try_from_row) + .collect::, _>>() + } + + #[tracing::instrument(level = "debug", skip(self, entry, ctx, bundles_root), fields(remote_path = %entry.remote_path))] + async fn sync_bundle( + &self, + entry: &RemoteBundleRef, + ctx: &ContentCtx, + bundles_root: &Path, + ) -> ContentResult<()> { + let loader = GameLoader::from_str(&entry.loader) + .map_err(|_| BundleError::UnknownLoader(entry.loader.clone()))?; + + let file_name = entry + .remote_path + .split('/') + .next_back() + .ok_or_else(|| BundleError::InvalidPath(entry.remote_path.clone()))? + .to_string(); + + let disk_path = bundles_root.join("bundles").join(&file_name); + let remote_url = format!("{}{}", ctx.net.config().meta_url_base, entry.remote_path); + + download_bundle_if_needed(ctx, &remote_url, &disk_path, &entry.sha1).await?; + + let meta = polymrpack::read_meta_from_archive(&disk_path).await?; + + let relative_disk_path = disk_path + .strip_prefix(paths::launcher_dir()?) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|_| disk_path.to_string_lossy().into_owned()); + + let synced_at = Utc::now().to_rfc3339(); + + bundle_dao::upsert_bundle( + &ctx.db, + NewBundle { + remote_path: &entry.remote_path, + mc_version: &entry.mc_version, + mc_loader: loader as i64, + file_name: &file_name, + name: Some(&meta.name), + version_id: Some(&meta.version_id), + category: Some(&meta.category), + loader_version: Some(&meta.loader_version), + disk_path: &relative_disk_path, + hidden: false, + etag: Some(&entry.sha1), + synced_at: Some(&synced_at), + }, + ) + .await?; + + Ok(()) + } + + /// Fetches the bundle catalog, deferring the ETag commit. + /// + /// The catalog is only half the payload: each entry then has to download. + /// Committing the ETag here would make the next sync answer 304 and skip + /// bundles that never actually arrived, so [`BundlesManager::sync`] writes + /// it only once every bundle has landed. + #[tracing::instrument(level = "debug", skip(ctx))] + async fn fetch_manifest( + ctx: &ContentCtx, + ) -> ContentResult> { + let manifest_path = paths::bundles_dir()?.join("metadata.json"); + let url = format!( + "{}/oneclient/bundles/metadata.json", + ctx.net.config().meta_url_base + ); + + let Some(fetched) = + fetch_cached(&ctx.net, &url, &manifest_path, EtagPolicy::Defer).await? + else { + return Ok(None); + }; + + Ok(Some(FetchedManifest { + manifest: fetched.json()?, + changed: fetched.changed, + etag: fetched.etag, + })) + } +} + +impl Default for BundlesManager { + fn default() -> Self { + Self::new() + } +} + +struct FetchedManifest { + manifest: RemoteBundleManifest, + changed: bool, + etag: Option, +} + + +#[tracing::instrument(level = "debug", skip(ctx))] +async fn download_bundle_if_needed( + ctx: &ContentCtx, + url: &str, + disk_path: &Path, + expected_sha1: &str, +) -> ContentResult<()> { + if oneclient_net::matches_on_disk(disk_path, expected_sha1).await { + tracing::debug!("bundle cache hit via SHA1: {url}"); + return Ok(()); + } + + tracing::debug!("downloading bundle from remote: {url}"); + oneclient_net::download_verified( + &ctx.net, + &ctx.events, + url, + disk_path, + Some(expected_sha1), + 0, + None, + ) + .await + .map_err(map_request_error) +} + + +fn map_request_error(err: RequestError) -> ContentError { + match err { + RequestError::DeserializeError { + source, + type_name, + url, + status, + snippet, + } => ContentError::InvalidData { + reason: format!( + "failed to parse {type_name} from {url} (HTTP {status}): {source}; body: {snippet}" + ), + }, + other => ContentError::InvalidData { + reason: other.to_string(), + }, + } +} diff --git a/packages/oneclient_core/src/bundles/manifest.rs b/packages/oneclient_content/src/bundles/manifest.rs similarity index 100% rename from packages/oneclient_core/src/bundles/manifest.rs rename to packages/oneclient_content/src/bundles/manifest.rs diff --git a/packages/oneclient_core/src/bundles/mod.rs b/packages/oneclient_content/src/bundles/mod.rs similarity index 97% rename from packages/oneclient_core/src/bundles/mod.rs rename to packages/oneclient_content/src/bundles/mod.rs index 11e02254..1f39a6ab 100644 --- a/packages/oneclient_core/src/bundles/mod.rs +++ b/packages/oneclient_content/src/bundles/mod.rs @@ -15,6 +15,7 @@ pub use install::{ list_cluster_bundle_overrides, on_user_disable_artifact, on_user_enable_artifact, on_user_remove_artifact, remove_artifact_from_cluster, set_bundle_package_enabled, set_bundle_package_opt_in, + toggle_artifact_enabled, set_bundle_package_override, set_bundle_package_overrides, }; pub use manager::{Bundle, BundlesManager}; diff --git a/packages/oneclient_core/src/bundles/overrides.rs b/packages/oneclient_content/src/bundles/overrides.rs similarity index 86% rename from packages/oneclient_core/src/bundles/overrides.rs rename to packages/oneclient_content/src/bundles/overrides.rs index d78393d9..797d7e03 100644 --- a/packages/oneclient_core/src/bundles/overrides.rs +++ b/packages/oneclient_content/src/bundles/overrides.rs @@ -4,17 +4,17 @@ use std::path::{Path, PathBuf}; use oneclient_db::models::ClusterRow; use serde::{Deserialize, Serialize}; -use crate::LauncherResult; -use crate::crypto::{sha1_bytes, sha1_file}; -use crate::notification::NotificationService; +use crate::error::ContentResult; +use polyio::{sha1_bytes, sha1_file}; +use oneclient_events::EventBus; const ALWAYS_UPDATE_GLOBS: &[&str] = &["config/fabric_loader_dependencies.json"]; const OVERRIDES_PREFIX: &str = "overrides/"; const LOCK_REL_PATH: &str = ".oneclient/bundle_overrides.json"; -fn cluster_root(cluster: &ClusterRow) -> LauncherResult { - Ok(crate::paths::clusters_dir()?.join(&cluster.folder_name)) +fn cluster_root(cluster: &ClusterRow) -> ContentResult { + Ok(oneclient_common::paths::clusters_dir()?.join(&cluster.folder_name)) } #[derive(Debug, Default, Clone, Serialize, Deserialize)] @@ -35,7 +35,7 @@ impl OverrideLock { } } - async fn save(&self, root: &Path) -> LauncherResult<()> { + async fn save(&self, root: &Path) -> ContentResult<()> { let path = root.join(LOCK_REL_PATH); if let Some(parent) = path.parent() { polyio::create_dir_all(parent).await.ok(); @@ -53,24 +53,24 @@ pub struct OverrideSyncReport { pub deleted: Vec, } -#[tracing::instrument(level = "debug", skip(cluster, notifier))] +#[tracing::instrument(level = "debug", skip(cluster, events))] pub async fn sync_bundle_overrides( archive_path: &Path, bundle_name: &str, cluster: &ClusterRow, - notifier: Option<&NotificationService>, -) -> LauncherResult { + events: Option<&EventBus>, +) -> ContentResult { let root = cluster_root(cluster)?; - sync_bundle_overrides_at(archive_path, bundle_name, &root, notifier).await + sync_bundle_overrides_at(archive_path, bundle_name, &root, events).await } -#[tracing::instrument(level = "debug", skip(notifier))] +#[tracing::instrument(level = "debug", skip(events))] async fn sync_bundle_overrides_at( archive_path: &Path, bundle_name: &str, root: &Path, - notifier: Option<&NotificationService>, -) -> LauncherResult { + events: Option<&EventBus>, +) -> ContentResult { let entries = polyio::read_zip_file_entries(archive_path, |name| name.starts_with(OVERRIDES_PREFIX)) .await?; @@ -166,7 +166,7 @@ async fn sync_bundle_overrides_at( ); if !report.conflicts.is_empty() { - notify_conflicts(notifier, bundle_name, &report.conflicts); + notify_conflicts(events, bundle_name, &report.conflicts); } Ok(report) @@ -194,11 +194,11 @@ async fn current_sha1(path: &Path) -> Option { } fn notify_conflicts( - notifier: Option<&NotificationService>, + events: Option<&EventBus>, bundle_name: &str, conflicts: &[String], ) { - let Some(notifier) = notifier else { + let Some(events) = events else { return; }; @@ -216,13 +216,12 @@ fn notify_conflicts( String::new() }; - notifier.send_info( - "Kept your config edits", - &format!( + events.notify("Kept your config edits") + .body(format!( "{bundle_name}: {} config file(s) you edited were left untouched by the update: {listed}{suffix}", conflicts.len() - ), - ); + )) + .send(); } fn matches_always_update(rel: &str) -> bool { @@ -282,8 +281,6 @@ fn segment_match(pattern: &str, segment: &str) -> bool { #[cfg(test)] mod tests { - use std::path::PathBuf; - use std::sync::atomic::{AtomicU64, Ordering}; use super::*; @@ -306,17 +303,6 @@ mod tests { assert!(!glob_match("config/mod-*.cfg", "config/other-foo.cfg")); } - fn tmp_root(tag: &str) -> PathBuf { - static COUNTER: AtomicU64 = AtomicU64::new(0); - let n = COUNTER.fetch_add(1, Ordering::Relaxed); - - let dir = - std::env::temp_dir().join(format!("oneclient_ovr_{}_{tag}_{n}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - - dir - } - async fn write_bundle(path: &Path, entries: &[(&str, &[u8])]) { let file = tokio::fs::File::create(path).await.unwrap(); let mut writer = async_zip::tokio::write::ZipFileWriter::with_tokio(file); @@ -342,7 +328,7 @@ mod tests { #[tokio::test] async fn fresh_install_writes_all() { - let root = tmp_root("fresh"); + let root = polyio::testing::ScratchDir::new("fresh"); let zip = root.join("v1.mrpack"); write_bundle( &zip, @@ -354,7 +340,7 @@ mod tests { ) .await; - let report = sync(&zip, &root).await; + let report = sync(&zip, root.path()).await; assert_eq!(report.written.len(), 2); assert!(report.conflicts.is_empty()); assert_eq!(read(&root.join("config/a.toml")).await.unwrap(), b"alpha"); @@ -366,12 +352,12 @@ mod tests { #[tokio::test] async fn rerun_same_version_is_noop() { - let root = tmp_root("noop"); + let root = polyio::testing::ScratchDir::new("noop"); let zip = root.join("v1.mrpack"); write_bundle(&zip, &[("overrides/config/a.toml", b"alpha")]).await; - sync(&zip, &root).await; + sync(&zip, root.path()).await; - let report = sync(&zip, &root).await; + let report = sync(&zip, root.path()).await; assert!(report.written.is_empty()); assert!(report.conflicts.is_empty()); assert!(report.deleted.is_empty()); @@ -379,14 +365,14 @@ mod tests { #[tokio::test] async fn untouched_file_updates() { - let root = tmp_root("update"); + let root = polyio::testing::ScratchDir::new("update"); let v1 = root.join("v1.mrpack"); write_bundle(&v1, &[("overrides/config/a.toml", b"alpha")]).await; - sync(&v1, &root).await; + sync(&v1, root.path()).await; let v2 = root.join("v2.mrpack"); write_bundle(&v2, &[("overrides/config/a.toml", b"alpha-v2")]).await; - let report = sync(&v2, &root).await; + let report = sync(&v2, root.path()).await; assert_eq!(report.written, vec!["config/a.toml".to_string()]); assert!(report.conflicts.is_empty()); @@ -398,16 +384,16 @@ mod tests { #[tokio::test] async fn user_edit_with_bundle_unchanged_is_kept_silently() { - let root = tmp_root("edit_noconflict"); + let root = polyio::testing::ScratchDir::new("edit_noconflict"); let v1 = root.join("v1.mrpack"); write_bundle(&v1, &[("overrides/config/a.toml", b"alpha")]).await; - sync(&v1, &root).await; + sync(&v1, root.path()).await; polyio::write(root.join("config/a.toml"), b"user-edit") .await .unwrap(); - let report = sync(&v1, &root).await; + let report = sync(&v1, root.path()).await; assert!(report.written.is_empty()); assert!(report.conflicts.is_empty()); assert_eq!( @@ -418,10 +404,10 @@ mod tests { #[tokio::test] async fn user_edit_and_bundle_changed_is_conflict_kept() { - let root = tmp_root("conflict"); + let root = polyio::testing::ScratchDir::new("conflict"); let v1 = root.join("v1.mrpack"); write_bundle(&v1, &[("overrides/config/a.toml", b"alpha")]).await; - sync(&v1, &root).await; + sync(&v1, root.path()).await; polyio::write(root.join("config/a.toml"), b"user-edit") .await @@ -429,7 +415,7 @@ mod tests { let v2 = root.join("v2.mrpack"); write_bundle(&v2, &[("overrides/config/a.toml", b"alpha-v2")]).await; - let report = sync(&v2, &root).await; + let report = sync(&v2, root.path()).await; assert_eq!(report.conflicts, vec!["config/a.toml".to_string()]); assert!(report.written.is_empty()); @@ -439,7 +425,7 @@ mod tests { b"user-edit" ); - let report2 = sync(&v2, &root).await; + let report2 = sync(&v2, root.path()).await; assert_eq!( read(&root.join("config/a.toml")).await.unwrap(), b"user-edit" @@ -449,11 +435,11 @@ mod tests { #[tokio::test] async fn always_update_overwrites_user_edit() { - let root = tmp_root("always"); + let root = polyio::testing::ScratchDir::new("always"); let path = "config/fabric_loader_dependencies.json"; let v1 = root.join("v1.mrpack"); write_bundle(&v1, &[(&format!("overrides/{path}"), b"deps-v1")]).await; - sync(&v1, &root).await; + sync(&v1, root.path()).await; polyio::write(root.join(path), b"runtime-mutated") .await @@ -461,7 +447,7 @@ mod tests { let v2 = root.join("v2.mrpack"); write_bundle(&v2, &[(&format!("overrides/{path}"), b"deps-v2")]).await; - let report = sync(&v2, &root).await; + let report = sync(&v2, root.path()).await; assert!(report.conflicts.is_empty()); assert_eq!(read(&root.join(path)).await.unwrap(), b"deps-v2"); @@ -469,7 +455,7 @@ mod tests { #[tokio::test] async fn removed_file_deleted_only_when_unmodified() { - let root = tmp_root("removed"); + let root = polyio::testing::ScratchDir::new("removed"); let v1 = root.join("v1.mrpack"); write_bundle( &v1, @@ -479,7 +465,7 @@ mod tests { ], ) .await; - sync(&v1, &root).await; + sync(&v1, root.path()).await; // User modified kept.toml; gone.toml untouched. polyio::write(root.join("config/kept.toml"), b"edited") @@ -488,7 +474,7 @@ mod tests { let v2 = root.join("v2.mrpack"); write_bundle(&v2, &[("overrides/config/a.toml", b"a")]).await; - let report = sync(&v2, &root).await; + let report = sync(&v2, root.path()).await; assert_eq!(report.deleted, vec!["config/gone.toml".to_string()]); assert!(read(&root.join("config/gone.toml")).await.is_none()); @@ -501,7 +487,7 @@ mod tests { #[tokio::test] async fn bootstrap_pre_existing_edit_is_never_overwritten() { - let root = tmp_root("bootstrap"); + let root = polyio::testing::ScratchDir::new("bootstrap"); polyio::create_dir_all(root.join("config")) .await @@ -512,7 +498,7 @@ mod tests { let v1 = root.join("v1.mrpack"); write_bundle(&v1, &[("overrides/config/a.toml", b"bundle-version")]).await; - let report = sync(&v1, &root).await; + let report = sync(&v1, root.path()).await; assert!(report.written.is_empty()); assert!(report.conflicts.is_empty()); @@ -521,7 +507,7 @@ mod tests { b"pre-existing" ); - let report2 = sync(&v1, &root).await; + let report2 = sync(&v1, root.path()).await; assert!(report2.written.is_empty()); assert_eq!( read(&root.join("config/a.toml")).await.unwrap(), diff --git a/packages/oneclient_core/src/bundles/polymrpack.rs b/packages/oneclient_content/src/bundles/polymrpack.rs similarity index 91% rename from packages/oneclient_core/src/bundles/polymrpack.rs rename to packages/oneclient_content/src/bundles/polymrpack.rs index 4e6ec3f8..fe3d460e 100644 --- a/packages/oneclient_core/src/bundles/polymrpack.rs +++ b/packages/oneclient_content/src/bundles/polymrpack.rs @@ -4,13 +4,13 @@ use std::str::FromStr; use serde::Deserialize; -use crate::LauncherResult; +use crate::error::ContentResult; use crate::bundles::error::BundleError; use crate::bundles::types::{ BundleFile, BundleFileKind, BundleManifest, content_type_from_bundle_path, }; -use crate::constants::MODRINTH_CDN_PREFIX; -use crate::packages::domain::{GameLoader, ProviderId}; +use oneclient_common::constants::MODRINTH_CDN_PREFIX; +use oneclient_common::domain::{GameLoader, ProviderId}; use crate::packages::types::ExternalFile; #[derive(Debug, Clone, PartialEq, Eq)] @@ -57,7 +57,7 @@ struct PolyMrpackHashes { } #[tracing::instrument(level = "debug")] -pub async fn read_meta_from_archive(path: &Path) -> LauncherResult { +pub async fn read_meta_from_archive(path: &Path) -> ContentResult { let manifest = read_manifest_from_archive(path).await?; Ok(PolyMrpackMeta { name: manifest.name, @@ -71,14 +71,14 @@ pub async fn read_meta_from_archive(path: &Path) -> LauncherResult LauncherResult { +pub async fn read_manifest_from_archive(path: &Path) -> ContentResult { let file = tokio::fs::File::open(path).await?; let reader = tokio::io::BufReader::new(file); let manifest_bytes = polyio::try_read_zip_entry_bytes(reader, "modrinth.index.json").await?; parse_manifest_bytes(&manifest_bytes) } -fn parse_manifest_bytes(bytes: &[u8]) -> LauncherResult { +fn parse_manifest_bytes(bytes: &[u8]) -> ContentResult { let manifest: PolyMrpackManifest = serde_json::from_slice(bytes).map_err(|_| BundleError::InvalidManifest)?; @@ -103,7 +103,7 @@ fn parse_manifest_bytes(bytes: &[u8]) -> LauncherResult { fn parse_dependencies( deps: &HashMap, -) -> LauncherResult<(String, GameLoader, String)> { +) -> ContentResult<(String, GameLoader, String)> { let mut mc_version = None; let mut loader = None; let mut loader_version = None; diff --git a/packages/oneclient_core/src/bundles/runtime.rs b/packages/oneclient_content/src/bundles/runtime.rs similarity index 88% rename from packages/oneclient_core/src/bundles/runtime.rs rename to packages/oneclient_content/src/bundles/runtime.rs index 0646898f..d2fb633c 100644 --- a/packages/oneclient_core/src/bundles/runtime.rs +++ b/packages/oneclient_content/src/bundles/runtime.rs @@ -4,8 +4,8 @@ use std::sync::atomic::{AtomicBool, Ordering}; use crate::bundles::manager::BundlesManager; use crate::bundles::ApplyBundleUpdatesResult; use crate::bundles::updates::apply_bundle_updates_for_all_clusters; -use crate::notification::GroupedProgressSession; -use crate::state::LauncherServices; +use oneclient_events::GroupedProgressSession; +use crate::ctx::ContentCtx; static BUNDLE_SYNCING: AtomicBool = AtomicBool::new(false); @@ -19,12 +19,12 @@ pub fn is_bundle_syncing() -> bool { #[tracing::instrument(skip_all)] pub async fn sync_all_cluster_bundles( bundles: &BundlesManager, - services: &LauncherServices, + ctx: &ContentCtx, session: Option<&GroupedProgressSession>, ) -> Vec<(i64, ApplyBundleUpdatesResult)> { tracing::info!("syncing all cluster bundles"); BUNDLE_SYNCING.store(true, Ordering::Relaxed); - let changed = match apply_bundle_updates_for_all_clusters(bundles, services, session).await { + let changed = match apply_bundle_updates_for_all_clusters(bundles, ctx, session).await { Ok(changed) => changed, Err(err) => { tracing::error!("failed to apply bundle updates for clusters: {err:#}"); diff --git a/packages/oneclient_core/src/bundles/types.rs b/packages/oneclient_content/src/bundles/types.rs similarity index 98% rename from packages/oneclient_core/src/bundles/types.rs rename to packages/oneclient_content/src/bundles/types.rs index eab5489b..8bf76cb9 100644 --- a/packages/oneclient_core/src/bundles/types.rs +++ b/packages/oneclient_content/src/bundles/types.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use crate::bundles::Bundle; -use crate::packages::domain::{ContentType, GameLoader, ProviderId}; +use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; use crate::packages::types::ExternalFile; #[derive(Debug, Clone)] diff --git a/packages/oneclient_core/src/bundles/updates.rs b/packages/oneclient_content/src/bundles/updates.rs similarity index 87% rename from packages/oneclient_core/src/bundles/updates.rs rename to packages/oneclient_content/src/bundles/updates.rs index 0f3530ab..a8ee9154 100644 --- a/packages/oneclient_core/src/bundles/updates.rs +++ b/packages/oneclient_content/src/bundles/updates.rs @@ -13,6 +13,7 @@ use futures_util::StreamExt; use crate::bundles::install::{ BUNDLE_INSTALL_CONCURRENCY, install_package_from_bundle, remove_artifact_from_cluster, + toggle_artifact_enabled, }; use crate::bundles::manager::BundlesManager; use crate::bundles::overrides; @@ -21,11 +22,11 @@ use crate::bundles::types::{ BundlePackageRemoval, BundlePackageUpdate, BundleUpdateCheckResult, BundleWithUpdateStatus, FileUpdateStatus, external_bundle_key, managed_bundle_key, }; -use crate::packages::domain::{GameLoader, ProviderId}; +use oneclient_common::domain::{GameLoader, ProviderId}; use crate::packages::store::PackageStore; use crate::packages::types::LinkedArtifactInfo; -use crate::state::LauncherServices; -use crate::{LauncherError, LauncherResult}; +use crate::ctx::ContentCtx; +use crate::error::{ContentError, ContentResult}; static CLUSTER_UPDATE_LOCKS: OnceLock>>>> = OnceLock::new(); @@ -37,40 +38,40 @@ fn cluster_lock(cluster_id: i64) -> Arc> { .clone() } -#[tracing::instrument(level = "debug", skip(bundles, services))] +#[tracing::instrument(level = "debug", skip(bundles, ctx))] pub async fn check_bundle_updates( cluster_id: i64, bundles: &BundlesManager, - services: &LauncherServices, -) -> LauncherResult { - let overrides = bundle_dao::list_overrides(&services.db, cluster_id).await?; - check_bundle_updates_inner(cluster_id, bundles, services, &overrides).await + ctx: &ContentCtx, +) -> ContentResult { + let overrides = bundle_dao::list_overrides(&ctx.db, cluster_id).await?; + check_bundle_updates_inner(cluster_id, bundles, ctx, &overrides).await } -#[tracing::instrument(level = "debug", skip(bundles, services, overrides))] +#[tracing::instrument(level = "debug", skip(bundles, ctx, overrides))] async fn check_bundle_updates_inner( cluster_id: i64, bundles: &BundlesManager, - services: &LauncherServices, + ctx: &ContentCtx, overrides: &[ClusterBundleOverrideRow], -) -> LauncherResult { - let cluster = PackageStore::get_cluster(cluster_id, services).await?; +) -> ContentResult { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; let loader = GameLoader::from_repr(cluster.mc_loader as u8).ok_or_else(|| { - LauncherError::InvalidSettingsProfile { + ContentError::InvalidData { reason: format!("unknown loader {}", cluster.mc_loader), } })?; - let bundle_packages = bundle_dao::list_bundle_tracked(&services.db, cluster_id).await?; - let all_linked = PackageStore::list_linked_artifacts(cluster_id, services).await?; + let bundle_packages = bundle_dao::list_bundle_tracked(&ctx.db, cluster_id).await?; + let all_linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?; let all_linked_by_hash: HashMap = all_linked.iter().map(|a| (a.hash.clone(), a)).collect(); let (all_installed_managed_keys, all_installed_external_hashes) = - installed_bundle_keys(services, &all_linked).await?; + installed_bundle_keys(ctx, &all_linked).await?; let archives = bundles - .archives_for(services, &cluster.mc_version, loader) + .archives_for(ctx, &cluster.mc_version, loader) .await?; let tracked_bundle_names: HashSet = bundle_packages @@ -343,18 +344,18 @@ async fn check_bundle_updates_inner( }) } -#[tracing::instrument(skip(bundles, services))] +#[tracing::instrument(skip(bundles, ctx))] pub async fn apply_bundle_updates( cluster_id: i64, bundles: &BundlesManager, - services: &LauncherServices, -) -> LauncherResult { + ctx: &ContentCtx, +) -> ContentResult { // Manual, single-cluster path: its own short-lived progress notification. - let session = crate::notification::GroupedProgressSession::start( - &services.notifier, + let session = oneclient_events::GroupedProgressSession::start( + &ctx.events, "Updating bundle content", ); - let result = apply_bundle_updates_with(cluster_id, bundles, services, Some(&session)).await; + let result = apply_bundle_updates_with(cluster_id, bundles, ctx, Some(&session)).await; session.finish(); result } @@ -363,17 +364,17 @@ pub async fn apply_bundle_updates( /// download progress is reported into that (possibly shared) grouped session, so /// a whole batch of clusters can surface as a single notification. The caller /// owns the session's lifetime. -#[tracing::instrument(skip(bundles, services, session))] +#[tracing::instrument(skip(bundles, ctx, session))] pub async fn apply_bundle_updates_with( cluster_id: i64, bundles: &BundlesManager, - services: &LauncherServices, - session: Option<&crate::notification::GroupedProgressSession>, -) -> LauncherResult { + ctx: &ContentCtx, + session: Option<&oneclient_events::GroupedProgressSession>, +) -> ContentResult { let lock = cluster_lock(cluster_id); let _guard = lock.lock().await; - let overrides = bundle_dao::list_overrides(&services.db, cluster_id).await?; - let check = check_bundle_updates_inner(cluster_id, bundles, services, &overrides).await?; + let overrides = bundle_dao::list_overrides(&ctx.db, cluster_id).await?; + let check = check_bundle_updates_inner(cluster_id, bundles, ctx, &overrides).await?; tracing::info!( cluster_id, @@ -386,7 +387,7 @@ pub async fn apply_bundle_updates_with( let mut result = ApplyBundleUpdatesResult::default(); for removal in check.removals_available { - match remove_artifact_from_cluster(cluster_id, &removal.hash, false, services).await { + match remove_artifact_from_cluster(cluster_id, &removal.hash, false, ctx).await { Ok(()) => result.removals_applied.push(removal), Err(err) => result .removals_failed @@ -404,7 +405,7 @@ pub async fn apply_bundle_updates_with( .map(|u| u.new_file.size.max(1)) .chain(check.additions_available.iter().map(|a| a.new_file.size.max(1))) .sum(); - s.expect(crate::notification::TaskCategory::Packages, count, bytes); + s.expect(oneclient_events::TaskCategory::Packages, count, bytes); } // Fetch concurrently, reconcile in order. The install path already fans out @@ -420,9 +421,9 @@ pub async fn apply_bundle_updates_with( let c = s.child( update.new_file.display_name(), update.new_file.size.max(1), - crate::notification::TaskCategory::Packages, + oneclient_events::TaskCategory::Packages, ); - c.set_phase(crate::notification::TaskPhase::Downloading); + c.set_phase(oneclient_events::TaskPhase::Downloading); c }); let installed = install_package_from_bundle( @@ -431,11 +432,11 @@ pub async fn apply_bundle_updates_with( &update.bundle_name, true, child.as_ref(), - services, + ctx, ) .await; if let Some(child) = child { - child.set_phase(crate::notification::TaskPhase::Installing); + child.set_phase(oneclient_events::TaskPhase::Installing); child.finish(); } (update, installed) @@ -447,7 +448,7 @@ pub async fn apply_bundle_updates_with( for (update, installed) in fetched_updates { match installed { - Ok(hash) => match reconcile_update(&update, &hash, &overrides, services).await { + Ok(hash) => match reconcile_update(&update, &hash, &overrides, ctx).await { Ok(()) => result.updates_applied.push(update), Err(err) => result.updates_failed.push(err.to_string()), }, @@ -461,9 +462,9 @@ pub async fn apply_bundle_updates_with( let c = s.child( addition.new_file.display_name(), addition.new_file.size.max(1), - crate::notification::TaskCategory::Packages, + oneclient_events::TaskCategory::Packages, ); - c.set_phase(crate::notification::TaskPhase::Downloading); + c.set_phase(oneclient_events::TaskPhase::Downloading); c }); let installed = install_package_from_bundle( @@ -472,11 +473,11 @@ pub async fn apply_bundle_updates_with( &addition.bundle_name, true, child.as_ref(), - services, + ctx, ) .await; if let Some(child) = child { - child.set_phase(crate::notification::TaskPhase::Installing); + child.set_phase(oneclient_events::TaskPhase::Installing); child.finish(); } (addition, installed) @@ -490,7 +491,7 @@ pub async fn apply_bundle_updates_with( let file_id = addition.new_file.kind.package_id(); match installed { Ok(hash) => { - match reconcile_addition(&addition, &hash, &overrides, services).await { + match reconcile_addition(&addition, &hash, &overrides, ctx).await { Ok(()) => result.additions_applied.push(addition), Err(err) => result.additions_failed.push(format!("{file_id}: {err:#}")), } @@ -500,10 +501,10 @@ pub async fn apply_bundle_updates_with( } { - let cluster = PackageStore::get_cluster(cluster_id, services).await?; + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Fabric); if let Ok(archives) = bundles - .archives_for(services, &cluster.mc_version, loader) + .archives_for(ctx, &cluster.mc_version, loader) .await { for archive in archives { @@ -511,7 +512,7 @@ pub async fn apply_bundle_updates_with( &archive.bundle.path, &archive.manifest.name, &cluster, - Some(&services.notifier), + Some(&ctx.events), ) .await { @@ -528,7 +529,7 @@ pub async fn apply_bundle_updates_with( let failures = result.updates_failed.len() + result.removals_failed.len() + result.additions_failed.len(); if failures == 0 { - sync_cluster_loader_version_from_bundles(cluster_id, bundles, services).await?; + sync_cluster_loader_version_from_bundles(cluster_id, bundles, ctx).await?; } Ok(result) @@ -542,18 +543,18 @@ async fn reconcile_update( update: &BundlePackageUpdate, hash: &str, overrides: &[ClusterBundleOverrideRow], - services: &LauncherServices, -) -> LauncherResult<()> { + ctx: &ContentCtx, +) -> ContentResult<()> { let file_id = update.new_file.kind.package_id(); if should_be_disabled(&update.bundle_name, &file_id, overrides) { - PackageStore::toggle_artifact_enabled(update.cluster_id, hash, services).await?; + toggle_artifact_enabled(update.cluster_id, hash, ctx).await?; } if hash == update.installed_hash { return Ok(()); } - remove_artifact_from_cluster(update.cluster_id, &update.installed_hash, false, services).await + remove_artifact_from_cluster(update.cluster_id, &update.installed_hash, false, ctx).await } #[tracing::instrument(level = "debug", skip_all, fields(cluster_id = addition.cluster_id, bundle = %addition.bundle_name))] @@ -561,11 +562,11 @@ async fn reconcile_addition( addition: &BundlePackageAddition, hash: &str, overrides: &[ClusterBundleOverrideRow], - services: &LauncherServices, -) -> LauncherResult<()> { + ctx: &ContentCtx, +) -> ContentResult<()> { let file_id = addition.new_file.kind.package_id(); if should_be_disabled(&addition.bundle_name, &file_id, overrides) { - PackageStore::toggle_artifact_enabled(addition.cluster_id, hash, services).await?; + toggle_artifact_enabled(addition.cluster_id, hash, ctx).await?; } Ok(()) @@ -583,21 +584,21 @@ fn should_be_disabled( }) } -#[tracing::instrument(level = "debug", skip(bundles, services))] +#[tracing::instrument(level = "debug", skip(bundles, ctx))] async fn sync_cluster_loader_version_from_bundles( cluster_id: i64, bundles: &BundlesManager, - services: &LauncherServices, -) -> LauncherResult<()> { - let cluster = PackageStore::get_cluster(cluster_id, services).await?; + ctx: &ContentCtx, +) -> ContentResult<()> { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Fabric); - let bundle_packages = bundle_dao::list_bundle_tracked(&services.db, cluster_id).await?; - let all_linked = PackageStore::list_linked_artifacts(cluster_id, services).await?; + let bundle_packages = bundle_dao::list_bundle_tracked(&ctx.db, cluster_id).await?; + let all_linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?; let archives = bundles - .archives_for(services, &cluster.mc_version, loader) + .archives_for(ctx, &cluster.mc_version, loader) .await?; - let (managed_keys, _) = installed_bundle_keys(services, &all_linked).await?; + let (managed_keys, _) = installed_bundle_keys(ctx, &all_linked).await?; let subscribed: HashSet = bundle_packages .iter() .filter_map(|bp| bp.bundle_name.clone()) @@ -620,7 +621,7 @@ async fn sync_cluster_loader_version_from_bundles( } cluster_dao::update( - &services.db, + &ctx.db, cluster_id, &ClusterPatch { mc_loader_version: Some(Some(target)), @@ -632,16 +633,16 @@ async fn sync_cluster_loader_version_from_bundles( Ok(()) } -#[tracing::instrument(level = "debug", skip(bundles, services))] +#[tracing::instrument(level = "debug", skip(bundles, ctx))] pub async fn get_bundles_with_update_status( cluster_id: i64, bundles: &BundlesManager, - services: &LauncherServices, -) -> LauncherResult> { - let cluster = PackageStore::get_cluster(cluster_id, services).await?; + ctx: &ContentCtx, +) -> ContentResult> { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Fabric); - let bundle_packages = bundle_dao::list_bundle_tracked(&services.db, cluster_id).await?; - let all_linked = PackageStore::list_linked_artifacts(cluster_id, services).await?; + let bundle_packages = bundle_dao::list_bundle_tracked(&ctx.db, cluster_id).await?; + let all_linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?; let all_linked_by_hash: HashMap = all_linked.iter().map(|a| (a.hash.clone(), a)).collect(); @@ -654,7 +655,7 @@ pub async fn get_bundles_with_update_status( installed_map.insert(key, bundle_pkg); } - let overrides = bundle_dao::list_overrides(&services.db, cluster_id).await?; + let overrides = bundle_dao::list_overrides(&ctx.db, cluster_id).await?; let overrides_map: HashMap<(String, String), OverrideType> = overrides .iter() .filter_map(|o| { @@ -664,7 +665,7 @@ pub async fn get_bundles_with_update_status( .collect(); let archives = bundles - .archives_for(services, &cluster.mc_version, loader) + .archives_for(ctx, &cluster.mc_version, loader) .await?; let mut results = Vec::new(); @@ -732,12 +733,12 @@ pub async fn get_bundles_with_update_status( #[tracing::instrument(level = "debug", skip_all)] pub async fn apply_bundle_updates_for_all_clusters( bundles: &BundlesManager, - services: &LauncherServices, - session: Option<&crate::notification::GroupedProgressSession>, -) -> LauncherResult> { + ctx: &ContentCtx, + session: Option<&oneclient_events::GroupedProgressSession>, +) -> ContentResult> { let mut changed = Vec::new(); - for cluster in cluster_dao::list_all(&services.db).await? { - match apply_bundle_updates_with(cluster.id, bundles, services, session).await { + for cluster in cluster_dao::list_all(&ctx.db).await? { + match apply_bundle_updates_with(cluster.id, bundles, ctx, session).await { Ok(result) => { if !result.updates_applied.is_empty() || !result.additions_applied.is_empty() @@ -772,14 +773,14 @@ fn bundle_package_key( #[tracing::instrument(level = "debug", skip_all)] async fn installed_bundle_keys( - services: &LauncherServices, + ctx: &ContentCtx, linked: &[LinkedArtifactInfo], -) -> LauncherResult<(HashSet, HashSet)> { +) -> ContentResult<(HashSet, HashSet)> { let mut managed = HashSet::new(); let mut external = HashSet::new(); for item in linked { - if let Some(release) = artifact_dao::get_release_by_hash(&services.db, &item.hash).await? { + if let Some(release) = artifact_dao::get_release_by_hash(&ctx.db, &item.hash).await? { let provider = ProviderId::from_repr(release.provider as u8).unwrap_or(ProviderId::Modrinth); managed.insert(managed_bundle_key(provider, &release.project_id)); diff --git a/packages/oneclient_content/src/ctx.rs b/packages/oneclient_content/src/ctx.rs new file mode 100644 index 00000000..abece5d1 --- /dev/null +++ b/packages/oneclient_content/src/ctx.rs @@ -0,0 +1,42 @@ +use oneclient_db::DbPool; +use oneclient_events::EventBus; +use oneclient_net::RequestClient; + +use crate::packages::provider::PackageProviderRegistry; + +/// What the content subsystems need to do their work. +/// +/// All four fields are used here, unlike the Java and Minecraft crates which +/// each need a strict subset. +/// +/// The database handle is taken directly rather than behind a port. The tables +/// here (`artifacts`, `clusters`, `cluster_bundles`) are shared relational +/// state with cross-entity operations (`link_cluster_artifact`, +/// `is_cluster_linked`, `track_bundle_artifact`) used from four subsystems. A +/// trait over that would be the SQL schema with extra steps, and every schema +/// change would touch it. `java_versions` gets a port because it has one +/// owner and no joins; these do not. +#[derive(Clone)] +pub struct ContentCtx { + pub db: DbPool, + pub net: RequestClient, + pub events: EventBus, + pub providers: PackageProviderRegistry, +} + +impl ContentCtx { + #[must_use] + pub fn new( + db: DbPool, + net: RequestClient, + events: EventBus, + providers: PackageProviderRegistry, + ) -> Self { + Self { + db, + net, + events, + providers, + } + } +} diff --git a/packages/oneclient_content/src/error.rs b/packages/oneclient_content/src/error.rs new file mode 100644 index 00000000..ebb12fd8 --- /dev/null +++ b/packages/oneclient_content/src/error.rs @@ -0,0 +1,76 @@ +use crate::bundles::BundleError; +use crate::packages::PackageError; + +pub type ContentResult = Result; + +/// The crate's error, funnelling the two subsystem errors plus plumbing. +/// +/// `PackageError` and `BundleError` stay distinct because they describe +/// different failures (a provider rejecting a request versus a bundle manifest +/// not matching what is on disk) and callers inside each subsystem still return +/// their own. +#[derive(Debug, thiserror::Error)] +pub enum ContentError { + #[error(transparent)] + Package(#[from] PackageError), + + #[error(transparent)] + Bundle(#[from] BundleError), + + #[error(transparent)] + Request(#[from] oneclient_net::RequestError), + + #[error(transparent)] + Db(#[from] oneclient_db::DbError), + + #[error("Database execution failed: {0}")] + Sql(#[from] sqlx::Error), + + #[error(transparent)] + Io(#[from] polyio::IOError), + + #[error(transparent)] + StdIo(#[from] std::io::Error), + + #[error(transparent)] + Paths(#[from] oneclient_common::PathsError), + + #[error(transparent)] + Events(#[from] oneclient_events::EventError), + + #[error("Unable to parse JSON: {0}")] + Json(#[from] serde_json::Error), + + #[error("Invalid URL: {0}")] + Url(#[from] url::ParseError), + + /// A value that came out of the database or a manifest did not match the + /// shape the code expects: an unknown content type, a loader id with no + /// matching variant. + #[error("invalid content data: {reason}")] + InvalidData { reason: String }, +} + +impl ContentError { + /// Whether this is the user's environment rather than a launcher bug. + #[must_use] + pub fn is_transient(&self) -> bool { + match self { + Self::Request(source) => source.is_transient(), + Self::Io(polyio::IOError::IOError(source)) + | Self::Io(polyio::IOError::PathIOError { source, .. }) + | Self::StdIo(source) => { + use std::io::ErrorKind; + matches!( + source.kind(), + ErrorKind::ConnectionRefused + | ErrorKind::ConnectionReset + | ErrorKind::ConnectionAborted + | ErrorKind::NotConnected + | ErrorKind::TimedOut + ) + } + _ => false, + } + } +} diff --git a/packages/oneclient_content/src/lib.rs b/packages/oneclient_content/src/lib.rs new file mode 100644 index 00000000..bcab5995 --- /dev/null +++ b/packages/oneclient_content/src/lib.rs @@ -0,0 +1,17 @@ +//! Everything the user installs into a cluster: mods and resource packs from +//! Modrinth and CurseForge, the content-addressed artifact store that holds +//! them, and the Polyfrost bundles that curate sets of them. +//! +//! `packages` and `bundles` ship together because they are one concern viewed +//! from two sides: the store holds files, bundles decide which files a cluster +//! should have. The dependency between them runs one way only, though; +//! `bundles` builds on `packages`, never the reverse. + +pub mod bundles; +pub mod packages; + +mod ctx; +mod error; + +pub use ctx::ContentCtx; +pub use error::{ContentError, ContentResult}; diff --git a/packages/oneclient_core/src/packages/error.rs b/packages/oneclient_content/src/packages/error.rs similarity index 96% rename from packages/oneclient_core/src/packages/error.rs rename to packages/oneclient_content/src/packages/error.rs index d4520dd6..b1120825 100644 --- a/packages/oneclient_core/src/packages/error.rs +++ b/packages/oneclient_content/src/packages/error.rs @@ -1,6 +1,6 @@ use thiserror::Error; -use super::domain::ProviderId; +use oneclient_common::domain::ProviderId; use super::types::PackageBody; #[derive(Debug, Error)] diff --git a/packages/oneclient_core/src/packages/file_identity.rs b/packages/oneclient_content/src/packages/file_identity.rs similarity index 92% rename from packages/oneclient_core/src/packages/file_identity.rs rename to packages/oneclient_content/src/packages/file_identity.rs index ac49e89d..a6002012 100644 --- a/packages/oneclient_core/src/packages/file_identity.rs +++ b/packages/oneclient_content/src/packages/file_identity.rs @@ -1,8 +1,8 @@ use std::path::Path; -use crate::LauncherResult; -use crate::crypto::{normalize_hash, sha1_bytes}; +use crate::error::ContentResult; +use polyio::{normalize_hash, sha1_bytes}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct FileIdentity { @@ -19,7 +19,7 @@ impl FileIdentity { } #[tracing::instrument(level = "debug", skip(path))] - pub async fn from_path(path: impl AsRef) -> LauncherResult { + pub async fn from_path(path: impl AsRef) -> ContentResult { let bytes = polyio::read(path).await?; Ok(Self::from_bytes(&bytes)) } diff --git a/packages/oneclient_core/src/packages/metadata_cache.rs b/packages/oneclient_content/src/packages/metadata_cache.rs similarity index 76% rename from packages/oneclient_core/src/packages/metadata_cache.rs rename to packages/oneclient_content/src/packages/metadata_cache.rs index cbff192a..41d00935 100644 --- a/packages/oneclient_core/src/packages/metadata_cache.rs +++ b/packages/oneclient_content/src/packages/metadata_cache.rs @@ -8,10 +8,11 @@ use oneclient_db::dao::artifact as artifact_dao; use oneclient_db::dao::package_metadata as meta_dao; use crate::packages::ProviderId; -use crate::packages::domain::{ContentType, GameLoader}; +use oneclient_common::domain::{ContentType, GameLoader}; use crate::packages::store::artifact_absolute_path; use crate::packages::types::{PackageBody, ProjectDetail, VersionDetail, VersionFile}; -use crate::{LauncherResult, LauncherServices}; +use crate::ctx::ContentCtx; +use crate::error::ContentResult; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CachedPackageMeta { @@ -23,9 +24,9 @@ pub struct CachedPackageMeta { pub icon_url: Option, } -#[tracing::instrument(level = "debug", skip(services, project_ids))] +#[tracing::instrument(level = "debug", skip(ctx, project_ids))] pub async fn read_cached_package_meta( - services: &LauncherServices, + ctx: &ContentCtx, provider: ProviderId, project_ids: &[String], ) -> HashMap { @@ -33,7 +34,7 @@ pub async fn read_cached_package_meta( return HashMap::new(); } - let rows = meta_dao::get_package_metadata_batch(&services.db, provider as i64, project_ids) + let rows = meta_dao::get_package_metadata_batch(&ctx.db, provider as i64, project_ids) .await .unwrap_or_default(); @@ -54,14 +55,14 @@ pub async fn read_cached_package_meta( .collect() } -#[tracing::instrument(level = "debug", skip(services))] +#[tracing::instrument(level = "debug", skip(ctx))] pub async fn cached_project_detail( - services: &LauncherServices, + ctx: &ContentCtx, provider: ProviderId, project_id: &str, content_type: ContentType, ) -> ProjectDetail { - let cached = read_cached_package_meta(services, provider, std::slice::from_ref(&project_id.to_string())) + let cached = read_cached_package_meta(ctx, provider, std::slice::from_ref(&project_id.to_string())) .await; let meta = cached.get(project_id); @@ -91,37 +92,37 @@ pub async fn cached_project_detail( } } -#[tracing::instrument(level = "debug", skip(services))] +#[tracing::instrument(level = "debug", skip(ctx))] pub async fn get_version_cached( - services: &LauncherServices, + ctx: &ContentCtx, provider: ProviderId, project_id: &str, version_id: &str, -) -> LauncherResult { - if let Some(cached) = cached_version_detail(services, provider, project_id, version_id).await { +) -> ContentResult { + if let Some(cached) = cached_version_detail(ctx, provider, project_id, version_id).await { return Ok(cached); } - services - .packages + ctx + .providers .get(provider)? - .get_version(project_id, version_id, services) + .get_version(project_id, version_id, ctx) .await } -#[tracing::instrument(level = "debug", skip(services))] +#[tracing::instrument(level = "debug", skip(ctx))] async fn cached_version_detail( - services: &LauncherServices, + ctx: &ContentCtx, provider: ProviderId, project_id: &str, version_id: &str, ) -> Option { let release = - artifact_dao::get_provider_release(&services.db, provider as i64, project_id, version_id) + artifact_dao::get_provider_release(&ctx.db, provider as i64, project_id, version_id) .await .ok() .flatten()?; - let artifact = artifact_dao::get_artifact_by_hash(&services.db, &release.hash) + let artifact = artifact_dao::get_artifact_by_hash(&ctx.db, &release.hash) .await .ok() .flatten()?; @@ -161,12 +162,12 @@ async fn cached_version_detail( }) } -#[tracing::instrument(level = "debug", skip(services, project_ids))] +#[tracing::instrument(level = "debug", skip(ctx, project_ids))] pub async fn fetch_package_meta( - services: &LauncherServices, + ctx: &ContentCtx, provider: ProviderId, project_ids: &[String], -) -> LauncherResult> { +) -> ContentResult> { if project_ids.is_empty() { return Ok(HashMap::new()); } @@ -174,7 +175,7 @@ pub async fn fetch_package_meta( let provider_repr = provider as i64; let mut out: HashMap = HashMap::new(); - for row in meta_dao::get_package_metadata_batch(&services.db, provider_repr, project_ids).await? + for row in meta_dao::get_package_metadata_batch(&ctx.db, provider_repr, project_ids).await? { out.insert( row.project_id.clone(), @@ -200,10 +201,10 @@ pub async fn fetch_package_meta( tracing::debug!(count = missing.len(), "fetching missing package metadata from provider"); - let details = services - .packages + let details = ctx + .providers .get(provider)? - .get_projects(&missing, services) + .get_projects(&missing, ctx) .await?; for detail in details { @@ -216,7 +217,7 @@ pub async fn fetch_package_meta( icon_url: detail.icon_url, }; meta_dao::upsert_package_metadata( - &services.db, + &ctx.db, provider_repr, &meta.project_id, &meta.name, diff --git a/packages/oneclient_core/src/packages/mod.rs b/packages/oneclient_content/src/packages/mod.rs similarity index 68% rename from packages/oneclient_core/src/packages/mod.rs rename to packages/oneclient_content/src/packages/mod.rs index a988b6db..1e390bf8 100644 --- a/packages/oneclient_core/src/packages/mod.rs +++ b/packages/oneclient_content/src/packages/mod.rs @@ -1,4 +1,3 @@ -pub mod domain; pub mod error; pub mod metadata_cache; pub mod modpack; @@ -8,7 +7,9 @@ pub mod types; mod file_identity; -pub use domain::{ContentType, GameLoader, HashAlgorithm, ProviderId}; +// Re-exported so `oneclient_core::packages::ContentType` keeps working; the +// types themselves now live in the leaf vocabulary crate. +pub use oneclient_common::domain::{ContentType, GameLoader, HashAlgorithm, ProviderId}; pub use metadata_cache::{ CachedPackageMeta, cached_project_detail, fetch_package_meta, get_version_cached, read_cached_package_meta, diff --git a/packages/oneclient_core/src/packages/modpack/mod.rs b/packages/oneclient_content/src/packages/modpack/mod.rs similarity index 53% rename from packages/oneclient_core/src/packages/modpack/mod.rs rename to packages/oneclient_content/src/packages/modpack/mod.rs index 15c3e3ec..83a65a9f 100644 --- a/packages/oneclient_core/src/packages/modpack/mod.rs +++ b/packages/oneclient_content/src/packages/modpack/mod.rs @@ -5,14 +5,14 @@ use std::path::Path; pub use mrpack::{install_mrpack_to_cluster, MrpackInstaller}; -use crate::state::LauncherServices; -use crate::LauncherResult; +use crate::ctx::ContentCtx; +use crate::error::ContentResult; -#[tracing::instrument(level = "debug", skip(archive_path, services))] +#[tracing::instrument(level = "debug", skip(archive_path, ctx))] pub async fn install_modpack_archive( archive_path: impl AsRef, cluster_id: i64, - services: &LauncherServices, -) -> LauncherResult<()> { - mrpack::install_mrpack_to_cluster(archive_path.as_ref().to_path_buf(), cluster_id, services).await + ctx: &ContentCtx, +) -> ContentResult<()> { + mrpack::install_mrpack_to_cluster(archive_path.as_ref().to_path_buf(), cluster_id, ctx).await } diff --git a/packages/oneclient_core/src/packages/modpack/mrpack.rs b/packages/oneclient_content/src/packages/modpack/mrpack.rs similarity index 80% rename from packages/oneclient_core/src/packages/modpack/mrpack.rs rename to packages/oneclient_content/src/packages/modpack/mrpack.rs index eb4e413a..16e32fc7 100644 --- a/packages/oneclient_core/src/packages/modpack/mrpack.rs +++ b/packages/oneclient_content/src/packages/modpack/mrpack.rs @@ -7,12 +7,12 @@ use oneclient_db::models::ClusterRow; use serde::Deserialize; use uuid::Uuid; -use crate::packages::domain::ContentType; +use oneclient_common::domain::ContentType; use crate::packages::error::PackageError; use crate::packages::store::{self, PackageStore}; use crate::packages::types::ExternalFile; -use crate::state::LauncherServices; -use crate::LauncherResult; +use crate::ctx::ContentCtx; +use crate::error::ContentResult; pub struct MrpackInstaller; @@ -41,15 +41,15 @@ struct MrpackHashes { } impl MrpackInstaller { - #[tracing::instrument(skip(services))] + #[tracing::instrument(skip(ctx))] pub async fn install_archive( archive_path: PathBuf, cluster_id: i64, - services: &LauncherServices, - ) -> LauncherResult<()> { + ctx: &ContentCtx, + ) -> ContentResult<()> { tracing::info!("installing mrpack archive to cluster"); - let cluster = PackageStore::get_cluster(cluster_id, services).await?; - let cluster_root = crate::paths::clusters_dir()?.join(&cluster.folder_name); + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let cluster_root = oneclient_common::paths::clusters_dir()?.join(&cluster.folder_name); let bytes = Arc::new(polyio::read(archive_path).await?); @@ -75,8 +75,8 @@ impl MrpackInstaller { let progress_id = Uuid::new_v4(); for (index, entry) in manifest.files.into_iter().enumerate() { - services.notifier.send_progress( - &progress_id, + ctx.events.progress( + progress_id, "Installing Modpack Files", index as u64, total, @@ -98,7 +98,7 @@ impl MrpackInstaller { hash, file_name, &cluster, - services, + ctx, ) .await { @@ -107,8 +107,8 @@ impl MrpackInstaller { } } - services.notifier - .send_progress(&progress_id, "Installing Modpack Files", total, total); + ctx.events + .progress(progress_id, "Installing Modpack Files", total, total); polyio::read_zip_entries_bytes(bytes.to_vec(), async |_, entry, reader| { let name = entry.filename().as_str().map_err(io_err)?; @@ -142,31 +142,31 @@ impl MrpackInstaller { } } -#[tracing::instrument(level = "debug", skip(entry, cluster, services))] +#[tracing::instrument(level = "debug", skip(entry, cluster, ctx))] async fn install_mrpack_file( entry: MrpackFileEntry, content_type: ContentType, hash: String, file_name: String, cluster: &ClusterRow, - services: &LauncherServices, -) -> LauncherResult<()> { + ctx: &ContentCtx, +) -> ContentResult<()> { if let Some(row) = - oneclient_db::dao::artifact::get_artifact_by_hash(&services.db, &hash).await? + oneclient_db::dao::artifact::get_artifact_by_hash(&ctx.db, &hash).await? { let path = store::artifact_absolute_path(&row.path)?; if path.exists() { - PackageStore::link_artifact(&row, cluster, Some(&file_name), services).await?; + PackageStore::link_artifact(&row, cluster, Some(&file_name), ctx).await?; return Ok(()); } } if let Some((provider_id, version)) = - services.packages.lookup_version(&hash, services).await? + ctx.providers.lookup_version(&hash, ctx).await? { - let provider = services.packages.get(provider_id)?; + let provider = ctx.providers.get(provider_id)?; let project = provider - .get_project(&version.project_id, services) + .get_project(&version.project_id, ctx) .await?; let artifact = PackageStore::download_and_cache( provider_id, @@ -174,10 +174,10 @@ async fn install_mrpack_file( &version, false, None, - services, + ctx, ) .await?; - PackageStore::link_artifact(&artifact, cluster, Some(&file_name), services).await?; + PackageStore::link_artifact(&artifact, cluster, Some(&file_name), ctx).await?; return Ok(()); } @@ -195,8 +195,8 @@ async fn install_mrpack_file( content_type, }; - let artifact = store::download_external(&external, false, None, services).await?; - PackageStore::link_artifact(&artifact, cluster, Some(&file_name), services).await?; + let artifact = store::download_external(&external, false, None, ctx).await?; + PackageStore::link_artifact(&artifact, cluster, Some(&file_name), ctx).await?; Ok(()) } @@ -213,11 +213,11 @@ fn io_err(err: impl std::error::Error + Send + Sync + 'static) -> polyio::IOErro } } -#[tracing::instrument(level = "debug", skip(services))] +#[tracing::instrument(level = "debug", skip(ctx))] pub async fn install_mrpack_to_cluster( archive_path: PathBuf, cluster_id: i64, - services: &LauncherServices, -) -> LauncherResult<()> { - MrpackInstaller::install_archive(archive_path, cluster_id, services).await + ctx: &ContentCtx, +) -> ContentResult<()> { + MrpackInstaller::install_archive(archive_path, cluster_id, ctx).await } diff --git a/packages/oneclient_core/src/packages/provider/curseforge.rs b/packages/oneclient_content/src/packages/provider/curseforge.rs similarity index 90% rename from packages/oneclient_core/src/packages/provider/curseforge.rs rename to packages/oneclient_content/src/packages/provider/curseforge.rs index 905c063d..46001966 100644 --- a/packages/oneclient_core/src/packages/provider/curseforge.rs +++ b/packages/oneclient_content/src/packages/provider/curseforge.rs @@ -7,15 +7,15 @@ use url::Url; use super::PackageProvider; use super::http::fetch_json; -use crate::LauncherResult; -use crate::constants::{CURSEFORGE_API_URL, CURSEFORGE_GAME_ID}; -use crate::packages::domain::{ContentType, GameLoader, ProviderId}; +use crate::error::ContentResult; +use oneclient_common::constants::{CURSEFORGE_API_URL, CURSEFORGE_GAME_ID}; +use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; use crate::packages::file_identity::FileIdentity; use crate::packages::types::{ GalleryImage, PackageBody, Page, ProjectDetail, ProjectMember, ProjectSummary, ReleaseType, SearchFilters, VersionDetail, VersionFile, VersionLookup, VersionSummary, }; -use crate::state::LauncherServices; +use crate::ctx::ContentCtx; pub struct CurseForgeProvider; @@ -30,12 +30,12 @@ impl PackageProvider for CurseForgeProvider { ProviderId::CurseForge } - #[tracing::instrument(level = "debug", skip(self, filters, services))] + #[tracing::instrument(level = "debug", skip(self, filters, ctx))] async fn search( &self, filters: &SearchFilters, - services: &LauncherServices, - ) -> LauncherResult> { + ctx: &ContentCtx, + ) -> ContentResult> { tracing::debug!(query = ?filters.query, "searching curseforge"); let mut url = Url::parse(&api_url("/mods/search"))?; @@ -74,7 +74,7 @@ impl PackageProvider for CurseForgeProvider { } let response: CfPaged> = fetch_json( - &services.requester, + &ctx.net, Method::GET, url.as_str(), None, @@ -93,14 +93,14 @@ impl PackageProvider for CurseForgeProvider { }) } - #[tracing::instrument(level = "debug", skip(self, services))] + #[tracing::instrument(level = "debug", skip(self, ctx))] async fn get_project( &self, project_id: &str, - services: &LauncherServices, - ) -> LauncherResult { + ctx: &ContentCtx, + ) -> ContentResult { let response: CfData = fetch_json( - &services.requester, + &ctx.net, Method::GET, &api_url(&format!("/mods/{project_id}")), None, @@ -109,27 +109,27 @@ impl PackageProvider for CurseForgeProvider { Ok(response.data.into_detail()) } - #[tracing::instrument(level = "debug", skip(self, services))] + #[tracing::instrument(level = "debug", skip(self, ctx))] async fn get_project_with_body( &self, project_id: &str, - services: &LauncherServices, - ) -> LauncherResult { - let mut detail = self.get_project(project_id, services).await?; - detail.body = fetch_body(project_id, &detail, services).await; + ctx: &ContentCtx, + ) -> ContentResult { + let mut detail = self.get_project(project_id, ctx).await?; + detail.body = fetch_body(project_id, &detail, ctx).await; Ok(detail) } - #[tracing::instrument(level = "debug", skip(self, project_ids, services))] + #[tracing::instrument(level = "debug", skip(self, project_ids, ctx))] async fn get_projects( &self, project_ids: &[String], - services: &LauncherServices, - ) -> LauncherResult> { + ctx: &ContentCtx, + ) -> ContentResult> { let mod_ids: Vec = project_ids.iter().filter_map(|s| s.parse().ok()).collect(); let body = serde_json::json!({ "modIds": mod_ids }); let response: CfData> = fetch_json( - &services.requester, + &ctx.net, Method::POST, &api_url("/mods"), Some(body), @@ -138,7 +138,7 @@ impl PackageProvider for CurseForgeProvider { Ok(response.data.into_iter().map(CfMod::into_detail).collect()) } - #[tracing::instrument(level = "debug", skip(self, services))] + #[tracing::instrument(level = "debug", skip(self, ctx))] async fn list_versions( &self, project_id: &str, @@ -146,8 +146,8 @@ impl PackageProvider for CurseForgeProvider { loader: Option, offset: usize, limit: usize, - services: &LauncherServices, - ) -> LauncherResult> { + ctx: &ContentCtx, + ) -> ContentResult> { let mut url = Url::parse(&api_url(&format!("/mods/{project_id}/files")))?; { let mut params = url.query_pairs_mut(); @@ -159,7 +159,7 @@ impl PackageProvider for CurseForgeProvider { } } let response: CfPaged> = fetch_json( - &services.requester, + &ctx.net, Method::GET, url.as_str(), None, @@ -183,12 +183,12 @@ impl PackageProvider for CurseForgeProvider { }) } - #[tracing::instrument(level = "debug", skip(self, services))] + #[tracing::instrument(level = "debug", skip(self, ctx))] async fn list_categories( &self, content_type: ContentType, - services: &LauncherServices, - ) -> LauncherResult> { + ctx: &ContentCtx, + ) -> ContentResult> { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct CfCategory { @@ -199,7 +199,7 @@ impl PackageProvider for CurseForgeProvider { .append_pair("gameId", &CURSEFORGE_GAME_ID.to_string()) .append_pair("classId", &cf_class_id(content_type).to_string()); let response: CfData> = fetch_json( - &services.requester, + &ctx.net, Method::GET, url.as_str(), None, @@ -208,15 +208,15 @@ impl PackageProvider for CurseForgeProvider { Ok(response.data.into_iter().map(|c| c.name).collect()) } - #[tracing::instrument(level = "debug", skip(self, _project_id, services))] + #[tracing::instrument(level = "debug", skip(self, _project_id, ctx))] async fn get_version( &self, _project_id: &str, version_id: &str, - services: &LauncherServices, - ) -> LauncherResult { + ctx: &ContentCtx, + ) -> ContentResult { let response: CfData = fetch_json( - &services.requester, + &ctx.net, Method::GET, &api_url(&format!("/mods/files/{version_id}")), None, @@ -225,16 +225,16 @@ impl PackageProvider for CurseForgeProvider { Ok(response.data.into()) } - #[tracing::instrument(level = "debug", skip(self, version_ids, services))] + #[tracing::instrument(level = "debug", skip(self, version_ids, ctx))] async fn get_versions( &self, version_ids: &[String], - services: &LauncherServices, - ) -> LauncherResult> { + ctx: &ContentCtx, + ) -> ContentResult> { let file_ids: Vec = version_ids.iter().filter_map(|s| s.parse().ok()).collect(); let body = serde_json::json!({ "fileIds": file_ids }); let response: CfData> = fetch_json( - &services.requester, + &ctx.net, Method::POST, &api_url("/mods/files"), Some(body), @@ -247,8 +247,8 @@ impl PackageProvider for CurseForgeProvider { async fn lookup_versions( &self, identities: &[FileIdentity], - services: &LauncherServices, - ) -> LauncherResult { + ctx: &ContentCtx, + ) -> ContentResult { let mut out = HashMap::new(); let with_fingerprint: Vec<(&FileIdentity, u32)> = identities @@ -274,7 +274,7 @@ impl PackageProvider for CurseForgeProvider { } let response: CfData = fetch_json( - &services.requester, + &ctx.net, Method::POST, &api_url(&format!("/mods/fingerprints/{CURSEFORGE_GAME_ID}")), Some(body), @@ -286,7 +286,7 @@ impl PackageProvider for CurseForgeProvider { let file_sha1 = version .files .first() - .map(|f| crate::crypto::normalize_hash(&f.sha1)); + .map(|f| polyio::normalize_hash(&f.sha1)); let fp = m.file.file_fingerprint; @@ -312,11 +312,11 @@ struct CfPaged { pagination: CfPagination, } -#[tracing::instrument(level = "debug", skip(detail, services))] +#[tracing::instrument(level = "debug", skip(detail, ctx))] async fn fetch_body( project_id: &str, detail: &ProjectDetail, - services: &LauncherServices, + ctx: &ContentCtx, ) -> PackageBody { let fallback = || { detail @@ -328,7 +328,7 @@ async fn fetch_body( }; let response: Result, _> = fetch_json( - &services.requester, + &ctx.net, Method::GET, &api_url(&format!("/mods/{project_id}/description")), None, diff --git a/packages/oneclient_core/src/packages/provider/http.rs b/packages/oneclient_content/src/packages/provider/http.rs similarity index 83% rename from packages/oneclient_core/src/packages/provider/http.rs rename to packages/oneclient_content/src/packages/provider/http.rs index 248ad3a9..ed17abbb 100644 --- a/packages/oneclient_core/src/packages/provider/http.rs +++ b/packages/oneclient_content/src/packages/provider/http.rs @@ -1,9 +1,9 @@ use reqwest::Method; use serde::de::DeserializeOwned; -use crate::http::{RequestClient, RequestError, ResponseNotifyOptions, ResponseOptions}; -use crate::notification::GroupedProgressChild; -use crate::state::LauncherServices; +use oneclient_net::{RequestClient, RequestError, ResponseNotifyOptions, ResponseOptions}; +use oneclient_events::GroupedProgressChild; +use crate::ctx::ContentCtx; #[tracing::instrument(level = "debug", skip(client, url, body))] pub async fn fetch_json( @@ -32,19 +32,19 @@ pub async fn fetch_json_with_headers( client.send_json(method, url, body, &borrowed).await } -#[tracing::instrument(level = "debug", skip(client, dest, child, services))] +#[tracing::instrument(level = "debug", skip(client, dest, child, ctx))] pub async fn download_url( client: &RequestClient, url: &str, dest: impl AsRef + Send, child: Option<&GroupedProgressChild>, - services: &LauncherServices, + ctx: &ContentCtx, ) -> Result<(), RequestError> { let request = reqwest::Request::new(Method::GET, url.parse()?); let options = ResponseOptions { notify: child.map(|c| ResponseNotifyOptions::grouped(c.clone())), }; client - .download_file(request, dest, options, services) + .download_file(request, dest, options, &ctx.events) .await } diff --git a/packages/oneclient_core/src/packages/provider/mod.rs b/packages/oneclient_content/src/packages/provider/mod.rs similarity index 61% rename from packages/oneclient_core/src/packages/provider/mod.rs rename to packages/oneclient_content/src/packages/provider/mod.rs index 46bda9ef..98fa6564 100644 --- a/packages/oneclient_core/src/packages/provider/mod.rs +++ b/packages/oneclient_content/src/packages/provider/mod.rs @@ -7,14 +7,14 @@ pub use curseforge::CurseForgeProvider; pub use modrinth::ModrinthProvider; pub use registry::PackageProviderRegistry; -use super::domain::ProviderId; +use oneclient_common::domain::ProviderId; use crate::packages::file_identity::FileIdentity; use super::types::{ Page, ProjectDetail, ProjectSummary, SearchFilters, VersionDetail, VersionLookup, VersionSummary, }; -use crate::LauncherResult; -use crate::state::LauncherServices; +use crate::error::ContentResult; +use crate::ctx::ContentCtx; #[async_trait::async_trait] pub trait PackageProvider: Send + Sync { @@ -23,14 +23,14 @@ pub trait PackageProvider: Send + Sync { async fn search( &self, filters: &SearchFilters, - services: &LauncherServices, - ) -> LauncherResult>; + ctx: &ContentCtx, + ) -> ContentResult>; async fn get_project( &self, project_id: &str, - services: &LauncherServices, - ) -> LauncherResult; + ctx: &ContentCtx, + ) -> ContentResult; /// Same as [`PackageProvider::get_project`], but guarantees [`ProjectDetail::body`] /// is populated when the provider has one. Only worth calling when the body is @@ -39,51 +39,51 @@ pub trait PackageProvider: Send + Sync { async fn get_project_with_body( &self, project_id: &str, - services: &LauncherServices, - ) -> LauncherResult { - self.get_project(project_id, services).await + ctx: &ContentCtx, + ) -> ContentResult { + self.get_project(project_id, ctx).await } async fn get_projects( &self, project_ids: &[String], - services: &LauncherServices, - ) -> LauncherResult>; + ctx: &ContentCtx, + ) -> ContentResult>; async fn list_versions( &self, project_id: &str, game_version: Option<&str>, - loader: Option, + loader: Option, offset: usize, limit: usize, - services: &LauncherServices, - ) -> LauncherResult>; + ctx: &ContentCtx, + ) -> ContentResult>; async fn get_version( &self, project_id: &str, version_id: &str, - services: &LauncherServices, - ) -> LauncherResult; + ctx: &ContentCtx, + ) -> ContentResult; async fn get_versions( &self, version_ids: &[String], - services: &LauncherServices, - ) -> LauncherResult>; + ctx: &ContentCtx, + ) -> ContentResult>; async fn lookup_versions( &self, identities: &[FileIdentity], - services: &LauncherServices, - ) -> LauncherResult; + ctx: &ContentCtx, + ) -> ContentResult; async fn list_categories( &self, - _content_type: super::domain::ContentType, - _services: &LauncherServices, - ) -> LauncherResult> { + _content_type: oneclient_common::domain::ContentType, + _ctx: &ContentCtx, + ) -> ContentResult> { Ok(Vec::new()) } } diff --git a/packages/oneclient_core/src/packages/provider/modrinth.rs b/packages/oneclient_content/src/packages/provider/modrinth.rs similarity index 89% rename from packages/oneclient_core/src/packages/provider/modrinth.rs rename to packages/oneclient_content/src/packages/provider/modrinth.rs index eab6fa25..58e05b3f 100644 --- a/packages/oneclient_core/src/packages/provider/modrinth.rs +++ b/packages/oneclient_content/src/packages/provider/modrinth.rs @@ -10,17 +10,16 @@ use serde::de::DeserializeOwned; use super::PackageProvider; use super::http::fetch_json_with_headers; -use crate::LauncherResult; -use crate::api_config::modrinth_headers; -use crate::constants::MODRINTH_API_URL; -use crate::http::{RequestClient, RequestError}; -use crate::packages::domain::{ContentType, GameLoader, ProviderId}; +use crate::error::ContentResult; +use oneclient_common::constants::MODRINTH_API_URL; +use oneclient_net::{RequestClient, RequestError}; +use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; use crate::packages::file_identity::FileIdentity; use crate::packages::types::{ GalleryImage, PackageBody, Page, ProjectDetail, ProjectMember, ProjectSummary, ReleaseType, SearchFilters, VersionDetail, VersionFile, VersionLookup, VersionSummary, }; -use crate::state::LauncherServices; +use crate::ctx::ContentCtx; pub struct ModrinthProvider; @@ -34,7 +33,7 @@ async fn fetch_json( url: impl reqwest::IntoUrl, body: Option, ) -> Result { - fetch_json_with_headers(client, method, url, body, &modrinth_headers()).await + fetch_json_with_headers(client, method, url, body, &client.config().modrinth_headers()).await } #[async_trait::async_trait] @@ -43,12 +42,12 @@ impl PackageProvider for ModrinthProvider { ProviderId::Modrinth } - #[tracing::instrument(level = "debug", skip(self, filters, services))] + #[tracing::instrument(level = "debug", skip(self, filters, ctx))] async fn search( &self, filters: &SearchFilters, - services: &LauncherServices, - ) -> LauncherResult> { + ctx: &ContentCtx, + ) -> ContentResult> { tracing::debug!(query = ?filters.query, "searching modrinth"); let mut url = Url::parse(&v2("/search"))?; { @@ -122,7 +121,7 @@ impl PackageProvider for ModrinthProvider { } let response: Response = - fetch_json(&services.requester, Method::GET, url.as_str(), None).await?; + fetch_json(&ctx.net, Method::GET, url.as_str(), None).await?; Ok(Page { offset: response.offset, @@ -154,18 +153,18 @@ impl PackageProvider for ModrinthProvider { }) } - #[tracing::instrument(level = "debug", skip(self, services))] + #[tracing::instrument(level = "debug", skip(self, ctx))] async fn get_project( &self, project_id: &str, - services: &LauncherServices, - ) -> LauncherResult { + ctx: &ContentCtx, + ) -> ContentResult { let project_url = v2(&format!("/project/{project_id}")); let members_url = v2(&format!("/project/{project_id}/members")); let project_fut = - fetch_json::(&services.requester, Method::GET, &project_url, None); + fetch_json::(&ctx.net, Method::GET, &project_url, None); let members_fut = - fetch_json::>(&services.requester, Method::GET, &members_url, None); + fetch_json::>(&ctx.net, Method::GET, &members_url, None); let (raw, members) = tokio::join!(project_fut, members_fut); let mut detail = raw?.into_detail(); @@ -175,19 +174,19 @@ impl PackageProvider for ModrinthProvider { Ok(detail) } - #[tracing::instrument(level = "debug", skip(self, services))] + #[tracing::instrument(level = "debug", skip(self, ctx))] async fn list_categories( &self, content_type: ContentType, - services: &LauncherServices, - ) -> LauncherResult> { + ctx: &ContentCtx, + ) -> ContentResult> { #[derive(Deserialize)] struct Tag { name: String, project_type: String, } let tags: Vec = - fetch_json(&services.requester, Method::GET, &v2("/tag/category"), None).await?; + fetch_json(&ctx.net, Method::GET, &v2("/tag/category"), None).await?; let want = content_type.modrinth_type(); Ok(tags .into_iter() @@ -196,12 +195,12 @@ impl PackageProvider for ModrinthProvider { .collect()) } - #[tracing::instrument(level = "debug", skip(self, project_ids, services))] + #[tracing::instrument(level = "debug", skip(self, project_ids, ctx))] async fn get_projects( &self, project_ids: &[String], - services: &LauncherServices, - ) -> LauncherResult> { + ctx: &ContentCtx, + ) -> ContentResult> { if project_ids.is_empty() { return Ok(Vec::new()); } @@ -212,7 +211,7 @@ impl PackageProvider for ModrinthProvider { .join(","); let raw: Vec = fetch_json( - &services.requester, + &ctx.net, Method::GET, &v2(&format!("/projects?ids=[{ids}]")), None, @@ -226,7 +225,7 @@ impl PackageProvider for ModrinthProvider { .into_iter() .collect(); - let mut members_by_team = fetch_modrinth_team_members(&services.requester, &team_ids) + let mut members_by_team = fetch_modrinth_team_members(&ctx.net, &team_ids) .await .unwrap_or_default(); @@ -243,7 +242,7 @@ impl PackageProvider for ModrinthProvider { .collect()) } - #[tracing::instrument(level = "debug", skip(self, services))] + #[tracing::instrument(level = "debug", skip(self, ctx))] async fn list_versions( &self, project_id: &str, @@ -251,8 +250,8 @@ impl PackageProvider for ModrinthProvider { loader: Option, offset: usize, limit: usize, - services: &LauncherServices, - ) -> LauncherResult> { + ctx: &ContentCtx, + ) -> ContentResult> { let mut url = Url::parse(&v2(&format!("/project/{project_id}/version")))?; if let Some(v) = game_version { url.query_pairs_mut() @@ -264,7 +263,7 @@ impl PackageProvider for ModrinthProvider { } let versions: Vec = - fetch_json(&services.requester, Method::GET, url.as_str(), None).await?; + fetch_json(&ctx.net, Method::GET, url.as_str(), None).await?; let total = versions.len(); let items = versions @@ -282,15 +281,15 @@ impl PackageProvider for ModrinthProvider { }) } - #[tracing::instrument(level = "debug", skip(self, _project_id, services))] + #[tracing::instrument(level = "debug", skip(self, _project_id, ctx))] async fn get_version( &self, _project_id: &str, version_id: &str, - services: &LauncherServices, - ) -> LauncherResult { + ctx: &ContentCtx, + ) -> ContentResult { let raw: ModrinthVersion = fetch_json( - &services.requester, + &ctx.net, Method::GET, &v2(&format!("/version/{version_id}")), None, @@ -299,12 +298,12 @@ impl PackageProvider for ModrinthProvider { Ok(raw.into()) } - #[tracing::instrument(level = "debug", skip(self, version_ids, services))] + #[tracing::instrument(level = "debug", skip(self, version_ids, ctx))] async fn get_versions( &self, version_ids: &[String], - services: &LauncherServices, - ) -> LauncherResult> { + ctx: &ContentCtx, + ) -> ContentResult> { if version_ids.is_empty() { return Ok(Vec::new()); } @@ -314,7 +313,7 @@ impl PackageProvider for ModrinthProvider { .collect::>() .join(","); let raw: Vec = fetch_json( - &services.requester, + &ctx.net, Method::GET, &v2(&format!("/versions?ids=[{ids}]")), None, @@ -327,8 +326,8 @@ impl PackageProvider for ModrinthProvider { async fn lookup_versions( &self, identities: &[FileIdentity], - services: &LauncherServices, - ) -> LauncherResult { + ctx: &ContentCtx, + ) -> ContentResult { let sha1_hashes: Vec = identities .iter() .map(|id| id.sha1.clone()) @@ -346,7 +345,7 @@ impl PackageProvider for ModrinthProvider { }); let fetched: HashMap = fetch_json( - &services.requester, + &ctx.net, Method::POST, &v2("/version_files"), Some(body), diff --git a/packages/oneclient_core/src/packages/provider/registry.rs b/packages/oneclient_content/src/packages/provider/registry.rs similarity index 75% rename from packages/oneclient_core/src/packages/provider/registry.rs rename to packages/oneclient_content/src/packages/provider/registry.rs index 0a8b7418..da7b7071 100644 --- a/packages/oneclient_core/src/packages/provider/registry.rs +++ b/packages/oneclient_content/src/packages/provider/registry.rs @@ -3,13 +3,13 @@ use std::sync::Arc; use super::{CurseForgeProvider, ModrinthProvider, PackageProvider}; -use crate::LauncherResult; -use crate::packages::domain::ProviderId; +use crate::error::ContentResult; +use oneclient_common::domain::ProviderId; use crate::packages::error::PackageError; use crate::packages::file_identity::FileIdentity; use crate::packages::store::artifact_absolute_path; use crate::packages::types::{VersionDetail, VersionLookup}; -use crate::state::LauncherServices; +use crate::ctx::ContentCtx; #[derive(Clone)] pub struct PackageProviderRegistry { @@ -30,7 +30,7 @@ impl PackageProviderRegistry { self.providers.insert(provider.id(), provider); } - pub fn get(&self, id: ProviderId) -> LauncherResult<&dyn PackageProvider> { + pub fn get(&self, id: ProviderId) -> ContentResult<&dyn PackageProvider> { self.providers .get(&id) .map(|p| p.as_ref()) @@ -56,21 +56,21 @@ impl PackageProviderRegistry { pub async fn lookup_versions( &self, identities: &[FileIdentity], - services: &LauncherServices, - ) -> LauncherResult { + ctx: &ContentCtx, + ) -> ContentResult { if identities.is_empty() { return Ok(HashMap::new()); } let mut enriched: Vec = identities.to_vec(); for identity in &mut enriched { - enrich_curseforge_fingerprint(identity, services).await?; + enrich_curseforge_fingerprint(identity, ctx).await?; } let mut merged = HashMap::new(); for id in self.remote_ids() { let provider = self.get(id)?; - let found = provider.lookup_versions(&enriched, services).await?; + let found = provider.lookup_versions(&enriched, ctx).await?; for (sha1, version) in found { merged.entry(sha1).or_insert(version); } @@ -78,19 +78,19 @@ impl PackageProviderRegistry { Ok(merged) } - #[tracing::instrument(level = "debug", skip(self, sha1, services))] + #[tracing::instrument(level = "debug", skip(self, sha1, ctx))] pub async fn lookup_version( &self, sha1: impl AsRef, - services: &LauncherServices, - ) -> LauncherResult> { + ctx: &ContentCtx, + ) -> ContentResult> { let mut identity = FileIdentity::from_sha1(sha1); - enrich_curseforge_fingerprint(&mut identity, services).await?; + enrich_curseforge_fingerprint(&mut identity, ctx).await?; for id in self.remote_ids() { let provider = self.get(id)?; let mut found = provider - .lookup_versions(std::slice::from_ref(&identity), services) + .lookup_versions(std::slice::from_ref(&identity), ctx) .await?; if let Some(version) = found.remove(&identity.sha1) { return Ok(Some((id, version))); @@ -99,19 +99,19 @@ impl PackageProviderRegistry { Ok(None) } - #[tracing::instrument(level = "debug", skip(self, services), fields(sha1 = %identity.sha1))] + #[tracing::instrument(level = "debug", skip(self, ctx), fields(sha1 = %identity.sha1))] pub async fn lookup_version_identity( &self, identity: &FileIdentity, - services: &LauncherServices, - ) -> LauncherResult> { + ctx: &ContentCtx, + ) -> ContentResult> { let mut identity = identity.clone(); - enrich_curseforge_fingerprint(&mut identity, services).await?; + enrich_curseforge_fingerprint(&mut identity, ctx).await?; for id in self.remote_ids() { let provider = self.get(id)?; let mut found = provider - .lookup_versions(std::slice::from_ref(&identity), services) + .lookup_versions(std::slice::from_ref(&identity), ctx) .await?; if let Some(version) = found.remove(&identity.sha1) { return Ok(Some((id, version))); @@ -127,17 +127,17 @@ impl Default for PackageProviderRegistry { } } -#[tracing::instrument(level = "debug", skip(identity, services), fields(sha1 = %identity.sha1))] +#[tracing::instrument(level = "debug", skip(identity, ctx), fields(sha1 = %identity.sha1))] async fn enrich_curseforge_fingerprint( identity: &mut FileIdentity, - services: &LauncherServices, -) -> LauncherResult<()> { + ctx: &ContentCtx, +) -> ContentResult<()> { if identity.cf_fingerprint.is_some() { return Ok(()); } let Some(row) = - oneclient_db::dao::artifact::get_artifact_by_hash(&services.db, &identity.sha1).await? + oneclient_db::dao::artifact::get_artifact_by_hash(&ctx.db, &identity.sha1).await? else { return Ok(()); }; diff --git a/packages/oneclient_core/src/packages/store/download.rs b/packages/oneclient_content/src/packages/store/download.rs similarity index 63% rename from packages/oneclient_core/src/packages/store/download.rs rename to packages/oneclient_content/src/packages/store/download.rs index 9c8fcc2b..630c9552 100644 --- a/packages/oneclient_core/src/packages/store/download.rs +++ b/packages/oneclient_content/src/packages/store/download.rs @@ -2,54 +2,42 @@ use oneclient_db::dao::artifact as artifact_dao; use oneclient_db::models::ArtifactRow; use super::paths::{artifact_absolute_path, cache_file_path, relative_cache_path}; -use crate::LauncherResult; -use crate::crypto::{normalize_hash, sha1_file}; -use crate::packages::domain::{ContentType, ProviderId}; -use crate::packages::error::PackageError; -use crate::notification::{GroupedProgressChild, TaskPhase}; -use crate::packages::provider::http::download_url; +use crate::error::ContentResult; +use polyio::{normalize_hash, sha1_file}; +use oneclient_common::domain::{ContentType, ProviderId}; +use oneclient_events::GroupedProgressChild; use crate::packages::types::{ExternalFile, VersionDetail, VersionFile}; -use crate::state::LauncherServices; +use crate::ctx::ContentCtx; -#[tracing::instrument(level = "debug", skip(child, services))] +#[tracing::instrument(level = "debug", skip(child, ctx))] pub async fn ensure_artifact_file( hash: &str, url: &str, dest: &std::path::Path, child: Option<&GroupedProgressChild>, - services: &LauncherServices, -) -> LauncherResult { - polyio::create_dir_all(dest.parent().unwrap_or(dest)).await?; - - if dest.exists() { - let existing = sha1_file(dest).await?; - if normalize_hash(&existing) == normalize_hash(hash) { - return Ok(polyio::stat(dest).await?.len()); - } - polyio::remove_file(dest).await?; + ctx: &ContentCtx, +) -> ContentResult { + if oneclient_net::matches_on_disk(dest, hash).await { + return Ok(polyio::stat(dest).await?.len()); } tracing::debug!("downloading artifact file"); - download_url(&services.requester, url, dest, child, services).await?; - - if let Some(child) = child { - child.set_phase(TaskPhase::Verifying); - } - let actual = sha1_file(dest).await?; - if normalize_hash(&actual) != normalize_hash(hash) { - tracing::warn!(expected = %hash, %actual, "downloaded artifact hash mismatch"); - return Err(PackageError::HashMismatch { - expected: hash.to_string(), - actual, - } - .into()); - } + oneclient_net::download_verified( + &ctx.net, + &ctx.events, + url, + dest, + Some(hash), + 0, + child.cloned(), + ) + .await?; Ok(polyio::stat(dest).await?.len()) } #[allow(clippy::too_many_arguments)] -#[tracing::instrument(level = "debug", skip(version, file, child, services))] +#[tracing::instrument(level = "debug", skip(version, file, child, ctx))] pub async fn download_version_file( provider: ProviderId, project_id: &str, @@ -58,10 +46,10 @@ pub async fn download_version_file( file: &VersionFile, force: bool, child: Option<&GroupedProgressChild>, - services: &LauncherServices, -) -> LauncherResult { + ctx: &ContentCtx, +) -> ContentResult { let hash = normalize_hash(&file.sha1); - if !force && let Some(row) = artifact_dao::get_artifact_by_hash(&services.db, &hash).await? { + if !force && let Some(row) = artifact_dao::get_artifact_by_hash(&ctx.db, &hash).await? { let path = artifact_absolute_path(&row.path)?; if path.exists() { let disk = sha1_file(&path).await?; @@ -79,13 +67,13 @@ pub async fn download_version_file( &file.file_name, )?; - let size = ensure_artifact_file(&hash, &file.url, &dest, child, services).await?; + let size = ensure_artifact_file(&hash, &file.url, &dest, child, ctx).await?; let stored_path = relative_cache_path(&dest)?; let published_at = version.published.to_rfc3339(); let row = artifact_dao::insert_artifact( - &services.db, + &ctx.db, &hash, content_type as i64, &stored_path, @@ -95,7 +83,7 @@ pub async fn download_version_file( .await?; artifact_dao::upsert_provider_release( - &services.db, + &ctx.db, provider as i64, project_id, &version.version_id, @@ -111,15 +99,15 @@ pub async fn download_version_file( Ok(row) } -#[tracing::instrument(level = "debug", skip(file, child, services), fields(name = %file.name))] +#[tracing::instrument(level = "debug", skip(file, child, ctx), fields(name = %file.name))] pub async fn download_external( file: &ExternalFile, force: bool, child: Option<&GroupedProgressChild>, - services: &LauncherServices, -) -> LauncherResult { + ctx: &ContentCtx, +) -> ContentResult { let hash = normalize_hash(&file.sha1); - if !force && let Some(row) = artifact_dao::get_artifact_by_hash(&services.db, &hash).await? { + if !force && let Some(row) = artifact_dao::get_artifact_by_hash(&ctx.db, &hash).await? { let path = artifact_absolute_path(&row.path)?; if path.exists() { return Ok(row); @@ -133,11 +121,11 @@ pub async fn download_external( &hash[..hash.len().min(16)], &file.name, )?; - let size = ensure_artifact_file(&hash, &file.url, &dest, child, services).await?; + let size = ensure_artifact_file(&hash, &file.url, &dest, child, ctx).await?; let stored_path = relative_cache_path(&dest)?; artifact_dao::insert_artifact( - &services.db, + &ctx.db, &hash, file.content_type as i64, &stored_path, diff --git a/packages/oneclient_core/src/packages/store/link.rs b/packages/oneclient_content/src/packages/store/link.rs similarity index 84% rename from packages/oneclient_core/src/packages/store/link.rs rename to packages/oneclient_content/src/packages/store/link.rs index d4beee0d..6f3a84b9 100644 --- a/packages/oneclient_core/src/packages/store/link.rs +++ b/packages/oneclient_content/src/packages/store/link.rs @@ -3,25 +3,25 @@ use std::path::Path; use oneclient_db::models::{ArtifactRow, ClusterRow}; use super::paths::artifact_absolute_path; -use crate::crypto::sha1_file; -use crate::packages::domain::ContentType; +use polyio::sha1_file; +use oneclient_common::domain::ContentType; use crate::packages::error::PackageError; -use crate::paths; -use crate::{LauncherError, LauncherResult}; +use oneclient_common::paths; +use crate::error::{ContentError, ContentResult}; #[tracing::instrument(level = "debug", skip(artifact, cluster))] pub async fn link_artifact_to_cluster( artifact: &ArtifactRow, cluster: &ClusterRow, cluster_file_name: Option<&str>, -) -> LauncherResult<()> { +) -> ContentResult<()> { let src = artifact_absolute_path(&artifact.path)?; if !src.exists() { return Err(PackageError::ArtifactMissing(src.display().to_string()).into()); } let content_type = ContentType::from_repr(artifact.content_type as u8) - .ok_or_else(|| LauncherError::InvalidSettingsProfile { + .ok_or_else(|| ContentError::InvalidData { reason: format!("unknown content type {}", artifact.content_type), })?; @@ -49,7 +49,7 @@ pub async fn link_artifact_to_cluster( } #[tracing::instrument(level = "debug")] -pub async fn link_or_copy(src: &Path, dest: &Path) -> LauncherResult<()> { +pub async fn link_or_copy(src: &Path, dest: &Path) -> ContentResult<()> { if dest.exists() { polyio::remove_file(dest).await?; } @@ -67,7 +67,7 @@ pub async fn unlink_cluster_file( cluster: &ClusterRow, content_type: ContentType, file_name: &str, -) -> LauncherResult<()> { +) -> ContentResult<()> { let path = paths::clusters_dir()? .join(&cluster.folder_name) .join(content_type.folder_name()) diff --git a/packages/oneclient_core/src/packages/store/mod.rs b/packages/oneclient_content/src/packages/store/mod.rs similarity index 69% rename from packages/oneclient_core/src/packages/store/mod.rs rename to packages/oneclient_content/src/packages/store/mod.rs index 6843cfea..2b74c5ef 100644 --- a/packages/oneclient_core/src/packages/store/mod.rs +++ b/packages/oneclient_content/src/packages/store/mod.rs @@ -9,49 +9,49 @@ pub use paths::{artifact_absolute_path, cache_file_path, relative_cache_path}; use oneclient_db::dao::{artifact as artifact_dao, cluster as cluster_dao}; use oneclient_db::models::{ArtifactRow, ClusterRow}; -use super::domain::{ContentType, GameLoader, ProviderId}; +use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; use super::error::PackageError; use super::types::{CachedArtifact, ProjectDetail, ProviderReleaseInfo, VersionDetail, LinkedArtifactInfo}; -use crate::crypto::{normalize_hash, sha1_file}; -use crate::notification::GroupedProgressChild; -use crate::state::LauncherServices; -use crate::{LauncherError, LauncherResult}; +use polyio::{normalize_hash, sha1_file}; +use oneclient_events::GroupedProgressChild; +use crate::ctx::ContentCtx; +use crate::error::{ContentError, ContentResult}; use std::path::Path; pub struct PackageStore; impl PackageStore { - #[tracing::instrument(level = "debug", skip(services))] + #[tracing::instrument(level = "debug", skip(ctx))] pub async fn get_cluster( cluster_id: i64, - services: &LauncherServices, - ) -> LauncherResult { - cluster_dao::get_by_id(&services.db, cluster_id) + ctx: &ContentCtx, + ) -> ContentResult { + cluster_dao::get_by_id(&ctx.db, cluster_id) .await? .ok_or(PackageError::ClusterNotFound(cluster_id).into()) } - #[tracing::instrument(level = "debug", skip(services))] + #[tracing::instrument(level = "debug", skip(ctx))] pub async fn cached_artifact( hash: &str, - services: &LauncherServices, - ) -> LauncherResult> { - let Some(row) = artifact_dao::get_artifact_by_hash(&services.db, hash).await? else { + ctx: &ContentCtx, + ) -> ContentResult> { + let Some(row) = artifact_dao::get_artifact_by_hash(&ctx.db, hash).await? else { return Ok(None); }; - Ok(Some(row_to_cached(row, services).await?)) + Ok(Some(row_to_cached(row, ctx).await?)) } - #[tracing::instrument(level = "debug", skip(project, version, child, services), fields(project_id = %project.id, version_id = %version.version_id))] + #[tracing::instrument(level = "debug", skip(project, version, child, ctx), fields(project_id = %project.id, version_id = %version.version_id))] pub async fn download_and_cache( provider_id: ProviderId, project: &ProjectDetail, version: &VersionDetail, force: bool, child: Option<&GroupedProgressChild>, - services: &LauncherServices, - ) -> LauncherResult { + ctx: &ContentCtx, + ) -> ContentResult { let file = version.primary_file().ok_or(PackageError::NoPrimaryFile)?; download::download_version_file( @@ -62,13 +62,13 @@ impl PackageStore { file, force, child, - services, + ctx, ) .await } #[allow(clippy::too_many_arguments)] - #[tracing::instrument(skip(project, version, child, services), fields(project_id = %project.id, version_id = %version.version_id))] + #[tracing::instrument(skip(project, version, child, ctx), fields(project_id = %project.id, version_id = %version.version_id))] pub async fn install_to_cluster( provider_id: ProviderId, project: &ProjectDetail, @@ -77,10 +77,10 @@ impl PackageStore { skip_compatibility: bool, force_download: bool, child: Option<&GroupedProgressChild>, - services: &LauncherServices, - ) -> LauncherResult { + ctx: &ContentCtx, + ) -> ContentResult { tracing::info!("installing package to cluster"); - let cluster = Self::get_cluster(cluster_id, services).await?; + let cluster = Self::get_cluster(cluster_id, ctx).await?; if !skip_compatibility { ensure_compatible(project, version, &cluster)?; @@ -92,81 +92,63 @@ impl PackageStore { version, force_download, child, - services, + ctx, ) .await?; - Self::link_artifact(&artifact, &cluster, None, services).await?; + Self::link_artifact(&artifact, &cluster, None, ctx).await?; Ok(artifact) } - #[tracing::instrument(level = "debug", skip(artifact, cluster, services))] + #[tracing::instrument(level = "debug", skip(artifact, cluster, ctx))] pub async fn link_artifact( artifact: &ArtifactRow, cluster: &ClusterRow, cluster_file_name: Option<&str>, - services: &LauncherServices, - ) -> LauncherResult<()> { + ctx: &ContentCtx, + ) -> ContentResult<()> { let name = cluster_file_name.unwrap_or(&artifact.file_name); link::link_artifact_to_cluster(artifact, cluster, Some(name)).await?; - artifact_dao::link_cluster_artifact(&services.db, cluster.id, &artifact.hash, name).await?; + artifact_dao::link_cluster_artifact(&ctx.db, cluster.id, &artifact.hash, name).await?; Ok(()) } - #[tracing::instrument(level = "debug", skip(services))] + #[tracing::instrument(level = "debug", skip(ctx))] pub async fn link_by_hash( hash: &str, cluster_id: i64, cluster_file_name: Option<&str>, - services: &LauncherServices, - ) -> LauncherResult<()> { - let artifact = artifact_dao::get_artifact_by_hash(&services.db, hash) + ctx: &ContentCtx, + ) -> ContentResult<()> { + let artifact = artifact_dao::get_artifact_by_hash(&ctx.db, hash) .await? .ok_or(PackageError::ArtifactMissing(hash.to_string()))?; - let cluster = Self::get_cluster(cluster_id, services).await?; + let cluster = Self::get_cluster(cluster_id, ctx).await?; - Self::link_artifact(&artifact, &cluster, cluster_file_name, services).await + Self::link_artifact(&artifact, &cluster, cluster_file_name, ctx).await } - #[tracing::instrument(level = "debug", skip(services))] - pub async fn unlink_from_cluster( - hash: &str, - cluster_id: i64, - services: &LauncherServices, - ) -> LauncherResult<()> { - crate::bundles::remove_artifact_from_cluster(cluster_id, hash, true, services).await - } - - #[tracing::instrument(level = "debug", skip(services))] - pub async fn unlink_from_cluster_system( - hash: &str, - cluster_id: i64, - services: &LauncherServices, - ) -> LauncherResult<()> { - crate::bundles::remove_artifact_from_cluster(cluster_id, hash, false, services).await - } - - #[tracing::instrument(level = "debug", skip(services))] + #[tracing::instrument(level = "debug", skip(ctx))] pub async fn list_linked_artifacts( cluster_id: i64, - services: &LauncherServices, - ) -> LauncherResult> { - let links = artifact_dao::list_cluster_artifacts(&services.db, cluster_id).await?; + ctx: &ContentCtx, + ) -> ContentResult> { + let links = artifact_dao::list_cluster_artifacts(&ctx.db, cluster_id).await?; let mut items = Vec::with_capacity(links.len()); for link in links { let Some(artifact) = - artifact_dao::get_artifact_by_hash(&services.db, &link.hash).await? + artifact_dao::get_artifact_by_hash(&ctx.db, &link.hash).await? else { continue; }; let content_type = ContentType::from_repr(artifact.content_type as u8) .unwrap_or(ContentType::Mod); - let release = artifact_dao::get_release_by_hash(&services.db, &link.hash).await?; + let release = artifact_dao::get_release_by_hash(&ctx.db, &link.hash).await?; items.push(LinkedArtifactInfo { hash: link.hash, @@ -186,23 +168,30 @@ impl PackageStore { Ok(items) } - #[tracing::instrument(level = "debug", skip(services))] - pub async fn toggle_artifact_enabled( + #[tracing::instrument(level = "debug", skip(ctx))] + /// Flips an artifact's enabled flag and relinks it on disk, returning the + /// new state. + /// + /// 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 + /// modules mutually recursive. + pub async fn set_artifact_enabled( cluster_id: i64, hash: &str, - services: &LauncherServices, - ) -> LauncherResult { - let cluster = Self::get_cluster(cluster_id, services).await?; - let artifact = artifact_dao::get_artifact_by_hash(&services.db, hash) + ctx: &ContentCtx, + ) -> ContentResult { + let cluster = Self::get_cluster(cluster_id, ctx).await?; + let artifact = artifact_dao::get_artifact_by_hash(&ctx.db, hash) .await? .ok_or(PackageError::ArtifactMissing(hash.to_string()))?; - let link = artifact_dao::get_cluster_artifact(&services.db, cluster_id, hash) + let link = artifact_dao::get_cluster_artifact(&ctx.db, cluster_id, hash) .await? .ok_or(PackageError::ArtifactMissing(hash.to_string()))?; let content_type = ContentType::from_repr(artifact.content_type as u8) - .ok_or_else(|| LauncherError::InvalidSettingsProfile { + .ok_or_else(|| ContentError::InvalidData { reason: format!("unknown content type {}", artifact.content_type), })?; @@ -222,7 +211,7 @@ impl PackageStore { } artifact_dao::update_cluster_artifact( - &services.db, + &ctx.db, cluster_id, hash, &file_name, @@ -230,22 +219,16 @@ impl PackageStore { ) .await?; - if enabled { - crate::bundles::on_user_enable_artifact(cluster_id, hash, services).await?; - } else { - crate::bundles::on_user_disable_artifact(cluster_id, hash, services).await?; - } - Ok(enabled) } - #[tracing::instrument(level = "debug", skip(services))] + #[tracing::instrument(level = "debug", skip(ctx))] pub async fn import_local_file( path: &Path, content_type: ContentType, cluster_id: i64, - services: &LauncherServices, - ) -> LauncherResult { + ctx: &ContentCtx, + ) -> ContentResult { let file_name = path .file_name() .and_then(|name| name.to_str()) @@ -254,9 +237,9 @@ impl PackageStore { let hash = normalize_hash(&sha1_file(path).await?); - if let Some(row) = artifact_dao::get_artifact_by_hash(&services.db, &hash).await? { - let cluster = Self::get_cluster(cluster_id, services).await?; - Self::link_artifact(&row, &cluster, None, services).await?; + if let Some(row) = artifact_dao::get_artifact_by_hash(&ctx.db, &hash).await? { + let cluster = Self::get_cluster(cluster_id, ctx).await?; + Self::link_artifact(&row, &cluster, None, ctx).await?; return Ok(row); } @@ -276,7 +259,7 @@ impl PackageStore { let stored_path = relative_cache_path(&dest)?; let row = artifact_dao::insert_artifact( - &services.db, + &ctx.db, &hash, content_type as i64, &stored_path, @@ -285,37 +268,37 @@ impl PackageStore { ) .await?; - let cluster = Self::get_cluster(cluster_id, services).await?; - Self::link_artifact(&row, &cluster, None, services).await?; + let cluster = Self::get_cluster(cluster_id, ctx).await?; + Self::link_artifact(&row, &cluster, None, ctx).await?; Ok(row) } - #[tracing::instrument(level = "debug", skip(services))] + #[tracing::instrument(level = "debug", skip(ctx))] pub async fn resolve_or_download( provider_id: ProviderId, project_id: &str, version_id: &str, - services: &LauncherServices, - ) -> LauncherResult { - let provider = services.packages.get(provider_id)?; + ctx: &ContentCtx, + ) -> ContentResult { + let provider = ctx.providers.get(provider_id)?; let version = provider - .get_version(project_id, version_id, services) + .get_version(project_id, version_id, ctx) .await?; - let project = provider.get_project(project_id, services).await?; + let project = provider.get_project(project_id, ctx).await?; - Self::download_and_cache(provider_id, &project, &version, false, None, services).await + Self::download_and_cache(provider_id, &project, &version, false, None, ctx).await } } -#[tracing::instrument(level = "debug", skip(row, services), fields(hash = %row.hash))] +#[tracing::instrument(level = "debug", skip(row, ctx), fields(hash = %row.hash))] async fn row_to_cached( row: ArtifactRow, - services: &LauncherServices, -) -> LauncherResult { + ctx: &ContentCtx, +) -> ContentResult { let path = artifact_absolute_path(&row.path)?; - let release = artifact_dao::get_release_by_hash(&services.db, &row.hash) + let release = artifact_dao::get_release_by_hash(&ctx.db, &row.hash) .await? .map(|r| ProviderReleaseInfo { provider: ProviderId::from_repr(r.provider as u8).unwrap_or(ProviderId::Local), @@ -341,7 +324,7 @@ fn ensure_compatible( project: &ProjectDetail, version: &VersionDetail, cluster: &ClusterRow, -) -> LauncherResult<()> { +) -> ContentResult<()> { if project.provider == ProviderId::Local { return Ok(()); } @@ -455,7 +438,7 @@ mod tests { assert!(matches!( result, - Err(LauncherError::PackageError(PackageError::IncompatibleLoader)) + Err(ContentError::Package(PackageError::IncompatibleLoader)) )); } @@ -469,7 +452,7 @@ mod tests { assert!(matches!( result, - Err(LauncherError::PackageError(PackageError::IncompatibleMcVersion)) + Err(ContentError::Package(PackageError::IncompatibleMcVersion)) )); } } diff --git a/packages/oneclient_core/src/packages/store/paths.rs b/packages/oneclient_content/src/packages/store/paths.rs similarity index 66% rename from packages/oneclient_core/src/packages/store/paths.rs rename to packages/oneclient_content/src/packages/store/paths.rs index 3153fc7b..a6ae50a2 100644 --- a/packages/oneclient_core/src/packages/store/paths.rs +++ b/packages/oneclient_content/src/packages/store/paths.rs @@ -1,8 +1,8 @@ use std::path::{Path, PathBuf}; -use crate::LauncherResult; -use crate::packages::domain::{ContentType, ProviderId}; -use crate::paths; +use crate::error::ContentResult; +use oneclient_common::domain::{ContentType, ProviderId}; +use oneclient_common::paths; pub fn cache_file_path( content_type: ContentType, @@ -10,16 +10,16 @@ pub fn cache_file_path( project_id: &str, version_id: &str, file_name: &str, -) -> LauncherResult { +) -> ContentResult { Ok(paths::package_version_dir(content_type, provider, project_id, version_id)?.join(file_name)) } -pub fn artifact_absolute_path(stored_path: &str) -> LauncherResult { +pub fn artifact_absolute_path(stored_path: &str) -> ContentResult { let root = paths::launcher_dir()?; Ok(root.join(stored_path)) } -pub fn relative_cache_path(abs: &Path) -> LauncherResult { +pub fn relative_cache_path(abs: &Path) -> ContentResult { let root = paths::launcher_dir()?; let rel = abs diff --git a/packages/oneclient_core/src/packages/types.rs b/packages/oneclient_content/src/packages/types.rs similarity index 98% rename from packages/oneclient_core/src/packages/types.rs rename to packages/oneclient_content/src/packages/types.rs index 1804a180..941a0df2 100644 --- a/packages/oneclient_core/src/packages/types.rs +++ b/packages/oneclient_content/src/packages/types.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use super::domain::{ContentType, GameLoader, ProviderId}; +use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; pub const DEFAULT_PAGE_SIZE: usize = 24; diff --git a/packages/oneclient_core/tests/file_identity.rs b/packages/oneclient_content/tests/file_identity.rs similarity index 93% rename from packages/oneclient_core/tests/file_identity.rs rename to packages/oneclient_content/tests/file_identity.rs index 0c6243cf..6c8aadd0 100644 --- a/packages/oneclient_core/tests/file_identity.rs +++ b/packages/oneclient_content/tests/file_identity.rs @@ -1,4 +1,4 @@ -use oneclient_core::packages::{curseforge_fingerprint, FileIdentity}; +use oneclient_content::packages::{curseforge_fingerprint, FileIdentity}; #[test] fn curseforge_fingerprint_ignores_padding_whitespace() { diff --git a/packages/oneclient_core/Cargo.toml b/packages/oneclient_core/Cargo.toml index c246a8d2..5b1417ab 100644 --- a/packages/oneclient_core/Cargo.toml +++ b/packages/oneclient_core/Cargo.toml @@ -16,14 +16,6 @@ path = "src/lib.rs" [features] default = [] -[[example]] -name = "java_list_providers" -path = "examples/java_list_providers.rs" - -[[example]] -name = "java_install_provider" -path = "examples/java_install_provider.rs" - [[example]] name = "package_search" path = "examples/package_search.rs" @@ -57,17 +49,27 @@ name = "notifications" path = "examples/notifications.rs" [[example]] -name = "discord_rpc" -path = "examples/discord_rpc.rs" +name = "detect_v1" +path = "examples/detect_v1.rs" [dev-dependencies] async-tungstenite = { version = "=0.32.1", features = ["tokio-runtime"] } +polyio = { workspace = true, features = ["testing"] } [dependencies] # core (extra) -# gamemode_rs.workspace = true discord-rich-presence.workspace = true interfrost.workspace = true +oneclient_auth.workspace = true +oneclient_cluster.workspace = true +oneclient_common.workspace = true +oneclient_content.workspace = true +oneclient_discord.workspace = true +oneclient_events.workspace = true +oneclient_java.workspace = true +oneclient_mc.workspace = true +oneclient_polyplus.workspace = true +oneclient_net.workspace = true oneclient_db.workspace = true polyio.workspace = true diff --git a/packages/oneclient_core/examples/auth_msa.rs b/packages/oneclient_core/examples/auth_msa.rs index 3f97b19b..d7915f6b 100644 --- a/packages/oneclient_core/examples/auth_msa.rs +++ b/packages/oneclient_core/examples/auth_msa.rs @@ -1,15 +1,14 @@ use std::process::Command; -use oneclient_core::auth::{self}; use oneclient_core::{dev, logger, LauncherResult}; #[tokio::main] async fn main() -> LauncherResult<()> { logger::init_debug()?; - dev::initialize().await?; + let state = dev::initialize().await?; - let session = auth::begin_microsoft_login().await?; + let session = state.auth.begin_microsoft_login().await?; println!("Sign in with EITHER method:\n"); println!(" Browser: opening {}", session.browser.auth_url); @@ -20,13 +19,13 @@ async fn main() -> LauncherResult<()> { ); println!("Waiting for you to sign in..."); - let account = auth::finish_microsoft_login(session).await?; + let account = state.auth.finish_microsoft_login(session).await?; println!( "Signed in as {} ({})\n token expires: {}", account.username, account.id, account.expires ); - println!("Account saved to {}", oneclient_core::paths::auth_file()?.display()); + println!("Account saved to {}", oneclient_common::paths::auth_file()?.display()); Ok(()) } diff --git a/packages/oneclient_core/examples/bundles_sync.rs b/packages/oneclient_core/examples/bundles_sync.rs index 2e6e20a2..eccb554d 100644 --- a/packages/oneclient_core/examples/bundles_sync.rs +++ b/packages/oneclient_core/examples/bundles_sync.rs @@ -1,6 +1,6 @@ use oneclient_core::dev; -use oneclient_core::packages::domain::GameLoader; +use oneclient_common::domain::GameLoader; #[tokio::main] async fn main() -> Result<(), Box> { @@ -8,11 +8,11 @@ async fn main() -> Result<(), Box> { let state = dev::ephemeral_state().await?; - state.bundles.sync(&state.services).await?; + state.bundles.sync(&state.services.content()).await?; let bundles = state .bundles - .list_for(&state.services, "1.21.11", GameLoader::Fabric) + .list_for(&state.services.content(), "1.21.11", GameLoader::Fabric) .await?; println!("Visible Fabric bundles for 1.21.11: {}", bundles.len()); diff --git a/packages/oneclient_core/examples/cluster_manage.rs b/packages/oneclient_core/examples/cluster_manage.rs index 8ba141a2..53790711 100644 --- a/packages/oneclient_core/examples/cluster_manage.rs +++ b/packages/oneclient_core/examples/cluster_manage.rs @@ -1,18 +1,19 @@ -use oneclient_core::clusters::{ClusterManager, CreateClusterOptions}; +use oneclient_core::clusters::CreateClusterOptions; use oneclient_core::dev; -use oneclient_core::packages::domain::GameLoader; -use oneclient_core::settings::store::list_named_profiles; +use oneclient_common::domain::GameLoader; +use oneclient_cluster::profiles::list_named_profiles; #[tokio::main] async fn main() -> Result<(), Box> { tracing_subscriber::fmt::init(); let state = dev::ephemeral_state().await?; + let global = state.settings.read().global_game_settings.clone(); - let cluster = ClusterManager::create( - &state, - CreateClusterOptions::new("Example 1.21.1 Fabric", "1.21.1", GameLoader::Fabric).mem_max(3072), + let cluster = state.clusters.create( + &global, + CreateClusterOptions::new("Example 1.21.1 Fabric", "1.21.1", GameLoader::Fabric).mem_max(3072), ) .await?; @@ -20,13 +21,13 @@ async fn main() -> Result<(), Box> { println!(" setting profile: {:?}", cluster.setting_profile_name); println!(" created at: {:?}", cluster.created_at); - let profile = ClusterManager::resolve_settings(&state, &cluster).await?; + let profile = state.clusters.resolve_settings(&global, &cluster).await?; println!( " resolved mem_max: {:?}, force_fullscreen: {:?}", profile.mem_max, profile.force_fullscreen ); - for c in ClusterManager::list(&state).await? { + for c in state.clusters.list().await? { println!(" - {} ({}, {})", c.name, c.mc_version, c.mc_loader); } @@ -34,7 +35,7 @@ async fn main() -> Result<(), Box> { println!("Profile {} (mem_max: {:?})", p.name, p.mem_max); } - ClusterManager::delete(&state, cluster.id, true).await?; + state.clusters.delete(cluster.id, true).await?; println!("Deleted cluster #{}", cluster.id); Ok(()) diff --git a/packages/oneclient_core/examples/download_game.rs b/packages/oneclient_core/examples/download_game.rs index a2738fdc..045ec845 100644 --- a/packages/oneclient_core/examples/download_game.rs +++ b/packages/oneclient_core/examples/download_game.rs @@ -1,7 +1,7 @@ #![recursion_limit = "256"] -use oneclient_core::clusters::{ClusterManager, CreateClusterOptions}; +use oneclient_core::clusters::CreateClusterOptions; use oneclient_core::dev; -use oneclient_core::packages::domain::GameLoader; +use oneclient_common::domain::GameLoader; use oneclient_core::LauncherResult; #[tokio::main] @@ -17,8 +17,10 @@ async fn main() -> LauncherResult<()> { .unwrap_or(GameLoader::Vanilla); let loader_version = args.get(2).map(String::as_str); - let cluster = ClusterManager::create( - &state, + let global = state.settings.read().global_game_settings.clone(); + + let cluster = state.clusters.create( + &global, CreateClusterOptions { name: format!("download-{mc_version}"), mc_version: mc_version.to_string(), @@ -34,7 +36,7 @@ async fn main() -> LauncherResult<()> { cluster.name, cluster.mc_version, cluster.mc_loader ); - let ready = ClusterManager::prepare(&state, cluster.id, false, true, true, None).await?; + let ready = oneclient_core::clusters::prepare_cluster_locked(&state, cluster.id, false, true, true, None).await?; println!("Cluster ready at {}", ready.dir()?.display()); Ok(()) diff --git a/packages/oneclient_core/examples/java_install_provider.rs b/packages/oneclient_core/examples/java_install_provider.rs deleted file mode 100644 index e68a0366..00000000 --- a/packages/oneclient_core/examples/java_install_provider.rs +++ /dev/null @@ -1,64 +0,0 @@ - -use std::env; - -use oneclient_core::dev; -use oneclient_core::java::check_java_runtime; -use oneclient_core::java::vendors::AdoptiumRuntimeProvider; -use oneclient_core::java::vendors::CorrettoRuntimeProvider; -use oneclient_core::java::vendors::JavaRuntimeProvider; -use oneclient_core::java::vendors::LibericaRuntimeProvider; -use oneclient_core::java::vendors::ZuluRuntimeProvider; -use oneclient_core::logger; -use oneclient_core::LauncherResult; -use oneclient_core::paths::java_dir; - -#[tokio::main] -async fn main() -> LauncherResult<()> { - logger::init_debug()?; - - let mut args = env::args().skip(1); - let vendor = args - .next() - .unwrap_or_else(|| { - eprintln!("usage: java_install_provider [major]"); - std::process::exit(1); - }); - - let major: u32 = args - .next() - .unwrap_or_else(|| "21".to_string()) - .parse() - .expect("major version must be a number"); - - let services = dev::ephemeral_services().await?; - - let provider: Box = match vendor.as_str() { - "zulu" => Box::new(ZuluRuntimeProvider), - "adoptium" => Box::new(AdoptiumRuntimeProvider), - "corretto" => Box::new(CorrettoRuntimeProvider), - "liberica" => Box::new(LibericaRuntimeProvider), - other => { - eprintln!("unknown vendor '{other}', use zulu, adoptium, corretto, or liberica"); - std::process::exit(1); - } - }; - - let packages = provider.list_packages(Some(major), &services).await?; - let package = packages - .into_iter() - .find(|p| p.java_version.contains(&major)) - .unwrap_or_else(|| panic!("no {vendor} package for Java {major}")); - - println!("Installing package {:#?} to '{:?}'", package, java_dir()?); - - let executable = provider.install_package(&package, &services, None).await?; - - println!("Installed: {}", executable.display()); - - let info = check_java_runtime(executable.display().to_string()).await?; - println!(" version: {}", info.version); - println!(" vendor: {}", info.vendor); - println!(" arch: {}", info.os_arch); - - Ok(()) -} diff --git a/packages/oneclient_core/examples/launch_game.rs b/packages/oneclient_core/examples/launch_game.rs index 989f869b..3da7091f 100644 --- a/packages/oneclient_core/examples/launch_game.rs +++ b/packages/oneclient_core/examples/launch_game.rs @@ -1,10 +1,9 @@ #![recursion_limit = "256"] use oneclient_core::LauncherResult; -use oneclient_core::auth; -use oneclient_core::clusters::{ClusterManager, CreateClusterOptions}; +use oneclient_core::clusters::CreateClusterOptions; use oneclient_core::dev; -use oneclient_core::packages::domain::GameLoader; +use oneclient_common::domain::GameLoader; #[tokio::main] async fn main() -> LauncherResult<()> { @@ -18,11 +17,13 @@ async fn main() -> LauncherResult<()> { .and_then(|s| s.parse().ok()) .unwrap_or(GameLoader::Vanilla); - let account = auth::add_offline_account("Example".to_string()).await?; + let account = state.auth.add_offline_account("Example".to_string()).await?; println!("Using offline account {} ({})", account.username, account.id); - let cluster = ClusterManager::create( - &state, + let global = state.settings.read().global_game_settings.clone(); + + let cluster = state.clusters.create( + &global, CreateClusterOptions::new(format!("launch-{mc_version}"), mc_version, loader), ) .await?; diff --git a/packages/oneclient_core/examples/notifications.rs b/packages/oneclient_core/examples/notifications.rs index 23f3fb6c..e7c6da98 100644 --- a/packages/oneclient_core/examples/notifications.rs +++ b/packages/oneclient_core/examples/notifications.rs @@ -1,25 +1,25 @@ -use oneclient_core::notification::{GroupedProgressSession, NotificationService}; +use oneclient_events::{GroupedProgressSession, EventBus}; use tokio::sync::mpsc; use uuid::Uuid; #[tokio::main] async fn main() { let (tx, mut rx) = mpsc::unbounded_channel(); - let notifier = NotificationService::new(tx); + let events = EventBus::new(tx); - notifier.send_info("Welcome", "Launcher started"); - notifier.send_error("Heads up", "Example error notification"); + events.notify("Welcome").body("Launcher started").send(); + events.notify("Heads up").body("Example error notification").error().send(); let download = Uuid::new_v4(); for current in (0..=100).step_by(25) { - notifier.send_progress(&download, "Downloading assets", current, 100); + events.progress(download, "Downloading assets", current, 100); } - let session = GroupedProgressSession::start(¬ifier, "Preparing cluster".to_string()); + let session = GroupedProgressSession::start(&events, "Preparing cluster".to_string()); session.finish(); - drop(notifier); + drop(events); while let Some(notification) = rx.recv().await { println!("{notification:?}"); diff --git a/packages/oneclient_core/examples/package_project.rs b/packages/oneclient_core/examples/package_project.rs index 24f19dc4..eb4f9662 100644 --- a/packages/oneclient_core/examples/package_project.rs +++ b/packages/oneclient_core/examples/package_project.rs @@ -1,7 +1,7 @@ use std::env; -use oneclient_core::packages::domain::ProviderId; +use oneclient_common::domain::ProviderId; use oneclient_core::{dev, logger, LauncherResult}; #[tokio::main] @@ -14,8 +14,8 @@ async fn main() -> LauncherResult<()> { let mc_version = args.next(); let provider_id = parse_provider(&provider_name)?; - let env = dev::ephemeral_services().await?; - let provider = env.packages.get(provider_id)?; + let env = dev::ephemeral_services().await?.content(); + let provider = env.providers.get(provider_id)?; let project = provider.get_project(&project_ref, &env).await?; diff --git a/packages/oneclient_core/examples/package_search.rs b/packages/oneclient_core/examples/package_search.rs index f201040f..7a326a06 100644 --- a/packages/oneclient_core/examples/package_search.rs +++ b/packages/oneclient_core/examples/package_search.rs @@ -1,8 +1,8 @@ use std::env; -use oneclient_core::packages::domain::{ContentType, ProviderId}; -use oneclient_core::packages::types::SearchFilters; +use oneclient_common::domain::{ContentType, ProviderId}; +use oneclient_content::packages::types::SearchFilters; use oneclient_core::{LauncherResult, dev, logger}; #[tokio::main] @@ -14,8 +14,8 @@ async fn main() -> LauncherResult<()> { let query = args.next().unwrap_or_else(|| "sodium".into()); let provider_id = parse_provider(&provider_name)?; - let env = dev::ephemeral_services().await?; - let provider = env.packages.get(provider_id)?; + let env = dev::ephemeral_services().await?.content(); + let provider = env.providers.get(provider_id)?; let page = provider .search( diff --git a/packages/oneclient_core/src/api_config.rs b/packages/oneclient_core/src/api_config.rs deleted file mode 100644 index 2a0b97f0..00000000 --- a/packages/oneclient_core/src/api_config.rs +++ /dev/null @@ -1,44 +0,0 @@ -use crate::constants; -use crate::settings::LauncherSettings; -use crate::state::LauncherState; - -fn setting(f: impl FnOnce(&LauncherSettings) -> Option) -> Option { - let state = LauncherState::get().ok()?; - let guard = state.settings.read(); - - f(&guard) -} - -fn non_empty(value: Option) -> Option { - value - .map(|v| v.trim().to_string()) - .filter(|v| !v.is_empty()) -} - -pub fn curseforge_api_key() -> String { - non_empty(setting(|s| s.curseforge_api_key.clone())) - .unwrap_or_else(|| constants::CURSEFORGE_API_KEY.to_string()) -} - -pub fn modrinth_api_key() -> Option { - non_empty(setting(|s| s.modrinth_api_key.clone())) -} - -pub fn metadata_api_url() -> String { - non_empty(setting(|s| s.custom_api_endpoint.clone())) - .unwrap_or_else(|| constants::METADATA_API_URL.to_string()) -} - -pub fn meta_url_base() -> String { - non_empty(setting(|s| s.custom_meta_url_base.clone())) - .unwrap_or_else(|| constants::META_URL_BASE.to_string()) - .trim_end_matches('/') - .to_string() -} - -pub fn modrinth_headers() -> Vec<(String, String)> { - match modrinth_api_key() { - Some(token) => vec![("Authorization".to_string(), token)], - None => Vec::new(), - } -} diff --git a/packages/oneclient_core/src/auth/mod.rs b/packages/oneclient_core/src/auth/mod.rs deleted file mode 100644 index 36c1ecf3..00000000 --- a/packages/oneclient_core/src/auth/mod.rs +++ /dev/null @@ -1,272 +0,0 @@ -mod error; -mod msa; -mod offline; -mod store; -mod data; -mod diagnostics; - -pub use error::{AuthError, MinecraftAuthError, MinecraftAuthStep}; -pub use diagnostics::{ - diagnose_auth_error, preview_samples, AuthErrorGuidance, AuthErrorSample, -}; -pub use offline::{offline_account, offline_uuid, validate_offline_username}; -pub use store::CredentialsStore; -pub use data::{ - AccountKind, BrowserLogin, DeviceCodeLogin, MicrosoftLoginSession, MinecraftAccount, -}; -pub use msa::PendingBrowserLogin; - -use std::collections::HashMap; -use std::sync::{Arc, LazyLock, Mutex as StdMutex}; - -use tokio::sync::Mutex; -use uuid::Uuid; - -use crate::LauncherResult; -use crate::notification::MicrosoftLoginStatus; -use crate::state::LauncherState; - -#[tracing::instrument(skip_all)] -pub async fn begin_microsoft_login() -> LauncherResult { - tracing::info!("beginning Microsoft login"); - let state = LauncherState::get()?; - let client = state.services.requester.http(); - - let (browser, pending) = msa::begin_browser_login().await.map_err(AuthError::from)?; - let device = msa::begin_device_login(client).await.map_err(AuthError::from)?; - - state - .microsoft_logins - .lock() - .await - .insert(browser.state.clone(), pending); - - Ok(MicrosoftLoginSession { browser, device }) -} - -#[tracing::instrument(skip_all)] -pub async fn finish_microsoft_login( - session: MicrosoftLoginSession, -) -> LauncherResult { - tracing::info!("finishing Microsoft login"); - let state = LauncherState::get()?; - - let pending = state - .microsoft_logins - .lock() - .await - .remove(&session.browser.state) - .ok_or(AuthError::Minecraft( - MinecraftAuthError::BrowserLoginNotFound, - ))?; - - let client = state.services.requester.http(); - let notifier = state.services.notifier.clone(); - - let result = msa::finish_dual_login( - client, - pending, - &session.device, - |label, current, total| { - notifier.microsoft_login_status(Some(MicrosoftLoginStatus { - label: label.to_string(), - current, - total, - })); - }, - ) - .await - .map_err(AuthError::from); - - state.services.notifier.microsoft_login_status(None); - - if let Err(err) = &result { - tracing::warn!("Microsoft login failed: {err}"); - let mut chain = String::new(); - let mut source = std::error::Error::source(err); - while let Some(cause) = source { - chain.push_str(&format!("\n caused by: {cause}")); - source = cause.source(); - } - tracing::warn!("Microsoft login failed: {err}{chain}"); - } - - let account = result?; - tracing::info!(username = %account.username, "Microsoft login succeeded"); - let mut auth = state.auth.lock().await; - auth.commit_account(account, &state.services).await -} - -#[tracing::instrument(level = "debug", skip_all)] -pub async fn cancel_microsoft_login(state_token: &str) { - if let Ok(state) = LauncherState::get() { - state.microsoft_logins.lock().await.remove(state_token); - } -} - -#[tracing::instrument(skip_all, fields(username = %username))] -pub async fn add_offline_account(username: String) -> LauncherResult { - let state = LauncherState::get()?; - let mut auth = state.auth.lock().await; - auth.add_offline_account_and_save(username).await -} - -pub async fn list_accounts() -> LauncherResult> { - let state = LauncherState::get()?; - let auth = state.auth.lock().await; - Ok(auth.list_accounts()) -} - -pub async fn get_account(id: Uuid) -> LauncherResult> { - let state = LauncherState::get()?; - let auth = state.auth.lock().await; - Ok(auth.get_account(id).cloned()) -} - -#[tracing::instrument(level = "debug", skip_all)] -pub async fn get_default_account() -> LauncherResult> { - let state = LauncherState::get()?; - let mut auth = state.auth.lock().await; - auth.default_account().await -} - -#[tracing::instrument(level = "debug", skip_all, fields(?id))] -pub async fn set_default_account(id: Option) -> LauncherResult<()> { - let state = LauncherState::get()?; - let mut auth = state.auth.lock().await; - auth.set_default_user(id).await -} - -#[tracing::instrument(skip_all, fields(%id))] -pub async fn remove_account(id: Uuid) -> LauncherResult<()> { - let state = LauncherState::get()?; - let mut auth = state.auth.lock().await; - auth.remove_account(id).await?; - Ok(()) -} - -/// Serialises token renewal per account. -/// -/// Microsoft rotates the refresh token on every use, so two concurrent renewals -/// of one account would race: the loser spends an already-consumed refresh token -/// and the account gets signed out. Clusters can launch in parallel, so this is -/// reachable — the guard makes the second caller wait and reuse the first -/// caller's result instead of starting its own chain. -static REFRESH_GUARDS: LazyLock>>>> = - LazyLock::new(|| StdMutex::new(HashMap::new())); - -fn refresh_guard(id: Uuid) -> Arc> { - Arc::clone( - REFRESH_GUARDS - .lock() - .expect("refresh guard registry poisoned") - .entry(id) - .or_default(), - ) -} - -/// Clones an account out of the store, returning an owned value so no caller -/// keeps the credentials-store lock alive past this call. -async fn account_snapshot( - state: &Arc, - id: Uuid, -) -> LauncherResult { - let auth = state.auth.lock().await; - auth.get_account(id) - .cloned() - .ok_or_else(|| AuthError::AccountNotFound(id).into()) -} - -/// Renews `id`'s access token if it has lapsed, returning a usable account. -/// -/// The credentials-store lock is deliberately not held across the Microsoft -/// handshake, only around the reads and the final write. Holding it across -/// those six sequential round trips is what used to stall every other account -/// read behind a multi-second network call. -#[tracing::instrument(level = "debug", skip(state), fields(%id))] -async fn renew_token( - state: &Arc, - id: Uuid, - force: bool, -) -> LauncherResult { - let existing = account_snapshot(state, id).await?; - if !existing.is_microsoft() || (!force && !existing.is_expired()) { - return Ok(existing); - } - - let guard = refresh_guard(id); - let _serialised = guard.lock().await; - - let existing = account_snapshot(state, id).await?; - if !existing.is_microsoft() || (!force && !existing.is_expired()) { - return Ok(existing); - } - - tracing::info!(username = %existing.username, "renewing Microsoft access token"); - match msa::refresh_microsoft_account(state.services.requester.http(), &existing).await { - Ok(refreshed) => { - state - .auth - .lock() - .await - .commit_refreshed_account(refreshed.clone()) - .await?; - Ok(refreshed) - } - Err(err) => { - let err = crate::LauncherError::from(AuthError::from(err)); - if store::is_transient_auth_error(&err) { - tracing::warn!("keeping existing token after transient renewal failure: {err}"); - Ok(existing) - } else { - Err(err) - } - } - } -} - -#[tracing::instrument(level = "debug", skip_all, fields(%id))] -pub async fn refresh_account(id: Uuid) -> LauncherResult { - let state = LauncherState::get()?; - renew_token(&state, id, true).await -} - -#[tracing::instrument(level = "debug", skip_all)] -pub async fn refresh_all_accounts() -> LauncherResult> { - let state = LauncherState::get()?; - let ids: Vec = state.auth.lock().await.users.keys().copied().collect(); - let mut refreshed = Vec::with_capacity(ids.len()); - - for id in ids { - refreshed.push(renew_token(&state, id, true).await?); - } - - Ok(refreshed) -} - -#[tracing::instrument(level = "debug", skip_all, fields(%id))] -pub async fn account_for_launch(id: Uuid) -> LauncherResult { - let state = LauncherState::get()?; - let account = renew_token(&state, id, false).await?; - - if account.is_offline() && !state.auth.lock().await.has_microsoft_account() { - return Err(AuthError::OfflineRequiresMicrosoft.into()); - } - - Ok(account) -} - -#[tracing::instrument(level = "debug", skip_all)] -pub async fn default_account_for_launch() -> LauncherResult> { - let state = LauncherState::get()?; - let Some(id) = state.auth.lock().await.resolve_default_id().await? else { - return Ok(None); - }; - Ok(Some(account_for_launch(id).await?)) -} - -pub async fn has_microsoft_account() -> LauncherResult { - let state = LauncherState::get()?; - let auth = state.auth.lock().await; - Ok(auth.has_microsoft_account()) -} diff --git a/packages/oneclient_core/src/bundles/manager.rs b/packages/oneclient_core/src/bundles/manager.rs deleted file mode 100644 index 70bbdfc8..00000000 --- a/packages/oneclient_core/src/bundles/manager.rs +++ /dev/null @@ -1,428 +0,0 @@ -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::str::FromStr; - -use chrono::Utc; -use oneclient_db::dao::bundle as bundle_dao; -use oneclient_db::models::{BundleRow, NewBundle}; -use reqwest::Method; -use reqwest::header; -use reqwest::StatusCode; -use tokio::sync::RwLock; - -use crate::bundles::error::BundleError; -use crate::bundles::manifest::{BundleManifest as RemoteBundleManifest, RemoteBundleRef}; -use crate::bundles::types::BundleArchive; -use crate::bundles::polymrpack; -use crate::api_config::meta_url_base; -use crate::crypto; -use crate::http::{RequestError, ResponseExt, ResponseOptions}; -use crate::packages::domain::GameLoader; -use crate::paths; -use crate::state::LauncherServices; -use crate::{LauncherError, LauncherResult}; - -#[derive(Debug, Clone)] -pub struct Bundle { - pub remote_path: String, - pub mc_version: String, - pub loader: GameLoader, - pub file_name: String, - pub name: String, - pub version_id: String, - pub category: String, - pub loader_version: String, - pub path: PathBuf, - pub hidden: bool, -} - -impl Bundle { - fn try_from_row(row: BundleRow) -> LauncherResult { - let loader = GameLoader::from_repr(row.mc_loader as u8) - .ok_or(BundleError::InvalidLoader(row.mc_loader))?; - - let launcher_dir = paths::launcher_dir()?; - let path = launcher_dir.join(&row.disk_path); - - Ok(Self { - remote_path: row.remote_path, - mc_version: row.mc_version, - loader, - file_name: row.file_name, - name: row.name.unwrap_or_default(), - version_id: row.version_id.unwrap_or_default(), - category: row.category.unwrap_or_default(), - loader_version: row.loader_version.unwrap_or_default(), - path, - hidden: row.hidden != 0, - }) - } -} - -pub struct BundlesManager { - manifest: RwLock, - pub(crate) archive_cache: RwLock>, -} - -impl BundlesManager { - pub fn new() -> Self { - Self { - manifest: RwLock::new(RemoteBundleManifest::default()), - archive_cache: RwLock::new(HashMap::new()), - } - } - - #[tracing::instrument(skip_all)] - pub async fn sync(&self, services: &LauncherServices) -> LauncherResult { - let Some(fetched) = Self::fetch_manifest(services).await? else { - tracing::debug!( - "skipping bundle sync because no remote or cached manifest is available" - ); - return Ok(false); - }; - let FetchedManifest { - manifest, - changed: manifest_changed, - etag, - } = fetched; - *self.manifest.write().await = manifest.clone(); - self.archive_cache.write().await.clear(); - - if !manifest_changed { - tracing::debug!("bundles manifest unchanged; skipping per-bundle remote checks"); - return Ok(false); - } - - let remote_paths: Vec = manifest - .remote_paths() - .into_iter() - .map(|entry| entry.remote_path) - .collect(); - - bundle_dao::hide_bundles_not_in(&services.db, &remote_paths).await?; - - let bundles_root = paths::bundles_dir()?; - polyio::create_dir_all(&bundles_root.join("bundles")).await?; - - let mut synced_all = true; - for entry in manifest.remote_paths() { - if let Err(err) = self.sync_bundle(&entry, services, &bundles_root).await { - synced_all = false; - tracing::warn!( - remote_path = %entry.remote_path, - error = %err, - "failed to sync bundle" - ); - } - } - - if synced_all { - if let Some(etag) = etag { - let etag_path = paths::bundles_dir()?.join("metadata.json.etag"); - let _ = polyio::write(&etag_path, etag.as_bytes()).await; - } - } else { - tracing::warn!( - "bundle catalog incomplete; not caching the manifest etag so the next \ - sync retries the missing bundles" - ); - } - - Ok(true) - } - - #[tracing::instrument(level = "debug", skip(self, services))] - pub async fn archives_for( - &self, - services: &LauncherServices, - mc_version: &str, - loader: GameLoader, - ) -> LauncherResult> { - let mut archives = Vec::new(); - for bundle in self.list_for(services, mc_version, loader).await? { - let manifest = self.manifest_for_archive(&bundle.path).await?; - archives.push(BundleArchive { bundle, manifest }); - } - Ok(archives) - } - - #[tracing::instrument(level = "debug", skip(self))] - async fn manifest_for_archive( - &self, - path: &Path, - ) -> LauncherResult { - if let Some(manifest) = self.archive_cache.read().await.get(path) { - return Ok(manifest.clone()); - } - let manifest = polymrpack::read_manifest_from_archive(path).await?; - self.archive_cache - .write() - .await - .insert(path.to_path_buf(), manifest.clone()); - Ok(manifest) - } - - #[tracing::instrument(level = "debug", skip(self, services))] - pub async fn list_for( - &self, - services: &LauncherServices, - mc_version: &str, - loader: GameLoader, - ) -> LauncherResult> { - let rows = - bundle_dao::list_visible_for_version_loader(&services.db, mc_version, loader as i64) - .await?; - - rows.into_iter() - .map(Bundle::try_from_row) - .collect::, _>>() - } - - #[tracing::instrument(level = "debug", skip(self, entry, services, bundles_root), fields(remote_path = %entry.remote_path))] - async fn sync_bundle( - &self, - entry: &RemoteBundleRef, - services: &LauncherServices, - bundles_root: &Path, - ) -> LauncherResult<()> { - let loader = GameLoader::from_str(&entry.loader) - .map_err(|_| BundleError::UnknownLoader(entry.loader.clone()))?; - - let file_name = entry - .remote_path - .split('/') - .next_back() - .ok_or_else(|| BundleError::InvalidPath(entry.remote_path.clone()))? - .to_string(); - - let disk_path = bundles_root.join("bundles").join(&file_name); - let remote_url = format!("{}{}", meta_url_base(), entry.remote_path); - - download_bundle_if_needed(services, &remote_url, &disk_path, &entry.sha1).await?; - - let meta = polymrpack::read_meta_from_archive(&disk_path).await?; - - let relative_disk_path = disk_path - .strip_prefix(paths::launcher_dir()?) - .map(|p| p.to_string_lossy().into_owned()) - .unwrap_or_else(|_| disk_path.to_string_lossy().into_owned()); - - let synced_at = Utc::now().to_rfc3339(); - - bundle_dao::upsert_bundle( - &services.db, - NewBundle { - remote_path: &entry.remote_path, - mc_version: &entry.mc_version, - mc_loader: loader as i64, - file_name: &file_name, - name: Some(&meta.name), - version_id: Some(&meta.version_id), - category: Some(&meta.category), - loader_version: Some(&meta.loader_version), - disk_path: &relative_disk_path, - hidden: false, - etag: Some(&entry.sha1), - synced_at: Some(&synced_at), - }, - ) - .await?; - - Ok(()) - } - - #[tracing::instrument(level = "debug", skip(services))] - async fn fetch_manifest( - services: &LauncherServices, - ) -> LauncherResult> { - let manifest_path = paths::bundles_dir()?.join("metadata.json"); - let etag_path = paths::bundles_dir()?.join("metadata.json.etag"); - - let stored_etag = read_sidecar_etag(&etag_path).await; - let url = format!("{}/oneclient/bundles/metadata.json", meta_url_base()); - let url_parsed = url.parse().map_err(LauncherError::UrlError)?; - - let mut request = reqwest::Request::new(Method::GET, url_parsed); - if let Some(etag) = &stored_etag { - insert_if_none_match(&mut request, etag); - } - - match services.requester.send(request).await { - Ok(res) if res.status() == StatusCode::NOT_MODIFIED => { - tracing::debug!("bundles manifest cache hit (304)"); - return read_cached_manifest(&manifest_path, false).await; - } - Ok(res) if res.status().is_success() => { - let server_etag = etag_from_response(&res); - let bytes = res.bytes().await.map_err(|err| LauncherError::InvalidSettingsProfile { - reason: err.to_string(), - })?; - let manifest: RemoteBundleManifest = serde_json::from_slice(&bytes)?; - - if let Some(parent) = manifest_path.parent() { - polyio::create_dir_all(parent).await?; - } - polyio::write(&manifest_path, &bytes).await?; - - Ok(Some(FetchedManifest { - manifest, - changed: true, - etag: server_etag, - })) - } - Err(err) if manifest_path.exists() => { - tracing::debug!( - "falling back to cached bundles manifest after remote fetch failed: {err}" - ); - return read_cached_manifest(&manifest_path, false).await; - } - Err(err) => { - tracing::error!("failed to fetch bundles manifest from remote: {err}"); - Ok(None) - } - Ok(res) => { - tracing::warn!( - status = %res.status(), - "unexpected bundles manifest response; using cache when available" - ); - if manifest_path.exists() { - read_cached_manifest(&manifest_path, false).await - } else { - Ok(None) - } - } - } - } -} - -impl Default for BundlesManager { - fn default() -> Self { - Self::new() - } -} - -struct FetchedManifest { - manifest: RemoteBundleManifest, - changed: bool, - etag: Option, -} - -async fn read_cached_manifest( - manifest_path: &Path, - changed: bool, -) -> LauncherResult> { - if !manifest_path.exists() { - return Ok(None); - } - let bytes = polyio::read(manifest_path).await?; - let manifest: RemoteBundleManifest = serde_json::from_slice(&bytes)?; - Ok(Some(FetchedManifest { - manifest, - changed, - etag: None, - })) -} - -#[tracing::instrument(level = "debug", skip(services))] -async fn download_bundle_if_needed( - services: &LauncherServices, - url: &str, - disk_path: &Path, - expected_sha1: &str, -) -> LauncherResult<()> { - let expected_sha1 = crypto::normalize_hash(expected_sha1); - - if disk_path.exists() { - let local_sha1 = crypto::sha1_file(disk_path).await?; - if crypto::normalize_hash(&local_sha1) == expected_sha1 { - tracing::debug!("bundle cache hit via SHA1: {url}"); - return Ok(()); - } - tracing::debug!("bundle SHA1 mismatch, re-downloading: {url}"); - } else { - tracing::debug!("downloading bundle from remote: {url}"); - } - - let get = reqwest::Request::new( - Method::GET, - url.parse().map_err(LauncherError::UrlError)?, - ); - - let res = services - .requester - .send(get) - .await - .map_err(map_request_error)?; - - if !res.status().is_success() { - return Err(BundleError::MissingFile(format!( - "{url} (HTTP {})", - res.status() - )) - .into()); - } - - if let Some(parent) = disk_path.parent() { - polyio::create_dir_all(parent).await?; - } - - let size_hint = res.content_length(); - let stream = res - .stream(ResponseOptions::default(), &services.notifier) - .await - .map_err(map_request_error)?; - let stream = std::pin::pin!(stream); - polyio::write_stream(disk_path, stream, size_hint) - .await - .map_err(map_request_error)?; - - let local_sha1 = crypto::sha1_file(disk_path).await?; - if crypto::normalize_hash(&local_sha1) != expected_sha1 { - return Err(BundleError::MissingFile(format!( - "{url} (SHA1 mismatch: expected {expected_sha1}, got {local_sha1})" - )) - .into()); - } - - Ok(()) -} - -fn insert_if_none_match(request: &mut reqwest::Request, etag: &str) { - if let Ok(value) = header::HeaderValue::from_str(etag) { - request.headers_mut().insert(header::IF_NONE_MATCH, value); - } -} - -fn etag_from_response(res: &reqwest::Response) -> Option { - res.headers() - .get(header::ETAG) - .and_then(|v| v.to_str().ok()) - .map(str::to_string) -} - -async fn read_sidecar_etag(path: &Path) -> Option { - polyio::read(path) - .await - .ok() - .and_then(|bytes| String::from_utf8(bytes).ok()) - .filter(|etag| !etag.is_empty()) -} - -fn map_request_error(err: RequestError) -> LauncherError { - match err { - RequestError::DeserializeError { - source, - type_name, - url, - status, - snippet, - } => LauncherError::InvalidSettingsProfile { - reason: format!( - "failed to parse {type_name} from {url} (HTTP {status}): {source} — body: {snippet}" - ), - }, - other => LauncherError::InvalidSettingsProfile { - reason: other.to_string(), - }, - } -} diff --git a/packages/oneclient_core/src/changelog.rs b/packages/oneclient_core/src/changelog.rs index 3567e5ba..ee0667d4 100644 --- a/packages/oneclient_core/src/changelog.rs +++ b/packages/oneclient_core/src/changelog.rs @@ -1,11 +1,8 @@ -use std::path::Path; -use reqwest::header; -use reqwest::{Method, StatusCode}; -use crate::api_config::meta_url_base; -use crate::paths; -use crate::state::LauncherServices; +use oneclient_common::paths; +use oneclient_net::{EtagPolicy, fetch_cached}; +use oneclient_net::RequestClient; use crate::{LauncherError, LauncherResult}; #[derive(Debug, Clone, PartialEq, Eq)] @@ -35,100 +32,21 @@ pub fn parse_changelog(data: &str) -> Vec { groups } -#[tracing::instrument(level = "debug", skip(services))] -pub async fn fetch_changelog(services: &LauncherServices) -> LauncherResult { - let url = format!("{}/oneclient/CHANGE_LOG.md", meta_url_base()); +#[tracing::instrument(level = "debug", skip(net))] +pub async fn fetch_changelog(net: &RequestClient) -> LauncherResult { + let url = format!( + "{}/oneclient/CHANGE_LOG.md", + net.config().meta_url_base + ); let cache_path = paths::caches_dir()?.join("CHANGE_LOG.md"); - let etag_path = paths::caches_dir()?.join("CHANGE_LOG.md.etag"); - let stored_etag = read_sidecar_etag(&etag_path).await; - let url_parsed = url.parse().map_err(LauncherError::UrlError)?; + let fetched = fetch_cached(net, &url, &cache_path, EtagPolicy::CommitNow) + .await? + .ok_or_else(|| LauncherError::InvalidSettingsProfile { + reason: "changelog is unavailable and not cached".to_string(), + })?; - let mut request = reqwest::Request::new(Method::GET, url_parsed); - if let Some(etag) = &stored_etag { - insert_if_none_match(&mut request, etag); - } - - match services.requester.send(request).await { - Ok(res) if res.status() == StatusCode::NOT_MODIFIED => { - tracing::debug!("changelog cache hit (304)"); - read_cached_changelog(&cache_path).await - } - Ok(res) if res.status().is_success() => { - let server_etag = etag_from_response(&res); - let bytes = res - .bytes() - .await - .map_err(|err| LauncherError::InvalidSettingsProfile { - reason: err.to_string(), - })?; - - if let Some(parent) = cache_path.parent() { - polyio::create_dir_all(parent).await?; - } - polyio::write(&cache_path, &bytes).await?; - if let Some(etag) = server_etag { - let _ = polyio::write(&etag_path, etag.as_bytes()).await; - } - - String::from_utf8(bytes.to_vec()).map_err(|err| LauncherError::InvalidSettingsProfile { - reason: err.to_string(), - }) - } - Err(err) if cache_path.exists() => { - tracing::debug!( - "falling back to cached changelog after remote fetch failed: {err}" - ); - read_cached_changelog(&cache_path).await - } - Err(err) => { - tracing::error!("failed to fetch changelog from remote: {err}"); - Err(LauncherError::InvalidSettingsProfile { - reason: err.to_string(), - }) - } - Ok(res) => { - tracing::warn!( - status = %res.status(), - "unexpected changelog response; using cache when available" - ); - if cache_path.exists() { - read_cached_changelog(&cache_path).await - } else { - Err(LauncherError::InvalidSettingsProfile { - reason: format!("changelog request failed with HTTP {}", res.status()), - }) - } - } - } -} - -async fn read_cached_changelog(path: &Path) -> LauncherResult { - let bytes = polyio::read(path).await?; - String::from_utf8(bytes).map_err(|err| LauncherError::InvalidSettingsProfile { - reason: err.to_string(), - }) -} - -fn insert_if_none_match(request: &mut reqwest::Request, etag: &str) { - if let Ok(value) = header::HeaderValue::from_str(etag) { - request.headers_mut().insert(header::IF_NONE_MATCH, value); - } -} - -fn etag_from_response(res: &reqwest::Response) -> Option { - res.headers() - .get(header::ETAG) - .and_then(|v| v.to_str().ok()) - .map(str::to_string) -} - -async fn read_sidecar_etag(path: &Path) -> Option { - polyio::read(path) - .await - .ok() - .and_then(|bytes| String::from_utf8(bytes).ok()) - .filter(|etag| !etag.is_empty()) + Ok(fetched.text()) } #[cfg(test)] diff --git a/packages/oneclient_core/src/clusters/error.rs b/packages/oneclient_core/src/clusters/error.rs deleted file mode 100644 index b6ea7d19..00000000 --- a/packages/oneclient_core/src/clusters/error.rs +++ /dev/null @@ -1,40 +0,0 @@ -use thiserror::Error; - -use super::stage::ClusterStage; -use crate::packages::domain::GameLoader; - -#[derive(Debug, Error)] -pub enum ClusterError { - #[error("cluster {0} not found")] - NotFound(i64), - - #[error("cluster folder '{0}' not found on disk")] - FolderNotFound(String), - - #[error("cluster is in {0} stage")] - Busy(ClusterStage), - - #[error("invalid Minecraft version '{0}'")] - InvalidVersion(String), - - #[error("cluster is missing a required Java version")] - MissingJavaVersion, - - #[error("loader {0} is not compatible with {1}")] - MismatchedLoader(GameLoader, GameLoader), - - #[error("setting profile '{0}' not found")] - ProfileNotFound(String), - - #[error("cluster has no settings profile assigned")] - NoProfile, - - #[error("cluster name is empty after sanitization")] - EmptyName, - - #[error("unknown loader id {0} in database")] - InvalidLoader(i64), - - #[error("unknown stage id {0} in database")] - InvalidStage(i64), -} diff --git a/packages/oneclient_core/src/clusters/manager.rs b/packages/oneclient_core/src/clusters/manager.rs deleted file mode 100644 index 9d7a9960..00000000 --- a/packages/oneclient_core/src/clusters/manager.rs +++ /dev/null @@ -1,366 +0,0 @@ -use oneclient_db::dao::{cluster as cluster_dao, setting_profile as profile_dao}; -use oneclient_db::models::{ClusterId, ClusterPatch, NewCluster}; - -use crate::packages::domain::ContentType; -use crate::settings::store::{ - create_profile_from_global, resolve_cluster_profile, update_named_profile, -}; -use crate::patch::Patch; -use crate::settings::{GameSettingsProfile, ProfileUpdate}; -use crate::state::LauncherState; -use crate::LauncherResult; - -use std::sync::Arc; - -use super::cluster::Cluster; -use super::error::ClusterError; -use super::options::{ClusterUpdate, CreateClusterOptions}; -use super::prepare; -use super::stage::ClusterStage; - -pub struct ClusterManager; - -impl ClusterManager { - pub fn sanitize_name(name: &str) -> String { - let mut name = name.to_string(); - name.retain(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | ' ' | '.' | '(' | ')')); - name.trim().to_string() - } - - #[tracing::instrument(level = "debug", skip(state))] - pub async fn get(state: &LauncherState, cluster_id: ClusterId) -> LauncherResult { - let row = cluster_dao::get_by_id(&state.services.db, cluster_id) - .await? - .ok_or(ClusterError::NotFound(cluster_id))?; - Ok(Cluster::try_from_row(row)?) - } - - #[tracing::instrument(level = "debug", skip(state))] - pub async fn list(state: &LauncherState) -> LauncherResult> { - let rows = cluster_dao::list_all(&state.services.db).await?; - rows.into_iter() - .map(Cluster::try_from_row) - .collect::, _>>() - .map_err(Into::into) - } - - #[tracing::instrument(skip(state))] - pub async fn create( - state: &LauncherState, - options: CreateClusterOptions, - ) -> LauncherResult { - let _guard = state.provisioning.lock().await; - Self::create_core(state, options).await - } - - /// Create a cluster for a version/loader that doesn't exist yet, and provision it. - #[tracing::instrument(level = "debug", skip(state))] - pub async fn create_provisioned( - state: &LauncherState, - options: CreateClusterOptions, - ) -> LauncherResult> { - let _guard = state.provisioning.lock().await; - if cluster_dao::find_by_version_loader( - &state.services.db, - &options.mc_version, - options.mc_loader as i64, - ) - .await? - .is_some() - { - return Ok(None); - } - Self::create_core(state, options).await.map(Some) - } - - /// Folder-resolve + insert core shared by `create` and `create_provisioned`. - /// Callers MUST hold `state.provisioning` — this does not lock. - #[tracing::instrument(level = "debug", skip(state))] - async fn create_core( - state: &LauncherState, - options: CreateClusterOptions, - ) -> LauncherResult { - let name = Self::sanitize_name(&options.name); - if name.is_empty() { - return Err(ClusterError::EmptyName.into()); - } - - let folder_name = resolve_unique_folder_name(&name).await?; - let cluster_path = crate::paths::clusters_dir()?.join(&folder_name); - - match create_inner(state, &options, &name, &folder_name, &cluster_path).await { - Ok(cluster) => { - tracing::info!(cluster_id = cluster.id, name = %cluster.name, "created cluster"); - Ok(cluster) - } - Err(err) => { - tracing::warn!(name = %name, error = %err, "cluster creation failed, cleaning up directory"); - let _ = polyio::remove_dir_all(&cluster_path).await; - Err(err) - } - } - } - - #[tracing::instrument(level = "debug", skip(state))] - pub async fn update( - state: &LauncherState, - cluster_id: ClusterId, - update: ClusterUpdate, - ) -> LauncherResult { - let _ = Self::get(state, cluster_id).await?; - - if let Patch::Set(ref profile_name) = update.setting_profile_name { - ensure_profile_exists(&state.services.db, profile_name).await?; - } - - let name = update.name.as_deref().map(Self::sanitize_name); - if name.as_deref().is_some_and(str::is_empty) { - return Err(ClusterError::EmptyName.into()); - } - - let patch = ClusterPatch { - name, - setting_profile_name: update.setting_profile_name.into_db_patch(), - mc_loader_version: update.mc_loader_version.into_db_patch(), - linked_modpack_hash: update.linked_modpack_hash.into_db_patch(), - }; - - let row = cluster_dao::update(&state.services.db, cluster_id, &patch).await?; - Ok(Cluster::try_from_row(row)?) - } - - #[tracing::instrument(skip(state))] - pub async fn delete( - state: &LauncherState, - cluster_id: ClusterId, - remove_files: bool, - ) -> LauncherResult<()> { - let cluster = Self::get(state, cluster_id).await?; - - if !cluster_dao::delete_by_id(&state.services.db, cluster_id).await? { - return Err(ClusterError::NotFound(cluster_id).into()); - } - - if remove_files { - let path = cluster.dir()?; - if path.exists() { - polyio::remove_dir_all(&path).await?; - } - } - - tracing::info!(cluster_id, remove_files, "deleted cluster"); - Ok(()) - } - - #[tracing::instrument(level = "debug", skip(state))] - pub async fn set_stage( - state: &LauncherState, - cluster_id: ClusterId, - stage: ClusterStage, - ) -> LauncherResult { - let row = cluster_dao::set_stage(&state.services.db, cluster_id, stage as i64).await?; - Ok(Cluster::try_from_row(row)?) - } - - #[tracing::instrument(level = "debug", skip(state))] - pub async fn uses_dedicated_dir( - state: &LauncherState, - cluster_id: ClusterId, - ) -> LauncherResult { - Ok(Self::get(state, cluster_id).await?.uses_dedicated_dir()) - } - - #[tracing::instrument(level = "debug", skip(state))] - pub async fn set_dedicated_dir( - state: &LauncherState, - cluster_id: ClusterId, - dedicated: bool, - ) -> LauncherResult<()> { - if state.games.is_active(cluster_id) { - return Err(crate::game::GameError::AlreadyRunning(cluster_id).into()); - } - - let cluster = Self::get(state, cluster_id).await?; - let marker = cluster.dedicated_marker()?; - if dedicated { - polyio::create_dir_all(cluster.dir()?).await?; - polyio::write(&marker, b"").await.ok(); - } else if marker.exists() { - polyio::remove_file(&marker).await.ok(); - } - Ok(()) - } - - #[tracing::instrument(level = "debug", skip(state))] - pub async fn add_playtime( - state: &LauncherState, - cluster_id: ClusterId, - duration: std::time::Duration, - ) -> LauncherResult { - let seconds = i64::try_from(duration.as_secs()).unwrap_or(i64::MAX); - let row = cluster_dao::add_playtime(&state.services.db, cluster_id, seconds).await?; - - Ok(Cluster::try_from_row(row)?) - } - - #[tracing::instrument(level = "debug", skip(state, cluster), fields(cluster_id = cluster.id))] - pub async fn resolve_settings( - state: &LauncherState, - cluster: &Cluster, - ) -> LauncherResult { - let settings = state.settings.read().clone(); - - resolve_cluster_profile( - &state.services.db, - &settings, - cluster.setting_profile_name.as_deref(), - ) - .await - } - - #[tracing::instrument(level = "debug", skip(state, update))] - pub async fn update_profile( - state: &LauncherState, - cluster_id: ClusterId, - update: ProfileUpdate, - ) -> LauncherResult { - let cluster = Self::get(state, cluster_id).await?; - let profile_name = cluster - .setting_profile_name - .ok_or(ClusterError::NoProfile)?; - - update_named_profile(&state.services.db, &profile_name, update).await - } - - #[tracing::instrument(skip(state, shared_progress))] - pub async fn prepare( - state: &Arc, - cluster_id: ClusterId, - force: bool, - search_for_java: bool, - auto_install_java: bool, - shared_progress: Option<&crate::notification::GroupedProgressSession>, - ) -> LauncherResult { - let mut metadata = state.metadata.lock().await; - prepare::prepare_cluster( - state, - &mut metadata, - cluster_id, - force, - search_for_java, - auto_install_java, - shared_progress, - ) - .await - } - - #[tracing::instrument(level = "debug", skip(state))] - pub async fn create_and_assign_profile( - state: &LauncherState, - cluster_id: ClusterId, - profile_name: &str, - ) -> LauncherResult { - let settings = state.settings.read().clone(); - - let profile = create_profile_from_global( - &state.services.db, - &settings, - profile_name, - None, - None, - ) - .await?; - - Self::update( - state, - cluster_id, - ClusterUpdate::default().setting_profile(&profile.name), - ) - .await?; - - Ok(profile) - } -} - -#[tracing::instrument(level = "debug", skip(state, options))] -async fn create_inner( - state: &LauncherState, - options: &CreateClusterOptions, - name: &str, - folder_name: &str, - cluster_path: &std::path::Path, -) -> LauncherResult { - polyio::create_dir_all(cluster_path).await?; - ensure_content_dirs(cluster_path).await?; - - let settings = state.settings.read().clone(); - - let profile = create_profile_from_global( - &state.services.db, - &settings, - name, - options.mem_max, - None, - ) - .await?; - - let row = cluster_dao::insert( - &state.services.db, - &NewCluster { - name, - folder_name, - mc_version: &options.mc_version, - mc_loader: options.mc_loader as i64, - mc_loader_version: options.mc_loader_version.as_deref(), - setting_profile_name: Some(&profile.name), - stage: ClusterStage::NotReady as i64, - }, - ) - .await?; - - Ok(Cluster::try_from_row(row)?) -} - -#[tracing::instrument(level = "debug", skip(pool))] -async fn ensure_profile_exists(pool: &oneclient_db::DbPool, name: &str) -> LauncherResult<()> { - if profile_dao::get_by_name(pool, name).await?.is_none() { - return Err(ClusterError::ProfileNotFound(name.to_string()).into()); - } - Ok(()) -} - -#[tracing::instrument(level = "debug")] -async fn resolve_unique_folder_name(name: &str) -> LauncherResult { - let cluster_dir = crate::paths::clusters_dir()?; - let mut folder_name = name.to_string(); - let mut path = cluster_dir.join(&folder_name); - - if path.exists() { - let mut which = 1; - loop { - let candidate = format!("{folder_name} ({which})"); - path = cluster_dir.join(&candidate); - if !path.exists() { - folder_name = candidate; - break; - } - which += 1; - } - } - - Ok(folder_name) -} - -#[tracing::instrument(level = "debug")] -async fn ensure_content_dirs(cluster_path: &std::path::Path) -> LauncherResult<()> { - for content_type in [ - ContentType::Mod, - ContentType::ResourcePack, - ContentType::Shader, - ContentType::DataPack, - ContentType::World, - ] { - polyio::create_dir_all(cluster_path.join(content_type.folder_name())).await?; - } - Ok(()) -} diff --git a/packages/oneclient_core/src/clusters/migrate.rs b/packages/oneclient_core/src/clusters/migrate.rs index d7bbb787..3c344f6b 100644 --- a/packages/oneclient_core/src/clusters/migrate.rs +++ b/packages/oneclient_core/src/clusters/migrate.rs @@ -5,7 +5,7 @@ use oneclient_db::dao::{applied_migration as migration_dao, cluster as cluster_d use oneclient_db::models::ClusterRow; use crate::LauncherResult; -use crate::packages::domain::GameLoader; +use oneclient_common::domain::GameLoader; use crate::state::LauncherState; use crate::versions::RemoteMigration; @@ -113,7 +113,7 @@ async fn migrate_cluster( let from = &rule.from.mc_version; let to = &rule.to.mc_version; - let clusters_dir = crate::paths::clusters_dir()?; + let clusters_dir = oneclient_common::paths::clusters_dir()?; let old_dir = clusters_dir.join(&source.folder_name); let new_folder = resolve_new_folder(&clusters_dir, &source.folder_name, from, to).await?; diff --git a/packages/oneclient_core/src/clusters/mod.rs b/packages/oneclient_core/src/clusters/mod.rs index 29106145..eb9000fb 100644 --- a/packages/oneclient_core/src/clusters/mod.rs +++ b/packages/oneclient_core/src/clusters/mod.rs @@ -1,17 +1,22 @@ -mod cluster; -mod error; -mod manager; +//! Cluster orchestration: preparing a cluster for launch, provisioning from the +//! remote catalog, and applying version migrations. +//! +//! The records themselves, plus settings profiles, logs and screenshots, live +//! in `oneclient_cluster`. These three modules are what compose that crate with +//! Java, Minecraft metadata, the package store and the versions manifest, which +//! is why they stay here. + mod migrate; -mod options; mod prepare; mod provision; -mod stage; -pub use cluster::{Cluster, ClusterLinkTarget}; -pub use error::ClusterError; -pub use manager::ClusterManager; pub use migrate::apply_remote_migrations; -pub use options::{ClusterUpdate, CreateClusterOptions}; -pub use prepare::{estimate_cluster_download, prepare_cluster}; +pub use prepare::{estimate_cluster_download, prepare_cluster, prepare_cluster_locked}; pub use provision::{ensure_from_bundles, ensure_from_versions}; -pub use stage::ClusterStage; + +// Re-exported so the rest of the launcher keeps addressing these through +// `clusters::`, as it did before the split. +pub use oneclient_cluster::{ + Cluster, ClusterError, ClusterLinkTarget, ClusterManager, ClusterStage, ClusterUpdate, + CreateClusterOptions, +}; diff --git a/packages/oneclient_core/src/clusters/prepare.rs b/packages/oneclient_core/src/clusters/prepare.rs index cb78e07f..50339e7c 100644 --- a/packages/oneclient_core/src/clusters/prepare.rs +++ b/packages/oneclient_core/src/clusters/prepare.rs @@ -5,22 +5,46 @@ use interfrost::api::minecraft::{DownloadType, VersionInfo}; use interfrost::api::modded::SidedDataEntry; use tokio::process::Command; -use crate::clusters::cluster::Cluster; -use crate::clusters::error::ClusterError; -use crate::clusters::stage::ClusterStage; -use crate::clusters::ClusterManager; +use oneclient_cluster::Cluster; +use oneclient_cluster::ClusterError; +use oneclient_cluster::ClusterStage; use crate::game::{ self, download_minecraft, download_version_info, get_loader_version, resolve_minecraft_version, }; -use crate::java::JavaRuntime; -use crate::java::JavaManager; -use crate::metadata::MetadataStore; -use crate::notification::GroupedProgressSession; -use crate::paths; +use oneclient_java::JavaRuntime; +use oneclient_mc::MetadataStore; +use oneclient_events::GroupedProgressSession; +use oneclient_common::paths; use crate::state::{LauncherServices, LauncherState}; use crate::{GameError, LauncherResult}; -#[tracing::instrument(skip(state, metadata, shared_progress))] +/// Locks the metadata store, then prepares the cluster. +/// +/// Was `ClusterManager::prepare`, which only existed to take that lock before +/// delegating here. It lives with `prepare` rather than with cluster records, +/// because the metadata store is a download concern. +#[tracing::instrument(skip(state, shared_progress))] +pub async fn prepare_cluster_locked( + state: &Arc, + cluster_id: i64, + force: bool, + search_for_java: bool, + auto_install_java: bool, + shared_progress: Option<&GroupedProgressSession>, +) -> LauncherResult { + let mut metadata = state.metadata.lock().await; + prepare_cluster( + state, + &mut metadata, + cluster_id, + force, + search_for_java, + auto_install_java, + shared_progress, + ) + .await +} + pub async fn prepare_cluster( state: &Arc, metadata: &mut MetadataStore, @@ -30,7 +54,7 @@ pub async fn prepare_cluster( auto_install_java: bool, shared_progress: Option<&GroupedProgressSession>, ) -> LauncherResult { - let cluster = ClusterManager::get(state, cluster_id).await?; + let cluster = state.clusters.get(cluster_id).await?; let continuing = cluster.stage == ClusterStage::Downloading; tracing::info!( @@ -42,12 +66,12 @@ pub async fn prepare_cluster( ); if !continuing { - ClusterManager::set_stage(state, cluster_id, ClusterStage::Downloading).await?; + state.clusters.set_stage(cluster_id, ClusterStage::Downloading).await?; } let owned = shared_progress.is_none().then(|| { GroupedProgressSession::start( - &state.services.notifier, + &state.services.events, format!("Downloading game - {}", cluster.mc_version), ) }); @@ -71,12 +95,12 @@ pub async fn prepare_cluster( if let Err(err) = result { tracing::error!(cluster_id, error = %err, "cluster preparation failed"); if !continuing { - let _ = ClusterManager::set_stage(state, cluster_id, ClusterStage::NotReady).await; + let _ = state.clusters.set_stage(cluster_id, ClusterStage::NotReady).await; } return Err(err); } - let cluster = ClusterManager::set_stage(state, cluster_id, ClusterStage::Ready).await?; + let cluster = state.clusters.set_stage(cluster_id, ClusterStage::Ready).await?; tracing::debug!(cluster_id, "cluster stage set to Ready"); Ok(cluster) } @@ -84,8 +108,8 @@ pub async fn prepare_cluster( const JRE_ESTIMATE_BYTES: u64 = 45_000_000; /// Bytes the game install still needs. Falls back to the manifest's full size -/// when the asset index isn't cached yet — which is exactly the case where -/// nothing is on disk, so the full size is the right answer anyway. +/// when the asset index isn't cached yet, which is also the case where nothing +/// is on disk, so the full size is the right answer anyway. async fn game_download_bytes(services: &LauncherServices, info: &VersionInfo) -> u64 { let Some(assets_index) = cached_assets_index(info).await else { let client = info @@ -127,32 +151,32 @@ async fn cached_assets_index( pub async fn estimate_cluster_download( state: &Arc, cluster_id: i64, - bundles: &crate::bundles::BundlesManager, + bundles: &oneclient_content::bundles::BundlesManager, ) -> LauncherResult { - let cluster = ClusterManager::get(state, cluster_id).await?; - let mc_version = crate::version::normalize_mc_version_input(&cluster.mc_version); + let cluster = state.clusters.get(cluster_id).await?; + let mc_version = oneclient_common::version::normalize_mc_version_input(&cluster.mc_version); let info = { let mut metadata = state.metadata.lock().await; let (version, _index, _updated) = - resolve_minecraft_version(&mut metadata, &state.services, &mc_version) + resolve_minecraft_version(&mut metadata, &state.services.mc(), &mc_version) .await .map_err(|_| ClusterError::InvalidVersion(cluster.mc_version.clone()))?; let loader_version = get_loader_version( &mut metadata, - &state.services, + &state.services.mc(), &mc_version, cluster.mc_loader, cluster.mc_loader_version.as_deref(), ) .await?; - download_version_info(&state.services, None, &version, loader_version.as_ref(), false).await? + download_version_info(&state.services.mc(), None, &version, loader_version.as_ref(), false).await? }; let mut total = game_download_bytes(&state.services, &info).await; if let Some(java) = &info.java_version { - let installed = JavaManager::list_runtimes(&state.services.db) + let installed = state.java.list_runtimes() .await .unwrap_or_default(); if !installed.iter().any(|rt| rt.major == java.major_version) { @@ -160,7 +184,7 @@ pub async fn estimate_cluster_download( } } - total += crate::bundles::enabled_bundle_bytes(cluster_id, bundles, &state.services) + total += oneclient_content::bundles::enabled_bundle_bytes(cluster_id, bundles, &state.services.content()) .await .unwrap_or(0); @@ -177,18 +201,19 @@ async fn install_cluster( search_for_java: bool, auto_install_java: bool, ) -> LauncherResult<()> { - let profile = ClusterManager::resolve_settings(state, cluster).await?; + let global = state.settings.read().global_game_settings.clone(); + let profile = state.clusters.resolve_settings(&global, cluster).await?; - let mc_version = crate::version::normalize_mc_version_input(&cluster.mc_version); + let mc_version = oneclient_common::version::normalize_mc_version_input(&cluster.mc_version); let (version, _version_index, minecraft_updated) = - resolve_minecraft_version(metadata, &state.services, &mc_version) + resolve_minecraft_version(metadata, &state.services.mc(), &mc_version) .await .map_err(|_| ClusterError::InvalidVersion(cluster.mc_version.clone()))?; let loader_version = get_loader_version( metadata, - &state.services, + &state.services.mc(), &mc_version, cluster.mc_loader, cluster.mc_loader_version.as_deref(), @@ -196,7 +221,7 @@ async fn install_cluster( .await?; let mut version_info = download_version_info( - &state.services, + &state.services.mc(), Some(progress), &version, loader_version.as_ref(), @@ -211,22 +236,18 @@ async fn install_cluster( .ok_or(ClusterError::MissingJavaVersion)?; let java = if let Some(runtime) = - JavaManager::java_for_profile(&state.services.db, profile.java_path.as_deref()).await? + state.java.runtime_for_profile(profile.java_path.as_deref()).await? { runtime } else { - JavaManager::prepare_java_with_services( - &state.services, - java_major, - search_for_java, - auto_install_java, - Some(progress), - ) - .await? + state + .java + .prepare(java_major, search_for_java, auto_install_java, Some(progress)) + .await? }; download_minecraft( - &state.services, + &state.services.mc(), progress, &version_info, &java.os_arch, diff --git a/packages/oneclient_core/src/clusters/provision.rs b/packages/oneclient_core/src/clusters/provision.rs index 18cc8daf..767f5f1a 100644 --- a/packages/oneclient_core/src/clusters/provision.rs +++ b/packages/oneclient_core/src/clusters/provision.rs @@ -2,14 +2,13 @@ use std::str::FromStr; use oneclient_db::dao::{bundle as bundle_dao, cluster as cluster_dao}; -use crate::packages::domain::GameLoader; +use oneclient_common::domain::GameLoader; use crate::state::LauncherState; -use crate::version::format_mc_version; +use oneclient_common::version::format_mc_version; use crate::LauncherResult; -use super::cluster::Cluster; -use super::manager::ClusterManager; -use super::options::CreateClusterOptions; +use oneclient_cluster::Cluster; +use oneclient_cluster::CreateClusterOptions; #[tracing::instrument(skip(state))] pub async fn ensure_from_bundles(state: &LauncherState) -> LauncherResult> { @@ -39,10 +38,13 @@ pub async fn ensure_from_bundles(state: &LauncherState) -> LauncherResult { @@ -71,7 +73,7 @@ pub async fn ensure_from_bundles(state: &LauncherState) -> LauncherResult LauncherResult> { - let metadata = state.versions.metadata().await; + let metadata = state.versions.metadata(&state.services.requester.config().meta_url_base).await; let mut created = Vec::new(); for entry in metadata { @@ -101,10 +103,13 @@ pub async fn ensure_from_versions(state: &LauncherState) -> LauncherResult { diff --git a/packages/oneclient_core/src/dev.rs b/packages/oneclient_core/src/dev.rs index fdd9341e..87d5f196 100644 --- a/packages/oneclient_core/src/dev.rs +++ b/packages/oneclient_core/src/dev.rs @@ -5,13 +5,13 @@ use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use tokio::sync::mpsc; use uuid::Uuid; +use oneclient_content::packages::provider::PackageProviderRegistry; +use oneclient_net::RequestClient; +use oneclient_events::{ + Event, EventBus, GameEvent, GroupedProgressEvent, Level, Notification, ProgressEvent, +}; + use crate::{ - auth::CredentialsStore, - http::RequestClient, - notification::{ - GroupedProgressEvent, NotificationLevel, Notification, NotificationService, - }, - packages::provider::PackageProviderRegistry, LauncherResult, LauncherServices, LauncherState, }; @@ -22,7 +22,7 @@ struct GroupedSessionUi { child_bytes: HashMap, } -fn spawn_notification_handler(mut rx: mpsc::UnboundedReceiver) { +fn spawn_notification_handler(mut rx: oneclient_events::EventReceiver) { tokio::spawn(async move { let mp = MultiProgress::new(); let mut progress_bars: HashMap = HashMap::new(); @@ -46,15 +46,16 @@ fn spawn_notification_handler(mut rx: mpsc::UnboundedReceiver) { .unwrap() .progress_chars("#>-"); - while let Some(notification) = rx.recv().await { - match notification { - Notification::Message { title, body, level } => { - mp.suspend(|| match level { - NotificationLevel::Info => tracing::info!(%title, %body), - NotificationLevel::Error => tracing::error!(%title, %body), + while let Some(event) = rx.recv().await { + match event { + Event::Notification(Notification::Message(message)) => { + let (title, body) = (message.title, message.body); + mp.suspend(|| match message.level { + Level::Info => tracing::info!(%title, %body), + Level::Error => tracing::error!(%title, %body), }); } - Notification::Progress { id, label, current, total } => { + Event::Progress(ProgressEvent::Update { id, label, current, total }) => { if current >= total { if let Some(pb) = progress_bars.remove(&id) { pb.finish_with_message(format!("{label} Done!")); @@ -71,14 +72,14 @@ fn spawn_notification_handler(mut rx: mpsc::UnboundedReceiver) { pb.set_position(current); } } - Notification::ProgressComplete { id, title, body } => { + Event::Progress(ProgressEvent::Complete { id, title, body }) => { if let Some(pb) = progress_bars.remove(&id) { pb.finish_with_message(format!("{title}: {body}")); } else { mp.suspend(|| tracing::info!(%title, %body)); } } - Notification::GroupedProgress(event) => match event { + Event::Progress(ProgressEvent::Grouped(event)) => match event { GroupedProgressEvent::Start { session_id, title } => { let parent = mp.add(ProgressBar::new(1)); parent.set_style(parent_style.clone()); @@ -166,26 +167,30 @@ fn spawn_notification_handler(mut rx: mpsc::UnboundedReceiver) { } } }, - Notification::InvalidateClusters => {} - Notification::InvalidateJava => {} - Notification::SyncComplete => {} - Notification::MicrosoftLoginStatus(status) => { - mp.suspend(|| tracing::info!(?status, "microsoft login status")); - } - Notification::GameStage { cluster_id, stage } => { + Event::Signal(_) => {} + Event::Game(GameEvent::Stage { cluster_id, stage }) => { mp.suspend(|| tracing::info!(cluster_id, ?stage, "game stage")); } - Notification::GameLog { line, .. } => { + Event::Game(GameEvent::Log { line, .. }) => { mp.suspend(|| tracing::info!("[game] {line}")); } - Notification::GameFailed { cluster_id, message } => { + Event::Game(GameEvent::Failed { cluster_id, message }) => { mp.suspend(|| tracing::error!(cluster_id, "launch failed: {message}")); } - Notification::Prompt { title, question, kind, .. } => { + // No TTY dialog here: answer `None` so the waiting task sees a + // dismissal and fails cleanly, rather than hanging on a prompt + // nobody can answer. + Event::Notification(Notification::Prompt(request)) => { + let choices: Vec<&str> = request.choices.iter().map(|c| c.id).collect(); mp.suspend(|| { - tracing::warn!("{title}: {question} | {kind:#?}"); - tracing::warn!("prompt not implemented"); + tracing::warn!( + "{}: {} | choices: {choices:?}", + request.title, + request.body + ); + tracing::warn!("prompts are not interactive in dev; dismissing"); }); + let _ = request.reply.send(None); } } } @@ -208,7 +213,7 @@ pub async fn initialize() -> LauncherResult> { spawn_notification_handler(rx); - LauncherState::initialize(NotificationService::new(tx)).await + LauncherState::new(EventBus::new(tx)).await } async fn ephemeral_root() -> LauncherResult { @@ -224,7 +229,7 @@ async fn ephemeral_root() -> LauncherResult { pub async fn ephemeral_state() -> LauncherResult> { let root = ephemeral_root().await?; - crate::paths::set_launcher_dir(root.clone()); + oneclient_common::paths::set_launcher_dir(root.clone()); let (tx, rx) = mpsc::unbounded_channel(); spawn_notification_handler(rx); @@ -232,37 +237,51 @@ pub async fn ephemeral_state() -> LauncherResult> { let db = oneclient_db::connect(root.join("example.db")).await?; let settings = crate::settings::store::load_settings(None).await; + let services = LauncherServices { + events: EventBus::new(tx), + requester: RequestClient::new(oneclient_net::NetConfig::default())?, + db, + packages: PackageProviderRegistry::new(), + }; + let auth = Arc::new(oneclient_auth::AuthService::with_store( + Default::default(), + services.requester.clone(), + services.events.clone(), + )); + + let clusters = crate::clusters::ClusterManager::new(services.db.clone()); + + let java = oneclient_java::JavaService::new( + Arc::new(crate::java_store::SqlJavaStore::new(services.db.clone())), + services.requester.clone(), + services.events.clone(), + ); + Ok(Arc::new(LauncherState { - services: LauncherServices { - notifier: NotificationService::new(tx), - requester: RequestClient::new()?, - db, - packages: PackageProviderRegistry::new(), - }, + services, + auth, + java, + clusters, settings: parking_lot::RwLock::new(settings), - auth: tokio::sync::Mutex::new(CredentialsStore::default()), - microsoft_logins: tokio::sync::Mutex::new(std::collections::HashMap::new()), - java: crate::java::JavaManager, - metadata: tokio::sync::Mutex::new(crate::metadata::MetadataStore::new()), - bundles: Arc::new(crate::bundles::BundlesManager::new()), + metadata: tokio::sync::Mutex::new(oneclient_mc::MetadataStore::new()), + bundles: Arc::new(oneclient_content::bundles::BundlesManager::new()), versions: Arc::new(crate::versions::VersionsManager::new()), images: crate::images::ImageCacheStore::new(), games: crate::game::GameProcessManager::new(), - discord: crate::discord::DiscordRpc::spawn(false), - provisioning: tokio::sync::Mutex::new(()), + discord: oneclient_discord::DiscordRpc::spawn(false), })) } pub async fn ephemeral_services() -> LauncherResult { let root = ephemeral_root().await?; - crate::paths::set_launcher_dir(root.clone()); + oneclient_common::paths::set_launcher_dir(root.clone()); let db = oneclient_db::connect(root.join("example.db")).await?; let (tx, rx) = mpsc::unbounded_channel(); spawn_notification_handler(rx); Ok(LauncherServices { - notifier: NotificationService::new(tx), - requester: RequestClient::new()?, + events: EventBus::new(tx), + requester: RequestClient::new(oneclient_net::NetConfig::default())?, db, packages: PackageProviderRegistry::new(), }) @@ -270,7 +289,7 @@ pub async fn ephemeral_services() -> LauncherResult { pub async fn seed_bundle_archive( state: &LauncherState, - manifest: crate::bundles::BundleManifest, + manifest: oneclient_content::bundles::BundleManifest, ) -> LauncherResult<()> { let disk_path = format!("bundles/{}.mrpack", manifest.name); let loader = manifest.loader as i64; @@ -294,13 +313,8 @@ pub async fn seed_bundle_archive( ) .await?; - let path = crate::paths::launcher_dir()?.join(&disk_path); - state - .bundles - .archive_cache - .write() - .await - .insert(path, manifest); + let path = oneclient_common::paths::launcher_dir()?.join(&disk_path); + state.bundles.cache_archive_manifest(path, manifest).await; Ok(()) } diff --git a/packages/oneclient_core/src/error.rs b/packages/oneclient_core/src/error.rs index d6620c2e..d0676701 100644 --- a/packages/oneclient_core/src/error.rs +++ b/packages/oneclient_core/src/error.rs @@ -15,10 +15,10 @@ pub enum LauncherError { StdIoError(#[from] std::io::Error), #[error(transparent)] - RequestError(#[from] crate::http::RequestError), + RequestError(#[from] oneclient_net::RequestError), - #[error("Could not resolve launcher data directory")] - DataDirUnavailable, + #[error(transparent)] + PathsError(#[from] oneclient_common::PathsError), #[error(transparent)] DbError(#[from] oneclient_db::DbError), @@ -33,37 +33,40 @@ pub enum LauncherError { InvalidSettingsProfile { reason: String }, #[error(transparent)] - JavaError(#[from] crate::java::JavaError), + JavaError(#[from] oneclient_java::JavaError), #[error(transparent)] UrlError(#[from] url::ParseError), #[error(transparent)] - NotificationError(#[from] crate::notification::NotificationError), + EventError(#[from] oneclient_events::EventError), #[error(transparent)] - PackageError(#[from] crate::packages::PackageError), + PackageError(#[from] oneclient_content::packages::PackageError), #[error(transparent)] - AuthError(#[from] crate::auth::AuthError), + AuthError(#[from] oneclient_auth::AuthError), #[error(transparent)] ClusterError(#[from] crate::clusters::ClusterError), #[error(transparent)] - MetadataError(#[from] crate::metadata::MetadataError), + McError(#[from] oneclient_mc::McError), #[error(transparent)] - BundleError(#[from] crate::bundles::BundleError), + ContentError(#[from] oneclient_content::ContentError), + + #[error(transparent)] + BundleError(#[from] oneclient_content::bundles::BundleError), #[error(transparent)] GameError(#[from] crate::game::GameError), #[error(transparent)] - LogsError(#[from] crate::logs::LogsError), + LogsError(#[from] oneclient_cluster::logs::LogsError), #[error(transparent)] - ScreenshotsError(#[from] crate::screenshots::ScreenshotsError), + ScreenshotsError(#[from] oneclient_cluster::screenshots::ScreenshotsError), #[error("minecraft: {0}")] Minecraft(String), @@ -71,10 +74,10 @@ pub enum LauncherError { impl LauncherError { #[must_use] - pub fn auth_guidance(&self) -> Option { + pub fn auth_guidance(&self) -> Option { match self { - LauncherError::AuthError(crate::auth::AuthError::Minecraft(err)) => { - crate::auth::diagnose_auth_error(err) + LauncherError::AuthError(oneclient_auth::AuthError::Minecraft(err)) => { + oneclient_auth::diagnose_auth_error(err) } _ => None, } @@ -94,8 +97,8 @@ impl SentryExclusion for LauncherError { match self { LauncherError::StdIoError(e) => e.is_sentry_excluded(), LauncherError::IoError(e) => e.is_sentry_excluded(), - LauncherError::RequestError(e) => e.is_sentry_excluded(), - LauncherError::JavaError(e) => e.is_sentry_excluded(), + LauncherError::RequestError(e) => e.is_transient(), + LauncherError::JavaError(e) => e.is_transient(), _ => false, } } diff --git a/packages/oneclient_core/src/game/analytics.rs b/packages/oneclient_core/src/game/analytics.rs index fc416f53..653d091e 100644 --- a/packages/oneclient_core/src/game/analytics.rs +++ b/packages/oneclient_core/src/game/analytics.rs @@ -6,7 +6,6 @@ use oneclient_db::dao::game_session as session_dao; use oneclient_db::models::{GameSessionServerRow, SessionSpan}; use crate::error::LauncherResult; -use crate::state::LauncherState; const NIGHT_HOURS: [usize; 8] = [22, 23, 0, 1, 2, 3, 4, 5]; const NIGHT_OWL_SHARE: f64 = 0.35; @@ -233,10 +232,8 @@ pub struct Analytics { pub servers: Vec, } -#[tracing::instrument(level = "debug")] -pub async fn global_analytics() -> LauncherResult { - let state = LauncherState::get()?; - let db = &state.services.db; +#[tracing::instrument(level = "debug", skip(db))] +pub async fn global_analytics(db: &oneclient_db::DbPool) -> LauncherResult { let spans = session_dao::all_session_spans(db).await?; let servers = session_dao::all_session_servers(db).await?; Ok(Analytics { @@ -245,10 +242,8 @@ pub async fn global_analytics() -> LauncherResult { }) } -#[tracing::instrument(level = "debug")] -pub async fn cluster_analytics(cluster_id: i64) -> LauncherResult { - let state = LauncherState::get()?; - let db = &state.services.db; +#[tracing::instrument(level = "debug", skip(db))] +pub async fn cluster_analytics(db: &oneclient_db::DbPool, cluster_id: i64) -> LauncherResult { let spans = session_dao::session_spans_for_cluster(db, cluster_id).await?; let servers = session_dao::session_servers_for_cluster(db, cluster_id).await?; Ok(Analytics { diff --git a/packages/oneclient_core/src/game/download.rs b/packages/oneclient_core/src/game/download.rs deleted file mode 100644 index de8adb20..00000000 --- a/packages/oneclient_core/src/game/download.rs +++ /dev/null @@ -1,140 +0,0 @@ -use reqwest::Method; - -use crate::crypto::{Sha1Stream, normalize_hash, sha1_bytes}; -use crate::game::GameError; -use crate::http::{RequestClient, ResponseExt, ResponseNotifyOptions, ResponseOptions}; -use crate::notification::{GroupedProgressSession, NotificationService, TaskCategory, TaskPhase}; -use crate::{LauncherError, LauncherResult}; - -#[allow(clippy::too_many_arguments)] -#[tracing::instrument(skip(requester, notifier, progress, label, expected_sha1), fields(%url), level = "debug")] -pub async fn download_to_path( - requester: &RequestClient, - notifier: &NotificationService, - progress: &GroupedProgressSession, - label: impl Into, - category: TaskCategory, - expected_size: u64, - url: &str, - dest: &std::path::Path, - expected_sha1: Option<&str>, -) -> LauncherResult<()> { - // Callers that fan out over thousands of files (assets) pre-create the - // directory tree, so this is usually a stat that avoids a blocking-pool - // round trip per file. - if let Some(parent) = dest.parent() - && !parent.is_dir() - { - polyio::create_dir_all(parent).await?; - } - - let label = label.into(); - let parsed_url = url.parse().map_err(LauncherError::UrlError)?; - let request = reqwest::Request::new(Method::GET, parsed_url); - - // Created before the request goes out: waiting on response headers is most - // of a small file's lifetime, and a child that only exists while bytes are - // moving never appears in the UI at all. `set_progress` raises the total if - // Content-Length turns out to be larger than the manifest size. - let child = progress.child(label, expected_size, category); - - let response = requester.send(request).await?; - - // Prefer the manifest size; fall back to Content-Length only when unknown. - let total = if expected_size > 0 { - expected_size - } else { - response.content_length().unwrap_or(0).max(1) - }; - - let stream = response - .stream( - ResponseOptions { - notify: Some(ResponseNotifyOptions::grouped(child.clone())), - }, - notifier, - ) - .await?; - - // Hash the bytes as they stream past. Verifying by re-reading the finished - // file costs a second full pass over every download, and it does it while - // still holding a slot in the caller's concurrency fan-out. - let mut hasher = expected_sha1.map(|_| Sha1Stream::new()); - { - let stream = futures_lite::StreamExt::map(stream, |item| { - if let (Ok(chunk), Some(hasher)) = (&item, hasher.as_mut()) { - hasher.update(chunk); - } - item - }); - let stream = std::pin::pin!(stream); - polyio::write_stream(dest, stream, Some(total)).await?; - } - - if let (Some(expected), Some(hasher)) = (expected_sha1, hasher) { - let actual = hasher.finish(); - if actual != normalize_hash(expected) { - let _ = polyio::remove_file(dest).await; - return Err(GameError::HashMismatch { - path: dest.display().to_string(), - expected: expected.to_string(), - actual, - } - .into()); - } - } - - child.finish(); - Ok(()) -} - -#[tracing::instrument(skip(requester, notifier, progress, label), fields(%url), level = "debug")] -#[allow(clippy::too_many_arguments)] -pub async fn fetch_bytes_verified( - requester: &RequestClient, - notifier: &NotificationService, - progress: &GroupedProgressSession, - label: impl Into, - category: TaskCategory, - expected_size: u64, - url: &str, - expected_sha1: &str, -) -> LauncherResult> { - let label = label.into(); - let parsed_url: url::Url = url.parse().map_err(LauncherError::UrlError)?; - let request = reqwest::Request::new(Method::GET, parsed_url.clone()); - - let child = progress.child(label, expected_size, category); - - let response = requester.send(request).await?; - - let stream = response - .stream( - ResponseOptions { - notify: Some(ResponseNotifyOptions::grouped(child.clone())), - }, - notifier, - ) - .await?; - - use futures_util::StreamExt; - let mut stream = std::pin::pin!(stream); - let mut bytes = Vec::new(); - while let Some(chunk) = stream.next().await { - bytes.extend_from_slice(&chunk?); - } - - child.set_phase(TaskPhase::Verifying); - let actual = sha1_bytes(&bytes); - if normalize_hash(&actual) != normalize_hash(expected_sha1) { - return Err(GameError::HashMismatch { - path: parsed_url.to_string(), - expected: expected_sha1.to_string(), - actual, - } - .into()); - } - - child.finish(); - Ok(bytes) -} diff --git a/packages/oneclient_core/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs index 03385a3e..ef697494 100644 --- a/packages/oneclient_core/src/game/launch.rs +++ b/packages/oneclient_core/src/game/launch.rs @@ -8,20 +8,21 @@ use interfrost::api::minecraft::ArgumentType; use tokio::process::Command; use crate::ClusterStage; -use crate::auth::MinecraftAccount; -use crate::clusters::{Cluster, ClusterManager}; -use crate::discord::Presence; +use oneclient_auth::MinecraftAccount; +use crate::clusters::Cluster; +use oneclient_discord::Presence; use crate::game::session::SessionRecorder; use crate::game::tail::spawn_log_tail; -use crate::game::{ - GameError, arguments, download_minecraft, download_version_info, get_loader_version, +use crate::game::GameError; +use oneclient_mc::{ + self as arguments, download_minecraft, download_version_info, get_loader_version, libraries_missing, resolve_minecraft_version, }; -use crate::java::JavaManager; -use crate::notification::{GroupedProgressSession, LaunchStage}; +use oneclient_events::{GroupedProgressSession, LaunchStage}; use crate::settings::GameSettingsProfile; use crate::state::LauncherState; -use crate::{LauncherResult, paths}; +use crate::LauncherResult; +use oneclient_common::paths; pub fn is_running(state: &LauncherState, cluster_id: i64) -> bool { state.games.is_running(cluster_id) @@ -48,15 +49,15 @@ pub async fn launch_cluster( return Err(GameError::AlreadyRunning(cluster_id).into()); } - let notifier = state.services.notifier.clone(); + let events = state.services.events.clone(); let stage = |s: LaunchStage| { state.games.set_stage(cluster_id, s); - notifier.game_stage(cluster_id, s); + events.game_stage(cluster_id, s); }; stage(LaunchStage::Checking); - let existing = ClusterManager::get(state, cluster_id).await?; + let existing = state.clusters.get(cluster_id).await?; let game_dir = existing.game_dir()?; if let Some(other) = state.games.dir_in_use_by(&game_dir, cluster_id) { @@ -66,7 +67,7 @@ pub async fn launch_cluster( let dedicated = existing.uses_dedicated_dir(); let progress = GroupedProgressSession::start( - &state.services.notifier, + &state.services.events, format!("Launching {}", existing.name), ); @@ -74,7 +75,7 @@ pub async fn launch_cluster( existing } else { stage(LaunchStage::Downloading); - match ClusterManager::prepare( + match crate::clusters::prepare_cluster_locked( state, cluster_id, false, @@ -92,32 +93,33 @@ pub async fn launch_cluster( } } }; - if let Err(err) = crate::bundles::install_cluster_bundles( + if let Err(err) = oneclient_content::bundles::install_cluster_bundles( cluster_id, state.bundles.as_ref(), Some(&progress), - &state.services, + &state.services.content(), ) .await { tracing::warn!(cluster_id, error = %err, "failed to install bundle content"); } - let profile = ClusterManager::resolve_settings(state, &cluster).await?; + let global = state.settings.read().global_game_settings.clone(); + let profile = state.clusters.resolve_settings(&global, &cluster).await?; - let mc_version = crate::version::normalize_mc_version_input(&cluster.mc_version); + let mc_version = oneclient_common::version::normalize_mc_version_input(&cluster.mc_version); let (version, updated, loader_version, version_info) = { let mut metadata = state.metadata.lock().await; let (version, _index, updated) = - resolve_minecraft_version(&mut metadata, &state.services, &mc_version) + resolve_minecraft_version(&mut metadata, &state.services.mc(), &mc_version) .await .map_err(|_| GameError::InvalidVersion(cluster.mc_version.clone()))?; let loader_version = get_loader_version( &mut metadata, - &state.services, + &state.services.mc(), &mc_version, cluster.mc_loader, cluster.mc_loader_version.as_deref(), @@ -125,7 +127,7 @@ pub async fn launch_cluster( .await?; let version_info = download_version_info( - &state.services, + &state.services.mc(), Some(&progress), &version, loader_version.as_ref(), @@ -141,7 +143,7 @@ pub async fn launch_cluster( Err(err) => { progress.finish(); stage(LaunchStage::Exited); - return Err(err); + return Err(err.into()); } }; @@ -162,7 +164,7 @@ pub async fn launch_cluster( ); let java = if let Some(runtime) = - JavaManager::java_for_profile(&state.services.db, profile.java_path.as_deref()).await? + state.java.runtime_for_profile(profile.java_path.as_deref()).await? { runtime } else { @@ -172,16 +174,16 @@ pub async fn launch_cluster( .map(|v| v.major_version) .ok_or(GameError::MissingJavaVersion)?; - JavaManager::prepare_java(state, major, search_for_java).await? + state.java.prepare(major, search_for_java, false, None).await? }; match libraries_missing(&version_info, &java.os_arch, updated) { Ok(true) => { tracing::info!(cluster_id, "missing game files; repairing"); - let _ = ClusterManager::set_stage(state, cluster_id, ClusterStage::Repairing).await; + let _ = state.clusters.set_stage(cluster_id, ClusterStage::Repairing).await; stage(LaunchStage::Downloading); if let Err(err) = download_minecraft( - &state.services, + &state.services.mc(), &progress, &version_info, &java.os_arch, @@ -192,9 +194,9 @@ pub async fn launch_cluster( { progress.finish(); stage(LaunchStage::Exited); - return Err(err); + return Err(err.into()); } - let _ = ClusterManager::set_stage(state, cluster_id, ClusterStage::Ready).await; + let _ = state.clusters.set_stage(cluster_id, ClusterStage::Ready).await; } Ok(false) => {} Err(err) => tracing::warn!(cluster_id, error = %err, "repair check failed"), @@ -288,7 +290,7 @@ pub async fn launch_cluster( .args(mc_args) .current_dir(&cwd); - let log_path = crate::logs::cluster_output_log(&cluster)?; + let log_path = oneclient_cluster::logs::cluster_output_log(&cluster)?; if let Some(parent) = log_path.parent() { polyio::create_dir_all(parent).await.ok(); } @@ -350,7 +352,7 @@ pub async fn launch_cluster( .as_ref() .and_then(SessionRecorder::started_at) .unwrap_or_else(Utc::now); - let tail = spawn_log_tail(cluster_id, log_path, notifier.clone(), recorder.clone()); + let tail = spawn_log_tail(cluster_id, log_path, events.clone(), recorder.clone()); let (kill_tx, kill_rx) = tokio::sync::oneshot::channel::<()>(); state.games.register_kill(cluster_id, kill_tx); @@ -440,7 +442,7 @@ pub(crate) struct SessionEnd { pub outcome: Exit, /// Whether this session is the one currently holding the cluster's running /// slot. A stale session recovered from the database may share its cluster - /// with a game that is playing right now — booking the old session's + /// with a game that is playing right now. Booking the old session's /// playtime is right, but clearing that slot would report the live game as /// exited. pub owns_slot: bool, @@ -466,7 +468,7 @@ pub(crate) async fn finalize_session( state.games.remove(cluster_id); state .services - .notifier + .events .game_stage(cluster_id, LaunchStage::Exited); if state.games.running_ids().is_empty() { @@ -475,7 +477,7 @@ pub(crate) async fn finalize_session( } if played > Duration::from_secs(1) { - let _ = ClusterManager::add_playtime(state, cluster_id, played).await; + let _ = state.clusters.add_playtime(cluster_id, played).await; } if let Some(recorder) = recorder { @@ -500,18 +502,18 @@ pub(crate) async fn finalize_session( match end.outcome { Exit::Observed { success: true, .. } => state .services - .notifier - .send_info("Game closed", &format!("{name} exited")), + .events + .notify("Game closed").body(format!("{name} exited")).send(), Exit::Observed { display, .. } => state .services - .notifier - .send_error("Game crashed", &format!("{name} exited with {display}")), + .events + .notify("Game crashed").body(format!("{name} exited with {display}")).error().send(), Exit::Failed(err) => state .services - .notifier - .send_error("Game error", &format!("{name}: {err}")), + .events + .notify("Game error").body(format!("{name}: {err}")).error().send(), // Nothing was watching, so there is no crash to report and no news the - // user wants a popup about — the session is simply booked and closed. + // user wants a popup about, so the session is just booked and closed. Exit::Inferred => {} } } diff --git a/packages/oneclient_core/src/game/log_replay.rs b/packages/oneclient_core/src/game/log_replay.rs index 9e0a536c..283a9f62 100644 --- a/packages/oneclient_core/src/game/log_replay.rs +++ b/packages/oneclient_core/src/game/log_replay.rs @@ -5,7 +5,7 @@ //! it to recover when the session ended and which servers it visited. //! //! Minecraft logs a bare wall-clock time (`[12:29:00]`) in the machine's local -//! zone — no date, no offset. Absolute timestamps therefore have to be rebuilt +//! zone: no date, no offset. Absolute timestamps therefore have to be rebuilt //! by anchoring at the session start and walking forward. use chrono::{DateTime, Local, LocalResult, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc}; @@ -18,7 +18,7 @@ const ROLLOVER_SLACK_SECS: i64 = 12 * 60 * 60; /// Lines that mean "no longer connected to the server we were tracking". /// Absent any of these the span is closed by the next join, or by the session -/// end — a missing marker costs accuracy, never correctness. +/// end. A missing marker costs accuracy, never correctness. const LEAVE_MARKERS: &[&str] = &[ "Stopping!", "Stopping worker threads", @@ -62,8 +62,8 @@ fn local_to_utc(date: NaiveDate, time: NaiveTime) -> DateTime { // better guess for a forward-walking log. LocalResult::Ambiguous(first, _) => first.with_timezone(&Utc), // DST start: this wall-clock time never existed, so step over the gap - // rather than reading the local time as if it were UTC — that would be - // wrong by the zone's whole offset. + // rather than reading the local time as if it were UTC, which would be wrong + // by the zone's whole offset. LocalResult::None => Local .from_local_datetime(&(naive + chrono::Duration::hours(1))) .earliest() @@ -112,7 +112,7 @@ pub(crate) struct ServerSpan { #[derive(Debug, Default, Clone)] pub(crate) struct LogReplay { - /// Timestamp of the last line carrying one — a lower bound on the exit. + /// Timestamp of the last line carrying one, a lower bound on the exit. pub last_activity: Option>, /// Set when the game logged a clean shutdown. pub stopped_at: Option>, diff --git a/packages/oneclient_core/src/game/mod.rs b/packages/oneclient_core/src/game/mod.rs index 517ab776..fda23d5e 100644 --- a/packages/oneclient_core/src/game/mod.rs +++ b/packages/oneclient_core/src/game/mod.rs @@ -1,13 +1,9 @@ mod analytics; -mod arguments; -mod download; mod error; mod launch; mod log_replay; -mod metadata; mod process; mod reattach; -mod rules; mod session; mod shared_dir; mod tail; @@ -16,23 +12,24 @@ pub use analytics::{ Analytics, DayPlaytime, Persona, PlaytimeStats, ServerStat, WEEKDAY_LABELS, aggregate_servers, cluster_analytics, global_analytics, }; -pub use arguments::{ - append_profile_game_arguments, classpaths, get_classpath_library, get_library, - java_arguments, main_class, minecraft_arguments, processor_arguments, +// Minecraft content lives in `oneclient_mc` now; re-exported so the rest of the +// launcher keeps addressing it through `game::`. +pub use oneclient_mc::{ + append_profile_game_arguments, classpaths, download_to_path, fetch_bytes_verified, + get_classpath_library, get_library, java_arguments, main_class, minecraft_arguments, + processor_arguments, }; -pub use download::{download_to_path, fetch_bytes_verified}; pub use error::GameError; pub use launch::{LaunchedGame, is_running, launch_cluster}; pub use process::{ GameProcess, GameProcessManager, is_process_alive, kill_process, process_start_time, }; pub use reattach::recover_sessions; -pub use metadata::{ - DownloadPlan, download_minecraft, plan_downloads, download_version_info, get_game_versions, get_loader_version, - get_loader_versions, get_loaders_for_version, is_version_updated, libraries_missing, - resolve_minecraft_version, +pub use oneclient_mc::{ + DownloadPlan, download_minecraft, download_version_info, get_game_versions, + get_loader_version, get_loader_versions, get_loaders_for_version, is_version_updated, + libraries_missing, plan_downloads, resolve_minecraft_version, validate_rules, }; -pub use rules::{validate_rule, validate_rules}; pub use shared_dir::{ clear_shared_content, import_manual_content, link_cluster_logs, sync_shared_content, unlink_cluster_logs, write_allowed_symlinks, diff --git a/packages/oneclient_core/src/game/process.rs b/packages/oneclient_core/src/game/process.rs index e9e73d25..802ed471 100644 --- a/packages/oneclient_core/src/game/process.rs +++ b/packages/oneclient_core/src/game/process.rs @@ -7,7 +7,7 @@ use std::time::Instant; use sysinfo::{Pid, ProcessesToUpdate, Signal, System}; use tokio::sync::oneshot; -use crate::notification::LaunchStage; +use oneclient_events::LaunchStage; fn probe(pid: u32) -> Option<(System, Pid)> { let pid = Pid::from_u32(pid); @@ -17,14 +17,14 @@ fn probe(pid: u32) -> Option<(System, Pid)> { Some((sys, pid)) } -/// Process start time in unix seconds — pids get recycled, this pins identity. +/// Process start time in unix seconds. Pids get recycled, this pins identity. pub fn process_start_time(pid: u32) -> Option { let (sys, pid) = probe(pid)?; Some(sys.process(pid)?.start_time()) } /// Whether `pid` is still the process we launched. A matching pid whose start -/// time differs is a recycled pid belonging to someone else — never ours. +/// time differs is a recycled pid belonging to someone else, never ours. pub fn is_process_alive(pid: u32, started_at: Option) -> bool { let Some(actual) = process_start_time(pid) else { return false; diff --git a/packages/oneclient_core/src/game/reattach.rs b/packages/oneclient_core/src/game/reattach.rs index 34791306..19ca41da 100644 --- a/packages/oneclient_core/src/game/reattach.rs +++ b/packages/oneclient_core/src/game/reattach.rs @@ -4,9 +4,9 @@ //! down with it. That leaves two loose ends to tidy on the next start, both of //! them sessions whose row still has no `ended_at`: //! -//! * the game is **still running** — re-adopt it, so the UI shows it as playing -//! and its exit is recorded properly when it finally happens; -//! * the game **already exited** unobserved — its log is the only witness, so +//! * the game is still running, so re-adopt it and the UI shows it as playing +//! with its exit recorded properly when it finally happens; +//! * the game already exited unobserved, and its log is the only witness, so //! replay it to recover when it ended and which servers it visited. use std::path::{Path, PathBuf}; @@ -17,14 +17,14 @@ use chrono::{DateTime, Utc}; use oneclient_db::dao::game_session as session_dao; use oneclient_db::models::UnfinishedSession; -use crate::clusters::{Cluster, ClusterManager}; -use crate::discord::Presence; +use crate::clusters::Cluster; +use oneclient_discord::Presence; use crate::game::launch::{Exit, SessionEnd, finalize_session}; use crate::game::log_replay::{self, ServerSpan}; use crate::game::process::{is_process_alive, kill_process}; use crate::game::session::SessionRecorder; use crate::game::tail::spawn_log_tail; -use crate::notification::LaunchStage; +use oneclient_events::LaunchStage; use crate::state::LauncherState; /// How often to check whether a re-adopted game is still alive. It is only a @@ -64,8 +64,8 @@ pub async fn recover_sessions(state: &Arc) { } // Re-adopt the live games first. Reconciling an exited session tears down - // its shared game directory, which a still-running cluster may be sharing — - // registering the live ones up front is what makes that check possible. + // its shared game directory, which a still-running cluster may be sharing. + // That check reads the live registrations, so they have to go in first. for (cluster, session, started_at, pid) in live { readopt(state, cluster, session, started_at, pid).await; } @@ -99,7 +99,7 @@ async fn classify( // The cluster is gone, so there is no log to consult and no playtime worth // attributing. Just close the row so it stops being reconsidered forever. - let Ok(cluster) = ClusterManager::get(state, cluster_id).await else { + let Ok(cluster) = state.clusters.get(cluster_id).await else { tracing::warn!(cluster_id, "cluster missing for unfinished session; closing it"); close_untraceable(state, &session).await; return None; @@ -154,19 +154,19 @@ async fn readopt( state.games.set_stage(cluster_id, LaunchStage::Running); state.games.set_pid(cluster_id, Some(pid)); state.games.set_dir(cluster_id, cwd.clone()); - state.services.notifier.game_stage(cluster_id, LaunchStage::Running); + state.services.events.game_stage(cluster_id, LaunchStage::Running); state.discord.set_presence(Presence::Playing { cluster: cluster.name.clone(), mc_version: cluster.mc_version.clone(), }); - let Ok(log_path) = crate::logs::cluster_output_log(&cluster) else { + let Ok(log_path) = oneclient_cluster::logs::cluster_output_log(&cluster) else { return; }; let tail = spawn_log_tail( cluster_id, log_path, - state.services.notifier.clone(), + state.services.events.clone(), Some(recorder.clone()), ); @@ -175,7 +175,8 @@ async fn readopt( // The post hook belongs to whichever settings apply now; resolve it up front // so the watcher task doesn't have to reach back into the state. - let post_hook = ClusterManager::resolve_settings(state, &cluster) + let global = state.settings.read().global_game_settings.clone(); + let post_hook = state.clusters.resolve_settings(&global, &cluster) .await .ok() .and_then(|profile| profile.hook_post); @@ -284,26 +285,26 @@ async fn reconcile( &cluster, &cwd, cluster.uses_dedicated_dir() || shared_dir_busy, - // The post hook is deliberately skipped. It is an *on exit* hook, and - // the exit already happened — possibly days ago. Firing it now, during - // startup, would surprise anyone whose hook does something real. + // The post hook is skipped on purpose. It is an *on exit* hook, and the exit + // already happened, possibly days ago. Firing it now, during startup, would + // surprise anyone whose hook does something real. None, Some(recorder), SessionEnd { started_at, ended_at, outcome: Exit::Inferred, - // This session is over, but the cluster may have a newer one playing - // right now — only claim the slot if nothing else holds it. + // This session is over, but the cluster may have a newer one playing right + // now, so only claim the slot if nothing else holds it. owns_slot: !state.games.is_active(cluster_id), }, ) .await; } -/// Rewrite a session's server spans from the log replay, which — unlike the -/// live rows — covers the whole session including the part we missed. Returns -/// the `joined_at` of a span still left open, for the caller to close. +/// Rewrite a session's server spans from the log replay, which unlike the live +/// rows covers the whole session including the part we missed. Returns the +/// `joined_at` of a span still left open, for the caller to close. async fn apply_spans( state: &Arc, session_id: &str, @@ -385,7 +386,7 @@ struct SessionLog { /// whereas `latest.log` is the game's own and may already have been rotated. async fn read_session_log(cluster: &Cluster) -> Option { let mut candidates: Vec = Vec::new(); - if let Ok(path) = crate::logs::cluster_output_log(cluster) { + if let Ok(path) = oneclient_cluster::logs::cluster_output_log(cluster) { candidates.push(path); } if let Ok(dir) = cluster.dir() { diff --git a/packages/oneclient_core/src/game/session.rs b/packages/oneclient_core/src/game/session.rs index c0760ccc..75e85faf 100644 --- a/packages/oneclient_core/src/game/session.rs +++ b/packages/oneclient_core/src/game/session.rs @@ -5,8 +5,8 @@ use oneclient_db::dao::game_session as session_dao; use oneclient_db::models::NewGameSession; use tokio::sync::Mutex; -use crate::java::JavaRuntime; -use crate::packages::{ContentType, PackageStore}; +use oneclient_java::JavaRuntime; +use oneclient_content::packages::{ContentType, PackageStore}; use crate::state::LauncherState; #[derive(Debug, Clone, PartialEq, Eq)] @@ -15,7 +15,7 @@ pub(crate) struct ServerJoin { pub port: Option, } -/// The session row's identity — its `started_at`, which is the primary key. +/// The session row's identity: its `started_at`, which is the primary key. pub(crate) type SessionId = String; pub(crate) fn parse_server_join(line: &str) -> Option { @@ -161,7 +161,7 @@ impl SessionRecorder { } /// Close the session. The time is explicit because an exit is not always - /// observed as it happens — one recovered from a log ended in the past. + /// observed as it happens; one recovered from a log ended in the past. #[tracing::instrument(skip(self), fields(exit_code), level = "debug")] pub(crate) async fn finish_at(self, ended_at: &str, exit_code: Option) { if let Some(open) = self.open_server.lock().await.take() @@ -181,7 +181,7 @@ impl SessionRecorder { #[tracing::instrument(skip(state), fields(cluster_id), level = "debug")] async fn count_enabled_mods(state: &Arc, cluster_id: i64) -> usize { - match PackageStore::list_linked_artifacts(cluster_id, &state.services).await { + match PackageStore::list_linked_artifacts(cluster_id, &state.services.content()).await { Ok(linked) => linked .into_iter() .filter(|a| a.enabled && a.content_type == ContentType::Mod) diff --git a/packages/oneclient_core/src/game/shared_dir.rs b/packages/oneclient_core/src/game/shared_dir.rs index 36600b5b..9af2cbf7 100644 --- a/packages/oneclient_core/src/game/shared_dir.rs +++ b/packages/oneclient_core/src/game/shared_dir.rs @@ -6,9 +6,9 @@ use oneclient_db::dao::artifact as artifact_dao; use crate::LauncherResult; use crate::clusters::Cluster; -use crate::packages::domain::ContentType; -use crate::packages::store::{artifact_absolute_path, link_or_copy}; -use crate::packages::{LinkedArtifactInfo, PackageStore}; +use oneclient_common::domain::ContentType; +use oneclient_content::packages::store::{artifact_absolute_path, link_or_copy}; +use oneclient_content::packages::{LinkedArtifactInfo, PackageStore}; use crate::state::LauncherServices; const REDIRECTED_DIRS: [&str; 2] = ["logs", "crash-reports"]; @@ -31,7 +31,7 @@ pub async fn sync_shared_content( import_manual_content(services, cluster, game_dir).await; - let linked = PackageStore::list_linked_artifacts(cluster.id, services).await?; + let linked = PackageStore::list_linked_artifacts(cluster.id, &services.content()).await?; for content_type in SWAP_TYPES { let dir = game_dir.join(content_type.folder_name()); @@ -40,8 +40,8 @@ pub async fn sync_shared_content( let managed = managed_names(&linked, content_type); - // Anything still here belongs to whoever played last (or crashed last) - // — take it into this cluster rather than deleting it. + // Anything still here belongs to whoever played last (or crashed last), so + // take it into this cluster rather than deleting it. stash_content_files(&dir, &stash, &managed).await; ensure_note(&dir, content_type).await; restore_stashed(&stash, &dir, &managed).await; @@ -87,7 +87,7 @@ pub async fn import_manual_content( cluster: &Cluster, game_dir: &Path, ) { - let linked = match PackageStore::list_linked_artifacts(cluster.id, services).await { + let linked = match PackageStore::list_linked_artifacts(cluster.id, &services.content()).await { Ok(linked) => linked, Err(err) => { tracing::warn!(error = %err, "failed to list links; skipping manual-content import"); @@ -126,7 +126,7 @@ pub async fn import_manual_content( continue; } - match PackageStore::import_local_file(&path, content_type, cluster.id, services).await { + match PackageStore::import_local_file(&path, content_type, cluster.id, &services.content()).await { Ok(_) => { tracing::debug!(file = name, "registered manually-added shared content") } @@ -153,7 +153,7 @@ const ALLOWED_SYMLINKS_NAME: &str = "allowed_symlinks.txt"; #[tracing::instrument(level = "debug")] pub async fn write_allowed_symlinks(game_dir: &Path) -> LauncherResult<()> { - let root = crate::paths::launcher_dir()?; + let root = oneclient_common::paths::launcher_dir()?; let base = polyio::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); let sep = std::path::MAIN_SEPARATOR; @@ -178,7 +178,7 @@ fn managed_names(linked: &[LinkedArtifactInfo], content_type: ContentType) -> Ha /// Empty a shared content folder without losing anything the game wrote into /// it. /// -/// Managed content is dropped — the cluster folder already holds an equivalent +/// Managed content is dropped, since the cluster folder already holds an /// link into the artifact cache. Everything else (a shaderpack's settings /// sidecar, an unzipped pack, a stray config) is *moved* into the cluster's own /// copy of the folder, and [`restore_stashed`] links it back on the next @@ -242,7 +242,7 @@ async fn restore_stashed(stash: &Path, dir: &Path, managed: &HashSet) { let Ok(file_type) = entry.file_type().await else { continue; }; - // A link into the artifact cache — managed content is linked from the + // A link into the artifact cache. Managed content is linked from the // database further down, not from here. if file_type.is_symlink() { continue; @@ -327,7 +327,7 @@ pub async fn clear_shared_content( ) -> LauncherResult<()> { // Run after `import_manual_content`, so anything the user dropped in is // already a tracked artifact by now and gets dropped rather than stashed. - let linked = PackageStore::list_linked_artifacts(cluster.id, services) + let linked = PackageStore::list_linked_artifacts(cluster.id, &services.content()) .await .unwrap_or_default(); @@ -457,22 +457,9 @@ async fn sync_fabric_dep_overrides(cluster: &Cluster, game_dir: &Path) -> Launch #[cfg(test)] mod tests { - use std::path::PathBuf; - use std::sync::atomic::{AtomicU64, Ordering}; use super::*; - fn tmp_root(tag: &str) -> PathBuf { - static COUNTER: AtomicU64 = AtomicU64::new(0); - let n = COUNTER.fetch_add(1, Ordering::Relaxed); - - let dir = - std::env::temp_dir().join(format!("oneclient_shd_{}_{tag}_{n}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - - dir - } - fn managed(names: &[&str]) -> HashSet { names.iter().map(|n| (*n).to_string()).collect() } @@ -481,7 +468,7 @@ mod tests { /// end up in the cluster rather than the shared dir. #[tokio::test] async fn shader_settings_survive_a_session() { - let root = tmp_root("shader_settings"); + let root = polyio::testing::ScratchDir::new("shader_settings"); let shared = root.join("shared").join("shaderpacks"); let stash = root.join("cluster").join("shaderpacks"); polyio::create_dir_all(&shared).await.unwrap(); @@ -521,13 +508,13 @@ mod tests { "BLOOM=on" ); - std::fs::remove_dir_all(&root).ok(); + std::fs::remove_dir_all(root.path()).ok(); } /// An unzipped pack is a directory; it moves out and comes back as a link. #[tokio::test] async fn unpacked_dirs_move_into_the_cluster() { - let root = tmp_root("unpacked"); + let root = polyio::testing::ScratchDir::new("unpacked"); let shared = root.join("shared").join("shaderpacks"); let stash = root.join("cluster").join("shaderpacks"); polyio::create_dir_all(shared.join("Loose/shaders")).await.unwrap(); @@ -547,13 +534,13 @@ mod tests { assert!(!shared.join("Loose").exists()); assert!(stash.join("Loose/shaders/final.fsh").exists()); - std::fs::remove_dir_all(&root).ok(); + std::fs::remove_dir_all(root.path()).ok(); } /// The note explaining the empty folder is ours, and stays put. #[tokio::test] async fn note_is_never_stashed() { - let root = tmp_root("note"); + let root = polyio::testing::ScratchDir::new("note"); let shared = root.join("shared").join("mods"); let stash = root.join("cluster").join("mods"); polyio::create_dir_all(&shared).await.unwrap(); @@ -565,6 +552,6 @@ mod tests { assert!(shared.join(EMPTY_NOTE_NAME).exists()); assert!(!stash.join(EMPTY_NOTE_NAME).exists()); - std::fs::remove_dir_all(&root).ok(); + std::fs::remove_dir_all(root.path()).ok(); } } diff --git a/packages/oneclient_core/src/game/tail.rs b/packages/oneclient_core/src/game/tail.rs index 44d4cd99..976a51d4 100644 --- a/packages/oneclient_core/src/game/tail.rs +++ b/packages/oneclient_core/src/game/tail.rs @@ -6,7 +6,7 @@ use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncSeekExt, BufReader}; use crate::game::session::SessionRecorder; -use crate::notification::NotificationService; +use oneclient_events::EventBus; const POLL_INTERVAL: Duration = Duration::from_millis(200); @@ -28,14 +28,14 @@ impl LogTail { pub(crate) fn spawn_log_tail( cluster_id: i64, path: PathBuf, - notifier: NotificationService, + events: EventBus, recorder: Option, ) -> LogTail { let stop = Arc::new(AtomicBool::new(false)); let flag = Arc::clone(&stop); let handle = tokio::spawn(async move { - if let Err(err) = tail(cluster_id, &path, ¬ifier, recorder.as_ref(), &flag).await { + if let Err(err) = tail(cluster_id, &path, &events, recorder.as_ref(), &flag).await { tracing::warn!(cluster_id, path = %path.display(), error = %err, "game log tail stopped"); } }); @@ -46,7 +46,7 @@ pub(crate) fn spawn_log_tail( async fn tail( cluster_id: i64, path: &PathBuf, - notifier: &NotificationService, + events: &EventBus, recorder: Option<&SessionRecorder>, stop: &AtomicBool, ) -> std::io::Result<()> { @@ -92,11 +92,11 @@ async fn tail( // A partial line means the game is mid-write; wait for the newline // rather than emitting half a line and re-emitting the rest. if !line.ends_with('\n') { - // Unless the game is already gone — a process killed mid-write + // Unless the game is already gone. A process killed mid-write // leaves a newline that will never arrive, and waiting for it would // hang the caller of `stop()` forever. if stop.load(Ordering::Relaxed) { - emit(cluster_id, &line, notifier, recorder).await; + emit(cluster_id, &line, events, recorder).await; return Ok(()); } reader.seek(std::io::SeekFrom::Start(offset - read as u64)).await?; @@ -105,14 +105,14 @@ async fn tail( continue; } - emit(cluster_id, &line, notifier, recorder).await; + emit(cluster_id, &line, events, recorder).await; } } async fn emit( cluster_id: i64, line: &str, - notifier: &NotificationService, + events: &EventBus, recorder: Option<&SessionRecorder>, ) { // Blank lines are kept: the game prints them, and the console should read @@ -121,20 +121,19 @@ async fn emit( if let Some(recorder) = recorder { recorder.observe(&text).await; } - notifier.game_log(cluster_id, text); + events.game_log(cluster_id, text); } #[cfg(test)] mod tests { use super::*; - use crate::notification::Notification; use tokio::io::AsyncWriteExt; use tokio::sync::mpsc; struct Harness { dir: async_tempfile::TempDir, - rx: mpsc::UnboundedReceiver, - notifier: NotificationService, + rx: oneclient_events::EventReceiver, + events: EventBus, } impl Harness { @@ -145,7 +144,7 @@ mod tests { Self { dir, rx, - notifier: NotificationService::new(tx), + events: EventBus::new(tx), } } @@ -156,7 +155,9 @@ mod tests { /// Drain whatever the tail has emitted so far. fn drain(&mut self) -> Vec { let mut out = Vec::new(); - while let Ok(Notification::GameLog { line, .. }) = self.rx.try_recv() { + while let Ok(oneclient_events::Event::Game(oneclient_events::GameEvent::Log { line, .. })) = + self.rx.try_recv() + { out.push(line); } out @@ -174,7 +175,7 @@ mod tests { let path = h.path(); polyio::write(&path, "[12:00:00] first\n").await.unwrap(); - let tail = spawn_log_tail(7, path.clone(), h.notifier.clone(), None); + let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None); settle().await; let mut file = tokio::fs::OpenOptions::new() @@ -195,7 +196,7 @@ mod tests { let mut h = Harness::new().await; let path = h.path(); - let tail = spawn_log_tail(7, path.clone(), h.notifier.clone(), None); + let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None); settle().await; polyio::write(&path, "[12:00:00] late\n").await.unwrap(); settle().await; @@ -210,7 +211,7 @@ mod tests { let path = h.path(); polyio::write(&path, "[12:00:00] half").await.unwrap(); - let tail = spawn_log_tail(7, path.clone(), h.notifier.clone(), None); + let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None); settle().await; // A line mid-write must not be emitted, or it would arrive twice. assert!(h.drain().is_empty()); @@ -234,7 +235,7 @@ mod tests { let path = h.path(); polyio::write(&path, "[12:00:00] old session line\n").await.unwrap(); - let tail = spawn_log_tail(7, path.clone(), h.notifier.clone(), None); + let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None); settle().await; assert_eq!(h.drain(), vec!["[12:00:00] old session line"]); @@ -254,7 +255,7 @@ mod tests { // A process killed mid-write leaves a newline that never arrives. polyio::write(&path, "[12:00:00] cut off mid-writ").await.unwrap(); - let tail = spawn_log_tail(7, path.clone(), h.notifier.clone(), None); + let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None); settle().await; // Waiting for that newline would hang the exit path forever. @@ -271,7 +272,7 @@ mod tests { // Stopping immediately, as the exit path does, must still flush the // reason the game exited rather than race past it. - let tail = spawn_log_tail(7, path.clone(), h.notifier.clone(), None); + let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None); tail.stop().await; assert_eq!(h.drain(), vec!["[12:00:00] Stopping!"]); diff --git a/packages/oneclient_core/src/http/mod.rs b/packages/oneclient_core/src/http/mod.rs deleted file mode 100644 index 5cbf45eb..00000000 --- a/packages/oneclient_core/src/http/mod.rs +++ /dev/null @@ -1,59 +0,0 @@ -mod request; -mod response; -mod service; - -pub use request::*; -pub use response::*; -pub use service::*; - -#[derive(Debug, thiserror::Error)] -pub enum RequestError { - #[error(transparent)] - ReqwestError(#[from] reqwest::Error), - - #[error("IO Error: {0}")] - IOError(#[from] polyio::IOError), - - #[error( - "Failed to parse {type_name} from {url} (HTTP {status}): {source} — body starts: {snippet}" - )] - DeserializeError { - #[source] - source: serde_json::Error, - type_name: String, - url: String, - status: u16, - snippet: String, - }, - - #[error("HTTP {status} from {url}: {snippet}")] - HttpStatus { - status: u16, - url: String, - snippet: String, - }, - - #[error("Failed to serialize request body: {0}")] - SerializeError(#[source] serde_json::Error), - - #[error("Invalid URL: {0}")] - UrlParseError(#[from] url::ParseError), - - #[error("Invalid HTTP header name: {0}")] - InvalidHeaderName(#[from] reqwest::header::InvalidHeaderName), - - #[error("Invalid HTTP header value: {0}")] - InvalidHeaderValue(#[from] reqwest::header::InvalidHeaderValue), -} - -impl crate::error::SentryExclusion for RequestError { - fn is_sentry_excluded(&self) -> bool { - // Only connectivity / disk failures are noise; malformed requests, bad - // URLs, and unexpected response shapes are bugs worth reporting. - match self { - RequestError::ReqwestError(source) => source.is_sentry_excluded(), - RequestError::IOError(source) => source.is_sentry_excluded(), - _ => false, - } - } -} \ No newline at end of file diff --git a/packages/oneclient_core/src/images/mod.rs b/packages/oneclient_core/src/images/mod.rs index b2de265d..67fc5bfd 100644 --- a/packages/oneclient_core/src/images/mod.rs +++ b/packages/oneclient_core/src/images/mod.rs @@ -5,10 +5,10 @@ use bytes::Bytes; use reqwest::Method; use tokio::sync::Mutex; -use crate::crypto::sha1_bytes; -use crate::http::RequestError; -use crate::paths; -use crate::state::LauncherServices; +use polyio::sha1_bytes; +use oneclient_net::RequestError; +use oneclient_common::paths; +use oneclient_net::RequestClient; use crate::LauncherResult; pub const DEFAULT_IMAGE_EDGE: u32 = 1600; @@ -24,10 +24,10 @@ impl ImageCacheStore { Self::default() } - #[tracing::instrument(level = "debug", skip(self, services))] + #[tracing::instrument(level = "debug", skip(self, net))] pub async fn get( &self, - services: &LauncherServices, + net: &RequestClient, url: &str, max_edge: u32, ) -> LauncherResult { @@ -44,7 +44,7 @@ impl ImageCacheStore { return Ok(bytes); } - let raw = download(services, url).await?; + let raw = download(net, url).await?; let bytes = downscale_if_oversized(raw, max_edge).await; if let Some(parent) = path.parent() { @@ -108,11 +108,11 @@ fn downscale(bytes: &[u8], max_edge: u32) -> Option { Some(Bytes::from(out)) } -#[tracing::instrument(level = "debug", skip(services))] -async fn download(services: &LauncherServices, url: &str) -> LauncherResult { +#[tracing::instrument(level = "debug", skip(net))] +async fn download(net: &RequestClient, url: &str) -> LauncherResult { let parsed = url.parse().map_err(RequestError::from)?; let request = reqwest::Request::new(Method::GET, parsed); - let res = services.requester.send(request).await?; + let res = net.send(request).await?; let bytes = res.bytes().await.map_err(RequestError::from)?; Ok(bytes) } diff --git a/packages/oneclient_core/src/java/error.rs b/packages/oneclient_core/src/java/error.rs deleted file mode 100644 index a825ce43..00000000 --- a/packages/oneclient_core/src/java/error.rs +++ /dev/null @@ -1,58 +0,0 @@ -use thiserror::Error; - -pub type JavaResult = Result; - -#[derive(Debug, Error)] -pub enum JavaError { - #[error("Error spawning Java runtime from path '{path}': {source}")] - RuntimeCheckError { - #[source] - source: std::io::Error, - path: String, - }, - - #[error("IO Error: {0}")] - PolyIOError(#[from] polyio::IOError), - - #[error("no java package found for major version {major}")] - PackageNotFound { major: u32 }, - - #[error("no java installations available")] - MissingJava, - - #[error("java setup was cancelled")] - Cancelled, - - #[error("selected path is not a valid java installation: {path}")] - InvalidJavaPath { path: String }, - - #[error("selected java is version {found}, but version {expected} is required")] - VersionMismatch { expected: u32, found: u32 }, - - #[error("failed to parse java version '{version}'")] - ParseVersion { version: String }, - - #[error("failed to extract archive '{archive}': {source}")] - ArchiveExtract { - archive: String, - source: std::io::Error, - }, - - #[error("failed to extract archive '{archive}'")] - ArchiveExtractFailed { archive: String }, -} - -impl crate::error::SentryExclusion for JavaError { - fn is_sentry_excluded(&self) -> bool { - match self { - // Expected outcomes: no build exists for the requested version, or the - // user cancelled setup. - JavaError::PackageNotFound { .. } | JavaError::Cancelled => true, - // Environmental IO (e.g. out of disk while extracting the runtime). - JavaError::RuntimeCheckError { source, .. } - | JavaError::ArchiveExtract { source, .. } => source.is_sentry_excluded(), - JavaError::PolyIOError(source) => source.is_sentry_excluded(), - _ => false, - } - } -} \ No newline at end of file diff --git a/packages/oneclient_core/src/java/manager.rs b/packages/oneclient_core/src/java/manager.rs deleted file mode 100644 index 5079747a..00000000 --- a/packages/oneclient_core/src/java/manager.rs +++ /dev/null @@ -1,316 +0,0 @@ -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; -use std::sync::Arc; - -use oneclient_db::{dao, DbPool}; - -use crate::java::checker::JavaCheckInfo; -use crate::java::data::{JavaPackage, JavaRuntime}; -use crate::java::resolve::resolve_java_executable; -use crate::java::vendors::{self, JavaVendor}; -use crate::java::{self, JavaError}; -use crate::notification::UserChoice; -use crate::state::LauncherState; -use crate::{LauncherResult, LauncherServices}; - -pub const INSTALLABLE_MAJORS: &[u32] = &[8, 11, 16, 17, 18, 19, 20, 21, 22, 23]; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AvailableJava { - pub major: u32, - pub package: JavaPackage, -} - -pub struct JavaManager; - -impl JavaManager { - #[tracing::instrument(skip(state))] - pub async fn prepare_java( - state: &Arc, - major: u32, - search_system: bool, - ) -> LauncherResult { - Self::prepare_java_with_services(&state.services, major, search_system, false, None).await - } - - #[tracing::instrument(skip(state))] - pub async fn prepare_java_auto( - state: &Arc, - major: u32, - search_system: bool, - ) -> LauncherResult { - Self::prepare_java_with_services(&state.services, major, search_system, true, None).await - } - - #[tracing::instrument(level = "debug", skip(services, progress))] - pub async fn prepare_java_with_services( - services: &LauncherServices, - major: u32, - search_system: bool, - auto_install: bool, - progress: Option<&crate::notification::GroupedProgressSession>, - ) -> LauncherResult { - if let Some(runtime) = get_latest_runtime(&services.db, major).await? { - return Ok(runtime); - } - - if search_system { - register_located_runtimes(&services.db, java::locate::locate_java().await?).await?; - - if let Some(runtime) = get_latest_runtime(&services.db, major).await? { - return Ok(runtime); - } - } - - if auto_install { - return download_and_register(major, services, progress).await; - } - - match services.notifier.prompt_java_install(major).await? { - UserChoice::Accept => download_and_register(major, services, progress).await, - UserChoice::Folder(folder) => register_custom_java(&services.db, folder, major).await, - UserChoice::Install { vendor, .. } => { - Self::install_runtime_from(services, &vendor, major).await - } - UserChoice::Cancel => Err(JavaError::Cancelled.into()), - } - } - - #[tracing::instrument(level = "debug", skip(pool))] - pub async fn list_runtimes(pool: &DbPool) -> LauncherResult> { - let rows = dao::java::list_all(pool).await?; - Ok(rows.into_iter().map(JavaRuntime::from_row).collect()) - } - - #[tracing::instrument(level = "debug", skip(pool))] - pub async fn rescan(pool: &DbPool) -> LauncherResult<()> { - register_located_runtimes(pool, java::locate::locate_java().await?).await - } - - #[tracing::instrument(skip(services))] - pub async fn install_runtime( - services: &LauncherServices, - major: u32, - ) -> LauncherResult { - download_and_register(major, services, None).await - } - - #[tracing::instrument(level = "debug", skip(services))] - pub async fn available_versions( - services: &LauncherServices, - vendor: &JavaVendor, - ) -> LauncherResult> { - let Some(provider) = provider_for_vendor(vendor) else { - return Ok(Vec::new()); - }; - - let packages = provider.list_packages(None, services).await?; - - let mut by_major = - BTreeMap::::new(); - - for package in packages { - let Some(&major) = package.java_version.first() else { - continue; - }; - by_major.entry(major).or_insert(package); - } - - let mut available: Vec = by_major - .into_iter() - .map(|(major, package)| AvailableJava { major, package }) - .collect(); - - available.sort_by_key(|b| std::cmp::Reverse(b.major)); - - Ok(available) - } - - #[tracing::instrument(skip(services))] - pub async fn install_runtime_from( - services: &LauncherServices, - vendor: &JavaVendor, - major: u32, - ) -> LauncherResult { - let provider = - provider_for_vendor(vendor).ok_or(JavaError::PackageNotFound { major })?; - let package = provider - .latest_package_by_major(major, services) - .await? - .ok_or(JavaError::PackageNotFound { major })?; - let executable = provider.install_package(&package, services, None).await?; - register_checked_java(&services.db, &executable, Some(major)).await - } - - #[tracing::instrument(skip(pool))] - pub async fn add_custom_runtime( - pool: &DbPool, - folder: PathBuf, - ) -> LauncherResult { - let executable = resolve_java_executable(&folder)?; - register_checked_java(pool, &executable, None).await - } - - #[tracing::instrument(skip(pool))] - pub async fn remove_runtime(pool: &DbPool, absolute_path: &str) -> LauncherResult<()> { - dao::java::delete_by_path(pool, absolute_path).await?; - tracing::info!("removed Java runtime"); - Ok(()) - } - - #[tracing::instrument(level = "debug", skip(pool))] - pub async fn java_for_profile( - pool: &DbPool, - java_path: Option<&str>, - ) -> LauncherResult> { - let Some(path) = java_path else { - return Ok(None); - }; - - let Some(row) = dao::java::get_by_path(pool, path).await? else { - return Ok(None); - }; - - Ok(Some(JavaRuntime::from_row(row))) - } -} - -fn provider_for_vendor(vendor: &JavaVendor) -> Option> { - vendors::runtime_providers() - .into_iter() - .find(|provider| &provider.vendor() == vendor) -} - -#[tracing::instrument(level = "debug", skip(services, progress))] -async fn download_and_register( - major: u32, - services: &LauncherServices, - progress: Option<&crate::notification::GroupedProgressSession>, -) -> LauncherResult { - for provider in vendors::runtime_providers() { - let vendor = provider.vendor(); - - let packages = provider.list_packages(Some(major), services).await; - - let package = match &packages { - Ok(packages) => match packages.iter().find(|p| p.java_version.first() == Some(&major)).or_else(|| packages.first()) { - Some(package) => package, - None => { - tracing::warn!(?vendor, major, "no packages found"); - continue; - } - }, - Err(err) => { - tracing::warn!(?vendor, major, "failed to query packages: {err}"); - continue; - } - }; - - tracing::info!(?vendor, major, "downloading Java runtime"); - - match provider.install_package(package, services, progress).await { - Ok(executable) => { - return register_checked_java(&services.db, &executable, Some(major)).await; - } - Err(err) => { - tracing::warn!(?vendor, major, "install failed: {err}"); - } - } - } - - Err(JavaError::PackageNotFound { major }.into()) -} - -#[tracing::instrument(level = "debug", skip(pool))] -async fn register_custom_java( - pool: &DbPool, - selection: PathBuf, - expected_major: u32, -) -> LauncherResult { - let executable = resolve_java_executable(&selection)?; - register_checked_java(pool, &executable, Some(expected_major)).await -} - -#[tracing::instrument(level = "debug", skip(pool))] -async fn register_checked_java( - pool: &DbPool, - executable: &Path, - expected_major: Option, -) -> LauncherResult { - let info = java::check_java_runtime(executable.display().to_string()).await?; - let major = parse_major_version(&info.version)?; - - if let Some(expected) = expected_major - && major != expected - { - return Err(JavaError::VersionMismatch { - expected, - found: major, - } - .into()); - } - - persist_runtime(pool, executable, &info).await -} - -#[tracing::instrument(level = "debug", skip(pool))] -async fn get_latest_runtime(pool: &DbPool, major: u32) -> LauncherResult> { - let row = dao::java::get_latest_by_major(pool, major).await?; - Ok(row.map(JavaRuntime::from_row)) -} - -#[tracing::instrument(level = "debug", skip_all)] -async fn register_located_runtimes( - pool: &DbPool, - located: impl IntoIterator, -) -> LauncherResult<()> { - for (path, info) in located { - if let Err(err) = persist_runtime(pool, &path, &info).await { - tracing::warn!( - path = %path.display(), - version = %info.version, - "skipping located Java runtime: {err}" - ); - } - } - Ok(()) -} - -#[tracing::instrument(level = "debug", skip(pool))] -async fn persist_runtime( - pool: &DbPool, - executable: &Path, - info: &JavaCheckInfo, -) -> LauncherResult { - let major = parse_major_version(&info.version)?; - let row = dao::java::insert( - pool, - &executable.to_string_lossy(), - major, - &info.version, - &info.vendor, - &info.os_arch, - ) - .await?; - - Ok(JavaRuntime::from_row(row)) -} - -fn parse_major_version(version: &str) -> Result { - if let Some(rest) = version.strip_prefix("1.") { - let digit = rest.chars().next().ok_or_else(|| JavaError::ParseVersion { - version: version.to_string(), - })?; - return digit - .to_digit(10) - .ok_or_else(|| JavaError::ParseVersion { - version: version.to_string(), - }); - } - - let head = version.split('.').next().unwrap_or(version); - head.parse::() - .map_err(|_| JavaError::ParseVersion { - version: version.to_string(), - }) -} diff --git a/packages/oneclient_core/src/java/mod.rs b/packages/oneclient_core/src/java/mod.rs deleted file mode 100644 index 54a94101..00000000 --- a/packages/oneclient_core/src/java/mod.rs +++ /dev/null @@ -1,16 +0,0 @@ -mod error; -mod checker; -mod data; -mod locate; -mod manager; -mod resolve; -mod install; -pub mod vendors; - -pub(super) use error::JavaResult; -pub use error::JavaError; -pub use checker::*; -pub use data::{JavaPackage, JavaRuntime, PackageArchive}; -pub use manager::{AvailableJava, JavaManager, INSTALLABLE_MAJORS}; -pub use vendors::JavaVendor; -pub use install::install_package; \ No newline at end of file diff --git a/packages/oneclient_core/src/java_store.rs b/packages/oneclient_core/src/java_store.rs new file mode 100644 index 00000000..19825701 --- /dev/null +++ b/packages/oneclient_core/src/java_store.rs @@ -0,0 +1,81 @@ +//! The database side of [`oneclient_java::JavaStore`]. +//! +//! `oneclient_java` defines what it needs; this maps it onto the launcher's +//! `java_versions` table. Keeping the adapter here rather than the trait there +//! lets the Java crate build without sqlx, and lets its tests run against +//! `MemoryJavaStore` instead of a real database file. + +use std::str::FromStr; + +use oneclient_db::{DbPool, dao, models::JavaVersionRow}; +use oneclient_java::{JavaRuntime, JavaStore, JavaVendor, StoreError, StoreResult}; + +pub struct SqlJavaStore { + pool: DbPool, +} + +impl SqlJavaStore { + #[must_use] + pub fn new(pool: DbPool) -> Self { + Self { pool } + } +} + +/// The one place a stored row becomes a domain value. Keeping it here spares +/// `JavaRuntime` from knowing the database's row type. +fn row_to_runtime(row: JavaVersionRow) -> JavaRuntime { + JavaRuntime { + absolute_path: row.absolute_path, + major: row.major as u32, + version: row.version, + vendor: JavaVendor::from_str(&row.vendor).unwrap_or(JavaVendor::Other(row.vendor)), + os_arch: row.os_arch, + } +} + +#[async_trait::async_trait] +impl JavaStore for SqlJavaStore { + async fn upsert(&self, runtime: &JavaRuntime) -> StoreResult { + let row = dao::java::insert( + &self.pool, + &runtime.absolute_path, + runtime.major, + &runtime.version, + &runtime.vendor.to_string(), + &runtime.os_arch, + ) + .await + .map_err(StoreError::new)?; + + Ok(row_to_runtime(row)) + } + + async fn get_by_path(&self, absolute_path: &str) -> StoreResult> { + Ok(dao::java::get_by_path(&self.pool, absolute_path) + .await + .map_err(StoreError::new)? + .map(row_to_runtime)) + } + + async fn latest_by_major(&self, major: u32) -> StoreResult> { + Ok(dao::java::get_latest_by_major(&self.pool, major) + .await + .map_err(StoreError::new)? + .map(row_to_runtime)) + } + + async fn delete_by_path(&self, absolute_path: &str) -> StoreResult<()> { + dao::java::delete_by_path(&self.pool, absolute_path) + .await + .map_err(StoreError::new) + } + + async fn list(&self) -> StoreResult> { + Ok(dao::java::list_all(&self.pool) + .await + .map_err(StoreError::new)? + .into_iter() + .map(row_to_runtime) + .collect()) + } +} diff --git a/packages/oneclient_core/src/lib.rs b/packages/oneclient_core/src/lib.rs index fb4fb685..b9fb2f4b 100644 --- a/packages/oneclient_core/src/lib.rs +++ b/packages/oneclient_core/src/lib.rs @@ -3,41 +3,22 @@ #[cfg(debug_assertions)] pub mod dev; -pub mod api_config; -pub mod auth; -pub mod bundles; pub mod changelog; pub mod clusters; -pub mod constants; -pub mod crypto; -pub mod discord; mod error; pub mod game; -pub mod http; pub mod images; -pub mod java; +mod java_store; pub mod logger; -pub mod logs; -pub mod metadata; pub mod migration; -pub mod minecraft; -pub mod notification; -pub mod os_ext; -pub mod packages; -pub mod patch; -pub mod paths; -pub mod plus; pub mod recovery; pub mod reporting; -pub mod screenshots; pub mod settings; -pub mod status; pub mod tos; mod state; -pub mod version; pub mod versions; -pub use bundles::{ +pub use oneclient_content::bundles::{ apply_bundle_updates, check_bundle_updates, effective_enabled, install_bundle, install_cluster_bundles, install_package_from_bundle, is_bundle_syncing, list_cluster_bundle_overrides, @@ -46,10 +27,11 @@ pub use bundles::{ ApplyBundleUpdatesResult, Bundle, BundleArchive, BundleError, BundleFile, BundleFileKind, BundleManifest, BundlesManager, BundleUpdateCheckResult, BundleWithUpdateStatus, FileUpdateStatus, get_bundles_with_update_status, + remove_artifact_from_cluster, toggle_artifact_enabled, }; pub use changelog::{fetch_changelog, parse_changelog, ChangelogGroup}; pub use tos::{fetch_terms, TermsDocument}; -pub use discord::{DiscordRpc, Presence}; +pub use oneclient_discord::{DiscordRpc, Presence}; pub use clusters::{ Cluster, ClusterError, ClusterManager, ClusterStage, ClusterUpdate, CreateClusterOptions, ensure_from_bundles, ensure_from_versions, estimate_cluster_download, @@ -57,25 +39,24 @@ pub use clusters::{ pub use error::{LauncherError, LauncherResult, SentryExclusion}; pub use game::{GameError, LaunchedGame, get_loader_versions, launch_cluster}; pub use images::ImageCacheStore; -pub use logs::{ +pub use oneclient_cluster::logs::{ LogFileInfo, LogKind, LogLevel, LogLine, LogsError, MclogsUploadResponse, ReadOptions, delete_log_at, list_cluster_logs, read_log_at, upload_log_at, }; -pub use metadata::{MetadataError, MetadataStore}; +// Minecraft metadata moved to `oneclient_mc`; kept re-exported for the app. +pub use oneclient_mc::{McError as MetadataError, MetadataStore}; pub use migration::{ detect as detect_migration, import_game_dir as import_migration_game_dir, ImportTarget, MigrationDetection, MigrationSource, SourceInstance, }; -pub use screenshots::{ +pub use oneclient_cluster::screenshots::{ ScreenshotInfo, ScreenshotsError, delete_screenshot, list_cluster_screenshots, load_screenshot, }; -pub use notification::{GroupedProgressChild, GroupedProgressEvent, GroupedProgressSession}; -pub use packages::LinkedArtifactInfo; -pub use patch::Patch; +pub use oneclient_content::packages::LinkedArtifactInfo; pub use settings::ProfileUpdate; pub use state::LauncherServices; pub use state::LauncherState; -pub use version::{ParsedMcVersion, VersionKey, format_mc_version, parse_mc_version}; +pub use state::run_startup_tasks; pub use versions::{ RemoteMigration, VersionMetadata, VersionsManager, VersionsManifest, resolve_migration_chain, }; diff --git a/packages/oneclient_core/src/logger.rs b/packages/oneclient_core/src/logger.rs index 7f2a5545..3d8dcbee 100644 --- a/packages/oneclient_core/src/logger.rs +++ b/packages/oneclient_core/src/logger.rs @@ -35,7 +35,7 @@ pub fn init_filtered(filter: impl FnOnce() -> EnvFilter) -> LauncherResult<()> { #[cfg(not(debug_assertions))] { - let logs_dir = crate::paths::logs_dir()?; + let logs_dir = oneclient_common::paths::logs_dir()?; std::fs::create_dir_all(&logs_dir)?; let log_path = logs_dir.join(format!("{}.log", chrono::Local::now().to_rfc3339().replace(':', "-"))); diff --git a/packages/oneclient_core/src/metadata/error.rs b/packages/oneclient_core/src/metadata/error.rs deleted file mode 100644 index 4c80a5f1..00000000 --- a/packages/oneclient_core/src/metadata/error.rs +++ /dev/null @@ -1,27 +0,0 @@ -use thiserror::Error; - -use crate::packages::domain::GameLoader; - -#[derive(Debug, Error)] -pub enum MetadataError { - #[error("failed to fetch metadata")] - FetchError, - - #[error("loader {0} does not use a modded manifest")] - NotModdedManifest(GameLoader), - - #[error("loader {0} does not use a vanilla manifest")] - NotVanillaManifest(GameLoader), - - #[error("failed to parse metadata: {0}")] - ParseError(#[from] serde_json::Error), - - #[error("no matching loader found")] - NoMatchingLoader, - - #[error("requested loader version '{requested}' was not found")] - RequestedLoaderVersionNotFound { requested: String }, - - #[error("no matching version found")] - NoMatchingVersion, -} diff --git a/packages/oneclient_core/src/metadata/mod.rs b/packages/oneclient_core/src/metadata/mod.rs deleted file mode 100644 index 1715f69d..00000000 --- a/packages/oneclient_core/src/metadata/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod error; -mod store; - -pub use error::MetadataError; -pub use store::MetadataStore; diff --git a/packages/oneclient_core/src/migration/fs.rs b/packages/oneclient_core/src/migration/fs.rs deleted file mode 100644 index c9499222..00000000 --- a/packages/oneclient_core/src/migration/fs.rs +++ /dev/null @@ -1,53 +0,0 @@ -use std::path::{Path, PathBuf}; - - -use crate::LauncherResult; - -#[tracing::instrument(level = "debug", skip(exclude_top))] -pub async fn copy_tree(src: &Path, dst: &Path, exclude_top: &[&str]) -> LauncherResult<()> { - let mut stack: Vec<(PathBuf, PathBuf, bool)> = - vec![(src.to_path_buf(), dst.to_path_buf(), true)]; - - while let Some((cur_src, cur_dst, is_top)) = stack.pop() { - let mut entries = polyio::read_dir(&cur_src).await?; - while let Some(entry) = entries.next_entry().await? { - let name = entry.file_name(); - - if is_top - && let Some(name_str) = name.to_str() - && exclude_top.iter().any(|e| e.eq_ignore_ascii_case(name_str)) - { - continue; - } - - let child_src = entry.path(); - let child_dst = cur_dst.join(&name); - let file_type = entry.file_type().await?; - - if file_type.is_dir() { - polyio::create_dir_all(&child_dst).await?; - - stack.push((child_src, child_dst, false)); - } else if file_type.is_file() { - if let Some(parent) = child_dst.parent() { - polyio::create_dir_all(parent).await?; - } - - polyio::copy(&child_src, &child_dst).await?; - } - } - } - - Ok(()) -} - -pub async fn dir_has_content(dir: &Path) -> bool { - if !dir.is_dir() { - return false; - } - - match polyio::read_dir(dir).await { - Ok(mut entries) => matches!(entries.next_entry().await, Ok(Some(_))), - Err(_) => false, - } -} diff --git a/packages/oneclient_core/src/migration/mod.rs b/packages/oneclient_core/src/migration/mod.rs index dcf6aa88..61090dd7 100644 --- a/packages/oneclient_core/src/migration/mod.rs +++ b/packages/oneclient_core/src/migration/mod.rs @@ -1,4 +1,3 @@ -mod fs; pub mod oneclient_v1; pub mod vanilla; @@ -7,9 +6,7 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; use crate::LauncherResult; -use crate::packages::domain::GameLoader; - -pub use fs::copy_tree; +use oneclient_common::domain::GameLoader; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum MigrationSource { @@ -96,15 +93,18 @@ pub async fn detect() -> LauncherResult> { Ok(None) } -#[tracing::instrument] +#[tracing::instrument(skip(state))] pub async fn import_game_dir( + state: &std::sync::Arc, source: MigrationSource, folder_name: &str, target: ImportTarget, ) -> LauncherResult<()> { match source { - MigrationSource::OneClientV1 => oneclient_v1::import_game_dir(folder_name, target).await, - MigrationSource::Vanilla => vanilla::import_game_dir(target).await, + MigrationSource::OneClientV1 => { + oneclient_v1::import_game_dir(state, folder_name, target).await + } + MigrationSource::Vanilla => vanilla::import_game_dir(state, target).await, } } diff --git a/packages/oneclient_core/src/migration/oneclient_v1.rs b/packages/oneclient_core/src/migration/oneclient_v1.rs index 846eaced..a48d86f0 100644 --- a/packages/oneclient_core/src/migration/oneclient_v1.rs +++ b/packages/oneclient_core/src/migration/oneclient_v1.rs @@ -4,10 +4,9 @@ use sqlx::Row; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use crate::LauncherResult; -use crate::packages::domain::GameLoader; -use crate::paths; +use oneclient_common::domain::GameLoader; +use oneclient_common::paths; -use super::fs::{copy_tree, dir_has_content}; use super::{ImportTarget, MigrationDetection, MigrationSource, SourceInstance}; const IMPORT_EXCLUDE_TOP: &[&str] = &["mods", "logs"]; @@ -92,7 +91,7 @@ async fn detect_inner(root: &Path, db_path: &Path) -> LauncherResult LauncherR Ok(categories) } -#[tracing::instrument(skip(target))] -pub async fn import_game_dir(folder_name: &str, target: ImportTarget) -> LauncherResult<()> { +#[tracing::instrument(skip(state, target))] +pub async fn import_game_dir( + state: &std::sync::Arc, + folder_name: &str, + target: ImportTarget, +) -> LauncherResult<()> { let Some(root) = old_root() else { return Ok(()); }; @@ -153,8 +156,7 @@ pub async fn import_game_dir(folder_name: &str, target: ImportTarget) -> Launche let dest = match &target { ImportTarget::Shared => paths::shared_minecraft_dir()?, ImportTarget::Dedicated { new_cluster_id } => { - let state = crate::state::LauncherState::get()?; - let cluster = crate::clusters::ClusterManager::get(&state, *new_cluster_id).await?; + let cluster = state.clusters.get(*new_cluster_id).await?; let dir = cluster.dir()?; polyio::create_dir_all(&dir).await?; // Mark dedicated so game_dir() resolves to this cluster's own dir. @@ -164,7 +166,7 @@ pub async fn import_game_dir(folder_name: &str, target: ImportTarget) -> Launche }; polyio::create_dir_all(&dest).await?; - copy_tree(&src, &dest, IMPORT_EXCLUDE_TOP).await?; + polyio::copy_dir(&src, &dest, IMPORT_EXCLUDE_TOP).await?; Ok(()) } diff --git a/packages/oneclient_core/src/migration/vanilla.rs b/packages/oneclient_core/src/migration/vanilla.rs index 012a849b..0772744e 100644 --- a/packages/oneclient_core/src/migration/vanilla.rs +++ b/packages/oneclient_core/src/migration/vanilla.rs @@ -1,10 +1,9 @@ use std::path::PathBuf; use crate::LauncherResult; -use crate::packages::domain::GameLoader; -use crate::paths; +use oneclient_common::domain::GameLoader; +use oneclient_common::paths; -use super::fs::{copy_tree, dir_has_content}; use super::{ImportTarget, MigrationDetection, MigrationSource, SourceInstance}; const ROOT_INSTANCE_NAME: &str = ".minecraft"; @@ -50,7 +49,7 @@ pub fn old_root() -> Option { { d.data_dir().join(".minecraft") } - // macOS: `~/Library/Application Support/minecraft` — no leading dot. + // macOS: `~/Library/Application Support/minecraft`, no leading dot. #[cfg(target_os = "macos")] { d.data_dir().join("minecraft") @@ -79,7 +78,7 @@ pub async fn detect() -> LauncherResult> { return Ok(None); }; - if !dir_has_content(&root).await { + if !polyio::dir_has_content(&root).await { tracing::debug!(root = %root.display(), "vanilla migration: empty install, skipping"); return Ok(None); } @@ -101,8 +100,11 @@ pub async fn detect() -> LauncherResult> { })) } -#[tracing::instrument(skip(target))] -pub async fn import_game_dir(target: ImportTarget) -> LauncherResult<()> { +#[tracing::instrument(skip(state, target))] +pub async fn import_game_dir( + state: &std::sync::Arc, + target: ImportTarget, +) -> LauncherResult<()> { let Some(src) = old_root() else { return Ok(()); }; @@ -110,8 +112,7 @@ pub async fn import_game_dir(target: ImportTarget) -> LauncherResult<()> { let dest = match &target { ImportTarget::Shared => paths::shared_minecraft_dir()?, ImportTarget::Dedicated { new_cluster_id } => { - let state = crate::state::LauncherState::get()?; - let cluster = crate::clusters::ClusterManager::get(&state, *new_cluster_id).await?; + let cluster = state.clusters.get(*new_cluster_id).await?; let dir = cluster.dir()?; polyio::create_dir_all(&dir).await?; polyio::write(cluster.dedicated_marker()?, Vec::new()).await?; @@ -120,7 +121,7 @@ pub async fn import_game_dir(target: ImportTarget) -> LauncherResult<()> { }; polyio::create_dir_all(&dest).await?; - copy_tree(&src, &dest, IMPORT_EXCLUDE_TOP).await?; + polyio::copy_dir(&src, &dest, IMPORT_EXCLUDE_TOP).await?; Ok(()) } @@ -210,7 +211,7 @@ mod tests { polyio::write(path, b"x".to_vec()).await.unwrap(); } - copy_tree(src, dst, IMPORT_EXCLUDE_TOP).await.unwrap(); + polyio::copy_dir(src, dst, IMPORT_EXCLUDE_TOP).await.unwrap(); for kept in [ "options.txt", diff --git a/packages/oneclient_core/src/minecraft/mod.rs b/packages/oneclient_core/src/minecraft/mod.rs deleted file mode 100644 index beb0fcd8..00000000 --- a/packages/oneclient_core/src/minecraft/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -mod profile; - -pub use profile::{ - fetch_logged_in_profile, fetch_player_profile, fetch_player_profile_view, MojangCape, - MojangFullPlayerProfile, MojangPlayerProfile, MojangSkin, PlayerProfileView, SkinVariant, -}; diff --git a/packages/oneclient_core/src/notification/data.rs b/packages/oneclient_core/src/notification/data.rs deleted file mode 100644 index 66c90d90..00000000 --- a/packages/oneclient_core/src/notification/data.rs +++ /dev/null @@ -1,203 +0,0 @@ -use std::path::PathBuf; - -use tokio::sync::oneshot; -use uuid::Uuid; - -#[derive(Debug, Clone)] -pub enum GroupedProgressEvent { - Start { - session_id: Uuid, - title: String, - }, - /// Pre-announce how many children (and total bytes) a category will have, - /// so the aggregate total is known up-front instead of climbing as each - /// child is added during a `buffer_unordered` fan-out. - Expect { - session_id: Uuid, - category: TaskCategory, - count: u64, - total: u64, - }, - AddChild { - session_id: Uuid, - child_id: Uuid, - label: String, - total: u64, - category: TaskCategory, - }, - UpdateChild { - session_id: Uuid, - child_id: Uuid, - current: u64, - total: u64, - }, - SetChildPhase { - session_id: Uuid, - child_id: Uuid, - phase: TaskPhase, - }, - FinishChild { - session_id: Uuid, - child_id: Uuid, - }, - End { - session_id: Uuid, - }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum TaskPhase { - #[default] - Downloading, - Verifying, - Extracting, - Installing, -} - -impl TaskPhase { - pub fn label(self) -> &'static str { - match self { - Self::Downloading => "Downloading", - Self::Verifying => "Verifying", - Self::Extracting => "Extracting", - Self::Installing => "Installing", - } - } -} - -/// Coarse grouping of a grouped-progress child. Drives the notification body text -/// ("Downloading Minecraft" vs "Downloading Packages") and the per-category task rows. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] -pub enum TaskCategory { - Client, - Metadata, - Libraries, - Natives, - Assets, - Java, - #[default] - Packages, -} - -impl TaskCategory { - pub fn label(self) -> &'static str { - match self { - Self::Client => "Client", - Self::Metadata => "Metadata", - Self::Libraries => "Libraries", - Self::Natives => "Natives", - Self::Assets => "Assets", - Self::Java => "Java runtime", - Self::Packages => "Packages", - } - } - - /// True for everything that is part of the Minecraft install (not user packages). - pub fn is_minecraft(self) -> bool { - !matches!(self, Self::Packages) - } -} - -#[derive(Debug)] -pub enum Notification { - Message { - title: String, - body: String, - level: NotificationLevel, - }, - - Progress { - id: Uuid, - label: String, - current: u64, - total: u64, - }, - - /// Convert an in-flight [`Notification::Progress`] entry (matched by `id`) into a - /// finished message in place, so a download and its completion notice are a single - /// notification instead of two separate cards. - ProgressComplete { - id: Uuid, - title: String, - body: String, - }, - - GroupedProgress(GroupedProgressEvent), - - GameStage { - cluster_id: i64, - stage: LaunchStage, - }, - - GameLog { - cluster_id: i64, - line: String, - }, - - GameFailed { - cluster_id: i64, - message: String, - }, - - Prompt { - title: String, - question: String, - kind: PromptKind, - reply_tx: oneshot::Sender, - }, - - InvalidateClusters, - - InvalidateJava, - - SyncComplete, - - /// Live status of Microsoft Auth - /// `None` clears the status - MicrosoftLoginStatus(Option), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MicrosoftLoginStatus { - pub label: String, - pub current: u64, - pub total: u64, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum LaunchStage { - Checking, - Downloading, - Launching, - Running, - Exited, -} - -impl LaunchStage { - pub fn is_busy(self) -> bool { - matches!(self, Self::Checking | Self::Downloading | Self::Launching) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PromptKind { - JavaInstall { major: u32 }, - Update, -} - -#[derive(Debug)] -pub enum UserChoice { - Accept, - Cancel, - Folder(PathBuf), - Install { - vendor: crate::java::JavaVendor, - major: u32, - }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum NotificationLevel { - Info, - Error, -} diff --git a/packages/oneclient_core/src/notification/mod.rs b/packages/oneclient_core/src/notification/mod.rs deleted file mode 100644 index 12fef898..00000000 --- a/packages/oneclient_core/src/notification/mod.rs +++ /dev/null @@ -1,18 +0,0 @@ -pub use data::{ - GroupedProgressEvent, LaunchStage, MicrosoftLoginStatus, Notification, NotificationLevel, - PromptKind, TaskCategory, TaskPhase, UserChoice, -}; -pub use grouped::{GroupedProgressChild, GroupedProgressSession}; -pub use service::NotificationService; - -mod data; -mod grouped; -mod service; - -#[derive(Debug, thiserror::Error)] -pub enum NotificationError { - #[error("Notification service is down")] - ServiceDown, - #[error("Notification prompt timed out")] - PromptTimedOut, -} diff --git a/packages/oneclient_core/src/notification/service.rs b/packages/oneclient_core/src/notification/service.rs deleted file mode 100644 index 2faffabe..00000000 --- a/packages/oneclient_core/src/notification/service.rs +++ /dev/null @@ -1,170 +0,0 @@ -use tokio::sync::{mpsc, oneshot}; -use uuid::Uuid; - -use crate::notification::{ - LaunchStage, MicrosoftLoginStatus, Notification, NotificationError, NotificationLevel, - PromptKind, UserChoice, -}; - -#[derive(Clone)] -pub struct NotificationService { - channel: mpsc::UnboundedSender, -} - -impl NotificationService { - pub fn new(channel: mpsc::UnboundedSender) -> Self { - Self { channel } - } - - pub fn channel(&self) -> &mpsc::UnboundedSender { - &self.channel - } - - pub fn send_message(&self, title: &str, body: &str, level: NotificationLevel) { - if let Err(err) = self.channel.send(Notification::Message { - title: title.to_string(), - body: body.to_string(), - level, - }) { - tracing::error!("{err}"); - } - } - - pub fn send_progress(&self, id: &Uuid, label: &str, current: u64, total: u64) { - if let Err(err) = self.channel.send(Notification::Progress { - id: *id, - label: label.to_string(), - current, - total, - }) { - tracing::error!("{err}"); - } - } - - /// Finish an in-flight progress notification (identified by the same `id` passed to - /// [`send_progress`](Self::send_progress)) by converting its card into a completed - /// message in place, rather than emitting a separate follow-up notification. - pub fn finish_progress(&self, id: &Uuid, title: &str, body: &str) { - if let Err(err) = self.channel.send(Notification::ProgressComplete { - id: *id, - title: title.to_string(), - body: body.to_string(), - }) { - tracing::error!("{err}"); - } - } - - #[tracing::instrument(level = "debug", skip(self))] - pub async fn prompt_java_install(&self, major: u32) -> Result { - self.prompt( - "Java required", - &format!( - "No Java {major} runtime was found. Download it automatically, choose an existing installation folder, or cancel?" - ), - PromptKind::JavaInstall { major }, - ) - .await - } - - #[tracing::instrument(level = "debug", skip(self))] - pub async fn prompt_update(&self, version: &str) -> Result { - self.prompt( - "Update available", - &format!("OneClient {version} is ready to install. Download and install it now?"), - PromptKind::Update, - ) - .await - } - - #[tracing::instrument(level = "debug", skip(self, question))] - pub async fn prompt( - &self, - title: &str, - question: &str, - kind: PromptKind, - ) -> Result { - let (reply_tx, reply_rx) = oneshot::channel(); - - self.channel - .send(Notification::Prompt { - title: title.to_string(), - question: question.to_string(), - kind, - reply_tx, - }) - .map_err(|_| NotificationError::ServiceDown)?; - - reply_rx.await.map_err(|_| NotificationError::ServiceDown) - } - - pub fn microsoft_login_status(&self, status: Option) { - if let Err(err) = self - .channel - .send(Notification::MicrosoftLoginStatus(status)) - { - tracing::error!("{err}"); - } - } - - pub fn send_info(&self, title: &str, body: &str) { - self.send_message(title, body, NotificationLevel::Info); - } - - pub fn send_error(&self, title: &str, body: &str) { - self.send_message(title, body, NotificationLevel::Error); - } - - pub fn game_stage(&self, cluster_id: i64, stage: LaunchStage) { - if let Err(err) = self.channel.send(Notification::GameStage { cluster_id, stage }) { - tracing::error!("{err}"); - } - } - - pub fn game_failed(&self, cluster_id: i64, message: impl Into) { - if let Err(err) = self.channel.send(Notification::GameFailed { - cluster_id, - message: message.into(), - }) { - tracing::error!("{err}"); - } - } - - pub fn game_log(&self, cluster_id: i64, line: impl Into) { - if let Err(err) = self.channel.send(Notification::GameLog { - cluster_id, - line: line.into(), - }) { - tracing::error!("{err}"); - } - } - - pub fn send_grouped(&self, event: super::data::GroupedProgressEvent) { - if let Err(err) = self - .channel - .send(super::data::Notification::GroupedProgress(event)) - { - tracing::error!("failed to send grouped progress: {err}"); - } - } - - pub fn invalidate_clusters(&self) { - if let Err(err) = self - .channel - .send(Notification::InvalidateClusters) - { - tracing::error!("failed to invalidate clusters: {err}"); - } - } - - pub fn invalidate_java(&self) { - if let Err(err) = self.channel.send(Notification::InvalidateJava) { - tracing::error!("failed to invalidate java: {err}"); - } - } - - pub fn sync_complete(&self) { - if let Err(err) = self.channel.send(Notification::SyncComplete) { - tracing::error!("failed to signal sync complete: {err}"); - } - } -} diff --git a/packages/oneclient_core/src/recovery.rs b/packages/oneclient_core/src/recovery.rs index 16e42b07..431a635e 100644 --- a/packages/oneclient_core/src/recovery.rs +++ b/packages/oneclient_core/src/recovery.rs @@ -7,13 +7,13 @@ use oneclient_db::models::NewCluster; use oneclient_db::DbPool; use strum::IntoEnumIterator; -use crate::clusters::{ClusterManager, ClusterStage}; -use crate::crypto::sha1_file; -use crate::packages::domain::{ContentType, GameLoader, ProviderId}; -use crate::paths; -use crate::settings::store::create_profile_from_global; +use crate::clusters::ClusterStage; +use polyio::sha1_file; +use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; +use oneclient_common::paths; +use oneclient_cluster::profiles::create_profile_from_global; use crate::state::LauncherState; -use crate::version::parse_mc_version; +use oneclient_common::version::parse_mc_version; use crate::LauncherResult; const FILE_CONTENT_TYPES: [ContentType; 4] = [ @@ -43,7 +43,7 @@ pub async fn reconstruct_from_disk(state: &LauncherState) -> LauncherResult LauncherResult LauncherResult<()> { let (name, mc_version, loader) = parse_folder_identity(folder_name); - let settings = state.settings.read().clone(); - let profile = create_profile_from_global(&state.services.db, &settings, &name, None, None).await?; + let global = state.settings.read().global_game_settings.clone(); + let profile = create_profile_from_global(&state.services.db, &global, &name, None, None).await?; let row = cluster_dao::insert( &state.services.db, @@ -295,12 +295,12 @@ async fn relink_cluster_files( #[tracing::instrument(skip(state))] pub async fn restore_bundle_tracking(state: &LauncherState) -> LauncherResult<()> { - let clusters = ClusterManager::list(state).await?; + let clusters = state.clusters.list().await?; for cluster in clusters { let archives = match state .bundles - .archives_for(&state.services, &cluster.mc_version, cluster.mc_loader) + .archives_for(&state.services.content(), &cluster.mc_version, cluster.mc_loader) .await { Ok(archives) => archives, @@ -316,7 +316,7 @@ pub async fn restore_bundle_tracking(state: &LauncherState) -> LauncherResult<() for archive in archives { let bundle_name = &archive.manifest.name; for file in &archive.manifest.files { - use crate::bundles::BundleFileKind; + use oneclient_content::bundles::BundleFileKind; let BundleFileKind::Managed { provider, project_id, diff --git a/packages/oneclient_core/src/reporting.rs b/packages/oneclient_core/src/reporting.rs index 5c03f335..d65f5abb 100644 --- a/packages/oneclient_core/src/reporting.rs +++ b/packages/oneclient_core/src/reporting.rs @@ -5,7 +5,7 @@ use std::time::Duration; use sentry::protocol::{Context, Event}; use sentry::{ClientInitGuard, ClientOptions}; -use crate::constants::SENTRY_DSN; +use oneclient_common::constants::SENTRY_DSN; const PANIC_FLUSH_TIMEOUT: Duration = Duration::from_secs(2); const TRACING_FIELDS_CONTEXT: &str = "Rust Tracing Fields"; diff --git a/packages/oneclient_core/src/settings/launcher.rs b/packages/oneclient_core/src/settings/launcher.rs index 0f56279c..702ad598 100644 --- a/packages/oneclient_core/src/settings/launcher.rs +++ b/packages/oneclient_core/src/settings/launcher.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; -use super::profile::GameSettingsProfile; +use oneclient_cluster::GameSettingsProfile; #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] diff --git a/packages/oneclient_core/src/settings/mod.rs b/packages/oneclient_core/src/settings/mod.rs index 09bb74fd..1d75139c 100644 --- a/packages/oneclient_core/src/settings/mod.rs +++ b/packages/oneclient_core/src/settings/mod.rs @@ -1,9 +1,26 @@ mod launcher; -mod profile; pub use launcher::{LauncherSettings, ViewLayout, ViewState}; -pub use profile::{GameSettingsProfile, Resolution, SettingsOsExtra}; +// Profiles moved to `oneclient_cluster`; re-exported for existing paths. +pub use oneclient_cluster::{GameSettingsProfile, ProfileUpdate, SettingsOsExtra}; +pub use oneclient_common::Resolution; pub mod store; -pub use store::ProfileUpdate; +/// The endpoint/credential subset of the settings, in the shape the transport +/// wants. +/// +/// This is the seam that replaced `api_config`: rather than the HTTP client +/// reaching back into global settings (and therefore into the state object that +/// owns the client), the composition layer pushes these down whenever settings +/// change. See [`crate::settings::store::save_settings`]. +#[must_use] +pub fn net_config(settings: &LauncherSettings) -> oneclient_net::NetConfig { + oneclient_net::NetConfig::default().with_overrides( + settings.curseforge_api_key.as_deref(), + settings.modrinth_api_key.as_deref(), + settings.custom_api_endpoint.as_deref(), + settings.custom_meta_url_base.as_deref(), + ) +} + diff --git a/packages/oneclient_core/src/settings/store.rs b/packages/oneclient_core/src/settings/store.rs index c92c41b5..42ebaa1d 100644 --- a/packages/oneclient_core/src/settings/store.rs +++ b/packages/oneclient_core/src/settings/store.rs @@ -1,16 +1,14 @@ -use oneclient_db::DbPool; use parking_lot::RwLock; -use crate::notification::NotificationService; -use crate::patch::Patch; -use crate::paths; +use oneclient_events::EventBus; +use oneclient_common::paths; use crate::{LauncherError, LauncherResult}; use super::launcher::LauncherSettings; -use super::profile::{GameSettingsProfile, Resolution, SettingsOsExtra}; +use oneclient_cluster::GameSettingsProfile; #[tracing::instrument(level = "debug", skip(notify))] -pub async fn load_settings(notify: Option<&NotificationService>) -> LauncherSettings { +pub async fn load_settings(notify: Option<&EventBus>) -> LauncherSettings { match async { let path = paths::settings_file()?; let exists = polyio::try_exists(&path).await?; @@ -29,7 +27,7 @@ pub async fn load_settings(notify: Option<&NotificationService>) -> LauncherSett tracing::warn!("failed to read settings file: {err}"); if let Some(notify) = notify { - notify.send_error("Settings", "Failed to load settings"); + notify.notify("Settings").body("Failed to load settings").error().send(); } LauncherSettings::default() @@ -37,171 +35,33 @@ pub async fn load_settings(notify: Option<&NotificationService>) -> LauncherSett } } +/// Persists settings to disk. +/// +/// Prefer [`save_settings_and_apply`] where the services are in reach: this one +/// leaves the HTTP client on whatever endpoints and API keys it already had, so +/// a changed CurseForge key or custom endpoint would not take effect until the +/// next launch. #[tracing::instrument(level = "debug", skip(settings))] pub async fn save_settings(settings: &LauncherSettings) -> LauncherResult<()> { let path = paths::settings_file()?; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - let data = serde_json::to_string_pretty(settings)?; - polyio::write(path, data).await?; + polyio::write_atomic(path, data).await?; Ok(()) } -#[tracing::instrument(level = "debug", skip(pool, settings))] -pub async fn get_profile_or_default( - pool: &DbPool, +/// Persists settings and pushes the endpoint/credential subset into the +/// transport, so an API key or endpoint change applies immediately. +#[tracing::instrument(level = "debug", skip(services, settings))] +pub async fn save_settings_and_apply( + services: &crate::LauncherServices, settings: &LauncherSettings, - name: Option<&str>, -) -> LauncherResult { - if let Some(name) = name - && let Some(row) = oneclient_db::dao::setting_profile::get_by_name(pool, name).await? - { - return GameSettingsProfile::from_row(row); - } - - Ok(settings.global_game_settings.clone()) -} - -#[tracing::instrument(level = "debug", skip(pool, settings))] -pub async fn resolve_cluster_profile( - pool: &DbPool, - settings: &LauncherSettings, - profile_name: Option<&str>, -) -> LauncherResult { - let global = settings.global_game_settings.clone(); - - let Some(name) = profile_name else { - return Ok(global); - }; - - let Some(row) = oneclient_db::dao::setting_profile::get_by_name(pool, name).await? else { - return Ok(global); - }; - - let mut profile = GameSettingsProfile::from_row(row)?; - if !profile.is_global() { - profile.merge_global(&global); - } - - Ok(profile) -} - -#[tracing::instrument(level = "debug", skip_all)] -pub async fn upsert_named_profile( - pool: &DbPool, - profile: &GameSettingsProfile, -) -> LauncherResult { - if profile.is_global() { - return Err(LauncherError::InvalidSettingsProfile { - reason: "the Global profile is stored in settings.json".into(), - }); - } - - let row = profile.into_row()?; - let saved = oneclient_db::dao::setting_profile::upsert(pool, &row).await?; - GameSettingsProfile::from_row(saved) -} - -#[tracing::instrument(level = "debug", skip(pool, settings))] -pub async fn create_profile_from_global( - pool: &DbPool, - settings: &LauncherSettings, - name: &str, - mem_max: Option, - force_fullscreen: Option, -) -> LauncherResult { - let mut profile = settings.global_game_settings.clone(); - - profile.name = name.to_string(); - if let Some(mem) = mem_max { - profile.mem_max = Some(mem); - } - - if let Some(fullscreen) = force_fullscreen { - profile.force_fullscreen = Some(fullscreen); - } - - upsert_named_profile(pool, &profile).await -} - -#[tracing::instrument(level = "debug", skip(pool, settings))] -pub async fn create_settings_profile( - pool: &DbPool, - settings: &LauncherSettings, - name: &str, -) -> LauncherResult { - create_profile_from_global(pool, settings, name, None, None).await -} - -#[tracing::instrument(level = "debug", skip(pool))] -pub async fn list_named_profiles(pool: &DbPool) -> LauncherResult> { - let rows = oneclient_db::dao::setting_profile::list_all(pool).await?; - rows.into_iter() - .map(GameSettingsProfile::from_row) - .collect() -} - -#[tracing::instrument(level = "debug", skip(pool))] -pub async fn delete_named_profile(pool: &DbPool, name: &str) -> LauncherResult<()> { - oneclient_db::dao::setting_profile::delete_by_name(pool, name).await?; +) -> LauncherResult<()> { + save_settings(settings).await?; + services.requester.set_config(super::net_config(settings)); Ok(()) } -#[derive(Debug, Clone, Default)] -pub struct ProfileUpdate { - pub java_path: Patch, - pub resolution: Patch, - pub force_fullscreen: Patch, - pub mem_max: Patch, - pub launch_args: Patch, - pub launch_env: Patch, - pub hook_pre: Patch, - pub hook_wrapper: Patch, - pub hook_post: Patch, - pub os_extra: Patch, -} - -impl ProfileUpdate { - pub fn apply(&self, profile: &mut GameSettingsProfile) { - self.java_path.apply_to_option(&mut profile.java_path); - self.resolution.apply_to_option(&mut profile.resolution); - self.force_fullscreen - .apply_to_option(&mut profile.force_fullscreen); - self.mem_max.apply_to_option(&mut profile.mem_max); - self.launch_args - .apply_to_command_option(&mut profile.launch_args); - self.launch_env - .apply_to_command_option(&mut profile.launch_env); - self.hook_pre.apply_to_command_option(&mut profile.hook_pre); - self.hook_wrapper - .apply_to_command_option(&mut profile.hook_wrapper); - self.hook_post - .apply_to_command_option(&mut profile.hook_post); - self.os_extra.apply_to_option(&mut profile.os_extra); - } -} - -#[tracing::instrument(level = "debug", skip(pool, update))] -pub async fn update_named_profile( - pool: &DbPool, - name: &str, - update: ProfileUpdate, -) -> LauncherResult { - let Some(row) = oneclient_db::dao::setting_profile::get_by_name(pool, name).await? else { - return Err(LauncherError::InvalidSettingsProfile { - reason: format!("profile '{name}' not found"), - }); - }; - - let mut profile = GameSettingsProfile::from_row(row)?; - update.apply(&mut profile); - - upsert_named_profile(pool, &profile).await -} - #[tracing::instrument(level = "debug", skip_all)] pub async fn save_global_profile( settings: &RwLock, diff --git a/packages/oneclient_core/src/state.rs b/packages/oneclient_core/src/state.rs index 2e5ed101..82d80b6d 100644 --- a/packages/oneclient_core/src/state.rs +++ b/packages/oneclient_core/src/state.rs @@ -1,28 +1,26 @@ -use std::collections::HashMap; use std::sync::Arc; use oneclient_db::DbPool; -use tokio::sync::{Mutex, OnceCell}; +use tokio::sync::Mutex; -use crate::auth::{CredentialsStore, PendingBrowserLogin}; -use crate::bundles::BundlesManager; -use crate::discord::DiscordRpc; -use crate::http::RequestClient; +use oneclient_auth::AuthService; +use crate::clusters::ClusterManager; +use oneclient_content::bundles::BundlesManager; +use oneclient_discord::DiscordRpc; +use oneclient_net::RequestClient; use crate::images::ImageCacheStore; -use crate::java::JavaManager; -use crate::notification::NotificationService; -use crate::metadata::MetadataStore; -use crate::packages::provider::PackageProviderRegistry; -use crate::paths; +use oneclient_java::JavaService; +use oneclient_events::EventBus; +use oneclient_mc::MetadataStore; +use oneclient_content::packages::provider::PackageProviderRegistry; +use oneclient_common::paths; use crate::settings::{store, LauncherSettings}; use crate::versions::VersionsManager; -use crate::{LauncherError, LauncherResult}; - -static STATE: OnceCell> = OnceCell::const_new(); +use crate::LauncherResult; #[derive(Clone)] pub struct LauncherServices { - pub notifier: NotificationService, + pub events: EventBus, pub requester: RequestClient, pub db: DbPool, pub packages: PackageProviderRegistry, @@ -31,60 +29,103 @@ pub struct LauncherServices { pub struct LauncherState { pub services: LauncherServices, pub settings: parking_lot::RwLock, - pub auth: Mutex, - pub microsoft_logins: Mutex>, - pub java: JavaManager, + pub auth: Arc, + pub java: JavaService, + pub clusters: ClusterManager, pub metadata: Mutex, pub bundles: Arc, pub versions: Arc, pub images: ImageCacheStore, pub games: crate::game::GameProcessManager, pub discord: DiscordRpc, - pub provisioning: Mutex<()>, +} + +impl LauncherServices { + /// What the content crate needs: db, client, bus and the provider registry. + /// + /// Unlike [`LauncherServices::mc`] this is the whole of `LauncherServices`; the + /// packages and bundles code uses all four fields. + #[must_use] + pub fn content(&self) -> oneclient_content::ContentCtx { + oneclient_content::ContentCtx::new( + self.db.clone(), + self.requester.clone(), + self.events.clone(), + self.packages.clone(), + ) + } + + /// The subset the Minecraft content crate needs: a client and a bus. + /// + /// Both are refcounted handles, so this is cheap enough to build per call + /// rather than store alongside the fields it is made of. + #[must_use] + pub fn mc(&self) -> oneclient_mc::McCtx { + oneclient_mc::McCtx::new(self.requester.clone(), self.events.clone()) + } } impl LauncherState { - #[tracing::instrument(skip(notifier))] - pub async fn initialize( - notifier: NotificationService, - ) -> LauncherResult> { - if let Some(state) = STATE.get() { - return Ok(Arc::clone(state)); - } - + /// Builds the launcher's services and returns the handle. + /// + /// Construction has no side effects beyond opening the database and reading + /// settings: the background startup work is [`run_startup_tasks`], which the + /// caller kicks off when it is ready. That split is why the state can be + /// constructed twice, or in a test only once. + #[tracing::instrument(skip(events))] + pub async fn new(events: EventBus) -> LauncherResult> { let services = LauncherServices { - notifier, + events, db: oneclient_db::connect(paths::database_file()?).await?, - requester: RequestClient::new()?, + // Built with defaults because loading settings needs the event bus, + // which is already inside `services`. The user's endpoints and keys + // are pushed in immediately below, before anything makes a request. + requester: RequestClient::new(oneclient_net::NetConfig::default())?, packages: PackageProviderRegistry::new(), }; - let settings = store::load_settings(Some(&services.notifier)).await; - let auth = CredentialsStore::load().await?; - let java = JavaManager; + let settings = store::load_settings(Some(&services.events)).await; + services + .requester + .set_config(crate::settings::net_config(&settings)); + let auth = Arc::new( + AuthService::load(services.requester.clone(), services.events.clone()).await?, + ); + let java = JavaService::new( + std::sync::Arc::new(crate::java_store::SqlJavaStore::new(services.db.clone())), + services.requester.clone(), + services.events.clone(), + ); let discord = DiscordRpc::spawn(settings.discord_enabled); + let clusters = ClusterManager::new(services.db.clone()); + let state = Arc::new(Self { services, settings: parking_lot::RwLock::new(settings), - auth: Mutex::new(auth), - microsoft_logins: Mutex::new(HashMap::new()), + auth, java, + clusters, metadata: Mutex::new(MetadataStore::new()), bundles: Arc::new(BundlesManager::new()), versions: Arc::new(VersionsManager::new()), images: ImageCacheStore::new(), games: crate::game::GameProcessManager::new(), discord, - provisioning: Mutex::new(()), }); - STATE - .set(Arc::clone(&state)) - .map_err(|_| LauncherError::AlreadyInitialized)?; + Ok(state) + } +} - let background = Arc::clone(&state); - tokio::spawn(async move { +/// Reconstructs anything missing from disk, adopts orphaned game sessions, then +/// syncs the remote catalogs. +/// +/// Split out so constructing a `LauncherState` is just construction. The caller +/// decides when, and whether, this runs. +pub fn run_startup_tasks(state: &Arc) { + let background = Arc::clone(state); + tokio::spawn(async move { let recovery = match crate::recovery::reconstruct_from_disk(&background).await { Ok(report) => report, Err(err) => { @@ -95,9 +136,10 @@ impl LauncherState { crate::game::recover_sessions(&background).await; + let content = background.services.content(); let (versions_res, bundles_res) = tokio::join!( background.versions.sync(&background.services), - background.bundles.sync(&background.services), + background.bundles.sync(&content), ); if let Err(err) = versions_res { tracing::error!("versions manifest sync failed: {err:#}"); @@ -119,18 +161,8 @@ impl LauncherState { if let Err(err) = crate::clusters::ensure_from_versions(&background).await { tracing::error!("versions cluster provisioning failed: {err:#}"); } else { - background.services.notifier.invalidate_clusters(); + background.services.events.signal(oneclient_events::Signal::ClustersChanged); } - background.services.notifier.sync_complete(); - }); - - Ok(state) - } - - pub fn get() -> LauncherResult> { - STATE - .get() - .cloned() - .ok_or(LauncherError::NotInitialized) - } + background.services.events.signal(oneclient_events::Signal::SyncComplete); + }); } diff --git a/packages/oneclient_core/src/tos.rs b/packages/oneclient_core/src/tos.rs index c30bb5ee..23676900 100644 --- a/packages/oneclient_core/src/tos.rs +++ b/packages/oneclient_core/src/tos.rs @@ -1,13 +1,10 @@ -use std::path::Path; -use reqwest::header; -use reqwest::{Method, StatusCode}; use serde::{Deserialize, Serialize}; -use crate::api_config::meta_url_base; -use crate::constants; -use crate::paths; -use crate::state::LauncherServices; +use oneclient_common::constants; +use oneclient_common::paths; +use oneclient_net::{EtagPolicy, fetch_cached}; +use oneclient_net::RequestClient; use crate::{LauncherError, LauncherResult}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -52,97 +49,23 @@ impl TermsDocument { } } -#[tracing::instrument(level = "debug", skip(services))] -pub async fn fetch_terms(services: &LauncherServices) -> LauncherResult { - let url = format!("{}/oneclient/tos.json", meta_url_base()); +#[tracing::instrument(level = "debug", skip(net))] +pub async fn fetch_terms(net: &RequestClient) -> LauncherResult { + let url = format!( + "{}/oneclient/tos.json", + net.config().meta_url_base + ); let cache_path = paths::caches_dir()?.join("TERMS.json"); - let etag_path = paths::caches_dir()?.join("TERMS.json.etag"); - let stored_etag = read_sidecar_etag(&etag_path).await; - let url_parsed = url.parse().map_err(LauncherError::UrlError)?; + let fetched = fetch_cached(net, &url, &cache_path, EtagPolicy::CommitNow) + .await? + .ok_or_else(|| LauncherError::InvalidSettingsProfile { + reason: "terms are unavailable and not cached".to_string(), + })?; - let mut request = reqwest::Request::new(Method::GET, url_parsed); - if let Some(etag) = &stored_etag { - insert_if_none_match(&mut request, etag); - } - - match services.requester.send(request).await { - Ok(res) if res.status() == StatusCode::NOT_MODIFIED => { - tracing::debug!("terms cache hit (304)"); - read_cached_terms(&cache_path).await - } - Ok(res) if res.status().is_success() => { - let server_etag = etag_from_response(&res); - let bytes = res - .bytes() - .await - .map_err(|err| LauncherError::InvalidSettingsProfile { - reason: err.to_string(), - })?; - - let document: TermsDocument = serde_json::from_slice(&bytes)?; - - if let Some(parent) = cache_path.parent() { - polyio::create_dir_all(parent).await?; - } - polyio::write(&cache_path, &bytes).await?; - if let Some(etag) = server_etag { - let _ = polyio::write(&etag_path, etag.as_bytes()).await; - } - - Ok(document) - } - Err(err) if cache_path.exists() => { - tracing::debug!("falling back to cached terms after remote fetch failed: {err}"); - read_cached_terms(&cache_path).await - } - Err(err) => { - tracing::error!("failed to fetch terms from remote: {err}"); - Err(LauncherError::InvalidSettingsProfile { - reason: err.to_string(), - }) - } - Ok(res) => { - tracing::warn!( - status = %res.status(), - "unexpected terms response; using cache when available" - ); - if cache_path.exists() { - read_cached_terms(&cache_path).await - } else { - Err(LauncherError::InvalidSettingsProfile { - reason: format!("terms request failed with HTTP {}", res.status()), - }) - } - } - } + Ok(fetched.json()?) } -async fn read_cached_terms(path: &Path) -> LauncherResult { - let bytes = polyio::read(path).await?; - Ok(serde_json::from_slice(&bytes)?) -} - -fn insert_if_none_match(request: &mut reqwest::Request, etag: &str) { - if let Ok(value) = header::HeaderValue::from_str(etag) { - request.headers_mut().insert(header::IF_NONE_MATCH, value); - } -} - -fn etag_from_response(res: &reqwest::Response) -> Option { - res.headers() - .get(header::ETAG) - .and_then(|v| v.to_str().ok()) - .map(str::to_string) -} - -async fn read_sidecar_etag(path: &Path) -> Option { - polyio::read(path) - .await - .ok() - .and_then(|bytes| String::from_utf8(bytes).ok()) - .filter(|etag| !etag.is_empty()) -} #[cfg(test)] mod tests { diff --git a/packages/oneclient_core/src/versions/manager.rs b/packages/oneclient_core/src/versions/manager.rs index e8fe5f8b..aae8c20f 100644 --- a/packages/oneclient_core/src/versions/manager.rs +++ b/packages/oneclient_core/src/versions/manager.rs @@ -1,14 +1,11 @@ -use std::path::Path; -use reqwest::header; -use reqwest::{Method, StatusCode}; use tokio::sync::RwLock; -use crate::api_config::meta_url_base; -use crate::paths; +use oneclient_common::paths; +use oneclient_net::{EtagPolicy, fetch_cached}; use crate::state::LauncherServices; use crate::versions::manifest::{RemoteMigration, VersionMetadata, VersionsManifest}; -use crate::{LauncherError, LauncherResult}; +use crate::LauncherResult; pub struct VersionsManager { manifest: RwLock, @@ -32,8 +29,8 @@ impl VersionsManager { Ok(changed) } - pub async fn metadata(&self) -> Vec { - self.manifest.read().await.metadata() + pub async fn metadata(&self, meta_url_base: &str) -> Vec { + self.manifest.read().await.metadata(meta_url_base) } pub async fn migrations(&self) -> Vec { @@ -45,64 +42,18 @@ impl VersionsManager { services: &LauncherServices, ) -> LauncherResult> { let manifest_path = paths::caches_dir()?.join("versions.json"); - let etag_path = paths::caches_dir()?.join("versions.json.etag"); + let url = format!( + "{}/oneclient/versions/metadata.json", + services.requester.config().meta_url_base + ); - let stored_etag = read_sidecar_etag(&etag_path).await; - let url = format!("{}/oneclient/versions/metadata.json", meta_url_base()); - let url_parsed = url.parse().map_err(LauncherError::UrlError)?; + let Some(fetched) = + fetch_cached(&services.requester, &url, &manifest_path, EtagPolicy::CommitNow).await? + else { + return Ok(None); + }; - let mut request = reqwest::Request::new(Method::GET, url_parsed); - if let Some(etag) = &stored_etag { - insert_if_none_match(&mut request, etag); - } - - match services.requester.send(request).await { - Ok(res) if res.status() == StatusCode::NOT_MODIFIED => { - tracing::debug!("versions manifest cache hit (304)"); - read_cached_manifest(&manifest_path, false).await - } - Ok(res) if res.status().is_success() => { - let server_etag = etag_from_response(&res); - let bytes = res - .bytes() - .await - .map_err(|err| LauncherError::InvalidSettingsProfile { - reason: err.to_string(), - })?; - let manifest: VersionsManifest = serde_json::from_slice(&bytes)?; - - if let Some(parent) = manifest_path.parent() { - polyio::create_dir_all(parent).await?; - } - polyio::write(&manifest_path, &bytes).await?; - if let Some(etag) = server_etag { - let _ = polyio::write(&etag_path, etag.as_bytes()).await; - } - - Ok(Some((manifest, true))) - } - Err(err) if manifest_path.exists() => { - tracing::debug!( - "falling back to cached versions manifest after remote fetch failed: {err}" - ); - read_cached_manifest(&manifest_path, false).await - } - Err(err) => { - tracing::error!("failed to fetch versions manifest from remote: {err}"); - Ok(None) - } - Ok(res) => { - tracing::warn!( - status = %res.status(), - "unexpected versions manifest response; using cache when available" - ); - if manifest_path.exists() { - read_cached_manifest(&manifest_path, false).await - } else { - Ok(None) - } - } - } + Ok(Some((fetched.json()?, fetched.changed))) } } @@ -112,35 +63,4 @@ impl Default for VersionsManager { } } -async fn read_cached_manifest( - manifest_path: &Path, - changed: bool, -) -> LauncherResult> { - if !manifest_path.exists() { - return Ok(None); - } - let bytes = polyio::read(manifest_path).await?; - let manifest: VersionsManifest = serde_json::from_slice(&bytes)?; - Ok(Some((manifest, changed))) -} -fn insert_if_none_match(request: &mut reqwest::Request, etag: &str) { - if let Ok(value) = header::HeaderValue::from_str(etag) { - request.headers_mut().insert(header::IF_NONE_MATCH, value); - } -} - -fn etag_from_response(res: &reqwest::Response) -> Option { - res.headers() - .get(header::ETAG) - .and_then(|v| v.to_str().ok()) - .map(str::to_string) -} - -async fn read_sidecar_etag(path: &Path) -> Option { - polyio::read(path) - .await - .ok() - .and_then(|bytes| String::from_utf8(bytes).ok()) - .filter(|etag| !etag.is_empty()) -} diff --git a/packages/oneclient_core/src/versions/manifest.rs b/packages/oneclient_core/src/versions/manifest.rs index 0df78144..8413fdd6 100644 --- a/packages/oneclient_core/src/versions/manifest.rs +++ b/packages/oneclient_core/src/versions/manifest.rs @@ -2,9 +2,8 @@ use std::str::FromStr; use serde::{Deserialize, Serialize}; -use crate::api_config::meta_url_base; -use crate::packages::domain::GameLoader; -use crate::version::{VersionKey, format_mc_version}; +use oneclient_common::domain::GameLoader; +use oneclient_common::version::{VersionKey, format_mc_version}; #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] pub struct VersionsManifest { @@ -137,13 +136,12 @@ impl VersionMetadata { } } -fn art_url(path: &Option) -> Option { - path.as_ref() - .map(|p| format!("{}{p}", meta_url_base())) +fn art_url(path: &Option, meta_url_base: &str) -> Option { + path.as_ref().map(|p| format!("{meta_url_base}{p}")) } impl VersionsManifest { - pub fn metadata(&self) -> Vec { + pub fn metadata(&self, meta_url_base: &str) -> Vec { let mut out = Vec::new(); for cluster in &self.clusters { @@ -158,7 +156,7 @@ impl VersionsManifest { patch_version: None, loader: None, name: cluster_name.clone(), - art_url: art_url(&cluster.art), + art_url: art_url(&cluster.art, meta_url_base), long_description: cluster.long_description.clone(), tags: cluster.tags.clone(), predownload: cluster.predownload.unwrap_or(false), @@ -171,7 +169,7 @@ impl VersionsManifest { patch_version: entry.patch_version, loader: entry.loader.clone(), name: entry.name.clone().unwrap_or_else(|| cluster_name.clone()), - art_url: art_url(&entry.art).or_else(|| art_url(&cluster.art)), + art_url: art_url(&entry.art, meta_url_base).or_else(|| art_url(&cluster.art, meta_url_base)), long_description: entry .long_description .clone() @@ -215,7 +213,7 @@ mod tests { ) .expect("should parse"); - let metadata = manifest.metadata(); + let metadata = manifest.metadata("https://example.test"); let entry = metadata .iter() .find(|m| m.minor_version == Some(1)) @@ -240,7 +238,7 @@ mod tests { ) .expect("should parse"); - let metadata = manifest.metadata(); + let metadata = manifest.metadata("https://example.test"); let flag = |major: u32, minor: Option| { metadata .iter() @@ -269,7 +267,7 @@ mod tests { ) .expect("should parse"); - let metadata = manifest.metadata(); + let metadata = manifest.metadata("https://example.test"); assert_eq!(metadata[0].mc_version(), None); assert_eq!(metadata[1].mc_version().as_deref(), Some("26.1.2")); } diff --git a/packages/oneclient_core/tests/bundle_updates.rs b/packages/oneclient_core/tests/bundle_updates.rs index c10f1ebf..41de3b2d 100644 --- a/packages/oneclient_core/tests/bundle_updates.rs +++ b/packages/oneclient_core/tests/bundle_updates.rs @@ -1,6 +1,6 @@ -use oneclient_core::bundles::{BundleFile, BundleFileKind, BundleManifest, check_bundle_updates}; -use oneclient_core::clusters::{ClusterManager, CreateClusterOptions}; -use oneclient_core::packages::domain::{ContentType, GameLoader, ProviderId}; +use oneclient_content::bundles::{BundleFile, BundleFileKind, BundleManifest, check_bundle_updates}; +use oneclient_core::clusters::CreateClusterOptions; +use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; use oneclient_core::LauncherState; use oneclient_db::dao::{artifact as artifact_dao, cluster_bundle as bundle_dao}; use oneclient_db::models::OverrideType; @@ -40,8 +40,9 @@ fn manifest(files: Vec) -> BundleManifest { } async fn cluster_with_tracked_mod(state: &LauncherState) -> i64 { - let cluster = ClusterManager::create( - state, + let global = state.settings.read().global_game_settings.clone(); + let cluster = state.clusters.create( + &global, CreateClusterOptions::new("Bundle Cluster", MC_VERSION, GameLoader::Fabric), ) .await @@ -99,7 +100,7 @@ async fn mod_still_in_manifest_is_not_removed() { .unwrap(); let cluster_id = cluster_with_tracked_mod(&state).await; - let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services) + let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services.content()) .await .unwrap(); @@ -120,7 +121,7 @@ async fn mod_dropped_from_manifest_is_removed() { .unwrap(); let cluster_id = cluster_with_tracked_mod(&state).await; - let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services) + let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services.content()) .await .unwrap(); @@ -153,7 +154,7 @@ async fn disabled_mod_dropped_from_manifest_is_still_removed() { .await .unwrap(); - let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services) + let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services.content()) .await .unwrap(); @@ -185,7 +186,7 @@ async fn user_disabled_mod_is_not_treated_as_a_removal() { .await .unwrap(); - let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services) + let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services.content()) .await .unwrap(); diff --git a/packages/oneclient_core/tests/clusters.rs b/packages/oneclient_core/tests/clusters.rs index 57279777..e8ba2c66 100644 --- a/packages/oneclient_core/tests/clusters.rs +++ b/packages/oneclient_core/tests/clusters.rs @@ -1,9 +1,9 @@ use std::time::Duration; -use oneclient_core::clusters::{ClusterManager, ClusterStage, CreateClusterOptions}; -use oneclient_core::packages::domain::GameLoader; -use oneclient_core::patch::Patch; -use oneclient_core::settings::store::create_settings_profile; +use oneclient_core::clusters::{ClusterStage, CreateClusterOptions}; +use oneclient_common::domain::GameLoader; +use oneclient_common::patch::Patch; +use oneclient_cluster::profiles::create_settings_profile; use oneclient_core::ProfileUpdate; #[tokio::test] @@ -11,9 +11,14 @@ async fn cluster_lifecycle_with_settings_profile() { let state = oneclient_core::dev::ephemeral_state().await.unwrap(); let settings = state.settings.read().clone(); - let cluster = ClusterManager::create( - &state, - CreateClusterOptions::new("Test Cluster", "1.21.1", GameLoader::Fabric), + let global = state.settings.read().global_game_settings.clone(); + + // Deliberately not the global default of 4096, so the assertions below + // distinguish "the cluster's own profile was used" from "we fell back to + // global and happened to match". + let cluster = state.clusters.create( + &global, + CreateClusterOptions::new("Test Cluster", "1.21.1", GameLoader::Fabric).mem_max(2048), ) .await .unwrap(); @@ -24,53 +29,49 @@ async fn cluster_lifecycle_with_settings_profile() { assert!(cluster.setting_profile_name.is_some()); assert!(cluster.created_at.is_some()); - let resolved = ClusterManager::resolve_settings(&state, &cluster) + let resolved = state.clusters.resolve_settings(&global, &cluster) .await .unwrap(); assert_eq!(resolved.mem_max, Some(2048)); - ClusterManager::update_profile( - &state, - cluster.id, + state.clusters.update_profile(cluster.id, ProfileUpdate { - mem_max: Patch::Set(4096), + mem_max: Patch::Set(6144), ..Default::default() }, ) .await .unwrap(); - let updated = ClusterManager::resolve_settings(&state, &cluster) + let updated = state.clusters.resolve_settings(&global, &cluster) .await .unwrap(); - assert_eq!(updated.mem_max, Some(4096)); + assert_eq!(updated.mem_max, Some(6144)); - let shared = create_settings_profile(&state.services.db, &settings, "Shared Profile") + let shared = create_settings_profile(&state.services.db, &settings.global_game_settings, "Shared Profile") .await .unwrap(); - ClusterManager::update( - &state, - cluster.id, + state.clusters.update(cluster.id, oneclient_core::ClusterUpdate::default().setting_profile(shared.name.clone()), ) .await .unwrap(); - let reassigned = ClusterManager::get(&state, cluster.id).await.unwrap(); + let reassigned = state.clusters.get(cluster.id).await.unwrap(); assert_eq!( reassigned.setting_profile_name.as_deref(), Some("Shared Profile") ); - let after_play = ClusterManager::add_playtime(&state, cluster.id, Duration::from_secs(120)) + let after_play = state.clusters.add_playtime(cluster.id, Duration::from_secs(120)) .await .unwrap(); assert_eq!(after_play.overall_played, Duration::from_secs(120)); assert!(after_play.last_played.is_some()); - ClusterManager::delete(&state, cluster.id, true).await.unwrap(); - assert!(ClusterManager::get(&state, cluster.id).await.is_err()); + state.clusters.delete(cluster.id, true).await.unwrap(); + assert!(state.clusters.get(cluster.id).await.is_err()); } #[tokio::test] @@ -82,18 +83,18 @@ async fn cleared_profile_fields_inherit_from_global() { settings.global_game_settings.mem_max = Some(8192); } - let cluster = ClusterManager::create( - &state, - CreateClusterOptions::new("Inherit Test", "1.21.1", GameLoader::Vanilla).mem_max(2048), + // Read the baseline *after* the write above, so the new cluster inherits it. + let global = state.settings.read().global_game_settings.clone(); + let cluster = state.clusters.create( + &global, + CreateClusterOptions::new("Inherit Test", "1.21.1", GameLoader::Vanilla).mem_max(2048), ) .await .unwrap(); let profile_name = cluster.setting_profile_name.clone().unwrap(); - ClusterManager::update_profile( - &state, - cluster.id, + state.clusters.update_profile(cluster.id, ProfileUpdate { mem_max: Patch::Clear, force_fullscreen: Patch::Clear, @@ -103,10 +104,10 @@ async fn cleared_profile_fields_inherit_from_global() { .await .unwrap(); - let settings = state.settings.read().clone(); - let stored = oneclient_core::settings::store::get_profile_or_default( + let global = state.settings.read().global_game_settings.clone(); + let stored = oneclient_cluster::profiles::get_profile_or_default( &state.services.db, - &settings, + &global, Some(&profile_name), ) .await @@ -114,7 +115,7 @@ async fn cleared_profile_fields_inherit_from_global() { assert_eq!(stored.mem_max, None); assert_eq!(stored.force_fullscreen, None); - let resolved = ClusterManager::resolve_settings(&state, &cluster) + let resolved = state.clusters.resolve_settings(&global, &cluster) .await .unwrap(); assert_eq!(resolved.mem_max, Some(8192)); diff --git a/packages/oneclient_core/tests/download_verify.rs b/packages/oneclient_core/tests/download_verify.rs index 22808357..7c741a46 100644 --- a/packages/oneclient_core/tests/download_verify.rs +++ b/packages/oneclient_core/tests/download_verify.rs @@ -1,7 +1,8 @@ -use oneclient_core::crypto::{Sha1Stream, sha1_bytes, sha1_file}; use oneclient_core::dev; use oneclient_core::game::download_to_path; -use oneclient_core::notification::{GroupedProgressSession, TaskCategory}; +use oneclient_events::{GroupedProgressSession, TaskCategory}; +use polyio::testing::ScratchDir as Scratch; +use polyio::{Sha1Stream, sha1_bytes, sha1_file}; /// A Minecraft asset object. Objects are content-addressed and immutable, so /// this URL/hash pair stays valid. @@ -10,30 +11,6 @@ const ASSET_URL: &str = "https://resources.download.minecraft.net/af/af96f55a90eaf11b327f1b5f8834a051027dc506"; const ASSET_SIZE: u64 = 2063; -/// Scratch directory unique to one test, removed by [`Scratch`]'s drop. -struct Scratch(std::path::PathBuf); - -impl Scratch { - fn new(tag: &str) -> Self { - static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); - let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let path = - std::env::temp_dir().join(format!("oneclient-{tag}-{}-{n}", std::process::id())); - std::fs::create_dir_all(&path).unwrap(); - Self(path) - } - - fn join(&self, name: &str) -> std::path::PathBuf { - self.0.join(name) - } -} - -impl Drop for Scratch { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } -} - #[test] fn sha1_stream_matches_one_shot_hash() { let data: Vec = (0..70_000u32).map(|i| (i % 251) as u8).collect(); @@ -76,13 +53,13 @@ async fn sha1_file_handles_empty_file() { #[ignore = "requires network"] async fn download_to_path_accepts_matching_hash() { let services = dev::ephemeral_services().await.unwrap(); - let progress = GroupedProgressSession::start(&services.notifier, "test"); + let progress = GroupedProgressSession::start(&services.events, "test"); let dir = Scratch::new("download-ok"); let dest = dir.join("nested").join("icon.png"); download_to_path( &services.requester, - &services.notifier, + &services.events, &progress, "icon", TaskCategory::Assets, @@ -102,13 +79,13 @@ async fn download_to_path_accepts_matching_hash() { #[ignore = "requires network"] async fn download_to_path_rejects_mismatched_hash_and_removes_file() { let services = dev::ephemeral_services().await.unwrap(); - let progress = GroupedProgressSession::start(&services.notifier, "test"); + let progress = GroupedProgressSession::start(&services.events, "test"); let dir = Scratch::new("download-bad"); let dest = dir.join("icon.png"); let err = download_to_path( &services.requester, - &services.notifier, + &services.events, &progress, "icon", TaskCategory::Assets, @@ -128,13 +105,13 @@ async fn download_to_path_rejects_mismatched_hash_and_removes_file() { #[ignore = "requires network"] async fn download_to_path_without_expected_hash_still_writes() { let services = dev::ephemeral_services().await.unwrap(); - let progress = GroupedProgressSession::start(&services.notifier, "test"); + let progress = GroupedProgressSession::start(&services.events, "test"); let dir = Scratch::new("download-nohash"); let dest = dir.join("icon.png"); download_to_path( &services.requester, - &services.notifier, + &services.events, &progress, "icon", TaskCategory::Assets, diff --git a/packages/oneclient_core/tests/patch.rs b/packages/oneclient_core/tests/patch.rs index ac0b4eb7..af44c1ef 100644 --- a/packages/oneclient_core/tests/patch.rs +++ b/packages/oneclient_core/tests/patch.rs @@ -1,4 +1,4 @@ -use oneclient_core::patch::Patch; +use oneclient_common::patch::Patch; #[test] fn patch_apply_to_option() { diff --git a/packages/oneclient_core/tests/providers.rs b/packages/oneclient_core/tests/providers.rs index e8151e22..73f3f2e8 100644 --- a/packages/oneclient_core/tests/providers.rs +++ b/packages/oneclient_core/tests/providers.rs @@ -1,11 +1,11 @@ use oneclient_core::dev; -use oneclient_core::packages::domain::{ContentType, ProviderId}; -use oneclient_core::packages::types::{PackageBody, SearchFilters}; +use oneclient_common::domain::{ContentType, ProviderId}; +use oneclient_content::packages::types::{PackageBody, SearchFilters}; #[test] fn registry_get_unknown_provider_errors() { - let registry = oneclient_core::packages::PackageProviderRegistry::new(); + let registry = oneclient_content::packages::PackageProviderRegistry::new(); let err = match registry.get(ProviderId::Local) { Err(e) => e, @@ -17,7 +17,7 @@ fn registry_get_unknown_provider_errors() { #[tokio::test] async fn registry_providers_match_ids() { - let registry = oneclient_core::packages::PackageProviderRegistry::new(); + let registry = oneclient_content::packages::PackageProviderRegistry::new(); for id in registry.remote_ids() { assert_eq!(registry.get(id).unwrap().id(), id); @@ -27,8 +27,8 @@ async fn registry_providers_match_ids() { #[tokio::test] #[ignore = "requires network"] async fn modrinth_search_returns_hits() { - let env = dev::ephemeral_services().await.unwrap(); - let provider = env.packages.get(ProviderId::Modrinth).unwrap(); + let env = dev::ephemeral_services().await.unwrap().content(); + let provider = env.providers.get(ProviderId::Modrinth).unwrap(); let page = provider .search( @@ -54,8 +54,8 @@ async fn modrinth_search_returns_hits() { #[tokio::test] #[ignore = "requires network"] async fn modrinth_get_project_and_versions() { - let env = dev::ephemeral_services().await.unwrap(); - let provider = env.packages.get(ProviderId::Modrinth).unwrap(); + let env = dev::ephemeral_services().await.unwrap().content(); + let provider = env.providers.get(ProviderId::Modrinth).unwrap(); let project = provider.get_project("AANobbMI", &env).await.unwrap(); assert_eq!(project.slug, "sodium"); @@ -71,8 +71,8 @@ async fn modrinth_get_project_and_versions() { #[tokio::test] #[ignore = "requires network"] async fn curseforge_search_returns_hits() { - let env = dev::ephemeral_services().await.unwrap(); - let provider = env.packages.get(ProviderId::CurseForge).unwrap(); + let env = dev::ephemeral_services().await.unwrap().content(); + let provider = env.providers.get(ProviderId::CurseForge).unwrap(); let page = provider .search( @@ -93,8 +93,8 @@ async fn curseforge_search_returns_hits() { #[tokio::test] #[ignore = "requires network"] async fn modrinth_lookup_version_by_sha1() { - let env = dev::ephemeral_services().await.unwrap(); - let provider = env.packages.get(ProviderId::Modrinth).unwrap(); + let env = dev::ephemeral_services().await.unwrap().content(); + let provider = env.providers.get(ProviderId::Modrinth).unwrap(); let versions = provider .list_versions("AANobbMI", Some("1.20.1"), None, 0, 1, &env) @@ -110,7 +110,7 @@ async fn modrinth_lookup_version_by_sha1() { .sha1 .clone(); - let found = env.packages.lookup_version(&sha1, &env).await.unwrap(); + let found = env.providers.lookup_version(&sha1, &env).await.unwrap(); assert!(found.is_some()); let (provider_id, _) = found.unwrap(); @@ -120,8 +120,8 @@ async fn modrinth_lookup_version_by_sha1() { #[tokio::test] #[ignore = "requires network"] async fn curseforge_get_project_fetches_markdown_body() { - let env = dev::ephemeral_services().await.unwrap(); - let provider = env.packages.get(ProviderId::CurseForge).unwrap(); + let env = dev::ephemeral_services().await.unwrap().content(); + let provider = env.providers.get(ProviderId::CurseForge).unwrap(); // 238222 = Just Enough Items (JEI) let project = provider @@ -149,8 +149,8 @@ async fn curseforge_get_project_fetches_markdown_body() { #[tokio::test] #[ignore = "requires network"] async fn modrinth_get_project_with_body_has_markdown() { - let env = dev::ephemeral_services().await.unwrap(); - let provider = env.packages.get(ProviderId::Modrinth).unwrap(); + let env = dev::ephemeral_services().await.unwrap().content(); + let provider = env.providers.get(ProviderId::Modrinth).unwrap(); let project = provider .get_project_with_body("AANobbMI", &env) diff --git a/packages/oneclient_discord/Cargo.toml b/packages/oneclient_discord/Cargo.toml new file mode 100644 index 00000000..f53dce78 --- /dev/null +++ b/packages/oneclient_discord/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "oneclient_discord" +description = "Discord rich presence for OneClient" +version.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true +documentation.workspace = true +readme.workspace = true +homepage.workspace = true +authors.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +oneclient_common.workspace = true + +chrono.workspace = true +discord-rich-presence.workspace = true +tokio.workspace = true +tracing.workspace = true + +[dev-dependencies] +tracing-subscriber.workspace = true diff --git a/packages/oneclient_core/examples/discord_rpc.rs b/packages/oneclient_discord/examples/rpc.rs similarity index 93% rename from packages/oneclient_core/examples/discord_rpc.rs rename to packages/oneclient_discord/examples/rpc.rs index b2fa7267..5b542250 100644 --- a/packages/oneclient_core/examples/discord_rpc.rs +++ b/packages/oneclient_discord/examples/rpc.rs @@ -1,6 +1,6 @@ use std::time::Duration; -use oneclient_core::discord::{DiscordRpc, Presence}; +use oneclient_discord::{DiscordRpc, Presence}; fn main() { tracing_subscriber::fmt() diff --git a/packages/oneclient_core/src/discord.rs b/packages/oneclient_discord/src/lib.rs similarity index 97% rename from packages/oneclient_core/src/discord.rs rename to packages/oneclient_discord/src/lib.rs index ac187391..f6357528 100644 --- a/packages/oneclient_core/src/discord.rs +++ b/packages/oneclient_discord/src/lib.rs @@ -1,10 +1,14 @@ +//! Discord rich presence. +//! +//! A leaf: it takes a presence to show and nothing else. + use std::sync::mpsc::{self, RecvTimeoutError, Sender}; use std::time::{Duration, Instant}; use discord_rich_presence::activity::{Activity, Assets, Button, Timestamps}; use discord_rich_presence::{DiscordIpc, DiscordIpcClient}; -use crate::constants::DISCORD_CLIENT_ID; +use oneclient_common::constants::DISCORD_CLIENT_ID; const RECONNECT_BACKOFF: Duration = Duration::from_secs(60); diff --git a/packages/oneclient_events/Cargo.toml b/packages/oneclient_events/Cargo.toml new file mode 100644 index 00000000..d7acf940 --- /dev/null +++ b/packages/oneclient_events/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "oneclient_events" +description = "The OneClient event bus: notifications, progress, prompts and signals" +version.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true +documentation.workspace = true +readme.workspace = true +homepage.workspace = true +authors.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +thiserror.workspace = true +tokio = { workspace = true } +tracing.workspace = true +uuid.workspace = true diff --git a/packages/oneclient_events/src/bus.rs b/packages/oneclient_events/src/bus.rs new file mode 100644 index 00000000..41d048cd --- /dev/null +++ b/packages/oneclient_events/src/bus.rs @@ -0,0 +1,334 @@ +use tokio::sync::{mpsc, oneshot}; +use uuid::Uuid; + +use crate::error::{EventError, EventResult}; +use crate::event::{Event, GameEvent, LaunchStage, Level, Message, Notification, ProgressEvent, Signal}; +use crate::prompt::{Answer, Chosen, Prompt, PromptRequest}; + +/// The sending half of the event bus. +/// +/// Cheap to clone, since `UnboundedSender` is refcounted internally and every +/// clone feeds the same channel. Hand clones to subsystems rather than wrapping +/// this in an `Arc`. +#[derive(Clone, Debug)] +pub struct EventBus { + tx: mpsc::UnboundedSender, +} + +pub type EventReceiver = mpsc::UnboundedReceiver; + +impl EventBus { + #[must_use] + pub fn new(tx: mpsc::UnboundedSender) -> Self { + Self { tx } + } + + /// Creates a bus and its receiver. The owner of the receiver is the UI. + #[must_use] + pub fn channel() -> (Self, EventReceiver) { + let (tx, rx) = mpsc::unbounded_channel(); + (Self::new(tx), rx) + } + + /// The one primitive every other method goes through. + /// + /// A closed bus is logged rather than returned: emitting is called from + /// deep inside download loops where there is nothing useful to do about a + /// UI that has already shut down. Use [`EventBus::ask`] when you need to + /// know whether the other end is alive. + pub fn emit(&self, event: impl Into) { + if let Err(err) = self.tx.send(event.into()) { + tracing::debug!("dropping event, bus is closed: {err}"); + } + } + + /// Whether the receiving end is still up. + #[must_use] + pub fn is_open(&self) -> bool { + !self.tx.is_closed() + } + + // --- notifications ----------------------------------------------------- + + /// Starts building a notification. Nothing is emitted until `.send()`. + /// + /// ```ignore + /// events.notify("Install failed").body(err.to_string()).error().send(); + /// ``` + pub fn notify(&self, title: impl Into) -> NotificationBuilder<'_> { + NotificationBuilder { + bus: self, + message: Message { + title: title.into(), + body: String::new(), + level: Level::Info, + }, + } + } + + // --- progress ---------------------------------------------------------- + + /// Reports progress for a single task. Repeated calls with the same `id` + /// replace one another. + /// + /// Not a builder: this runs tens of thousands of times during a download, + /// and the argument list is short enough that a builder would only add + /// ceremony to a hot path. + pub fn progress(&self, id: Uuid, label: impl Into, current: u64, total: u64) { + self.emit(ProgressEvent::Update { + id, + label: label.into(), + current, + total, + }); + } + + /// Converts the in-flight progress entry `id` into a finished message. + pub fn finish_progress(&self, id: Uuid, title: impl Into, body: impl Into) { + self.emit(ProgressEvent::Complete { + id, + title: title.into(), + body: body.into(), + }); + } + + // --- game -------------------------------------------------------------- + + pub fn game_stage(&self, cluster_id: i64, stage: LaunchStage) { + self.emit(GameEvent::Stage { cluster_id, stage }); + } + + pub fn game_log(&self, cluster_id: i64, line: impl Into) { + self.emit(GameEvent::Log { + cluster_id, + line: line.into(), + }); + } + + pub fn game_failed(&self, cluster_id: i64, message: impl Into) { + self.emit(GameEvent::Failed { + cluster_id, + message: message.into(), + }); + } + + // --- signals ----------------------------------------------------------- + + pub fn signal(&self, signal: Signal) { + self.emit(signal); + } + + // --- prompts ----------------------------------------------------------- + + /// Asks the user a question and waits for the answer. + /// + /// Returns `Ok(None)` when the prompt was dismissed. The caller's own value + /// for the chosen option comes back in [`Chosen::value`]; the untyped choice + /// id never escapes this function. + #[tracing::instrument(level = "debug", skip_all, fields(title = %prompt.title))] + pub async fn ask(&self, prompt: Prompt) -> EventResult>> { + let Prompt { + title, + body, + options, + dismiss, + } = prompt; + + let (choices, values): (Vec<_>, Vec<_>) = options.into_iter().unzip(); + let (reply, reply_rx) = oneshot::channel(); + + self.tx + .send(Event::Notification(Notification::Prompt(PromptRequest { + title, + body, + choices: choices.clone(), + dismiss, + reply, + }))) + .map_err(|_| EventError::BusClosed)?; + + // A dropped sender means the UI went away without answering, which is + // indistinguishable from a dead bus as far as the caller is concerned. + let Some(answer) = reply_rx.await.map_err(|_| EventError::BusClosed)? else { + return Ok(None); + }; + + let Answer { id, input } = answer; + let index = choices + .iter() + .position(|choice| choice.id == id) + .ok_or(EventError::UnknownChoice(id))?; + + let value = values + .into_iter() + .nth(index) + .ok_or(EventError::UnknownChoice(id))?; + + Ok(Some(Chosen { value, input })) + } +} + +#[must_use = "the notification is not emitted until `.send()` is called"] +pub struct NotificationBuilder<'a> { + bus: &'a EventBus, + message: Message, +} + +impl NotificationBuilder<'_> { + pub fn body(mut self, body: impl Into) -> Self { + self.message.body = body.into(); + self + } + + pub fn level(mut self, level: Level) -> Self { + self.message.level = level; + self + } + + pub fn info(self) -> Self { + self.level(Level::Info) + } + + pub fn error(self) -> Self { + self.level(Level::Error) + } + + pub fn send(self) { + self.bus.emit(self.message); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::prompt::{Choice, InputValue}; + + #[derive(Debug, PartialEq, Eq)] + enum Answered { + Download, + Folder, + } + + fn prompt() -> Prompt { + Prompt::new("Java required", "No Java 21 runtime was found.") + .option( + Choice::primary("download", "Download").picks_selection("java-vendor"), + Answered::Download, + ) + .option( + Choice::new("folder", "Choose folder").picks_folder("Pick a folder"), + Answered::Folder, + ) + .dismiss("Cancel") + } + + /// The whole point of the redesign: the caller gets its own enum back, not + /// a string it has to re-match. + #[tokio::test] + async fn ask_maps_the_answer_back_to_the_callers_value() { + let (bus, mut rx) = EventBus::channel(); + + let task = tokio::spawn(async move { bus.ask(prompt()).await }); + + let Some(Event::Notification(Notification::Prompt(request))) = rx.recv().await else { + panic!("expected a prompt"); + }; + assert_eq!(request.choices.len(), 2); + assert_eq!(request.dismiss.as_deref(), Some("Cancel")); + request + .reply + .send(Some(Answer::new("folder").with_folder("/opt/jdk21"))) + .unwrap(); + + let chosen = task.await.unwrap().unwrap().unwrap(); + assert_eq!(chosen.value, Answered::Folder); + assert_eq!(chosen.folder(), Some(std::path::Path::new("/opt/jdk21"))); + } + + #[tokio::test] + async fn selection_input_round_trips() { + let (bus, mut rx) = EventBus::channel(); + let task = tokio::spawn(async move { bus.ask(prompt()).await }); + + let Some(Event::Notification(Notification::Prompt(request))) = rx.recv().await else { + panic!("expected a prompt"); + }; + request + .reply + .send(Some(Answer::new("download").with_selection("adoptium"))) + .unwrap(); + + let chosen = task.await.unwrap().unwrap().unwrap(); + assert_eq!(chosen.value, Answered::Download); + assert_eq!(chosen.selection(), Some("adoptium")); + assert_eq!( + chosen.input, + Some(InputValue::Selection("adoptium".to_string())) + ); + } + + #[tokio::test] + async fn dismissing_yields_none() { + let (bus, mut rx) = EventBus::channel(); + let task = tokio::spawn(async move { bus.ask(prompt()).await }); + + let Some(Event::Notification(Notification::Prompt(request))) = rx.recv().await else { + panic!("expected a prompt"); + }; + request.reply.send(None).unwrap(); + + assert!(task.await.unwrap().unwrap().is_none()); + } + + /// A front-end answering with a choice that was never offered is a bug, and + /// must not be silently folded into "the user cancelled". + #[tokio::test] + async fn unknown_choice_is_an_error_not_a_dismissal() { + let (bus, mut rx) = EventBus::channel(); + let task = tokio::spawn(async move { bus.ask(prompt()).await }); + + let Some(Event::Notification(Notification::Prompt(request))) = rx.recv().await else { + panic!("expected a prompt"); + }; + request.reply.send(Some(Answer::new("nonsense"))).unwrap(); + + assert_eq!( + task.await.unwrap().unwrap_err(), + EventError::UnknownChoice("nonsense") + ); + } + + /// Dropping the UI mid-prompt must unblock the waiting task rather than + /// hanging it forever. + #[tokio::test] + async fn dropping_the_receiver_unblocks_the_asker() { + let (bus, rx) = EventBus::channel(); + let task = tokio::spawn(async move { bus.ask(prompt()).await }); + drop(rx); + + assert_eq!(task.await.unwrap().unwrap_err(), EventError::BusClosed); + } + + #[test] + fn emitting_on_a_closed_bus_does_not_panic() { + let (bus, rx) = EventBus::channel(); + drop(rx); + + assert!(!bus.is_open()); + bus.notify("gone").body("nobody is listening").error().send(); + bus.game_log(1, "still fine"); + } + + #[tokio::test] + async fn notification_builder_defaults_to_info_with_an_empty_body() { + let (bus, mut rx) = EventBus::channel(); + bus.notify("Done").send(); + + let Some(Event::Notification(Notification::Message(message))) = rx.recv().await else { + panic!("expected a message"); + }; + assert_eq!(message.title, "Done"); + assert_eq!(message.body, ""); + assert_eq!(message.level, Level::Info); + } +} diff --git a/packages/oneclient_events/src/error.rs b/packages/oneclient_events/src/error.rs new file mode 100644 index 00000000..5a3ef014 --- /dev/null +++ b/packages/oneclient_events/src/error.rs @@ -0,0 +1,14 @@ +pub type EventResult = Result; + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum EventError { + /// The receiving end went away: the UI shut down, or was never started. + #[error("the event bus is closed")] + BusClosed, + + /// The UI answered a prompt with a choice that was not offered. Only + /// reachable from a buggy front-end, but it is a wrong answer rather than a + /// missing one, so it must not be silently read as "dismissed". + #[error("prompt answered with an unknown choice '{0}'")] + UnknownChoice(&'static str), +} diff --git a/packages/oneclient_events/src/event.rs b/packages/oneclient_events/src/event.rs new file mode 100644 index 00000000..1d9050f2 --- /dev/null +++ b/packages/oneclient_events/src/event.rs @@ -0,0 +1,152 @@ +use uuid::Uuid; + +use crate::progress::GroupedProgressEvent; +use crate::prompt::PromptRequest; + +/// Everything the core tells the UI about. +/// +/// One channel carries all of it, so ordering between a progress update and the +/// notification that follows it is preserved. +/// +/// The split between [`Event::Notification`] and [`Event::Progress`] is the +/// difference between an opinion and a fact. A notification is the core saying +/// "tell the user this", so it is user-facing by definition. Progress is the +/// core stating that some work is 40% done; whether that becomes a toast, a bar +/// in a modal, or nothing at all is the front-end's call. +/// +/// That is also why nothing here names a screen or a surface. Progress that +/// should not become a toast simply never reaches the global bus: whoever +/// starts the work hands it a bus they own (see [`crate::EventBus::channel`]), +/// and the core never learns who is listening. +#[derive(Debug)] +pub enum Event { + /// Something to put in front of the user: a message, or a question. + Notification(Notification), + /// Work happening, with a completion fraction. Carries no display opinion. + Progress(ProgressEvent), + /// A running Minecraft instance. + Game(GameEvent), + /// State changed elsewhere; whoever caches it should refetch. + Signal(Signal), +} + +/// Something the notification layer owns: a message to show, or a question that +/// blocks the emitting task until answered. +#[derive(Debug)] +pub enum Notification { + Message(Message), + Prompt(PromptRequest), +} + +impl From for Event { + fn from(value: Notification) -> Self { + Self::Notification(value) + } +} + +impl From for Event { + fn from(value: Message) -> Self { + Self::Notification(Notification::Message(value)) + } +} + +impl From for Event { + fn from(value: PromptRequest) -> Self { + Self::Notification(Notification::Prompt(value)) + } +} + +impl From for Event { + fn from(value: ProgressEvent) -> Self { + Self::Progress(value) + } +} + +impl From for Event { + fn from(value: GameEvent) -> Self { + Self::Game(value) + } +} + +impl From for Event { + fn from(value: Signal) -> Self { + Self::Signal(value) + } +} + +impl From for Event { + fn from(value: GroupedProgressEvent) -> Self { + Self::Progress(ProgressEvent::Grouped(value)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Message { + pub title: String, + pub body: String, + pub level: Level, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +pub enum Level { + #[default] + Info, + Error, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProgressEvent { + /// A single task's progress, keyed by `id` so repeated updates replace one + /// another rather than stacking up. + Update { + id: Uuid, + label: String, + current: u64, + total: u64, + }, + + /// Turn the in-flight [`ProgressEvent::Update`] with this `id` into a + /// finished message in place, so a download and its completion notice are + /// one card instead of two. + Complete { + id: Uuid, + title: String, + body: String, + }, + + /// A tree of related tasks. See [`crate::progress`]. + Grouped(GroupedProgressEvent), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GameEvent { + Stage { cluster_id: i64, stage: LaunchStage }, + Log { cluster_id: i64, line: String }, + Failed { cluster_id: i64, message: String }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LaunchStage { + Checking, + Downloading, + Launching, + Running, + Exited, +} + +impl LaunchStage { + #[must_use] + pub fn is_busy(self) -> bool { + matches!(self, Self::Checking | Self::Downloading | Self::Launching) + } +} + +/// Fire-and-forget notices that something changed. Carry no payload beyond +/// what the consumer needs to decide what to invalidate. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Signal { + ClustersChanged, + JavaChanged, + /// Initial background sync finished. + SyncComplete, +} diff --git a/packages/oneclient_events/src/lib.rs b/packages/oneclient_events/src/lib.rs new file mode 100644 index 00000000..1f5cc583 --- /dev/null +++ b/packages/oneclient_events/src/lib.rs @@ -0,0 +1,27 @@ +//! The channel the core talks to the UI over. +//! +//! Every subsystem holds an [`EventBus`] clone and emits [`Event`]s into it; +//! the front-end owns the single receiver and decides what to render. The bus +//! is mostly fire-and-forget. The one exception is [`EventBus::ask`], which +//! blocks the emitting task until the user answers. +//! +//! This crate is a leaf. It must not learn what a Java vendor, a package +//! provider or a cluster is: prompts carry caller-defined values through +//! [`Prompt`], and [`TaskCategory`] holds display labels rather than types. + +mod bus; +mod error; +mod event; + +pub mod progress; +pub mod prompt; + +pub use bus::{EventBus, EventReceiver, NotificationBuilder}; +pub use error::{EventError, EventResult}; +pub use event::{ + Event, GameEvent, LaunchStage, Level, Message, Notification, ProgressEvent, Signal, +}; +pub use progress::{ + GroupedProgressChild, GroupedProgressEvent, GroupedProgressSession, TaskCategory, TaskPhase, +}; +pub use prompt::{Answer, Choice, ChoiceInput, ChoiceStyle, Chosen, InputValue, Prompt, PromptRequest}; diff --git a/packages/oneclient_core/src/notification/grouped.rs b/packages/oneclient_events/src/progress.rs similarity index 64% rename from packages/oneclient_core/src/notification/grouped.rs rename to packages/oneclient_events/src/progress.rs index 8dcb1f40..538f4bdc 100644 --- a/packages/oneclient_core/src/notification/grouped.rs +++ b/packages/oneclient_events/src/progress.rs @@ -1,14 +1,121 @@ +//! Progress for a tree of related tasks. One session has many children, so a +//! multi-file download reports as a single aggregate rather than N cards. +//! +//! Both handles are RAII: dropping the last one ends the session or finishes +//! the child, so an early return on an error path cannot leave the UI showing a +//! task that will never complete. + use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use uuid::Uuid; -use super::NotificationService; -use super::data::{GroupedProgressEvent, TaskCategory}; +use crate::bus::EventBus; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GroupedProgressEvent { + Start { + session_id: Uuid, + title: String, + }, + /// Pre-announce how many children (and total bytes) a category will have, + /// so the aggregate total is known up-front instead of climbing as each + /// child is added during a `buffer_unordered` fan-out. + Expect { + session_id: Uuid, + category: TaskCategory, + count: u64, + total: u64, + }, + AddChild { + session_id: Uuid, + child_id: Uuid, + label: String, + total: u64, + category: TaskCategory, + }, + UpdateChild { + session_id: Uuid, + child_id: Uuid, + current: u64, + total: u64, + }, + SetChildPhase { + session_id: Uuid, + child_id: Uuid, + phase: TaskPhase, + }, + FinishChild { + session_id: Uuid, + child_id: Uuid, + }, + End { + session_id: Uuid, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TaskPhase { + #[default] + Downloading, + Verifying, + Extracting, + Installing, +} + +impl TaskPhase { + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::Downloading => "Downloading", + Self::Verifying => "Verifying", + Self::Extracting => "Extracting", + Self::Installing => "Installing", + } + } +} + +/// Coarse grouping of a grouped-progress child. Drives the notification body text +/// ("Downloading Minecraft" vs "Downloading Packages") and the per-category task rows. +/// +/// These are display labels, not a dependency on the subsystems they name. The +/// event layer must not learn what a Java vendor or a package provider is. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum TaskCategory { + Client, + Metadata, + Libraries, + Natives, + Assets, + Java, + #[default] + Packages, +} + +impl TaskCategory { + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::Client => "Client", + Self::Metadata => "Metadata", + Self::Libraries => "Libraries", + Self::Natives => "Natives", + Self::Assets => "Assets", + Self::Java => "Java runtime", + Self::Packages => "Packages", + } + } + + /// True for everything that is part of the Minecraft install (not user packages). + #[must_use] + pub fn is_minecraft(self) -> bool { + !matches!(self, Self::Packages) + } +} struct SessionInner { session_id: Uuid, - notifier: NotificationService, + events: EventBus, ended: AtomicBool, } @@ -18,7 +125,7 @@ impl SessionInner { return; } - self.notifier.send_grouped(GroupedProgressEvent::End { + self.events.emit(GroupedProgressEvent::End { session_id: self.session_id, }); } @@ -33,7 +140,7 @@ impl Drop for SessionInner { struct ChildInner { session_id: Uuid, child_id: Uuid, - notifier: NotificationService, + events: EventBus, label: String, total: AtomicU64, finished: Arc, @@ -50,16 +157,16 @@ pub struct GroupedProgressChild { } impl GroupedProgressSession { - pub fn start(notifier: &NotificationService, title: impl Into) -> Self { + pub fn start(events: &EventBus, title: impl Into) -> Self { let session_id = Uuid::new_v4(); let title = title.into(); - notifier.send_grouped(GroupedProgressEvent::Start { session_id, title }); + events.emit(GroupedProgressEvent::Start { session_id, title }); Self { inner: Arc::new(SessionInner { session_id, - notifier: notifier.clone(), + events: events.clone(), ended: AtomicBool::new(false), }), } @@ -76,8 +183,8 @@ impl GroupedProgressSession { return; } self.inner - .notifier - .send_grouped(GroupedProgressEvent::Expect { + .events + .emit(GroupedProgressEvent::Expect { session_id: self.inner.session_id, category, count, @@ -96,8 +203,8 @@ impl GroupedProgressSession { let total = total.max(1); self.inner - .notifier - .send_grouped(GroupedProgressEvent::AddChild { + .events + .emit(GroupedProgressEvent::AddChild { session_id: self.inner.session_id, child_id, label: label.clone(), @@ -109,7 +216,7 @@ impl GroupedProgressSession { inner: Arc::new(ChildInner { session_id: self.inner.session_id, child_id, - notifier: self.inner.notifier.clone(), + events: self.inner.events.clone(), label, total: AtomicU64::new(total), finished: Arc::new(std::sync::atomic::AtomicBool::new(false)), @@ -154,7 +261,7 @@ impl GroupedProgressChild { &self.inner.label } - pub fn set_phase(&self, phase: super::data::TaskPhase) { + pub fn set_phase(&self, phase: TaskPhase) { if self .inner .finished @@ -163,8 +270,8 @@ impl GroupedProgressChild { return; } self.inner - .notifier - .send_grouped(GroupedProgressEvent::SetChildPhase { + .events + .emit(GroupedProgressEvent::SetChildPhase { session_id: self.inner.session_id, child_id: self.inner.child_id, phase, @@ -183,15 +290,15 @@ impl GroupedProgressChild { // Prefer the largest known total. Streaming reports Content-Length which is // often missing (1) for compressed/chunked responses; when the child was // created with a real expected size (from a manifest) we must not let that - // stale header clobber it — otherwise the download bar reads 100%/frozen. + // stale header clobber it, otherwise the download bar reads 100%/frozen. let stored = self.inner.total.load(Ordering::Relaxed); let total = total.unwrap_or(0).max(stored).max(1); if total > stored { self.inner.total.store(total, Ordering::Relaxed); } self.inner - .notifier - .send_grouped(GroupedProgressEvent::UpdateChild { + .events + .emit(GroupedProgressEvent::UpdateChild { session_id: self.inner.session_id, child_id: self.inner.child_id, current, @@ -209,8 +316,8 @@ impl GroupedProgressChild { } self.inner - .notifier - .send_grouped(GroupedProgressEvent::FinishChild { + .events + .emit(GroupedProgressEvent::FinishChild { session_id: self.inner.session_id, child_id: self.inner.child_id, }); @@ -221,7 +328,7 @@ impl Drop for GroupedProgressChild { /// Only the *last* handle finishes the task. /// /// `finished` lives in the shared inner, so finishing on every clone's drop - /// ended the task the moment any temporary copy died — and copies are handed + /// ended the task the moment any temporary copy died, and copies are handed /// around routinely (into `ResponseOptions`, into stream closures). A /// download would emit `FinishChild` before its first byte arrived, after /// which every `set_progress` silently no-opped and the UI removed the row. @@ -237,14 +344,12 @@ impl Drop for GroupedProgressChild { #[cfg(test)] mod tests { use super::*; - use crate::notification::Notification; + use crate::event::{Event, ProgressEvent}; - fn drain( - rx: &mut tokio::sync::mpsc::UnboundedReceiver, - ) -> Vec { + fn drain(rx: &mut crate::bus::EventReceiver) -> Vec { let mut events = Vec::new(); - while let Ok(notification) = rx.try_recv() { - if let Notification::GroupedProgress(event) = notification { + while let Ok(event) = rx.try_recv() { + if let Event::Progress(ProgressEvent::Grouped(event)) = event { events.push(event); } } @@ -264,8 +369,8 @@ mod tests { #[test] fn dropping_a_clone_does_not_finish_the_task() { let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); - let notifier = NotificationService::new(tx); - let session = GroupedProgressSession::start(¬ifier, "test"); + let events = EventBus::new(tx); + let session = GroupedProgressSession::start(&events, "test"); let child = session.child("file", 100, TaskCategory::Assets); let _ = drain(&mut rx); @@ -295,8 +400,8 @@ mod tests { #[test] fn explicit_finish_reports_once() { let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); - let notifier = NotificationService::new(tx); - let session = GroupedProgressSession::start(¬ifier, "test"); + let events = EventBus::new(tx); + let session = GroupedProgressSession::start(&events, "test"); let child = session.child("file", 100, TaskCategory::Assets); let _ = drain(&mut rx); @@ -310,8 +415,8 @@ mod tests { #[test] fn progress_after_finish_is_ignored() { let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); - let notifier = NotificationService::new(tx); - let session = GroupedProgressSession::start(¬ifier, "test"); + let events = EventBus::new(tx); + let session = GroupedProgressSession::start(&events, "test"); let child = session.child("file", 100, TaskCategory::Assets); child.finish(); let _ = drain(&mut rx); diff --git a/packages/oneclient_events/src/prompt.rs b/packages/oneclient_events/src/prompt.rs new file mode 100644 index 00000000..d06e4b83 --- /dev/null +++ b/packages/oneclient_events/src/prompt.rs @@ -0,0 +1,214 @@ +//! Asking the user a question and getting a typed answer back. +//! +//! The previous design had one closed `UserChoice` enum listing every answer +//! any prompt could produce, including `Install { vendor: JavaVendor }`, which +//! meant the event layer depended on the Java subsystem that depended on it. +//! +//! Here a prompt carries its own [`Choice`] list. The wire format is untyped +//! (a choice id plus optional collected input), but [`Prompt`] pairs each +//! choice with a caller-defined value and hands that value back, so call sites +//! match on their own enum rather than on strings. + +use std::path::{Path, PathBuf}; + +use tokio::sync::oneshot; + +/// How prominently the UI should render a choice. Only a hint; the event layer +/// has no business knowing what a button looks like. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ChoiceStyle { + /// The recommended action. + Primary, + #[default] + Secondary, + /// Destructive; render with a warning treatment. + Danger, +} + +/// Something the UI must collect from the user before the choice can be +/// answered. Without this a prompt could only return "which button", and +/// answers like "…and here is the folder they picked" would have no home. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ChoiceInput { + /// Open a directory picker. `title` is the dialog's title. + Folder { title: String }, + /// Collect a caller-defined selection, e.g. a Java vendor chosen from a + /// list the UI fetches live. `hint` names the kind of thing being picked so + /// the front-end can route to the right picker; its meaning belongs to + /// whoever built the prompt. + Selection { hint: String }, +} + +/// What the UI collected for a [`ChoiceInput`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InputValue { + Folder(PathBuf), + Selection(String), +} + +/// One answer the user can give. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Choice { + /// Stable identifier, matched when the answer comes back. A `&'static str` + /// rather than a `String` because every id in practice is a literal, and it + /// keeps `Choice` cheap to clone onto the wire. + pub id: &'static str, + pub label: String, + pub style: ChoiceStyle, + pub input: Option, +} + +impl Choice { + pub fn new(id: &'static str, label: impl Into) -> Self { + Self { + id, + label: label.into(), + style: ChoiceStyle::Secondary, + input: None, + } + } + + #[must_use] + pub fn primary(id: &'static str, label: impl Into) -> Self { + Self::new(id, label).style(ChoiceStyle::Primary) + } + + #[must_use] + pub fn danger(id: &'static str, label: impl Into) -> Self { + Self::new(id, label).style(ChoiceStyle::Danger) + } + + #[must_use] + pub fn style(mut self, style: ChoiceStyle) -> Self { + self.style = style; + self + } + + /// Require a directory before this choice can be answered. + #[must_use] + pub fn picks_folder(mut self, title: impl Into) -> Self { + self.input = Some(ChoiceInput::Folder { + title: title.into(), + }); + self + } + + /// Require a caller-defined selection before this choice can be answered. + #[must_use] + pub fn picks_selection(mut self, hint: impl Into) -> Self { + self.input = Some(ChoiceInput::Selection { hint: hint.into() }); + self + } +} + +/// A question, its answers, and what each answer means to the caller. +/// +/// ```ignore +/// enum JavaAnswer { Download, PickFolder } +/// +/// let answer = events.ask( +/// Prompt::new("Java required", "No Java 21 runtime was found.") +/// .option( +/// Choice::primary("download", "Download").picks_selection("java-vendor"), +/// JavaAnswer::Download, +/// ) +/// .option( +/// Choice::new("folder", "Choose folder").picks_folder("Select a Java 21 folder"), +/// JavaAnswer::PickFolder, +/// ) +/// .dismiss("Cancel"), +/// ).await?; +/// ``` +#[must_use = "a prompt is not shown until it is passed to `EventBus::ask`"] +pub struct Prompt { + pub(crate) title: String, + pub(crate) body: String, + pub(crate) options: Vec<(Choice, T)>, + pub(crate) dismiss: Option, +} + +impl Prompt { + pub fn new(title: impl Into, body: impl Into) -> Self { + Self { + title: title.into(), + body: body.into(), + options: Vec::new(), + dismiss: None, + } + } + + /// Add a choice and the value returned if the user picks it. + pub fn option(mut self, choice: Choice, value: T) -> Self { + self.options.push((choice, value)); + self + } + + /// Offer an explicit way out, labelled `label`. Dismissing resolves the + /// prompt to `Ok(None)`; a prompt without this can still be dismissed by + /// closing it, so callers must always handle `None`. + pub fn dismiss(mut self, label: impl Into) -> Self { + self.dismiss = Some(label.into()); + self + } +} + +/// What the user picked: the caller's own value, plus anything the UI collected. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Chosen { + pub value: T, + pub input: Option, +} + +impl Chosen { + /// The directory picked for a [`ChoiceInput::Folder`] choice. + pub fn folder(&self) -> Option<&Path> { + match &self.input { + Some(InputValue::Folder(path)) => Some(path), + _ => None, + } + } + + /// The selection made for a [`ChoiceInput::Selection`] choice. + pub fn selection(&self) -> Option<&str> { + match &self.input { + Some(InputValue::Selection(value)) => Some(value), + _ => None, + } + } +} + +/// The untyped prompt as it crosses the bus. The UI renders `choices` and +/// replies with the id of the one picked; `T` never leaves the caller's crate. +#[derive(Debug)] +pub struct PromptRequest { + pub title: String, + pub body: String, + pub choices: Vec, + pub dismiss: Option, + pub reply: oneshot::Sender>, +} + +/// The UI's reply. `None` on the wire means dismissed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Answer { + pub id: &'static str, + pub input: Option, +} + +impl Answer { + pub fn new(id: &'static str) -> Self { + Self { id, input: None } + } + + #[must_use] + pub fn with_folder(mut self, path: impl Into) -> Self { + self.input = Some(InputValue::Folder(path.into())); + self + } + + #[must_use] + pub fn with_selection(mut self, value: impl Into) -> Self { + self.input = Some(InputValue::Selection(value.into())); + self + } +} diff --git a/packages/oneclient_java/Cargo.toml b/packages/oneclient_java/Cargo.toml new file mode 100644 index 00000000..b677d596 --- /dev/null +++ b/packages/oneclient_java/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "oneclient_java" +description = "Locating, probing and installing Java runtimes" +version.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true +documentation.workspace = true +readme.workspace = true +homepage.workspace = true +authors.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +oneclient_common.workspace = true +oneclient_events.workspace = true +oneclient_net.workspace = true +polyio.workspace = true + +async-trait.workspace = true +async-tempfile.workspace = true +async_zip.workspace = true +astral-tokio-tar.workspace = true +futures-lite.workspace = true +tokio.workspace = true + +reqwest.workspace = true +url.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tracing.workspace = true + +[dev-dependencies] +tokio = { workspace = true } diff --git a/packages/oneclient_core/assets/java/JavaInfo.class b/packages/oneclient_java/assets/java/JavaInfo.class similarity index 100% rename from packages/oneclient_core/assets/java/JavaInfo.class rename to packages/oneclient_java/assets/java/JavaInfo.class diff --git a/packages/oneclient_core/assets/java/JavaInfo.java b/packages/oneclient_java/assets/java/JavaInfo.java similarity index 100% rename from packages/oneclient_core/assets/java/JavaInfo.java rename to packages/oneclient_java/assets/java/JavaInfo.java diff --git a/packages/oneclient_core/assets/java/README.md b/packages/oneclient_java/assets/java/README.md similarity index 100% rename from packages/oneclient_core/assets/java/README.md rename to packages/oneclient_java/assets/java/README.md diff --git a/packages/oneclient_java/examples/install_provider.rs b/packages/oneclient_java/examples/install_provider.rs new file mode 100644 index 00000000..85611236 --- /dev/null +++ b/packages/oneclient_java/examples/install_provider.rs @@ -0,0 +1,71 @@ +//! Downloads and probes one vendor's JRE, then records it in an in-memory store. +//! +//! Runs against `MemoryJavaStore`, so it needs no database and no ephemeral +//! `LauncherState` just to list packages. + +use std::env; + +use oneclient_common::paths::java_dir; +use oneclient_events::EventBus; +use oneclient_java::vendors::{ + AdoptiumRuntimeProvider, CorrettoRuntimeProvider, JavaRuntimeProvider, LibericaRuntimeProvider, + ZuluRuntimeProvider, +}; +use oneclient_java::{JavaResult, JavaService, MemoryJavaStore, check_java_runtime}; +use oneclient_net::{NetConfig, RequestClient}; + +#[tokio::main] +async fn main() -> JavaResult<()> { + let mut args = env::args().skip(1); + let vendor = args.next().unwrap_or_else(|| { + eprintln!("usage: install_provider [major]"); + std::process::exit(1); + }); + + let major: u32 = args + .next() + .unwrap_or_else(|| "21".to_string()) + .parse() + .expect("major version must be a number"); + + let net = RequestClient::new(NetConfig::default())?; + // Nothing drains this, so events are dropped; the install path only uses it + // to report progress. + let (events, _rx) = EventBus::channel(); + + let provider: Box = match vendor.as_str() { + "zulu" => Box::new(ZuluRuntimeProvider), + "adoptium" => Box::new(AdoptiumRuntimeProvider), + "corretto" => Box::new(CorrettoRuntimeProvider), + "liberica" => Box::new(LibericaRuntimeProvider), + other => { + eprintln!("unknown vendor '{other}', use zulu, adoptium, corretto, or liberica"); + std::process::exit(1); + } + }; + + let packages = provider.list_packages(Some(major), &net).await?; + let package = packages + .into_iter() + .find(|p| p.java_version.contains(&major)) + .unwrap_or_else(|| panic!("no {vendor} package for Java {major}")); + + println!("Installing package {:#?} to '{:?}'", package, java_dir()?); + + let executable = provider + .install_package(&package, &net, &events, None) + .await?; + println!("Installed: {}", executable.display()); + + let info = check_java_runtime(executable.display().to_string()).await?; + println!(" version: {}", info.version); + println!(" vendor: {}", info.vendor); + println!(" arch: {}", info.os_arch); + + // Prove the service records it without a database in sight. + let service = JavaService::new(MemoryJavaStore::new(), net, events); + service.add_custom_runtime(executable).await?; + println!("Registered runtimes: {:?}", service.list_runtimes().await?); + + Ok(()) +} diff --git a/packages/oneclient_core/examples/java_list_providers.rs b/packages/oneclient_java/examples/list_providers.rs similarity index 60% rename from packages/oneclient_core/examples/java_list_providers.rs rename to packages/oneclient_java/examples/list_providers.rs index 669c0d2d..bbab2b63 100644 --- a/packages/oneclient_core/examples/java_list_providers.rs +++ b/packages/oneclient_java/examples/list_providers.rs @@ -1,23 +1,25 @@ +//! Lists the JRE packages each vendor currently publishes. +//! +//! Needs only a network client, no database or launcher state. That is the +//! point of `JavaStore` being a port: the vendor providers are exercisable on +//! their own. use std::env; -use oneclient_core::LauncherResult; -use oneclient_core::dev; -use oneclient_core::java::vendors::{JavaVendor, runtime_providers}; -use oneclient_core::logger; +use oneclient_java::vendors::{JavaVendor, runtime_providers}; +use oneclient_java::{JavaPackage, JavaResult}; +use oneclient_net::{NetConfig, RequestClient}; #[tokio::main] -async fn main() -> LauncherResult<()> { - logger::init_debug()?; - +async fn main() -> JavaResult<()> { let arg = env::args().nth(1).unwrap_or_else(|| "21".to_string()); let major: Option = if arg == "all" { None } else { - Some(arg.parse().expect("usage: java_list_providers [major|all]")) + Some(arg.parse().expect("usage: list_providers [major|all]")) }; - let services = dev::ephemeral_services().await?; + let net = RequestClient::new(NetConfig::default())?; match major { Some(major) => println!("Listing JRE packages for Java {major} (metadata only):\n"), @@ -25,7 +27,7 @@ async fn main() -> LauncherResult<()> { } for provider in runtime_providers() { - match provider.list_packages(major, &services).await { + match provider.list_packages(major, &net).await { Ok(packages) => print_vendor(provider.vendor(), &packages), Err(err) => println!("{:?}: ERROR {err}", provider.vendor()), } @@ -34,7 +36,7 @@ async fn main() -> LauncherResult<()> { Ok(()) } -fn print_vendor(vendor: JavaVendor, packages: &[oneclient_core::java::JavaPackage]) { +fn print_vendor(vendor: JavaVendor, packages: &[JavaPackage]) { let label = format!("{vendor:?}"); if packages.is_empty() { diff --git a/packages/oneclient_core/src/java/checker.rs b/packages/oneclient_java/src/checker.rs similarity index 95% rename from packages/oneclient_core/src/java/checker.rs rename to packages/oneclient_java/src/checker.rs index 41a8705e..bb3765ff 100644 --- a/packages/oneclient_core/src/java/checker.rs +++ b/packages/oneclient_java/src/checker.rs @@ -3,9 +3,9 @@ use std::{collections::HashMap, path::PathBuf}; use async_tempfile::TempDir; use tokio::sync::OnceCell; -use crate::java::{JavaError, JavaResult}; +use crate::error::{JavaError, JavaResult}; -const JAVA_INFO_CLASS: &[u8] = include_bytes!("../../assets/java/JavaInfo.class"); +const JAVA_INFO_CLASS: &[u8] = include_bytes!("../assets/java/JavaInfo.class"); #[derive(Debug)] pub struct JavaCheckInfo { diff --git a/packages/oneclient_core/src/java/data.rs b/packages/oneclient_java/src/data.rs similarity index 62% rename from packages/oneclient_core/src/java/data.rs rename to packages/oneclient_java/src/data.rs index 34f7a45b..3ee42f00 100644 --- a/packages/oneclient_core/src/java/data.rs +++ b/packages/oneclient_java/src/data.rs @@ -1,10 +1,8 @@ use std::path::PathBuf; -use std::str::FromStr; -use oneclient_db::models::JavaVersionRow; use serde::{Deserialize, Serialize}; -use crate::java::vendors::JavaVendor; +use crate::vendors::JavaVendor; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct JavaRuntime { @@ -15,17 +13,6 @@ pub struct JavaRuntime { pub os_arch: String, } -impl JavaRuntime { - pub fn from_row(row: JavaVersionRow) -> Self { - Self { - absolute_path: row.absolute_path, - major: row.major as u32, - version: row.version, - vendor: JavaVendor::from_str(&row.vendor).unwrap_or(JavaVendor::Other(row.vendor)), - os_arch: row.os_arch, - } - } -} #[derive(Debug, Clone, PartialEq, Eq)] pub struct JavaPackage { @@ -55,10 +42,10 @@ impl PackageArchive { pub fn java_executable_relative_path() -> PathBuf { #[cfg(target_os = "macos")] { - PathBuf::from("Contents/Home/bin").join(crate::constants::JAVA_BIN) + PathBuf::from("Contents/Home/bin").join(oneclient_common::constants::JAVA_BIN) } #[cfg(not(target_os = "macos"))] { - PathBuf::from("bin").join(crate::constants::JAVA_BIN) + PathBuf::from("bin").join(oneclient_common::constants::JAVA_BIN) } } diff --git a/packages/oneclient_java/src/error.rs b/packages/oneclient_java/src/error.rs new file mode 100644 index 00000000..919b65a0 --- /dev/null +++ b/packages/oneclient_java/src/error.rs @@ -0,0 +1,110 @@ +use thiserror::Error; + +pub type JavaResult = Result; + +#[derive(Debug, Error)] +pub enum JavaError { + #[error(transparent)] + Store(#[from] crate::store::StoreError), + + #[error(transparent)] + Request(#[from] oneclient_net::RequestError), + + #[error(transparent)] + Events(#[from] oneclient_events::EventError), + + #[error(transparent)] + Paths(#[from] oneclient_common::PathsError), + + #[error("Invalid URL: {0}")] + Url(#[from] url::ParseError), + + #[error("Error spawning Java runtime from path '{path}': {source}")] + RuntimeCheckError { + #[source] + source: std::io::Error, + path: String, + }, + + #[error("IO Error: {0}")] + PolyIOError(#[from] polyio::IOError), + + #[error("no java package found for major version {major}")] + PackageNotFound { major: u32 }, + + #[error("no java installations available")] + MissingJava, + + #[error("java setup was cancelled")] + Cancelled, + + #[error("selected path is not a valid java installation: {path}")] + InvalidJavaPath { path: String }, + + #[error("selected java is version {found}, but version {expected} is required")] + VersionMismatch { expected: u32, found: u32 }, + + #[error("failed to parse java version '{version}'")] + ParseVersion { version: String }, + + #[error("failed to extract archive '{archive}': {source}")] + ArchiveExtract { + archive: String, + source: std::io::Error, + }, + + #[error("failed to extract archive '{archive}'")] + ArchiveExtractFailed { archive: String }, +} + +impl JavaError { + /// Whether this is an expected outcome or the user's environment, rather + /// than a launcher bug. + /// + /// The crate states the fact; turning it into "keep this out of Sentry" is + /// a policy the composition layer applies, since nothing down here should + /// know that Sentry exists. + #[must_use] + pub fn is_transient(&self) -> bool { + match self { + // Expected outcomes: no build exists for the requested version, or + // the user cancelled setup. + JavaError::PackageNotFound { .. } | JavaError::Cancelled => true, + // Environmental IO (e.g. out of disk while extracting the runtime). + JavaError::RuntimeCheckError { source, .. } + | JavaError::ArchiveExtract { source, .. } => is_transient_io(source), + JavaError::PolyIOError(polyio::IOError::IOError(source)) + | JavaError::PolyIOError(polyio::IOError::PathIOError { source, .. }) => { + is_transient_io(source) + } + JavaError::Request(source) => source.is_transient(), + _ => false, + } + } +} + +fn is_transient_io(error: &std::io::Error) -> bool { + use std::io::ErrorKind; + + if let Some(code) = error.raw_os_error() { + #[cfg(unix)] + if code == 28 { + // ENOSPC: out of disk part-way through extracting a runtime. + return true; + } + #[cfg(windows)] + if code == 112 || code == 39 { + return true; + } + let _ = code; + } + + matches!( + error.kind(), + ErrorKind::ConnectionRefused + | ErrorKind::ConnectionReset + | ErrorKind::ConnectionAborted + | ErrorKind::NotConnected + | ErrorKind::TimedOut + ) +} \ No newline at end of file diff --git a/packages/oneclient_core/src/java/install.rs b/packages/oneclient_java/src/install.rs similarity index 88% rename from packages/oneclient_core/src/java/install.rs rename to packages/oneclient_java/src/install.rs index 0cbd37bc..f1c92a16 100644 --- a/packages/oneclient_core/src/java/install.rs +++ b/packages/oneclient_java/src/install.rs @@ -3,21 +3,23 @@ use std::path::{Path, PathBuf}; use reqwest::Method; use url::Url; -use crate::http::{ResponseNotifyOptions, ResponseOptions}; -use crate::java::data::{ +use oneclient_net::{ResponseNotifyOptions, ResponseOptions}; +use crate::data::{ java_executable_relative_path, JavaPackage, PackageArchive, }; -use crate::notification::{GroupedProgressSession, TaskCategory, TaskPhase}; -use crate::paths; -use crate::state::LauncherServices; -use crate::LauncherResult; +use oneclient_events::{GroupedProgressSession, TaskCategory, TaskPhase}; +use oneclient_common::paths; +use oneclient_events::EventBus; +use oneclient_net::RequestClient; +use crate::error::JavaResult; -#[tracing::instrument(level = "debug", skip(services, progress))] +#[tracing::instrument(level = "debug", skip(net, events, progress))] pub async fn install_package( package: &JavaPackage, - services: &LauncherServices, + net: &RequestClient, + events: &EventBus, progress: Option<&GroupedProgressSession>, -) -> LauncherResult { +) -> JavaResult { let java_dir = paths::java_dir()?; polyio::create_dir_all(&java_dir).await?; @@ -26,8 +28,8 @@ pub async fn install_package( let major = package.java_version.first().copied().unwrap_or(0); // When a caller owns a grouped session (onboarding, cluster prepare) the - // runtime is one of that session's tasks. Only a standalone install — from - // settings, say — gets its own notification. + // runtime is one of that session's tasks. Only a standalone install (from + // settings, say) gets its own notification. // No size in the package metadata; the child picks up its real total from // Content-Length as soon as the response headers land. let child = progress @@ -42,14 +44,14 @@ pub async fn install_package( .done_label(format!("Installed {} {}", package.vendor, major)), }; - services.requester + net .download_file( reqwest::Request::new(Method::GET, Url::parse(&package.download_url)?), &archive_path, ResponseOptions { notify: Some(notify), }, - services, + events, ) .await?; @@ -111,7 +113,7 @@ fn resolve_installed_executable(extract_root: &Path, package: &JavaPackage) -> P #[cfg(target_os = "macos")] { - use crate::java::vendors::JavaVendor; + use crate::vendors::JavaVendor; if !base_path.join("Contents").join("Home").exists() && let Ok(entries) = std::fs::read_dir(&base_path) diff --git a/packages/oneclient_java/src/lib.rs b/packages/oneclient_java/src/lib.rs new file mode 100644 index 00000000..e00e022c --- /dev/null +++ b/packages/oneclient_java/src/lib.rs @@ -0,0 +1,31 @@ +//! Locating, probing and installing Java runtimes. +//! +//! [`JavaService`] is the entry point. It takes a [`JavaStore`] rather than a +//! database handle, so this crate has no schema dependency: the launcher wires +//! up a SQLite-backed implementation, while tests and examples use +//! [`MemoryJavaStore`]. + +mod checker; +mod data; +mod error; +mod install; +mod locate; +mod platform; +mod resolve; +mod service; +mod store; + +pub mod vendors; + +pub use checker::{JavaCheckInfo, check_java_runtime}; +pub use data::{JavaPackage, JavaRuntime, PackageArchive, java_executable_relative_path}; +pub use error::{JavaError, JavaResult}; +pub use install::install_package; +pub use locate::locate_java; +pub use platform::{HostArch, HostOs, HostTarget}; +pub use service::{ + AvailableJava, INSTALLABLE_MAJORS, JAVA_CHOICE_DOWNLOAD, JAVA_CHOICE_FOLDER, JAVA_VENDOR_HINT, + JavaService, +}; +pub use store::{JavaStore, MemoryJavaStore, StoreError, StoreResult}; +pub use vendors::JavaVendor; diff --git a/packages/oneclient_core/src/java/locate.rs b/packages/oneclient_java/src/locate.rs similarity index 95% rename from packages/oneclient_core/src/java/locate.rs rename to packages/oneclient_java/src/locate.rs index 535c1694..d8a52917 100644 --- a/packages/oneclient_core/src/java/locate.rs +++ b/packages/oneclient_java/src/locate.rs @@ -2,13 +2,13 @@ use std::collections::HashMap; use std::path::PathBuf; -use crate::constants::JAVA_BIN; -use crate::java::checker::{check_java_runtime, JavaCheckInfo}; -use crate::java::data::java_executable_relative_path; -use crate::LauncherResult; +use oneclient_common::constants::JAVA_BIN; +use crate::checker::{JavaCheckInfo, check_java_runtime}; +use crate::data::java_executable_relative_path; +use crate::error::JavaResult; #[tracing::instrument(level = "debug")] -pub async fn locate_java() -> LauncherResult> { +pub async fn locate_java() -> JavaResult> { let paths = internal_locate_java(); let mut valid = HashMap::new(); diff --git a/packages/oneclient_java/src/platform.rs b/packages/oneclient_java/src/platform.rs new file mode 100644 index 00000000..3e5d29eb --- /dev/null +++ b/packages/oneclient_java/src/platform.rs @@ -0,0 +1,122 @@ +//! What machine we are running on, decided once. +//! +//! Each of the four vendors had its own `cfg_select!` pair working this out, +//! which is how they drifted: Adoptium says `mac` where the rest say `macos`, +//! Adoptium says `alpine-linux` where Liberica and Zulu say `linux-musl`, and +//! Adoptium alone spells 32-bit x86 `x32`. +//! +//! Detection is shared here. The *naming* stays with each vendor: that part is +//! not duplication, it is what each API actually expects, and flattening it +//! would only hide the differences. + +use crate::data::PackageArchive; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HostArch { + X86, + X86_64, + Arm, + Aarch64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HostOs { + Windows, + MacOs, + /// `musl` distinguishes Alpine-style builds, which several vendors publish + /// under a separate name. + Linux { + musl: bool, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HostTarget { + pub arch: HostArch, + pub os: HostOs, +} + +impl HostTarget { + /// The machine this build is running on. + pub const CURRENT: Self = Self { + arch: cfg_select! { + target_arch = "x86" => HostArch::X86, + target_arch = "x86_64" => HostArch::X86_64, + target_arch = "arm" => HostArch::Arm, + target_arch = "aarch64" => HostArch::Aarch64, + }, + os: cfg_select! { + target_os = "windows" => HostOs::Windows, + target_os = "macos" => HostOs::MacOs, + target_os = "linux" => cfg_select! { + target_env = "musl" => HostOs::Linux { musl: true }, + _ => HostOs::Linux { musl: false }, + }, + }, + }; + + /// Pointer width as the vendors spell it. Liberica takes architecture and + /// bitness as separate query parameters rather than one combined token. + #[must_use] + pub const fn bitness(self) -> &'static str { + match self.arch { + HostArch::X86 | HostArch::Arm => "32", + HostArch::X86_64 | HostArch::Aarch64 => "64", + } + } + + /// Archive format the vendors ship for this platform. + #[must_use] + pub const fn archive(self) -> PackageArchive { + match self.os { + HostOs::Windows => PackageArchive::Zip, + _ => PackageArchive::TarGz, + } + } + + #[must_use] + pub const fn archive_ext(self) -> &'static str { + match self.os { + HostOs::Windows => "zip", + _ => "tar.gz", + } + } + + #[must_use] + pub const fn is_musl(self) -> bool { + matches!(self.os, HostOs::Linux { musl: true }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bitness_matches_the_architecture() { + assert_eq!(HostTarget::CURRENT.bitness(), { + if cfg!(target_pointer_width = "64") { + "64" + } else { + "32" + } + }); + } + + #[test] + fn windows_ships_zips_and_everything_else_tarballs() { + let expected = if cfg!(windows) { + PackageArchive::Zip + } else { + PackageArchive::TarGz + }; + assert_eq!(HostTarget::CURRENT.archive(), expected); + } + + #[test] + fn musl_is_only_ever_reported_on_linux() { + if HostTarget::CURRENT.is_musl() { + assert!(matches!(HostTarget::CURRENT.os, HostOs::Linux { .. })); + } + } +} diff --git a/packages/oneclient_core/src/java/resolve.rs b/packages/oneclient_java/src/resolve.rs similarity index 91% rename from packages/oneclient_core/src/java/resolve.rs rename to packages/oneclient_java/src/resolve.rs index b2784f4e..a5e671eb 100644 --- a/packages/oneclient_core/src/java/resolve.rs +++ b/packages/oneclient_java/src/resolve.rs @@ -1,9 +1,9 @@ use std::path::{Path, PathBuf}; -use crate::constants::JAVA_BIN; -use crate::java::data::java_executable_relative_path; -use crate::java::{JavaError, JavaResult}; +use oneclient_common::constants::JAVA_BIN; +use crate::data::java_executable_relative_path; +use crate::error::{JavaError, JavaResult}; #[tracing::instrument(level = "debug", skip_all)] pub fn resolve_java_executable(selection: impl AsRef) -> JavaResult { diff --git a/packages/oneclient_java/src/service.rs b/packages/oneclient_java/src/service.rs new file mode 100644 index 00000000..09ad2d8f --- /dev/null +++ b/packages/oneclient_java/src/service.rs @@ -0,0 +1,390 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use std::sync::Arc; + +use oneclient_events::{Choice, EventBus, GroupedProgressSession, Prompt}; +use oneclient_net::RequestClient; + +use crate::checker::{self, JavaCheckInfo}; +use crate::data::{JavaPackage, JavaRuntime}; +use crate::error::{JavaError, JavaResult}; +use crate::resolve::resolve_java_executable; +use crate::store::JavaStore; +use crate::vendors::{self, JavaVendor}; + +/// Choice ids for the "Java required" prompt. Namespaced so a front-end can +/// tell this prompt apart from any other by looking for one of these ids. +pub const JAVA_CHOICE_DOWNLOAD: &str = "java.download"; +pub const JAVA_CHOICE_FOLDER: &str = "java.folder"; + +/// Names the kind of selection the Java prompt expects back, so the front-end +/// knows to show its vendor list rather than some other picker. +pub const JAVA_VENDOR_HINT: &str = "java-vendor"; + +pub const INSTALLABLE_MAJORS: &[u32] = &[8, 11, 16, 17, 18, 19, 20, 21, 22, 23]; + +/// What each option on the "Java required" prompt means. Local to this module, +/// since the event bus only ever sees the choice ids. +enum JavaPromptAnswer { + Download, + PickFolder, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AvailableJava { + pub major: u32, + pub package: JavaPackage, +} + +/// Everything the Java flows need: somewhere to remember runtimes, a way to +/// fetch them, and a bus to report progress and ask questions on. +/// +/// `JavaManager` was a unit struct whose methods each took a `&DbPool` or a +/// `&LauncherServices`; the state is held here instead, and the store is a port +/// (see [`crate::JavaStore`]) so this crate never depends on the database. +pub struct JavaService { + store: Arc, + net: RequestClient, + events: EventBus, +} + +impl JavaService { + #[must_use] + pub fn new(store: Arc, net: RequestClient, events: EventBus) -> Self { + Self { store, net, events } + } + + #[must_use] + pub fn net(&self) -> &RequestClient { + &self.net + } + + #[must_use] + pub fn events(&self) -> &EventBus { + &self.events + } + + // --- queries ----------------------------------------------------------- + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn list_runtimes(&self) -> JavaResult> { + Ok(self.store.list().await?) + } + + /// The runtime a settings profile points at, if it is still registered. + #[tracing::instrument(level = "debug", skip(self))] + pub async fn runtime_for_profile( + &self, + java_path: Option<&str>, + ) -> JavaResult> { + let Some(path) = java_path else { + return Ok(None); + }; + Ok(self.store.get_by_path(path).await?) + } + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn available_versions(&self, vendor: &JavaVendor) -> JavaResult> { + let Some(provider) = provider_for_vendor(vendor) else { + return Ok(Vec::new()); + }; + + let packages = provider.list_packages(None, &self.net).await?; + + let mut by_major = BTreeMap::::new(); + for package in packages { + let Some(&major) = package.java_version.first() else { + continue; + }; + by_major.entry(major).or_insert(package); + } + + let mut available: Vec = by_major + .into_iter() + .map(|(major, package)| AvailableJava { major, package }) + .collect(); + + available.sort_by_key(|entry| std::cmp::Reverse(entry.major)); + Ok(available) + } + + // --- registration ------------------------------------------------------ + + /// Re-scans the well-known install locations and records what it finds. + #[tracing::instrument(level = "debug", skip(self))] + pub async fn rescan(&self) -> JavaResult<()> { + self.register_located(crate::locate::locate_java().await?) + .await + } + + #[tracing::instrument(skip(self))] + pub async fn add_custom_runtime(&self, folder: PathBuf) -> JavaResult { + let executable = resolve_java_executable(&folder)?; + self.register_checked(&executable, None).await + } + + #[tracing::instrument(skip(self))] + pub async fn remove_runtime(&self, absolute_path: &str) -> JavaResult<()> { + self.store.delete_by_path(absolute_path).await?; + tracing::info!("removed Java runtime"); + Ok(()) + } + + // --- acquisition ------------------------------------------------------- + + /// Finds a usable Java `major`, downloading or asking the user if needed. + #[tracing::instrument(level = "debug", skip(self, progress))] + pub async fn prepare( + &self, + major: u32, + search_system: bool, + auto_install: bool, + progress: Option<&GroupedProgressSession>, + ) -> JavaResult { + if let Some(runtime) = self.store.latest_by_major(major).await? { + return Ok(runtime); + } + + if search_system { + self.register_located(crate::locate::locate_java().await?) + .await?; + + if let Some(runtime) = self.store.latest_by_major(major).await? { + return Ok(runtime); + } + } + + if auto_install { + return self.download_and_register(major, progress).await; + } + + self.prompt_and_install(major, progress).await + } + + #[tracing::instrument(skip(self, progress))] + pub async fn install_runtime( + &self, + major: u32, + progress: Option<&GroupedProgressSession>, + ) -> JavaResult { + self.download_and_register(major, progress).await + } + + #[tracing::instrument(skip(self))] + pub async fn install_runtime_from( + &self, + vendor: &JavaVendor, + major: u32, + ) -> JavaResult { + let provider = provider_for_vendor(vendor).ok_or(JavaError::PackageNotFound { major })?; + let package = provider + .latest_package_by_major(major, &self.net) + .await? + .ok_or(JavaError::PackageNotFound { major })?; + let executable = provider + .install_package(&package, &self.net, &self.events, None) + .await?; + + self.register_checked(&executable, Some(major)).await + } + + /// Ask the user how to get a missing runtime, then act on the answer. + /// + /// The choice ids stay inside this function: `ask` hands back the + /// [`JavaPromptAnswer`] paired with the chosen option, so the vendor never + /// has to be named in the event layer. + async fn prompt_and_install( + &self, + major: u32, + progress: Option<&GroupedProgressSession>, + ) -> JavaResult { + let prompt = Prompt::new( + "Java required", + format!( + "No Java {major} runtime was found. Download it automatically, choose an existing installation folder, or cancel?" + ), + ) + .option( + Choice::primary(JAVA_CHOICE_DOWNLOAD, "Download").picks_selection(JAVA_VENDOR_HINT), + JavaPromptAnswer::Download, + ) + .option( + Choice::new(JAVA_CHOICE_FOLDER, "Choose folder") + .picks_folder(format!("Select a Java {major} installation folder")), + JavaPromptAnswer::PickFolder, + ) + .dismiss("Cancel"); + + let Some(chosen) = self.events.ask(prompt).await? else { + return Err(JavaError::Cancelled); + }; + + match chosen.value { + // The UI offers a vendor list; picking one installs it directly. + // Without a selection we fall back to trying every vendor in turn. + JavaPromptAnswer::Download => match chosen.selection() { + Some(vendor) => { + let vendor = JavaVendor::from_str(vendor) + .unwrap_or_else(|_| JavaVendor::Other(vendor.to_string())); + self.install_runtime_from(&vendor, major).await + } + None => self.download_and_register(major, progress).await, + }, + JavaPromptAnswer::PickFolder => match chosen.folder() { + Some(folder) => { + let executable = resolve_java_executable(folder)?; + self.register_checked(&executable, Some(major)).await + } + None => Err(JavaError::Cancelled), + }, + } + } + + /// Tries every vendor in turn until one yields a working runtime. + #[tracing::instrument(level = "debug", skip(self, progress))] + async fn download_and_register( + &self, + major: u32, + progress: Option<&GroupedProgressSession>, + ) -> JavaResult { + for provider in vendors::runtime_providers() { + let vendor = provider.vendor(); + + let packages = provider.list_packages(Some(major), &self.net).await; + let package = match &packages { + Ok(packages) => match packages + .iter() + .find(|p| p.java_version.first() == Some(&major)) + .or_else(|| packages.first()) + { + Some(package) => package, + None => { + tracing::warn!(?vendor, major, "no packages found"); + continue; + } + }, + Err(err) => { + tracing::warn!(?vendor, major, "failed to query packages: {err}"); + continue; + } + }; + + tracing::info!(?vendor, major, "downloading Java runtime"); + match provider + .install_package(package, &self.net, &self.events, progress) + .await + { + Ok(executable) => return self.register_checked(&executable, Some(major)).await, + Err(err) => tracing::warn!(?vendor, major, "install failed: {err}"), + } + } + + Err(JavaError::PackageNotFound { major }) + } + + // --- persistence ------------------------------------------------------- + + /// Probes `executable`, checks it is the version we expected, and records it. + #[tracing::instrument(level = "debug", skip(self))] + async fn register_checked( + &self, + executable: &Path, + expected_major: Option, + ) -> JavaResult { + let info = checker::check_java_runtime(executable.display().to_string()).await?; + let major = parse_major_version(&info.version)?; + + if let Some(expected) = expected_major + && major != expected + { + return Err(JavaError::VersionMismatch { + expected, + found: major, + }); + } + + self.persist(executable, &info).await + } + + /// Records everything a filesystem scan turned up, skipping anything that + /// fails to probe. A broken JDK on the system should not abort the scan. + #[tracing::instrument(level = "debug", skip_all)] + async fn register_located( + &self, + located: impl IntoIterator, + ) -> JavaResult<()> { + for (path, info) in located { + if let Err(err) = self.persist(&path, &info).await { + tracing::warn!( + path = %path.display(), + version = %info.version, + "skipping located Java runtime: {err}" + ); + } + } + Ok(()) + } + + async fn persist(&self, executable: &Path, info: &JavaCheckInfo) -> JavaResult { + let runtime = JavaRuntime { + absolute_path: executable.to_string_lossy().into_owned(), + major: parse_major_version(&info.version)?, + version: info.version.clone(), + vendor: JavaVendor::from_str(&info.vendor) + .unwrap_or_else(|_| JavaVendor::Other(info.vendor.clone())), + os_arch: info.os_arch.clone(), + }; + + Ok(self.store.upsert(&runtime).await?) + } +} + +fn provider_for_vendor(vendor: &JavaVendor) -> Option> { + vendors::runtime_providers() + .into_iter() + .find(|provider| &provider.vendor() == vendor) +} + +/// Parses the major version out of a `java.version` string. +/// +/// Java 8 and earlier report `1.8.0_412`, where the major is the *second* +/// component; 9 and later report `21.0.3`, where it is the first. +fn parse_major_version(version: &str) -> Result { + if let Some(rest) = version.strip_prefix("1.") { + let digit = rest.chars().next().ok_or_else(|| JavaError::ParseVersion { + version: version.to_string(), + })?; + return digit.to_digit(10).ok_or_else(|| JavaError::ParseVersion { + version: version.to_string(), + }); + } + + let head = version.split('.').next().unwrap_or(version); + head.parse::().map_err(|_| JavaError::ParseVersion { + version: version.to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn legacy_versions_take_the_second_component() { + assert_eq!(parse_major_version("1.8.0_412").unwrap(), 8); + assert_eq!(parse_major_version("1.7.0").unwrap(), 7); + } + + #[test] + fn modern_versions_take_the_first() { + assert_eq!(parse_major_version("21.0.3").unwrap(), 21); + assert_eq!(parse_major_version("17").unwrap(), 17); + } + + #[test] + fn garbage_is_rejected_rather_than_defaulted() { + assert!(parse_major_version("not-a-version").is_err()); + assert!(parse_major_version("1.x").is_err()); + } +} diff --git a/packages/oneclient_java/src/store.rs b/packages/oneclient_java/src/store.rs new file mode 100644 index 00000000..94a4a795 --- /dev/null +++ b/packages/oneclient_java/src/store.rs @@ -0,0 +1,98 @@ +//! Where located and installed runtimes are remembered. +//! +//! Defined as a port rather than a concrete database handle so this crate does +//! not depend on the launcher's schema. `java_versions` is a leaf table with a +//! single owner (this crate) and no joins, so the interface stays five methods +//! wide, unlike the artifact and cluster tables where a trait would just be the +//! SQL schema with extra steps. +//! +//! So `oneclient_java` has no database in its dependency tree, and its examples +//! and tests can run against an in-memory store instead of standing up a real +//! SQLite file. + +use std::sync::Arc; + +use crate::data::JavaRuntime; + +/// Whatever went wrong in the backing store, kept opaque so this crate never +/// learns what is behind the port. +#[derive(Debug, thiserror::Error)] +#[error("java store: {0}")] +pub struct StoreError(#[source] pub Box); + +impl StoreError { + pub fn new(source: impl std::error::Error + Send + Sync + 'static) -> Self { + Self(Box::new(source)) + } +} + +pub type StoreResult = Result; + +#[async_trait::async_trait] +pub trait JavaStore: Send + Sync { + /// Records a runtime, replacing any entry with the same path. + async fn upsert(&self, runtime: &JavaRuntime) -> StoreResult; + + async fn get_by_path(&self, absolute_path: &str) -> StoreResult>; + + /// The newest recorded runtime for a major version, if any. + async fn latest_by_major(&self, major: u32) -> StoreResult>; + + async fn delete_by_path(&self, absolute_path: &str) -> StoreResult<()>; + + async fn list(&self) -> StoreResult>; +} + +/// An in-memory [`JavaStore`], for tests and for examples that only want to +/// exercise the vendor providers. +#[derive(Debug, Default)] +pub struct MemoryJavaStore { + runtimes: std::sync::Mutex>, +} + +impl MemoryJavaStore { + #[must_use] + pub fn new() -> Arc { + Arc::new(Self::default()) + } +} + +#[async_trait::async_trait] +impl JavaStore for MemoryJavaStore { + async fn upsert(&self, runtime: &JavaRuntime) -> StoreResult { + let mut runtimes = self.runtimes.lock().expect("java store poisoned"); + runtimes.retain(|existing| existing.absolute_path != runtime.absolute_path); + runtimes.push(runtime.clone()); + Ok(runtime.clone()) + } + + async fn get_by_path(&self, absolute_path: &str) -> StoreResult> { + let runtimes = self.runtimes.lock().expect("java store poisoned"); + Ok(runtimes + .iter() + .find(|runtime| runtime.absolute_path == absolute_path) + .cloned()) + } + + async fn latest_by_major(&self, major: u32) -> StoreResult> { + let runtimes = self.runtimes.lock().expect("java store poisoned"); + Ok(runtimes + .iter() + .filter(|runtime| runtime.major == major) + .max_by(|a, b| a.version.cmp(&b.version)) + .cloned()) + } + + async fn delete_by_path(&self, absolute_path: &str) -> StoreResult<()> { + let mut runtimes = self.runtimes.lock().expect("java store poisoned"); + runtimes.retain(|runtime| runtime.absolute_path != absolute_path); + Ok(()) + } + + async fn list(&self) -> StoreResult> { + let runtimes = self.runtimes.lock().expect("java store poisoned"); + let mut out = runtimes.clone(); + out.sort_by(|a, b| b.major.cmp(&a.major).then_with(|| b.version.cmp(&a.version))); + Ok(out) + } +} diff --git a/packages/oneclient_core/src/java/vendors/adoptium.rs b/packages/oneclient_java/src/vendors/adoptium.rs similarity index 77% rename from packages/oneclient_core/src/java/vendors/adoptium.rs rename to packages/oneclient_java/src/vendors/adoptium.rs index 382aa608..0fcf6132 100644 --- a/packages/oneclient_core/src/java/vendors/adoptium.rs +++ b/packages/oneclient_java/src/vendors/adoptium.rs @@ -2,11 +2,12 @@ use reqwest::{Method, Request}; use serde::Deserialize; use url::Url; -use crate::LauncherResult; -use crate::java::PackageArchive; -use crate::java::data::JavaPackage; -use crate::java::vendors::{JavaRuntimeProvider, JavaVendor}; -use crate::state::LauncherServices; +use oneclient_net::RequestClient; + +use crate::data::{JavaPackage, PackageArchive}; +use crate::error::JavaResult; +use crate::platform::{HostArch, HostOs, HostTarget}; +use crate::vendors::{JavaRuntimeProvider, JavaVendor}; pub struct AdoptiumRuntimeProvider; @@ -45,18 +46,17 @@ impl JavaRuntimeProvider for AdoptiumRuntimeProvider { JavaVendor::Adoptium } - #[tracing::instrument(level = "debug", skip(self, services))] + #[tracing::instrument(level = "debug", skip(self, net))] async fn list_packages( &self, major: Option, - services: &LauncherServices, - ) -> LauncherResult> { + net: &RequestClient, + ) -> JavaResult> { // fetched from `/v3/info/available_releases`. let (low, high) = match major { Some(major) => (major, major), None => { - let info = services - .requester + let info = net .send_as::(Request::new( Method::GET, Url::parse("https://api.adoptium.net/v3/info/available_releases")?, @@ -69,8 +69,7 @@ impl JavaRuntimeProvider for AdoptiumRuntimeProvider { }; let url = adoptium_url(low, high)?; - let releases = services - .requester + let releases = net .send_as::>(Request::new(Method::GET, url)) .await?; @@ -106,7 +105,7 @@ impl JavaRuntimeProvider for AdoptiumRuntimeProvider { // One request spanning every GA major via a version range (`[low,high]`), // newest build per major, instead of one request per major. -fn adoptium_url(low: u32, high: u32) -> LauncherResult { +fn adoptium_url(low: u32, high: u32) -> JavaResult { let range = format!("%5B{low}%2C{}%5D", high + 1); let mut url = Url::parse(&format!( @@ -129,18 +128,19 @@ fn adoptium_url(low: u32, high: u32) -> LauncherResult { Ok(url) } -const ADOPTIUM_ARCH: &str = cfg_select! { - target_arch = "x86" => "x32", - target_arch = "x86_64" => "x64", - target_arch = "arm" => "arm", - target_arch = "aarch64" => "aarch64", +/// Adoptium alone spells 32-bit x86 `x32`. +const ADOPTIUM_ARCH: &str = match HostTarget::CURRENT.arch { + HostArch::X86 => "x32", + HostArch::X86_64 => "x64", + HostArch::Arm => "arm", + HostArch::Aarch64 => "aarch64", }; -const ADOPTIUM_OS: &str = cfg_select! { - target_os = "windows" => "windows", - target_os = "macos" => "mac", - target_os = "linux" => cfg_select! { - target_env = "musl" => "alpine-linux", - _ => "linux", - }, +/// Adoptium says `mac` where the other vendors say `macos`, and publishes musl +/// builds under `alpine-linux` rather than `linux-musl`. +const ADOPTIUM_OS: &str = match HostTarget::CURRENT.os { + HostOs::Windows => "windows", + HostOs::MacOs => "mac", + HostOs::Linux { musl: true } => "alpine-linux", + HostOs::Linux { musl: false } => "linux", }; diff --git a/packages/oneclient_core/src/java/vendors/corretto.rs b/packages/oneclient_java/src/vendors/corretto.rs similarity index 71% rename from packages/oneclient_core/src/java/vendors/corretto.rs rename to packages/oneclient_java/src/vendors/corretto.rs index e44f1d3d..1b36dca9 100644 --- a/packages/oneclient_core/src/java/vendors/corretto.rs +++ b/packages/oneclient_java/src/vendors/corretto.rs @@ -2,11 +2,12 @@ use reqwest::{Method, Request}; use serde_json::Value; use url::Url; -use crate::LauncherResult; -use crate::java::PackageArchive; -use crate::java::data::JavaPackage; -use crate::java::vendors::{JavaRuntimeProvider, JavaVendor}; -use crate::state::LauncherServices; +use oneclient_net::RequestClient; + +use crate::data::{JavaPackage, PackageArchive}; +use crate::error::JavaResult; +use crate::platform::{HostArch, HostOs, HostTarget}; +use crate::vendors::{JavaRuntimeProvider, JavaVendor}; pub struct CorrettoRuntimeProvider; @@ -20,14 +21,13 @@ impl JavaRuntimeProvider for CorrettoRuntimeProvider { JavaVendor::Corretto } - #[tracing::instrument(level = "debug", skip(self, services))] + #[tracing::instrument(level = "debug", skip(self, net))] async fn list_packages( &self, major: Option, - services: &LauncherServices, - ) -> LauncherResult> { - let index = services - .requester + net: &RequestClient, + ) -> JavaResult> { + let index = net .send_as::(Request::new(Method::GET, Url::parse(INDEX_URL)?)) .await?; @@ -86,20 +86,21 @@ fn latest_url(major: u32, ext: &str) -> String { ) } -const CORRETTO_ARCH: &str = cfg_select! { - target_arch = "x86" => "x86", - target_arch = "x86_64" => "x64", - target_arch = "arm" => "arm", - target_arch = "aarch64" => "aarch64", +const CORRETTO_ARCH: &str = match HostTarget::CURRENT.arch { + HostArch::X86 => "x86", + HostArch::X86_64 => "x64", + HostArch::Arm => "arm", + HostArch::Aarch64 => "aarch64", }; -const CORRETTO_OS: &str = cfg_select! { - target_os = "windows" => "windows", - target_os = "macos" => "macos", - target_os = "linux" => "linux", +/// Corretto publishes no separate musl build, so Alpine gets the glibc one. +const CORRETTO_OS: &str = match HostTarget::CURRENT.os { + HostOs::Windows => "windows", + HostOs::MacOs => "macos", + HostOs::Linux { .. } => "linux", }; -const CORRETTO_EXT: (&str, PackageArchive) = cfg_select! { - target_os = "windows" => ("zip", PackageArchive::Zip), - _ => ("tar.gz", PackageArchive::TarGz), -}; +const CORRETTO_EXT: (&str, PackageArchive) = ( + HostTarget::CURRENT.archive_ext(), + HostTarget::CURRENT.archive(), +); diff --git a/packages/oneclient_core/src/java/vendors/liberica.rs b/packages/oneclient_java/src/vendors/liberica.rs similarity index 70% rename from packages/oneclient_core/src/java/vendors/liberica.rs rename to packages/oneclient_java/src/vendors/liberica.rs index 50424d59..261253e1 100644 --- a/packages/oneclient_core/src/java/vendors/liberica.rs +++ b/packages/oneclient_java/src/vendors/liberica.rs @@ -2,11 +2,12 @@ use reqwest::{Method, Request}; use serde::Deserialize; use url::Url; -use crate::LauncherResult; -use crate::java::PackageArchive; -use crate::java::data::JavaPackage; -use crate::java::vendors::{JavaRuntimeProvider, JavaVendor}; -use crate::state::LauncherServices; +use oneclient_net::RequestClient; + +use crate::data::{JavaPackage, PackageArchive}; +use crate::error::JavaResult; +use crate::platform::{HostArch, HostOs, HostTarget}; +use crate::vendors::{JavaRuntimeProvider, JavaVendor}; pub struct LibericaRuntimeProvider; @@ -30,15 +31,14 @@ impl JavaRuntimeProvider for LibericaRuntimeProvider { JavaVendor::Liberica } - #[tracing::instrument(level = "debug", skip(self, services))] + #[tracing::instrument(level = "debug", skip(self, net))] async fn list_packages( &self, major: Option, - services: &LauncherServices, - ) -> LauncherResult> { + net: &RequestClient, + ) -> JavaResult> { let url = liberica_url(major)?; - let releases = services - .requester + let releases = net .send_as::>(Request::new(Method::GET, url)) .await?; @@ -75,7 +75,7 @@ impl JavaRuntimeProvider for LibericaRuntimeProvider { } } -fn liberica_url(major: Option) -> LauncherResult { +fn liberica_url(major: Option) -> JavaResult { let mut url = Url::parse("https://api.bell-sw.com/v1/liberica/releases")?; { let mut q = url.query_pairs_mut(); @@ -97,30 +97,20 @@ fn liberica_url(major: Option) -> LauncherResult { Ok(url) } -const LIBERICA_ARCH: &str = cfg_select! { - target_arch = "x86" => "x86", - target_arch = "x86_64" => "x86", - target_arch = "arm" => "arm", - target_arch = "aarch64" => "arm", +/// Liberica takes architecture family and bitness as separate parameters, so +/// x86/x86_64 collapse to `x86` and both ARM variants to `arm`. +const LIBERICA_ARCH: &str = match HostTarget::CURRENT.arch { + HostArch::X86 | HostArch::X86_64 => "x86", + HostArch::Arm | HostArch::Aarch64 => "arm", }; -const LIBERICA_BITNESS: &str = cfg_select! { - target_arch = "x86" => "32", - target_arch = "x86_64" => "64", - target_arch = "arm" => "32", - target_arch = "aarch64" => "64", +const LIBERICA_BITNESS: &str = HostTarget::CURRENT.bitness(); + +const LIBERICA_OS: &str = match HostTarget::CURRENT.os { + HostOs::Windows => "windows", + HostOs::MacOs => "macos", + HostOs::Linux { musl: true } => "linux-musl", + HostOs::Linux { musl: false } => "linux", }; -const LIBERICA_OS: &str = cfg_select! { - target_os = "windows" => "windows", - target_os = "macos" => "macos", - target_os = "linux" => cfg_select! { - target_env = "musl" => "linux-musl", - _ => "linux" - }, -}; - -const LIBERICA_PACKAGE: &str = cfg_select! { - target_os = "windows" => "zip", - _ => "tar.gz", -}; +const LIBERICA_PACKAGE: &str = HostTarget::CURRENT.archive_ext(); diff --git a/packages/oneclient_core/src/java/vendors/mod.rs b/packages/oneclient_java/src/vendors/mod.rs similarity index 81% rename from packages/oneclient_core/src/java/vendors/mod.rs rename to packages/oneclient_java/src/vendors/mod.rs index 759cda21..b1c5e872 100644 --- a/packages/oneclient_core/src/java/vendors/mod.rs +++ b/packages/oneclient_java/src/vendors/mod.rs @@ -1,9 +1,11 @@ use std::{path::PathBuf, str::FromStr}; -use crate::java::JavaPackage; -use crate::state::LauncherServices; -use crate::LauncherResult; +use oneclient_events::{EventBus, GroupedProgressSession}; +use oneclient_net::RequestClient; + +use crate::data::JavaPackage; +use crate::error::JavaResult; mod adoptium; mod corretto; @@ -32,16 +34,16 @@ pub trait JavaRuntimeProvider: Send + Sync { async fn list_packages( &self, major: Option, - services: &LauncherServices, - ) -> LauncherResult>; + net: &RequestClient, + ) -> JavaResult>; - #[tracing::instrument(level = "debug", skip(self, services))] + #[tracing::instrument(level = "debug", skip(self, net))] async fn latest_package_by_major( &self, major: u32, - services: &LauncherServices - ) -> LauncherResult> { - let packages = self.list_packages(Some(major), services).await?; + net: &RequestClient, + ) -> JavaResult> { + let packages = self.list_packages(Some(major), net).await?; Ok(packages.into_iter().find(|p| p.java_version.first() == Some(&major))) } @@ -49,10 +51,11 @@ pub trait JavaRuntimeProvider: Send + Sync { async fn install_package( &self, package: &JavaPackage, - services: &LauncherServices, - progress: Option<&crate::notification::GroupedProgressSession>, - ) -> LauncherResult { - super::install_package(package, services, progress).await + net: &RequestClient, + events: &EventBus, + progress: Option<&GroupedProgressSession>, + ) -> JavaResult { + crate::install::install_package(package, net, events, progress).await } } diff --git a/packages/oneclient_core/src/java/vendors/zulu.rs b/packages/oneclient_java/src/vendors/zulu.rs similarity index 63% rename from packages/oneclient_core/src/java/vendors/zulu.rs rename to packages/oneclient_java/src/vendors/zulu.rs index ad12b8c5..ab2eb828 100644 --- a/packages/oneclient_core/src/java/vendors/zulu.rs +++ b/packages/oneclient_java/src/vendors/zulu.rs @@ -2,11 +2,12 @@ use reqwest::{Method, Request}; use serde::Deserialize; use url::Url; -use crate::java::PackageArchive; -use crate::java::data::JavaPackage; -use crate::java::vendors::{JavaRuntimeProvider, JavaVendor}; -use crate::state::LauncherServices; -use crate::LauncherResult; +use oneclient_net::RequestClient; + +use crate::data::{JavaPackage, PackageArchive}; +use crate::error::JavaResult; +use crate::platform::{HostArch, HostOs, HostTarget}; +use crate::vendors::{JavaRuntimeProvider, JavaVendor}; pub struct ZuluRuntimeProvider; @@ -23,10 +24,10 @@ impl JavaRuntimeProvider for ZuluRuntimeProvider { JavaVendor::Zulu } - #[tracing::instrument(level = "debug", skip(self, services))] - async fn list_packages(&self, major: Option, services: &LauncherServices) -> LauncherResult> { + #[tracing::instrument(level = "debug", skip(self, net))] + async fn list_packages(&self, major: Option, net: &RequestClient) -> JavaResult> { let url = zulu_url(major)?; - let packages = services.requester.send_as::>(Request::new(Method::GET, url)).await?; + let packages = net.send_as::>(Request::new(Method::GET, url)).await?; let packages: Vec = packages.into_iter().map(map_zulu_package).collect(); tracing::debug!(count = packages.len(), "listed Zulu packages"); @@ -45,7 +46,7 @@ fn map_zulu_package(pkg: ZuluPackage) -> JavaPackage { } } -fn zulu_url(major: Option) -> LauncherResult { +fn zulu_url(major: Option) -> JavaResult { let mut url = Url::parse("https://api.azul.com/metadata/v1/zulu/packages/")?; { let mut q = url.query_pairs_mut(); @@ -67,18 +68,16 @@ fn zulu_url(major: Option) -> LauncherResult { Ok(url) } -const ZULU_ARCH: &str = cfg_select! { - target_arch = "x86" => "x86", - target_arch = "x86_64" => "x64", - target_arch = "arm" => "arm", - target_arch = "aarch64" => "aarch64", +const ZULU_ARCH: &str = match HostTarget::CURRENT.arch { + HostArch::X86 => "x86", + HostArch::X86_64 => "x64", + HostArch::Arm => "arm", + HostArch::Aarch64 => "aarch64", }; -const ZULU_OS: &str = cfg_select! { - target_os = "windows" => "windows", - target_os = "macos" => "macos", - target_os = "linux" => cfg_select! { - target_env = "musl" => "linux-musl", - _ => "linux" - }, +const ZULU_OS: &str = match HostTarget::CURRENT.os { + HostOs::Windows => "windows", + HostOs::MacOs => "macos", + HostOs::Linux { musl: true } => "linux-musl", + HostOs::Linux { musl: false } => "linux", }; \ No newline at end of file diff --git a/packages/oneclient_mc/Cargo.toml b/packages/oneclient_mc/Cargo.toml new file mode 100644 index 00000000..36510292 --- /dev/null +++ b/packages/oneclient_mc/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "oneclient_mc" +description = "Minecraft content: assets, libraries, natives, client jars, launch arguments and player profiles" +version.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true +documentation.workspace = true +readme.workspace = true +homepage.workspace = true +authors.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +oneclient_common.workspace = true +oneclient_events.workspace = true +oneclient_net.workspace = true +polyio.workspace = true + +# Mojang / modded metadata schemas +interfrost.workspace = true + +futures-util.workspace = true +tokio.workspace = true + +base64.workspace = true +chrono.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tracing.workspace = true +url.workspace = true +uuid.workspace = true + +[dev-dependencies] +tokio = { workspace = true } diff --git a/packages/oneclient_core/src/game/arguments.rs b/packages/oneclient_mc/src/arguments.rs similarity index 93% rename from packages/oneclient_core/src/game/arguments.rs rename to packages/oneclient_mc/src/arguments.rs index 7667d751..498e9cd3 100644 --- a/packages/oneclient_core/src/game/arguments.rs +++ b/packages/oneclient_mc/src/arguments.rs @@ -5,11 +5,11 @@ use interfrost::api::minecraft::{Argument, ArgumentValue, Library, VersionType}; use interfrost::api::modded::SidedDataEntry; use interfrost::utils::get_path_from_artifact; -use crate::constants::{self, DUMMY_REPLACE_NEWLINE}; -use crate::game::rules::validate_rules; -use crate::game::GameError; -use crate::settings::Resolution; -use crate::LauncherResult; +use oneclient_common::constants::{self, DUMMY_REPLACE_NEWLINE}; +use crate::rules::validate_rules; +use crate::error::McError; +use oneclient_common::Resolution; +use crate::error::McResult; #[allow(clippy::too_many_arguments)] pub fn java_arguments( @@ -22,7 +22,7 @@ pub fn java_arguments( mem_max: u32, custom_args: String, java_arch: &str, -) -> LauncherResult> { +) -> McResult> { let mut parsed = Vec::new(); if let Some(args) = arguments { parse_arguments( @@ -44,7 +44,7 @@ pub fn java_arguments( parsed.push(format!( "-Djava.library.path={}", polyio::canonicalize(natives_path) - .map_err(|_| GameError::LibraryPath(natives_path.display().to_string()))? + .map_err(|_| McError::LibraryPath(natives_path.display().to_string()))? .display() )); parsed.push("-cp".to_string()); @@ -75,7 +75,7 @@ pub fn minecraft_arguments( version_type: VersionType, resolution: Resolution, java_arch: &str, -) -> LauncherResult> { +) -> McResult> { if let Some(args) = args { let mut parsed = Vec::new(); parse_arguments( @@ -141,7 +141,7 @@ pub fn processor_arguments, S: std::hash::BuildHasher>( libraries_path: &Path, args: &[T], data: &HashMap, -) -> LauncherResult> { +) -> McResult> { let mut parsed = Vec::new(); for arg in args { @@ -169,7 +169,7 @@ pub fn processor_arguments, S: std::hash::BuildHasher>( } #[tracing::instrument(skip_all, level = "debug")] -pub async fn main_class(path: impl AsRef) -> LauncherResult> { +pub async fn main_class(path: impl AsRef) -> McResult> { let data = polyio::read(path.as_ref()).await?; let mut class_name = None; @@ -223,7 +223,7 @@ pub fn parse_minecraft_argument( assets_directory: &Path, version_type: VersionType, resolution: Resolution, -) -> LauncherResult { +) -> McResult { Ok(argument .replace("${accessToken}", access_token) .replace("${auth_access_token}", access_token) @@ -232,7 +232,7 @@ pub fn parse_minecraft_argument( .replace("${auth_xuid}", "0") .replace("${auth_uuid}", &uuid.simple().to_string()) .replace("${uuid}", &uuid.simple().to_string()) - .replace("${clientid}", crate::constants::MICROSOFT_CLIENT_ID) + .replace("${clientid}", oneclient_common::constants::MICROSOFT_CLIENT_ID) .replace("${user_properties}", "{}") .replace("${user_type}", "msa") .replace("${version_name}", version) @@ -260,7 +260,7 @@ fn parse_java_argument( libraries_path: &Path, classpaths: &str, version: &str, -) -> LauncherResult { +) -> McResult { argument.retain(|c| !c.is_whitespace()); Ok(argument .replace( @@ -284,7 +284,7 @@ pub fn classpaths( client_path: &Path, java_arch: &str, updated: bool, -) -> LauncherResult { +) -> McResult { let mut chosen: HashMap, &str)> = HashMap::new(); for lib in libraries { if let Some(rules) = &lib.rules @@ -350,7 +350,7 @@ fn version_key(version: &str) -> Vec { pub fn get_classpath_library>( libraries_path: &Path, libraries: &[T], -) -> LauncherResult { +) -> McResult { let classpaths = libraries .iter() .map(|lib| get_library(libraries_path, lib.as_ref(), false)) @@ -363,10 +363,10 @@ pub fn get_library( libraries_path: &Path, library: &str, error_exist: bool, -) -> LauncherResult { +) -> McResult { let mut path = libraries_path.to_path_buf(); path.push(get_path_from_artifact(library).map_err(|_| { - GameError::LibraryPath(library.to_string()) + McError::LibraryPath(library.to_string()) })?); if !path.exists() && error_exist { @@ -382,9 +382,9 @@ fn parse_arguments( parsed: &mut Vec, parse_function: ParseFn, java_arch: &str, -) -> LauncherResult<()> +) -> McResult<()> where - ParseFn: Fn(&str) -> LauncherResult, + ParseFn: Fn(&str) -> McResult, { for arg in args { match arg { diff --git a/packages/oneclient_mc/src/download.rs b/packages/oneclient_mc/src/download.rs new file mode 100644 index 00000000..0f76a636 --- /dev/null +++ b/packages/oneclient_mc/src/download.rs @@ -0,0 +1,65 @@ +//! Minecraft-flavoured wrappers over [`oneclient_net`]'s verified downloads. +//! +//! All that is left here is the part specific to the game install: naming the +//! grouped-progress child and its category. + +use oneclient_events::{EventBus, GroupedProgressSession, TaskCategory}; +use oneclient_net::RequestClient; + +use crate::error::McResult; + +/// Downloads `url` to `dest` as one child of `progress`, verifying SHA-1 as the +/// bytes stream past. +/// +/// The child is created *before* the request goes out: waiting on response +/// headers is most of a small file's lifetime, and a child that only existed +/// while bytes were moving would never appear in the UI at all. +#[allow(clippy::too_many_arguments)] +#[tracing::instrument(skip(requester, events, progress, label, expected_sha1), fields(%url), level = "debug")] +pub async fn download_to_path( + requester: &RequestClient, + events: &EventBus, + progress: &GroupedProgressSession, + label: impl Into, + category: TaskCategory, + expected_size: u64, + url: &str, + dest: &std::path::Path, + expected_sha1: Option<&str>, +) -> McResult<()> { + let child = progress.child(label, expected_size, category); + + oneclient_net::download_verified( + requester, + events, + url, + dest, + expected_sha1, + expected_size, + Some(child), + ) + .await?; + + Ok(()) +} + +/// Downloads `url` into memory, verifying SHA-1 over the collected bytes. +/// +/// For payloads that get parsed rather than stored, where writing to disk only +/// to read it straight back would be wasted IO. +#[tracing::instrument(skip(requester, events, progress, label), fields(%url), level = "debug")] +#[allow(clippy::too_many_arguments)] +pub async fn fetch_bytes_verified( + requester: &RequestClient, + events: &EventBus, + progress: &GroupedProgressSession, + label: impl Into, + category: TaskCategory, + expected_size: u64, + url: &str, + expected_sha1: &str, +) -> McResult> { + let child = progress.child(label, expected_size, category); + + Ok(oneclient_net::fetch_verified(requester, events, url, expected_sha1, Some(child)).await?) +} diff --git a/packages/oneclient_mc/src/error.rs b/packages/oneclient_mc/src/error.rs new file mode 100644 index 00000000..b48072bc --- /dev/null +++ b/packages/oneclient_mc/src/error.rs @@ -0,0 +1,84 @@ +use oneclient_common::domain::GameLoader; +use thiserror::Error; + +pub type McResult = Result; + +#[derive(Debug, Error)] +pub enum McError { + // --- content --- + #[error("no client download exists for version {0}")] + NoClientDownload(String), + + #[error("failed to resolve library artifact path for {0}")] + LibraryPath(String), + + #[error("invalid game version {0}")] + InvalidVersion(String), + + // --- manifests --- + #[error("failed to fetch metadata")] + FetchError, + + #[error("loader {0} does not use a modded manifest")] + NotModdedManifest(GameLoader), + + #[error("loader {0} does not use a vanilla manifest")] + NotVanillaManifest(GameLoader), + + #[error("no matching loader found")] + NoMatchingLoader, + + #[error("requested loader version '{requested}' was not found")] + RequestedLoaderVersionNotFound { requested: String }, + + #[error("no matching version found")] + NoMatchingVersion, + + /// A Mojang response was well-formed JSON but not what the API documents. + #[error("minecraft: {0}")] + Minecraft(String), + + // --- plumbing --- + #[error("failed to parse metadata: {0}")] + ParseError(#[from] serde_json::Error), + + #[error(transparent)] + Request(#[from] oneclient_net::RequestError), + + #[error(transparent)] + Io(#[from] polyio::IOError), + + #[error(transparent)] + StdIo(#[from] std::io::Error), + + #[error(transparent)] + Paths(#[from] oneclient_common::PathsError), + + #[error("Invalid URL: {0}")] + Url(#[from] url::ParseError), +} + +impl McError { + /// Whether this is the user's environment rather than a launcher bug. + /// The composition layer turns this into a Sentry policy. + #[must_use] + pub fn is_transient(&self) -> bool { + match self { + Self::Request(source) => source.is_transient(), + Self::Io(polyio::IOError::IOError(source)) + | Self::Io(polyio::IOError::PathIOError { source, .. }) + | Self::StdIo(source) => { + use std::io::ErrorKind; + matches!( + source.kind(), + ErrorKind::ConnectionRefused + | ErrorKind::ConnectionReset + | ErrorKind::ConnectionAborted + | ErrorKind::NotConnected + | ErrorKind::TimedOut + ) + } + _ => false, + } + } +} diff --git a/packages/oneclient_core/src/game/metadata.rs b/packages/oneclient_mc/src/install.rs similarity index 82% rename from packages/oneclient_core/src/game/metadata.rs rename to packages/oneclient_mc/src/install.rs index 3d7699d5..1afffe67 100644 --- a/packages/oneclient_core/src/game/metadata.rs +++ b/packages/oneclient_mc/src/install.rs @@ -7,17 +7,16 @@ use interfrost::api::minecraft::{ use interfrost::api::modded::LoaderVersion; use reqwest::Method; -use crate::game::GameError; -use crate::game::download::{download_to_path, fetch_bytes_verified}; -use crate::game::rules::validate_rules; -use crate::metadata::MetadataError; -use crate::metadata::MetadataStore; -use crate::os_ext::OsExt; -use crate::packages::domain::GameLoader; -use crate::notification::{GroupedProgressSession, TaskCategory}; -use crate::paths; -use crate::state::LauncherServices; -use crate::{LauncherError, LauncherResult}; +use crate::download::{download_to_path, fetch_bytes_verified}; +use crate::rules::validate_rules; + +use crate::manifest::MetadataStore; +use oneclient_common::os_ext::OsExt; +use oneclient_common::domain::GameLoader; +use oneclient_events::{GroupedProgressSession, TaskCategory}; +use oneclient_common::paths; +use crate::McCtx; +use crate::error::{McError, McResult}; /// Asset objects are tiny (median ~10 KiB) and latency-bound, so throughput /// scales with how many are in flight, not with bandwidth. @@ -28,9 +27,9 @@ const LIBRARY_DOWNLOAD_CONCURRENCY: usize = 16; /// Everything a version still needs, with what is already on disk subtracted. /// /// Computed once, up-front, so the size shown before a download starts and the -/// denominators the progress bars fill against come from the same numbers — -/// previously the estimate assumed a full download while the bars discovered -/// their own total as files were added, so neither matched reality. +/// denominators the progress bars fill against come from the same numbers. +/// Without it the estimate assumes a full download while the bars discover +/// their own total as files are added, so neither matches reality. #[derive(Debug, Default)] pub struct DownloadPlan { pub assets: Vec<(String, Asset)>, @@ -66,13 +65,13 @@ fn native_download_size(lib: &Library, java_arch: &str) -> u64 { return 0; }; - let parsed = os_key.replace("${arch}", crate::constants::ARCH_WIDTH); + let parsed = os_key.replace("${arch}", oneclient_common::constants::ARCH_WIDTH); classifiers .get(&parsed) .map_or(0, |native| u64::from(native.size)) } -/// Stats every candidate file, so it runs on the blocking pool — an asset index +/// Stats every candidate file, so it runs on the blocking pool. An asset index /// is thousands of entries. Doing it here also means the download fan-outs no /// longer stat each file from inside a concurrency slot. #[tracing::instrument(skip_all, level = "debug")] @@ -82,7 +81,7 @@ pub async fn plan_downloads( java_arch: &str, minecraft_updated: bool, force: bool, -) -> LauncherResult { +) -> McResult { let legacy = version.assets == "legacy"; let asset_dir = if legacy { paths::legacy_assets_dir()? @@ -161,15 +160,15 @@ pub async fn plan_downloads( #[tracing::instrument(skip_all)] pub async fn download_minecraft( - services: &LauncherServices, + ctx: &McCtx, progress: &GroupedProgressSession, version: &VersionInfo, java_arch: &str, minecraft_updated: bool, force: bool, -) -> LauncherResult<()> { +) -> McResult<()> { let started = std::time::Instant::now(); - let asset_index = download_assets_index(services, progress, version, force).await?; + let asset_index = download_assets_index(ctx, progress, version, force).await?; let plan = plan_downloads(version, asset_index, java_arch, minecraft_updated, force).await?; tracing::info!( @@ -200,14 +199,14 @@ pub async fn download_minecraft( tokio::try_join!( download_assets( - services, + ctx, progress, version.assets == "legacy", assets, ), - download_client(services, progress, version, force), + download_client(ctx, progress, version, force), download_libraries( - services, + ctx, progress, version.id.clone(), libraries, @@ -224,14 +223,14 @@ pub async fn download_minecraft( Ok(()) } -#[tracing::instrument(skip(services, progress), level = "debug")] +#[tracing::instrument(skip(ctx, progress), level = "debug")] pub async fn download_version_info( - services: &LauncherServices, + ctx: &McCtx, progress: Option<&GroupedProgressSession>, version: &Version, loader: Option<&LoaderVersion>, force: bool, -) -> LauncherResult { +) -> McResult { let version_id = loader .map(|it| format!("{}-{}", version.id, it.id)) .unwrap_or_else(|| version.id.clone()); @@ -249,8 +248,8 @@ pub async fn download_version_info( "downloading Minecraft version metadata" ); - let version_url = version.url.parse().map_err(LauncherError::UrlError)?; - let requester = services.requester.clone(); + let version_url = version.url.parse().map_err(McError::Url)?; + let requester = ctx.net.clone(); let mut info: VersionInfo = match progress { Some(progress) => { progress @@ -265,9 +264,9 @@ pub async fn download_version_info( let result = requester .send_json(Method::GET, version_url, None, &[]) .await - .map_err(LauncherError::from)?; + .map_err(McError::from)?; child.set_progress(1, Some(1)); - Ok::(result) + Ok::(result) } }) .await? @@ -275,12 +274,12 @@ pub async fn download_version_info( None => requester .send_json(Method::GET, version_url, None, &[]) .await - .map_err(LauncherError::from)?, + .map_err(McError::from)?, }; if let Some(loader) = loader { - let loader_url = loader.url.parse().map_err(LauncherError::UrlError)?; - let requester = services.requester.clone(); + let loader_url = loader.url.parse().map_err(McError::Url)?; + let requester = ctx.net.clone(); let partial: interfrost::api::modded::PartialVersionInfo = match progress { Some(progress) => { progress @@ -295,9 +294,9 @@ pub async fn download_version_info( let result = requester .send_json(Method::GET, loader_url, None, &[]) .await - .map_err(LauncherError::from)?; + .map_err(McError::from)?; child.set_progress(1, Some(1)); - Ok::( + Ok::( result, ) } @@ -307,7 +306,7 @@ pub async fn download_version_info( None => requester .send_json(Method::GET, loader_url, None, &[]) .await - .map_err(LauncherError::from)?, + .map_err(McError::from)?, }; info = interfrost::api::modded::merge_partial_version(partial, info); @@ -333,11 +332,11 @@ pub async fn download_version_info( #[tracing::instrument(skip_all, level = "debug")] pub async fn download_assets_index( - services: &LauncherServices, + ctx: &McCtx, progress: &GroupedProgressSession, version: &VersionInfo, force: bool, -) -> LauncherResult { +) -> McResult { let path = paths::assets_index_dir()?.join(format!("{}.json", version.asset_index.id)); if path.exists() && !force { @@ -348,8 +347,8 @@ pub async fn download_assets_index( } download_to_path( - &services.requester, - &services.notifier, + &ctx.net, + &ctx.events, progress, format!("Assets index ({})", version.asset_index.id), TaskCategory::Assets, @@ -365,11 +364,11 @@ pub async fn download_assets_index( #[tracing::instrument(skip_all, level = "debug")] pub async fn download_assets( - services: &LauncherServices, + ctx: &McCtx, progress: &GroupedProgressSession, legacy: bool, assets: Vec<(String, Asset)>, -) -> LauncherResult { +) -> McResult { if assets.is_empty() { return Ok(0); } @@ -397,13 +396,13 @@ pub async fn download_assets( let started = std::time::Instant::now(); let count = assets.len(); - let requester = services.requester.clone(); - let notifier = services.notifier.clone(); + let requester = ctx.net.clone(); + let events = ctx.events.clone(); let progress = progress.clone(); let requests = stream::iter(assets.into_iter().map(|(name, asset)| { let dir = dir.clone(); let requester = requester.clone(); - let notifier = notifier.clone(); + let events = events.clone(); let progress = progress.clone(); async move { @@ -414,7 +413,7 @@ pub async fn download_assets( let url = format!("https://resources.download.minecraft.net/{subhash}/{hash}"); download_to_path( &requester, - ¬ifier, + &events, &progress, format!("Asset {name}"), TaskCategory::Assets, @@ -447,17 +446,17 @@ pub async fn download_assets( Ok(failed) } -#[tracing::instrument(skip(services, progress, version), fields(version_id = %version.id, force), level = "debug")] +#[tracing::instrument(skip(ctx, progress, version), fields(version_id = %version.id, force), level = "debug")] pub async fn download_client( - services: &LauncherServices, + ctx: &McCtx, progress: &GroupedProgressSession, version: &VersionInfo, force: bool, -) -> LauncherResult { +) -> McResult { let client = version .downloads .get(&DownloadType::Client) - .ok_or_else(|| GameError::NoClientDownload(version.id.clone()))?; + .ok_or_else(|| McError::NoClientDownload(version.id.clone()))?; let path = paths::versions_dir()? .join(&version.id) @@ -465,17 +464,17 @@ pub async fn download_client( if path.exists() && !force - && let Ok(actual) = crate::crypto::sha1_file(&path).await + && let Ok(actual) = polyio::sha1_file(&path).await { - if crate::crypto::normalize_hash(&actual) == crate::crypto::normalize_hash(&client.sha1) { + if polyio::normalize_hash(&actual) == polyio::normalize_hash(&client.sha1) { return Ok(path); } tracing::warn!("client hash mismatch, redownloading"); } download_to_path( - &services.requester, - &services.notifier, + &ctx.net, + &ctx.events, progress, format!("Client {}", version.id), TaskCategory::Client, @@ -493,7 +492,7 @@ pub fn libraries_missing( version_info: &VersionInfo, java_arch: &str, minecraft_updated: bool, -) -> LauncherResult { +) -> McResult { let lib_dir = paths::libraries_dir()?; for lib in &version_info.libraries { if let Some(rules) = &lib.rules @@ -523,14 +522,14 @@ fn lib_short(name: &str) -> String { } } -#[tracing::instrument(skip(services, progress, libraries), level = "debug")] +#[tracing::instrument(skip(ctx, progress, libraries), level = "debug")] pub async fn download_libraries( - services: &LauncherServices, + ctx: &McCtx, progress: &GroupedProgressSession, version: String, libraries: Vec, java_arch: &str, -) -> LauncherResult { +) -> McResult { if libraries.is_empty() { return Ok(0); } @@ -548,15 +547,15 @@ pub async fn download_libraries( let lib_dir = lib_dir.clone(); let natives_dest = natives_dest.clone(); let java_arch = java_arch.clone(); - let requester = services.requester.clone(); - let notifier = services.notifier.clone(); + let requester = ctx.net.clone(); + let events = ctx.events.clone(); let progress = progress.clone(); async move { // Rule evaluation and the on-disk check already happened in // `plan_downloads`; everything here is known to need fetching. let artifact_path = interfrost::utils::get_path_from_artifact(&lib.name) - .map_err(|_| GameError::LibraryPath(lib.name.clone()))?; + .map_err(|_| McError::LibraryPath(lib.name.clone()))?; let path = lib_dir.join(&artifact_path); tokio::try_join!( @@ -569,7 +568,7 @@ pub async fn download_libraries( { download_to_path( &requester, - ¬ifier, + &events, &progress, format!("Library {}", lib_short(&lib.name)), TaskCategory::Libraries, @@ -579,7 +578,7 @@ pub async fn download_libraries( Some(&artifact.sha1), ) .await?; - return Ok::<_, LauncherError>(()); + return Ok::<_, McError>(()); } let url = [ @@ -591,7 +590,7 @@ pub async fn download_libraries( .concat(); download_to_path( &requester, - ¬ifier, + &events, &progress, format!("Library {}", lib_short(&lib.name)), TaskCategory::Libraries, @@ -610,11 +609,11 @@ pub async fn download_libraries( lib.downloads.as_ref()?.classifiers.as_ref()?, )) }) { - let parsed = os_key.replace("${arch}", crate::constants::ARCH_WIDTH); + let parsed = os_key.replace("${arch}", oneclient_common::constants::ARCH_WIDTH); if let Some(native) = classifiers.get(&parsed) { let data = fetch_bytes_verified( &requester, - ¬ifier, + &events, &progress, format!("Natives {}", lib_short(&lib.name)), TaskCategory::Natives, @@ -629,7 +628,7 @@ pub async fn download_libraries( 1, TaskCategory::Natives, ); - extract.set_phase(crate::notification::TaskPhase::Extracting); + extract.set_phase(oneclient_events::TaskPhase::Extracting); polyio::unzip_bytes_filtered( data, Some(|name: &str| !name.starts_with("META-INF")), @@ -644,7 +643,7 @@ pub async fn download_libraries( } )?; - Ok::<(), LauncherError>(()) + Ok::<(), McError>(()) } })) .buffer_unordered(LIBRARY_DOWNLOAD_CONCURRENCY) @@ -668,18 +667,18 @@ pub async fn download_libraries( Ok(failed) } -#[tracing::instrument(skip(metadata, services), level = "debug")] +#[tracing::instrument(skip(metadata, ctx), level = "debug")] pub async fn get_loader_versions( metadata: &mut MetadataStore, - services: &LauncherServices, + ctx: &McCtx, mc_version: &str, loader: GameLoader, -) -> LauncherResult> { +) -> McResult> { if loader == GameLoader::Vanilla { return Ok(Vec::new()); } - let manifest = metadata.get_modded_or_fetch(services, loader).await?; + let manifest = metadata.get_modded_or_fetch(ctx, loader).await?; for entry in &manifest.game_versions { let id = entry .id @@ -692,14 +691,14 @@ pub async fn get_loader_versions( Ok(Vec::new()) } -#[tracing::instrument(skip(metadata, services), level = "debug")] +#[tracing::instrument(skip(metadata, ctx), level = "debug")] pub async fn get_loader_version( metadata: &mut MetadataStore, - services: &LauncherServices, + ctx: &McCtx, mc_version: &str, loader: GameLoader, loader_version: Option<&str>, -) -> LauncherResult> { +) -> McResult> { if loader == GameLoader::Vanilla { return Ok(None); } @@ -743,14 +742,14 @@ pub async fn get_loader_version( (saw_matching_game_version, None) }; - let mut manifest = metadata.get_modded_or_fetch(services, loader).await?; + let mut manifest = metadata.get_modded_or_fetch(ctx, loader).await?; let (mut saw_matching, mut resolved) = resolve_from_manifest(manifest); if resolved.is_some() { return Ok(resolved); } if !saw_matching || loader_version.is_some() { - metadata.fetch_all(services).await; + metadata.fetch_all(ctx).await; manifest = metadata.get_modded(loader)?; (saw_matching, resolved) = resolve_from_manifest(manifest); if resolved.is_some() { @@ -760,37 +759,36 @@ pub async fn get_loader_version( if let Some(requested) = loader_version { if !saw_matching { - return Err(MetadataError::NoMatchingVersion.into()); + return Err(McError::NoMatchingVersion); } - return Err(MetadataError::RequestedLoaderVersionNotFound { + return Err(McError::RequestedLoaderVersionNotFound { requested: requested.to_string(), - } - .into()); + }); } if saw_matching { - Err(MetadataError::NoMatchingLoader.into()) + Err(McError::NoMatchingLoader) } else { - Err(MetadataError::NoMatchingVersion.into()) + Err(McError::NoMatchingVersion) } } -#[tracing::instrument(skip(metadata, services), level = "debug")] +#[tracing::instrument(skip(metadata, ctx), level = "debug")] pub async fn resolve_minecraft_version( metadata: &mut MetadataStore, - services: &LauncherServices, + ctx: &McCtx, mc_version: &str, -) -> LauncherResult<(Version, usize, bool)> { - let mut manifest = metadata.get_vanilla_or_fetch(services).await?; +) -> McResult<(Version, usize, bool)> { + let mut manifest = metadata.get_vanilla_or_fetch(ctx).await?; let mut version_index = manifest.versions.iter().position(|it| it.id == mc_version); if version_index.is_none() { - metadata.fetch_all(services).await; + metadata.fetch_all(ctx).await; manifest = metadata.get_vanilla()?; version_index = manifest.versions.iter().position(|it| it.id == mc_version); } - let version_index = version_index.ok_or(MetadataError::NoMatchingVersion)?; + let version_index = version_index.ok_or(McError::NoMatchingVersion)?; let versions = &manifest.versions; Ok(( @@ -800,22 +798,22 @@ pub async fn resolve_minecraft_version( )) } -#[tracing::instrument(skip(metadata, services), level = "debug")] +#[tracing::instrument(skip(metadata, ctx), level = "debug")] pub async fn get_game_versions( metadata: &mut MetadataStore, - services: &LauncherServices, -) -> LauncherResult> { - let manifest = metadata.get_vanilla_or_fetch(services).await?; + ctx: &McCtx, +) -> McResult> { + let manifest = metadata.get_vanilla_or_fetch(ctx).await?; Ok(manifest.versions.clone()) } -#[tracing::instrument(skip(metadata, services), level = "debug")] +#[tracing::instrument(skip(metadata, ctx), level = "debug")] pub async fn get_loaders_for_version( metadata: &mut MetadataStore, - services: &LauncherServices, + ctx: &McCtx, mc_version: &str, -) -> LauncherResult> { - metadata.get_loaders_for_version(services, mc_version).await +) -> McResult> { + metadata.get_loaders_for_version(ctx, mc_version).await } #[must_use] diff --git a/packages/oneclient_mc/src/lib.rs b/packages/oneclient_mc/src/lib.rs new file mode 100644 index 00000000..65c3cf4d --- /dev/null +++ b/packages/oneclient_mc/src/lib.rs @@ -0,0 +1,47 @@ +//! Everything that makes a Minecraft install: the client jar, its libraries and +//! natives, the asset index and objects, the launch arguments assembled from +//! them, and the Mojang player profile. +//! +//! This crate has no database. It takes a [`RequestClient`](oneclient_net::RequestClient) +//! and an [`EventBus`](oneclient_events::EventBus) and works against paths, so +//! the download planner is testable on its own. + +use oneclient_events::EventBus; +use oneclient_net::RequestClient; + +/// What the install engine needs: somewhere to fetch from, and somewhere to +/// report progress to. +/// +/// Narrower than the full launcher services: no database handle and no +/// package-provider registry, since none of this code touches them. +#[derive(Clone)] +pub struct McCtx { + pub net: RequestClient, + pub events: EventBus, +} + +impl McCtx { + #[must_use] + pub fn new(net: RequestClient, events: EventBus) -> Self { + Self { net, events } + } +} + +mod arguments; +mod download; +mod error; +mod install; +mod manifest; +mod profile; +mod rules; + +pub use arguments::*; +pub use download::{download_to_path, fetch_bytes_verified}; +pub use error::{McError, McResult}; +pub use install::*; +pub use manifest::MetadataStore; +pub use profile::{ + MojangCape, MojangFullPlayerProfile, MojangPlayerProfile, MojangSkin, PlayerProfileView, + SkinVariant, fetch_logged_in_profile, fetch_player_profile, fetch_player_profile_view, +}; +pub use rules::validate_rules; diff --git a/packages/oneclient_core/src/metadata/store.rs b/packages/oneclient_mc/src/manifest.rs similarity index 66% rename from packages/oneclient_core/src/metadata/store.rs rename to packages/oneclient_mc/src/manifest.rs index 5d5f8598..dd3b8f1c 100644 --- a/packages/oneclient_core/src/metadata/store.rs +++ b/packages/oneclient_mc/src/manifest.rs @@ -7,12 +7,11 @@ use reqwest::Method; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; -use crate::api_config::metadata_api_url; -use crate::metadata::MetadataError; -use crate::packages::domain::GameLoader; -use crate::paths; -use crate::state::LauncherServices; -use crate::{LauncherError, LauncherResult}; + +use oneclient_common::domain::GameLoader; +use oneclient_common::paths; +use crate::McCtx; +use crate::error::{McError, McResult}; #[derive(Debug, Default)] pub struct MetadataStore { @@ -42,45 +41,45 @@ impl MetadataStore { Self::default() } - #[tracing::instrument(level = "debug", skip(self, services))] + #[tracing::instrument(level = "debug", skip(self, ctx))] pub async fn get_vanilla_or_fetch( &mut self, - services: &LauncherServices, - ) -> LauncherResult<&VanillaManifest> { + ctx: &McCtx, + ) -> McResult<&VanillaManifest> { if !self.initialized() { - self.initialize(services).await?; + self.initialize(ctx).await?; } self.get_vanilla() } - #[tracing::instrument(level = "debug", skip(self, services))] + #[tracing::instrument(level = "debug", skip(self, ctx))] pub async fn get_modded_or_fetch( &mut self, - services: &LauncherServices, + ctx: &McCtx, loader: GameLoader, - ) -> LauncherResult<&ModdedManifest> { + ) -> McResult<&ModdedManifest> { if !loader.is_modded() { - return Err(MetadataError::NotModdedManifest(loader).into()); + return Err(McError::NotModdedManifest(loader)); } if !self.initialized() { - self.initialize(services).await?; + self.initialize(ctx).await?; } self.get_modded(loader) } - pub fn get_vanilla(&self) -> LauncherResult<&VanillaManifest> { + pub fn get_vanilla(&self) -> McResult<&VanillaManifest> { self.inner .minecraft .as_ref() - .ok_or_else(|| MetadataError::FetchError.into()) + .ok_or_else(|| McError::FetchError) } - pub fn get_modded(&self, loader: GameLoader) -> LauncherResult<&ModdedManifest> { + pub fn get_modded(&self, loader: GameLoader) -> McResult<&ModdedManifest> { if !loader.is_modded() { - return Err(MetadataError::NotModdedManifest(loader).into()); + return Err(McError::NotModdedManifest(loader)); } match loader { @@ -91,11 +90,11 @@ impl MetadataStore { GameLoader::LegacyFabric => self.inner.legacyfabric.as_ref(), GameLoader::Vanilla => None, } - .ok_or_else(|| MetadataError::FetchError.into()) + .ok_or_else(|| McError::FetchError) } #[tracing::instrument(skip_all)] - pub async fn initialize(&mut self, services: &LauncherServices) -> LauncherResult<()> { + pub async fn initialize(&mut self, ctx: &McCtx) -> McResult<()> { let path = paths::caches_dir()?.join("metadata.json"); let mut save_file = false; let mut metadata = Self::default(); @@ -104,15 +103,15 @@ impl MetadataStore { if let Ok(inner) = serde_json::from_slice::(&bytes) { metadata.inner = inner; - if metadata.refetch_errored(services).await > 0 { + if metadata.refetch_errored(ctx).await > 0 { save_file = true; } } else { - metadata.fetch_all(services).await; + metadata.fetch_all(ctx).await; save_file = true; } } else { - metadata.fetch_all(services).await; + metadata.fetch_all(ctx).await; save_file = true; } @@ -130,11 +129,11 @@ impl MetadataStore { } #[tracing::instrument(level = "debug", skip_all)] - pub async fn refetch_errored(&mut self, services: &LauncherServices) -> u8 { + pub async fn refetch_errored(&mut self, ctx: &McCtx) -> u8 { let mut changed: u8 = 0; if self.inner.minecraft.is_none() - && let Ok(data) = fetch_vanilla_manifest(services).await { + && let Ok(data) = fetch_vanilla_manifest(ctx).await { self.inner.minecraft = Some(data); changed += 1; } @@ -143,7 +142,7 @@ impl MetadataStore { ($var:ident) => { if self.inner.$var.is_none() { if let Ok(loader) = GameLoader::from_str(stringify!($var)) { - match fetch_modded_manifest(services, loader).await { + match fetch_modded_manifest(ctx, loader).await { Ok(data) => { self.inner.$var = Some(data); changed += 1; @@ -170,13 +169,13 @@ impl MetadataStore { } #[tracing::instrument(level = "debug", skip_all)] - pub async fn fetch_all(&mut self, services: &LauncherServices) { + pub async fn fetch_all(&mut self, ctx: &McCtx) { let (minecraft, forge, neo, fabric, quilt) = tokio::join!( - fetch_vanilla_manifest(services), - fetch_modded_manifest(services, GameLoader::Forge), - fetch_modded_manifest(services, GameLoader::NeoForge), - fetch_modded_manifest(services, GameLoader::Fabric), - fetch_modded_manifest(services, GameLoader::Quilt), + fetch_vanilla_manifest(ctx), + fetch_modded_manifest(ctx, GameLoader::Forge), + fetch_modded_manifest(ctx, GameLoader::NeoForge), + fetch_modded_manifest(ctx, GameLoader::Fabric), + fetch_modded_manifest(ctx, GameLoader::Quilt), ); self.inner.minecraft = minecraft.ok(); @@ -186,14 +185,14 @@ impl MetadataStore { self.inner.quilt = quilt.ok(); } - #[tracing::instrument(level = "debug", skip(self, services))] + #[tracing::instrument(level = "debug", skip(self, ctx))] pub async fn get_loaders_for_version( &mut self, - services: &LauncherServices, + ctx: &McCtx, mc_version: &str, - ) -> LauncherResult> { + ) -> McResult> { if !self.initialized() { - self.initialize(services).await?; + self.initialize(ctx).await?; } if let Some(hit) = self.version_loader_cache.get(mc_version) { @@ -204,7 +203,7 @@ impl MetadataStore { for loader in GameLoader::modded_loaders() { let manifest = match self.get_modded(*loader) { Ok(manifest) => manifest, - Err(LauncherError::MetadataError(MetadataError::NotModdedManifest(_))) => { + Err(McError::NotModdedManifest(_)) => { continue } Err(e) => return Err(e), @@ -230,9 +229,9 @@ impl MetadataStore { } } -#[tracing::instrument(level = "debug", skip(services))] -async fn fetch_vanilla_manifest(services: &LauncherServices) -> LauncherResult { - match fetch_manifest::(services, GameLoader::Vanilla).await { +#[tracing::instrument(level = "debug", skip_all)] +async fn fetch_vanilla_manifest(ctx: &McCtx) -> McResult { + match fetch_manifest::(ctx, GameLoader::Vanilla).await { Ok(manifest) => Ok(manifest), Err(err) => { tracing::warn!( @@ -242,41 +241,41 @@ async fn fetch_vanilla_manifest(services: &LauncherServices) -> LauncherResult LauncherResult { - fetch_manifest(services, loader).await +) -> McResult { + fetch_manifest(ctx, loader).await } -#[tracing::instrument(level = "debug", skip(services))] +#[tracing::instrument(level = "debug", skip_all)] async fn fetch_manifest( - services: &LauncherServices, + ctx: &McCtx, loader: GameLoader, -) -> LauncherResult { +) -> McResult { let url = format!( "{}/{}/v{}/manifest.json", - metadata_api_url(), + ctx.net.config().metadata_api_url, loader.get_format_name(), loader.get_format_version() ); - let parsed = url.parse().map_err(LauncherError::UrlError)?; - services - .requester + let parsed = url.parse().map_err(McError::Url)?; + ctx + .net .send_json(Method::GET, parsed, None, &[]) .await - .map_err(LauncherError::from) + .map_err(McError::from) } diff --git a/packages/oneclient_core/src/minecraft/profile.rs b/packages/oneclient_mc/src/profile.rs similarity index 93% rename from packages/oneclient_core/src/minecraft/profile.rs rename to packages/oneclient_mc/src/profile.rs index 777567bf..c1e0f9c5 100644 --- a/packages/oneclient_core/src/minecraft/profile.rs +++ b/packages/oneclient_mc/src/profile.rs @@ -3,8 +3,8 @@ use chrono::{DateTime, TimeDelta, Utc}; use reqwest::Method; use serde::{Deserialize, Serialize}; -use crate::http::RequestClient; -use crate::{LauncherError, LauncherResult}; +use oneclient_net::RequestClient; +use crate::error::{McError, McResult}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct MojangPlayerProfile { @@ -131,7 +131,7 @@ fn auth_headers(access_token: &str) -> [(&'static str, String); 1] { pub async fn fetch_player_profile( client: &RequestClient, uuid: &str, -) -> LauncherResult { +) -> McResult { #[derive(Deserialize)] struct Properties { name: String, @@ -173,7 +173,7 @@ pub async fn fetch_player_profile( let uuid = normalize_uuid(uuid); let url = format!("https://sessionserver.mojang.com/session/minecraft/profile/{uuid}") .parse() - .map_err(LauncherError::UrlError)?; + .map_err(McError::Url)?; let response = client .send_as::(reqwest::Request::new(Method::GET, url)) @@ -183,16 +183,16 @@ pub async fn fetch_player_profile( .properties .iter() .find(|p| p.name == "textures") - .ok_or_else(|| LauncherError::Minecraft("no textures in player profile".into()))?; + .ok_or_else(|| McError::Minecraft("no textures in player profile".into()))?; let decoded = base64::prelude::BASE64_STANDARD .decode(texture_property.value.as_bytes()) .map_err(|_| { - LauncherError::Minecraft("failed to decode profile texture property".into()) + McError::Minecraft("failed to decode profile texture property".into()) })?; let decoded = serde_json::from_slice::(&decoded) - .map_err(LauncherError::JsonError)? + .map_err(McError::ParseError)? .textures; let is_slim = decoded @@ -219,10 +219,10 @@ pub async fn fetch_player_profile( pub async fn fetch_logged_in_profile( client: &RequestClient, access_token: &str, -) -> LauncherResult { +) -> McResult { let url = "https://api.minecraftservices.com/minecraft/profile" .parse() - .map_err(LauncherError::UrlError)?; + .map_err(McError::Url)?; let headers = auth_headers(access_token); let header_refs: Vec<(&str, &str)> = headers @@ -267,7 +267,7 @@ async fn fetch_profile_view( client: &RequestClient, uuid: &str, access_token: Option<&str>, -) -> LauncherResult { +) -> McResult { if let Some(token) = access_token.filter(|t| !t.is_empty()) { let full = fetch_logged_in_profile(client, token).await?; return Ok(PlayerProfileView::from(full)); @@ -282,7 +282,7 @@ pub async fn fetch_player_profile_view( client: &RequestClient, uuid: &str, access_token: Option<&str>, -) -> LauncherResult { +) -> McResult { let authenticated = access_token.is_some_and(|t| !t.is_empty()); if !authenticated && let Some(cached) = read_cached_profile(uuid).await { @@ -320,8 +320,8 @@ pub async fn fetch_player_profile_view( } fn profile_cache_path(uuid: &str) -> Option { - let key = crate::crypto::sha1_bytes(normalize_uuid(uuid).as_bytes()); - Some(crate::paths::profiles_cache_dir().ok()?.join(format!("{key}.json"))) + let key = polyio::sha1_bytes(normalize_uuid(uuid).as_bytes()); + Some(oneclient_common::paths::profiles_cache_dir().ok()?.join(format!("{key}.json"))) } async fn write_cached_profile(uuid: &str, view: &PlayerProfileView) { @@ -364,7 +364,7 @@ mod tests { fn temp_launcher_dir() { let dir = std::env::temp_dir().join(format!("oneclient-profile-test-{}", uuid::Uuid::new_v4())); - crate::paths::set_launcher_dir(dir); + oneclient_common::paths::set_launcher_dir(dir); } fn sample_view(username: &str) -> PlayerProfileView { @@ -386,7 +386,7 @@ mod tests { let view = sample_view("Cached"); write_cached_profile(&uuid, &view).await; - let client = RequestClient::new().unwrap(); + let client = RequestClient::new(oneclient_net::NetConfig::default()).unwrap(); let resolved = fetch_player_profile_view(&client, &uuid, None) .await .expect("fresh cache entry should resolve offline"); diff --git a/packages/oneclient_core/src/game/rules.rs b/packages/oneclient_mc/src/rules.rs similarity index 98% rename from packages/oneclient_core/src/game/rules.rs rename to packages/oneclient_mc/src/rules.rs index c969f9d9..323200c5 100644 --- a/packages/oneclient_core/src/game/rules.rs +++ b/packages/oneclient_mc/src/rules.rs @@ -1,6 +1,6 @@ use interfrost::api::minecraft::{Os, OsRule, Rule, RuleAction}; -use crate::os_ext::OsExt; +use oneclient_common::os_ext::OsExt; #[must_use] pub fn validate_rules(rules: &[Rule], java_arch: &str, updated: bool) -> bool { diff --git a/packages/oneclient_net/Cargo.toml b/packages/oneclient_net/Cargo.toml new file mode 100644 index 00000000..50dd5502 --- /dev/null +++ b/packages/oneclient_net/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "oneclient_net" +description = "OneClient HTTP transport: retries, throttling, ETag caching and verified downloads" +version.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true +documentation.workspace = true +readme.workspace = true +homepage.workspace = true +authors.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +oneclient_common.workspace = true +oneclient_events.workspace = true +polyio.workspace = true + +reqwest.workspace = true +tokio.workspace = true +futures-lite.workspace = true +futures-util.workspace = true +async-trait.workspace = true +bytes.workspace = true + +# Config read on every outbound request, written only on a settings save. +arc-swap.workspace = true + +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tracing.workspace = true +url.workspace = true +uuid.workspace = true diff --git a/packages/oneclient_net/src/cache.rs b/packages/oneclient_net/src/cache.rs new file mode 100644 index 00000000..308a639a --- /dev/null +++ b/packages/oneclient_net/src/cache.rs @@ -0,0 +1,158 @@ +//! ETag-conditional GET backed by an on-disk cache. +//! +//! Four call sites had a byte-identical copy of the same three helpers +//! (`insert_if_none_match`, `etag_from_response`, `read_sidecar_etag`) plus the +//! same arm-for-arm 304 / 2xx / error / other match around them. They differed +//! only in the cache filename, the log noun, and whether a total failure was an +//! error or an empty result. + +use std::path::{Path, PathBuf}; + +use reqwest::{Method, StatusCode, header}; + +use crate::error::RequestError; +use crate::service::RequestClient; + +/// Whether the ETag sidecar is written as soon as a fresh body arrives. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EtagPolicy { + /// Write it immediately. Right when the body alone is the whole payload. + CommitNow, + /// Leave it alone and hand it back on [`Fetched::etag`]. Use when work + /// downstream of the body can still fail: the bundle catalog only commits + /// its ETag once every bundle in it has actually downloaded, so an + /// interrupted sync retries instead of reporting 304 and skipping them. + Defer, +} + +/// The outcome of a conditional fetch. +#[derive(Debug, Clone)] +pub struct Fetched { + pub bytes: Vec, + /// `false` when the server answered 304, or the request failed and the + /// cached copy was served instead. Callers use this to skip work that only + /// matters when the document actually changed. + pub changed: bool, + /// The server's ETag, present only for a fresh body under + /// [`EtagPolicy::Defer`]. + pub etag: Option, +} + +impl Fetched { + /// Deserialize the body as JSON. + pub fn json(&self) -> Result { + serde_json::from_slice(&self.bytes).map_err(|source| RequestError::DeserializeError { + source, + type_name: std::any::type_name::().to_string(), + url: String::new(), + status: 200, + snippet: crate::error::body_snippet(&self.bytes), + }) + } + + /// Body as UTF-8, lossily. Used for documents that are text by contract + /// (changelogs), where refusing to render because of one bad byte is worse + /// than showing a replacement character. + #[must_use] + pub fn text(&self) -> String { + String::from_utf8_lossy(&self.bytes).into_owned() + } +} + +fn etag_path(cache_path: &Path) -> PathBuf { + let mut path = cache_path.as_os_str().to_os_string(); + path.push(".etag"); + PathBuf::from(path) +} + +async fn read_sidecar_etag(cache_path: &Path) -> Option { + polyio::read(etag_path(cache_path)) + .await + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()) + .filter(|etag| !etag.is_empty()) +} + +/// Writes the ETag sidecar for `cache_path`. Callers using [`EtagPolicy::Defer`] +/// call this once the work depending on the body has succeeded. +pub async fn commit_etag(cache_path: &Path, etag: &str) { + if let Err(err) = polyio::write_atomic(etag_path(cache_path), etag.as_bytes()).await { + tracing::warn!("failed to write etag sidecar: {err}"); + } +} + +/// `GET url`, using `cache_path` as both the conditional-request cache and the +/// offline fallback. +/// +/// Returns `Ok(None)` only when the request failed *and* there is no cached +/// copy. Callers decide whether that is fatal. +#[tracing::instrument(level = "debug", skip(client), fields(%url))] +pub async fn fetch_cached( + client: &RequestClient, + url: &str, + cache_path: &Path, + policy: EtagPolicy, +) -> Result, RequestError> { + let stored_etag = read_sidecar_etag(cache_path).await; + let mut request = reqwest::Request::new(Method::GET, url.parse()?); + + if let Some(etag) = &stored_etag + && let Ok(value) = header::HeaderValue::from_str(etag) + { + request.headers_mut().insert(header::IF_NONE_MATCH, value); + } + + let cached = || async { + match polyio::read(cache_path).await { + Ok(bytes) => Ok(Some(Fetched { + bytes, + changed: false, + etag: None, + })), + Err(err) => { + tracing::debug!("no usable cache at {}: {err}", cache_path.display()); + Ok(None) + } + } + }; + + match client.send(request).await { + Ok(res) if res.status() == StatusCode::NOT_MODIFIED => { + tracing::debug!("cache hit (304)"); + cached().await + } + Ok(res) if res.status().is_success() => { + let server_etag = res + .headers() + .get(header::ETAG) + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + + let bytes = res.bytes().await?.to_vec(); + polyio::write_atomic(cache_path, &bytes).await?; + + let etag = match (&server_etag, policy) { + (Some(etag), EtagPolicy::CommitNow) => { + commit_etag(cache_path, etag).await; + None + } + (_, EtagPolicy::Defer) => server_etag, + (None, EtagPolicy::CommitNow) => None, + }; + + Ok(Some(Fetched { + bytes, + changed: true, + etag, + })) + } + Ok(res) => { + tracing::warn!(status = %res.status(), "unexpected response; using cache if present"); + cached().await + } + Err(err) => { + tracing::debug!("fetch failed, using cache if present: {err}"); + cached().await + } + } +} diff --git a/packages/oneclient_net/src/config.rs b/packages/oneclient_net/src/config.rs new file mode 100644 index 00000000..efe3e757 --- /dev/null +++ b/packages/oneclient_net/src/config.rs @@ -0,0 +1,105 @@ +use oneclient_common::constants; + +/// Endpoint and credential settings the transport needs. +/// +/// The composition layer pushes these down; the transport never looks anything +/// up. Reaching for the CurseForge key through global state would make the HTTP +/// client depend on the object that owns it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NetConfig { + pub curseforge_api_key: String, + pub modrinth_api_key: Option, + /// Base for the metadata API (`meta.polyfrost.org`). + pub metadata_api_url: String, + /// Base for static assets; never has a trailing slash. + pub meta_url_base: String, +} + +impl Default for NetConfig { + fn default() -> Self { + Self { + curseforge_api_key: constants::CURSEFORGE_API_KEY.to_string(), + modrinth_api_key: None, + metadata_api_url: constants::METADATA_API_URL.to_string(), + meta_url_base: constants::META_URL_BASE.to_string(), + } + } +} + +impl NetConfig { + /// Applies user overrides, falling back to the built-in defaults for any + /// that are absent or blank. Blank is treated as absent so clearing a field + /// in the settings UI restores the default rather than sending an empty key. + #[must_use] + pub fn with_overrides( + mut self, + curseforge_api_key: Option<&str>, + modrinth_api_key: Option<&str>, + metadata_api_url: Option<&str>, + meta_url_base: Option<&str>, + ) -> Self { + if let Some(value) = non_empty(curseforge_api_key) { + self.curseforge_api_key = value; + } + self.modrinth_api_key = non_empty(modrinth_api_key); + if let Some(value) = non_empty(metadata_api_url) { + self.metadata_api_url = value; + } + if let Some(value) = non_empty(meta_url_base) { + self.meta_url_base = value; + } + self.meta_url_base = self.meta_url_base.trim_end_matches('/').to_string(); + self + } + + /// Headers Modrinth needs, if a token is configured. + #[must_use] + pub fn modrinth_headers(&self) -> Vec<(String, String)> { + match &self.modrinth_api_key { + Some(token) => vec![("Authorization".to_string(), token.clone())], + None => Vec::new(), + } + } +} + +fn non_empty(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(str::to_string) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn blank_overrides_fall_back_to_defaults() { + let config = NetConfig::default().with_overrides(Some(" "), Some(""), None, None); + + assert_eq!(config.curseforge_api_key, constants::CURSEFORGE_API_KEY); + assert_eq!(config.modrinth_api_key, None); + assert_eq!(config.metadata_api_url, constants::METADATA_API_URL); + } + + #[test] + fn meta_url_base_never_keeps_a_trailing_slash() { + let config = NetConfig::default() + .with_overrides(None, None, None, Some("https://example.test/base/")); + + assert_eq!(config.meta_url_base, "https://example.test/base"); + } + + #[test] + fn overrides_replace_defaults() { + let config = + NetConfig::default().with_overrides(Some("key"), Some("token"), Some("https://a"), None); + + assert_eq!(config.curseforge_api_key, "key"); + assert_eq!( + config.modrinth_headers(), + vec![("Authorization".to_string(), "token".to_string())] + ); + assert_eq!(config.metadata_api_url, "https://a"); + } +} diff --git a/packages/oneclient_net/src/download.rs b/packages/oneclient_net/src/download.rs new file mode 100644 index 00000000..f164fd7c --- /dev/null +++ b/packages/oneclient_net/src/download.rs @@ -0,0 +1,153 @@ +//! Downloading with content verification. +//! +//! Streams and hashes in one pass, so nothing has to re-read a finished file +//! just to verify it. + +use std::path::Path; + +use oneclient_events::{EventBus, GroupedProgressChild, TaskPhase}; +use polyio::{Sha1Stream, normalize_hash}; +use reqwest::Method; + +use crate::error::RequestError; +use crate::response::{ResponseExt, ResponseNotifyOptions, ResponseOptions}; +use crate::service::RequestClient; + +/// Whether a file already on disk matches `expected_sha1`. +/// +/// Hashing an existing file is cheaper than re-downloading it, so every caller +/// wants this check before fetching. Returns `false` when the file is missing or +/// unreadable, in which case the caller should just download it. +pub async fn matches_on_disk(path: &Path, expected_sha1: &str) -> bool { + if !path.is_file() { + return false; + } + + match polyio::sha1_file(path).await { + Ok(actual) => normalize_hash(&actual) == normalize_hash(expected_sha1), + Err(err) => { + tracing::debug!("could not hash {}: {err}", path.display()); + false + } + } +} + +/// Downloads `url` to `dest`, verifying SHA-1 from the bytes as they stream past. +/// +/// On a mismatch the partial file is removed, so a retry cannot mistake it for a +/// complete download. `progress` is optional: pass a child when the caller is +/// running a grouped session, `None` for a one-off. +#[tracing::instrument(level = "debug", skip(client, events, progress), fields(%url))] +pub async fn download_verified( + client: &RequestClient, + events: &EventBus, + url: &str, + dest: &Path, + expected_sha1: Option<&str>, + expected_size: u64, + progress: Option, +) -> Result<(), RequestError> { + // Callers that fan out over thousands of files (assets) pre-create the tree, + // so this is usually a stat that avoids a blocking-pool round trip per file. + if let Some(parent) = dest.parent() + && !parent.is_dir() + { + polyio::create_dir_all(parent).await?; + } + + let request = reqwest::Request::new(Method::GET, url.parse()?); + let response = client.send(request).await?; + + // Prefer the manifest size; fall back to Content-Length only when unknown. + let total = if expected_size > 0 { + expected_size + } else { + response.content_length().unwrap_or(0).max(1) + }; + + let options = ResponseOptions { + notify: progress.clone().map(ResponseNotifyOptions::grouped), + }; + let stream = response.stream(options, events).await?; + + let mut hasher = expected_sha1.map(|_| Sha1Stream::new()); + { + let stream = futures_lite::StreamExt::map(stream, |item| { + if let (Ok(chunk), Some(hasher)) = (&item, hasher.as_mut()) { + hasher.update(chunk); + } + item + }); + let stream = std::pin::pin!(stream); + polyio::write_stream(dest, stream, Some(total)).await?; + } + + if let (Some(expected), Some(hasher)) = (expected_sha1, hasher) { + if let Some(child) = &progress { + child.set_phase(TaskPhase::Verifying); + } + + let actual = hasher.finish(); + if actual != normalize_hash(expected) { + let _ = polyio::remove_file(dest).await; + return Err(RequestError::HashMismatch { + source_desc: dest.display().to_string(), + expected: expected.to_string(), + actual, + }); + } + } + + if let Some(child) = progress { + child.finish(); + } + + Ok(()) +} + +/// Downloads `url` into memory, verifying SHA-1 over the collected bytes. +/// +/// For payloads that are parsed rather than stored (version manifests), where +/// writing to disk only to read it straight back would be wasted IO. +#[tracing::instrument(level = "debug", skip(client, events, progress), fields(%url))] +pub async fn fetch_verified( + client: &RequestClient, + events: &EventBus, + url: &str, + expected_sha1: &str, + progress: Option, +) -> Result, RequestError> { + let request = reqwest::Request::new(Method::GET, url.parse()?); + let response = client.send(request).await?; + + let options = ResponseOptions { + notify: progress.clone().map(ResponseNotifyOptions::grouped), + }; + let stream = response.stream(options, events).await?; + + use futures_util::StreamExt; + let mut stream = std::pin::pin!(stream); + let mut bytes = Vec::new(); + while let Some(chunk) = stream.next().await { + bytes.extend_from_slice(&chunk?); + } + + if let Some(child) = &progress { + child.set_phase(TaskPhase::Verifying); + } + + let actual = polyio::sha1_bytes(&bytes); + if normalize_hash(&actual) != normalize_hash(expected_sha1) { + return Err(RequestError::HashMismatch { + source_desc: url.to_string(), + expected: expected_sha1.to_string(), + actual, + }); + } + + if let Some(child) = progress { + child.finish(); + } + + Ok(bytes) +} diff --git a/packages/oneclient_net/src/error.rs b/packages/oneclient_net/src/error.rs new file mode 100644 index 00000000..b0886c32 --- /dev/null +++ b/packages/oneclient_net/src/error.rs @@ -0,0 +1,121 @@ +#[derive(Debug, thiserror::Error)] +pub enum RequestError { + #[error(transparent)] + ReqwestError(#[from] reqwest::Error), + + #[error("IO Error: {0}")] + IOError(#[from] polyio::IOError), + + #[error( + "Failed to parse {type_name} from {url} (HTTP {status}): {source}; body starts: {snippet}" + )] + DeserializeError { + #[source] + source: serde_json::Error, + type_name: String, + url: String, + status: u16, + snippet: String, + }, + + #[error("HTTP {status} from {url}: {snippet}")] + HttpStatus { + status: u16, + url: String, + snippet: String, + }, + + #[error("Failed to serialize request body: {0}")] + SerializeError(#[source] serde_json::Error), + + /// A download's contents did not match the hash the manifest promised. + #[error("{source_desc} has SHA-1 {actual}, expected {expected}")] + HashMismatch { + source_desc: String, + expected: String, + actual: String, + }, + + #[error("Invalid URL: {0}")] + UrlParseError(#[from] url::ParseError), + + #[error("Invalid HTTP header name: {0}")] + InvalidHeaderName(#[from] reqwest::header::InvalidHeaderName), + + #[error("Invalid HTTP header value: {0}")] + InvalidHeaderValue(#[from] reqwest::header::InvalidHeaderValue), +} + +impl RequestError { + /// Whether this is the user's environment rather than a launcher bug: offline, + /// connection refused, timed out, disk full. + /// + /// The transport reports the fact; deciding that such errors stay out of + /// Sentry is a policy the composition layer applies on top. + #[must_use] + pub fn is_transient(&self) -> bool { + match self { + Self::ReqwestError(source) => source.is_timeout() || source.is_connect(), + Self::IOError(source) => is_transient_io(source), + _ => false, + } + } +} + +fn is_transient_io(error: &polyio::IOError) -> bool { + let source = match error { + polyio::IOError::IOError(source) | polyio::IOError::PathIOError { source, .. } => source, + _ => return false, + }; + + use std::io::ErrorKind; + + // Out of disk space. `ErrorKind::StorageFull` is still unstable, so match the + // raw OS codes. They are platform-gated so a Unix errno cannot collide with a + // Windows code (112 is the unrelated EHOSTDOWN on Linux). + if let Some(code) = source.raw_os_error() { + #[cfg(unix)] + if code == 28 { + // ENOSPC + return true; + } + #[cfg(windows)] + if code == 112 || code == 39 { + // ERROR_DISK_FULL / ERROR_HANDLE_DISK_FULL + return true; + } + let _ = code; + } + + matches!( + source.kind(), + ErrorKind::ConnectionRefused + | ErrorKind::ConnectionReset + | ErrorKind::ConnectionAborted + | ErrorKind::NotConnected + | ErrorKind::TimedOut + ) +} + +pub(crate) fn body_snippet(bytes: &[u8]) -> String { + const MAX: usize = 240; + if bytes.is_empty() { + return "".to_string(); + } + let text = String::from_utf8_lossy(bytes); + let collapsed = text.split_whitespace().collect::>().join(" "); + if collapsed.is_empty() { + return "".to_string(); + } + if collapsed.len() > MAX { + let end = collapsed + .char_indices() + .map(|(i, _)| i) + .take_while(|i| *i <= MAX) + .last() + .unwrap_or(0); + format!("{}...", &collapsed[..end]) + } else { + collapsed + } +} diff --git a/packages/oneclient_net/src/lib.rs b/packages/oneclient_net/src/lib.rs new file mode 100644 index 00000000..60c87b52 --- /dev/null +++ b/packages/oneclient_net/src/lib.rs @@ -0,0 +1,25 @@ +//! HTTP transport for OneClient: one client with retries, throttling and a +//! global in-flight cap, plus the two things every caller was reimplementing: +//! ETag-cached fetches and hash-verified downloads. +//! +//! Endpoints and credentials arrive as data ([`NetConfig`]) rather than being +//! looked up, so the transport does not depend on the launcher state that owns +//! the client. + +mod cache; +mod config; +mod download; +mod error; +mod request; +mod response; +mod service; + +pub mod status; + +pub use cache::{EtagPolicy, Fetched, commit_etag, fetch_cached}; +pub use config::NetConfig; +pub use download::{download_verified, fetch_verified, matches_on_disk}; +pub use error::RequestError; +pub use request::*; +pub use response::*; +pub use service::RequestClient; diff --git a/packages/oneclient_core/src/http/request.rs b/packages/oneclient_net/src/request.rs similarity index 100% rename from packages/oneclient_core/src/http/request.rs rename to packages/oneclient_net/src/request.rs diff --git a/packages/oneclient_core/src/http/response.rs b/packages/oneclient_net/src/response.rs similarity index 92% rename from packages/oneclient_core/src/http/response.rs rename to packages/oneclient_net/src/response.rs index a6508c2c..c45cdc39 100644 --- a/packages/oneclient_core/src/http/response.rs +++ b/packages/oneclient_net/src/response.rs @@ -1,8 +1,8 @@ use reqwest::Response; use uuid::Uuid; -use crate::http::RequestError; -use crate::notification::{GroupedProgressChild, NotificationService}; +use crate::error::RequestError; +use oneclient_events::{GroupedProgressChild, EventBus}; /// Minimum gap between progress events emitted for a single response body. const PROGRESS_INTERVAL: std::time::Duration = std::time::Duration::from_millis(50); @@ -55,7 +55,7 @@ pub trait ResponseExt { async fn stream( self, options: ResponseOptions, - notifier: &NotificationService, + events: &EventBus, ) -> Result>, RequestError>; } @@ -65,7 +65,7 @@ impl ResponseExt for Response { async fn stream( self, options: ResponseOptions, - notifier: &NotificationService, + events: &EventBus, ) -> Result>, RequestError> { let total = self.content_length().unwrap_or(0).max(1); @@ -91,10 +91,10 @@ impl ResponseExt for Response { if let Some(ref child) = grouped_child { child.set_progress(0, Some(total)); } else if let (Some(id), Some(label)) = (&standalone_id, &standalone_label) { - notifier.send_progress(id, label, 0, total); + events.progress(*id, label, 0, total); } - let notifier = notifier.clone(); + let events = events.clone(); // A chunk arrives every few KiB; forwarding one event per chunk buries the // UI in tens of thousands of updates per download session, all of which // collapse into the same repainted progress bar. Sample instead, and @@ -120,7 +120,7 @@ impl ResponseExt for Response { } else { label }; - notifier.send_progress(id, label, current, total); + events.progress(*id, label, current, total); } } diff --git a/packages/oneclient_core/src/http/service.rs b/packages/oneclient_net/src/service.rs similarity index 84% rename from packages/oneclient_core/src/http/service.rs rename to packages/oneclient_net/src/service.rs index 06661b35..4950fd7f 100644 --- a/packages/oneclient_core/src/http/service.rs +++ b/packages/oneclient_net/src/service.rs @@ -1,15 +1,20 @@ use std::sync::Arc; +use arc_swap::ArcSwap; +use oneclient_events::EventBus; use reqwest::{ClientBuilder, Response}; use serde::de::DeserializeOwned; use tokio::sync::Semaphore; -use crate::{LauncherServices, http::{HttpRequest, RequestError, ResponseExt, ResponseOptions}}; +use crate::config::NetConfig; +use crate::error::{RequestError, body_snippet}; +use crate::request::HttpRequest; +use crate::response::{ResponseExt, ResponseOptions}; const MAX_THROTTLE_RETRIES: u32 = 6; /// Ceiling on requests waiting for response headers. This is a backstop against -/// a runaway fan-out, not the download throttle — per-phase concurrency is set +/// a runaway fan-out, not the download throttle. Per-phase concurrency is set /// by the callers, and this has to stay above their sum or it becomes the /// bottleneck instead. Permits are released once headers arrive, so streaming /// bodies don't hold a slot. @@ -53,7 +58,10 @@ fn is_curseforge_host(url: &reqwest::Url) -> bool { || host.ends_with(".forgecdn.net") } -fn apply_curseforge_auth(request: &mut reqwest::Request) -> Result<(), RequestError> { +fn apply_curseforge_auth( + request: &mut reqwest::Request, + api_key: &str, +) -> Result<(), RequestError> { const API_KEY_HEADER: reqwest::header::HeaderName = reqwest::header::HeaderName::from_static("x-api-key"); @@ -66,41 +74,22 @@ fn apply_curseforge_auth(request: &mut reqwest::Request) -> Result<(), RequestEr return Ok(()); } - let mut value = - reqwest::header::HeaderValue::try_from(crate::api_config::curseforge_api_key())?; + let mut value = reqwest::header::HeaderValue::try_from(api_key)?; value.set_sensitive(true); headers.insert(API_KEY_HEADER, value); Ok(()) } -fn body_snippet(bytes: &[u8]) -> String { - const MAX: usize = 240; - if bytes.is_empty() { - return "".to_string(); - } - let text = String::from_utf8_lossy(bytes); - let collapsed = text.split_whitespace().collect::>().join(" "); - if collapsed.is_empty() { - return "".to_string(); - } - if collapsed.len() > MAX { - let end = collapsed - .char_indices() - .map(|(i, _)| i) - .take_while(|i| *i <= MAX) - .last() - .unwrap_or(0); - format!("{}...", &collapsed[..end]) - } else { - collapsed - } -} - #[derive(Clone)] pub struct RequestClient { client: reqwest::Client, semaphore: Arc, + /// Shared across every clone, so a settings save is visible to in-flight + /// handles. `ArcSwap` rather than a lock because this is read once per + /// outbound request (with 64 in flight a lock would serialise them) and + /// written only when the user saves settings. + config: Arc>, } impl RequestClient { @@ -108,7 +97,18 @@ impl RequestClient { &self.client } - pub fn new() -> Result { + /// The endpoint/credential config every request is sent with. + #[must_use] + pub fn config(&self) -> arc_swap::Guard> { + self.config.load() + } + + /// Replaces the config for this client and every clone of it. + pub fn set_config(&self, config: NetConfig) { + self.config.store(Arc::new(config)); + } + + pub fn new(config: NetConfig) -> Result { #[cfg_attr(target_os = "windows", allow(unused_mut))] let mut builder = ClientBuilder::new() .connect_timeout(std::time::Duration::from_secs(10)) @@ -131,6 +131,7 @@ impl RequestClient { Ok(Self { client, semaphore: Arc::new(Semaphore::new(MAX_INFLIGHT_REQUESTS)), + config: Arc::new(ArcSwap::from_pointee(config)), }) } } @@ -142,7 +143,7 @@ impl RequestClient { let mut retries = 0; let mut throttle_retries = 0u32; - apply_curseforge_auth(&mut request.request)?; + apply_curseforge_auth(&mut request.request, &self.config.load().curseforge_api_key)?; let cloned_backup = request.request.try_clone(); let cloneable = cloned_backup.is_some(); @@ -217,17 +218,17 @@ impl RequestClient { Ok(res) } - #[tracing::instrument(level = "debug", skip(self, request, options, services), fields(dest = %dest.as_ref().display()))] + #[tracing::instrument(level = "debug", skip(self, request, options, events), fields(dest = %dest.as_ref().display()))] pub async fn download_file( &self, request: impl Into, dest: impl AsRef + Send, options: ResponseOptions, - services: &LauncherServices, + events: &EventBus, ) -> Result<(), RequestError> { let res = self.send(request).await?; let size_hint = res.content_length(); - let http_stream = res.stream(options, &services.notifier).await?; + let http_stream = res.stream(options, events).await?; let http_stream = std::pin::pin!(http_stream); polyio::write_stream(dest, http_stream, size_hint).await?; @@ -298,7 +299,7 @@ mod tests { fn api_key_of(url: &str) -> Option { let mut req = request(url); - apply_curseforge_auth(&mut req).unwrap(); + apply_curseforge_auth(&mut req, oneclient_common::constants::CURSEFORGE_API_KEY).unwrap(); req.headers() .get("x-api-key") .map(|v| v.to_str().unwrap().to_string()) @@ -315,7 +316,7 @@ mod tests { ] { assert_eq!( api_key_of(url).as_deref(), - Some(crate::constants::CURSEFORGE_API_KEY), + Some(oneclient_common::constants::CURSEFORGE_API_KEY), "expected key on {url}" ); } @@ -339,7 +340,7 @@ mod tests { let mut req = request("https://api.curseforge.com/v1/mods"); req.headers_mut() .insert("x-api-key", reqwest::header::HeaderValue::from_static("mine")); - apply_curseforge_auth(&mut req).unwrap(); + apply_curseforge_auth(&mut req, oneclient_common::constants::CURSEFORGE_API_KEY).unwrap(); assert_eq!(req.headers()["x-api-key"], "mine"); } } diff --git a/packages/oneclient_core/src/status.rs b/packages/oneclient_net/src/status.rs similarity index 87% rename from packages/oneclient_core/src/status.rs rename to packages/oneclient_net/src/status.rs index df39817e..15b2399d 100644 --- a/packages/oneclient_core/src/status.rs +++ b/packages/oneclient_net/src/status.rs @@ -4,7 +4,7 @@ use std::time::Duration; use tokio::sync::{Notify, watch}; -use crate::LauncherState; +use crate::service::RequestClient; const CONNECTIVITY_URL: &str = "https://www.gstatic.com/generate_204"; const MC_AUTH_URL: &str = "https://api.minecraftservices.com/"; @@ -68,7 +68,9 @@ pub fn note_request_result(success: bool) { } } -pub fn start() { +/// Starts the connectivity prober. Idempotent; later calls are ignored, so the +/// client handed in on the first call is used for the process lifetime. +pub fn start(client: RequestClient) { let conn = connectivity(); if conn.started.swap(true, Ordering::SeqCst) { return; @@ -76,10 +78,7 @@ pub fn start() { tokio::spawn(async move { loop { - if let Ok(state) = LauncherState::get() { - let status = check_service_status(&state).await; - let _ = conn.tx.send(status); - } + let _ = conn.tx.send(check_service_status(&client).await); conn.notify.notified().await; } }); @@ -94,8 +93,8 @@ async fn reachable(client: &reqwest::Client, url: &str) -> bool { .is_ok() } -pub async fn check_service_status(state: &LauncherState) -> ServiceStatus { - let client = state.services.requester.http(); +pub async fn check_service_status(requester: &RequestClient) -> ServiceStatus { + let client = requester.http(); if !reachable(client, CONNECTIVITY_URL).await { return ServiceStatus { diff --git a/packages/oneclient_polyplus/Cargo.toml b/packages/oneclient_polyplus/Cargo.toml new file mode 100644 index 00000000..b62e0684 --- /dev/null +++ b/packages/oneclient_polyplus/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "oneclient_polyplus" +description = "Poly+ playtime reporting" +version.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true +documentation.workspace = true +readme.workspace = true +homepage.workspace = true +authors.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +oneclient_auth.workspace = true +oneclient_common.workspace = true +polyio.workspace = true + +reqwest.workspace = true +reqwest-websocket.workspace = true +tokio.workspace = true +futures-util.workspace = true + +bytes.workspace = true +rand.workspace = true +serde.workspace = true +serde_json.workspace = true +sha1.workspace = true +thiserror.workspace = true +tracing.workspace = true +url.workspace = true +uuid.workspace = true + +[dev-dependencies] +async-tungstenite = { version = "=0.32.1", features = ["tokio-runtime"] } +tokio = { workspace = true } diff --git a/packages/oneclient_core/src/plus.rs b/packages/oneclient_polyplus/src/lib.rs similarity index 81% rename from packages/oneclient_core/src/plus.rs rename to packages/oneclient_polyplus/src/lib.rs index 6b00d961..917c3c62 100644 --- a/packages/oneclient_core/src/plus.rs +++ b/packages/oneclient_polyplus/src/lib.rs @@ -1,3 +1,9 @@ +//! Poly+ playtime reporting. +//! +//! Opens a websocket to the Poly+ backend for the signed-in account and keeps +//! it alive while the user plays. Depends only on the auth service. + +use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; @@ -7,9 +13,8 @@ use rand::Rng; use reqwest_websocket::{Message, Upgrade}; use uuid::Uuid; -use crate::LauncherResult; -use crate::auth::MinecraftAccount; -use crate::constants; +use oneclient_auth::MinecraftAccount; +use oneclient_common::constants; const MOJANG_JOIN_URL: &str = "https://sessionserver.mojang.com/session/minecraft/join"; @@ -22,6 +27,9 @@ static STARTED: AtomicBool = AtomicBool::new(false); #[derive(Debug, thiserror::Error)] pub enum PlusError { + #[error(transparent)] + Auth(#[from] oneclient_auth::AuthError), + #[error("Mojang rejected the Poly+ session join (HTTP {status}): {body}")] JoinRejected { status: u16, body: String }, @@ -58,7 +66,11 @@ enum Event { Message(Option), } -pub fn start() { +/// Starts the Poly+ playtime reporter. +/// +/// Takes the state it needs rather than reaching for a global; idempotent, so +/// the first caller's handle is the one used for the process lifetime. +pub fn start(auth: Arc) { if STARTED.swap(true, Ordering::SeqCst) { return; } @@ -75,7 +87,7 @@ pub fn start() { let mut delay = RECONNECT_DELAY; loop { - delay = match session(&client).await { + delay = match session(&auth, &client).await { Outcome::Connected | Outcome::NoAccount => RECONNECT_DELAY, Outcome::Failed => (delay * 2).min(MAX_RECONNECT_DELAY), }; @@ -104,8 +116,8 @@ fn base_url() -> &'static str { constants::PLUS_BACKEND_URL.trim_end_matches('/') } -async fn session(client: &reqwest::Client) -> Outcome { - let account = match current_account().await { +async fn session(auth: &oneclient_auth::AuthService, client: &reqwest::Client) -> Outcome { + let account = match current_account(auth).await { Ok(Some(account)) => account, Ok(None) => return Outcome::NoAccount, Err(err) => { @@ -117,7 +129,7 @@ async fn session(client: &reqwest::Client) -> Outcome { let id = account.id; let mut connected = false; - match run_session(client, &account, &mut connected).await { + match run_session(auth, client, &account, &mut connected).await { Ok(()) => tracing::info!("[plus] playtime session for {id} ended"), Err(err) => tracing::warn!("[plus] playtime session for {id} failed: {err}"), } @@ -129,8 +141,10 @@ async fn session(client: &reqwest::Client) -> Outcome { } } -async fn current_account() -> LauncherResult> { - let Some(account) = crate::auth::default_account_for_launch().await? else { +async fn current_account( + auth: &oneclient_auth::AuthService, +) -> Result, PlusError> { + let Some(account) = auth.default_account_for_launch().await? else { return Ok(None); }; @@ -138,6 +152,7 @@ async fn current_account() -> LauncherResult> { } async fn run_session( + auth: &oneclient_auth::AuthService, client: &reqwest::Client, account: &MinecraftAccount, connected: &mut bool, @@ -156,14 +171,29 @@ async fn run_session( *connected = true; tracing::info!("[plus] playtime websocket connected for {}", account.id); - pump(&mut websocket, PING_INTERVAL, account.id).await + let account_id = account.id; + pump(&mut websocket, PING_INTERVAL, account_id, || { + account_changed(auth, account_id) + }) + .await } -async fn pump( +/// Keeps the playtime socket alive, stopping when `should_stop` says the +/// session is no longer valid or the server hangs up. +/// +/// Takes a predicate rather than the launcher state: this is a websocket ping +/// loop, and whether the signed-in account changed is the caller's business. +/// It also means the loop is testable without standing up a launcher. +async fn pump( websocket: &mut reqwest_websocket::WebSocket, ping_interval: Duration, account_id: Uuid, -) -> Result<(), PlusError> { + should_stop: F, +) -> Result<(), PlusError> +where + F: Fn() -> Fut, + Fut: std::future::Future, +{ let mut ping = tokio::time::interval(ping_interval); ping.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); ping.tick().await; @@ -176,7 +206,7 @@ async fn pump( match event { Event::Ping => { - if account_changed(account_id).await { + if should_stop().await { tracing::info!( "[plus] default account changed, ending playtime session for {account_id}" ); @@ -243,8 +273,8 @@ async fn login( Ok(login.json::().await?.token) } -async fn account_changed(id: Uuid) -> bool { - match crate::auth::get_default_account().await { +async fn account_changed(auth: &oneclient_auth::AuthService, id: Uuid) -> bool { + match auth.default_account().await { Ok(Some(account)) => account.id != id, Ok(None) => true, Err(_) => false, @@ -254,7 +284,7 @@ async fn account_changed(id: Uuid) -> bool { fn generate_server_id() -> String { let mut bytes = [0u8; 20]; rand::rng().fill_bytes(&mut bytes); - crate::crypto::to_hex(&bytes) + polyio::to_hex(&bytes) } #[cfg(test)] @@ -309,14 +339,14 @@ mod tests { #[tokio::test] async fn pings_on_every_interval_and_stops_when_the_server_closes() { - assert!(!account_changed(Uuid::nil()).await); - let (url, pings) = ping_counting_server(3).await; let mut websocket = connect(&url).await; let pumped = tokio::time::timeout( Duration::from_secs(10), - pump(&mut websocket, Duration::from_millis(50), Uuid::nil()), + pump(&mut websocket, Duration::from_millis(50), Uuid::nil(), || async { + false + }), ) .await .expect("pump should return once the server closes the socket"); diff --git a/packages/polyio/Cargo.toml b/packages/polyio/Cargo.toml index 05d097ba..290f4889 100644 --- a/packages/polyio/Cargo.toml +++ b/packages/polyio/Cargo.toml @@ -15,6 +15,8 @@ path = "src/lib.rs" [features] default = [] +# Test-only helpers (`polyio::testing`). Enable as a dev-dependency. +testing = [] [dependencies] tracing.workspace = true @@ -37,6 +39,10 @@ serde_json.workspace = true dunce.workspace = true bytes.workspace = true +# content hashing +digest.workspace = true +sha1.workspace = true + [target.'cfg(windows)'.dependencies] junction.workspace = true diff --git a/packages/polyio/src/file.rs b/packages/polyio/src/file.rs index c99bdb46..f9097c7b 100644 --- a/packages/polyio/src/file.rs +++ b/packages/polyio/src/file.rs @@ -1,4 +1,5 @@ -use std::{fs::Metadata, path::PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::{fs::Metadata, path::{Path, PathBuf}}; use async_tempfile::{TempDir, TempFile}; use serde::{Serialize, de::DeserializeOwned}; @@ -283,6 +284,187 @@ pub async fn write_json( ).await } +/// Counter for [`temp_sibling`] names, so two concurrent atomic writes to the +/// same path can't pick the same scratch file. +static ATOMIC_WRITE_COUNTER: AtomicU64 = AtomicU64::new(0); + +/// A scratch path next to `path`, so the subsequent rename stays within one +/// filesystem. A temp dir would not: `rename` across mount points fails with +/// `EXDEV`, which is exactly the case on Linux where `/tmp` is often a tmpfs. +fn temp_sibling(path: &Path) -> PathBuf { + let n = ATOMIC_WRITE_COUNTER.fetch_add(1, Ordering::Relaxed); + let stem = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "tmp".to_string()); + + let name = format!(".{stem}.{}.{n}.tmp", std::process::id()); + match path.parent() { + Some(parent) => parent.join(name), + None => PathBuf::from(name), + } +} + +/// Writes a file such that a reader only ever sees the old contents or the +/// complete new ones, never a half-written file. +/// +/// Writes to a sibling scratch file, fsyncs it, then renames over `path`. +/// Without the fsync the rename can land before the data does, so a crash +/// leaves a correctly-named zero-length file, worse than the torn write this +/// is meant to prevent. Parent directories are created if missing. +#[tracing::instrument( + level = "debug", + skip(path, data), + fields(path = %path.as_ref().display()) +)] +pub async fn write_atomic( + path: impl AsRef, + data: impl AsRef<[u8]>, +) -> PolyIOResult<()> { + let path = path.as_ref(); + + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + create_dir_all(parent).await?; + } + + let tmp = temp_sibling(path); + let ctx = |e: std::io::Error| IOError::PathIOError { + source: e, + path: tmp.to_string_lossy().to_string(), + }; + + let write = async { + let mut file = tokio::fs::File::create(&tmp).await.map_err(ctx)?; + tokio::io::AsyncWriteExt::write_all(&mut file, data.as_ref()) + .await + .map_err(ctx)?; + file.sync_all().await.map_err(ctx)?; + Ok::<_, IOError>(()) + } + .await; + + if let Err(err) = write { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(err); + } + + if let Err(err) = rename(&tmp, path).await { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(err); + } + + Ok(()) +} + +/// [`write_atomic`] for a serializable value, mirroring [`write_json`]. +#[tracing::instrument( + level = "debug", + skip(path, data), + fields(path = %path.as_ref().display()) +)] +pub async fn write_json_atomic( + path: impl AsRef, + data: T, +) -> PolyIOResult<()> { + let bytes = serde_json::to_vec(&data).map_err(|err| IOError::JsonFileWrite { + source: err, + file: path.as_ref().to_path_buf(), + })?; + + write_atomic(path, bytes).await +} + +/// Canonicalises `path` and returns it only if it lies under one of `roots`. +/// +/// This is the guard for paths that arrive from outside (a log or screenshot +/// the UI asked to open) so that `../` cannot walk out of the launcher's own +/// directories. Returns `Ok(None)` when the path resolves outside every root; +/// `Err` only when `path` itself cannot be canonicalised. +/// +/// Roots that cannot be canonicalised (typically because they don't exist yet) +/// are skipped rather than treated as a match. +#[tracing::instrument( + level = "debug", + skip(path, roots), + fields(path = %path.as_ref().display()) +)] +pub fn ensure_under>( + path: impl AsRef, + roots: impl IntoIterator, +) -> PolyIOResult> { + // `canonicalize` rather than `std::fs::canonicalize`: on Windows the latter + // returns a `\\?\` UNC path while the roots are plain paths, so `starts_with` + // would never match and every path would look like an escape. + let canon = crate::canonicalize(path)?; + + for root in roots { + if let Ok(root) = crate::canonicalize(root) + && canon.starts_with(&root) + { + return Ok(Some(canon)); + } + } + + Ok(None) +} + +/// Recursively copies `src` into `dst`, creating directories as needed. +/// +/// Entries named in `exclude_top` are skipped, but only at the top level; a +/// nested directory of the same name is still copied. Symlinks are followed, +/// copied as their target's contents. +#[tracing::instrument(level = "debug", skip(exclude_top))] +pub async fn copy_dir(src: &Path, dst: &Path, exclude_top: &[&str]) -> PolyIOResult<()> { + let mut stack: Vec<(PathBuf, PathBuf, bool)> = + vec![(src.to_path_buf(), dst.to_path_buf(), true)]; + + while let Some((cur_src, cur_dst, is_top)) = stack.pop() { + let mut entries = read_dir(&cur_src).await?; + while let Some(entry) = entries.next_entry().await? { + let name = entry.file_name(); + + if is_top + && let Some(name_str) = name.to_str() + && exclude_top.iter().any(|e| e.eq_ignore_ascii_case(name_str)) + { + continue; + } + + let child_src = entry.path(); + let child_dst = cur_dst.join(&name); + let file_type = entry.file_type().await?; + + if file_type.is_dir() { + create_dir_all(&child_dst).await?; + + stack.push((child_src, child_dst, false)); + } else if file_type.is_file() { + if let Some(parent) = child_dst.parent() { + create_dir_all(parent).await?; + } + + copy(&child_src, &child_dst).await?; + } + } + } + + Ok(()) +} + +/// Whether `dir` is a directory containing at least one entry. +pub async fn dir_has_content(dir: &Path) -> bool { + if !dir.is_dir() { + return false; + } + + match read_dir(dir).await { + Ok(mut entries) => matches!(entries.next_entry().await, Ok(Some(_))), + Err(_) => false, + } +} + /// Renames a file or directory to a new name, replacing the original file if `to` already exists. #[tracing::instrument( level = "debug", @@ -473,7 +655,7 @@ pub async fn tempfile() -> PolyIOResult { Ok(TempFile::new().await?) } -/// Makes sure a path is a valid path +/// Sanitises every component of a path and normalises separators to `/`. #[tracing::instrument( level = "debug", skip(path), @@ -518,4 +700,115 @@ mod tests { assert_eq!(write_buffer_size(None), DEFAULT_WRITE_BUFFER); assert_eq!(write_buffer_size(Some(0)), DEFAULT_WRITE_BUFFER); } + + fn scratch(tag: &str) -> PathBuf { + static N: AtomicU64 = AtomicU64::new(0); + let dir = std::env::temp_dir().join(format!( + "polyio-{tag}-{}-{}", + std::process::id(), + N.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[tokio::test] + async fn write_atomic_creates_missing_parents() { + let dir = scratch("atomic-parents"); + let target = dir.join("a").join("b").join("settings.json"); + + write_atomic(&target, b"{}").await.unwrap(); + + assert_eq!(std::fs::read(&target).unwrap(), b"{}"); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn write_atomic_replaces_and_leaves_no_scratch_files() { + let dir = scratch("atomic-replace"); + let target = dir.join("settings.json"); + + write_atomic(&target, b"old").await.unwrap(); + write_atomic(&target, b"new-and-longer").await.unwrap(); + + assert_eq!(std::fs::read(&target).unwrap(), b"new-and-longer"); + + // The scratch sibling must not survive a successful write. + let leftovers: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().to_string()) + .filter(|n| n != "settings.json") + .collect(); + assert!(leftovers.is_empty(), "left scratch files behind: {leftovers:?}"); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn ensure_under_accepts_a_path_inside_a_root() { + let dir = scratch("under-inside"); + let nested = dir.join("clusters").join("logs"); + std::fs::create_dir_all(&nested).unwrap(); + let file = nested.join("latest.log"); + std::fs::write(&file, b"").unwrap(); + + let resolved = ensure_under(&file, [&dir]).unwrap(); + assert!(resolved.is_some()); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn ensure_under_rejects_a_traversal_out_of_every_root() { + let dir = scratch("under-escape"); + let root = dir.join("clusters"); + let outside = dir.join("secrets"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let file = outside.join("auth.json"); + std::fs::write(&file, b"").unwrap(); + + // The classic shape: a path that only escapes once resolved. + let sneaky = root.join("..").join("secrets").join("auth.json"); + assert_eq!(ensure_under(&sneaky, [&root]).unwrap(), None); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn ensure_under_skips_roots_that_do_not_exist() { + let dir = scratch("under-missing-root"); + let file = dir.join("a.log"); + std::fs::write(&file, b"").unwrap(); + + let missing = dir.join("not-created-yet"); + let resolved = ensure_under(&file, [&missing, &dir]).unwrap(); + assert!(resolved.is_some(), "a missing root must not shadow a real one"); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn copy_dir_excludes_only_at_the_top_level() { + let dir = scratch("copy-dir"); + let src = dir.join("src"); + let dst = dir.join("dst"); + std::fs::create_dir_all(src.join("mods")).unwrap(); + std::fs::create_dir_all(src.join("keep").join("mods")).unwrap(); + std::fs::write(src.join("mods").join("top.jar"), b"top").unwrap(); + std::fs::write(src.join("keep").join("mods").join("nested.jar"), b"nested").unwrap(); + std::fs::write(src.join("options.txt"), b"opts").unwrap(); + + copy_dir(&src, &dst, &["mods"]).await.unwrap(); + + assert!(!dst.join("mods").exists(), "top-level `mods` should be excluded"); + assert!(dst.join("options.txt").exists()); + assert!( + dst.join("keep").join("mods").join("nested.jar").exists(), + "exclusion must not apply below the top level" + ); + + std::fs::remove_dir_all(&dir).unwrap(); + } } diff --git a/packages/oneclient_core/src/crypto.rs b/packages/polyio/src/hash.rs similarity index 59% rename from packages/oneclient_core/src/crypto.rs rename to packages/polyio/src/hash.rs index 203acfdb..f7e87859 100644 --- a/packages/oneclient_core/src/crypto.rs +++ b/packages/polyio/src/hash.rs @@ -1,9 +1,10 @@ use std::fmt::Write; use std::io::Read; +use std::path::Path; use digest::Digest; -use crate::LauncherResult; +use crate::{IOError, PolyIOResult}; /// Largest read chunk used when hashing a file. Files smaller than this get a /// buffer sized to fit, so hashing a 10 KiB asset doesn't allocate (and zero) @@ -39,15 +40,18 @@ impl Default for Sha1Stream { /// Hashing is CPU- and syscall-bound, so it runs on the blocking pool: one /// dispatch for the whole file rather than one per read, and the digest itself /// stays off the async workers that are driving concurrent downloads. -pub async fn sha1_file(path: impl AsRef) -> LauncherResult { +pub async fn sha1_file(path: impl AsRef) -> PolyIOResult { let path = path.as_ref().to_path_buf(); tokio::task::spawn_blocking(move || sha1_file_sync(&path)) .await .map_err(std::io::Error::other)? } -pub fn sha1_file_sync(path: &std::path::Path) -> LauncherResult { - let mut file = std::fs::File::open(path)?; +pub fn sha1_file_sync(path: &Path) -> PolyIOResult { + let mut file = std::fs::File::open(path).map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + })?; let mut hasher = Sha1Stream::new(); let size = file @@ -58,7 +62,10 @@ pub fn sha1_file_sync(path: &std::path::Path) -> LauncherResult { let mut buffer = vec![0u8; capacity]; loop { - let n = file.read(&mut buffer)?; + let n = file.read(&mut buffer).map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + })?; if n == 0 { break; } @@ -68,19 +75,54 @@ pub fn sha1_file_sync(path: &std::path::Path) -> LauncherResult { Ok(hasher.finish()) } +#[must_use] pub fn sha1_bytes(data: &[u8]) -> String { let mut hasher = Sha1Stream::new(); hasher.update(data); hasher.finish() } +/// Canonical form for a hash read from an external manifest, so a comparison +/// against a locally computed one doesn't fail on case or stray whitespace. +#[must_use] pub fn normalize_hash(hash: &str) -> String { hash.trim().to_ascii_lowercase() } -pub(crate) fn to_hex(data: &[u8]) -> String { +#[must_use] +pub fn to_hex(data: &[u8]) -> String { data.iter().fold(String::new(), |mut out, b| { let _ = write!(out, "{b:02x}"); out }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sha1_bytes_matches_known_vector() { + assert_eq!( + sha1_bytes(b"abc"), + "a9993e364706816aba3e25717850c26c9cd0d89d" + ); + assert_eq!( + sha1_bytes(b""), + "da39a3ee5e6b4b0d3255bfef95601890afd80709" + ); + } + + #[test] + fn stream_and_oneshot_agree() { + let mut stream = Sha1Stream::new(); + stream.update(b"ab"); + stream.update(b"c"); + assert_eq!(stream.finish(), sha1_bytes(b"abc")); + } + + #[test] + fn normalize_hash_trims_and_lowercases() { + assert_eq!(normalize_hash(" A9B2 "), "a9b2"); + } +} diff --git a/packages/polyio/src/lib.rs b/packages/polyio/src/lib.rs index a7f6f1a1..f693d8f9 100644 --- a/packages/polyio/src/lib.rs +++ b/packages/polyio/src/lib.rs @@ -2,9 +2,14 @@ mod error; mod system; mod file; mod archive; +mod hash; + +#[cfg(feature = "testing")] +pub mod testing; pub(crate) use error::PolyIOResult; pub use error::IOError; pub use file::*; pub use system::*; -pub use archive::*; \ No newline at end of file +pub use archive::*; +pub use hash::*; diff --git a/packages/polyio/src/testing.rs b/packages/polyio/src/testing.rs new file mode 100644 index 00000000..2659dc28 --- /dev/null +++ b/packages/polyio/src/testing.rs @@ -0,0 +1,55 @@ +//! Helpers for tests. Gated behind the `testing` feature so they stay out of +//! release builds; enable it as a dev-dependency. + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// A uniquely-named directory under the system temp dir, removed when dropped. +/// +/// Prefer this over a bare `std::env::temp_dir().join(...)`: hand-rolled +/// versions of this leak their directory on every run, because there is nothing +/// to remove it once the test finishes. +/// +/// The name embeds the process id and a counter, so concurrent tests (and +/// concurrent *runs*) cannot collide. +#[derive(Debug)] +pub struct ScratchDir(PathBuf); + +impl ScratchDir { + /// Creates the directory. `tag` is only there to make a stray directory + /// identifiable if a test aborts hard enough to skip the destructor. + #[must_use] + pub fn new(tag: &str) -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + + let path = std::env::temp_dir() + .join(format!("oneclient-{tag}-{}-{n}", std::process::id())); + std::fs::create_dir_all(&path) + .unwrap_or_else(|e| panic!("failed to create scratch dir {}: {e}", path.display())); + + Self(path) + } + + #[must_use] + pub fn path(&self) -> &Path { + &self.0 + } + + #[must_use] + pub fn join(&self, name: impl AsRef) -> PathBuf { + self.0.join(name) + } +} + +impl AsRef for ScratchDir { + fn as_ref(&self) -> &Path { + &self.0 + } +} + +impl Drop for ScratchDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +}