From 2ad94a192b2a19c7b8a5ae0d22fbaf170c52808b Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:34:08 +0200 Subject: [PATCH 1/6] feat: Use different launcher folder in dev builds --- packages/oneclient_core/src/paths.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/oneclient_core/src/paths.rs b/packages/oneclient_core/src/paths.rs index a4f18c3d..032bd3e3 100644 --- a/packages/oneclient_core/src/paths.rs +++ b/packages/oneclient_core/src/paths.rs @@ -7,7 +7,12 @@ use crate::LauncherResult; const QUALIFIER: &str = "org"; const ORGANIZATION: &str = "Polyfrost"; +#[cfg(not(debug_assertions))] const APPLICATION: &str = "OneClient"; +// Dev builds use a separate launcher dir (e.g. `oneclient-dev`) so a running +// dev environment never touches prod data. +#[cfg(debug_assertions)] +const APPLICATION: &str = "OneClient-dev"; static PROJECT_DIRS: OnceLock = OnceLock::new(); static LAUNCHER_DIR_OVERRIDE: OnceLock = OnceLock::new(); From 48a2d67ae695ae50a2d8cbd64c4171abff87c0bd Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:37:30 +0200 Subject: [PATCH 2/6] fix: Use higher stack size in debug builds --- packages/oneclient_app/src/main.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/oneclient_app/src/main.rs b/packages/oneclient_app/src/main.rs index d5e7a082..c2ac7c12 100644 --- a/packages/oneclient_app/src/main.rs +++ b/packages/oneclient_app/src/main.rs @@ -31,11 +31,15 @@ impl App for OneClientApp { } fn main() { - let rt = Builder::new_multi_thread() - .enable_all() - .max_blocking_threads(16) - .build() - .unwrap(); + let mut builder = Builder::new_multi_thread(); + builder.enable_all().max_blocking_threads(16); + + // 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 + #[cfg(debug_assertions)] + builder.thread_stack_size(4 * 1024 * 1024); + + let rt = builder.build().unwrap(); let _tokio_guard = rt.enter(); let settings = rt.block_on(oneclient_core::settings::store::load_settings(None)); From c338d09c608bff082d696c72c72939fa3c34d3b9 Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:19:33 +0200 Subject: [PATCH 3/6] feat(ui): Made the download notification more descriptive --- packages/oneclient_app/src/bridge/runtime.rs | 6 +- .../src/components/notifications.rs | 91 ++++++- packages/oneclient_app/src/notifications.rs | 222 ++++++++++++++---- .../src/view/onboarding/downloading/mod.rs | 1 + .../oneclient_core/src/bundles/install.rs | 13 +- .../oneclient_core/src/bundles/updates.rs | 23 +- packages/oneclient_core/src/dev.rs | 2 + packages/oneclient_core/src/game/download.rs | 24 +- packages/oneclient_core/src/game/metadata.rs | 32 ++- .../oneclient_core/src/notification/data.rs | 41 ++++ .../src/notification/grouped.rs | 41 +++- .../oneclient_core/src/notification/mod.rs | 2 +- 12 files changed, 417 insertions(+), 81 deletions(-) diff --git a/packages/oneclient_app/src/bridge/runtime.rs b/packages/oneclient_app/src/bridge/runtime.rs index ded6564e..2e6cbf36 100644 --- a/packages/oneclient_app/src/bridge/runtime.rs +++ b/packages/oneclient_app/src/bridge/runtime.rs @@ -854,7 +854,11 @@ async fn install_package( format!("Installing {}", project.name), ); let size = version.primary_file().map(|f| f.size).unwrap_or(0); - let child = session.child(project.name.clone(), size); + let child = session.child( + project.name.clone(), + size, + oneclient_core::notification::TaskCategory::Packages, + ); let result = PackageStore::install_to_cluster( provider, diff --git a/packages/oneclient_app/src/components/notifications.rs b/packages/oneclient_app/src/components/notifications.rs index ae17e46c..5f9d5411 100644 --- a/packages/oneclient_app/src/components/notifications.rs +++ b/packages/oneclient_app/src/components/notifications.rs @@ -9,8 +9,9 @@ use crate::{ Route, components::{Button, ButtonVariant, Icon, IconType, OverlayPopup, ScrollArea}, hooks::{use_dispatch, use_notifications_snapshot}, - notifications::{InboxEntry, NotificationActionKind}, + notifications::{InboxEntry, NotificationActionKind, TransferStats}, theme::colors, + utils::{format_duration_hms, format_size}, }; #[derive(PartialEq)] @@ -212,15 +213,7 @@ fn tasks_section(entry: &InboxEntry, mut expanded: State) -> impl IntoElem let now = !*expanded.peek(); expanded.set(now); }) - .child( - Icon::new(if is_expanded { - IconType::ChevronDown - } else { - IconType::ChevronRight - }) - .size(14.) - .color(colors::fg_secondary()), - ) + .child(ChevronToggle { expanded: is_expanded }) .child( label() .text(if is_expanded { @@ -269,6 +262,14 @@ fn task_row(task: &crate::notifications::TaskView) -> impl IntoElement { .width(Size::flex(1.0)) .color(colors::fg_primary()), ) + .maybe(task.total_count > 1, |el| { + el.child( + label() + .text(format!("{}/{}", task.done_count, task.total_count)) + .font_size(10.) + .color(colors::fg_secondary()), + ) + }) .child( label() .text(format!("{percent}%")) @@ -278,6 +279,35 @@ fn task_row(task: &crate::notifications::TaskView) -> impl IntoElement { .into_element() } +/// A right-facing chevron that rotates 90° down when `expanded`, animated. +#[derive(PartialEq)] +struct ChevronToggle { + expanded: bool, +} + +impl Component for ChevronToggle { + fn render(&self) -> impl IntoElement { + let expanded = self.expanded; + let anim = use_animation(|_| AnimNum::new(0., 90.).time(160).ease(Ease::Out)); + + use_side_effect_with_deps(&expanded, move |&expanded| { + let mut anim = anim; + if expanded { + anim.start(); + } else { + anim.reverse(); + } + }); + + let deg = anim.get().value(); + rect().rotate(deg).child( + Icon::new(IconType::ChevronRight) + .size(14.) + .color(colors::fg_secondary()), + ) + } +} + fn phase_badge(phase: &str) -> impl IntoElement { let color = match phase { "Verifying" => colors::code_warn(), @@ -353,10 +383,9 @@ fn row_body(entry: &InboxEntry, dispatch: crate::BridgeDispatch) -> impl IntoEle fn row_extra(entry: &InboxEntry, dispatch: crate::BridgeDispatch) -> 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); - return rect() + let bar = rect() .width(Size::fill()) .height(Size::px(6.)) - .margin(Gaps::new(8., 0., 0., 0.)) .corner_radius(CornerRadius::new_all(50.)) .background(colors::brand().with_a(128)) .child( @@ -365,7 +394,15 @@ fn row_extra(entry: &InboxEntry, dispatch: crate::BridgeDispatch) -> impl IntoEl .height(Size::px(6.)) .corner_radius(CornerRadius::new_all(50.)) .background(colors::brand()), - ) + ); + + return rect() + .vertical() + .width(Size::fill()) + .spacing(5.) + .margin(Gaps::new(8., 0., 0., 0.)) + .child(bar) + .maybe_child(entry.transfer.map(transfer_footer)) .into_element(); } @@ -412,6 +449,34 @@ fn row_extra(entry: &InboxEntry, dispatch: crate::BridgeDispatch) -> impl IntoEl rect().into_element() } +fn transfer_footer(stats: TransferStats) -> Element { + let speed = format!("{}/s", format_size(stats.speed_bps as u64)); + let eta = stats + .eta_secs + .map(|secs| format!("{} left", format_duration_hms(secs as i64))); + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .main_align(Alignment::SpaceBetween) + .child( + label() + .text(speed) + .font_size(10.) + .color(colors::fg_secondary()), + ) + .maybe_child(eta.map(|eta| { + label() + .text(eta) + .font_size(10.) + .color(colors::fg_secondary()) + .into_element() + })) + .into_element() +} + fn run_action(dispatch: &crate::BridgeDispatch, kind: &NotificationActionKind) { match kind { NotificationActionKind::OpenClusterUpdate(summary) => { diff --git a/packages/oneclient_app/src/notifications.rs b/packages/oneclient_app/src/notifications.rs index 4b829575..2915d5b0 100644 --- a/packages/oneclient_app/src/notifications.rs +++ b/packages/oneclient_app/src/notifications.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::time::{Duration, Instant}; use oneclient_core::notification::{ - GroupedProgressEvent, Notification, NotificationLevel, PromptKind, UserChoice, + GroupedProgressEvent, Notification, NotificationLevel, PromptKind, TaskCategory, UserChoice, }; use oneclient_db::models::ClusterId; use tokio::sync::oneshot; @@ -51,14 +51,26 @@ pub struct InboxEntry { pub created_at: Instant, pub actions: Vec, pub tasks: Vec, + /// Live transfer stats for grouped downloads (bytes/sec, seconds remaining). + 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 +/// single [`TaskCategory`] (e.g. all libraries collapse into one "Libraries" row). #[derive(Clone, Debug, PartialEq)] pub struct TaskView { pub label: String, pub phase: &'static str, pub current: u64, pub total: u64, + pub done_count: u64, + pub total_count: u64, } #[derive(Clone, Debug, PartialEq)] @@ -137,28 +149,97 @@ pub struct NotificationState { cluster_update: Option, } +/// Fixed display order for the category rows. +const CATEGORY_ORDER: [TaskCategory; 6] = [ + TaskCategory::Client, + TaskCategory::Libraries, + TaskCategory::Natives, + TaskCategory::Assets, + TaskCategory::Metadata, + TaskCategory::Packages, +]; + +struct ChildRec { + category: TaskCategory, + phase: &'static str, + current: u64, + total: u64, +} + #[derive(Default)] struct GroupedTasks { title: String, - children: HashMap, - seen: u64, - done: u64, - done_units: u64, - current: Option, + children: HashMap, + done_units: HashMap, + done_count: HashMap, + seen_count: HashMap, + /// 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, } impl GroupedTasks { + /// Aggregate live + finished children into one row per category, in display order. fn task_list(&self) -> Vec { - let mut tasks: Vec = self.children.values().cloned().collect(); - tasks.sort_by(|a, b| a.label.cmp(&b.label)); - tasks + CATEGORY_ORDER + .iter() + .filter_map(|&cat| { + let seen_count = self.seen_count.get(&cat).copied().unwrap_or(0); + let reserved_count = self.reserved_count.get(&cat).copied().unwrap_or(0); + let total_count = seen_count.max(reserved_count); + if total_count == 0 { + return None; + } + let done_units = self.done_units.get(&cat).copied().unwrap_or(0); + let done_count = self.done_count.get(&cat).copied().unwrap_or(0); + let reserved_units = self.reserved_units.get(&cat).copied().unwrap_or(0); + + let live: Vec<&ChildRec> = + self.children.values().filter(|c| c.category == cat).collect(); + let live_current: u64 = live.iter().map(|c| c.current.min(c.total)).sum(); + let live_total: u64 = live.iter().map(|c| c.total).sum(); + + // Surface the most advanced phase among live children; default Downloading. + let phase = live + .iter() + .map(|c| c.phase) + .find(|p| *p != "Downloading") + .unwrap_or("Downloading"); + + Some(TaskView { + label: cat.label().to_string(), + phase, + current: done_units + live_current, + // Reserved total keeps the denominator stable while children are + // still being added; fall back to what we've actually seen. + total: reserved_units.max(done_units + live_total), + done_count, + total_count, + }) + }) + .collect() } - fn describe(task: &TaskView) -> String { - if task.phase == "Verifying" { - format!("Verifying {} checksum", task.label) + /// Which coarse group is currently downloading, for the notification body. + fn active_body(&self) -> Option<&'static str> { + let mut minecraft = false; + let mut packages = false; + for child in self.children.values() { + if child.category.is_minecraft() { + minecraft = true; + } else { + packages = true; + } + } + if minecraft { + Some("Downloading Minecraft") + } else if packages { + Some("Downloading Packages") } else { - format!("{} {}", task.phase, task.label) + None } } } @@ -359,6 +440,7 @@ impl NotificationState { created_at: Instant::now(), actions: Vec::new(), tasks: Vec::new(), + transfer: None, }, ); id @@ -392,6 +474,7 @@ impl NotificationState { created_at: Instant::now(), actions, tasks: Vec::new(), + transfer: None, }, ); @@ -506,22 +589,39 @@ impl NotificationState { }, ); } - GroupedProgressEvent::AddChild { + GroupedProgressEvent::Expect { session_id, - child_id, - label, + category, + count, total, } => { if let Some(group) = self.grouped_tasks.get_mut(&session_id) { - let task = TaskView { - label, - phase: "Downloading", - current: 0, - total: total.max(1), - }; - group.current = Some(GroupedTasks::describe(&task)); - group.children.insert(child_id, task); - group.seen += 1; + *group.reserved_count.entry(category).or_default() += count; + *group.reserved_units.entry(category).or_default() += total; + } + self.refresh_grouped_entry(inbox, session_id); + if let Some(&entry_id) = self.grouped_entries.get(&session_id) { + self.ensure_progress_toast(entry_id); + } + } + GroupedProgressEvent::AddChild { + session_id, + child_id, + label: _, + total, + category, + } => { + if let Some(group) = self.grouped_tasks.get_mut(&session_id) { + group.children.insert( + child_id, + ChildRec { + category, + phase: "Downloading", + current: 0, + total: total.max(1), + }, + ); + *group.seen_count.entry(category).or_default() += 1; } self.refresh_grouped_entry(inbox, session_id); if let Some(&entry_id) = self.grouped_entries.get(&session_id) { @@ -539,8 +639,6 @@ impl NotificationState { { task.current = current; task.total = total.max(1); - let desc = GroupedTasks::describe(task); - group.current = Some(desc); } self.refresh_grouped_entry(inbox, session_id); } @@ -553,8 +651,6 @@ impl NotificationState { && let Some(task) = group.children.get_mut(&child_id) { task.phase = phase.label(); - let desc = GroupedTasks::describe(task); - group.current = Some(desc); } self.refresh_grouped_entry(inbox, session_id); } @@ -565,8 +661,8 @@ impl NotificationState { if let Some(group) = self.grouped_tasks.get_mut(&session_id) && let Some(task) = group.children.remove(&child_id) { - group.done += 1; - group.done_units += task.total; + *group.done_units.entry(task.category).or_default() += task.total; + *group.done_count.entry(task.category).or_default() += 1; } self.refresh_grouped_entry(inbox, session_id); } @@ -584,40 +680,70 @@ impl NotificationState { entry.progress = Some((1, 1)); entry.is_loading = false; entry.tasks = Vec::new(); + entry.transfer = None; } } } } } - fn refresh_grouped_entry(&self, inbox: &mut [InboxEntry], session_id: Uuid) { + fn refresh_grouped_entry(&mut self, inbox: &mut [InboxEntry], session_id: Uuid) { let Some(&entry_id) = self.grouped_entries.get(&session_id) else { return; }; - let Some(group) = self.grouped_tasks.get(&session_id) else { - return; - }; - let Some(entry) = inbox.iter_mut().find(|e| e.id == entry_id) else { + let Some(group) = self.grouped_tasks.get_mut(&session_id) else { return; }; let tasks = group.task_list(); - entry.title = group.title.clone(); - entry.body = group - .current - .clone() + let body = group + .active_body() + .map(str::to_string) .unwrap_or_else(|| "Preparing...".to_string()); - let completed: u64 = group.done_units - + group - .children - .values() - .map(|t| t.current.min(t.total)) - .sum::(); - let total: u64 = group.done_units + group.children.values().map(|t| t.total).sum::(); - entry.progress = Some((completed, total.max(1))); + let title = group.title.clone(); + + 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 + } + }; + + let Some(entry) = inbox.iter_mut().find(|e| e.id == entry_id) else { + return; + }; + entry.title = title; + entry.body = body; + entry.progress = Some((completed, total)); entry.is_loading = true; entry.read = false; entry.tasks = tasks; + entry.transfer = transfer; } } diff --git a/packages/oneclient_app/src/view/onboarding/downloading/mod.rs b/packages/oneclient_app/src/view/onboarding/downloading/mod.rs index 5db872fa..f5e096bf 100644 --- a/packages/oneclient_app/src/view/onboarding/downloading/mod.rs +++ b/packages/oneclient_app/src/view/onboarding/downloading/mod.rs @@ -802,6 +802,7 @@ fn apply_grouped(agg: &mut GroupedAgg, event: GroupedProgressEvent) { agg.children.clear(); agg.done_units = 0; } + GroupedProgressEvent::Expect { .. } => {} GroupedProgressEvent::AddChild { child_id, label, diff --git a/packages/oneclient_core/src/bundles/install.rs b/packages/oneclient_core/src/bundles/install.rs index c328d7a2..e17eb962 100644 --- a/packages/oneclient_core/src/bundles/install.rs +++ b/packages/oneclient_core/src/bundles/install.rs @@ -10,7 +10,7 @@ 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, TaskPhase}; +use crate::notification::{GroupedProgressChild, GroupedProgressSession, TaskCategory, TaskPhase}; use crate::packages::domain::{ContentType, GameLoader}; use crate::packages::error::PackageError; use crate::packages::store::{PackageStore, unlink_cluster_file}; @@ -233,10 +233,19 @@ pub async fn install_enabled_bundle_files( "installing enabled bundle files" ); + if let Some(p) = progress { + let reserved_bytes: u64 = to_install.iter().map(|f| f.size.max(1)).sum(); + p.expect(TaskCategory::Packages, to_install.len() as u64, reserved_bytes); + } + let bundle_name = &bundle_name; let results = futures_util::stream::iter(to_install.into_iter().map(|file| async move { let child = progress.map(|p| { - let c = p.child(format!("Mod {}", file.display_name()), file.size.max(1)); + let c = p.child( + format!("Mod {}", file.display_name()), + file.size.max(1), + crate::notification::TaskCategory::Packages, + ); c.set_phase(TaskPhase::Downloading); c }); diff --git a/packages/oneclient_core/src/bundles/updates.rs b/packages/oneclient_core/src/bundles/updates.rs index ba11903b..f33cea64 100644 --- a/packages/oneclient_core/src/bundles/updates.rs +++ b/packages/oneclient_core/src/bundles/updates.rs @@ -367,9 +367,24 @@ pub async fn apply_bundle_updates( ) }); + if let Some(s) = session.as_ref() { + let count = (check.updates_available.len() + check.additions_available.len()) as u64; + let bytes: u64 = check + .updates_available + .iter() + .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); + } + for update in check.updates_available { let child = session.as_ref().map(|s| { - let c = s.child(update.new_file.display_name(), update.new_file.size.max(1)); + let c = s.child( + update.new_file.display_name(), + update.new_file.size.max(1), + crate::notification::TaskCategory::Packages, + ); c.set_phase(crate::notification::TaskPhase::Downloading); c }); @@ -382,7 +397,11 @@ pub async fn apply_bundle_updates( for addition in check.additions_available { let file_id = addition.new_file.kind.package_id(); let child = session.as_ref().map(|s| { - let c = s.child(addition.new_file.display_name(), addition.new_file.size.max(1)); + let c = s.child( + addition.new_file.display_name(), + addition.new_file.size.max(1), + crate::notification::TaskCategory::Packages, + ); c.set_phase(crate::notification::TaskPhase::Downloading); c }); diff --git a/packages/oneclient_core/src/dev.rs b/packages/oneclient_core/src/dev.rs index 93e2a613..7a00c356 100644 --- a/packages/oneclient_core/src/dev.rs +++ b/packages/oneclient_core/src/dev.rs @@ -87,11 +87,13 @@ fn spawn_notification_handler(mut rx: mpsc::UnboundedReceiver) { }, ); } + GroupedProgressEvent::Expect { .. } => {} GroupedProgressEvent::AddChild { session_id, child_id, label, total, + .. } => { let Some(session) = grouped_sessions.get_mut(&session_id) else { continue; diff --git a/packages/oneclient_core/src/game/download.rs b/packages/oneclient_core/src/game/download.rs index f5994f83..cc7852c7 100644 --- a/packages/oneclient_core/src/game/download.rs +++ b/packages/oneclient_core/src/game/download.rs @@ -3,15 +3,18 @@ use reqwest::Method; use crate::crypto::{normalize_hash, sha1_bytes, sha1_file}; use crate::game::GameError; use crate::http::{RequestClient, ResponseExt, ResponseNotifyOptions, ResponseOptions}; -use crate::notification::{GroupedProgressSession, NotificationService, TaskPhase}; +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>, @@ -25,8 +28,13 @@ pub async fn download_to_path( let request = reqwest::Request::new(Method::GET, parsed_url); let response = requester.send(request).await?; - let total = response.content_length().unwrap_or(0).max(1); - let child = progress.child(label, total); + // 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 child = progress.child(label, total, category); let stream = response .stream( @@ -63,6 +71,8 @@ pub async fn fetch_bytes_verified( notifier: &NotificationService, progress: &GroupedProgressSession, label: impl Into, + category: TaskCategory, + expected_size: u64, url: &str, expected_sha1: &str, ) -> LauncherResult> { @@ -71,8 +81,12 @@ pub async fn fetch_bytes_verified( let request = reqwest::Request::new(Method::GET, parsed_url.clone()); let response = requester.send(request).await?; - let total = response.content_length().unwrap_or(0).max(1); - let child = progress.child(label, total); + let total = if expected_size > 0 { + expected_size + } else { + response.content_length().unwrap_or(0).max(1) + }; + let child = progress.child(label, total, category); let stream = response .stream( diff --git a/packages/oneclient_core/src/game/metadata.rs b/packages/oneclient_core/src/game/metadata.rs index 45e75807..67c3cd18 100644 --- a/packages/oneclient_core/src/game/metadata.rs +++ b/packages/oneclient_core/src/game/metadata.rs @@ -12,7 +12,7 @@ use crate::metadata::MetadataError; use crate::metadata::MetadataStore; use crate::os_ext::OsExt; use crate::packages::domain::GameLoader; -use crate::notification::GroupedProgressSession; +use crate::notification::{GroupedProgressSession, TaskCategory}; use crate::paths; use crate::state::LauncherServices; use crate::{LauncherError, LauncherResult}; @@ -80,7 +80,11 @@ pub async fn download_version_info( let mut info: VersionInfo = match progress { Some(progress) => { progress - .run_child(format!("Version metadata ({version_id})"), 1, |child| { + .run_child( + format!("Version metadata ({version_id})"), + 1, + TaskCategory::Metadata, + |child| { let requester = requester.clone(); async move { child.set_progress(0, Some(1)); @@ -106,7 +110,11 @@ pub async fn download_version_info( let partial: interfrost::api::modded::PartialVersionInfo = match progress { Some(progress) => { progress - .run_child(format!("Loader metadata ({version_id})"), 1, |child| { + .run_child( + format!("Loader metadata ({version_id})"), + 1, + TaskCategory::Metadata, + |child| { let requester = requester.clone(); async move { child.set_progress(0, Some(1)); @@ -170,6 +178,8 @@ pub async fn download_assets_index( &services.notifier, progress, format!("Assets index ({})", version.asset_index.id), + TaskCategory::Assets, + u64::from(version.asset_index.size), &version.asset_index.url, &path, Some(version.asset_index.sha1.as_str()), @@ -223,6 +233,8 @@ pub async fn download_assets( ¬ifier, &progress, format!("Asset {name}"), + TaskCategory::Assets, + u64::from(asset.size), &url, &path, Some(hash), @@ -275,6 +287,8 @@ pub async fn download_client( &services.notifier, progress, format!("Client {}", version.id), + TaskCategory::Client, + u64::from(client.size), &client.url, &path, Some(&client.sha1), @@ -375,6 +389,8 @@ pub async fn download_libraries( ¬ifier, &progress, format!("Library {}", lib_short(&lib.name)), + TaskCategory::Libraries, + u64::from(artifact.size), &artifact.url, &path, Some(&artifact.sha1), @@ -395,6 +411,8 @@ pub async fn download_libraries( ¬ifier, &progress, format!("Library {}", lib_short(&lib.name)), + TaskCategory::Libraries, + 0, &url, &path, None, @@ -416,12 +434,18 @@ pub async fn download_libraries( ¬ifier, &progress, format!("Natives {}", lib_short(&lib.name)), + TaskCategory::Natives, + u64::from(native.size), &native.url, &native.sha1, ) .await?; - let extract = progress.child(format!("Natives {}", lib_short(&lib.name)), 1); + let extract = progress.child( + format!("Natives {}", lib_short(&lib.name)), + 1, + TaskCategory::Natives, + ); extract.set_phase(crate::notification::TaskPhase::Extracting); polyio::unzip_bytes_filtered( data, diff --git a/packages/oneclient_core/src/notification/data.rs b/packages/oneclient_core/src/notification/data.rs index e8741584..dc8c5efb 100644 --- a/packages/oneclient_core/src/notification/data.rs +++ b/packages/oneclient_core/src/notification/data.rs @@ -9,11 +9,21 @@ pub enum GroupedProgressEvent { 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, @@ -55,6 +65,37 @@ impl TaskPhase { } } +/// 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, + #[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::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 { diff --git a/packages/oneclient_core/src/notification/grouped.rs b/packages/oneclient_core/src/notification/grouped.rs index bdb95b26..d9d2e9b9 100644 --- a/packages/oneclient_core/src/notification/grouped.rs +++ b/packages/oneclient_core/src/notification/grouped.rs @@ -1,8 +1,9 @@ use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; use uuid::Uuid; -use super::data::GroupedProgressEvent; +use super::data::{GroupedProgressEvent, TaskCategory}; use super::NotificationService; #[derive(Clone)] @@ -11,12 +12,12 @@ struct SessionInner { notifier: NotificationService, } -#[derive(Clone)] struct ChildInner { session_id: Uuid, child_id: Uuid, notifier: NotificationService, label: String, + total: AtomicU64, finished: Arc, } @@ -49,7 +50,26 @@ impl GroupedProgressSession { self.inner.session_id } - pub fn child(&self, label: impl Into, total: u64) -> GroupedProgressChild { + /// Reserve the expected work for a category before its children are added. + /// `count` = number of files, `total` = sum of their sizes in bytes. + pub fn expect(&self, category: TaskCategory, count: u64, total: u64) { + if count == 0 { + return; + } + self.inner.notifier.send_grouped(GroupedProgressEvent::Expect { + session_id: self.inner.session_id, + category, + count, + total, + }); + } + + pub fn child( + &self, + label: impl Into, + total: u64, + category: TaskCategory, + ) -> GroupedProgressChild { let child_id = Uuid::new_v4(); let label = label.into(); let total = total.max(1); @@ -59,6 +79,7 @@ impl GroupedProgressSession { child_id, label: label.clone(), total, + category, }); GroupedProgressChild { @@ -67,6 +88,7 @@ impl GroupedProgressSession { child_id, notifier: self.inner.notifier.clone(), label, + total: AtomicU64::new(total), finished: Arc::new(std::sync::atomic::AtomicBool::new(false)), }), } @@ -76,13 +98,14 @@ impl GroupedProgressSession { &self, label: impl Into, total: u64, + category: TaskCategory, f: F, ) -> Result where F: FnOnce(GroupedProgressChild) -> Fut, Fut: std::future::Future>, { - let child = self.child(label, total); + let child = self.child(label, total, category); let result = f(child.clone()).await; if result.is_ok() { child.finish(); @@ -120,7 +143,15 @@ impl GroupedProgressChild { return; } - let total = total.unwrap_or(1).max(1); + // 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. + 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 { session_id: self.inner.session_id, child_id: self.inner.child_id, diff --git a/packages/oneclient_core/src/notification/mod.rs b/packages/oneclient_core/src/notification/mod.rs index 80e44880..12fef898 100644 --- a/packages/oneclient_core/src/notification/mod.rs +++ b/packages/oneclient_core/src/notification/mod.rs @@ -1,6 +1,6 @@ pub use data::{ GroupedProgressEvent, LaunchStage, MicrosoftLoginStatus, Notification, NotificationLevel, - PromptKind, TaskPhase, UserChoice, + PromptKind, TaskCategory, TaskPhase, UserChoice, }; pub use grouped::{GroupedProgressChild, GroupedProgressSession}; pub use service::NotificationService; From a9481a5789c88d6083e9dd40f38f3313c6499357 Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:28:27 +0200 Subject: [PATCH 4/6] feat(core): Provision cluster creation in an attempt to not have race conditions --- .../oneclient_core/src/clusters/manager.rs | 31 +++++++++++++++++++ .../oneclient_core/src/clusters/provision.rs | 10 +++--- packages/oneclient_core/src/dev.rs | 1 + packages/oneclient_core/src/recovery.rs | 5 +++ packages/oneclient_core/src/state.rs | 2 ++ 5 files changed, 45 insertions(+), 4 deletions(-) diff --git a/packages/oneclient_core/src/clusters/manager.rs b/packages/oneclient_core/src/clusters/manager.rs index 72d11bac..9d7a9960 100644 --- a/packages/oneclient_core/src/clusters/manager.rs +++ b/packages/oneclient_core/src/clusters/manager.rs @@ -48,6 +48,37 @@ impl ClusterManager { 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() { diff --git a/packages/oneclient_core/src/clusters/provision.rs b/packages/oneclient_core/src/clusters/provision.rs index 898a3657..18cc8daf 100644 --- a/packages/oneclient_core/src/clusters/provision.rs +++ b/packages/oneclient_core/src/clusters/provision.rs @@ -39,13 +39,13 @@ pub async fn ensure_from_bundles(state: &LauncherState) -> LauncherResult { + Ok(Some(cluster)) => { tracing::info!( cluster_id = cluster.id, mc_version = %cluster.mc_version, @@ -54,6 +54,7 @@ pub async fn ensure_from_bundles(state: &LauncherState) -> LauncherResult {} Err(err) => { tracing::warn!( mc_version = %mc_version, @@ -100,13 +101,13 @@ pub async fn ensure_from_versions(state: &LauncherState) -> LauncherResult { + Ok(Some(cluster)) => { tracing::info!( cluster_id = cluster.id, mc_version = %cluster.mc_version, @@ -115,6 +116,7 @@ pub async fn ensure_from_versions(state: &LauncherState) -> LauncherResult {} Err(err) => { tracing::warn!( mc_version = %mc_version, diff --git a/packages/oneclient_core/src/dev.rs b/packages/oneclient_core/src/dev.rs index 7a00c356..4ea18906 100644 --- a/packages/oneclient_core/src/dev.rs +++ b/packages/oneclient_core/src/dev.rs @@ -242,6 +242,7 @@ pub async fn ephemeral_state() -> LauncherResult> { images: crate::images::ImageCacheStore::new(), games: crate::game::GameProcessManager::new(), discord: crate::discord::DiscordRpc::spawn(false), + provisioning: tokio::sync::Mutex::new(()), })) } diff --git a/packages/oneclient_core/src/recovery.rs b/packages/oneclient_core/src/recovery.rs index abdedb0b..16e42b07 100644 --- a/packages/oneclient_core/src/recovery.rs +++ b/packages/oneclient_core/src/recovery.rs @@ -40,6 +40,11 @@ impl RecoveryReport { pub async fn reconstruct_from_disk(state: &LauncherState) -> LauncherResult { let mut report = RecoveryReport::default(); + // Hold the provisioning lock across the whole scan+adopt so a concurrent + // cluster create (folder-on-disk written before its DB row commits) can't be + // misclassified as an orphan and adopted into a duplicate row. + let _guard = state.provisioning.lock().await; + let clusters_dir = paths::clusters_dir()?; let orphans = orphan_cluster_folders(&state.services.db, &clusters_dir).await?; if orphans.is_empty() { diff --git a/packages/oneclient_core/src/state.rs b/packages/oneclient_core/src/state.rs index 2f829022..2e5ed101 100644 --- a/packages/oneclient_core/src/state.rs +++ b/packages/oneclient_core/src/state.rs @@ -40,6 +40,7 @@ pub struct LauncherState { pub images: ImageCacheStore, pub games: crate::game::GameProcessManager, pub discord: DiscordRpc, + pub provisioning: Mutex<()>, } impl LauncherState { @@ -75,6 +76,7 @@ impl LauncherState { images: ImageCacheStore::new(), games: crate::game::GameProcessManager::new(), discord, + provisioning: Mutex::new(()), }); STATE From c881bf858df10f988203b74ec3ca1ee0ae0b15ee Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:37:08 +0200 Subject: [PATCH 5/6] feat(ui): Missing Java Popup --- .../src/components/java_install_manager.rs | 278 ++++++++++++++++++ .../src/components/java_prompt.rs | 138 +++++++++ packages/oneclient_app/src/components/mod.rs | 4 + .../oneclient_app/src/layout/root_layout.rs | 4 +- packages/oneclient_app/src/notifications.rs | 9 - .../src/view/app/settings/java.rs | 213 +------------- packages/oneclient_core/src/game/launch.rs | 1 + packages/oneclient_core/src/java/manager.rs | 3 + .../oneclient_core/src/notification/data.rs | 4 + .../src/notification/grouped.rs | 110 ++++--- 10 files changed, 516 insertions(+), 248 deletions(-) create mode 100644 packages/oneclient_app/src/components/java_install_manager.rs create mode 100644 packages/oneclient_app/src/components/java_prompt.rs diff --git a/packages/oneclient_app/src/components/java_install_manager.rs b/packages/oneclient_app/src/components/java_install_manager.rs new file mode 100644 index 00000000..4a3c98c2 --- /dev/null +++ b/packages/oneclient_app/src/components/java_install_manager.rs @@ -0,0 +1,278 @@ +use freya::prelude::*; +use oneclient_core::java::{AvailableJava, JavaVendor}; + +use crate::components::{Button, Dropdown, Icon, IconType, OverlayPopup, ScrollArea}; +use crate::hooks::{provider_versions, use_provider_versions}; +use crate::theme::colors; +use crate::ui::border_all_color; + +fn providers() -> Vec<(JavaVendor, &'static str)> { + vec![ + (JavaVendor::Zulu, "Azul Zulu"), + (JavaVendor::Adoptium, "Eclipse Temurin"), + (JavaVendor::Corretto, "Amazon Corretto"), + (JavaVendor::Liberica, "BellSoft Liberica"), + ] +} + +/// Reusable "Install Java" picker. Lists downloadable runtimes per provider and +/// calls `on_install` with the chosen `(vendor, major)`. When `suggested` is set, +/// the matching major is pulled to the top and highlighted. +#[derive(PartialEq)] +pub struct JavaInstallManager { + suggested: Option, + on_install: Option>, + on_close: Option>, +} + +impl Default for JavaInstallManager { + fn default() -> Self { + Self::new() + } +} + +impl JavaInstallManager { + pub fn new() -> Self { + Self { + suggested: None, + on_install: None, + on_close: None, + } + } + + pub fn suggested(mut self, major: Option) -> Self { + self.suggested = major; + self + } + + pub fn on_install(mut self, handler: impl Into>) -> Self { + self.on_install = Some(handler.into()); + self + } + + pub fn on_close(mut self, handler: impl Into>) -> Self { + self.on_close = Some(handler.into()); + self + } +} + +impl Component for JavaInstallManager { + fn render(&self) -> impl IntoElement { + let providers = providers(); + let mut selected = use_state(|| 0usize); + let suggested = self.suggested; + + let idx = (*selected.read()).min(providers.len() - 1); + let (vendor, _) = providers[idx].clone(); + + let versions_query = use_provider_versions(vendor.clone()); + let (mut versions, loading) = provider_versions(&versions_query); + + // Pull the suggested major to the top so it reads as the default choice. + if let Some(major) = suggested { + versions.sort_by_key(|v| v.major != major); + } + + let provider_labels: Vec = + providers.iter().map(|(_, name)| name.to_string()).collect(); + let current_label = provider_labels[idx].clone(); + + let inner: Element = if loading { + status_text("Loading available versions...") + } else if versions.is_empty() { + status_text("No versions available from this provider.") + } else { + let mut area = ScrollArea::new() + .width(Size::fill()) + .height(Size::fill()) + .spacing(8.); + for available in versions { + let highlight = suggested == Some(available.major); + area = area.child( + VersionRow { + vendor: vendor.clone(), + available, + suggested: highlight, + on_install: self.on_install.clone(), + } + .into_element(), + ); + } + area.into_element() + }; + + let list = rect() + .width(Size::fill()) + .height(Size::px(280.)) + .child(inner) + .into_element(); + + let close_scrim = self.on_close.clone(); + let close_button = self.on_close.clone(); + + OverlayPopup::new() + .on_close(move |()| { + if let Some(handler) = &close_scrim { + handler.call(()); + } + }) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(440.)) + .max_width(Size::window_percent(90.)) + .spacing(16.) + .padding(Gaps::new_all(20.)) + .corner_radius(CornerRadius::new_all(16.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .child( + label() + .text("Install Java") + .font_size(18.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(6.) + .child( + label() + .text("Provider") + .font_size(11.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ) + .child( + Dropdown::new(current_label, provider_labels) + .width(Size::fill()) + .height(Size::px(34.)) + .on_select(move |i: usize| selected.set(i)), + ), + ) + .child(list) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .child( + Button::new() + .ghost() + .on_press(move |_| { + if let Some(handler) = &close_button { + handler.call(()); + } + }) + .text("Close"), + ), + ), + ), + ) + .into_element() + } +} + +fn status_text(text: &'static str) -> Element { + rect() + .width(Size::fill()) + .padding(Gaps::new_symmetric(16., 4.)) + .child( + label() + .text(text) + .font_size(13.) + .color(colors::fg_secondary()), + ) + .into_element() +} + +#[derive(PartialEq)] +struct VersionRow { + vendor: JavaVendor, + available: AvailableJava, + suggested: bool, + on_install: Option>, +} + +impl Component for VersionRow { + fn render(&self) -> impl IntoElement { + let vendor = self.vendor.clone(); + let major = self.available.major; + let version = self.available.package.name.clone(); + let on_install = self.on_install.clone(); + + let mut title_row = rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .child( + label() + .text(format!("Java {major}")) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ); + + if self.suggested { + title_row = title_row.child( + rect() + .padding(Gaps::new_symmetric(2., 8.)) + .corner_radius(CornerRadius::new_all(6.)) + .background(colors::brand()) + .child( + label() + .text("Suggested") + .font_size(10.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ), + ); + } + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(12.) + .padding(Gaps::new_symmetric(10., 12.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(colors::component_bg()) + .maybe(self.suggested, |el| { + el.border(border_all_color(1., colors::brand())) + }) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child(title_row) + .child( + label() + .text(version) + .font_size(11.) + .max_lines(1) + .color(colors::fg_secondary()), + ), + ) + .child( + Button::new() + .primary() + .small() + .on_press(move |_| { + if let Some(handler) = &on_install { + handler.call((vendor.clone(), major)); + } + }) + .child(Icon::new(IconType::Download01).size(13.)) + .text("Install"), + ) + } +} diff --git a/packages/oneclient_app/src/components/java_prompt.rs b/packages/oneclient_app/src/components/java_prompt.rs new file mode 100644 index 00000000..eb4cb2eb --- /dev/null +++ b/packages/oneclient_app/src/components/java_prompt.rs @@ -0,0 +1,138 @@ +use freya::prelude::*; +use oneclient_core::java::JavaVendor; +use oneclient_core::notification::{PromptKind, UserChoice}; + +use crate::components::{Button, Icon, IconType, JavaInstallManager, OverlayPopup}; +use crate::hooks::{use_dispatch, use_notifications_snapshot}; +use crate::theme::colors; +use crate::ui::border_all_color; + +const CARD_BG: Color = Color::from_rgb(26, 34, 41); + +#[derive(PartialEq)] +pub struct JavaPromptOverlay; + +impl Component for JavaPromptOverlay { + fn render(&self) -> impl IntoElement { + // Hooks must run unconditionally, before any early return, so the hook + // count stays stable whether or not a Java prompt is pending. + let snapshot = use_notifications_snapshot(); + let dispatch = use_dispatch(); + let mut show_manager = use_state(|| false); + + let Some(prompt) = snapshot.pending_prompt.clone() else { + return rect().into_element(); + }; + + let PromptKind::JavaInstall { major } = prompt.kind else { + return rect().into_element(); + }; + + // Download opens the install manager with the required major highlighted. + if *show_manager.read() { + let install = dispatch.clone(); + return JavaInstallManager::new() + .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 }); + show_manager.set(false); + }) + .on_close(move |()| show_manager.set(false)) + .into_element(); + } + + let close = dispatch.clone(); + let cancel = dispatch.clone(); + let folder = dispatch.clone(); + + let pick_folder = move |_| { + let dispatch = folder.clone(); + spawn(async move { + if let Some(handle) = rfd::AsyncFileDialog::new() + .set_title(format!("Select a Java {major} installation folder")) + .pick_folder() + .await + { + dispatch.answer_prompt(UserChoice::Folder(handle.path().to_path_buf())); + } + }); + }; + + OverlayPopup::new() + .on_close(move |_| close.answer_prompt(UserChoice::Cancel)) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(440.)) + .max_width(Size::window_percent(90.)) + .spacing(14.) + .padding(Gaps::new_all(20.)) + .corner_radius(CornerRadius::new_all(14.)) + .background(CARD_BG) + .border(border_all_color(1., colors::component_border())) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(10.) + .child( + Icon::new(IconType::FolderDownload) + .size(20.) + .color(colors::brand()), + ) + .child( + label() + .text(prompt.title.clone()) + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ), + ) + .child( + label() + .text(prompt.question.clone()) + .font_size(12.) + .max_lines(4) + .width(Size::fill()) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .secondary() + .on_press(move |_| { + cancel.answer_prompt(UserChoice::Cancel) + }) + .text("Cancel"), + ) + .child( + Button::new() + .secondary() + .on_press(pick_folder) + .child(Icon::new(IconType::Folder).size(14.)) + .text("Choose folder"), + ) + .child( + Button::new() + .primary() + .on_press(move |_| show_manager.set(true)) + .child(Icon::new(IconType::DownloadCloud02).size(14.)) + .text("Download"), + ), + ), + ), + ) + .into_element() + } +} diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index 1da21773..3078dc22 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -9,6 +9,8 @@ mod context_menu; mod dropdown; mod dynamic_art; mod icons; +mod java_install_manager; +mod java_prompt; mod link_confirm; mod local_image; mod log_viewer; @@ -42,6 +44,8 @@ pub use context_menu::ContextMenu; pub use dropdown::Dropdown; pub use dynamic_art::DynamicArt; pub use icons::{Icon, IconType}; +pub use java_install_manager::JavaInstallManager; +pub use java_prompt::JavaPromptOverlay; pub use link_confirm::ConfirmLinkOverlay; pub use local_image::LocalImage; pub use log_viewer::LogViewer; diff --git a/packages/oneclient_app/src/layout/root_layout.rs b/packages/oneclient_app/src/layout/root_layout.rs index c8d6df51..9f886d72 100644 --- a/packages/oneclient_app/src/layout/root_layout.rs +++ b/packages/oneclient_app/src/layout/root_layout.rs @@ -2,7 +2,8 @@ use freya::prelude::*; use freya::router::*; use crate::components::{ - AccountSwitcher, ClusterUpdatePopup, NotificationCenter, StatusBar, Toasts, UpdatePromptOverlay, + AccountSwitcher, ClusterUpdatePopup, JavaPromptOverlay, NotificationCenter, StatusBar, Toasts, + UpdatePromptOverlay, }; use crate::routes::Route; use crate::theme; @@ -31,6 +32,7 @@ impl Component for RootLayout { .child(AccountSwitcher) .child(Toasts) .child(UpdatePromptOverlay) + .child(JavaPromptOverlay) .child(ClusterUpdatePopup) .child(StatusBar) } diff --git a/packages/oneclient_app/src/notifications.rs b/packages/oneclient_app/src/notifications.rs index 2915d5b0..1fffaa5d 100644 --- a/packages/oneclient_app/src/notifications.rs +++ b/packages/oneclient_app/src/notifications.rs @@ -312,15 +312,6 @@ impl NotificationState { kind, reply_tx, } => { - let entry_id = self.push_inbox( - inbox, - title.clone(), - question.clone(), - NotificationLevel::Info, - None, - false, - ); - self.push_ephemeral_toast(entry_id, MESSAGE_TOAST_TTL); let pending_prompt = Some(PendingPrompt { title: title.clone(), question: question.clone(), diff --git a/packages/oneclient_app/src/view/app/settings/java.rs b/packages/oneclient_app/src/view/app/settings/java.rs index bc8cda85..10ab58d6 100644 --- a/packages/oneclient_app/src/view/app/settings/java.rs +++ b/packages/oneclient_app/src/view/app/settings/java.rs @@ -1,33 +1,22 @@ use freya::prelude::*; -use oneclient_core::java::{AvailableJava, JavaRuntime, JavaVendor}; +use oneclient_core::java::{JavaRuntime, JavaVendor}; use super::settings_page; -use crate::components::{Button, Dropdown, Icon, IconType, OverlayPopup, ScrollArea}; -use crate::hooks::{ - BridgeDispatch, java_runtimes, provider_versions, use_dispatch, use_java_runtimes, - use_provider_versions, -}; +use crate::components::{Button, Icon, IconType, JavaInstallManager, ScrollArea}; +use crate::hooks::{BridgeDispatch, java_runtimes, use_dispatch, use_java_runtimes}; use crate::theme::colors; use crate::ui::border_all_color; use crate::view::app::settings::section_header; -fn providers() -> Vec<(JavaVendor, &'static str)> { - vec![ - (JavaVendor::Zulu, "Azul Zulu"), - (JavaVendor::Adoptium, "Eclipse Temurin"), - (JavaVendor::Corretto, "Amazon Corretto"), - (JavaVendor::Liberica, "BellSoft Liberica"), - ] -} - #[derive(PartialEq)] pub struct SettingsJava; impl Component for SettingsJava { fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); let runtimes_query = use_java_runtimes(); let runtimes = java_runtimes(&runtimes_query); - let show_manager = use_state(|| false); + let mut show_manager = use_state(|| false); let mut shell = settings_page() .child(section_header("ADD RUNTIME")) @@ -36,7 +25,15 @@ impl Component for SettingsJava { .child(runtimes_table(runtimes)); if *show_manager.read() { - shell = shell.child(InstallManagerPopup { show_manager }.into_element()); + shell = shell.child( + JavaInstallManager::new() + .on_install(move |(vendor, major): (JavaVendor, u32)| { + dispatch.install_java_runtime(vendor, major); + show_manager.set(false); + }) + .on_close(move |()| show_manager.set(false)) + .into_element(), + ); } shell.into_element() @@ -249,185 +246,3 @@ fn remove_button(dispatch: BridgeDispatch, path: String) -> impl IntoElement { ) .into_element() } - -#[derive(PartialEq)] -struct InstallManagerPopup { - show_manager: State, -} - -impl Component for InstallManagerPopup { - fn render(&self) -> impl IntoElement { - let mut show_manager = self.show_manager; - let providers = providers(); - let mut selected = use_state(|| 0usize); - - let idx = (*selected.read()).min(providers.len() - 1); - let (vendor, _) = providers[idx].clone(); - - let versions_query = use_provider_versions(vendor.clone()); - let (versions, loading) = provider_versions(&versions_query); - - let provider_labels: Vec = - providers.iter().map(|(_, name)| name.to_string()).collect(); - let current_label = provider_labels[idx].clone(); - - let inner: Element = if loading { - status_text("Loading available versions...") - } else if versions.is_empty() { - status_text("No versions available from this provider.") - } else { - let mut area = ScrollArea::new() - .width(Size::fill()) - .height(Size::fill()) - .spacing(8.); - for available in versions { - area = area.child( - VersionRow { - vendor: vendor.clone(), - available, - show_manager, - } - .into_element(), - ); - } - area.into_element() - }; - - let list = rect() - .width(Size::fill()) - .height(Size::px(280.)) - .child(inner) - .into_element(); - - OverlayPopup::new() - .on_close(move |()| show_manager.set(false)) - .child( - rect() - .width(Size::window_percent(100.)) - .height(Size::window_percent(100.)) - .center() - .child( - rect() - .vertical() - .width(Size::px(440.)) - .max_width(Size::window_percent(90.)) - .spacing(16.) - .padding(Gaps::new_all(20.)) - .corner_radius(CornerRadius::new_all(16.)) - .background(colors::page_elevated()) - .border(border_all_color(1., colors::component_border())) - .child( - label() - .text("Install Java") - .font_size(18.) - .font_weight(FontWeight::SEMI_BOLD) - .color(colors::fg_primary()), - ) - .child( - rect() - .vertical() - .width(Size::fill()) - .spacing(6.) - .child( - label() - .text("Provider") - .font_size(11.) - .font_weight(FontWeight::MEDIUM) - .color(colors::fg_secondary()), - ) - .child( - Dropdown::new(current_label, provider_labels) - .width(Size::fill()) - .height(Size::px(34.)) - .on_select(move |i: usize| selected.set(i)), - ), - ) - .child(list) - .child( - rect() - .horizontal() - .width(Size::fill()) - .main_align(Alignment::End) - .child( - Button::new() - .ghost() - .on_press(move |_| show_manager.set(false)) - .text("Close"), - ), - ), - ), - ) - .into_element() - } -} - -fn status_text(text: &'static str) -> Element { - rect() - .width(Size::fill()) - .padding(Gaps::new_symmetric(16., 4.)) - .child( - label() - .text(text) - .font_size(13.) - .color(colors::fg_secondary()), - ) - .into_element() -} - -#[derive(PartialEq)] -struct VersionRow { - vendor: JavaVendor, - available: AvailableJava, - show_manager: State, -} - -impl Component for VersionRow { - fn render(&self) -> impl IntoElement { - let dispatch = use_dispatch(); - let mut show_manager = self.show_manager; - let vendor = self.vendor.clone(); - let major = self.available.major; - let version = self.available.package.name.clone(); - - rect() - .horizontal() - .width(Size::fill()) - .content(Content::Flex) - .cross_align(Alignment::Center) - .spacing(12.) - .padding(Gaps::new_symmetric(10., 12.)) - .corner_radius(CornerRadius::new_all(10.)) - .background(colors::component_bg()) - .child( - rect() - .vertical() - .width(Size::flex(1.0)) - .spacing(2.) - .child( - label() - .text(format!("Java {major}")) - .font_size(14.) - .font_weight(FontWeight::MEDIUM) - .color(colors::fg_primary()), - ) - .child( - label() - .text(version) - .font_size(11.) - .max_lines(1) - .color(colors::fg_secondary()), - ), - ) - .child( - Button::new() - .primary() - .small() - .on_press(move |_| { - dispatch.install_java_runtime(vendor.clone(), major); - show_manager.set(false); - }) - .child(Icon::new(IconType::Download01).size(13.)) - .text("Install"), - ) - } -} diff --git a/packages/oneclient_core/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs index b9245167..9e4b3667 100644 --- a/packages/oneclient_core/src/game/launch.rs +++ b/packages/oneclient_core/src/game/launch.rs @@ -171,6 +171,7 @@ pub async fn launch_cluster( .as_ref() .map(|v| v.major_version) .ok_or(GameError::MissingJavaVersion)?; + JavaManager::prepare_java(state, major, search_for_java).await? }; diff --git a/packages/oneclient_core/src/java/manager.rs b/packages/oneclient_core/src/java/manager.rs index 41d6da59..5845a7f8 100644 --- a/packages/oneclient_core/src/java/manager.rs +++ b/packages/oneclient_core/src/java/manager.rs @@ -68,6 +68,9 @@ impl JavaManager { match services.notifier.prompt_java_install(major).await? { UserChoice::Accept => download_and_register(major, services).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()), } } diff --git a/packages/oneclient_core/src/notification/data.rs b/packages/oneclient_core/src/notification/data.rs index dc8c5efb..80025d7b 100644 --- a/packages/oneclient_core/src/notification/data.rs +++ b/packages/oneclient_core/src/notification/data.rs @@ -179,6 +179,10 @@ pub enum UserChoice { Accept, Cancel, Folder(PathBuf), + Install { + vendor: crate::java::JavaVendor, + major: u32, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] diff --git a/packages/oneclient_core/src/notification/grouped.rs b/packages/oneclient_core/src/notification/grouped.rs index d9d2e9b9..4c2e1523 100644 --- a/packages/oneclient_core/src/notification/grouped.rs +++ b/packages/oneclient_core/src/notification/grouped.rs @@ -1,15 +1,33 @@ use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use uuid::Uuid; -use super::data::{GroupedProgressEvent, TaskCategory}; use super::NotificationService; +use super::data::{GroupedProgressEvent, TaskCategory}; -#[derive(Clone)] struct SessionInner { session_id: Uuid, notifier: NotificationService, + ended: AtomicBool, +} + +impl SessionInner { + fn end(&self) { + if self.ended.swap(true, Ordering::Relaxed) { + return; + } + + self.notifier.send_grouped(GroupedProgressEvent::End { + session_id: self.session_id, + }); + } +} + +impl Drop for SessionInner { + fn drop(&mut self) { + self.end(); + } } struct ChildInner { @@ -42,6 +60,7 @@ impl GroupedProgressSession { inner: Arc::new(SessionInner { session_id, notifier: notifier.clone(), + ended: AtomicBool::new(false), }), } } @@ -56,12 +75,14 @@ impl GroupedProgressSession { if count == 0 { return; } - self.inner.notifier.send_grouped(GroupedProgressEvent::Expect { - session_id: self.inner.session_id, - category, - count, - total, - }); + self.inner + .notifier + .send_grouped(GroupedProgressEvent::Expect { + session_id: self.inner.session_id, + category, + count, + total, + }); } pub fn child( @@ -74,13 +95,15 @@ impl GroupedProgressSession { let label = label.into(); let total = total.max(1); - self.inner.notifier.send_grouped(GroupedProgressEvent::AddChild { - session_id: self.inner.session_id, - child_id, - label: label.clone(), - total, - category, - }); + self.inner + .notifier + .send_grouped(GroupedProgressEvent::AddChild { + session_id: self.inner.session_id, + child_id, + label: label.clone(), + total, + category, + }); GroupedProgressChild { inner: Arc::new(ChildInner { @@ -114,11 +137,7 @@ impl GroupedProgressSession { } pub fn finish(self) { - self.inner - .notifier - .send_grouped(GroupedProgressEvent::End { - session_id: self.inner.session_id, - }); + self.inner.end(); } } @@ -128,18 +147,28 @@ impl GroupedProgressChild { } pub fn set_phase(&self, phase: super::data::TaskPhase) { - if self.inner.finished.load(std::sync::atomic::Ordering::Relaxed) { + if self + .inner + .finished + .load(std::sync::atomic::Ordering::Relaxed) + { return; } - self.inner.notifier.send_grouped(GroupedProgressEvent::SetChildPhase { - session_id: self.inner.session_id, - child_id: self.inner.child_id, - phase, - }); + self.inner + .notifier + .send_grouped(GroupedProgressEvent::SetChildPhase { + session_id: self.inner.session_id, + child_id: self.inner.child_id, + phase, + }); } pub fn set_progress(&self, current: u64, total: Option) { - if self.inner.finished.load(std::sync::atomic::Ordering::Relaxed) { + if self + .inner + .finished + .load(std::sync::atomic::Ordering::Relaxed) + { return; } @@ -152,12 +181,14 @@ impl GroupedProgressChild { if total > stored { self.inner.total.store(total, Ordering::Relaxed); } - self.inner.notifier.send_grouped(GroupedProgressEvent::UpdateChild { - session_id: self.inner.session_id, - child_id: self.inner.child_id, - current, - total, - }); + self.inner + .notifier + .send_grouped(GroupedProgressEvent::UpdateChild { + session_id: self.inner.session_id, + child_id: self.inner.child_id, + current, + total, + }); } pub fn finish(&self) { @@ -169,10 +200,12 @@ impl GroupedProgressChild { return; } - self.inner.notifier.send_grouped(GroupedProgressEvent::FinishChild { - session_id: self.inner.session_id, - child_id: self.inner.child_id, - }); + self.inner + .notifier + .send_grouped(GroupedProgressEvent::FinishChild { + session_id: self.inner.session_id, + child_id: self.inner.child_id, + }); } } @@ -181,4 +214,3 @@ impl Drop for GroupedProgressChild { self.finish(); } } - From 964cfa61b37f9163973163934f3108be2ef22fa7 Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Sun, 19 Jul 2026 17:37:04 +0200 Subject: [PATCH 6/6] feat(ui): Add SQL debug console in debug page --- packages/oneclient_app/src/view/app/debug.rs | 171 ++++++++++++++++++ packages/oneclient_db/src/console.rs | 177 +++++++++++++++++++ packages/oneclient_db/src/lib.rs | 1 + 3 files changed, 349 insertions(+) create mode 100644 packages/oneclient_db/src/console.rs diff --git a/packages/oneclient_app/src/view/app/debug.rs b/packages/oneclient_app/src/view/app/debug.rs index 13e6b66b..a7482976 100644 --- a/packages/oneclient_app/src/view/app/debug.rs +++ b/packages/oneclient_app/src/view/app/debug.rs @@ -1,11 +1,16 @@ use freya::prelude::*; use freya::router::RouterContext; +use oneclient_core::LauncherState; +use oneclient_db::console::{ConsoleQueryResult, run_console_query}; use crate::components::{Button, Icon, IconType, TextInput, toggle}; use crate::hooks::use_dispatch; use crate::notifications::{ClusterUpdateSummary, NotificationAction, NotificationActionKind}; use crate::routes::Route; use crate::theme::colors; +use crate::ui::border_all_color; + +type SqlResult = Option>; #[derive(PartialEq)] pub struct Debug; @@ -63,6 +68,8 @@ impl Component for Debug { vec![ClusterUpdateSimulator.into_element()], )) .child(divider()) + .child(section("SQL Console", vec![SqlConsole.into_element()])) + .child(divider()) .child(section( "Other", vec![action_row(vec![ @@ -254,6 +261,170 @@ impl Component for ClusterUpdateSimulator { } } +#[derive(PartialEq)] +struct SqlConsole; + +impl Component for SqlConsole { + fn render(&self) -> impl IntoElement { + let query = use_state(|| "SELECT * FROM clusters;".to_string()); + let result = use_state(|| None::>); + let running = use_state(|| false); + + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(12.) + .child( + rect().width(Size::flex(1.0)).child( + TextInput::new(query) + .placeholder("SELECT * FROM …") + .on_submit(move |_| run_sql(query, result, running)), + ), + ) + .child( + Button::new() + .primary() + .child(Icon::new(IconType::Terminal).size(16.)) + .text(if *running.read() { "Running…" } else { "Run" }) + .on_press(move |_| run_sql(query, result, running)), + ), + ) + .child(sql_result(&result.read())) + .into_element() + } +} + +fn run_sql(query: State, mut result: State, mut running: State) { + if *running.read() { + return; + } + let sql = query.read().clone(); + if sql.trim().is_empty() { + return; + } + + running.set(true); + spawn(async move { + let res = match LauncherState::get() { + Ok(state) => run_console_query(&state.services.db, &sql) + .await + .map_err(|e| e.to_string()), + Err(e) => Err(e.to_string()), + }; + result.set(Some(res)); + running.set(false); + }); +} + +fn sql_result(state: &SqlResult) -> Element { + match state { + None => label() + .text("No query run yet.") + .font_size(13.) + .color(colors::fg_secondary()) + .into_element(), + Some(Err(err)) => rect() + .width(Size::fill()) + .padding(Gaps::new_symmetric(10., 14.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::danger())) + .child( + label() + .text(err.clone()) + .font_size(13.) + .color(colors::danger()), + ) + .into_element(), + Some(Ok(res)) if !res.is_select => label() + .text(format!("OK — {} row(s) affected.", res.rows_affected)) + .font_size(13.) + .color(colors::success()) + .into_element(), + Some(Ok(res)) => sql_table(res), + } +} + +fn sql_table(res: &ConsoleQueryResult) -> Element { + if res.columns.is_empty() { + return label() + .text("0 rows returned.") + .font_size(13.) + .color(colors::fg_secondary()) + .into_element(); + } + + let header = table_row(&res.columns, true); + let mut table = rect() + .vertical() + .width(Size::fill()) + .corner_radius(CornerRadius::new_all(8.)) + .border(border_all_color(1., colors::component_border())) + .child(header); + + for row in &res.rows { + table = table.child(table_row(row, false)); + } + + rect() + .vertical() + .width(Size::fill()) + .spacing(6.) + .child( + label() + .text(format!("{} row(s)", res.rows.len())) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .child(table) + .into_element() +} + +fn table_row(cells: &[String], header: bool) -> Element { + let mut row = rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .background(if header { + colors::page_elevated() + } else { + colors::component_bg() + }); + + for cell in cells { + row = row.child( + rect() + .width(Size::flex(1.0)) + .padding(Gaps::new_symmetric(6., 10.)) + .border(border_all_color(0.5, colors::component_border())) + .child( + label() + .text(cell.clone()) + .font_size(12.) + .max_lines(1) + .font_weight(if header { + FontWeight::SEMI_BOLD + } else { + FontWeight::NORMAL + }) + .color(if header { + colors::fg_primary() + } else { + colors::fg_secondary() + }), + ), + ); + } + + row.into_element() +} + fn split_csv(raw: &str) -> Vec { raw.split(',') .map(|s| s.trim().to_string()) diff --git a/packages/oneclient_db/src/console.rs b/packages/oneclient_db/src/console.rs new file mode 100644 index 00000000..5fb23824 --- /dev/null +++ b/packages/oneclient_db/src/console.rs @@ -0,0 +1,177 @@ +use std::collections::HashSet; +use std::sync::Mutex; + +use sqlx::sqlite::SqliteRow; +use sqlx::{Column, Row, ValueRef}; + +use crate::{DbError, DbPool}; + +/// sqlx runs SQLite on a worker thread and requires the SQL string to be +/// `'static`. Intern each distinct console query so repeated runs of the same +/// text don't leak, while still accepting arbitrary runtime strings. +fn intern(sql: &str) -> &'static str { + static CACHE: Mutex>> = Mutex::new(None); + + let mut guard = CACHE.lock().unwrap(); + let cache = guard.get_or_insert_with(HashSet::new); + + if let Some(existing) = cache.get(sql) { + return existing; + } + + let leaked: &'static str = Box::leak(sql.to_owned().into_boxed_str()); + cache.insert(leaked); + leaked +} + +/// Outcome of a console query, stringified for display. +#[derive(Debug, Clone, Default)] +pub struct ConsoleQueryResult { + /// Column headers (empty when the statement returned no rows). + pub columns: Vec, + /// Row cells, already converted to display strings. + pub rows: Vec>, + /// Rows affected by a non-select statement. + pub rows_affected: u64, + /// Whether the statement returned a result set. + pub is_select: bool, +} + +/// Execute an arbitrary SQL statement. +/// +/// Statements that start with a row-returning keyword (`SELECT`, `WITH`, +/// `PRAGMA`, `EXPLAIN`) are fetched into a table; everything else is executed +/// and reports `rows_affected`. +pub async fn run_console_query(pool: &DbPool, sql: &str) -> Result { + let sql = intern(sql.trim().trim_end_matches(';').trim()); + + if returns_rows(sql) { + let fetched = sqlx::query(sql).persistent(false).fetch_all(pool).await?; + Ok(rows_to_result(&fetched)) + } else { + let done = sqlx::query(sql).persistent(false).execute(pool).await?; + Ok(ConsoleQueryResult { + rows_affected: done.rows_affected(), + is_select: false, + ..Default::default() + }) + } +} + +fn returns_rows(sql: &str) -> bool { + let head = sql + .trim_start() + .split(|c: char| c.is_whitespace() || c == '(') + .next() + .unwrap_or("") + .to_ascii_uppercase(); + + matches!(head.as_str(), "SELECT" | "WITH" | "PRAGMA" | "EXPLAIN") +} + +fn rows_to_result(fetched: &[SqliteRow]) -> ConsoleQueryResult { + let columns = fetched + .first() + .map(|row| { + row.columns() + .iter() + .map(|c| c.name().to_string()) + .collect::>() + }) + .unwrap_or_default(); + + let rows = fetched + .iter() + .map(|row| { + (0..row.columns().len()) + .map(|idx| cell_to_string(row, idx)) + .collect() + }) + .collect(); + + ConsoleQueryResult { + columns, + rows, + rows_affected: 0, + is_select: true, + } +} + +/// SQLite is dynamically typed, so probe the raw value and decode accordingly. +fn cell_to_string(row: &SqliteRow, idx: usize) -> String { + match row.try_get_raw(idx) { + Ok(raw) if raw.is_null() => return "NULL".to_string(), + Ok(_) => {} + Err(_) => return "".to_string(), + } + + if let Ok(v) = row.try_get::(idx) { + return v; + } + if let Ok(v) = row.try_get::(idx) { + return v.to_string(); + } + if let Ok(v) = row.try_get::(idx) { + return v.to_string(); + } + if let Ok(v) = row.try_get::, _>(idx) { + return format!("", v.len()); + } + + "".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::sqlite::SqlitePoolOptions; + + async fn pool() -> DbPool { + let pool = SqlitePoolOptions::new() + .connect("sqlite::memory:") + .await + .unwrap(); + sqlx::query("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT, ratio REAL)") + .execute(&pool) + .await + .unwrap(); + pool + } + + #[tokio::test] + async fn select_returns_columns_and_rows() { + let pool = pool().await; + sqlx::query("INSERT INTO t (name, ratio) VALUES ('a', 1.5), (NULL, 2.0)") + .execute(&pool) + .await + .unwrap(); + + let res = run_console_query(&pool, "SELECT id, name, ratio FROM t ORDER BY id;") + .await + .unwrap(); + + assert!(res.is_select); + assert_eq!(res.columns, vec!["id", "name", "ratio"]); + assert_eq!(res.rows.len(), 2); + assert_eq!(res.rows[0], vec!["1", "a", "1.5"]); + assert_eq!(res.rows[1][1], "NULL"); + } + + #[tokio::test] + async fn non_select_reports_rows_affected() { + let pool = pool().await; + let res = run_console_query(&pool, "INSERT INTO t (name) VALUES ('x'), ('y')") + .await + .unwrap(); + + assert!(!res.is_select); + assert_eq!(res.rows_affected, 2); + assert!(res.rows.is_empty()); + } + + #[tokio::test] + async fn invalid_sql_errors() { + let pool = pool().await; + assert!(run_console_query(&pool, "SELECT * FROM nope").await.is_err()); + } +} diff --git a/packages/oneclient_db/src/lib.rs b/packages/oneclient_db/src/lib.rs index c5546a81..67096aa7 100644 --- a/packages/oneclient_db/src/lib.rs +++ b/packages/oneclient_db/src/lib.rs @@ -1,6 +1,7 @@ mod error; mod pool; +pub mod console; pub mod dao; pub mod models;