Merge branch 'main' of https://github.com/Polyfrost/OneLauncher
This commit is contained in:
commit
9b74c051e7
28 changed files with 1332 additions and 329 deletions
|
|
@ -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,
|
||||
|
|
|
|||
278
packages/oneclient_app/src/components/java_install_manager.rs
Normal file
278
packages/oneclient_app/src/components/java_install_manager.rs
Normal file
|
|
@ -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<u32>,
|
||||
on_install: Option<EventHandler<(JavaVendor, u32)>>,
|
||||
on_close: Option<EventHandler<()>>,
|
||||
}
|
||||
|
||||
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<u32>) -> Self {
|
||||
self.suggested = major;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_install(mut self, handler: impl Into<EventHandler<(JavaVendor, u32)>>) -> Self {
|
||||
self.on_install = Some(handler.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_close(mut self, handler: impl Into<EventHandler<()>>) -> 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<String> =
|
||||
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<EventHandler<(JavaVendor, u32)>>,
|
||||
}
|
||||
|
||||
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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
138
packages/oneclient_app/src/components/java_prompt.rs
Normal file
138
packages/oneclient_app/src/components/java_prompt.rs
Normal file
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<bool>) -> 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) => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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<NotificationAction>,
|
||||
pub tasks: Vec<TaskView>,
|
||||
/// Live transfer stats for grouped downloads (bytes/sec, seconds remaining).
|
||||
pub transfer: Option<TransferStats>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct TransferStats {
|
||||
pub speed_bps: f64,
|
||||
pub eta_secs: Option<u64>,
|
||||
}
|
||||
|
||||
/// 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<ClusterUpdateSummary>,
|
||||
}
|
||||
|
||||
/// 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<Uuid, TaskView>,
|
||||
seen: u64,
|
||||
done: u64,
|
||||
done_units: u64,
|
||||
current: Option<String>,
|
||||
children: HashMap<Uuid, ChildRec>,
|
||||
done_units: HashMap<TaskCategory, u64>,
|
||||
done_count: HashMap<TaskCategory, u64>,
|
||||
seen_count: HashMap<TaskCategory, u64>,
|
||||
/// Expected counts/bytes announced up-front so totals don't climb mid-download.
|
||||
reserved_count: HashMap<TaskCategory, u64>,
|
||||
reserved_units: HashMap<TaskCategory, u64>,
|
||||
/// (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<TaskView> {
|
||||
let mut tasks: Vec<TaskView> = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -231,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(),
|
||||
|
|
@ -359,6 +431,7 @@ impl NotificationState {
|
|||
created_at: Instant::now(),
|
||||
actions: Vec::new(),
|
||||
tasks: Vec::new(),
|
||||
transfer: None,
|
||||
},
|
||||
);
|
||||
id
|
||||
|
|
@ -392,6 +465,7 @@ impl NotificationState {
|
|||
created_at: Instant::now(),
|
||||
actions,
|
||||
tasks: Vec::new(),
|
||||
transfer: None,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -506,22 +580,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 +630,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 +642,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 +652,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 +671,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::<u64>();
|
||||
let total: u64 = group.done_units + group.children.values().map(|t| t.total).sum::<u64>();
|
||||
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::<u64>().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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Result<ConsoleQueryResult, String>>;
|
||||
|
||||
#[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::<Result<ConsoleQueryResult, String>>);
|
||||
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<String>, mut result: State<SqlResult>, mut running: State<bool>) {
|
||||
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<String> {
|
||||
raw.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
|
|
|
|||
|
|
@ -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<bool>,
|
||||
}
|
||||
|
||||
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<String> =
|
||||
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<bool>,
|
||||
}
|
||||
|
||||
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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -48,6 +48,37 @@ impl ClusterManager {
|
|||
pub async fn create(
|
||||
state: &LauncherState,
|
||||
options: CreateClusterOptions,
|
||||
) -> LauncherResult<Cluster> {
|
||||
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<Option<Cluster>> {
|
||||
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<Cluster> {
|
||||
let name = Self::sanitize_name(&options.name);
|
||||
if name.is_empty() {
|
||||
|
|
|
|||
|
|
@ -39,13 +39,13 @@ pub async fn ensure_from_bundles(state: &LauncherState) -> LauncherResult<Vec<Cl
|
|||
|
||||
let mc_version = group.mc_version.clone();
|
||||
let name = format!("{mc_version} {loader}");
|
||||
match ClusterManager::create(
|
||||
match ClusterManager::create_provisioned(
|
||||
state,
|
||||
CreateClusterOptions::new(name, mc_version.clone(), loader),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(cluster) => {
|
||||
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<Vec<Cl
|
|||
);
|
||||
created.push(cluster);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
mc_version = %mc_version,
|
||||
|
|
@ -100,13 +101,13 @@ pub async fn ensure_from_versions(state: &LauncherState) -> LauncherResult<Vec<C
|
|||
}
|
||||
|
||||
let name = format!("{mc_version} {loader}");
|
||||
match ClusterManager::create(
|
||||
match ClusterManager::create_provisioned(
|
||||
state,
|
||||
CreateClusterOptions::new(name, mc_version.clone(), loader),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(cluster) => {
|
||||
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<Vec<C
|
|||
);
|
||||
created.push(cluster);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
mc_version = %mc_version,
|
||||
|
|
|
|||
|
|
@ -87,11 +87,13 @@ fn spawn_notification_handler(mut rx: mpsc::UnboundedReceiver<Notification>) {
|
|||
},
|
||||
);
|
||||
}
|
||||
GroupedProgressEvent::Expect { .. } => {}
|
||||
GroupedProgressEvent::AddChild {
|
||||
session_id,
|
||||
child_id,
|
||||
label,
|
||||
total,
|
||||
..
|
||||
} => {
|
||||
let Some(session) = grouped_sessions.get_mut(&session_id) else {
|
||||
continue;
|
||||
|
|
@ -240,6 +242,7 @@ pub async fn ephemeral_state() -> LauncherResult<Arc<LauncherState>> {
|
|||
images: crate::images::ImageCacheStore::new(),
|
||||
games: crate::game::GameProcessManager::new(),
|
||||
discord: crate::discord::DiscordRpc::spawn(false),
|
||||
provisioning: tokio::sync::Mutex::new(()),
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
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<String>,
|
||||
category: TaskCategory,
|
||||
expected_size: u64,
|
||||
url: &str,
|
||||
expected_sha1: &str,
|
||||
) -> LauncherResult<Vec<u8>> {
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -138,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)]
|
||||
|
|
|
|||
|
|
@ -1,22 +1,41 @@
|
|||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::data::GroupedProgressEvent;
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ChildInner {
|
||||
session_id: Uuid,
|
||||
child_id: Uuid,
|
||||
notifier: NotificationService,
|
||||
label: String,
|
||||
total: AtomicU64,
|
||||
finished: Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
|
||||
|
|
@ -41,6 +60,7 @@ impl GroupedProgressSession {
|
|||
inner: Arc::new(SessionInner {
|
||||
session_id,
|
||||
notifier: notifier.clone(),
|
||||
ended: AtomicBool::new(false),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
|
@ -49,17 +69,41 @@ impl GroupedProgressSession {
|
|||
self.inner.session_id
|
||||
}
|
||||
|
||||
pub fn child(&self, label: impl Into<String>, 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<String>,
|
||||
total: u64,
|
||||
category: TaskCategory,
|
||||
) -> GroupedProgressChild {
|
||||
let child_id = Uuid::new_v4();
|
||||
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,
|
||||
});
|
||||
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 {
|
||||
|
|
@ -67,6 +111,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 +121,14 @@ impl GroupedProgressSession {
|
|||
&self,
|
||||
label: impl Into<String>,
|
||||
total: u64,
|
||||
category: TaskCategory,
|
||||
f: F,
|
||||
) -> Result<T, E>
|
||||
where
|
||||
F: FnOnce(GroupedProgressChild) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<T, E>>,
|
||||
{
|
||||
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();
|
||||
|
|
@ -91,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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -105,28 +147,48 @@ 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<u64>) {
|
||||
if self.inner.finished.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
if self
|
||||
.inner
|
||||
.finished
|
||||
.load(std::sync::atomic::Ordering::Relaxed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let total = total.unwrap_or(1).max(1);
|
||||
self.inner.notifier.send_grouped(GroupedProgressEvent::UpdateChild {
|
||||
session_id: self.inner.session_id,
|
||||
child_id: self.inner.child_id,
|
||||
current,
|
||||
total,
|
||||
});
|
||||
// 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,
|
||||
current,
|
||||
total,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn finish(&self) {
|
||||
|
|
@ -138,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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -150,4 +214,3 @@ impl Drop for GroupedProgressChild {
|
|||
self.finish();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<ProjectDirs> = OnceLock::new();
|
||||
static LAUNCHER_DIR_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
|
|
|||
|
|
@ -40,6 +40,11 @@ impl RecoveryReport {
|
|||
pub async fn reconstruct_from_disk(state: &LauncherState) -> LauncherResult<RecoveryReport> {
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
177
packages/oneclient_db/src/console.rs
Normal file
177
packages/oneclient_db/src/console.rs
Normal file
|
|
@ -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<Option<HashSet<&'static str>>> = 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<String>,
|
||||
/// Row cells, already converted to display strings.
|
||||
pub rows: Vec<Vec<String>>,
|
||||
/// 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<ConsoleQueryResult, DbError> {
|
||||
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::<Vec<_>>()
|
||||
})
|
||||
.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 "<err>".to_string(),
|
||||
}
|
||||
|
||||
if let Ok(v) = row.try_get::<String, _>(idx) {
|
||||
return v;
|
||||
}
|
||||
if let Ok(v) = row.try_get::<i64, _>(idx) {
|
||||
return v.to_string();
|
||||
}
|
||||
if let Ok(v) = row.try_get::<f64, _>(idx) {
|
||||
return v.to_string();
|
||||
}
|
||||
if let Ok(v) = row.try_get::<Vec<u8>, _>(idx) {
|
||||
return format!("<blob {} bytes>", 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
mod error;
|
||||
mod pool;
|
||||
|
||||
pub mod console;
|
||||
pub mod dao;
|
||||
pub mod models;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue