This commit is contained in:
Julian Chang 2026-07-14 16:42:17 -04:00
commit e5cd435ecf
No known key found for this signature in database
123 changed files with 4319 additions and 1667 deletions

17
Cargo.lock generated
View file

@ -3116,6 +3116,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "junction"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "160f2eade097f30263b548aae5deb12ad349c909baa710fa24b92c9090b2e006"
dependencies = [
"scopeguard",
"windows-sys 0.61.2",
]
[[package]]
name = "keyboard-types"
version = "0.8.3"
@ -4133,6 +4143,7 @@ dependencies = [
"oneclient_core",
"oneclient_db",
"oneclient_macro",
"open",
"polyio",
"regex",
"rfd",
@ -4142,7 +4153,6 @@ dependencies = [
"tracing",
"tracing-subscriber",
"uuid",
"windows-sys 0.60.2",
]
[[package]]
@ -4216,9 +4226,9 @@ dependencies = [
[[package]]
name = "open"
version = "5.3.6"
version = "5.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd8d3b65c44123a56e0133d2cd06ce4361bd3ca99d41198b2f25e3c3db9b8b4a"
checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5"
dependencies = [
"is-wsl",
"libc",
@ -4455,6 +4465,7 @@ dependencies = [
"dunce",
"futures-lite",
"futures-util",
"junction",
"sanitize-filename",
"serde",
"serde_json",

View file

@ -95,6 +95,7 @@ sanitize-filename = { version = "=0.6.0" }
regex = { version = "=1.12.3" }
parking_lot = { version = "=0.12.5" }
dunce = { version = "=1.0.5" }
junction = { version = "=2.0.0" }
bytes = { version = "=1.11.1" }
indicatif = { version = "=0.18.4", features = [ "tokio" ]}
sysinfo = { version = "=0.39.5" }

View file

@ -34,8 +34,8 @@ regex.workspace = true
cargo-packager-updater = "=0.2.3"
# file dialog
rfd = "=0.17.2"
open = "=5.4.0"
# clipboard image copy + decode for it
arboard.workspace = true
@ -47,9 +47,6 @@ freya.workspace = true
[target.'cfg(target_os = "macos")'.dependencies]
mimalloc = "=0.1.52"
[target.'cfg(target_os = "windows")'.dependencies]
windows-sys = { version = "0.60", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_Foundation"] }
# Build then package (per OS):
# cargo build --release -p oneclient_app
# cargo packager --release -p oneclient_app --formats <nsis|app,dmg|deb,appimage>

View file

@ -6,7 +6,7 @@ use oneclient_core::packages::{ContentType, ProviderId};
use oneclient_core::settings::{GameSettingsProfile, LauncherSettings, ProfileUpdate};
use oneclient_db::models::ClusterId;
use crate::notifications::NotificationSpec;
use crate::notifications::{ClusterUpdateSummary, NotificationSpec};
#[derive(Debug)]
pub enum BridgeCommand {
@ -54,12 +54,16 @@ pub enum BridgeCommand {
ToggleNotificationCenter,
CloseNotificationCenter,
ToggleAccountSwitcher,
CloseAccountSwitcher,
ClearNotificationInbox,
DismissToast(u64),
BumpToast(u64),
MarkNotificationRead(u64),
DismissNotification(u64),
AnswerPrompt(UserChoice),
OpenClusterUpdate(ClusterUpdateSummary),
CloseClusterUpdate,
SendNotification { spec: NotificationSpec },
SendTestProgress {
current: u64,

View file

@ -1,4 +1,4 @@
use oneclient_core::notification::{NotificationLevel, Notification, NotificationService};
use oneclient_core::notification::{Notification, NotificationLevel, NotificationService};
use oneclient_core::packages::PackageStore;
use oneclient_core::settings::store::{
self, create_profile_from_global, create_settings_profile, delete_named_profile,
@ -7,8 +7,10 @@ use oneclient_core::settings::store::{
use oneclient_core::{ClusterManager, LauncherState};
use tokio::sync::{mpsc, watch};
use crate::components::IconType;
use crate::notifications::{
InboxEntry, MESSAGE_TOAST_TTL, NotificationState, PendingPrompt, PendingPromptView,
ClusterUpdateSummary, InboxEntry, MESSAGE_TOAST_TTL, NotificationAction, NotificationActionKind,
NotificationSpec, NotificationState, PendingPrompt, PendingPromptView,
};
use super::commands::BridgeCommand;
@ -147,6 +149,10 @@ impl CoreBridgeRuntime {
apply_game_failed(snapshots, cluster_id, message);
immediate = true;
}
Notification::MicrosoftLoginStatus(status) => {
snapshots.microsoft_login = status;
immediate = true;
}
other => {
let (notif, _timers, prompt) = engine.dispatch(inbox, other);
snapshots.notifications = notif;
@ -402,18 +408,24 @@ impl CoreBridgeRuntime {
bump_clusters_generation(snapshots);
}
BridgeCommand::ImportLauncher { source, folder_name, target } => {
BridgeCommand::ImportLauncher {
source,
folder_name,
target,
} => {
let state = LauncherState::get()?;
tokio::spawn(async move {
let notifier = state.services.notifier.clone();
match oneclient_core::import_migration_game_dir(source, &folder_name, target).await {
match oneclient_core::import_migration_game_dir(source, &folder_name, target)
.await
{
Ok(()) => {
notifier.send_info(
"Import complete",
&format!("Copied your files from {}.", source.display_name()),
);
notifier.invalidate_clusters();
notifier.invalidate_clusters();
}
Err(err) => {
notifier.send_error("Import failed", &err.to_string());
@ -432,11 +444,7 @@ impl CoreBridgeRuntime {
)
.await
{
Ok(runtime) => {
notifier.send_info(
"Java installed",
&format!("Java {} ({})", runtime.major, runtime.vendor),
);
Ok(_) => {
notifier.invalidate_java();
}
Err(err) => {
@ -482,6 +490,12 @@ impl CoreBridgeRuntime {
BridgeCommand::CloseNotificationCenter => {
*notification_center_open = false;
}
BridgeCommand::ToggleAccountSwitcher => {
snapshots.account_switcher_open = !snapshots.account_switcher_open;
}
BridgeCommand::CloseAccountSwitcher => {
snapshots.account_switcher_open = false;
}
BridgeCommand::ClearNotificationInbox => {
inbox.clear();
notification_engine.clear_inbox();
@ -503,6 +517,13 @@ impl CoreBridgeRuntime {
let _ = reply_tx.send(choice);
}
}
BridgeCommand::OpenClusterUpdate(summary) => {
notification_engine.open_cluster_update(summary);
*notification_center_open = false;
}
BridgeCommand::CloseClusterUpdate => {
notification_engine.close_cluster_update();
}
BridgeCommand::SendNotification { spec } => {
notification_engine.push_custom(inbox, spec);
}
@ -519,14 +540,11 @@ impl CoreBridgeRuntime {
let state = LauncherState::get()?;
tokio::spawn(async move {
let notifier = state.services.notifier.clone();
let account = match oneclient_core::auth::get_default_account().await {
Ok(Some(account)) => Some(account),
Ok(None) => oneclient_core::auth::list_accounts()
.await
.ok()
.and_then(|accounts| accounts.into_iter().next()),
// Renews a lapsed token before launching
let account = match oneclient_core::auth::default_account_for_launch().await {
Ok(account) => account,
Err(err) => {
notifier.game_failed(cluster_id, err.to_string());
notifier.game_failed(cluster_id, format!("{err:#}"));
return;
}
};
@ -591,7 +609,8 @@ impl CoreBridgeRuntime {
version_id,
} => {
let state = LauncherState::get()?;
match install_package(&state, provider, &project_id, &version_id, cluster_id).await {
match install_package(&state, provider, &project_id, &version_id, cluster_id).await
{
Ok(()) => {
state
.services
@ -624,24 +643,36 @@ impl CoreBridgeRuntime {
}
BridgeCommand::ApplyBundleUpdates { cluster_id } => {
let state = LauncherState::get()?;
oneclient_core::apply_bundle_updates(
let result = oneclient_core::apply_bundle_updates(
cluster_id,
state.bundles.as_ref(),
&state.services,
)
.await?;
bump_clusters_generation(snapshots);
if let Some(spec) =
cluster_update_notification(cluster_id, &result, &state.services).await
{
notification_engine.push_custom(inbox, spec);
}
}
BridgeCommand::SyncBundles => {
let state = LauncherState::get()?;
state.bundles.sync(&state.services).await?;
oneclient_core::clusters::ensure_from_bundles(&state).await?;
oneclient_core::bundles::sync_all_cluster_bundles(
let changed = oneclient_core::bundles::sync_all_cluster_bundles(
state.bundles.as_ref(),
&state.services,
)
.await;
bump_clusters_generation(snapshots);
for (cluster_id, result) in &changed {
if let Some(spec) =
cluster_update_notification(*cluster_id, result, &state.services).await
{
notification_engine.push_custom(inbox, spec);
}
}
}
}
@ -649,6 +680,63 @@ impl CoreBridgeRuntime {
}
}
async fn cluster_update_notification(
cluster_id: i64,
result: &oneclient_core::ApplyBundleUpdatesResult,
services: &oneclient_core::LauncherServices,
) -> Option<NotificationSpec> {
let updated: Vec<String> = result
.updates_applied
.iter()
.map(|u| u.new_file.display_name())
.collect();
let added: Vec<String> = result
.additions_applied
.iter()
.map(|a| a.new_file.display_name())
.collect();
let removed: Vec<String> = result
.removals_applied
.iter()
.map(|r| r.package_id.clone())
.collect();
if updated.is_empty() && added.is_empty() && removed.is_empty() {
return None;
}
let cluster_name = PackageStore::get_cluster(cluster_id, services)
.await
.map(|c| c.name)
.unwrap_or_else(|_| "Cluster".to_string());
let summary = ClusterUpdateSummary {
cluster_id,
cluster_name: cluster_name.clone(),
updated,
added,
removed,
};
let total = summary.total();
let body = format!(
"{total} package{} changed in {cluster_name}",
if total == 1 { "" } else { "s" }
);
Some(NotificationSpec {
title: "Cluster updated".to_string(),
body,
level: NotificationLevel::Info,
icon: Some(IconType::DownloadCloud02),
progress: None,
actions: vec![NotificationAction {
label: "View changes".to_string(),
kind: NotificationActionKind::OpenClusterUpdate(summary),
}],
})
}
fn publish(tx: &watch::Sender<BridgeSnapshot>, snapshots: &BridgeSnapshot) {
let _ = tx.send(snapshots.clone());
}
@ -706,21 +794,35 @@ async fn install_package(
cluster_id: i64,
) -> anyhow::Result<()> {
let provider_impl = state.services.packages.get(provider)?;
let project = provider_impl.get_project(project_id, &state.services).await?;
let project = provider_impl
.get_project(project_id, &state.services)
.await?;
let version = provider_impl
.get_version(project_id, version_id, &state.services)
.await?;
PackageStore::install_to_cluster(
let session = oneclient_core::notification::GroupedProgressSession::start(
&state.services.notifier,
format!("Installing {}", project.name),
);
let size = version.primary_file().map(|f| f.size).unwrap_or(0);
let child = session.child(project.name.clone(), size);
let result = PackageStore::install_to_cluster(
provider,
&project,
&version,
cluster_id,
false,
false,
Some(&child),
&state.services,
)
.await?;
.await;
child.finish();
session.finish();
result?;
Ok(())
}
@ -736,7 +838,10 @@ fn apply_game_stage(snapshots: &mut BridgeSnapshot, cluster_id: i64, stage: Laun
}
fn apply_game_failed(snapshots: &mut BridgeSnapshot, cluster_id: i64, message: String) {
snapshots.game.stages.insert(cluster_id, LaunchStage::Exited);
snapshots
.game
.stages
.insert(cluster_id, LaunchStage::Exited);
snapshots.game.error = Some(message);
}

View file

@ -1,7 +1,7 @@
use std::collections::HashMap;
use std::sync::Arc;
use oneclient_core::notification::LaunchStage;
use oneclient_core::notification::{LaunchStage, MicrosoftLoginStatus};
use oneclient_core::settings::LauncherSettings;
#[derive(Clone, Debug, Default, PartialEq)]
@ -98,4 +98,6 @@ pub struct BridgeSnapshot {
pub java: JavaSnapshot,
pub notifications: crate::notifications::NotificationSnapshot,
pub game: GameSnapshot,
pub account_switcher_open: bool,
pub microsoft_login: Option<MicrosoftLoginStatus>,
}

View file

@ -0,0 +1,257 @@
use freya::{
animation::{AnimNum, Ease, OnCreation, use_animation},
prelude::*,
router::RouterContext,
};
use oneclient_core::auth::MinecraftAccount;
use crate::{
Route,
components::{Avatar, Button, Icon, IconType, OverlayPopup},
hooks::{
SetDefaultAccountKeys, try_accounts, try_default_account, use_account_switcher_open,
use_accounts, use_current_account, use_dispatch, use_set_default_account,
},
theme::colors,
};
#[derive(PartialEq)]
pub struct AccountSwitcher;
impl Component for AccountSwitcher {
fn render(&self) -> impl IntoElement {
let open = use_account_switcher_open();
let dispatch = use_dispatch();
if !open {
return rect().into_element();
}
OverlayPopup::new()
.position(Position::new_global().top(72.).right(40.))
.on_close(move |()| dispatch.close_account_switcher())
.child(AccountPanel)
.into_element()
}
}
#[derive(PartialEq)]
struct AccountPanel;
impl Component for AccountPanel {
fn render(&self) -> impl IntoElement {
let accounts_query = use_accounts();
let current_query = use_current_account();
let default_account = try_default_account(&current_query);
let default_id = default_account.as_ref().map(|account| account.id);
let accounts = try_accounts(&accounts_query).unwrap_or_default();
let intro = use_animation(|conf| {
conf.on_creation(OnCreation::Run);
AnimNum::new(0., 1.).time(200).ease(Ease::Out)
});
let progress = intro.read().value();
// active account first, then the rest
let mut ordered: Vec<MinecraftAccount> = Vec::new();
if let Some(account) = default_account.clone() {
ordered.push(account);
}
for account in accounts {
if Some(account.id) != default_id {
ordered.push(account);
}
}
let mut rows = rect().vertical().width(Size::fill()).spacing(4.);
if ordered.is_empty() {
rows = rows.child(
label()
.text("No accounts yet")
.font_size(13.)
.color(colors::fg_secondary()),
);
} else {
for account in ordered {
let id = account.id;
rows = rows.child(
AccountRow::new(account, Some(id) == default_id)
.key(id)
.into_element(),
);
}
}
rect()
.vertical()
.width(Size::px(300.))
.padding(Gaps::new_all(12.))
.spacing(8.)
.opacity(progress)
.margin(Gaps::new((1.0 - progress) * -8.0, 0., 0., 0.))
.background(colors::page_elevated().with_a(220))
.blur(12.)
.corner_radius(CornerRadius::new_all(12.))
.border(
Border::new()
.fill(colors::component_border())
.width(1.)
.alignment(BorderAlignment::Inner),
)
.shadow(Shadow::from((
0.,
8.,
32.,
0.,
Color::from_argb(120, 0, 0, 0),
)))
.child(
label()
.text("Accounts")
.font_size(18.)
.font_weight(FontWeight::MEDIUM)
.a11y_role(AccessibilityRole::TitleBar),
)
.child(divider())
.child(rows)
.child(divider())
.child(Footer)
}
}
struct AccountRow {
account: MinecraftAccount,
active: bool,
key: DiffKey,
}
impl PartialEq for AccountRow {
fn eq(&self, other: &Self) -> bool {
self.account.id == other.account.id
&& self.account.username == other.account.username
&& self.active == other.active
}
}
impl KeyExt for AccountRow {
fn write_key(&mut self) -> &mut DiffKey {
&mut self.key
}
}
impl AccountRow {
fn new(account: MinecraftAccount, active: bool) -> Self {
Self {
account,
active,
key: DiffKey::None,
}
}
}
impl Component for AccountRow {
fn render(&self) -> impl IntoElement {
let id = self.account.id;
let username = self.account.username.clone();
let active = self.active;
let dispatch = use_dispatch();
let set_default = use_set_default_account();
let mut hovered = use_state(|| false);
let switch = move |_| {
if !active {
set_default.mutate(SetDefaultAccountKeys { id: Some(id) });
}
dispatch.close_account_switcher();
};
rect()
.horizontal()
.width(Size::fill())
.content(Content::Flex)
.cross_align(Alignment::Center)
.spacing(10.)
.padding(Gaps::new_all(8.))
.corner_radius(CornerRadius::new_all(8.))
.maybe(*hovered.read() && !active, |el| {
el.background(colors::ghost_overlay_hover())
})
.on_pointer_enter(move |_| {
hovered.set(true);
if !active {
Cursor::set(CursorIcon::Pointer);
}
})
.on_pointer_leave(move |_| {
hovered.set(false);
Cursor::set(CursorIcon::default());
})
.on_press(switch)
.child(
Avatar::new(id.to_string())
.width(Size::px(32.))
.height(Size::px(32.)),
)
.child(
rect()
.vertical()
.width(Size::flex(1.0))
.child(
label()
.text(username)
.font_size(14.)
.font_weight(FontWeight::MEDIUM)
.max_lines(1)
.color(colors::fg_primary()),
)
.maybe(active, |el| {
el.child(
label()
.text("Active")
.font_size(11.)
.color(colors::fg_secondary()),
)
}),
)
.maybe(active, |el| {
el.child(
Icon::new(IconType::CheckCircle)
.size(18.)
.color(colors::success()),
)
})
}
}
fn divider() -> impl IntoElement {
rect()
.width(Size::fill())
.height(Size::px(1.))
.background(colors::component_border())
}
#[derive(PartialEq)]
struct Footer;
impl Component for Footer {
fn render(&self) -> impl IntoElement {
let dispatch = use_dispatch();
let open_accounts = move |_| {
dispatch.close_account_switcher();
let _ = RouterContext::get().push(Route::Accounts {});
};
Button::new()
.ghost()
.small()
.on_press(open_accounts)
.child(
Icon::new(IconType::Users01)
.size(16.)
.color(colors::fg_primary()),
)
.text("Manage accounts")
}
}

View file

@ -0,0 +1,267 @@
use freya::{prelude::*, router::RouterContext};
use crate::components::{Button, Icon, IconType, OverlayPopup, ScrollArea};
use crate::hooks::{use_dispatch, use_notifications_snapshot};
use crate::notifications::ClusterUpdateSummary;
use crate::routes::Route;
use crate::theme::colors;
use crate::ui::border_all_color;
const CARD_BG: Color = Color::from_rgb(26, 34, 41);
const DIALOG_W: f32 = 680.;
const DIALOG_H: f32 = 400.;
const BANNER_W: f32 = 300.;
#[derive(PartialEq)]
pub struct ClusterUpdatePopup;
impl Component for ClusterUpdatePopup {
fn render(&self) -> impl IntoElement {
let snapshot = use_notifications_snapshot();
let dispatch = use_dispatch();
let Some(summary) = snapshot.cluster_update.clone() else {
return rect().into_element();
};
let close = dispatch.clone();
OverlayPopup::new()
.on_close(move |_| close.close_cluster_update())
.child(
rect()
.width(Size::window_percent(100.))
.height(Size::window_percent(100.))
.center()
.child(dialog(&summary, dispatch)),
)
.into_element()
}
}
fn dialog(summary: &ClusterUpdateSummary, dispatch: crate::BridgeDispatch) -> impl IntoElement {
rect()
.horizontal()
.width(Size::px(DIALOG_W))
.height(Size::px(DIALOG_H))
.max_width(Size::window_percent(95.))
.overflow(Overflow::Clip)
.corner_radius(CornerRadius::new_all(16.))
.background(CARD_BG)
.border(border_all_color(1., colors::component_border()))
.shadow(Shadow::from((0., 18., 52., 0., Color::from_argb(150, 0, 0, 0))))
.child(banner(summary))
.child(content(summary, dispatch))
}
fn banner(summary: &ClusterUpdateSummary) -> impl IntoElement {
rect()
.vertical()
.width(Size::px(BANNER_W))
.height(Size::fill())
.overflow(Overflow::Clip)
.background(colors::brand().with_a(30))
.main_align(Alignment::SpaceBetween)
// watermark
.child(
rect()
.width(Size::fill())
.height(Size::flex(1.0))
.center()
.child(
Icon::new(IconType::DownloadCloud02)
.size(150.)
.color(colors::brand().with_a(34)),
),
)
// seated title block
.child(
rect()
.vertical()
.width(Size::fill())
.spacing(10.)
.padding(Gaps::new(24., 20., 20., 20.))
.background(CARD_BG.with_a(170))
.child(
rect()
.center()
.padding(Gaps::new_symmetric(4., 10.))
.corner_radius(CornerRadius::new_all(999.))
.background(colors::brand())
.child(
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(5.)
.child(Icon::new(IconType::RefreshCw01).size(12.).color(Color::WHITE))
.child(
label()
.text("Updated")
.font_size(11.)
.font_weight(FontWeight::SEMI_BOLD)
.color(Color::WHITE),
),
),
)
.child(
label()
.text(summary.cluster_name.clone())
.font_size(23.)
.font_weight(FontWeight::BOLD)
.max_lines(2)
.color(colors::fg_primary()),
),
)
}
fn content(summary: &ClusterUpdateSummary, dispatch: crate::BridgeDispatch) -> impl IntoElement {
let dismiss = dispatch.clone();
let cluster_id = summary.cluster_id;
let open = move |_| {
dispatch.close_cluster_update();
let _ = RouterContext::get().push(Route::ClusterOverview { cluster_id });
};
let total = summary.total();
let mut sections = ScrollArea::new()
.width(Size::fill())
.height(Size::flex(1.0))
.spacing(10.);
if !summary.updated.is_empty() {
sections = sections.child(change_section(
IconType::RefreshCw01,
"Updated",
colors::brand(),
&summary.updated,
));
}
if !summary.added.is_empty() {
sections = sections.child(change_section(
IconType::Plus,
"Added",
colors::success(),
&summary.added,
));
}
if !summary.removed.is_empty() {
sections = sections.child(change_section(
IconType::Trash01,
"Removed",
colors::danger(),
&summary.removed,
));
}
rect()
.vertical()
.width(Size::flex(1.0))
.height(Size::fill())
.content(Content::Flex)
.padding(Gaps::new_all(22.))
.spacing(14.)
.child(
rect()
.vertical()
.width(Size::fill())
.spacing(3.)
.child(
label()
.text("Changes applied")
.font_size(17.)
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_primary()),
)
.child(
label()
.text(format!(
"{total} change{} synced to this cluster.",
if total == 1 { "" } else { "s" }
))
.font_size(12.5)
.max_lines(2)
.color(colors::fg_secondary()),
),
)
.child(sections)
.child(
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.main_align(Alignment::SpaceBetween)
.spacing(8.)
.child(
Button::new()
.ghost()
.on_press(move |_| dismiss.close_cluster_update())
.text("Dismiss"),
)
.child(
Button::new()
.primary()
.on_press(open)
.text("Open cluster")
.child(Icon::new(IconType::ArrowRight).size(15.)),
),
)
}
fn change_section(
icon: IconType,
heading: &str,
accent: Color,
items: &[String],
) -> impl IntoElement {
let mut list = rect().vertical().width(Size::fill()).spacing(1.);
for item in items {
list = list.child(
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(9.)
.padding(Gaps::new_symmetric(4., 4.))
.child(
rect()
.width(Size::px(5.))
.height(Size::px(5.))
.corner_radius(CornerRadius::new_all(3.))
.background(accent),
)
.child(
label()
.text(item.clone())
.font_size(12.5)
.max_lines(1)
.width(Size::flex(1.0))
.color(colors::fg_primary()),
),
);
}
rect()
.vertical()
.width(Size::fill())
.spacing(5.)
.padding(Gaps::new(9., 11., 11., 11.))
.corner_radius(CornerRadius::new_all(10.))
.background(colors::page_elevated().with_a(140))
.border(border_all_color(1., colors::component_border().with_a(120)))
.child(
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(7.)
.child(Icon::new(icon).size(14.).color(accent))
.child(
label()
.text(format!("{heading} · {}", items.len()))
.font_size(12.)
.font_weight(FontWeight::SEMI_BOLD)
.color(accent),
),
)
.child(list)
}

View file

@ -59,18 +59,53 @@ impl Component for Dropdown {
let mut open = use_state(|| false);
let mut hovering = use_state(|| false);
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let mut button_area = use_state(|| None::<Area>);
let mut list_size = use_state(|| None::<Size2D>);
let selected = self.selected.clone();
let options = self.options.clone();
let on_select = self.on_select.clone();
let list_width = self.width.clone();
let is_open = open();
if !is_open && list_size().is_some() {
let _ = list_size.take();
}
let trigger_bg = if hovering() {
colors::ghost_overlay_hover()
} else {
colors::ghost_overlay()
};
let visible = options.len().min(MAX_VISIBLE_OPTIONS);
let list_h = visible as f32 * OPTION_HEIGHT
+ visible.saturating_sub(1) as f32 * OPTION_SPACING;
let offset_y = match (button_area(), list_size()) {
(Some(button), Some(list)) => {
let root_height = Platform::get().root_size.peek().height;
let space_below = root_height - button.max_y();
let space_above = button.min_y();
if list.height > space_below && list.height <= space_above {
-(button.height() + list.height + 8.)
} else {
0.
}
}
_ => 0.,
};
let list_width = button_area()
.map(|b| Size::px(b.width()))
.unwrap_or_else(|| self.width.clone());
let on_global_pointer_press = move |_: Event<PointerEventData>| {
open.set_if_modified(false);
};
rect()
.width(self.width.clone())
.child(
@ -83,9 +118,19 @@ impl Component for Dropdown {
.padding(Gaps::new_symmetric(0., 8.))
.corner_radius(CornerRadius::new_all(6.))
.background(trigger_bg)
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.maybe(focus().is_focused(), |el| {
el.border(border_all_color(1., colors::brand()))
})
.on_pointer_enter(move |_| hovering.set(true))
.on_pointer_leave(move |_| hovering.set(false))
.on_press(move |e: Event<PressEventData>| {
.on_global_pointer_press(on_global_pointer_press)
.on_sized(move |e: Event<SizedEventData>| {
button_area.set_if_modified(Some(e.area));
})
.on_all_press(move |e: Event<PressEventData>| {
e.prevent_default();
e.stop_propagation();
open.toggle();
@ -103,44 +148,41 @@ impl Component for Dropdown {
),
)
.maybe_child(is_open.then(|| {
let visible = options.len().min(MAX_VISIBLE_OPTIONS);
let list_h = visible as f32 * OPTION_HEIGHT
+ visible.saturating_sub(1) as f32 * OPTION_SPACING;
rect().height(Size::px(0.)).width(list_width)
.layer(Layer::Overlay)
.child(
rect()
.layer(Layer::RelativeOverlay(10))
.position(Position::new_global().top(0.).left(0.))
.width(Size::window_percent(100.))
.height(Size::window_percent(100.))
.on_press(move |_| open.set(false)),
)
.child(
rect().width(Size::px(0.)).height(Size::px(0.)).child(
rect()
.width(Size::fill())
.layer(Layer::Overlay)
.width(list_width)
.margin(Gaps::new(4., 0., 0., 0.))
.layer(Layer::RelativeOverlay(12))
.padding(4.)
.corner_radius(CornerRadius::new_all(8.))
.border(border_all_color(1., colors::component_border()))
.background(colors::page_elevated())
.overflow(Overflow::Clip)
.offset_y(offset_y)
.content(Content::Fit)
.on_sized(move |e: Event<SizedEventData>| {
list_size.set_if_modified(Some(e.area.size));
})
.child(
ScrollArea::new()
.height(Size::px(list_h))
.spacing(OPTION_SPACING)
.children(options.into_iter().enumerate().map(|(idx, option)| {
let on_select = on_select.clone();
DropdownOption::new(option, move |_| {
if let Some(handler) = &on_select {
handler.call(idx);
}
open.set(false);
})
.into_element()
})),
rect()
.width(Size::fill())
.padding(4.)
.corner_radius(CornerRadius::new_all(8.))
.border(border_all_color(1., colors::component_border()))
.background(colors::page_elevated())
.overflow(Overflow::Clip)
.child(
ScrollArea::new()
.height(Size::px(list_h))
.spacing(OPTION_SPACING)
.children(options.into_iter().enumerate().map(
|(idx, option)| {
let on_select = on_select.clone();
DropdownOption::new(option, move |_| {
if let Some(handler) = &on_select {
handler.call(idx);
}
open.set(false);
})
.into_element()
},
)),
),
),
)
}))
@ -178,18 +220,24 @@ impl Component for DropdownOption {
fn render(&self) -> impl IntoElement {
let mut hovering = use_state(|| false);
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
rect()
.width(Size::fill())
.padding(Gaps::new_symmetric(6., 8.))
.corner_radius(CornerRadius::new_all(6.))
.background(if hovering() {
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.background(if hovering() || focus().is_focused() {
colors::ghost_overlay_hover()
} else {
Color::TRANSPARENT
})
.on_pointer_enter(move |_| hovering.set(true))
.on_pointer_leave(move |_| hovering.set(false))
.on_press(self.on_press.clone())
.on_all_press(self.on_press.clone())
.child(
label()
.text(self.text.clone())

View file

@ -46,19 +46,25 @@ impl Component for MajorVersionCard {
fn render(&self) -> impl IntoElement {
let mut hovering = use_state(|| false);
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let selected = self.selected;
let hovered = *hovering.read();
let focused = focus().is_focused();
let on_press = self.on_press.clone();
let opacity = if selected {
1.0
} else if hovered {
0.85
let opacity = if selected || hovered || focused {
if selected {
1.0
} else {
0.85
}
} else {
0.6
};
let border_color = if selected {
let border_color = if selected || focused {
colors::brand()
} else if hovered {
colors::component_border_hover()
@ -70,7 +76,10 @@ impl Component for MajorVersionCard {
.key(self.major)
.width(Size::flex(1.0))
.height(Size::px(CARD_HEIGHT_PX))
.on_press(move |e| on_press.call(e))
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.on_all_press(move |e| on_press.call(e))
.on_pointer_enter(move |_| {
*hovering.write() = true;
})

View file

@ -0,0 +1,334 @@
use freya::prelude::*;
use freya::query::{MutationCapability, MutationStateData, UseMutation};
use freya::text_edit::Clipboard;
use oneclient_core::auth::MicrosoftLoginSession;
use oneclient_core::notification::MicrosoftLoginStatus;
use crate::components::{Button, Icon, IconType, OverlayPopup};
use crate::hooks::{
CancelMicrosoftLoginKeys, login_code_already_handled, reset_login_code_dedup,
use_begin_microsoft_login, use_cancel_microsoft_login, use_finish_microsoft_login,
use_microsoft_login_status,
};
use crate::platform;
use crate::theme::colors;
use crate::ui::border_all_color;
#[derive(Clone)]
pub struct MicrosoftLogin {
begin: UseMutation<crate::hooks::BeginMicrosoftLoginMutation>,
cancel: UseMutation<crate::hooks::CancelMicrosoftLoginMutation>,
pending_login: State<Option<MicrosoftLoginSession>>,
cancelled: State<bool>,
status: Option<MicrosoftLoginStatus>,
locked: bool,
pub pending: bool,
pub error: Option<String>,
}
impl MicrosoftLogin {
pub fn start(&self) {
let mut cancelled = self.cancelled;
cancelled.set(false);
self.begin.mutate(());
}
fn cancel(&self) {
if self.locked {
return;
}
if let Some(session) = self.pending_login.peek().clone() {
self.cancel.mutate(CancelMicrosoftLoginKeys {
state_token: session.dedupe_key().to_string(),
});
}
reset_login_code_dedup();
let mut cancelled = self.cancelled;
let mut pending_login = self.pending_login;
cancelled.set(true);
pending_login.set(None);
}
pub fn popup(&self) -> Option<impl IntoElement> {
let handle = self.clone();
self.pending_login.read().clone().map(move |login| {
microsoft_dialog(
login,
handle.clone(),
handle.status.clone(),
handle.error.clone(),
)
})
}
}
pub fn use_microsoft_login() -> MicrosoftLogin {
let begin = use_begin_microsoft_login();
let finish = use_finish_microsoft_login();
let cancel = use_cancel_microsoft_login();
let mut pending_login = use_state(|| None::<MicrosoftLoginSession>);
let cancelled = use_state(|| false);
let status = use_microsoft_login_status();
use_side_effect(move || {
let session = match &*begin.read().state() {
MutationStateData::Settled {
res: Ok(session), ..
} => Some(session.clone()),
_ => None,
};
let Some(session) = session else { return };
if login_code_already_handled(session.dedupe_key()) {
return;
}
// open the browser immediately
platform::open_url(session.auth_url());
finish.mutate(session.clone());
pending_login.set(Some(session));
});
use_side_effect(move || {
if matches!(
&*finish.read().state(),
MutationStateData::Settled { res: Ok(_), .. }
) && pending_login.peek().is_some()
{
pending_login.set(None);
}
});
// is true once the flow has some status updates (just to ensure the user has seen the popup) and the flow is still in progress
let locked = status.as_ref().is_some_and(|s| s.current > 0);
let in_flight = begin.read().state().is_loading() || finish.read().state().is_loading();
let pending = in_flight && !*cancelled.read();
let error = mutation_err_text(&finish).or_else(|| mutation_err_text(&begin));
MicrosoftLogin {
begin,
cancel,
pending_login,
cancelled,
status,
locked,
pending,
error,
}
}
fn mutation_err_text<M>(mutation: &UseMutation<M>) -> Option<String>
where
M: MutationCapability,
M::Err: std::fmt::Display,
{
match &*mutation.read().state() {
MutationStateData::Settled { res: Err(err), .. } => Some(err.to_string()),
MutationStateData::Loading {
res: Some(Err(err)),
} => Some(err.to_string()),
_ => None,
}
}
fn microsoft_dialog(
login: MicrosoftLoginSession,
handle: MicrosoftLogin,
status: Option<MicrosoftLoginStatus>,
error: Option<String>,
) -> impl IntoElement {
let locked = handle.locked;
let close_handle = handle.clone();
let cancel_handle = handle;
OverlayPopup::new()
// Backdrop / Esc only dismiss while the flow is still cancellable, if its locked the popup can't be closed and cancelling aborts everything.
.on_close(move |()| close_handle.cancel())
.child(
rect()
.width(Size::window_percent(100.))
.height(Size::window_percent(100.))
.center()
.child(
rect()
.vertical()
.width(Size::px(420.))
.max_width(Size::window_percent(90.))
.cross_align(Alignment::Center)
.spacing(18.)
.padding(Gaps::new_all(28.))
.corner_radius(CornerRadius::new_all(16.))
.background(colors::page_elevated())
.border(border_all_color(1., colors::component_border()))
.child(
label()
.text("Sign in to Microsoft")
.font_size(18.)
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_primary()),
)
.child(browser_dialog_body(login.auth_url().to_string()))
.child(dialog_divider())
.child(device_code_dialog_body(
login.user_code().to_string(),
login.verification_uri().to_string(),
))
.child(status_row(status, error))
.maybe_child((!locked).then(|| {
Button::new()
.ghost()
.on_press(move |_| cancel_handle.cancel())
.text("Cancel")
.into_element()
})),
),
)
.into_element()
}
fn dialog_divider() -> impl IntoElement {
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.content(Content::Flex)
.spacing(10.)
.child(
rect()
.width(Size::flex(1.0))
.height(Size::px(1.))
.background(colors::component_border()),
)
.child(
label()
.text("or")
.font_size(11.)
.color(colors::fg_secondary()),
)
.child(
rect()
.width(Size::flex(1.0))
.height(Size::px(1.))
.background(colors::component_border()),
)
.into_element()
}
fn status_row(status: Option<MicrosoftLoginStatus>, error: Option<String>) -> impl IntoElement {
if let Some(error) = error {
return rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(6.)
.child(
Icon::new(IconType::AlertTriangle)
.size(13.)
.color(colors::danger()),
)
.child(label().text(error).font_size(12.).color(colors::danger()))
.into_element();
}
let text = match &status {
Some(status) if status.total > 0 && status.current > 0 => {
format!("{} ({}/{})", status.label, status.current, status.total)
}
_ => "Waiting for you to finish signing in...".to_string(),
};
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(6.)
.child(
Icon::new(IconType::Loading02)
.size(13.)
.color(colors::brand()),
)
.child(
label()
.text(text)
.font_size(12.)
.color(colors::fg_secondary()),
)
.into_element()
}
fn browser_dialog_body(auth_url: String) -> impl IntoElement {
rect()
.vertical()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(12.)
.child(
label()
.text("We opened the Microsoft sign-in page in your browser. Finish there and you'll be brought back automatically.")
.font_size(13.)
.color(colors::fg_secondary()),
)
.child(
Button::new()
.primary()
.on_press(move |_| platform::open_url(&auth_url))
.child(Icon::new(IconType::LinkExternal01).size(16.))
.text("Open in browser again"),
)
.into_element()
}
fn device_code_dialog_body(code: String, verification_uri: String) -> impl IntoElement {
let copy_code = code.clone();
rect()
.vertical()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(12.)
.child(
label()
.text("Or enter this code at the Microsoft sign-in page:")
.font_size(13.)
.color(colors::fg_secondary()),
)
.child(
rect()
.width(Size::fill())
.center()
.padding(Gaps::new_symmetric(20., 16.))
.corner_radius(CornerRadius::new_all(14.))
.background(colors::component_bg())
.border(border_all_color(1., colors::brand()))
.child(
label()
.text(code)
.font_size(48.)
.font_weight(FontWeight::BOLD)
.color(colors::fg_primary()),
),
)
.child(
rect()
.horizontal()
.width(Size::fill())
.main_align(Alignment::Center)
.spacing(8.)
.child(
Button::new()
.secondary()
.on_press(move |_| {
if let Err(err) = Clipboard::set(copy_code.clone()) {
tracing::warn!("clipboard copy failed: {err:?}");
}
})
.child(Icon::new(IconType::Copy01).size(16.))
.text("Copy code"),
)
.child(
Button::new()
.primary()
.on_press(move |_| platform::open_url(&verification_uri))
.child(Icon::new(IconType::LinkExternal01).size(16.))
.text("Open in browser"),
),
)
.into_element()
}

View file

@ -1,8 +1,10 @@
mod account_switcher;
mod active_cluster_panel;
mod avatar;
mod button;
mod charts;
mod cluster_landscape_art;
mod cluster_update_popup;
mod context_menu;
mod dropdown;
mod dynamic_art;
@ -11,6 +13,7 @@ mod link_confirm;
mod local_image;
mod log_viewer;
mod major_version_card;
mod microsoft_login;
mod navbar;
mod notifications;
mod overlay_popup;
@ -28,11 +31,13 @@ mod toasts;
mod toggle;
mod update_prompt;
pub use account_switcher::AccountSwitcher;
pub use active_cluster_panel::ActiveClusterPanel;
pub use avatar::Avatar;
pub use button::{Button, ButtonSize, ButtonVariant, link_button, open_folder_button};
pub use charts::{BarChart, ValueUnit};
pub use cluster_landscape_art::ClusterLandscapeArt;
pub use cluster_update_popup::ClusterUpdatePopup;
pub use context_menu::ContextMenu;
pub use dropdown::Dropdown;
pub use dynamic_art::DynamicArt;
@ -41,8 +46,9 @@ pub use link_confirm::ConfirmLinkOverlay;
pub use local_image::LocalImage;
pub use log_viewer::LogViewer;
pub use major_version_card::MajorVersionCard;
pub use navbar::{AppNavbar, OnboardingNavbar};
pub use microsoft_login::use_microsoft_login;
pub(crate) use navbar::window_controls;
pub use navbar::{AppNavbar, OnboardingNavbar};
pub use notifications::NotificationCenter;
pub use overlay_popup::OverlayPopup;
pub use package_row::{CardLayout, PackageEntry, PackageRow};
@ -50,8 +56,8 @@ pub use pagination::Pagination;
pub use player_model::PlayerModel;
pub use recents_row::RecentsRow;
pub use screenshot_viewer::ScreenshotViewer;
pub use scrollview::{ScrollArea, ScrollAreaCtx};
pub(crate) use scrollview::corrected_scroll;
pub use scrollview::{ScrollArea, ScrollAreaCtx};
pub use segmented_control::{Segment, SegmentedControl};
pub use status_bar::StatusBar;
pub use tab_bar::{TabBar, TabItem};

View file

@ -106,7 +106,7 @@ impl Component for NavLink {
Cursor::set(CursorIcon::default());
});
let color = if active || *hovering.peek() || focused.peek().is_focused() {
let color = if active || hovering() || focused().is_focused() {
theme::colors::fg_primary()
} else {
theme::colors::fg_secondary()
@ -114,7 +114,7 @@ impl Component for NavLink {
let underline_width = if active {
27.
} else if *hovering.peek() || focused.peek().is_focused() {
} else if hovering() || focused().is_focused() {
18.
} else {
0.
@ -170,7 +170,7 @@ struct NavbarRight;
impl Component for NavbarRight {
fn render(&self) -> impl IntoElement {
let current_account = use_current_account();
let dispatch: crate::BridgeDispatch = use_dispatch();
let dispatch = use_dispatch();
let unread = use_notifications_snapshot().unread_count();
let account_uuid = try_default_account(&current_account)
@ -178,8 +178,14 @@ impl Component for NavbarRight {
.unwrap_or_else(|| uuid::Uuid::nil().to_string());
// open notification center
let notif_dispatch = dispatch.clone();
let open_notifications = move |_| {
dispatch.toggle_notification_center();
notif_dispatch.toggle_notification_center();
};
// open account switcher
let open_account_switcher = move |_| {
dispatch.toggle_account_switcher();
};
// open settings
@ -204,11 +210,14 @@ impl Component for NavbarRight {
.on_press(open_settings),
)
.child(
super::navbar_button().padding(0.0).child(
Avatar::new(account_uuid)
.width(Size::px(24.))
.height(Size::px(24.)),
),
super::navbar_button()
.padding(0.0)
.on_press(open_account_switcher)
.child(
Avatar::new(account_uuid)
.width(Size::px(24.))
.height(Size::px(24.)),
),
)
.child(super::window_controls())
}

View file

@ -9,7 +9,7 @@ use crate::{
Route,
components::{Button, ButtonVariant, Icon, IconType, OverlayPopup, ScrollArea},
hooks::{use_dispatch, use_notifications_snapshot},
notifications::InboxEntry,
notifications::{InboxEntry, NotificationActionKind},
theme::colors,
};
@ -139,18 +139,13 @@ impl Component for NotifEntryRow {
let id = entry.id;
let dispatch = use_dispatch();
let enter_dispatch = dispatch.clone();
let body_dispatch = dispatch.clone();
let click_dismissable = entry.click_dismissable();
let has_actions = !entry.actions.is_empty();
let mut hovered = use_state(|| false);
let expanded = use_state(|| false);
let intro = use_animation(|conf| {
conf.on_creation(OnCreation::Run);
AnimNum::new(0., 1.).time(240).ease(Ease::Out)
});
let progress = intro.read().value();
let slide = (1.0 - progress) * 16.0;
let content = rect()
.horizontal()
.width(Size::fill())
@ -158,9 +153,7 @@ impl Component for NotifEntryRow {
.cross_align(Alignment::Start)
.padding(Gaps::new_all(8.))
.corner_radius(CornerRadius::new_all(8.))
.opacity(progress)
.margin(Gaps::new(0., 0., 0., slide))
.maybe(*hovered.read(), |el| {
.maybe(*hovered.read() && !has_actions, |el| {
el.background(colors::ghost_overlay_hover())
})
.on_pointer_enter(move |_| {
@ -183,7 +176,7 @@ impl Component for NotifEntryRow {
.color(level_color(&entry.level)),
),
)
.child(row_body(&entry));
.child(row_body(&entry, body_dispatch));
rect()
.vertical()
@ -307,7 +300,7 @@ fn phase_badge(phase: &str) -> impl IntoElement {
.into_element()
}
fn row_body(entry: &InboxEntry) -> impl IntoElement {
fn row_body(entry: &InboxEntry, dispatch: crate::BridgeDispatch) -> impl IntoElement {
rect()
.vertical()
.width(Size::flex(1.0))
@ -354,10 +347,10 @@ fn row_body(entry: &InboxEntry) -> impl IntoElement {
.font_size(12.)
.color(colors::fg_secondary()),
)
.child(row_extra(entry))
.child(row_extra(entry, dispatch))
}
fn row_extra(entry: &InboxEntry) -> impl IntoElement {
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()
@ -377,17 +370,40 @@ fn row_extra(entry: &InboxEntry) -> impl IntoElement {
}
if !entry.actions.is_empty() {
let id = entry.id;
let dismiss_dispatch = dispatch.clone();
let mut row = rect()
.horizontal()
.spacing(16.)
.margin(Gaps::new(8., 0., 0., 0.));
.width(Size::fill())
.content(Content::Flex)
.cross_align(Alignment::Center)
.spacing(8.)
.margin(Gaps::new(8., 0., 0., 0.))
.child(
Button::new()
.small()
.secondary()
.width(Size::flex(1.0))
.text("Dismiss")
.on_press(move |_| dismiss_dispatch.dismiss_notification(id)),
);
for (i, text) in entry.actions.iter().enumerate() {
row = row.child(Button::new().small().text(text.clone()).variant(if i == 0 {
ButtonVariant::Primary
} else {
ButtonVariant::Secondary
}));
for (i, action) in entry.actions.iter().enumerate() {
let action_dispatch = dispatch.clone();
let kind = action.kind.clone();
row = row.child(
Button::new()
.small()
.width(Size::flex(1.0))
.text(action.label.clone())
.variant(if i == 0 {
ButtonVariant::Primary
} else {
ButtonVariant::Secondary
})
.on_press(move |_| run_action(&action_dispatch, &kind)),
);
}
return row.into_element();
@ -396,6 +412,14 @@ fn row_extra(entry: &InboxEntry) -> impl IntoElement {
rect().into_element()
}
fn run_action(dispatch: &crate::BridgeDispatch, kind: &NotificationActionKind) {
match kind {
NotificationActionKind::OpenClusterUpdate(summary) => {
dispatch.open_cluster_update(summary.clone());
}
}
}
fn divider() -> impl IntoElement {
rect()
.width(Size::fill())

View file

@ -1,6 +1,6 @@
use freya::prelude::*;
use crate::components::{Icon, IconType};
use crate::components::{Button, Icon, IconType};
use crate::theme::colors;
use crate::ui::border_all_color;
@ -45,18 +45,11 @@ impl Component for Pagination {
let icon_btn = move |target: usize, enabled: bool, icon: IconType| {
let mut page = page;
rect()
.center()
.width(Size::px(32.))
.height(Size::px(32.))
.corner_radius(CornerRadius::new_all(8.))
.background(colors::component_bg())
.border(border_all_color(1., colors::component_border()))
.maybe(enabled, |el| {
el.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
.on_press(move |_| page.set(target))
})
Button::new()
.secondary()
.icon()
.enabled(enabled)
.on_press(move |_| page.set(target))
.child(Icon::new(icon).size(14.).color(if enabled {
colors::fg_primary()
} else {
@ -68,43 +61,38 @@ impl Component for Pagination {
let num_btn = move |target: usize| {
let mut page = page;
let is_current = target == current;
rect()
.center()
if is_current {
return rect()
.center()
.width(Size::px(32.))
.height(Size::px(32.))
.corner_radius(CornerRadius::new_all(8.))
.background(colors::component_bg_pressed())
.border(border_all_color(1., colors::brand()))
.child(
label()
.text(format!("{}", target + 1))
.font_size(12.)
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_primary()),
)
.into_element();
}
Button::new()
.secondary()
.width(Size::px(32.))
.height(Size::px(32.))
.corner_radius(CornerRadius::new_all(8.))
.background(if is_current {
colors::component_bg_pressed()
} else {
colors::component_bg()
})
.border(border_all_color(
1.,
if is_current {
colors::brand()
} else {
colors::component_border()
},
))
.maybe(!is_current, |el| {
el.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
.on_press(move |_| page.set(target))
})
.padding(Gaps::new_symmetric(6., 0.))
.on_press(move |_| page.set(target))
.child(
label()
.text(format!("{}", target + 1))
.font_size(12.)
.font_weight(if is_current {
FontWeight::SEMI_BOLD
} else {
FontWeight::NORMAL
})
.color(if is_current {
colors::fg_primary()
} else {
colors::fg_secondary()
}),
.font_weight(FontWeight::NORMAL)
.max_lines(1)
.color(colors::fg_secondary()),
)
.into_element()
};

View file

@ -14,6 +14,7 @@ use crate::utils::sort_clusters_for_home;
const ROW_HEIGHT_PX: f32 = 208.0;
const CARD_GAP_PX: f32 = 24.0;
const MORE_TILE_WIDTH_PX: f32 = 96.0;
const MIN_CARD_WIDTH_PX: f32 = 300.0;
const MAX_CARD_WIDTH_PX: f32 = 480.0;
const CARD_MS: u64 = 460;
const STAGGER_MS: u64 = 85;
@ -100,6 +101,10 @@ impl Component for ClusterCard {
let active = *active_id.read() == Some(self.cluster.id);
let mut hovering = use_state(|| false);
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let focused = focus().is_focused();
let total = CARD_MS as f32 + (self.count.saturating_sub(1) as f32) * STAGGER_MS as f32;
let elapsed = self.progress * total;
let local =
@ -121,6 +126,7 @@ impl Component for ClusterCard {
rect()
.key(self.cluster.id)
.width(Size::flex(1.0))
.min_width(Size::px(MIN_CARD_WIDTH_PX))
.max_width(Size::px(MAX_CARD_WIDTH_PX))
.height(Size::fill())
.offset_y(rise)
@ -133,7 +139,7 @@ impl Component for ClusterCard {
.corner_radius(CornerRadius::new_all(12.))
.overflow(Overflow::Clip)
.border(
if active {
if active || focused {
border_all_color(2., colors::brand())
} else if *hovering.read() {
border_all_color(1., colors::component_border_hover())
@ -142,6 +148,9 @@ impl Component for ClusterCard {
}
.alignment(BorderAlignment::Outer),
)
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.on_pointer_over(move |_| {
if !*hovering.peek() {
*hovering.write() = true;
@ -158,7 +167,7 @@ impl Component for ClusterCard {
.y(0.)
.color(Color::from_af32rgb(0.3, 0, 0, 0)),
)
.on_press(on_press)
.on_all_press(on_press)
.child(
rect()
.width(Size::fill())
@ -218,17 +227,31 @@ impl Component for OtherVersionsTile {
fn render(&self) -> impl IntoElement {
let mut hovering = use_state(|| false);
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let focused = focus().is_focused();
rect()
.width(Size::px(MORE_TILE_WIDTH_PX))
.height(Size::fill())
.corner_radius(CornerRadius::new_all(12.))
.border(border_all(1.))
.border(
if focused {
border_all_color(2., colors::brand())
} else {
border_all(1.)
}
.alignment(BorderAlignment::Outer),
)
.background(if *hovering.read() {
colors::ghost_overlay_pressed()
} else {
colors::ghost_overlay_hover()
})
.blur(32.)
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.on_pointer_enter(move |_| {
*hovering.write() = true;
})
@ -243,7 +266,7 @@ impl Component for OtherVersionsTile {
.y(0.)
.color(Color::from_af32rgb(0.3, 0, 0, 0)),
)
.on_press(|_| {
.on_all_press(|_| {
let _ = RouterContext::get().push(Route::Clusters {});
})
.child(
@ -257,15 +280,11 @@ impl Component for OtherVersionsTile {
}
fn recent_card_slots_for_width(row_width_px: f32) -> usize {
const MORE_TILE_WIDTH: f32 = 96.0;
const GAP: f32 = 24.0;
const MIN_CARD_WIDTH: f32 = 300.0;
if row_width_px <= MORE_TILE_WIDTH + GAP {
if row_width_px <= MORE_TILE_WIDTH_PX + CARD_GAP_PX {
return 0;
}
let available = row_width_px - MORE_TILE_WIDTH - GAP;
let slot = MIN_CARD_WIDTH + GAP;
let available = row_width_px - MORE_TILE_WIDTH_PX;
let slot = MIN_CARD_WIDTH_PX + CARD_GAP_PX;
(available / slot).floor() as usize
}

View file

@ -42,7 +42,7 @@ pub(crate) fn scrollbar_pos_and_size(inner: f32, viewport: f32, scroll: f32) ->
let scroll_range = inner - viewport;
let thumb_range = viewport - thumb;
let normalized = -scroll / scroll_range;
(normalized * thumb_range, thumb)
}
@ -307,30 +307,46 @@ impl ScrollArea {
let us_thumb = on_user_scroll.clone();
let us_h_thumb = on_user_scroll;
let can_scroll_v = vp_h > 0. && vp_h < ct_h;
let can_scroll_h = horizontal && vp_w > 0. && vp_w < content_w;
let on_wheel = move |e: Event<WheelEventData>| {
e.stop_propagation();
let shift = *shift_held.read();
if (e.delta_y != 0.0 || e.delta_x != 0.0)
&& let Some(cb) = &us_wheel
{
cb.call(());
}
let h_delta = if horizontal {
if shift && e.delta_x == 0.0 { e.delta_y } else { e.delta_x }
} else {
0.0
};
let v_delta = if horizontal && shift { 0.0 } else { e.delta_y };
if horizontal && *shift_held.read() {
let delta = if e.delta_x != 0.0 { e.delta_x } else { e.delta_y };
if delta != 0.0 {
let cur_x = corrected_scroll(content_w, vp_w, *scroll_x.read());
let new_x = scroll_pos_from_wheel(delta as f32, content_w, vp_w, cur_x);
scroll_x.set(new_x as f32);
}
let do_h = h_delta != 0.0 && can_scroll_h;
let do_v = v_delta != 0.0 && can_scroll_v;
// check if scrollable
if !do_h && !do_v {
return;
}
let (_, cur_y) = controller.into();
let current = corrected_scroll(ct_h, vp_h, cur_y as f32);
let new_y = scroll_pos_from_wheel(e.delta_y as f32, ct_h, vp_h, current);
if new_y != cur_y {
controller.scroll_to_y(new_y);
e.stop_propagation();
if let Some(cb) = &us_wheel {
cb.call(());
}
if do_h {
let cur_x = corrected_scroll(content_w, vp_w, *scroll_x.read());
let new_x = scroll_pos_from_wheel(h_delta as f32, content_w, vp_w, cur_x);
scroll_x.set(new_x as f32);
}
if do_v {
let (_, cur_y) = controller.into();
let current = corrected_scroll(ct_h, vp_h, cur_y as f32);
let new_y = scroll_pos_from_wheel(v_delta as f32, ct_h, vp_h, current);
if new_y != cur_y {
controller.scroll_to_y(new_y);
}
}
};
@ -393,6 +409,11 @@ impl ScrollArea {
rect()
.width(self.width.clone())
.height(self.height.clone())
.scrollable(true)
.a11y_builder(move |node| {
node.set_scroll_x(corrected_x as f64);
node.set_scroll_y(corrected_y as f64);
})
.on_wheel(on_wheel)
.on_capture_global_pointer_move(on_global_move)
.on_capture_global_pointer_press(on_global_release)

View file

@ -101,16 +101,6 @@ impl<T: Copy + PartialEq + 'static> Component for SegmentedControl<T> {
let equal_width = self.equal_width;
let disabled = self.disabled;
let active_color = |is_selected: bool| {
if disabled {
colors::fg_secondary().with_a(110)
} else if is_selected {
colors::fg_primary()
} else {
colors::fg_secondary()
}
};
rect()
.horizontal()
.height(Size::px(self.height))
@ -121,55 +111,103 @@ impl<T: Copy + PartialEq + 'static> Component for SegmentedControl<T> {
.background(colors::component_bg())
.border(border_all_color(1., colors::component_border()))
.children(self.segments.iter().map(move |seg| {
let value = seg.value;
let is_selected = value == active;
let mut selected = selected;
let label_text = seg.label.clone();
SegmentButton {
selected,
value: seg.value,
is_selected: seg.value == active,
icon: seg.icon,
label: seg.label.clone(),
icon_size,
tint,
equal_width,
disabled,
}
.into_element()
}))
}
}
rect()
.horizontal()
.height(Size::fill())
.cross_align(Alignment::Center)
.spacing(6.)
.corner_radius(CornerRadius::new_all(6.))
.map(equal_width, |el, w| {
el.width(Size::px(w)).main_align(Alignment::Center)
})
.maybe(equal_width.is_none(), |el| {
el.padding(Gaps::new_symmetric(0., 12.))
})
.background(if is_selected {
colors::component_bg_pressed()
#[derive(PartialEq)]
struct SegmentButton<T: Copy + PartialEq + 'static> {
selected: State<T>,
value: T,
is_selected: bool,
icon: Option<IconType>,
label: Option<String>,
icon_size: Option<f32>,
tint: bool,
equal_width: Option<f32>,
disabled: bool,
}
impl<T: Copy + PartialEq + 'static> Component for SegmentButton<T> {
fn render(&self) -> impl IntoElement {
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let mut selected = self.selected;
let value = self.value;
let is_selected = self.is_selected;
let disabled = self.disabled;
let equal_width = self.equal_width;
let focused = focus().is_focused();
let content_color = if disabled {
colors::fg_secondary().with_a(110)
} else if is_selected {
colors::fg_primary()
} else {
colors::fg_secondary()
};
rect()
.horizontal()
.height(Size::fill())
.cross_align(Alignment::Center)
.spacing(6.)
.corner_radius(CornerRadius::new_all(6.))
.a11y_id(a11y_id)
.a11y_focusable(!disabled)
.a11y_role(AccessibilityRole::Button)
.map(equal_width, |el, w| {
el.width(Size::px(w)).main_align(Alignment::Center)
})
.maybe(equal_width.is_none(), |el| {
el.padding(Gaps::new_symmetric(0., 12.))
})
.background(if is_selected {
colors::component_bg_pressed()
} else {
Color::TRANSPARENT
})
.maybe(focused && !disabled, |el| {
el.border(border_all_color(1., colors::brand()))
})
.maybe(!disabled, |el| {
el.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
.on_all_press(move |_| *selected.write() = value)
})
.maybe_child(self.icon.map(|icon| {
let mut ic = Icon::new(icon);
if let Some(size) = self.icon_size {
ic = ic.size(size);
}
if self.tint {
ic = ic.color(content_color);
}
ic.into_element()
}))
.maybe_child(self.label.clone().map(|text| {
label()
.text(text)
.font_size(13.)
.font_weight(if is_selected {
FontWeight::MEDIUM
} else {
Color::TRANSPARENT
FontWeight::NORMAL
})
.maybe(!disabled, |el| {
el.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
.on_press(move |_| *selected.write() = value)
})
.maybe_child(seg.icon.map(|icon| {
let mut ic = Icon::new(icon);
if let Some(size) = icon_size {
ic = ic.size(size);
}
if tint {
ic = ic.color(active_color(is_selected));
}
ic.into_element()
}))
.maybe_child(label_text.map(|text| {
label()
.text(text)
.font_size(13.)
.font_weight(if is_selected {
FontWeight::MEDIUM
} else {
FontWeight::NORMAL
})
.color(active_color(is_selected))
.into_element()
}))
.color(content_color)
.into_element()
}))
}

View file

@ -6,7 +6,7 @@ pub struct TabItem {
label: String,
active: bool,
count: Option<String>,
on_press: Option<Box<dyn FnMut(Event<PressEventData>)>>,
on_press: Option<EventHandler<Event<PressEventData>>>,
}
impl TabItem {
@ -25,8 +25,8 @@ impl TabItem {
self
}
pub fn on_press(mut self, handler: impl FnMut(Event<PressEventData>) + 'static) -> Self {
self.on_press = Some(Box::new(handler));
pub fn on_press(mut self, handler: impl Into<EventHandler<Event<PressEventData>>>) -> Self {
self.on_press = Some(handler.into());
self
}
}
@ -147,46 +147,79 @@ impl IntoElement for TabBar {
.cross_align(Alignment::Center)
.content(Content::Fit)
.children(self.tabs.into_iter().map(|tab| {
let active = tab.active;
let mut el = rect()
.vertical()
.content(Content::Fit)
.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()));
if let Some(handler) = tab.on_press {
el = el.on_press(handler);
TabButton {
label: tab.label,
active: tab.active,
count: tab.count,
font_size,
on_press: tab.on_press,
}
el.child(
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(6.)
.content(Content::Fit)
.child(
rect()
.vertical()
.content(Content::Fit)
.child(tab_label(&tab.label, font_size, active))
.child(
rect()
.height(Size::px(1.5))
.width(Size::fill_minimum())
.margin(Gaps::new_symmetric(0., 4.0))
.corner_radius(CornerRadius::new_all(2.))
.background(if active {
colors::fg_primary()
} else {
Color::TRANSPARENT
}),
),
)
.maybe_child(tab.count.map(|c| count_pill(&c, font_size).into_element())),
)
.into_element()
}))
.into_element()
}
}
#[derive(PartialEq)]
struct TabButton {
label: String,
active: bool,
count: Option<String>,
font_size: f32,
on_press: Option<EventHandler<Event<PressEventData>>>,
}
impl Component for TabButton {
fn render(&self) -> impl IntoElement {
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let active = self.active;
let font_size = self.font_size;
let underline_on = active || focus().is_focused();
let mut el = rect()
.vertical()
.content(Content::Fit)
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()));
if let Some(handler) = self.on_press.clone() {
el = el.on_all_press(move |e| handler.call(e));
}
el.child(
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(6.)
.content(Content::Fit)
.child(
rect()
.vertical()
.content(Content::Fit)
.child(tab_label(&self.label, font_size, active))
.child(
rect()
.height(Size::px(1.5))
.width(Size::fill_minimum())
.margin(Gaps::new_symmetric(0., 4.0))
.corner_radius(CornerRadius::new_all(2.))
.background(if underline_on {
colors::fg_primary()
} else {
Color::TRANSPARENT
}),
),
)
.maybe_child(
self.count
.as_ref()
.map(|c| count_pill(c, font_size).into_element()),
),
)
}
}

View file

@ -7,7 +7,7 @@ use oneclient_core::notification::NotificationLevel;
use crate::{
components::{Button, ButtonSize, Icon, IconType},
hooks::{use_dispatch, use_notifications_snapshot},
notifications::InboxEntry,
notifications::{InboxEntry, NotificationActionKind},
theme::colors,
};
@ -263,11 +263,7 @@ fn message_card(
}
fn action_card(entry: &InboxEntry, dispatch: crate::BridgeDispatch, id: u64, ttl_pct: f32) -> Rect {
let review_label = entry
.actions
.first()
.cloned()
.unwrap_or_else(|| "Review".to_string());
let action = entry.actions.first().cloned();
toast_shell(entry)
.child(
@ -287,60 +283,60 @@ fn action_card(entry: &InboxEntry, dispatch: crate::BridgeDispatch, id: u64, ttl
.child(header(entry)),
)
.child(divider())
.child(action_row(review_label, dispatch, id)),
.child(action_row(action, dispatch, id)),
)
.child(ttl_bar(entry, ttl_pct))
}
fn action_row(review_label: String, dispatch: crate::BridgeDispatch, id: u64) -> impl IntoElement {
fn action_row(
action: Option<crate::notifications::NotificationAction>,
dispatch: crate::BridgeDispatch,
id: u64,
) -> impl IntoElement {
let dismiss = dispatch.clone();
let review_label = action
.as_ref()
.map(|a| a.label.clone())
.unwrap_or_else(|| "Review".to_string());
let review_kind = action.map(|a| a.kind);
rect()
.horizontal()
.width(Size::fill())
.content(Content::Flex)
.cross_align(Alignment::Center)
.padding(Gaps::new(0., 30., 0., 30.))
.spacing(8.)
.child(
rect()
.horizontal()
Button::new()
.small()
.secondary()
.width(Size::flex(1.0))
.cross_align(Alignment::Center)
.spacing(8.)
.on_press(move |_| dispatch.mark_notification_read(id))
.child(
Icon::new(IconType::Eye)
.size(18.)
.color(colors::toast_action()),
)
.child(
label()
.text(review_label)
.font_size(12.)
.font_weight(FontWeight::MEDIUM)
.color(colors::toast_action()),
),
.on_press(move |_| dismiss.dismiss_toast(id))
.child(Icon::new(IconType::XClose).size(16.))
.text("Dismiss"),
)
.child(
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(8.)
.on_press(move |_| dismiss.dismiss_toast(id))
.child(
Icon::new(IconType::XClose)
.size(18.)
.color(colors::toast_action()),
)
.child(
label()
.text("Dismiss")
.font_size(12.)
.font_weight(FontWeight::MEDIUM)
.color(colors::toast_action()),
),
Button::new()
.small()
.primary()
.width(Size::flex(1.0))
.on_press(move |_| match &review_kind {
Some(kind) => run_action(&dispatch, kind),
None => dispatch.mark_notification_read(id),
})
.child(Icon::new(IconType::Eye).size(16.))
.text(review_label),
)
}
fn run_action(dispatch: &crate::BridgeDispatch, kind: &NotificationActionKind) {
match kind {
NotificationActionKind::OpenClusterUpdate(summary) => {
dispatch.open_cluster_update(summary.clone());
}
}
}
fn progress_card(entry: &InboxEntry) -> Rect {
let frac = entry
.progress

View file

@ -50,8 +50,17 @@ impl Component for Switch {
let on_press = self.on_press.clone();
let on = *value.read();
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let knob_align = if on { Alignment::End } else { Alignment::Start };
let track_border = if focus().is_focused() {
colors::fg_primary()
} else {
colors::component_border()
};
let background = use_animation_transition(value, |_, on| {
if on {
AnimColor::new(colors::component_bg(), colors::brand())
@ -84,9 +93,12 @@ impl Component for Switch {
.horizontal()
.cross_align(Alignment::Center)
.spacing(8.)
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
.on_press(move |_| {
.on_all_press(move |_| {
on_press.call(());
})
.child(
@ -104,7 +116,7 @@ impl Component for Switch {
.main_align(knob_align)
.cross_align(Alignment::Center)
.background(&*background.read())
.border(ui::border_all_color(1., colors::component_border()))
.border(ui::border_all_color(1., track_border))
.child(
rect()
.width(Size::px(HANDLE_SIZE))

View file

@ -4,7 +4,7 @@ use oneclient_db::models::ClusterId;
use crate::bridge::{BridgeCommand, OneClientBridge};
use crate::components::IconType;
use crate::notifications::NotificationSpec;
use crate::notifications::{ClusterUpdateSummary, NotificationAction, NotificationSpec};
#[derive(Clone)]
pub struct BridgeDispatch {
@ -152,6 +152,14 @@ impl BridgeDispatch {
self.send(BridgeCommand::CloseNotificationCenter);
}
pub fn toggle_account_switcher(&self) {
self.send(BridgeCommand::ToggleAccountSwitcher);
}
pub fn close_account_switcher(&self) {
self.send(BridgeCommand::CloseAccountSwitcher);
}
pub fn clear_notification_inbox(&self) {
self.send(BridgeCommand::ClearNotificationInbox);
}
@ -176,6 +184,14 @@ impl BridgeDispatch {
self.send(BridgeCommand::AnswerPrompt(choice));
}
pub fn open_cluster_update(&self, summary: ClusterUpdateSummary) {
self.send(BridgeCommand::OpenClusterUpdate(summary));
}
pub fn close_cluster_update(&self) {
self.send(BridgeCommand::CloseClusterUpdate);
}
pub fn notify(&self, title: impl Into<String>) -> NotificationBuilder {
NotificationBuilder {
dispatch: self.clone(),
@ -291,13 +307,13 @@ impl NotificationBuilder {
self
}
pub fn action(mut self, label: impl Into<String>) -> Self {
self.spec.actions.push(label.into());
pub fn action(mut self, action: NotificationAction) -> Self {
self.spec.actions.push(action);
self
}
pub fn actions(mut self, actions: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.spec.actions = actions.into_iter().map(Into::into).collect();
pub fn actions(mut self, actions: impl IntoIterator<Item = NotificationAction>) -> Self {
self.spec.actions = actions.into_iter().collect();
self
}

View file

@ -15,38 +15,43 @@ pub use active_cluster::{
pub use dispatch::BridgeDispatch;
pub use queries::{
AddOfflineAccountKeys, BROWSE_PAGE_SIZE, CachedImageQuery, ClusterAction, ClusterBundles,
ClusterLogsQuery, LogAction, LogContentQuery, MigrationQuery, OnboardingBundlesQuery,
RefreshAccountKeys, RemoveAccountKeys, ScreenshotAction, SetDefaultAccountKeys, UploadLogKeys,
UploadLogMutation, UseLogAction, UseRefreshAccount, UseRemoveAccount, UseScreenshotAction,
UseSetDefaultAccount, UseUploadLog, VERSIONS_PAGE_SIZE, accounts_have_microsoft,
bundle_overrides_map, bundles_with_status_items, category_list, changelog_error,
changelog_groups, changelog_is_loading, cluster_content_items, content_type_for_slug,
has_migration_data, invalidate_cluster_queries, invalidate_java_queries,
AddOfflineAccountKeys, BROWSE_PAGE_SIZE, BeginMicrosoftLoginMutation, CachedImageQuery,
CancelMicrosoftLoginKeys, CancelMicrosoftLoginMutation, ClusterAction, ClusterBundles,
ClusterLogsQuery, FinishMicrosoftLoginMutation, LogAction, LogContentQuery, MigrationQuery,
OnboardingBundlesQuery, RefreshAccountKeys, RemoveAccountKeys, ScreenshotAction,
SetDefaultAccountKeys, UploadLogKeys, UploadLogMutation, UseLogAction, UseRefreshAccount,
UseRemoveAccount, UseScreenshotAction, UseSetDefaultAccount, UseUploadLog, VERSIONS_PAGE_SIZE,
accounts_have_microsoft, bundle_overrides_map, bundles_with_status_items, category_list,
changelog_error, changelog_groups, changelog_is_loading, cluster_content_items,
content_type_for_slug, has_migration_data, invalidate_cluster_queries, invalidate_java_queries,
invalidate_logs_queries, invalidate_profile_queries, invalidate_screenshots_queries,
java_runtimes, loader_versions, login_code_already_handled, migration_detection,
mutation_error, mutation_is_pending, onboarding_bundles_items, package_meta_batch,
pick_version_metadata, project_detail, provider_versions, search_items, search_pending,
search_total, try_account, try_accounts, try_cluster_analytics, try_cluster_logs,
try_cluster_screenshots, try_default_account, try_game_profile, try_global_analytics,
try_log_content, use_account, use_accounts, use_add_microsoft_account, use_add_offline_account,
use_begin_microsoft_login, use_bundle_overrides, use_bundle_updates, use_bundles_with_status,
use_cached_image, use_changelog, use_cluster_analytics, use_cluster_content, use_cluster_logs,
use_cluster_mutation, use_cluster_profile, use_cluster_screenshots, use_cluster_settings,
use_clusters, use_current_account, use_default_account, use_finish_microsoft_login,
use_game_profile, use_global_analytics, use_java_runtimes, use_loader_versions,
use_local_image, use_log_action, use_log_content, use_migration, use_named_profiles,
use_onboarding_bundles, use_package_categories, use_package_meta_batch, use_package_project,
use_package_search, use_package_versions, use_player_profile, use_player_skin,
use_provider_versions, use_refresh_account, use_refresh_all_accounts, use_remove_account,
use_screenshot_action, use_set_default_account, use_upload_log, use_version_metadata,
use_versions, version_list, versions_total,
pick_version_metadata, project_detail, provider_versions, reset_login_code_dedup, search_items,
search_pending, search_total, try_account, try_accounts, try_cluster_analytics,
try_cluster_logs, try_cluster_screenshots, try_default_account, try_game_profile,
try_global_analytics, try_log_content, use_account, use_accounts, use_add_microsoft_account,
use_add_offline_account, use_begin_microsoft_login, use_bundle_overrides, use_bundle_updates,
use_bundles_with_status, use_cached_image, use_cancel_microsoft_login, use_changelog,
use_cluster_analytics, use_cluster_content, use_cluster_logs, use_cluster_mutation,
use_cluster_profile, use_cluster_screenshots, use_cluster_settings, use_clusters,
use_current_account, use_default_account, use_finish_microsoft_login, use_game_profile,
use_global_analytics, use_java_runtimes, use_loader_versions, use_local_image, use_log_action,
use_log_content, use_migration, use_named_profiles, use_onboarding_bundles,
use_package_categories, use_package_meta_batch, use_package_project, use_package_search,
use_package_versions, use_player_profile, use_player_skin, use_provider_versions,
use_refresh_account, use_refresh_all_accounts, use_remove_account, use_screenshot_action,
use_set_default_account, use_upload_log, use_version_metadata, use_versions, version_list,
versions_total,
};
use crate::{bridge::{
BridgeSnapshot, ClustersSnapshot, GameSnapshot, JavaSnapshot, LauncherInit, OneClientBridge,
ProfilesSnapshot, SettingsSnapshot, use_bridge_snapshot,
}, notifications::NotificationSnapshot};
use crate::{
bridge::{
BridgeSnapshot, ClustersSnapshot, GameSnapshot, JavaSnapshot, LauncherInit,
OneClientBridge, ProfilesSnapshot, SettingsSnapshot, use_bridge_snapshot,
},
notifications::NotificationSnapshot,
};
use freya::prelude::*;
pub fn use_provide_bridge(bridge: &OneClientBridge) {
@ -88,10 +93,18 @@ pub fn use_notifications_snapshot() -> NotificationSnapshot {
use_snapshots().notifications
}
pub fn use_account_switcher_open() -> bool {
use_snapshots().account_switcher_open
}
pub fn use_game_snapshot() -> GameSnapshot {
use_snapshots().game
}
pub fn use_microsoft_login_status() -> Option<oneclient_core::notification::MicrosoftLoginStatus> {
use_snapshots().microsoft_login
}
pub fn use_java_snapshot() -> JavaSnapshot {
use_snapshots().java
}
@ -112,10 +125,12 @@ impl Component for DataSync {
last_clusters.set(clusters_pulse);
spawn(async move { invalidate_cluster_queries().await });
}
if *last_profiles.peek() != profiles_pulse {
last_profiles.set(profiles_pulse);
spawn(async move { invalidate_profile_queries().await });
}
if *last_java.peek() != java_pulse {
last_java.set(java_pulse);
spawn(async move { invalidate_java_queries().await });

View file

@ -6,7 +6,7 @@ use freya::query::{
QueryStateData, UseMutation, UseQuery, use_mutation, use_query,
};
use oneclient_core::LauncherError;
use oneclient_core::auth::{self, AccountKind, MinecraftAccount, MinecraftLogin};
use oneclient_core::auth::{self, AccountKind, MicrosoftLoginSession, MinecraftAccount};
use uuid::Uuid;
static HANDLED_LOGIN_CODE: Mutex<Option<String>> = Mutex::new(None);
@ -20,6 +20,10 @@ pub fn login_code_already_handled(user_code: &str) -> bool {
false
}
pub fn reset_login_code_dedup() {
*HANDLED_LOGIN_CODE.lock().unwrap() = None;
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct ListAccountsKeys;
@ -141,7 +145,7 @@ async fn invalidate_auth_queries(account_id: Option<Uuid>) {
pub struct BeginMicrosoftLoginMutation;
impl MutationCapability for BeginMicrosoftLoginMutation {
type Ok = MinecraftLogin;
type Ok = MicrosoftLoginSession;
type Err = LauncherError;
type Keys = ();
@ -156,7 +160,7 @@ pub struct FinishMicrosoftLoginMutation;
impl MutationCapability for FinishMicrosoftLoginMutation {
type Ok = MinecraftAccount;
type Err = LauncherError;
type Keys = MinecraftLogin;
type Keys = MicrosoftLoginSession;
async fn run(&self, flow: &Self::Keys) -> Result<Self::Ok, Self::Err> {
auth::finish_microsoft_login(flow.clone()).await
@ -169,6 +173,27 @@ impl MutationCapability for FinishMicrosoftLoginMutation {
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct CancelMicrosoftLoginMutation;
/// Drops a pending browser login server-side when the user cancels before the
/// flow reaches the point of no return. Keyed by the session's CSRF state token.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct CancelMicrosoftLoginKeys {
pub state_token: String,
}
impl MutationCapability for CancelMicrosoftLoginMutation {
type Ok = ();
type Err = LauncherError;
type Keys = CancelMicrosoftLoginKeys;
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
auth::cancel_microsoft_login(&keys.state_token).await;
Ok(())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct AddMicrosoftAccountMutation;
@ -270,7 +295,7 @@ pub struct RefreshAccountKeys {
}
impl MutationCapability for RefreshAccountMutation {
type Ok = Option<MinecraftAccount>;
type Ok = MinecraftAccount;
type Err = LauncherError;
type Keys = RefreshAccountKeys;
@ -316,6 +341,10 @@ pub fn use_finish_microsoft_login() -> UseMutation<FinishMicrosoftLoginMutation>
use_mutation(Mutation::new(FinishMicrosoftLoginMutation))
}
pub fn use_cancel_microsoft_login() -> UseMutation<CancelMicrosoftLoginMutation> {
use_mutation(Mutation::new(CancelMicrosoftLoginMutation))
}
pub fn use_add_microsoft_account() -> UseMutation<AddMicrosoftAccountMutation> {
use_mutation(Mutation::new(AddMicrosoftAccountMutation))
}

View file

@ -21,6 +21,7 @@ impl QueryCapability for CachedImageQuery {
type Err = LauncherError;
type Keys = CachedImageKeys;
#[tracing::instrument(name = "cached_image", level = "debug", skip(self, keys), fields(url = %keys.url, max_edge = keys.max_edge))]
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
if keys.url.is_empty() {
return Err(LauncherError::Minecraft("no image url".to_string()));

View file

@ -21,13 +21,15 @@ pub use analytics::{
try_cluster_analytics, try_global_analytics, use_cluster_analytics, use_global_analytics,
};
pub use auth::{
AddOfflineAccountKeys, RefreshAccountKeys, RemoveAccountKeys, SetDefaultAccountKeys,
UseRefreshAccount, UseRemoveAccount, UseSetDefaultAccount, accounts_have_microsoft,
login_code_already_handled, mutation_error, mutation_is_pending, try_account, try_accounts,
try_default_account, use_account, use_accounts, use_add_microsoft_account,
use_add_offline_account, use_begin_microsoft_login, use_current_account, use_default_account,
use_finish_microsoft_login, use_refresh_account, use_refresh_all_accounts, use_remove_account,
use_set_default_account,
AddOfflineAccountKeys, BeginMicrosoftLoginMutation, CancelMicrosoftLoginKeys,
CancelMicrosoftLoginMutation, FinishMicrosoftLoginMutation, RefreshAccountKeys,
RemoveAccountKeys, SetDefaultAccountKeys, UseRefreshAccount, UseRemoveAccount,
UseSetDefaultAccount, accounts_have_microsoft, login_code_already_handled, mutation_error,
mutation_is_pending, reset_login_code_dedup, try_account, try_accounts, try_default_account,
use_account, use_accounts, use_add_microsoft_account, use_add_offline_account,
use_begin_microsoft_login, use_cancel_microsoft_login, use_current_account,
use_default_account, use_finish_microsoft_login, use_refresh_account, use_refresh_all_accounts,
use_remove_account, use_set_default_account,
};
pub use bundles::{
ClusterBundles, OnboardingBundlesQuery, bundle_overrides_map, bundles_with_status_items,

View file

@ -39,6 +39,7 @@ impl QueryCapability for PackageSearchQuery {
type Err = LauncherError;
type Keys = PackageSearchKeys;
#[tracing::instrument(name = "package_search", level = "debug", skip(self, keys), fields(provider = ?keys.provider, page = keys.page))]
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let state = LauncherState::get()?;
let provider = state.services.packages.get(keys.provider)?;
@ -165,6 +166,7 @@ impl QueryCapability for PackageMetaBatchQuery {
type Err = LauncherError;
type Keys = PackageMetaBatchKeys;
#[tracing::instrument(name = "package_meta_batch", level = "debug", skip(self, keys), fields(provider = ?keys.provider, ids = keys.project_ids.len()))]
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let state = LauncherState::get()?;
oneclient_core::packages::fetch_package_meta(
@ -286,6 +288,7 @@ impl QueryCapability for PackageCategoriesQuery {
type Err = LauncherError;
type Keys = PackageCategoriesKeys;
#[tracing::instrument(name = "package_categories", level = "debug", skip(self, keys), fields(provider = ?keys.provider))]
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let state = LauncherState::get()?;
let provider = state.services.packages.get(keys.provider)?;

View file

@ -249,14 +249,13 @@ fn cluster_header(
.horizontal()
.content(Content::Flex)
.width(Size::fill())
.height(Size::px(HEADER_HEIGHT))
.min_height(Size::px(HEADER_HEIGHT))
.cross_align(Alignment::Center)
.spacing(12.)
.child(
rect()
.vertical()
.width(Size::flex(1.0))
.height(Size::fill())
.main_align(Alignment::Center)
.spacing(6.)
.child(

View file

@ -1,7 +1,9 @@
use freya::prelude::*;
use freya::router::*;
use crate::components::{NotificationCenter, StatusBar, Toasts, UpdatePromptOverlay};
use crate::components::{
AccountSwitcher, ClusterUpdatePopup, NotificationCenter, StatusBar, Toasts, UpdatePromptOverlay,
};
use crate::routes::Route;
use crate::theme;
use crate::theme::colors;
@ -26,8 +28,10 @@ impl Component for RootLayout {
.child(Outlet::<Route>::new()),
)
.child(NotificationCenter)
.child(AccountSwitcher)
.child(Toasts)
.child(UpdatePromptOverlay)
.child(ClusterUpdatePopup)
.child(StatusBar)
}
}

View file

@ -717,6 +717,9 @@ impl Component for SidebarItem {
fn render(&self) -> impl IntoElement {
let mut hovering = use_state(|| false);
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let tab = self.tab;
let active = self.active;
let route = tab.route();
@ -731,7 +734,7 @@ impl Component for SidebarItem {
let has_dot = matches!(tab, SettingsTab::Changelog);
rect()
let mut el = rect()
.horizontal()
.content(Content::Flex)
.width(Size::fill())
@ -741,6 +744,9 @@ impl Component for SidebarItem {
.padding(Gaps::new_symmetric(0., 12.))
.corner_radius(CornerRadius::new_all(7.))
.background(background)
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.on_pointer_enter(move |_| {
*hovering.write() = true;
Cursor::set(CursorIcon::Pointer);
@ -750,11 +756,21 @@ impl Component for SidebarItem {
Cursor::set(CursorIcon::default());
})
.map(route, |el, route| {
el.on_press(move |_| {
el.on_all_press(move |_| {
let _ = RouterContext::get().push(route.clone());
})
})
.child(Icon::new(tab.icon()).size(18.).color(colors::fg_primary()))
});
if focus().is_focused() {
el = el.border(
Border::new()
.fill(colors::brand())
.width(1.)
.alignment(BorderAlignment::Inner),
);
}
el.child(Icon::new(tab.icon()).size(18.).color(colors::fg_primary()))
.child(
rect()
.horizontal()

View file

@ -1,5 +1,7 @@
// TODO: Remove this once freya has some workaround for this
#![allow(float_literal_f32_fallback)]
// Deeply nested async call chains through instrumented oneclient_core fns exceed the default limit
#![recursion_limit = "256"]
mod assets;
mod bridge;

View file

@ -4,6 +4,7 @@ use std::time::{Duration, Instant};
use oneclient_core::notification::{
GroupedProgressEvent, Notification, NotificationLevel, PromptKind, UserChoice,
};
use oneclient_db::models::ClusterId;
use tokio::sync::oneshot;
use uuid::Uuid;
@ -11,6 +12,32 @@ use crate::components::IconType;
pub const MESSAGE_TOAST_TTL: Duration = Duration::from_secs(5);
#[derive(Clone, Debug, PartialEq)]
pub struct NotificationAction {
pub label: String,
pub kind: NotificationActionKind,
}
#[derive(Clone, Debug, PartialEq)]
pub enum NotificationActionKind {
OpenClusterUpdate(ClusterUpdateSummary),
}
#[derive(Clone, Debug, PartialEq)]
pub struct ClusterUpdateSummary {
pub cluster_id: ClusterId,
pub cluster_name: String,
pub updated: Vec<String>,
pub added: Vec<String>,
pub removed: Vec<String>,
}
impl ClusterUpdateSummary {
pub fn total(&self) -> usize {
self.updated.len() + self.added.len() + self.removed.len()
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct InboxEntry {
pub level: NotificationLevel,
@ -22,7 +49,7 @@ pub struct InboxEntry {
pub is_loading: bool,
pub read: bool,
pub created_at: Instant,
pub actions: Vec<String>,
pub actions: Vec<NotificationAction>,
pub tasks: Vec<TaskView>,
}
@ -41,7 +68,7 @@ pub struct NotificationSpec {
pub level: NotificationLevel,
pub icon: Option<IconType>,
pub progress: Option<(u64, u64)>,
pub actions: Vec<String>,
pub actions: Vec<NotificationAction>,
}
impl InboxEntry {
@ -89,6 +116,7 @@ pub struct NotificationSnapshot {
pub inbox: Vec<InboxEntry>,
pub center_open: bool,
pub pending_prompt: Option<PendingPromptView>,
pub cluster_update: Option<ClusterUpdateSummary>,
pub active_toast_entry_ids: Vec<u64>,
}
@ -106,6 +134,7 @@ pub struct NotificationState {
grouped_entries: HashMap<Uuid, u64>,
grouped_tasks: HashMap<Uuid, GroupedTasks>,
pending_timers: Vec<ToastDismissTimer>,
cluster_update: Option<ClusterUpdateSummary>,
}
#[derive(Default)]
@ -145,10 +174,19 @@ impl NotificationState {
inbox: inbox.to_vec(),
center_open,
pending_prompt,
cluster_update: self.cluster_update.clone(),
active_toast_entry_ids: self.active_toasts.iter().map(|t| t.entry_id).collect(),
}
}
pub fn open_cluster_update(&mut self, summary: ClusterUpdateSummary) {
self.cluster_update = Some(summary);
}
pub fn close_cluster_update(&mut self) {
self.cluster_update = None;
}
pub fn unread_count(inbox: &[InboxEntry]) -> usize {
inbox.iter().filter(|entry| !entry.read).count()
}
@ -183,6 +221,7 @@ impl NotificationState {
Notification::InvalidateClusters => {}
Notification::InvalidateJava => {}
Notification::SyncComplete => {}
Notification::MicrosoftLoginStatus(_) => {}
Notification::GameStage { .. }
| Notification::GameLog { .. }
| Notification::GameFailed { .. } => {}
@ -268,7 +307,8 @@ impl NotificationState {
}
pub fn expire_toast(&mut self, _inbox: &[InboxEntry], entry_id: u64) {
self.active_toasts.retain(|toast| toast.entry_id != entry_id);
self.active_toasts
.retain(|toast| toast.entry_id != entry_id);
}
pub fn mark_read(&mut self, inbox: &mut [InboxEntry], entry_id: u64) {
@ -281,7 +321,7 @@ impl NotificationState {
if inbox
.iter()
.find(|e| e.id == entry_id)
.is_some_and(|e| e.click_dismissable())
.is_some_and(|e| e.dismissable())
{
self.forget_entry(inbox, entry_id);
}

View file

@ -1,56 +1,9 @@
#[cfg(not(target_os = "windows"))]
pub fn open_url(url: &str) {
let result = {
#[cfg(target_os = "linux")]
{
std::process::Command::new("xdg-open").arg(url).spawn()
}
#[cfg(target_os = "macos")]
{
std::process::Command::new("open").arg(url).spawn()
}
};
if let Err(err) = result {
if let Err(err) = open::that_detached(url) {
tracing::warn!("failed to open url {url}: {err}");
}
}
#[cfg(target_os = "windows")]
pub fn open_url(url: &str) {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::UI::Shell::ShellExecuteW;
// SW_SHOWNORMAL
const SW_SHOWNORMAL: i32 = 1;
fn wide(s: &str) -> Vec<u16> {
std::ffi::OsStr::new(s)
.encode_wide()
.chain(std::iter::once(0))
.collect()
}
let operation = wide("open");
let file = wide(url);
let ret = unsafe {
ShellExecuteW(
std::ptr::null_mut(),
operation.as_ptr(),
file.as_ptr(),
std::ptr::null(),
std::ptr::null(),
SW_SHOWNORMAL,
)
};
if (ret as isize) <= 32 {
tracing::warn!("failed to open url {url}: ShellExecuteW returned {}", ret as isize);
}
}
pub fn copy_image_to_clipboard(path: std::path::PathBuf) {
std::thread::spawn(move || {
let img = match image::open(&path) {
@ -95,13 +48,9 @@ pub mod macos {
fn release_unused_memory() {
unsafe {
unsafe extern "C" {
fn malloc_zone_pressure_relief(
zone: *mut core::ffi::c_void,
goal: usize,
) -> usize;
fn malloc_zone_pressure_relief(zone: *mut core::ffi::c_void, goal: usize) -> usize;
}
malloc_zone_pressure_relief(core::ptr::null_mut(), 0);
}
}
}

View file

@ -1,23 +1,21 @@
use chrono::Utc;
use freya::prelude::*;
use freya::query::{MutationCapability, MutationStateData, UseMutation};
use freya::text_edit::Clipboard;
use oneclient_core::auth::{AccountKind, MinecraftAccount, MinecraftLogin};
use oneclient_core::auth::{AccountKind, MinecraftAccount};
use crate::components::{
Avatar, Button, Icon, IconType, OverlayPopup, PlayerModel, ScrollArea, TextInput,
use_microsoft_login,
};
use crate::hooks::{
AddOfflineAccountKeys, RefreshAccountKeys, RemoveAccountKeys, SetDefaultAccountKeys,
accounts_have_microsoft, login_code_already_handled, try_accounts, try_default_account,
use_accounts, use_add_offline_account, use_begin_microsoft_login, use_current_account,
use_finish_microsoft_login, use_refresh_account, use_remove_account, use_set_default_account,
accounts_have_microsoft, try_accounts, try_default_account, use_accounts,
use_add_offline_account, use_current_account, use_refresh_account, use_remove_account,
use_set_default_account,
};
use crate::platform;
use crate::theme::colors;
use crate::ui::border_all_color;
#[derive(PartialEq)]
pub struct Accounts;
@ -26,44 +24,16 @@ impl Component for Accounts {
let accounts_query = use_accounts();
let default_query = use_current_account();
let begin = use_begin_microsoft_login();
let finish = use_finish_microsoft_login();
let msa = use_microsoft_login();
let add_offline = use_add_offline_account();
let set_default = use_set_default_account();
let remove = use_remove_account();
let refresh = use_refresh_account();
let mut username = use_state(String::new);
let mut pending_login = use_state(|| None::<MinecraftLogin>);
let mut handled_code = use_state(|| None::<String>);
let mut show_offline = use_state(|| false);
let mut closing_offline = use_state(|| false);
use_side_effect(move || {
let login = match &*begin.read().state() {
MutationStateData::Settled { res: Ok(login), .. } => Some(login.clone()),
_ => None,
};
let Some(login) = login else { return };
if login_code_already_handled(login.dedupe_key()) {
return;
}
handled_code.set(Some(login.dedupe_key().to_string()));
if let Some(url) = login.browser_url() {
platform::open_url(url);
}
finish.mutate(login.clone());
pending_login.set(Some(login));
});
use_side_effect(move || {
if matches!(&*finish.read().state(), MutationStateData::Settled { .. })
&& pending_login.peek().is_some()
{
pending_login.set(None);
}
});
use_side_effect(move || {
if !*closing_offline.read() {
return;
@ -84,15 +54,13 @@ impl Component for Accounts {
let accounts = try_accounts(&accounts_query).unwrap_or_default();
let default_account = try_default_account(&default_query);
let default_id = default_account.as_ref().map(|a| a.id);
let microsoft_pending =
begin.read().state().is_loading() || finish.read().state().is_loading();
let has_microsoft = accounts_have_microsoft(&accounts);
let offline_name = username.read().trim().to_string();
let offline_uuid = (!offline_name.is_empty())
.then(|| oneclient_core::auth::offline_uuid(&offline_name).to_string());
let offline_error = mutation_err_text(&add_offline);
let microsoft_error = mutation_err_text(&finish).or_else(|| mutation_err_text(&begin));
let on_confirm_offline = move |_| {
let name = username.peek().trim().to_string();
@ -144,10 +112,13 @@ impl Component for Accounts {
)
.child(add_bar(
has_microsoft,
microsoft_pending,
microsoft_error,
msa.pending,
msa.error.clone(),
move |_| show_offline.set(true),
move |_| begin.mutate(()),
{
let msa = msa.clone();
move |_| msa.start()
},
))
.child(
ScrollArea::new()
@ -166,12 +137,7 @@ impl Component for Accounts {
show_offline,
)
}))
.maybe_child(
pending_login
.read()
.clone()
.map(|login| microsoft_dialog(login, pending_login, handled_code)),
)
.maybe_child(msa.popup())
.into_element()
}
}
@ -187,33 +153,6 @@ fn render_panel(account: Option<MinecraftAccount>) -> impl IntoElement {
.corner_radius(CornerRadius::new_all(16.))
.background(colors::page_elevated())
.border(border_all_color(1., colors::component_border()))
// .maybe_child(account.as_ref().map(|a| {
// Avatar::new(a.id.to_string())
// .width(Size::px(120.))
// .height(Size::px(120.))
// .into_element()
// }))
// .maybe_child(account.as_ref().map(|a| {
// label()
// .text(a.username.clone())
// .font_size(18.)
// .font_weight(FontWeight::SEMI_BOLD)
// .color(colors::fg_primary())
// .into_element()
// }))
// .maybe_child(account.is_none().then(|| {
// label()
// .text("No account selected")
// .font_size(14.)
// .color(colors::fg_secondary())
// .into_element()
// }))
// .child(
// label()
// .text("3D preview coming soon")
// .font_size(11.)
// .color(colors::fg_secondary()),
// )
.maybe_child(account.as_ref().map(|account| {
PlayerModel::new(account.id)
.yaw(-0.5)
@ -509,166 +448,6 @@ fn account_row(
.into_element()
}
fn microsoft_dialog(
login: MinecraftLogin,
mut pending_login: State<Option<MinecraftLogin>>,
mut handled_code: State<Option<String>>,
) -> impl IntoElement {
let body = match &login {
MinecraftLogin::DeviceCode(flow) => {
device_code_dialog_body(flow.user_code.clone(), flow.verification_uri.clone())
.into_element()
}
MinecraftLogin::Browser(flow) => {
browser_dialog_body(flow.auth_url.clone()).into_element()
}
};
let mut close_pending = pending_login;
let mut close_handled = handled_code;
OverlayPopup::new()
.on_close(move |()| {
pending_login.set(None);
handled_code.set(None);
})
.child(
rect()
.width(Size::window_percent(100.))
.height(Size::window_percent(100.))
.center()
.child(
rect()
.vertical()
.width(Size::px(420.))
.max_width(Size::window_percent(90.))
.cross_align(Alignment::Center)
.spacing(18.)
.padding(Gaps::new_all(28.))
.corner_radius(CornerRadius::new_all(16.))
.background(colors::page_elevated())
.border(border_all_color(1., colors::component_border()))
.child(
label()
.text("Sign in to Microsoft")
.font_size(18.)
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_primary()),
)
.child(body)
.child(waiting_row())
.child(
Button::new()
.ghost()
.on_press(move |_| {
close_pending.set(None);
close_handled.set(None);
})
.text("Cancel"),
),
),
)
.into_element()
}
fn waiting_row() -> impl IntoElement {
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(6.)
.child(
Icon::new(IconType::Loading02)
.size(13.)
.color(colors::brand()),
)
.child(
label()
.text("Waiting for you to finish signing in...")
.font_size(12.)
.color(colors::fg_secondary()),
)
.into_element()
}
fn browser_dialog_body(auth_url: String) -> impl IntoElement {
rect()
.vertical()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(18.)
.child(
label()
.text("Your browser has opened the Microsoft sign-in page. Complete sign-in there and you'll be brought back automatically.")
.font_size(13.)
.color(colors::fg_secondary()),
)
.child(
Button::new()
.primary()
.on_press(move |_| platform::open_url(&auth_url))
.child(Icon::new(IconType::LinkExternal01).size(16.))
.text("Open in browser again"),
)
.into_element()
}
fn device_code_dialog_body(code: String, verification_uri: String) -> impl IntoElement {
let copy_code = code.clone();
rect()
.vertical()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(18.)
.child(
label()
.text("Enter this code on the Microsoft sign-in page:")
.font_size(13.)
.color(colors::fg_secondary()),
)
.child(
rect()
.width(Size::fill())
.center()
.padding(Gaps::new_symmetric(20., 16.))
.corner_radius(CornerRadius::new_all(14.))
.background(colors::component_bg())
.border(border_all_color(1., colors::brand()))
.child(
label()
.text(code)
.font_size(48.)
.font_weight(FontWeight::BOLD)
.color(colors::fg_primary()),
),
)
.child(
rect()
.horizontal()
.width(Size::fill())
.main_align(Alignment::Center)
.spacing(8.)
.child(
Button::new()
.secondary()
.on_press(move |_| {
if let Err(err) = Clipboard::set(copy_code.clone()) {
tracing::warn!("clipboard copy failed: {err:?}");
}
})
.child(Icon::new(IconType::Copy01).size(16.))
.text("Copy code"),
)
.child(
Button::new()
.primary()
.on_press(move |_| platform::open_url(&verification_uri))
.child(Icon::new(IconType::LinkExternal01).size(16.))
.text("Open in browser"),
),
)
.into_element()
}
fn kind_badge(kind: AccountKind) -> impl IntoElement {
let (icon, text) = match kind {
AccountKind::Microsoft => (IconType::Globe01, "Microsoft"),

View file

@ -66,6 +66,10 @@ impl Component for PackageCard {
let icon_url = self.item.icon_url.clone();
let cluster_id = self.cluster_id;
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let focused = focus().is_focused();
use_drop(|| {
Cursor::set(CursorIcon::default());
});
@ -76,11 +80,21 @@ impl Component for PackageCard {
.height(Size::px(CARD_H))
.corner_radius(CornerRadius::new_all(10.))
.background(CARD_BG)
.border(border_all_color(1., colors::component_border()))
.border(border_all_color(
1.,
if focused {
colors::brand()
} else {
colors::component_border()
},
))
.overflow(Overflow::Clip)
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
.on_press(move |_| open_package(cluster_id, &package_type, provider, &id))
.on_all_press(move |_| open_package(cluster_id, &package_type, provider, &id))
.child(
rect()
.margin(Gaps::new_all(1.))
@ -141,9 +155,35 @@ impl Component for PackageCard {
}
pub(super) fn list_row(item: ProjectSummary, cluster_id: i64, package_type: &str) -> impl IntoElement {
let id = item.id.clone();
let provider = item.provider;
let package_type = package_type.to_string();
ListRow {
item,
cluster_id,
package_type: package_type.to_string(),
}
}
#[derive(PartialEq)]
struct ListRow {
item: ProjectSummary,
cluster_id: i64,
package_type: String,
}
impl Component for ListRow {
fn render(&self) -> impl IntoElement {
let item = self.item.clone();
let cluster_id = self.cluster_id;
let id = item.id.clone();
let provider = item.provider;
let package_type = self.package_type.clone();
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let focused = focus().is_focused();
use_drop(|| {
Cursor::set(CursorIcon::default());
});
rect()
.horizontal()
@ -154,12 +194,22 @@ pub(super) fn list_row(item: ProjectSummary, cluster_id: i64, package_type: &str
.padding(Gaps::new_all(16.))
.corner_radius(CornerRadius::new_all(10.))
.background(CARD_BG)
.border(border_all_color(1., colors::component_border()))
.border(border_all_color(
1.,
if focused {
colors::brand()
} else {
colors::component_border()
},
))
.overflow(Overflow::Clip)
.content(Content::Flex)
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
.on_press(move |_| open_package(cluster_id, &package_type, provider, &id))
.on_all_press(move |_| open_package(cluster_id, &package_type, provider, &id))
.child(Thumbnail::new(item.icon_url.clone(), 48.).radius(8.))
.child(
rect()
@ -198,6 +248,7 @@ pub(super) fn list_row(item: ProjectSummary, cluster_id: i64, package_type: &str
),
)
.child(downloads_row(item.downloads))
}
}
fn downloads_row(downloads: u64) -> impl IntoElement {

View file

@ -77,10 +77,15 @@ impl KeyExt for CategoryRow {
impl Component for CategoryRow {
fn render(&self) -> impl IntoElement {
let mut hovering = use_state(|| false);
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let selected = self.selected;
let on_toggle = self.on_toggle.clone();
let hovered = *hovering.read();
let color = if selected || hovered {
let focused = focus().is_focused();
let color = if selected || hovered || focused {
colors::fg_primary()
} else {
colors::fg_secondary()
@ -89,6 +94,9 @@ impl Component for CategoryRow {
rect()
.width(Size::fill())
.padding(Gaps::new_symmetric(3., 0.))
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.on_pointer_enter(move |_| {
hovering.set(true);
Cursor::set(CursorIcon::Pointer);
@ -97,7 +105,7 @@ impl Component for CategoryRow {
hovering.set(false);
Cursor::set(CursorIcon::default());
})
.on_press(move |_| on_toggle.call(()))
.on_all_press(move |_| on_toggle.call(()))
.child(
label()
.text(capitalize(&self.name))

View file

@ -184,7 +184,7 @@ impl Component for BrowserPackage {
.vertical()
.width(Size::flex(1.0))
.spacing(12.)
.child(tabs(current, active_tab, has_gallery))
.child(tabs(active_tab, has_gallery))
.child(body),
)
.into_element();

View file

@ -1,17 +1,21 @@
use super::*;
use freya::query::QueryStateData;
use oneclient_core::packages::types::{
GalleryImage, PackageBody, ProjectDetail, ReleaseType, VersionSummary,
};
use oneclient_core::packages::ProviderId;
use crate::BridgeDispatch;
use crate::components::{Button, Icon, IconType};
use crate::hooks::VERSIONS_PAGE_SIZE;
use crate::components::{Button, Icon, IconType, Segment, SegmentedControl};
use crate::hooks::{use_cached_image, VERSIONS_PAGE_SIZE};
use crate::theme::colors;
use crate::ui::border_all_color;
use crate::utils::format_size;
const GALLERY_IMAGE_H: f32 = 360.;
const GALLERY_EDGE: u32 = 1024;
pub(super) fn loading_body() -> impl IntoElement {
rect()
@ -27,40 +31,15 @@ pub(super) fn loading_body() -> impl IntoElement {
.into_element()
}
pub(super) fn tabs(current: usize, active_tab: State<usize>, has_gallery: bool) -> impl IntoElement {
let mut labels: Vec<&str> = vec!["About", "Versions"];
pub(super) fn tabs(active_tab: State<usize>, has_gallery: bool) -> impl IntoElement {
let mut control = SegmentedControl::new(active_tab)
.height(36.)
.segment(Segment::new(0usize).label("About"))
.segment(Segment::new(1usize).label("Versions"));
if has_gallery {
labels.push("Gallery");
control = control.segment(Segment::new(2usize).label("Gallery"));
}
rect()
.horizontal()
.spacing(6.)
.padding(Gaps::new_all(4.))
.corner_radius(CornerRadius::new_all(9.))
.background(colors::component_bg())
.children(labels.into_iter().enumerate().map(move |(i, t)| {
let selected = i == current;
let mut active_tab = active_tab;
rect()
.center()
.padding(Gaps::new_symmetric(6., 16.))
.corner_radius(CornerRadius::new_all(7.))
.background(if selected {
colors::component_bg_pressed()
} else {
Color::TRANSPARENT
})
.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
.on_press(move |_| *active_tab.write() = i)
.child(label().text(t).font_size(13.).color(if selected {
colors::fg_primary()
} else {
colors::fg_secondary()
}))
.into_element()
}))
.into_element()
control
}
pub(super) fn about_panel(project: &ProjectDetail) -> impl IntoElement {
@ -137,6 +116,31 @@ impl KeyExt for GalleryTile {
impl Component for GalleryTile {
fn render(&self) -> impl IntoElement {
let query = use_cached_image(Some(self.image.url.clone()), GALLERY_EDGE);
let reader = query.read();
let loaded = match &*reader.state() {
QueryStateData::Settled { res: Ok(bytes), .. }
| QueryStateData::Loading {
res: Some(Ok(bytes)),
} => Some((self.image.url.clone(), bytes.clone())),
_ => None,
};
let preview = rect()
.width(Size::fill())
.height(Size::px(GALLERY_IMAGE_H))
.center()
.overflow(Overflow::Clip)
.background(colors::component_bg())
.maybe_child(loaded.map(|(url, bytes)| {
ImageViewer::new((url, bytes))
.width(Size::fill())
.height(Size::fill())
.aspect_ratio(AspectRatio::Max)
.image_cover(ImageCover::Center)
.into_element()
}));
rect()
.vertical()
.width(Size::fill())
@ -144,7 +148,7 @@ impl Component for GalleryTile {
.overflow(Overflow::Clip)
.background(PANEL_BG)
.border(border_all_color(1., colors::component_border()))
.child(PackageBanner::new(Some(self.image.url.clone()), 240.))
.child(preview)
.maybe(self.image.title.is_some(), |el| {
el.child(
rect()

View file

@ -174,44 +174,78 @@ fn authors_card(project: &ProjectDetail, confirm: State<Option<String>>) -> impl
card("Authors", rows)
}
fn author_row(member: &ProjectMember, mut confirm: State<Option<String>>) -> Element {
let url = member.url.clone();
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(8.)
.corner_radius(CornerRadius::new_all(6.))
.padding(Gaps::new_all(4.))
.maybe(url.is_some(), |el| {
let url = url.clone().unwrap_or_default();
el.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
.on_press(move |_| confirm.set(Some(url.clone())))
})
.child(Thumbnail::new(member.avatar_url.clone(), 32.).radius(6.))
.child(
rect()
.vertical()
.width(Size::flex(1.0))
.child(
label()
.text(member.name.clone())
.font_size(12.)
.max_lines(1)
.color(colors::fg_primary()),
)
.maybe(!member.role.is_empty(), |el| {
el.child(
fn author_row(member: &ProjectMember, confirm: State<Option<String>>) -> Element {
AuthorRow {
name: member.name.clone(),
role: member.role.clone(),
url: member.url.clone(),
avatar_url: member.avatar_url.clone(),
confirm,
}
.into_element()
}
#[derive(PartialEq)]
struct AuthorRow {
name: String,
role: String,
url: Option<String>,
avatar_url: Option<String>,
confirm: State<Option<String>>,
}
impl Component for AuthorRow {
fn render(&self) -> impl IntoElement {
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let focused = focus().is_focused();
let url = self.url.clone();
let interactive = url.is_some();
let mut confirm = self.confirm;
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(8.)
.corner_radius(CornerRadius::new_all(6.))
.padding(Gaps::new_all(4.))
.maybe(interactive, |el| {
let url = url.clone().unwrap_or_default();
el.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
.on_all_press(move |_| confirm.set(Some(url.clone())))
})
.maybe(interactive && focused, |el| {
el.border(border_all_color(1., colors::brand()))
})
.child(Thumbnail::new(self.avatar_url.clone(), 32.).radius(6.))
.child(
rect()
.vertical()
.width(Size::flex(1.0))
.child(
label()
.text(member.role.clone())
.font_size(10.)
.text(self.name.clone())
.font_size(12.)
.max_lines(1)
.color(colors::fg_secondary()),
.color(colors::fg_primary()),
)
}),
)
.into_element()
.maybe(!self.role.is_empty(), |el| {
el.child(
label()
.text(self.role.clone())
.font_size(10.)
.max_lines(1)
.color(colors::fg_secondary()),
)
}),
)
}
}
fn details_card(project: &ProjectDetail) -> impl IntoElement {
@ -282,25 +316,57 @@ fn links_card(links: &[(String, String)], confirm: State<Option<String>>) -> imp
card("Links", rows)
}
fn link_row(label_text: String, url: String, mut confirm: State<Option<String>>) -> Element {
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(8.)
.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
.on_press(move |_| confirm.set(Some(url.clone())))
.child(
Icon::new(IconType::Link03)
.size(14.)
.color(colors::code_info()),
)
.child(
label()
.text(label_text)
.font_size(12.)
.color(colors::code_info()),
)
.into_element()
fn link_row(label_text: String, url: String, confirm: State<Option<String>>) -> Element {
LinkRow {
label_text,
url,
confirm,
}
.into_element()
}
#[derive(PartialEq)]
struct LinkRow {
label_text: String,
url: String,
confirm: State<Option<String>>,
}
impl Component for LinkRow {
fn render(&self) -> impl IntoElement {
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let focused = focus().is_focused();
let url = self.url.clone();
let mut confirm = self.confirm;
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(8.)
.corner_radius(CornerRadius::new_all(6.))
.padding(Gaps::new_all(4.))
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.maybe(focused, |el| {
el.border(border_all_color(1., colors::brand()))
})
.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
.on_all_press(move |_| confirm.set(Some(url.clone())))
.child(
Icon::new(IconType::Link03)
.size(14.)
.color(colors::code_info()),
)
.child(
label()
.text(self.label_text.clone())
.font_size(12.)
.color(colors::code_info()),
)
}
}

View file

@ -6,7 +6,7 @@ use oneclient_core::packages::domain::GameLoader;
use oneclient_core::settings::{GameSettingsProfile, ProfileUpdate, Resolution};
use crate::components::{
Dropdown, Icon, IconType, ScrollArea, TextInput, toggle, toggle_controlled, validate_number,
Button, Dropdown, Icon, IconType, ScrollArea, TextInput, toggle, toggle_controlled, validate_number
};
use crate::hooks::{
ClusterAction, java_runtimes, loader_versions, try_game_profile, use_cluster_mutation,
@ -147,16 +147,15 @@ fn reset_button(overridden: bool, on_reset: EventHandler<()>) -> impl IntoElemen
colors::fg_secondary().with_a(60)
};
rect()
.center()
.width(Size::px(30.))
.height(Size::px(30.))
Button::new()
.small()
.ghost()
.icon()
.corner_radius(CornerRadius::new_all(7.))
.maybe(overridden, |el| {
el.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
.on_press(move |_| on_reset.call(()))
el.on_press(move |_| on_reset.call(()))
})
.enabled(overridden)
.child(Icon::new(IconType::RefreshCcw02).size(15.).color(color))
.into_element()
}

View file

@ -355,6 +355,9 @@ impl Component for LogPicker {
let mut open = use_state(|| false);
let filter = use_state(String::new);
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let enabled = self.enabled;
let label_text = self.label.clone();
let files = self.files.clone();
@ -400,14 +403,24 @@ impl Component for LogPicker {
.padding(Gaps::new_symmetric(0., 12.))
.corner_radius(CornerRadius::new_all(9.))
.background(colors::component_bg())
.border(ui::border_all_color(1., colors::component_border()))
.a11y_id(a11y_id)
.a11y_focusable(enabled)
.a11y_role(AccessibilityRole::Button)
.border(ui::border_all_color(
1.,
if enabled && focus().is_focused() {
colors::brand()
} else {
colors::component_border()
},
))
.on_pointer_enter(move |_| {
if enabled {
Cursor::set(CursorIcon::Pointer)
}
})
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
.on_press(move |e: Event<PressEventData>| {
.on_all_press(move |e: Event<PressEventData>| {
if !enabled {
return;
}
@ -495,6 +508,9 @@ impl Component for PickerRow {
let mut hovering = use_state(|| false);
let file = self.file.clone();
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
rect()
.width(Size::fill())
.horizontal()
@ -503,7 +519,10 @@ impl Component for PickerRow {
.spacing(8.)
.padding(Gaps::new_symmetric(6., 8.))
.corner_radius(CornerRadius::new_all(7.))
.background(if hovering() {
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.background(if hovering() || focus().is_focused() {
colors::ghost_overlay_hover()
} else {
Color::TRANSPARENT
@ -516,7 +535,7 @@ impl Component for PickerRow {
hovering.set(false);
Cursor::set(CursorIcon::default());
})
.on_press(self.on_press.clone())
.on_all_press(self.on_press.clone())
.child(
rect()
.vertical()

View file

@ -1,5 +1,6 @@
use super::*;
use freya::animation::{AnimNum, Ease, OnCreation, use_animation};
use freya::router::RouterContext;
use oneclient_core::packages::ContentType;
use oneclient_core::settings::ViewLayout;
@ -184,7 +185,6 @@ struct FilterButton {
impl Component for FilterButton {
fn render(&self) -> impl IntoElement {
let mut open = use_state(|| false);
let mut hovering = use_state(|| false);
let sort = self.sort;
let current_sort = self.current_sort;
@ -194,11 +194,6 @@ impl Component for FilterButton {
let active =
current_sort != SortMode::NameAsc || *enabled_filter.read() != EnabledFilter::All;
let bg = if is_open || hovering() {
colors::component_bg_hover()
} else {
colors::component_bg()
};
let icon_color = if active {
colors::brand()
} else {
@ -208,21 +203,11 @@ impl Component for FilterButton {
rect()
.width(Size::px(FILTER_BTN_W))
.child(
rect()
Button::new()
.secondary()
.icon()
.width(Size::px(FILTER_BTN_W))
.height(Size::px(34.))
.center()
.corner_radius(CornerRadius::new_all(8.))
.background(bg)
.border(crate::ui::border_all_color(1., colors::component_border()))
.on_pointer_enter(move |_| {
hovering.set(true);
Cursor::set(CursorIcon::Pointer);
})
.on_pointer_leave(move |_| {
hovering.set(false);
Cursor::set(CursorIcon::default());
})
.on_press(move |e: Event<PressEventData>| {
e.stop_propagation();
open.toggle();
@ -230,67 +215,112 @@ impl Component for FilterButton {
.child(Icon::new(IconType::Sliders04).size(16.).color(icon_color)),
)
.maybe_child(is_open.then(|| {
filter_popover(sort, current_sort, enabled_filter, move || open.set(false))
.into_element()
let on_close: EventHandler<()> = (move |()| open.set(false)).into();
FilterPopover {
sort,
current_sort,
enabled_filter,
on_close,
}
.into_element()
}))
}
}
fn filter_popover(
mut sort: State<Option<String>>,
#[derive(PartialEq)]
struct FilterPopover {
sort: State<Option<String>>,
current_sort: SortMode,
mut enabled_filter: State<EnabledFilter>,
on_close: impl FnMut() + Clone + 'static,
) -> impl IntoElement {
let mut close_backdrop = on_close.clone();
enabled_filter: State<EnabledFilter>,
on_close: EventHandler<()>,
}
let show = *enabled_filter.read();
impl Component for FilterPopover {
fn render(&self) -> impl IntoElement {
let mut sort = self.sort;
let current_sort = self.current_sort;
let mut enabled_filter = self.enabled_filter;
let backdrop_close = self.on_close.clone();
let key_close = self.on_close.clone();
let mut panel = rect()
.vertical()
.width(Size::fill())
.spacing(4.)
.padding(Gaps::new_all(8.))
.corner_radius(CornerRadius::new_all(10.))
.border(crate::ui::border_all_color(1., colors::component_border()))
.background(colors::page_elevated())
.child(section_label("Sort by"));
let a11y_id = use_a11y();
for mode in SortMode::ALL {
let selected = mode == current_sort;
panel = panel.child(choice_row(mode.label(), selected, move |_| {
sort.set(Some(mode.key().to_string()));
}));
let fade = use_animation(|conf| {
conf.on_creation(OnCreation::Run);
AnimNum::new(0., 1.).time(160).ease(Ease::Out)
});
let progress = fade.read().value();
let show = *enabled_filter.read();
let mut panel = rect()
.vertical()
.width(Size::fill())
.spacing(4.)
.padding(Gaps::new_all(8.))
.opacity(progress)
.offset_y((progress - 1.0) * 6.)
.corner_radius(CornerRadius::new_all(10.))
.background(colors::page_elevated().with_a(230))
.blur(12.)
.border(crate::ui::border_all_color(1., colors::component_border()))
.shadow(Shadow::from((0., 8., 32., 0., Color::from_argb(120, 0, 0, 0))))
.a11y_id(a11y_id)
.a11y_role(AccessibilityRole::Dialog)
.on_global_key_down(move |e: Event<KeyboardEventData>| {
if e.key == Key::Named(NamedKey::Escape) {
key_close.call(());
}
})
.child(section_label("Sort by"));
for mode in SortMode::ALL {
let selected = mode == current_sort;
let on_press: EventHandler<Event<PressEventData>> = (move |_| {
sort.set(Some(mode.key().to_string()));
})
.into();
panel = panel.child(ChoiceRow {
text: mode.label(),
selected,
on_press,
});
}
panel = panel.child(section_label("Show"));
for filter in EnabledFilter::ALL {
let selected = filter == show;
let on_press: EventHandler<Event<PressEventData>> = (move |_| {
enabled_filter.set(filter);
})
.into();
panel = panel.child(ChoiceRow {
text: filter.label(),
selected,
on_press,
});
}
rect()
.height(Size::px(0.))
.width(Size::px(FILTER_PANEL_W))
.layer(Layer::Overlay)
.child(
rect()
.layer(Layer::RelativeOverlay(10))
.position(Position::new_global().top(0.).left(0.))
.width(Size::window_percent(100.))
.height(Size::window_percent(100.))
.on_press(move |_| backdrop_close.call(())),
)
.child(
rect()
.width(Size::fill())
.layer(Layer::RelativeOverlay(12))
.margin(Gaps::new(6., 0., 0., -(FILTER_PANEL_W - FILTER_BTN_W)))
.child(panel),
)
}
panel = panel.child(section_label("Show"));
for filter in EnabledFilter::ALL {
let selected = filter == show;
panel = panel.child(choice_row(filter.label(), selected, move |_| {
enabled_filter.set(filter);
}));
}
rect()
.height(Size::px(0.))
.width(Size::px(FILTER_PANEL_W))
.layer(Layer::Overlay)
.child(
rect()
.layer(Layer::RelativeOverlay(10))
.position(Position::new_global().top(0.).left(0.))
.width(Size::window_percent(100.))
.height(Size::window_percent(100.))
.on_press(move |_| close_backdrop()),
)
.child(
rect()
.width(Size::fill())
.layer(Layer::RelativeOverlay(12))
.margin(Gaps::new(6., 0., 0., -(FILTER_PANEL_W - FILTER_BTN_W)))
.child(panel),
)
.into_element()
}
fn section_label(text: &'static str) -> impl IntoElement {
@ -301,45 +331,68 @@ fn section_label(text: &'static str) -> impl IntoElement {
.color(colors::fg_secondary())
}
fn choice_row(
#[derive(PartialEq)]
struct ChoiceRow {
text: &'static str,
selected: bool,
on_press: impl FnMut(Event<PressEventData>) + 'static,
) -> impl IntoElement {
let color = if selected {
colors::fg_primary()
} else {
colors::fg_secondary()
};
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(8.)
.padding(Gaps::new_symmetric(5., 8.))
.corner_radius(CornerRadius::new_all(6.))
.background(if selected {
on_press: EventHandler<Event<PressEventData>>,
}
impl Component for ChoiceRow {
fn render(&self) -> impl IntoElement {
let text = self.text;
let selected = self.selected;
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let focused = focus().is_focused();
let color = if selected {
colors::fg_primary()
} else {
colors::fg_secondary()
};
let bg = if selected {
colors::component_bg()
} else if focused {
colors::ghost_overlay_hover()
} else {
Color::TRANSPARENT
})
.content(Content::Flex)
.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
.on_press(on_press)
.child(
label()
.text(text)
.font_size(12.)
.width(Size::flex(1.0))
.color(color),
)
.maybe_child(selected.then(|| {
Icon::new(IconType::Check)
.size(14.)
.color(colors::brand())
.into_element()
}))
};
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(8.)
.padding(Gaps::new_symmetric(5., 8.))
.corner_radius(CornerRadius::new_all(6.))
.background(bg)
.content(Content::Flex)
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::MenuItemRadio)
.maybe(focused, |el| {
el.border(crate::ui::border_all_color(1., colors::brand()))
})
.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
.on_all_press(self.on_press.clone())
.child(
label()
.text(text)
.font_size(12.)
.width(Size::flex(1.0))
.color(color),
)
.maybe_child(selected.then(|| {
Icon::new(IconType::Check)
.size(14.)
.color(colors::brand())
.into_element()
}))
}
}
fn add_from_file_button(
@ -352,16 +405,18 @@ fn add_from_file_button(
.on_press(move |_| {
let dispatch = dispatch.clone();
spawn(async move {
if let Some(handle) = rfd::AsyncFileDialog::new()
.set_title("Select a file to import")
.pick_file()
if let Some(handles) = rfd::AsyncFileDialog::new()
.set_title("Select files to import")
.pick_files()
.await
{
dispatch.import_local_file(
cluster_id,
content_type,
handle.path().to_path_buf(),
);
for handle in handles {
dispatch.import_local_file(
cluster_id,
content_type,
handle.path().to_path_buf(),
);
}
}
});
})
@ -470,14 +525,29 @@ impl Component for ContentBox {
ContentKind::Other => empty_state(noun_plural).into_element(),
});
let header = (count > 0)
.then(|| match kind {
ContentKind::External => Some(
action_header(browse_button(cluster_id, package_type)).into_element(),
),
ContentKind::Local => Some(
action_header(add_from_file_button(cluster_id, content_type, dispatch.clone()))
.into_element(),
),
ContentKind::Other => None,
})
.flatten();
let mut bottom_corners = CornerRadius::new_all(0.);
bottom_corners.fill_bottom(12.);
let drop_dispatch = dispatch.clone();
rect()
.vertical()
.width(Size::fill())
.height(Size::flex(1.0))
.spacing(8.)
.padding(Gaps::new_all(8.))
.corner_radius(bottom_corners)
.background(colors::page_elevated())
@ -498,11 +568,22 @@ impl Component for ContentBox {
drop_dispatch.import_local_file(cluster_id, content_type, path.clone());
}
})
.maybe_child(header)
.maybe_child(scroll)
.maybe_child(empty)
}
}
fn action_header(button: impl IntoElement) -> impl IntoElement {
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.content(Content::Flex)
.child(rect().width(Size::flex(1.0)))
.child(button)
}
fn grid_content(
items: &[PackageEntry],
package_type: &'static str,

View file

@ -3,6 +3,7 @@ use freya::router::RouterContext;
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;
@ -20,48 +21,58 @@ impl Component for Debug {
.vertical()
.width(Size::fill())
.height(Size::fill())
.overflow(Overflow::Clip)
.padding(40.)
.spacing(8.)
.child(
rect()
.vertical()
ScrollView::new()
.child(
label()
.text("Debug")
.font_size(32.)
.font_weight(FontWeight::BOLD)
.color(colors::fg_primary()),
rect()
.vertical()
.child(
label()
.text("Debug")
.font_size(32.)
.font_weight(FontWeight::BOLD)
.color(colors::fg_primary()),
)
.child(
label()
.text("The end user won't really be looking at this page.")
.font_size(13.)
.color(colors::fg_secondary()),
),
)
.child(
label()
.text("The end user won't really be looking at this page.")
.font_size(13.)
.color(colors::fg_secondary()),
),
.child(divider())
.child(section(
"Settings",
vec![
toggle_row("Log Debug Info", log_debug_info),
toggle_row("Show Dev stuff", show_dev_stuff),
toggle_row("Seen Onboarding", seen_onboarding),
toggle_row("Use Grid On Mods List", use_grid_on_mods_list),
],
))
.child(divider())
.child(section(
"Toast Controller",
vec![ToastController.into_element()],
))
.child(divider())
.child(section(
"Cluster Update Simulator",
vec![ClusterUpdateSimulator.into_element()],
))
.child(divider())
.child(section(
"Other",
vec![action_row(vec![
("Open Dev Tools", IconType::CodeSnippet02),
("Open Onboarding", IconType::Rocket02),
("Open Launcher Data", IconType::Folder),
("Log Running Processes", IconType::Terminal),
])],
)),
)
.child(divider())
.child(section(
"Settings",
vec![
toggle_row("Log Debug Info", log_debug_info),
toggle_row("Show Dev stuff", show_dev_stuff),
toggle_row("Seen Onboarding", seen_onboarding),
toggle_row("Use Grid On Mods List", use_grid_on_mods_list),
],
))
.child(divider())
.child(section("Toast Controller", vec![ToastController.into_element()]))
.child(divider())
.child(section(
"Other",
vec![action_row(vec![
("Open Dev Tools", IconType::CodeSnippet02),
("Open Onboarding", IconType::Rocket02),
("Open Launcher Data", IconType::Folder),
("Log Running Processes", IconType::Terminal),
])],
))
}
}
@ -123,7 +134,8 @@ impl Component for ToastController {
.child(Icon::new(IconType::AlertTriangle).size(16.))
.text("Send Error")
.on_press(move |_| {
error.notify(title.read().clone())
error
.notify(title.read().clone())
.body(body.read().clone())
.error()
.send();
@ -154,6 +166,101 @@ impl Component for ToastController {
}
}
#[derive(PartialEq)]
struct ClusterUpdateSimulator;
impl Component for ClusterUpdateSimulator {
fn render(&self) -> impl IntoElement {
let dispatch = use_dispatch();
let cluster_id = use_state(|| "1".to_string());
let cluster_name = use_state(|| "PolyBlock".to_string());
let updated = use_state(|| "Sodium 0.5 → 0.6, Iris 1.7 → 1.8".to_string());
let added = use_state(|| "Lithium".to_string());
let removed = use_state(|| "OptiFine".to_string());
let simulate = dispatch;
rect()
.vertical()
.width(Size::fill())
.spacing(10.)
.child(
rect()
.horizontal()
.width(Size::fill())
.spacing(12.)
.child(
rect()
.width(Size::px(120.))
.child(TextInput::new(cluster_id).placeholder("Cluster ID")),
)
.child(
rect()
.width(Size::flex(1.0))
.child(TextInput::new(cluster_name).placeholder("Cluster name")),
),
)
.child(
rect()
.horizontal()
.width(Size::fill())
.spacing(12.)
.child(
rect().width(Size::flex(1.0)).child(
TextInput::new(updated).placeholder("Updated (comma separated)"),
),
)
.child(
rect()
.width(Size::flex(1.0))
.child(TextInput::new(added).placeholder("Added (comma separated)")),
)
.child(
rect().width(Size::flex(1.0)).child(
TextInput::new(removed).placeholder("Removed (comma separated)"),
),
),
)
.child(
Button::new()
.primary()
.child(Icon::new(IconType::DownloadCloud02).size(16.))
.text("Simulate Cluster Update")
.on_press(move |_| {
let name = cluster_name.read().clone();
let summary = ClusterUpdateSummary {
cluster_id: cluster_id.read().trim().parse().unwrap_or(1),
cluster_name: name.clone(),
updated: split_csv(&updated.read()),
added: split_csv(&added.read()),
removed: split_csv(&removed.read()),
};
let total = summary.total();
simulate
.notify("Cluster updated")
.body(format!(
"{total} package{} changed in {name}",
if total == 1 { "" } else { "s" }
))
.icon(IconType::DownloadCloud02)
.action(NotificationAction {
label: "View changes".to_string(),
kind: NotificationActionKind::OpenClusterUpdate(summary),
})
.send();
}),
)
.into_element()
}
}
fn split_csv(raw: &str) -> Vec<String> {
raw.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
fn divider() -> impl IntoElement {
rect()
.width(Size::fill())
@ -230,10 +337,7 @@ fn toggle_row(title: &'static str, on: State<bool>) -> Element {
}
fn action_row(buttons: Vec<(&'static str, IconType)>) -> Element {
let mut row = rect()
.horizontal()
.width(Size::fill())
.spacing(12.);
let mut row = rect().horizontal().width(Size::fill()).spacing(12.);
for (text, icon) in buttons {
let mut button = Button::new()

View file

@ -15,6 +15,8 @@ fn providers() -> Vec<(JavaVendor, &'static str)> {
vec![
(JavaVendor::Zulu, "Azul Zulu"),
(JavaVendor::Adoptium, "Eclipse Temurin"),
(JavaVendor::Corretto, "Amazon Corretto"),
(JavaVendor::Liberica, "BellSoft Liberica"),
]
}
@ -209,18 +211,31 @@ impl Component for RuntimeRow {
.child(
rect()
.width(Size::flex(1.0))
.overflow(Overflow::Clip)
.child(
label()
.text(runtime.absolute_path.clone())
.font_size(12.)
.max_lines(1)
.color(colors::fg_secondary()),
ScrollArea::new()
.horizontal(path_content_width(&runtime.absolute_path))
.width(Size::fill())
.height(Size::px(18.))
.show_scrollbar(false)
.child(
label()
.text(runtime.absolute_path.clone())
.font_size(12.)
.max_lines(1)
.color(colors::fg_secondary())
.into_element(),
),
),
)
.child(remove_button(dispatch, path))
}
}
fn path_content_width(path: &str) -> f32 {
(path.chars().count() as f32 * 7.0).max(1.0)
}
fn remove_button(dispatch: BridgeDispatch, path: String) -> impl IntoElement {
Button::new()
.ghost()

View file

@ -19,24 +19,18 @@ impl Component for SettingsLauncher {
let v = settings.discord_enabled;
move || v
});
let browser_login = use_state({
let v = settings.microsoft_login_use_browser;
move || v
});
let mut first = use_state(|| true);
{
let settings = settings.clone();
use_side_effect(move || {
let discord = *discord_rpc.read();
let browser = *browser_login.read();
if *first.peek() {
first.set(false);
return;
}
let mut next = settings.clone();
next.discord_enabled = discord;
next.microsoft_login_use_browser = browser;
dispatch.set_settings(next);
});
}
@ -52,12 +46,6 @@ impl Component for SettingsLauncher {
"Enable Discord Rich Presence.",
toggle(discord_rpc),
))
.child(settings_row(
IconType::Globe01,
"Browser sign-in",
"Sign in to Microsoft by opening your browser instead of entering a code.",
toggle(browser_login),
))
.child(section_header("FOLDERS AND FILES"))
.child(settings_row(
IconType::Folder,

View file

@ -1,17 +1,10 @@
use freya::prelude::*;
use freya::query::{MutationCapability, MutationStateData, UseMutation};
use freya::text_edit::Clipboard;
use oneclient_core::auth::{MinecraftAccount, MinecraftLogin};
use oneclient_core::auth::MinecraftAccount;
use crate::components::{Avatar, Button, Icon, IconType, OverlayPopup};
use crate::hooks::{
login_code_already_handled, try_default_account, use_begin_microsoft_login,
use_current_account, use_finish_microsoft_login,
};
use crate::platform;
use crate::components::{Avatar, Button, Icon, IconType, use_microsoft_login};
use crate::hooks::{try_default_account, use_current_account};
use crate::routes::Route;
use crate::theme::colors;
use crate::ui::border_all_color;
use crate::view::onboarding::{
onboarding_illustration, onboarding_nav, onboarding_page, step_heading,
};
@ -22,42 +15,10 @@ pub struct OnboardingAccount;
impl Component for OnboardingAccount {
fn render(&self) -> impl IntoElement {
let account_query = use_current_account();
let begin = use_begin_microsoft_login();
let finish = use_finish_microsoft_login();
let mut pending_login = use_state(|| None::<MinecraftLogin>);
use_side_effect(move || {
let login = match &*begin.read().state() {
MutationStateData::Settled { res: Ok(login), .. } => Some(login.clone()),
_ => None,
};
let Some(login) = login else { return };
if login_code_already_handled(login.dedupe_key()) {
return;
}
// Browser flow: open the system browser straight away so the user
// lands on the Microsoft sign-in page without copying a code.
if let Some(url) = login.browser_url() {
platform::open_url(url);
}
finish.mutate(login.clone());
pending_login.set(Some(login));
});
use_side_effect(move || {
if matches!(&*finish.read().state(), MutationStateData::Settled { .. })
&& pending_login.peek().is_some()
{
pending_login.set(None);
}
});
let msa = use_microsoft_login();
let account = try_default_account(&account_query);
let has_account = account.is_some();
let pending = begin.read().state().is_loading() || finish.read().state().is_loading();
let error = mutation_err_text(&finish).or_else(|| mutation_err_text(&begin));
let content = rect()
.vertical()
@ -69,7 +30,11 @@ impl Component for OnboardingAccount {
))
.child(match &account {
Some(account) => account_preview(account).into_element(),
None => sign_in_card(pending, error, move |_| begin.mutate(())).into_element(),
None => {
let start = msa.clone();
sign_in_card(msa.pending, msa.error.clone(), move |_| start.start())
.into_element()
}
})
.into_element();
@ -87,26 +52,7 @@ impl Component for OnboardingAccount {
.width(Size::fill())
.height(Size::fill())
.child(page)
.maybe_child(
pending_login
.read()
.clone()
.map(|login| microsoft_dialog(login, pending_login)),
)
}
}
fn mutation_err_text<M>(mutation: &UseMutation<M>) -> Option<String>
where
M: MutationCapability,
M::Err: std::fmt::Display,
{
match &*mutation.read().state() {
MutationStateData::Settled { res: Err(err), .. } => Some(err.to_string()),
MutationStateData::Loading {
res: Some(Err(err)),
} => Some(err.to_string()),
_ => None,
.maybe_child(msa.popup())
}
}
@ -114,23 +60,7 @@ fn account_preview(account: &MinecraftAccount) -> impl IntoElement {
rect()
.horizontal()
.width(Size::fill())
// .height(Size::px(260.))
.spacing(24.)
// .cross_align(Alignment::Center)
// .child(
// rect()
// .width(Size::px(200.))
// .height(Size::fill())
// .corner_radius(CornerRadius::new_all(16.))
// .background(colors::page_elevated())
// .border(border_all_color(1., colors::component_border()))
// .child(
// PlayerModel::new(account.id)
// .yaw(-0.5)
// .width(Size::fill())
// .height(Size::fill()),
// ),
// )
.child(
rect()
.horizontal()
@ -205,153 +135,3 @@ fn sign_in_card(
}))
.into_element()
}
fn microsoft_dialog(
login: MinecraftLogin,
mut pending_login: State<Option<MinecraftLogin>>,
) -> impl IntoElement {
let body = match &login {
MinecraftLogin::DeviceCode(flow) => {
device_code_dialog_body(flow.user_code.clone(), flow.verification_uri.clone())
.into_element()
}
MinecraftLogin::Browser(flow) => {
browser_dialog_body(flow.auth_url.clone()).into_element()
}
};
OverlayPopup::new()
.on_close(move |()| pending_login.set(None))
.child(
rect()
.width(Size::window_percent(100.))
.height(Size::window_percent(100.))
.center()
.child(
rect()
.vertical()
.width(Size::px(420.))
.max_width(Size::window_percent(90.))
.cross_align(Alignment::Center)
.spacing(18.)
.padding(Gaps::new_all(28.))
.corner_radius(CornerRadius::new_all(16.))
.background(colors::page_elevated())
.border(border_all_color(1., colors::component_border()))
.child(
label()
.text("Sign in to Microsoft")
.font_size(18.)
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_primary()),
)
.child(body)
.child(waiting_row())
.child(
Button::new()
.ghost()
.on_press(move |_| pending_login.set(None))
.text("Cancel"),
),
),
)
.into_element()
}
fn waiting_row() -> impl IntoElement {
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(6.)
.child(
Icon::new(IconType::Loading02)
.size(13.)
.color(colors::brand()),
)
.child(
label()
.text("Waiting for you to finish signing in...")
.font_size(12.)
.color(colors::fg_secondary()),
)
.into_element()
}
fn browser_dialog_body(auth_url: String) -> impl IntoElement {
rect()
.vertical()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(18.)
.child(
label()
.text("Your browser has opened the Microsoft sign-in page. Complete sign-in there and you'll be brought back automatically.")
.font_size(13.)
.color(colors::fg_secondary()),
)
.child(
Button::new()
.primary()
.on_press(move |_| platform::open_url(&auth_url))
.child(Icon::new(IconType::LinkExternal01).size(16.))
.text("Open in browser again"),
)
.into_element()
}
fn device_code_dialog_body(code: String, verification_uri: String) -> impl IntoElement {
let copy_code = code.clone();
rect()
.vertical()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(18.)
.child(
label()
.text("Enter this code on the Microsoft sign-in page:")
.font_size(13.)
.color(colors::fg_secondary()),
)
.child(
rect()
.width(Size::fill())
.center()
.padding(Gaps::new_symmetric(20., 16.))
.corner_radius(CornerRadius::new_all(14.))
.background(colors::component_bg())
.border(border_all_color(1., colors::brand()))
.child(
label()
.text(code)
.font_size(48.)
.font_weight(FontWeight::BOLD)
.color(colors::fg_primary()),
),
)
.child(
rect()
.horizontal()
.width(Size::fill())
.main_align(Alignment::Center)
.spacing(8.)
.child(
Button::new()
.secondary()
.on_press(move |_| {
if let Err(err) = Clipboard::set(copy_code.clone()) {
tracing::warn!("clipboard copy failed: {err:?}");
}
})
.child(Icon::new(IconType::Copy01).size(16.))
.text("Copy code"),
)
.child(
Button::new()
.primary()
.on_press(move |_| platform::open_url(&verification_uri))
.child(Icon::new(IconType::LinkExternal01).size(16.))
.text("Open in browser"),
),
)
.into_element()
}

View file

@ -57,6 +57,7 @@ struct TaskLine {
struct GroupedAgg {
children: HashMap<Uuid, TaskLine>,
done_units: u64,
carried: u64,
}
impl GroupedAgg {
@ -82,7 +83,8 @@ impl GroupedAgg {
}
fn downloaded_bytes(&self) -> u64 {
self.done_units
self.carried
+ self.done_units
+ self
.children
.values()
@ -260,8 +262,12 @@ impl Component for OnboardingDownloading {
let is_running = *running.read();
let is_complete = *complete.read();
let global = if total == 0 {
if is_complete { 100.0 } else { 0.0 }
let global = if is_complete {
100.0
} else if total == 0 {
0.0
} else if total_estimate_now > 0 {
(agg_now.downloaded_bytes() as f32 / total_estimate_now as f32 * 100.0).clamp(0.0, 99.0)
} else {
((done as f32 + agg_now.fraction()) / total as f32 * 100.0).clamp(0.0, 100.0)
};
@ -531,7 +537,7 @@ fn run_install_batch(plans: Vec<ClusterPlan>, predownload: bool, handles: Instal
let downloaded = agg.peek().downloaded_bytes();
let delta = downloaded.saturating_sub(prev);
let inst = delta as f64 / 0.5;
let inst = delta as f64;
speed = if speed <= 0.0 {
inst
@ -755,6 +761,12 @@ fn apply_grouped(agg: &mut GroupedAgg, event: GroupedProgressEvent) {
}
}
GroupedProgressEvent::End { .. } => {
agg.carried += agg.done_units
+ agg
.children
.values()
.map(|t| t.current.min(t.total))
.sum::<u64>();
agg.children.clear();
agg.done_units = 0;
}

View file

@ -1,7 +1,7 @@
use std::process::Command;
use oneclient_core::auth::{self, MinecraftLogin};
use oneclient_core::auth::{self};
use oneclient_core::{dev, logger, LauncherResult};
#[tokio::main]
@ -9,23 +9,18 @@ async fn main() -> LauncherResult<()> {
logger::init_debug()?;
dev::initialize().await?;
let flow = auth::begin_microsoft_login().await?;
let session = auth::begin_microsoft_login().await?;
match &flow {
MinecraftLogin::DeviceCode(device) => {
println!("{}\n", device.message);
println!("Verification URL: {}", device.verification_uri);
println!("Enter this code: {}\n", device.user_code);
try_open_browser(&device.verification_uri);
}
MinecraftLogin::Browser(browser) => {
println!("Opening browser to sign in...\n");
try_open_browser(&browser.auth_url);
}
}
println!("Sign in with EITHER method:\n");
println!(" Browser: opening {}", session.browser.auth_url);
try_open_browser(&session.browser.auth_url);
println!(
" Device code: go to {} and enter {}\n",
session.device.verification_uri, session.device.user_code
);
println!("Waiting for you to sign in...");
let account = auth::finish_microsoft_login(flow).await?;
let account = auth::finish_microsoft_login(session).await?;
println!(
"Signed in as {} ({})\n token expires: {}",

View file

@ -1,4 +1,4 @@
#![recursion_limit = "256"]
use oneclient_core::clusters::{ClusterManager, CreateClusterOptions};
use oneclient_core::dev;
use oneclient_core::packages::domain::GameLoader;

View file

@ -4,7 +4,9 @@ use std::env;
use oneclient_core::dev;
use oneclient_core::java::check_java_runtime;
use oneclient_core::java::vendors::AdoptiumRuntimeProvider;
use oneclient_core::java::vendors::CorrettoRuntimeProvider;
use oneclient_core::java::vendors::JavaRuntimeProvider;
use oneclient_core::java::vendors::LibericaRuntimeProvider;
use oneclient_core::java::vendors::ZuluRuntimeProvider;
use oneclient_core::logger;
use oneclient_core::LauncherResult;
@ -18,7 +20,7 @@ async fn main() -> LauncherResult<()> {
let vendor = args
.next()
.unwrap_or_else(|| {
eprintln!("usage: java_install_provider <zulu|adoptium|corretto> [major]");
eprintln!("usage: java_install_provider <zulu|adoptium|corretto|liberica> [major]");
std::process::exit(1);
});
@ -33,13 +35,15 @@ async fn main() -> LauncherResult<()> {
let provider: Box<dyn JavaRuntimeProvider> = match vendor.as_str() {
"zulu" => Box::new(ZuluRuntimeProvider),
"adoptium" => Box::new(AdoptiumRuntimeProvider),
"corretto" => Box::new(CorrettoRuntimeProvider),
"liberica" => Box::new(LibericaRuntimeProvider),
other => {
eprintln!("unknown vendor '{other}', use zulu, adoptium, or corretto");
eprintln!("unknown vendor '{other}', use zulu, adoptium, corretto, or liberica");
std::process::exit(1);
}
};
let packages = provider.list_packages_by_major(major, &services).await?;
let packages = provider.list_packages(Some(major), &services).await?;
let package = packages
.into_iter()
.find(|p| p.java_version.contains(&major))

View file

@ -10,19 +10,25 @@ use oneclient_core::logger;
async fn main() -> LauncherResult<()> {
logger::init_debug()?;
let major: u32 = env::args()
.nth(1)
.unwrap_or_else(|| "21".to_string())
.parse()
.expect("usage: java_list_providers [major_version]");
let arg = env::args().nth(1).unwrap_or_else(|| "21".to_string());
let major: Option<u32> = if arg == "all" {
None
} else {
Some(arg.parse().expect("usage: java_list_providers [major|all]"))
};
let services = dev::ephemeral_services().await?;
println!("Listing JRE packages for Java {major} (metadata only):\n");
match major {
Some(major) => println!("Listing JRE packages for Java {major} (metadata only):\n"),
None => println!("Listing JRE packages for all majors (metadata only):\n"),
}
for provider in runtime_providers() {
let packages = provider.list_packages_by_major(major, &services).await?;
print_vendor(provider.vendor(), &packages);
match provider.list_packages(major, &services).await {
Ok(packages) => print_vendor(provider.vendor(), &packages),
Err(err) => println!("{:?}: ERROR {err}", provider.vendor()),
}
}
Ok(())

View file

@ -1,3 +1,4 @@
#![recursion_limit = "256"]
use oneclient_core::LauncherResult;
use oneclient_core::auth;

View file

@ -32,6 +32,10 @@ impl MinecraftAccount {
pub fn is_offline(&self) -> bool {
self.kind == AccountKind::Offline
}
pub fn is_expired(&self) -> bool {
self.expires <= Utc::now() + chrono::TimeDelta::seconds(60)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
@ -52,23 +56,25 @@ pub struct BrowserLogin {
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum MinecraftLogin {
DeviceCode(DeviceCodeLogin),
Browser(BrowserLogin),
pub struct MicrosoftLoginSession {
pub browser: BrowserLogin,
pub device: DeviceCodeLogin,
}
impl MinecraftLogin {
impl MicrosoftLoginSession {
pub fn dedupe_key(&self) -> &str {
match self {
MinecraftLogin::DeviceCode(flow) => &flow.user_code,
MinecraftLogin::Browser(flow) => &flow.state,
}
&self.browser.state
}
pub fn browser_url(&self) -> Option<&str> {
match self {
MinecraftLogin::Browser(flow) => Some(&flow.auth_url),
MinecraftLogin::DeviceCode(_) => None,
}
pub fn auth_url(&self) -> &str {
&self.browser.auth_url
}
pub fn user_code(&self) -> &str {
&self.device.user_code
}
pub fn verification_uri(&self) -> &str {
&self.device.verification_uri
}
}

View file

@ -7,72 +7,93 @@ mod data;
pub use error::{AuthError, MinecraftAuthError, MinecraftAuthStep};
pub use offline::{offline_account, offline_uuid, validate_offline_username};
pub use store::CredentialsStore;
pub use data::{AccountKind, BrowserLogin, DeviceCodeLogin, MinecraftAccount, MinecraftLogin};
pub use data::{
AccountKind, BrowserLogin, DeviceCodeLogin, MicrosoftLoginSession, MinecraftAccount,
};
pub use msa::PendingBrowserLogin;
use std::collections::HashMap;
use std::sync::{Arc, LazyLock, Mutex as StdMutex};
use tokio::sync::Mutex;
use uuid::Uuid;
use crate::LauncherResult;
use crate::notification::MicrosoftLoginStatus;
use crate::state::LauncherState;
pub async fn begin_microsoft_login() -> LauncherResult<MinecraftLogin> {
#[tracing::instrument(skip_all)]
pub async fn begin_microsoft_login() -> LauncherResult<MicrosoftLoginSession> {
tracing::info!("beginning Microsoft login");
let state = LauncherState::get()?;
let use_browser = state.settings.read().microsoft_login_use_browser;
let client = state.services.requester.http();
if use_browser {
let (login, pending) = msa::begin_browser_login().await.map_err(AuthError::from)?;
state
.microsoft_logins
.lock()
.await
.insert(login.state.clone(), pending);
Ok(MinecraftLogin::Browser(login))
} else {
let client = state.services.requester.http();
let flow = msa::begin_device_login(client).await.map_err(AuthError::from)?;
Ok(MinecraftLogin::DeviceCode(flow))
}
let (browser, pending) = msa::begin_browser_login().await.map_err(AuthError::from)?;
let device = msa::begin_device_login(client).await.map_err(AuthError::from)?;
state
.microsoft_logins
.lock()
.await
.insert(browser.state.clone(), pending);
Ok(MicrosoftLoginSession { browser, device })
}
pub async fn finish_microsoft_login(flow: MinecraftLogin) -> LauncherResult<MinecraftAccount> {
#[tracing::instrument(skip_all)]
pub async fn finish_microsoft_login(
session: MicrosoftLoginSession,
) -> LauncherResult<MinecraftAccount> {
tracing::info!("finishing Microsoft login");
let state = LauncherState::get()?;
match flow {
MinecraftLogin::DeviceCode(flow) => {
let mut auth = state.auth.lock().await;
auth.finish_device_login(&flow, &state.services).await
}
MinecraftLogin::Browser(login) => {
let pending = state
.microsoft_logins
.lock()
.await
.remove(&login.state)
.ok_or(AuthError::Minecraft(
MinecraftAuthError::BrowserLoginNotFound,
))?;
let pending = state
.microsoft_logins
.lock()
.await
.remove(&session.browser.state)
.ok_or(AuthError::Minecraft(
MinecraftAuthError::BrowserLoginNotFound,
))?;
let client = state.services.requester.http();
let progress_id = Uuid::new_v4();
let notifier = state.services.notifier.clone();
let account = msa::finish_browser_login(client, pending, |label, current, total| {
notifier.send_progress(&progress_id, label, current, total);
})
.await
.map_err(AuthError::from)?;
let client = state.services.requester.http();
let notifier = state.services.notifier.clone();
let mut auth = state.auth.lock().await;
auth.commit_account(account, &state.services).await
}
let result = msa::finish_dual_login(
client,
pending,
&session.device,
|label, current, total| {
notifier.microsoft_login_status(Some(MicrosoftLoginStatus {
label: label.to_string(),
current,
total,
}));
},
)
.await
.map_err(AuthError::from);
state.services.notifier.microsoft_login_status(None);
if let Err(err) = &result {
tracing::warn!("Microsoft login failed: {err}");
}
let account = result?;
tracing::info!(username = %account.username, "Microsoft login succeeded");
let mut auth = state.auth.lock().await;
auth.commit_account(account, &state.services).await
}
#[tracing::instrument(level = "debug", skip_all)]
pub async fn cancel_microsoft_login(state_token: &str) {
if let Ok(state) = LauncherState::get() {
state.microsoft_logins.lock().await.remove(state_token);
}
}
#[tracing::instrument(skip_all, fields(username = %username))]
pub async fn add_offline_account(username: String) -> LauncherResult<MinecraftAccount> {
let state = LauncherState::get()?;
let mut auth = state.auth.lock().await;
@ -91,18 +112,21 @@ pub async fn get_account(id: Uuid) -> LauncherResult<Option<MinecraftAccount>> {
Ok(auth.get_account(id).cloned())
}
#[tracing::instrument(level = "debug", skip_all)]
pub async fn get_default_account() -> LauncherResult<Option<MinecraftAccount>> {
let state = LauncherState::get()?;
let mut auth = state.auth.lock().await;
auth.default_account(&state.services).await
auth.default_account().await
}
#[tracing::instrument(level = "debug", skip_all, fields(?id))]
pub async fn set_default_account(id: Option<Uuid>) -> LauncherResult<()> {
let state = LauncherState::get()?;
let mut auth = state.auth.lock().await;
auth.set_default_user(id).await
}
#[tracing::instrument(skip_all, fields(%id))]
pub async fn remove_account(id: Uuid) -> LauncherResult<()> {
let state = LauncherState::get()?;
let mut auth = state.auth.lock().await;
@ -110,31 +134,124 @@ pub async fn remove_account(id: Uuid) -> LauncherResult<()> {
Ok(())
}
pub async fn refresh_account(id: Uuid) -> LauncherResult<Option<MinecraftAccount>> {
let state = LauncherState::get()?;
let mut auth = state.auth.lock().await;
auth.refresh_microsoft_account(id, &state.services).await
/// Serialises token renewal per account.
///
/// Microsoft rotates the refresh token on every use, so two concurrent renewals
/// of one account would race: the loser spends an already-consumed refresh token
/// and the account gets signed out. Clusters can launch in parallel, so this is
/// reachable — the guard makes the second caller wait and reuse the first
/// caller's result instead of starting its own chain.
static REFRESH_GUARDS: LazyLock<StdMutex<HashMap<Uuid, Arc<Mutex<()>>>>> =
LazyLock::new(|| StdMutex::new(HashMap::new()));
fn refresh_guard(id: Uuid) -> Arc<Mutex<()>> {
Arc::clone(
REFRESH_GUARDS
.lock()
.expect("refresh guard registry poisoned")
.entry(id)
.or_default(),
)
}
/// Clones an account out of the store, returning an owned value so no caller
/// keeps the credentials-store lock alive past this call.
async fn account_snapshot(
state: &Arc<LauncherState>,
id: Uuid,
) -> LauncherResult<MinecraftAccount> {
let auth = state.auth.lock().await;
auth.get_account(id)
.cloned()
.ok_or_else(|| AuthError::AccountNotFound(id).into())
}
/// Renews `id`'s access token if it has lapsed, returning a usable account.
///
/// The credentials-store lock is deliberately not held across the Microsoft
/// handshake, only around the reads and the final write. Holding it across
/// those six sequential round trips is what used to stall every other account
/// read behind a multi-second network call.
#[tracing::instrument(level = "debug", skip(state), fields(%id))]
async fn renew_token(
state: &Arc<LauncherState>,
id: Uuid,
force: bool,
) -> LauncherResult<MinecraftAccount> {
let existing = account_snapshot(state, id).await?;
if !existing.is_microsoft() || (!force && !existing.is_expired()) {
return Ok(existing);
}
let guard = refresh_guard(id);
let _serialised = guard.lock().await;
let existing = account_snapshot(state, id).await?;
if !existing.is_microsoft() || (!force && !existing.is_expired()) {
return Ok(existing);
}
tracing::info!(username = %existing.username, "renewing Microsoft access token");
match msa::refresh_microsoft_account(state.services.requester.http(), &existing).await {
Ok(refreshed) => {
state
.auth
.lock()
.await
.commit_refreshed_account(refreshed.clone())
.await?;
Ok(refreshed)
}
Err(err) => {
let err = crate::LauncherError::from(AuthError::from(err));
if store::is_transient_auth_error(&err) {
tracing::warn!("keeping existing token after transient renewal failure: {err}");
Ok(existing)
} else {
Err(err)
}
}
}
}
#[tracing::instrument(level = "debug", skip_all, fields(%id))]
pub async fn refresh_account(id: Uuid) -> LauncherResult<MinecraftAccount> {
let state = LauncherState::get()?;
renew_token(&state, id, true).await
}
#[tracing::instrument(level = "debug", skip_all)]
pub async fn refresh_all_accounts() -> LauncherResult<Vec<MinecraftAccount>> {
let state = LauncherState::get()?;
let mut auth = state.auth.lock().await;
let ids: Vec<Uuid> = auth.users.keys().copied().collect();
let ids: Vec<Uuid> = state.auth.lock().await.users.keys().copied().collect();
let mut refreshed = Vec::with_capacity(ids.len());
for id in ids {
if let Some(account) = auth.refresh_microsoft_account(id, &state.services).await? {
refreshed.push(account);
}
refreshed.push(renew_token(&state, id, true).await?);
}
Ok(refreshed)
}
#[tracing::instrument(level = "debug", skip_all, fields(%id))]
pub async fn account_for_launch(id: Uuid) -> LauncherResult<MinecraftAccount> {
let state = LauncherState::get()?;
let mut auth = state.auth.lock().await;
auth.account_for_launch(id, &state.services).await
let account = renew_token(&state, id, false).await?;
if account.is_offline() && !state.auth.lock().await.has_microsoft_account() {
return Err(AuthError::OfflineRequiresMicrosoft.into());
}
Ok(account)
}
#[tracing::instrument(level = "debug", skip_all)]
pub async fn default_account_for_launch() -> LauncherResult<Option<MinecraftAccount>> {
let state = LauncherState::get()?;
let Some(id) = state.auth.lock().await.resolve_default_id().await? else {
return Ok(None);
};
Ok(Some(account_for_launch(id).await?))
}
pub async fn has_microsoft_account() -> LauncherResult<bool> {

View file

@ -29,23 +29,71 @@ const TOKEN_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0
const AUTH_STEPS: u64 = 5;
const BROWSER_LOGIN_TIMEOUT: Duration = Duration::from_secs(5 * 60);
#[tracing::instrument(level = "debug", skip_all)]
pub async fn begin_device_login(client: &Client) -> Result<DeviceCodeLogin, MinecraftAuthError> {
request_device_code(client).await
}
pub async fn finish_device_login(
#[tracing::instrument(level = "debug", skip_all)]
pub async fn finish_dual_login(
client: &Client,
flow: &DeviceCodeLogin,
pending: PendingBrowserLogin,
device: &DeviceCodeLogin,
on_progress: impl Fn(&str, u64, u64),
) -> Result<MinecraftAccount, MinecraftAuthError> {
tracing::info!("waiting for Microsoft sign-in (browser or device code)");
on_progress("Waiting for sign-in", 0, AUTH_STEPS);
let msa = poll_device_token(client, flow).await?;
let deadline = Duration::from_secs(device.expires_in);
let msa = tokio::time::timeout(deadline, race_msa_token(client, &pending, device))
.await
.map_err(|_| MinecraftAuthError::DeviceAuthorizationExpired)??;
account_from_msa_token(client, msa, on_progress).await
}
#[tracing::instrument(level = "debug", skip_all)]
async fn race_msa_token(
client: &Client,
pending: &PendingBrowserLogin,
device: &DeviceCodeLogin,
) -> Result<MsaToken, MinecraftAuthError> {
let browser = browser_msa_token(client, pending);
let device = poll_device_token(client, device);
tokio::pin!(browser, device);
let mut browser_done = false;
let mut device_done = false;
let mut last_err: Option<MinecraftAuthError> = None;
loop {
tokio::select! {
res = &mut browser, if !browser_done => match res {
Ok(token) => return Ok(token),
Err(err) => {
browser_done = true;
if device_done {
return Err(err);
}
last_err = Some(err);
}
},
res = &mut device, if !device_done => match res {
Ok(token) => return Ok(token),
Err(err) => {
device_done = true;
if browser_done {
return Err(err);
}
last_err = Some(err);
}
},
else => return Err(last_err.unwrap_or(MinecraftAuthError::DeviceAuthorizationExpired)),
}
}
}
pub struct PendingBrowserLogin {
listener: TcpListener,
pkce_verifier: String,
@ -53,6 +101,7 @@ pub struct PendingBrowserLogin {
csrf_state: String,
}
#[tracing::instrument(level = "debug", skip_all)]
pub async fn begin_browser_login() -> Result<(BrowserLogin, PendingBrowserLogin), MinecraftAuthError>
{
let listener = TcpListener::bind("127.0.0.1:0")
@ -95,20 +144,16 @@ pub async fn begin_browser_login() -> Result<(BrowserLogin, PendingBrowserLogin)
Ok((login, pending))
}
pub async fn finish_browser_login(
#[tracing::instrument(level = "debug", skip_all)]
async fn browser_msa_token(
client: &Client,
pending: PendingBrowserLogin,
on_progress: impl Fn(&str, u64, u64),
) -> Result<MinecraftAccount, MinecraftAuthError> {
on_progress("Waiting for sign-in", 0, AUTH_STEPS);
let code = tokio::time::timeout(BROWSER_LOGIN_TIMEOUT, wait_for_redirect(&pending))
.await
.map_err(|_| MinecraftAuthError::BrowserAuthorizationExpired)??;
let msa = exchange_auth_code(client, &code, &pending).await?;
account_from_msa_token(client, msa, on_progress).await
pending: &PendingBrowserLogin,
) -> Result<MsaToken, MinecraftAuthError> {
let code = wait_for_redirect(pending).await?;
exchange_auth_code(client, &code, pending).await
}
#[tracing::instrument(level = "debug", skip_all)]
async fn wait_for_redirect(pending: &PendingBrowserLogin) -> Result<String, MinecraftAuthError> {
loop {
let (mut stream, _) = pending
@ -148,6 +193,7 @@ async fn wait_for_redirect(pending: &PendingBrowserLogin) -> Result<String, Mine
}
if let Some(error) = error {
tracing::warn!(%error, "browser authorization failed");
respond(&mut stream, RedirectPage::Failed).await;
return Err(MinecraftAuthError::BrowserAuthorizationFailed { error });
}
@ -254,6 +300,7 @@ p{{font-size:14px;line-height:1.55;color:#6b7280}}
)
}
#[tracing::instrument(level = "debug", skip_all)]
async fn exchange_auth_code(
client: &Client,
code: &str,
@ -298,10 +345,12 @@ fn pkce_challenge(verifier: &str) -> String {
BASE64_URL_SAFE_NO_PAD.encode(digest)
}
#[tracing::instrument(level = "debug", skip_all, fields(username = %creds.username))]
pub async fn refresh_microsoft_account(
client: &Client,
creds: &MinecraftAccount,
) -> Result<MinecraftAccount, MinecraftAuthError> {
tracing::debug!("refreshing Microsoft account token");
let msa = refresh_msa_token(client, &creds.refresh_token).await?;
let mut account = account_from_msa_token(client, msa, |_, _, _| {}).await?;
account.id = creds.id;
@ -309,6 +358,7 @@ pub async fn refresh_microsoft_account(
Ok(account)
}
#[tracing::instrument(level = "debug", skip_all)]
async fn request_device_code(client: &Client) -> Result<DeviceCodeLogin, MinecraftAuthError> {
let body = [
("client_id", MICROSOFT_CLIENT_ID),
@ -331,6 +381,7 @@ async fn request_device_code(client: &Client) -> Result<DeviceCodeLogin, Minecra
parse_json_response(res, MinecraftAuthStep::DeviceCodeRequest).await
}
#[tracing::instrument(level = "debug", skip_all)]
async fn poll_device_token(
client: &Client,
flow: &DeviceCodeLogin,
@ -414,6 +465,7 @@ async fn poll_device_token(
}
}
#[tracing::instrument(level = "debug", skip_all)]
async fn refresh_msa_token(
client: &Client,
refresh_token: &str,
@ -460,6 +512,7 @@ impl MsaToken {
}
}
#[tracing::instrument(level = "debug", skip_all)]
async fn account_from_msa_token(
client: &Client,
msa: MsaToken,
@ -498,6 +551,7 @@ async fn account_from_msa_token(
})
}
#[tracing::instrument(level = "debug", skip_all)]
async fn xbl_authenticate(
client: &Client,
rps_ticket: &str,
@ -538,6 +592,7 @@ async fn xbl_authenticate(
})
}
#[tracing::instrument(level = "debug", skip_all)]
async fn xsts_authorize(
client: &Client,
user_token: &str,
@ -574,6 +629,7 @@ struct MinecraftToken {
access_token: String,
}
#[tracing::instrument(level = "debug", skip_all)]
async fn minecraft_login(
client: &Client,
uhs: &str,
@ -603,6 +659,7 @@ struct MinecraftProfile {
name: String,
}
#[tracing::instrument(level = "debug", skip_all)]
async fn minecraft_profile(
client: &Client,
token: &str,
@ -627,6 +684,7 @@ async fn minecraft_profile(
#[serde(rename_all = "camelCase")]
struct MinecraftEntitlements {}
#[tracing::instrument(level = "debug", skip_all)]
async fn minecraft_entitlements(
client: &Client,
token: &str,

View file

@ -40,7 +40,9 @@ pub fn validate_offline_username(username: &str) -> LauncherResult<()> {
Ok(())
}
#[tracing::instrument(level = "debug", fields(username = %username))]
pub fn offline_account(username: String) -> MinecraftAccount {
tracing::info!("creating offline account");
MinecraftAccount {
id: offline_uuid(&username),
username,

View file

@ -1,11 +1,10 @@
use std::collections::HashMap;
use chrono::Utc;
use uuid::Uuid;
use super::error::AuthError;
use super::offline::{offline_account, validate_offline_username};
use super::data::{AccountKind, DeviceCodeLogin, MinecraftAccount};
use super::data::{AccountKind, MinecraftAccount};
use crate::paths;
use crate::state::LauncherServices;
use crate::LauncherResult;
@ -17,6 +16,7 @@ pub struct CredentialsStore {
}
impl CredentialsStore {
#[tracing::instrument(level = "debug")]
pub async fn load() -> LauncherResult<Self> {
let path = paths::auth_file()?;
if !path.exists() {
@ -32,6 +32,7 @@ impl CredentialsStore {
}
}
#[tracing::instrument(level = "debug", skip_all)]
pub async fn save(&self) -> LauncherResult<()> {
let path = paths::auth_file()?;
@ -57,23 +58,7 @@ impl CredentialsStore {
self.users.get(&id)
}
pub async fn finish_device_login(
&mut self,
flow: &DeviceCodeLogin,
services: &LauncherServices,
) -> LauncherResult<MinecraftAccount> {
let client = services.requester.http();
let progress_id = Uuid::new_v4();
let notifier = services.notifier.clone();
let account = super::msa::finish_device_login(client, flow, |label, current, total| {
notifier.send_progress(&progress_id, label, current, total);
})
.await
.map_err(AuthError::from)?;
self.commit_account(account, services).await
}
#[tracing::instrument(level = "debug", skip_all, fields(username = %account.username))]
pub async fn commit_account(
&mut self,
account: MinecraftAccount,
@ -86,6 +71,7 @@ impl CredentialsStore {
}
self.save().await?;
tracing::debug!("committed account to credentials store");
services
.notifier
.send_info("Account added", &format!("Signed in as {}", account.username));
@ -97,6 +83,7 @@ impl CredentialsStore {
Ok(account)
}
#[tracing::instrument(level = "debug", skip(self), fields(username = %username))]
pub async fn add_offline_account_and_save(
&mut self,
username: String,
@ -126,30 +113,18 @@ impl CredentialsStore {
Ok(account)
}
pub async fn refresh_microsoft_account(
#[tracing::instrument(level = "debug", skip_all, fields(%account.id))]
pub async fn commit_refreshed_account(
&mut self,
id: Uuid,
services: &LauncherServices,
) -> LauncherResult<Option<MinecraftAccount>> {
let Some(existing) = self.users.get(&id).cloned() else {
return Ok(None);
};
if existing.kind != AccountKind::Microsoft {
return Ok(Some(existing));
}
let client = services.requester.http();
let account = super::msa::refresh_microsoft_account(client, &existing)
.await
.map_err(AuthError::from)?;
self.users.insert(id, account.clone());
account: MinecraftAccount,
) -> LauncherResult<()> {
self.users.insert(account.id, account);
self.save().await?;
Ok(Some(account))
tracing::debug!("stored refreshed Microsoft account");
Ok(())
}
#[tracing::instrument(level = "debug", skip(self), fields(%id))]
pub async fn remove_account(&mut self, id: Uuid) -> LauncherResult<Option<MinecraftAccount>> {
let removed = self.users.remove(&id);
@ -161,6 +136,7 @@ impl CredentialsStore {
Ok(removed)
}
#[tracing::instrument(level = "debug", skip(self), fields(?id))]
pub async fn set_default_user(&mut self, id: Option<Uuid>) -> LauncherResult<()> {
if let Some(id) = id
&& !self.users.contains_key(&id)
@ -173,10 +149,8 @@ impl CredentialsStore {
Ok(())
}
pub async fn default_account(
&mut self,
services: &LauncherServices,
) -> LauncherResult<Option<MinecraftAccount>> {
#[tracing::instrument(level = "debug", skip_all)]
pub async fn resolve_default_id(&mut self) -> LauncherResult<Option<Uuid>> {
let id = self
.default_user
.or_else(|| self.users.keys().copied().next());
@ -185,68 +159,38 @@ impl CredentialsStore {
return Ok(None);
};
let Some(account) = self.users.get(&id).cloned() else {
if !self.users.contains_key(&id) {
return Ok(None);
};
}
if self.default_user != Some(id) {
self.default_user = Some(id);
self.save().await?;
}
if account.kind == AccountKind::Offline {
if !self.has_microsoft_account() {
return Err(AuthError::OfflineRequiresMicrosoft.into());
}
return Ok(Some(account));
}
if account.expires >= Utc::now() {
return Ok(Some(account));
}
let old = account.clone();
match self.refresh_microsoft_account(id, services).await {
Ok(Some(updated)) => Ok(Some(updated)),
Ok(None) => Ok(None),
Err(err) => {
if is_transient_auth_error(&err) {
Ok(Some(old))
} else {
Err(err)
}
}
}
Ok(Some(id))
}
pub async fn account_for_launch(
&mut self,
id: Uuid,
services: &LauncherServices,
) -> LauncherResult<MinecraftAccount> {
if !self.users.contains_key(&id) {
return Err(AuthError::AccountNotFound(id).into());
}
/// The current account as stored. Never touches the network: callers that
/// need a usable token go through [`crate::auth::account_for_launch`].
#[tracing::instrument(level = "debug", skip_all)]
pub async fn default_account(&mut self) -> LauncherResult<Option<MinecraftAccount>> {
let Some(id) = self.resolve_default_id().await? else {
return Ok(None);
};
let Some(account) = self.users.get(&id).cloned() else {
return Ok(None);
};
let previous_default = self.default_user;
self.default_user = Some(id);
let account = self
.default_account(services)
.await?
.ok_or(AuthError::AccountNotFound(id))?;
self.default_user = previous_default;
if account.kind == AccountKind::Offline && !self.has_microsoft_account() {
if account.is_offline() && !self.has_microsoft_account() {
return Err(AuthError::OfflineRequiresMicrosoft.into());
}
Ok(account)
Ok(Some(account))
}
}
fn is_transient_auth_error(err: &crate::LauncherError) -> bool {
pub(super) fn is_transient_auth_error(err: &crate::LauncherError) -> bool {
match err {
crate::LauncherError::AuthError(super::error::AuthError::Minecraft(
super::error::MinecraftAuthError::RequestError { source, .. },

View file

@ -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::{GroupedProgressSession, TaskPhase};
use crate::notification::{GroupedProgressChild, GroupedProgressSession, TaskPhase};
use crate::packages::domain::{ContentType, GameLoader};
use crate::packages::error::PackageError;
use crate::packages::store::{PackageStore, unlink_cluster_file};
@ -21,11 +21,13 @@ fn is_base62(s: &str) -> bool {
!s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric())
}
#[tracing::instrument(level = "debug", skip(file, child, services), fields(package = %file.display_name()))]
pub async fn install_package_from_bundle(
file: &BundleFile,
cluster_id: i64,
bundle_name: &str,
skip_compatibility: bool,
child: Option<&GroupedProgressChild>,
services: &LauncherServices,
) -> LauncherResult<String> {
let cluster = PackageStore::get_cluster(cluster_id, services).await?;
@ -63,13 +65,14 @@ pub async fn install_package_from_bundle(
cluster_id,
skip_compatibility,
false,
child,
services,
)
.await?;
artifact.hash
}
BundleFileKind::External(ext) => {
install_external(ext, &cluster, skip_compatibility, services).await?
install_external(ext, &cluster, skip_compatibility, child, services).await?
}
};
@ -86,28 +89,34 @@ pub async fn install_package_from_bundle(
Ok(hash)
}
#[tracing::instrument(level = "debug", skip(ext, cluster, child, services), fields(file = %ext.name))]
async fn install_external(
ext: &ExternalFile,
cluster: &ClusterRow,
skip_compatibility: bool,
child: Option<&GroupedProgressChild>,
services: &LauncherServices,
) -> LauncherResult<String> {
let artifact = crate::packages::store::download_external(ext, false, services).await?;
let artifact = crate::packages::store::download_external(ext, false, child, services).await?;
PackageStore::link_artifact(&artifact, cluster, Some(&ext.name), services).await?;
let _ = skip_compatibility;
Ok(artifact.hash)
}
#[tracing::instrument(level = "debug", skip(archive, services), fields(bundle = %archive.manifest.name))]
pub async fn extract_bundle_overrides_for_cluster(
archive: &BundleArchive,
cluster_id: i64,
services: &LauncherServices,
) -> LauncherResult<()> {
let cluster = PackageStore::get_cluster(cluster_id, services).await?;
overrides::extract_bundle_overrides(&archive.bundle.path, &cluster).await
overrides::sync_bundle_overrides(&archive.bundle.path, &archive.manifest.name, &cluster, None)
.await?;
Ok(())
}
#[tracing::instrument(skip(bundles, services))]
pub async fn install_bundle(
cluster_id: i64,
bundle_name: &str,
@ -132,6 +141,7 @@ pub async fn install_bundle(
install_enabled_bundle_files(&archive, cluster_id, skip_compatibility, None, services).await
}
#[tracing::instrument(level = "debug", skip(archive, progress, services), fields(bundle = %archive.manifest.name))]
pub async fn install_enabled_bundle_files(
archive: &BundleArchive,
cluster_id: i64,
@ -194,11 +204,18 @@ pub async fn install_enabled_bundle_files(
.cloned()
.collect();
tracing::info!(
cluster_id,
bundle = %bundle_name,
to_install = to_install.len(),
"installing enabled bundle files"
);
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()), 1);
c.set_phase(TaskPhase::Installing);
let c = p.child(format!("Mod {}", file.display_name()), file.size.max(1));
c.set_phase(TaskPhase::Downloading);
c
});
@ -207,11 +224,13 @@ pub async fn install_enabled_bundle_files(
cluster_id,
bundle_name,
skip_compatibility,
child.as_ref(),
services,
)
.await;
if let Some(child) = child {
child.set_phase(TaskPhase::Installing);
child.finish();
}
(file.display_name(), result)
@ -234,6 +253,7 @@ pub async fn install_enabled_bundle_files(
const BUNDLE_INSTALL_CONCURRENCY: usize = 6;
#[tracing::instrument(level = "debug", skip(bundles, services))]
pub async fn enabled_bundle_bytes(
cluster_id: i64,
bundles: &BundlesManager,
@ -296,6 +316,7 @@ pub async fn enabled_bundle_bytes(
Ok(total)
}
#[tracing::instrument(level = "debug", skip(services))]
pub async fn set_bundle_package_enabled(
cluster_id: i64,
bundle_name: &str,
@ -319,6 +340,7 @@ pub async fn set_bundle_package_enabled(
Ok(())
}
#[tracing::instrument(level = "debug", skip(services))]
pub async fn list_cluster_bundle_overrides(
cluster_id: i64,
services: &LauncherServices,
@ -330,6 +352,7 @@ pub async fn list_cluster_bundle_overrides(
.collect())
}
#[tracing::instrument(skip(bundles, progress, services))]
pub async fn install_cluster_bundles(
cluster_id: i64,
bundles: &BundlesManager,
@ -366,6 +389,7 @@ pub async fn install_cluster_bundles(
Ok(())
}
#[tracing::instrument(level = "debug", skip(services))]
pub async fn on_user_remove_artifact(
cluster_id: i64,
hash: &str,
@ -374,6 +398,7 @@ pub async fn on_user_remove_artifact(
handle_user_artifact_action(cluster_id, hash, services, OverrideType::Removed).await
}
#[tracing::instrument(level = "debug", skip(services))]
pub async fn on_user_disable_artifact(
cluster_id: i64,
hash: &str,
@ -382,6 +407,7 @@ pub async fn on_user_disable_artifact(
handle_user_artifact_action(cluster_id, hash, services, OverrideType::Disabled).await
}
#[tracing::instrument(level = "debug", skip(services))]
pub async fn on_user_enable_artifact(
cluster_id: i64,
hash: &str,
@ -395,6 +421,7 @@ pub async fn on_user_enable_artifact(
Ok(())
}
#[tracing::instrument(level = "debug", skip(services))]
async fn handle_user_artifact_action(
cluster_id: i64,
hash: &str,
@ -422,6 +449,7 @@ async fn handle_user_artifact_action(
Ok(())
}
#[tracing::instrument(level = "debug", skip(services))]
pub async fn remove_artifact_from_cluster(
cluster_id: i64,
hash: &str,

View file

@ -9,7 +9,6 @@ use reqwest::Method;
use reqwest::header;
use reqwest::StatusCode;
use tokio::sync::RwLock;
use tracing::instrument;
use crate::bundles::error::BundleError;
use crate::bundles::manifest::{BundleManifest as RemoteBundleManifest, RemoteBundleRef};
@ -73,6 +72,7 @@ impl BundlesManager {
}
}
#[tracing::instrument(skip_all)]
pub async fn sync(&self, services: &LauncherServices) -> LauncherResult<bool> {
let Some((manifest, manifest_changed)) = Self::fetch_manifest(services).await? else {
tracing::debug!(
@ -112,6 +112,7 @@ impl BundlesManager {
Ok(true)
}
#[tracing::instrument(level = "debug", skip(self, services))]
pub async fn archives_for(
&self,
services: &LauncherServices,
@ -126,6 +127,7 @@ impl BundlesManager {
Ok(archives)
}
#[tracing::instrument(level = "debug", skip(self))]
async fn manifest_for_archive(
&self,
path: &Path,
@ -141,6 +143,7 @@ impl BundlesManager {
Ok(manifest)
}
#[tracing::instrument(level = "debug", skip(self, services))]
pub async fn list_for(
&self,
services: &LauncherServices,
@ -156,6 +159,7 @@ impl BundlesManager {
.collect::<Result<Vec<_>, _>>()
}
#[tracing::instrument(level = "debug", skip(self, entry, services, bundles_root), fields(remote_path = %entry.remote_path))]
async fn sync_bundle(
&self,
entry: &RemoteBundleRef,
@ -208,6 +212,7 @@ impl BundlesManager {
Ok(())
}
#[tracing::instrument(level = "debug", skip(services))]
async fn fetch_manifest(
services: &LauncherServices,
) -> LauncherResult<Option<(RemoteBundleManifest, bool)>> {
@ -288,7 +293,7 @@ async fn read_cached_manifest(
Ok(Some((manifest, changed)))
}
#[instrument(skip(services))]
#[tracing::instrument(level = "debug", skip(services))]
async fn download_bundle_if_needed(
services: &LauncherServices,
url: &str,

View file

@ -1,86 +1,531 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use oneclient_db::models::ClusterRow;
use serde::{Deserialize, Serialize};
use crate::LauncherResult;
use crate::crypto::{sha1_bytes, sha1_file};
use crate::notification::NotificationService;
pub async fn extract_bundle_overrides(
archive_path: &Path,
cluster: &ClusterRow,
) -> LauncherResult<()> {
extract_overrides(archive_path, &cluster_root(cluster)?, false).await
}
const ALWAYS_UPDATE_GLOBS: &[&str] = &["config/fabric_loader_dependencies.json"];
pub async fn extract_bundle_overrides_no_overwrite(
archive_path: &Path,
cluster: &ClusterRow,
) -> LauncherResult<()> {
extract_overrides(archive_path, &cluster_root(cluster)?, true).await
}
const OVERRIDES_PREFIX: &str = "overrides/";
const LOCK_REL_PATH: &str = ".oneclient/bundle_overrides.json";
fn cluster_root(cluster: &ClusterRow) -> LauncherResult<PathBuf> {
Ok(crate::paths::clusters_dir()?.join(&cluster.folder_name))
}
async fn extract_overrides(
archive_path: &Path,
dest_path: &Path,
skip_existing: bool,
) -> LauncherResult<()> {
if skip_existing {
extract_overrides_no_overwrite(archive_path, dest_path).await?;
return Ok(());
}
polyio::extract_zip_filtered(
archive_path,
dest_path,
Some(|entry: &async_zip::StoredZipEntry| {
entry
.filename()
.as_str()
.is_ok_and(|name| name.starts_with("overrides/"))
}),
Some(|name: &str| name.trim_start_matches("overrides/").to_string()),
)
.await?;
Ok(())
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
struct OverrideLock {
#[serde(default)]
bundles: HashMap<String, HashMap<String, String>>,
}
async fn extract_overrides_no_overwrite(
impl OverrideLock {
async fn load(root: &Path) -> Self {
let path = root.join(LOCK_REL_PATH);
match polyio::read(&path).await {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|err| {
tracing::warn!(error = %err, "corrupt bundle override lock; starting fresh");
Self::default()
}),
Err(_) => Self::default(),
}
}
async fn save(&self, root: &Path) -> LauncherResult<()> {
let path = root.join(LOCK_REL_PATH);
if let Some(parent) = path.parent() {
polyio::create_dir_all(parent).await.ok();
}
let bytes = serde_json::to_vec_pretty(self).map_err(std::io::Error::other)?;
polyio::write(&path, bytes).await?;
Ok(())
}
}
#[derive(Debug, Default)]
pub struct OverrideSyncReport {
pub written: Vec<String>,
pub conflicts: Vec<String>,
pub deleted: Vec<String>,
}
#[tracing::instrument(level = "debug", skip(cluster, notifier))]
pub async fn sync_bundle_overrides(
archive_path: &Path,
dest_path: &Path,
) -> LauncherResult<()> {
polyio::extract_zip_filtered(
archive_path,
dest_path,
Some(|entry: &async_zip::StoredZipEntry| {
if let Ok(name) = entry.filename().as_str() {
if !name.starts_with("overrides/") {
return false;
}
let stripped = name.trim_start_matches("overrides/");
if stripped.is_empty() {
return false;
}
bundle_name: &str,
cluster: &ClusterRow,
notifier: Option<&NotificationService>,
) -> LauncherResult<OverrideSyncReport> {
let root = cluster_root(cluster)?;
sync_bundle_overrides_at(archive_path, bundle_name, &root, notifier).await
}
let target_path = dest_path.join(polyio::sanitize_path(stripped));
if target_path.exists() {
return false;
}
#[tracing::instrument(level = "debug", skip(notifier))]
async fn sync_bundle_overrides_at(
archive_path: &Path,
bundle_name: &str,
root: &Path,
notifier: Option<&NotificationService>,
) -> LauncherResult<OverrideSyncReport> {
let entries =
polyio::read_zip_file_entries(archive_path, |name| name.starts_with(OVERRIDES_PREFIX))
.await?;
if let Ok(is_dir) = entry.dir() {
return !is_dir;
let mut lock = OverrideLock::load(root).await;
let previous = lock.bundles.remove(bundle_name).unwrap_or_default();
let mut next: HashMap<String, String> = HashMap::new();
let mut report = OverrideSyncReport::default();
for (name, bytes) in entries {
let rel = name.trim_start_matches(OVERRIDES_PREFIX);
if rel.is_empty() {
continue;
}
let new_sha1 = sha1_bytes(&bytes);
let dest = root.join(polyio::sanitize_path(rel));
let disk_sha1 = current_sha1(&dest).await;
let base = previous.get(rel).cloned();
if matches_always_update(rel) {
let wrote = write_override(&dest, &bytes, rel).await;
if wrote {
if disk_sha1.as_deref() != Some(new_sha1.as_str()) {
report.written.push(rel.to_string());
}
next.insert(rel.to_string(), new_sha1);
} else if let Some(b) = base {
next.insert(rel.to_string(), b);
}
continue;
}
match disk_sha1 {
// Nothing on disk -> copy from bundle
None => {
if write_override(&dest, &bytes, rel).await {
report.written.push(rel.to_string());
next.insert(rel.to_string(), new_sha1);
}
}
// Already matches the bundle -> so just record it in the lock
Some(disk) if disk == new_sha1 => {
next.insert(rel.to_string(), new_sha1);
}
// Untouched -< safe to update
Some(disk) if base.as_deref() == Some(disk.as_str()) => {
if write_override(&dest, &bytes, rel).await {
report.written.push(rel.to_string());
next.insert(rel.to_string(), new_sha1);
} else {
next.insert(rel.to_string(), disk);
}
}
// Differs from both the bundle and the base -> conflict
Some(_) => {
if let Some(b) = base {
if b != new_sha1 {
report.conflicts.push(rel.to_string());
}
next.insert(rel.to_string(), b);
}
// untracked and untouched
}
}
}
// delete files the bundle no longer ships, but only if they were unmodified locally
for (rel, base_sha1) in &previous {
if next.contains_key(rel) {
continue;
}
let dest = root.join(polyio::sanitize_path(rel));
if current_sha1(&dest).await.as_deref() == Some(base_sha1.as_str())
&& polyio::remove_file(&dest).await.is_ok()
{
report.deleted.push(rel.clone());
}
}
lock.bundles.insert(bundle_name.to_string(), next);
if let Err(err) = lock.save(root).await {
tracing::warn!(error = %err, "failed to persist bundle override lock");
}
tracing::debug!(
bundle = bundle_name,
written = report.written.len(),
conflicts = report.conflicts.len(),
deleted = report.deleted.len(),
"synced bundle overrides"
);
if !report.conflicts.is_empty() {
notify_conflicts(notifier, bundle_name, &report.conflicts);
}
Ok(report)
}
async fn write_override(dest: &Path, bytes: &[u8], rel: &str) -> bool {
if let Some(parent) = dest.parent() {
polyio::create_dir_all(parent).await.ok();
}
match polyio::write(dest, bytes).await {
Ok(()) => true,
Err(err) => {
tracing::warn!(path = rel, error = %err, "failed to write bundle override");
false
}),
Some(|old_name: &str| {
old_name.trim_start_matches("overrides/").to_string()
}),
)
.await
.map_err(Into::into)
}
}
}
}
async fn current_sha1(path: &Path) -> Option<String> {
if polyio::try_exists(path).await.unwrap_or(false) {
sha1_file(path).await.ok()
} else {
None
}
}
fn notify_conflicts(
notifier: Option<&NotificationService>,
bundle_name: &str,
conflicts: &[String],
) {
let Some(notifier) = notifier else {
return;
};
const MAX_LISTED: usize = 5;
let listed = conflicts
.iter()
.take(MAX_LISTED)
.cloned()
.collect::<Vec<_>>()
.join(", ");
let extra = conflicts.len().saturating_sub(MAX_LISTED);
let suffix = if extra > 0 {
format!(" (+{extra} more)")
} else {
String::new()
};
notifier.send_info(
"Kept your config edits",
&format!(
"{bundle_name}: {} config file(s) you edited were left untouched by the update: {listed}{suffix}",
conflicts.len()
),
);
}
fn matches_always_update(rel: &str) -> bool {
ALWAYS_UPDATE_GLOBS
.iter()
.any(|pattern| glob_match(pattern, rel))
}
fn glob_match(pattern: &str, path: &str) -> bool {
let pat: Vec<&str> = pattern.split('/').collect();
let seg: Vec<&str> = path.split('/').collect();
glob_segments(&pat, &seg)
}
fn glob_segments(pat: &[&str], path: &[&str]) -> bool {
match pat.split_first() {
None => path.is_empty(),
Some((&"**", rest)) => (0..=path.len()).any(|i| glob_segments(rest, &path[i..])),
Some((first, rest)) => {
if let Some((head, tail)) = path.split_first() {
segment_match(first, head) && glob_segments(rest, tail)
} else {
false
}
}
}
}
fn segment_match(pattern: &str, segment: &str) -> bool {
if !pattern.contains('*') {
return pattern == segment;
}
let parts: Vec<&str> = pattern.split('*').collect();
let mut pos = 0usize;
for (index, part) in parts.iter().enumerate() {
if part.is_empty() {
continue;
}
if index == 0 {
if !segment[pos..].starts_with(part) {
return false;
}
pos += part.len();
} else if index == parts.len() - 1 {
if !segment[pos..].ends_with(part) {
return false;
}
} else if let Some(found) = segment[pos..].find(part) {
pos += found + part.len();
} else {
return false;
}
}
true
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use super::*;
#[test]
fn glob_exact_and_wildcards() {
assert!(glob_match(
"config/fabric_loader_dependencies.json",
"config/fabric_loader_dependencies.json"
));
assert!(!glob_match(
"config/fabric_loader_dependencies.json",
"config/other.json"
));
assert!(glob_match("config/*.json", "config/anything.json"));
assert!(!glob_match("config/*.json", "config/nested/anything.json"));
assert!(glob_match("config/**", "config/nested/deep/file.toml"));
assert!(glob_match("**/options.txt", "a/b/options.txt"));
assert!(glob_match("**/options.txt", "options.txt"));
assert!(glob_match("config/mod-*.cfg", "config/mod-foo.cfg"));
assert!(!glob_match("config/mod-*.cfg", "config/other-foo.cfg"));
}
fn tmp_root(tag: &str) -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir =
std::env::temp_dir().join(format!("oneclient_ovr_{}_{tag}_{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
async fn write_bundle(path: &Path, entries: &[(&str, &[u8])]) {
let file = tokio::fs::File::create(path).await.unwrap();
let mut writer = async_zip::tokio::write::ZipFileWriter::with_tokio(file);
for (name, data) in entries {
let builder = async_zip::ZipEntryBuilder::new(
(*name).to_string().into(),
async_zip::Compression::Stored,
);
writer.write_entry_whole(builder, data).await.unwrap();
}
writer.close().await.unwrap();
}
async fn read(path: &Path) -> Option<Vec<u8>> {
tokio::fs::read(path).await.ok()
}
async fn sync(zip: &Path, root: &Path) -> OverrideSyncReport {
sync_bundle_overrides_at(zip, "test-bundle", root, None)
.await
.unwrap()
}
#[tokio::test]
async fn fresh_install_writes_all() {
let root = tmp_root("fresh");
let zip = root.join("v1.mrpack");
write_bundle(
&zip,
&[
("overrides/config/a.toml", b"alpha"),
("overrides/config/nested/b.cfg", b"beta"),
("overrides/", b""),
],
)
.await;
let report = sync(&zip, &root).await;
assert_eq!(report.written.len(), 2);
assert!(report.conflicts.is_empty());
assert_eq!(read(&root.join("config/a.toml")).await.unwrap(), b"alpha");
assert_eq!(
read(&root.join("config/nested/b.cfg")).await.unwrap(),
b"beta"
);
}
#[tokio::test]
async fn rerun_same_version_is_noop() {
let root = tmp_root("noop");
let zip = root.join("v1.mrpack");
write_bundle(&zip, &[("overrides/config/a.toml", b"alpha")]).await;
sync(&zip, &root).await;
let report = sync(&zip, &root).await;
assert!(report.written.is_empty());
assert!(report.conflicts.is_empty());
assert!(report.deleted.is_empty());
}
#[tokio::test]
async fn untouched_file_updates() {
let root = tmp_root("update");
let v1 = root.join("v1.mrpack");
write_bundle(&v1, &[("overrides/config/a.toml", b"alpha")]).await;
sync(&v1, &root).await;
let v2 = root.join("v2.mrpack");
write_bundle(&v2, &[("overrides/config/a.toml", b"alpha-v2")]).await;
let report = sync(&v2, &root).await;
assert_eq!(report.written, vec!["config/a.toml".to_string()]);
assert!(report.conflicts.is_empty());
assert_eq!(
read(&root.join("config/a.toml")).await.unwrap(),
b"alpha-v2"
);
}
#[tokio::test]
async fn user_edit_with_bundle_unchanged_is_kept_silently() {
let root = tmp_root("edit_noconflict");
let v1 = root.join("v1.mrpack");
write_bundle(&v1, &[("overrides/config/a.toml", b"alpha")]).await;
sync(&v1, &root).await;
tokio::fs::write(root.join("config/a.toml"), b"user-edit")
.await
.unwrap();
let report = sync(&v1, &root).await;
assert!(report.written.is_empty());
assert!(report.conflicts.is_empty());
assert_eq!(
read(&root.join("config/a.toml")).await.unwrap(),
b"user-edit"
);
}
#[tokio::test]
async fn user_edit_and_bundle_changed_is_conflict_kept() {
let root = tmp_root("conflict");
let v1 = root.join("v1.mrpack");
write_bundle(&v1, &[("overrides/config/a.toml", b"alpha")]).await;
sync(&v1, &root).await;
tokio::fs::write(root.join("config/a.toml"), b"user-edit")
.await
.unwrap();
let v2 = root.join("v2.mrpack");
write_bundle(&v2, &[("overrides/config/a.toml", b"alpha-v2")]).await;
let report = sync(&v2, &root).await;
assert_eq!(report.conflicts, vec!["config/a.toml".to_string()]);
assert!(report.written.is_empty());
assert_eq!(
read(&root.join("config/a.toml")).await.unwrap(),
b"user-edit"
);
let report2 = sync(&v2, &root).await;
assert_eq!(
read(&root.join("config/a.toml")).await.unwrap(),
b"user-edit"
);
assert_eq!(report2.conflicts, vec!["config/a.toml".to_string()]);
}
#[tokio::test]
async fn always_update_overwrites_user_edit() {
let root = tmp_root("always");
let path = "config/fabric_loader_dependencies.json";
let v1 = root.join("v1.mrpack");
write_bundle(&v1, &[(&format!("overrides/{path}"), b"deps-v1")]).await;
sync(&v1, &root).await;
tokio::fs::write(root.join(path), b"runtime-mutated")
.await
.unwrap();
let v2 = root.join("v2.mrpack");
write_bundle(&v2, &[(&format!("overrides/{path}"), b"deps-v2")]).await;
let report = sync(&v2, &root).await;
assert!(report.conflicts.is_empty());
assert_eq!(read(&root.join(path)).await.unwrap(), b"deps-v2");
}
#[tokio::test]
async fn removed_file_deleted_only_when_unmodified() {
let root = tmp_root("removed");
let v1 = root.join("v1.mrpack");
write_bundle(
&v1,
&[
("overrides/config/gone.toml", b"g"),
("overrides/config/kept.toml", b"k"),
],
)
.await;
sync(&v1, &root).await;
// User modified kept.toml; gone.toml untouched.
tokio::fs::write(root.join("config/kept.toml"), b"edited")
.await
.unwrap();
let v2 = root.join("v2.mrpack");
write_bundle(&v2, &[("overrides/config/a.toml", b"a")]).await;
let report = sync(&v2, &root).await;
assert_eq!(report.deleted, vec!["config/gone.toml".to_string()]);
assert!(read(&root.join("config/gone.toml")).await.is_none());
assert_eq!(
read(&root.join("config/kept.toml")).await.unwrap(),
b"edited"
);
}
#[tokio::test]
async fn bootstrap_pre_existing_edit_is_never_overwritten() {
let root = tmp_root("bootstrap");
tokio::fs::create_dir_all(root.join("config"))
.await
.unwrap();
tokio::fs::write(root.join("config/a.toml"), b"pre-existing")
.await
.unwrap();
let v1 = root.join("v1.mrpack");
write_bundle(&v1, &[("overrides/config/a.toml", b"bundle-version")]).await;
let report = sync(&v1, &root).await;
assert!(report.written.is_empty());
assert!(report.conflicts.is_empty());
assert_eq!(
read(&root.join("config/a.toml")).await.unwrap(),
b"pre-existing"
);
let report2 = sync(&v1, &root).await;
assert!(report2.written.is_empty());
assert_eq!(
read(&root.join("config/a.toml")).await.unwrap(),
b"pre-existing"
);
}
}

View file

@ -56,6 +56,7 @@ struct PolyMrpackHashes {
pub sha1: String,
}
#[tracing::instrument(level = "debug")]
pub async fn read_meta_from_archive(path: &Path) -> LauncherResult<PolyMrpackMeta> {
let manifest = read_manifest_from_archive(path).await?;
Ok(PolyMrpackMeta {
@ -69,6 +70,7 @@ pub async fn read_meta_from_archive(path: &Path) -> LauncherResult<PolyMrpackMet
})
}
#[tracing::instrument(level = "debug")]
pub async fn read_manifest_from_archive(path: &Path) -> LauncherResult<BundleManifest> {
let file = tokio::fs::File::open(path).await?;
let reader = tokio::io::BufReader::new(file);

View file

@ -1,6 +1,8 @@
use std::sync::atomic::{AtomicBool, Ordering};
use crate::bundles::manager::BundlesManager;
use crate::bundles::ApplyBundleUpdatesResult;
use crate::bundles::updates::apply_bundle_updates_for_all_clusters;
use crate::state::LauncherServices;
@ -10,10 +12,20 @@ pub fn is_bundle_syncing() -> bool {
BUNDLE_SYNCING.load(Ordering::Relaxed)
}
pub async fn sync_all_cluster_bundles(bundles: &BundlesManager, services: &LauncherServices) {
#[tracing::instrument(skip_all)]
pub async fn sync_all_cluster_bundles(
bundles: &BundlesManager,
services: &LauncherServices,
) -> Vec<(i64, ApplyBundleUpdatesResult)> {
tracing::info!("syncing all cluster bundles");
BUNDLE_SYNCING.store(true, Ordering::Relaxed);
if let Err(err) = apply_bundle_updates_for_all_clusters(bundles, services).await {
tracing::error!("failed to apply bundle updates for clusters: {err:#}");
}
let changed = match apply_bundle_updates_for_all_clusters(bundles, services).await {
Ok(changed) => changed,
Err(err) => {
tracing::error!("failed to apply bundle updates for clusters: {err:#}");
Vec::new()
}
};
BUNDLE_SYNCING.store(false, Ordering::Relaxed);
changed
}

View file

@ -8,7 +8,6 @@ use oneclient_db::dao::cluster_bundle as bundle_dao;
use oneclient_db::models::ClusterPatch;
use oneclient_db::models::{BundleTrackedArtifactRow, ClusterBundleOverrideRow, OverrideType};
use tokio::sync::Mutex as AsyncMutex;
use tracing::instrument;
use crate::bundles::install::{install_package_from_bundle, remove_artifact_from_cluster};
use crate::bundles::manager::BundlesManager;
@ -34,6 +33,7 @@ fn cluster_lock(cluster_id: i64) -> Arc<AsyncMutex<()>> {
.clone()
}
#[tracing::instrument(level = "debug", skip(bundles, services))]
pub async fn check_bundle_updates(
cluster_id: i64,
bundles: &BundlesManager,
@ -43,7 +43,7 @@ pub async fn check_bundle_updates(
check_bundle_updates_inner(cluster_id, bundles, services, &overrides).await
}
#[instrument(skip(bundles, services, overrides))]
#[tracing::instrument(level = "debug", skip(bundles, services, overrides))]
async fn check_bundle_updates_inner(
cluster_id: i64,
bundles: &BundlesManager,
@ -307,6 +307,7 @@ async fn check_bundle_updates_inner(
})
}
#[tracing::instrument(skip(bundles, services))]
pub async fn apply_bundle_updates(
cluster_id: i64,
bundles: &BundlesManager,
@ -317,6 +318,14 @@ pub async fn apply_bundle_updates(
let overrides = bundle_dao::list_overrides(&services.db, cluster_id).await?;
let check = check_bundle_updates_inner(cluster_id, bundles, services, &overrides).await?;
tracing::info!(
cluster_id,
updates = check.updates_available.len(),
additions = check.additions_available.len(),
removals = check.removals_available.len(),
"applying bundle updates"
);
let mut result = ApplyBundleUpdatesResult::default();
for removal in check.removals_available {
@ -328,8 +337,21 @@ pub async fn apply_bundle_updates(
}
}
let session = (!check.updates_available.is_empty() || !check.additions_available.is_empty())
.then(|| {
crate::notification::GroupedProgressSession::start(
&services.notifier,
"Updating bundle content",
)
});
for update in check.updates_available {
match apply_single_update(&update, &overrides, services).await {
let child = session.as_ref().map(|s| {
let c = s.child(update.new_file.display_name(), update.new_file.size.max(1));
c.set_phase(crate::notification::TaskPhase::Downloading);
c
});
match apply_single_update(&update, &overrides, child.as_ref(), services).await {
Ok(_) => result.updates_applied.push(update),
Err(err) => result.updates_failed.push(err.to_string()),
}
@ -337,28 +359,22 @@ pub async fn apply_bundle_updates(
for addition in check.additions_available {
let file_id = addition.new_file.kind.package_id();
match apply_single_addition(&addition, &overrides, services).await {
let child = session.as_ref().map(|s| {
let c = s.child(addition.new_file.display_name(), addition.new_file.size.max(1));
c.set_phase(crate::notification::TaskPhase::Downloading);
c
});
match apply_single_addition(&addition, &overrides, child.as_ref(), services).await {
Ok(_) => result.additions_applied.push(addition),
Err(err) => result.additions_failed.push(format!("{file_id}: {err:#}")),
}
}
let has_changes = !result.updates_applied.is_empty()
|| !result.removals_applied.is_empty()
|| !result.additions_applied.is_empty();
if has_changes {
let mut affected = HashSet::new();
for u in &result.updates_applied {
affected.insert(u.bundle_name.clone());
}
for r in &result.removals_applied {
affected.insert(r.bundle_name.clone());
}
for a in &result.additions_applied {
affected.insert(a.bundle_name.clone());
}
if let Some(session) = session {
session.finish();
}
{
let cluster = PackageStore::get_cluster(cluster_id, services).await?;
let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Fabric);
if let Ok(archives) = bundles
@ -366,12 +382,19 @@ pub async fn apply_bundle_updates(
.await
{
for archive in archives {
if affected.contains(&archive.manifest.name) {
let _ = overrides::extract_bundle_overrides_no_overwrite(
&archive.bundle.path,
&cluster,
)
.await;
if let Err(err) = overrides::sync_bundle_overrides(
&archive.bundle.path,
&archive.manifest.name,
&cluster,
Some(&services.notifier),
)
.await
{
tracing::warn!(
bundle = %archive.manifest.name,
error = %err,
"failed to sync bundle overrides during update"
);
}
}
}
@ -386,9 +409,11 @@ pub async fn apply_bundle_updates(
Ok(result)
}
#[tracing::instrument(level = "debug", skip_all, fields(cluster_id = update.cluster_id, bundle = %update.bundle_name, new_version = %update.new_version_id))]
async fn apply_single_update(
update: &BundlePackageUpdate,
overrides: &[ClusterBundleOverrideRow],
child: Option<&crate::notification::GroupedProgressChild>,
services: &LauncherServices,
) -> LauncherResult<()> {
let hash = install_package_from_bundle(
@ -396,9 +421,14 @@ async fn apply_single_update(
update.cluster_id,
&update.bundle_name,
true,
child,
services,
)
.await?;
if let Some(child) = child {
child.set_phase(crate::notification::TaskPhase::Installing);
child.finish();
}
let file_id = update.new_file.kind.package_id();
if should_be_disabled(&update.bundle_name, &file_id, overrides) {
@ -412,9 +442,11 @@ async fn apply_single_update(
remove_artifact_from_cluster(update.cluster_id, &update.installed_hash, false, services).await
}
#[tracing::instrument(level = "debug", skip_all, fields(cluster_id = addition.cluster_id, bundle = %addition.bundle_name))]
async fn apply_single_addition(
addition: &BundlePackageAddition,
overrides: &[ClusterBundleOverrideRow],
child: Option<&crate::notification::GroupedProgressChild>,
services: &LauncherServices,
) -> LauncherResult<()> {
let hash = install_package_from_bundle(
@ -422,9 +454,14 @@ async fn apply_single_addition(
addition.cluster_id,
&addition.bundle_name,
true,
child,
services,
)
.await?;
if let Some(child) = child {
child.set_phase(crate::notification::TaskPhase::Installing);
child.finish();
}
let file_id = addition.new_file.kind.package_id();
if should_be_disabled(&addition.bundle_name, &file_id, overrides) {
@ -446,6 +483,7 @@ fn should_be_disabled(
})
}
#[tracing::instrument(level = "debug", skip(bundles, services))]
async fn sync_cluster_loader_version_from_bundles(
cluster_id: i64,
bundles: &BundlesManager,
@ -494,6 +532,7 @@ async fn sync_cluster_loader_version_from_bundles(
Ok(())
}
#[tracing::instrument(level = "debug", skip(bundles, services))]
pub async fn get_bundles_with_update_status(
cluster_id: i64,
bundles: &BundlesManager,
@ -590,20 +629,30 @@ pub async fn get_bundles_with_update_status(
Ok(results)
}
#[tracing::instrument(level = "debug", skip_all)]
pub async fn apply_bundle_updates_for_all_clusters(
bundles: &BundlesManager,
services: &LauncherServices,
) -> LauncherResult<()> {
) -> LauncherResult<Vec<(i64, ApplyBundleUpdatesResult)>> {
let mut changed = Vec::new();
for cluster in cluster_dao::list_all(&services.db).await? {
if let Err(err) = apply_bundle_updates(cluster.id, bundles, services).await {
tracing::warn!(
match apply_bundle_updates(cluster.id, bundles, services).await {
Ok(result) => {
if !result.updates_applied.is_empty()
|| !result.additions_applied.is_empty()
|| !result.removals_applied.is_empty()
{
changed.push((cluster.id, result));
}
}
Err(err) => tracing::warn!(
cluster_id = cluster.id,
error = %err,
"bundle update apply failed for cluster"
);
),
}
}
Ok(())
Ok(changed)
}
fn bundle_package_key(
@ -620,6 +669,7 @@ fn bundle_package_key(
}
}
#[tracing::instrument(level = "debug", skip_all)]
async fn installed_bundle_keys(
services: &LauncherServices,
linked: &[LinkedArtifactInfo],

View file

@ -35,6 +35,7 @@ pub fn parse_changelog(data: &str) -> Vec<ChangelogGroup> {
groups
}
#[tracing::instrument(level = "debug", skip(services))]
pub async fn fetch_changelog(services: &LauncherServices) -> LauncherResult<String> {
let url = format!("{}/oneclient/CHANGE_LOG.md", meta_url_base());
let cache_path = paths::caches_dir()?.join("CHANGE_LOG.md");

View file

@ -1,6 +1,5 @@
use oneclient_db::dao::{cluster as cluster_dao, setting_profile as profile_dao};
use oneclient_db::models::{ClusterId, ClusterPatch, NewCluster};
use tracing::instrument;
use crate::packages::domain::ContentType;
use crate::settings::store::{
@ -28,6 +27,7 @@ impl ClusterManager {
name.trim().to_string()
}
#[tracing::instrument(level = "debug", skip(state))]
pub async fn get(state: &LauncherState, cluster_id: ClusterId) -> LauncherResult<Cluster> {
let row = cluster_dao::get_by_id(&state.services.db, cluster_id)
.await?
@ -35,6 +35,7 @@ impl ClusterManager {
Ok(Cluster::try_from_row(row)?)
}
#[tracing::instrument(level = "debug", skip(state))]
pub async fn list(state: &LauncherState) -> LauncherResult<Vec<Cluster>> {
let rows = cluster_dao::list_all(&state.services.db).await?;
rows.into_iter()
@ -43,7 +44,7 @@ impl ClusterManager {
.map_err(Into::into)
}
#[instrument(skip(state))]
#[tracing::instrument(skip(state))]
pub async fn create(
state: &LauncherState,
options: CreateClusterOptions,
@ -57,14 +58,19 @@ impl ClusterManager {
let cluster_path = crate::paths::clusters_dir()?.join(&folder_name);
match create_inner(state, &options, &name, &folder_name, &cluster_path).await {
Ok(cluster) => Ok(cluster),
Ok(cluster) => {
tracing::info!(cluster_id = cluster.id, name = %cluster.name, "created cluster");
Ok(cluster)
}
Err(err) => {
tracing::warn!(name = %name, error = %err, "cluster creation failed, cleaning up directory");
let _ = polyio::remove_dir_all(&cluster_path).await;
Err(err)
}
}
}
#[tracing::instrument(level = "debug", skip(state))]
pub async fn update(
state: &LauncherState,
cluster_id: ClusterId,
@ -92,6 +98,7 @@ impl ClusterManager {
Ok(Cluster::try_from_row(row)?)
}
#[tracing::instrument(skip(state))]
pub async fn delete(
state: &LauncherState,
cluster_id: ClusterId,
@ -110,9 +117,11 @@ impl ClusterManager {
}
}
tracing::info!(cluster_id, remove_files, "deleted cluster");
Ok(())
}
#[tracing::instrument(level = "debug", skip(state))]
pub async fn set_stage(
state: &LauncherState,
cluster_id: ClusterId,
@ -122,6 +131,7 @@ impl ClusterManager {
Ok(Cluster::try_from_row(row)?)
}
#[tracing::instrument(level = "debug", skip(state))]
pub async fn uses_dedicated_dir(
state: &LauncherState,
cluster_id: ClusterId,
@ -129,6 +139,7 @@ impl ClusterManager {
Ok(Self::get(state, cluster_id).await?.uses_dedicated_dir())
}
#[tracing::instrument(level = "debug", skip(state))]
pub async fn set_dedicated_dir(
state: &LauncherState,
cluster_id: ClusterId,
@ -149,6 +160,7 @@ impl ClusterManager {
Ok(())
}
#[tracing::instrument(level = "debug", skip(state))]
pub async fn add_playtime(
state: &LauncherState,
cluster_id: ClusterId,
@ -160,6 +172,7 @@ impl ClusterManager {
Ok(Cluster::try_from_row(row)?)
}
#[tracing::instrument(level = "debug", skip(state, cluster), fields(cluster_id = cluster.id))]
pub async fn resolve_settings(
state: &LauncherState,
cluster: &Cluster,
@ -174,6 +187,7 @@ impl ClusterManager {
.await
}
#[tracing::instrument(level = "debug", skip(state, update))]
pub async fn update_profile(
state: &LauncherState,
cluster_id: ClusterId,
@ -187,6 +201,7 @@ impl ClusterManager {
update_named_profile(&state.services.db, &profile_name, update).await
}
#[tracing::instrument(skip(state, shared_progress))]
pub async fn prepare(
state: &Arc<LauncherState>,
cluster_id: ClusterId,
@ -208,6 +223,7 @@ impl ClusterManager {
.await
}
#[tracing::instrument(level = "debug", skip(state))]
pub async fn create_and_assign_profile(
state: &LauncherState,
cluster_id: ClusterId,
@ -235,6 +251,7 @@ impl ClusterManager {
}
}
#[tracing::instrument(level = "debug", skip(state, options))]
async fn create_inner(
state: &LauncherState,
options: &CreateClusterOptions,
@ -273,6 +290,7 @@ async fn create_inner(
Ok(Cluster::try_from_row(row)?)
}
#[tracing::instrument(level = "debug", skip(pool))]
async fn ensure_profile_exists(pool: &oneclient_db::DbPool, name: &str) -> LauncherResult<()> {
if profile_dao::get_by_name(pool, name).await?.is_none() {
return Err(ClusterError::ProfileNotFound(name.to_string()).into());
@ -280,6 +298,7 @@ async fn ensure_profile_exists(pool: &oneclient_db::DbPool, name: &str) -> Launc
Ok(())
}
#[tracing::instrument(level = "debug")]
async fn resolve_unique_folder_name(name: &str) -> LauncherResult<String> {
let cluster_dir = crate::paths::clusters_dir()?;
let mut folder_name = name.to_string();
@ -301,6 +320,7 @@ async fn resolve_unique_folder_name(name: &str) -> LauncherResult<String> {
Ok(folder_name)
}
#[tracing::instrument(level = "debug")]
async fn ensure_content_dirs(cluster_path: &std::path::Path) -> LauncherResult<()> {
for content_type in [
ContentType::Mod,

View file

@ -4,7 +4,6 @@ use std::sync::Arc;
use interfrost::api::minecraft::{DownloadType, VersionInfo};
use interfrost::api::modded::SidedDataEntry;
use tokio::process::Command;
use tracing::instrument;
use crate::clusters::cluster::Cluster;
use crate::clusters::error::ClusterError;
@ -21,7 +20,7 @@ use crate::paths;
use crate::state::LauncherState;
use crate::{GameError, LauncherResult};
#[instrument(skip(state, metadata, shared_progress))]
#[tracing::instrument(skip(state, metadata, shared_progress))]
pub async fn prepare_cluster(
state: &Arc<LauncherState>,
metadata: &mut MetadataStore,
@ -34,6 +33,14 @@ pub async fn prepare_cluster(
let cluster = ClusterManager::get(state, cluster_id).await?;
let continuing = cluster.stage == ClusterStage::Downloading;
tracing::info!(
cluster_id,
mc_version = %cluster.mc_version,
force,
continuing,
"preparing cluster"
);
if !continuing {
ClusterManager::set_stage(state, cluster_id, ClusterStage::Downloading).await?;
}
@ -62,6 +69,7 @@ pub async fn prepare_cluster(
}
if let Err(err) = result {
tracing::error!(cluster_id, error = %err, "cluster preparation failed");
if !continuing {
let _ = ClusterManager::set_stage(state, cluster_id, ClusterStage::NotReady).await;
}
@ -69,6 +77,7 @@ pub async fn prepare_cluster(
}
let cluster = ClusterManager::set_stage(state, cluster_id, ClusterStage::Ready).await?;
tracing::debug!(cluster_id, "cluster stage set to Ready");
Ok(cluster)
}
@ -91,6 +100,7 @@ fn game_download_bytes(info: &VersionInfo) -> u64 {
client + assets + libraries
}
#[tracing::instrument(level = "debug", skip(state, bundles))]
pub async fn estimate_cluster_download(
state: &Arc<LauncherState>,
cluster_id: i64,
@ -134,6 +144,7 @@ pub async fn estimate_cluster_download(
Ok(total)
}
#[tracing::instrument(skip(state, metadata, cluster, progress), fields(cluster_id = cluster.id))]
async fn install_cluster(
state: &Arc<LauncherState>,
metadata: &mut MetadataStore,
@ -205,6 +216,7 @@ async fn install_cluster(
Ok(())
}
#[tracing::instrument(level = "debug", skip(cluster, version_info, java), fields(cluster_id = cluster.id))]
async fn run_forge_processors(
cluster: &Cluster,
version_info: &mut VersionInfo,

View file

@ -1,7 +1,6 @@
use std::str::FromStr;
use oneclient_db::dao::{bundle as bundle_dao, cluster as cluster_dao};
use tracing::instrument;
use crate::packages::domain::GameLoader;
use crate::state::LauncherState;
@ -12,7 +11,7 @@ use super::cluster::Cluster;
use super::manager::ClusterManager;
use super::options::CreateClusterOptions;
#[instrument(skip(state))]
#[tracing::instrument(skip(state))]
pub async fn ensure_from_bundles(state: &LauncherState) -> LauncherResult<Vec<Cluster>> {
let groups = bundle_dao::list_distinct_version_loaders(&state.services.db).await?;
let mut created = Vec::new();
@ -69,7 +68,7 @@ pub async fn ensure_from_bundles(state: &LauncherState) -> LauncherResult<Vec<Cl
Ok(created)
}
#[instrument(skip(state))]
#[tracing::instrument(skip(state))]
pub async fn ensure_from_versions(state: &LauncherState) -> LauncherResult<Vec<Cluster>> {
let metadata = state.versions.metadata().await;
let mut created = Vec::new();

View file

@ -160,6 +160,9 @@ fn spawn_notification_handler(mut rx: mpsc::UnboundedReceiver<Notification>) {
Notification::InvalidateClusters => {}
Notification::InvalidateJava => {}
Notification::SyncComplete => {}
Notification::MicrosoftLoginStatus(status) => {
mp.suspend(|| tracing::info!(?status, "microsoft login status"));
}
Notification::GameStage { cluster_id, stage } => {
mp.suspend(|| tracing::info!(cluster_id, ?stage, "game stage"));
}

View file

@ -233,6 +233,7 @@ pub struct Analytics {
pub servers: Vec<ServerStat>,
}
#[tracing::instrument(level = "debug")]
pub async fn global_analytics() -> LauncherResult<Analytics> {
let state = LauncherState::get()?;
let db = &state.services.db;
@ -244,6 +245,7 @@ pub async fn global_analytics() -> LauncherResult<Analytics> {
})
}
#[tracing::instrument(level = "debug")]
pub async fn cluster_analytics(cluster_id: i64) -> LauncherResult<Analytics> {
let state = LauncherState::get()?;
let db = &state.services.db;

View file

@ -168,6 +168,7 @@ pub fn processor_arguments<T: AsRef<str>, S: std::hash::BuildHasher>(
Ok(parsed)
}
#[tracing::instrument(skip_all, level = "debug")]
pub async fn main_class(path: impl AsRef<std::path::Path>) -> LauncherResult<Option<String>> {
let data = polyio::read(path.as_ref()).await?;
let mut class_name = None;

View file

@ -6,6 +6,7 @@ use crate::http::{RequestClient, ResponseExt, ResponseNotifyOptions, ResponseOpt
use crate::notification::{GroupedProgressSession, NotificationService, TaskPhase};
use crate::{LauncherError, LauncherResult};
#[tracing::instrument(skip(requester, notifier, progress, label, expected_sha1), fields(%url), level = "debug")]
pub async fn download_to_path(
requester: &RequestClient,
notifier: &NotificationService,
@ -56,6 +57,7 @@ pub async fn download_to_path(
Ok(())
}
#[tracing::instrument(skip(requester, notifier, progress, label), fields(%url), level = "debug")]
pub async fn fetch_bytes_verified(
requester: &RequestClient,
notifier: &NotificationService,

View file

@ -40,8 +40,11 @@ pub async fn launch_cluster(
account: &MinecraftAccount,
search_for_java: bool,
) -> LauncherResult<LaunchedGame> {
tracing::info!(cluster_id, search_for_java, "launching cluster");
let parallel = state.settings.read().allow_parallel_running_clusters;
if !parallel && state.games.is_running(cluster_id) {
tracing::warn!(cluster_id, "cluster already running; refusing launch");
return Err(GameError::AlreadyRunning(cluster_id).into());
}
@ -435,6 +438,7 @@ fn apply_env(command: &mut Command, profile: &GameSettingsProfile) {
}
}
#[tracing::instrument(skip(cwd), fields(hook), level = "debug")]
async fn run_hook(hook: Option<&str>, cwd: &Path) {
let Some(hook) = hook.map(str::trim).filter(|h| !h.is_empty()) else {
return;

View file

@ -4,7 +4,6 @@ use futures_util::{StreamExt, stream};
use interfrost::api::minecraft::{AssetsIndex, DownloadType, Library, Os, Version, VersionInfo};
use interfrost::api::modded::LoaderVersion;
use reqwest::Method;
use tracing::instrument;
use crate::game::GameError;
use crate::game::download::{download_to_path, fetch_bytes_verified};
@ -18,7 +17,7 @@ use crate::paths;
use crate::state::LauncherServices;
use crate::{LauncherError, LauncherResult};
#[instrument(skip_all)]
#[tracing::instrument(skip_all)]
pub async fn download_minecraft(
services: &LauncherServices,
progress: &GroupedProgressSession,
@ -51,7 +50,7 @@ pub async fn download_minecraft(
Ok(())
}
#[instrument(skip(services, progress))]
#[tracing::instrument(skip(services, progress), level = "debug")]
pub async fn download_version_info(
services: &LauncherServices,
progress: Option<&GroupedProgressSession>,
@ -71,7 +70,7 @@ pub async fn download_version_info(
let data = polyio::read(&path).await?;
serde_json::from_slice(&data)?
} else {
tracing::info!(
tracing::debug!(
version_id = %version_id,
"downloading Minecraft version metadata"
);
@ -150,7 +149,7 @@ pub async fn download_version_info(
Ok(result)
}
#[instrument(skip_all)]
#[tracing::instrument(skip_all, level = "debug")]
pub async fn download_assets_index(
services: &LauncherServices,
progress: &GroupedProgressSession,
@ -180,7 +179,7 @@ pub async fn download_assets_index(
polyio::read_json(&path).await.map_err(Into::into)
}
#[instrument(skip_all)]
#[tracing::instrument(skip_all, level = "debug")]
pub async fn download_assets(
services: &LauncherServices,
progress: &GroupedProgressSession,
@ -245,6 +244,7 @@ pub async fn download_assets(
Ok(failed)
}
#[tracing::instrument(skip(services, progress, version), fields(version_id = %version.id, force), level = "debug")]
pub async fn download_client(
services: &LauncherServices,
progress: &GroupedProgressSession,
@ -283,6 +283,7 @@ pub async fn download_client(
Ok(path)
}
#[tracing::instrument(skip(version_info), level = "debug")]
pub fn libraries_missing(
version_info: &VersionInfo,
java_arch: &str,
@ -317,6 +318,7 @@ fn lib_short(name: &str) -> String {
}
}
#[tracing::instrument(skip(services, progress, libraries), level = "debug")]
pub async fn download_libraries(
services: &LauncherServices,
progress: &GroupedProgressSession,
@ -452,6 +454,7 @@ pub async fn download_libraries(
Ok(failed)
}
#[tracing::instrument(skip(metadata, services), level = "debug")]
pub async fn get_loader_versions(
metadata: &mut MetadataStore,
services: &LauncherServices,
@ -475,6 +478,7 @@ pub async fn get_loader_versions(
Ok(Vec::new())
}
#[tracing::instrument(skip(metadata, services), level = "debug")]
pub async fn get_loader_version(
metadata: &mut MetadataStore,
services: &LauncherServices,
@ -557,6 +561,7 @@ pub async fn get_loader_version(
}
}
#[tracing::instrument(skip(metadata, services), level = "debug")]
pub async fn resolve_minecraft_version(
metadata: &mut MetadataStore,
services: &LauncherServices,
@ -581,6 +586,7 @@ pub async fn resolve_minecraft_version(
))
}
#[tracing::instrument(skip(metadata, services), level = "debug")]
pub async fn get_game_versions(
metadata: &mut MetadataStore,
services: &LauncherServices,
@ -589,6 +595,7 @@ pub async fn get_game_versions(
Ok(manifest.versions.clone())
}
#[tracing::instrument(skip(metadata, services), level = "debug")]
pub async fn get_loaders_for_version(
metadata: &mut MetadataStore,
services: &LauncherServices,

View file

@ -31,9 +31,13 @@ impl GameProcessManager {
self.kills.lock().unwrap().insert(cluster_id, tx);
}
#[tracing::instrument(skip(self), level = "debug")]
pub fn kill(&self, cluster_id: i64) -> bool {
match self.kills.lock().unwrap().remove(&cluster_id) {
Some(tx) => tx.send(()).is_ok(),
Some(tx) => {
tracing::debug!(cluster_id, "signalling kill to running game");
tx.send(()).is_ok()
}
None => false,
}
}

View file

@ -50,6 +50,7 @@ pub(crate) struct SessionRecorder {
}
impl SessionRecorder {
#[tracing::instrument(skip(state, java), fields(cluster_id, ram_allocated_mb), level = "debug")]
pub(crate) async fn start(
state: &Arc<LauncherState>,
cluster_id: i64,
@ -110,6 +111,7 @@ impl SessionRecorder {
}
}
#[tracing::instrument(skip(self), fields(exit_code), level = "debug")]
pub(crate) async fn finish(self, exit_code: Option<i64>) {
if let Some(open) = self.open_server.lock().await.take()
&& let Err(err) = session_dao::finish_server(&self.db, &open).await
@ -125,6 +127,7 @@ impl SessionRecorder {
}
}
#[tracing::instrument(skip(state), fields(cluster_id), level = "debug")]
async fn count_enabled_mods(state: &Arc<LauncherState>, cluster_id: i64) -> usize {
match PackageStore::list_linked_artifacts(cluster_id, &state.services).await {
Ok(linked) => linked

View file

@ -20,6 +20,7 @@ const SWAP_TYPES: [ContentType; 3] = [
const FABRIC_DEP_OVERRIDES: &str = "config/fabric_loader_dependencies.json";
#[tracing::instrument(skip(services, cluster), fields(cluster_id = cluster.id, game_dir = %game_dir.display()), level = "debug")]
pub async fn sync_shared_content(
services: &LauncherServices,
cluster: &Cluster,
@ -71,6 +72,7 @@ pub async fn sync_shared_content(
Ok(())
}
#[tracing::instrument(skip(services, cluster), fields(cluster_id = cluster.id), level = "debug")]
pub async fn import_manual_content(
services: &LauncherServices,
cluster: &Cluster,
@ -117,7 +119,7 @@ pub async fn import_manual_content(
match PackageStore::import_local_file(&path, content_type, cluster.id, services).await {
Ok(_) => {
tracing::info!(file = name, "registered manually-added shared content")
tracing::debug!(file = name, "registered manually-added shared content")
}
Err(err) => tracing::warn!(
file = name,
@ -140,6 +142,7 @@ fn has_content_extension(content_type: ContentType, name: &str) -> bool {
const ALLOWED_SYMLINKS_NAME: &str = "allowed_symlinks.txt";
#[tracing::instrument(level = "debug")]
pub async fn write_allowed_symlinks(game_dir: &Path) -> LauncherResult<()> {
let root = crate::paths::launcher_dir()?;
let base = polyio::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
@ -203,6 +206,7 @@ async fn ensure_note(dir: &Path, content_type: ContentType) {
.ok();
}
#[tracing::instrument(level = "debug")]
pub async fn clear_shared_content(game_dir: &Path) -> LauncherResult<()> {
for content_type in SWAP_TYPES {
let dir = game_dir.join(content_type.folder_name());
@ -213,6 +217,7 @@ pub async fn clear_shared_content(game_dir: &Path) -> LauncherResult<()> {
Ok(())
}
#[tracing::instrument(skip(cluster), fields(cluster_id = cluster.id), level = "debug")]
pub async fn link_cluster_logs(cluster: &Cluster, game_dir: &Path) {
let cluster_dir = match cluster.dir() {
Ok(dir) => dir,
@ -268,6 +273,7 @@ async fn redirect_dir(shared: &Path, target: &Path) -> LauncherResult<()> {
Ok(())
}
#[tracing::instrument(level = "debug")]
pub async fn unlink_cluster_logs(game_dir: &Path) {
for name in REDIRECTED_DIRS {
let shared = game_dir.join(name);

View file

@ -13,6 +13,8 @@ pub struct ResponseOptions {
pub struct ResponseNotifyOptions {
child: Option<GroupedProgressChild>,
standalone_label: Option<String>,
standalone_id: Option<Uuid>,
done_label: Option<String>,
}
impl ResponseNotifyOptions {
@ -20,6 +22,8 @@ impl ResponseNotifyOptions {
Self {
child: Some(child),
standalone_label: None,
standalone_id: None,
done_label: None,
}
}
@ -27,8 +31,20 @@ impl ResponseNotifyOptions {
Self {
child: None,
standalone_label: Some(label.into()),
standalone_id: None,
done_label: None,
}
}
pub fn with_id(mut self, id: Uuid) -> Self {
self.standalone_id = Some(id);
self
}
pub fn done_label(mut self, label: impl Into<String>) -> Self {
self.done_label = Some(label.into());
self
}
}
#[async_trait::async_trait]
@ -42,6 +58,7 @@ pub trait ResponseExt {
#[async_trait::async_trait]
impl ResponseExt for Response {
#[tracing::instrument(level = "debug", skip_all)]
async fn stream(
self,
options: ResponseOptions,
@ -59,7 +76,14 @@ impl ResponseExt for Response {
.notify
.as_ref()
.and_then(|n| n.standalone_label.clone());
let standalone_id = standalone_label.as_ref().map(|_| Uuid::new_v4());
let done_label = options
.notify
.as_ref()
.and_then(|n| n.done_label.clone());
let standalone_id = standalone_label
.as_ref()
.map(|_| options.notify.as_ref().and_then(|n| n.standalone_id).unwrap_or_else(Uuid::new_v4));
if let Some(ref child) = grouped_child {
child.set_progress(0, Some(total));
@ -67,6 +91,7 @@ impl ResponseExt for Response {
notifier.send_progress(id, label, 0, total);
}
let notifier = notifier.clone();
let stream = futures_lite::StreamExt::map(self.bytes_stream(), move |item| {
match item {
Ok(chunk) => {
@ -75,6 +100,12 @@ impl ResponseExt for Response {
if let Some(ref child) = grouped_child {
child.set_progress(current, Some(total));
} else if let (Some(id), Some(label)) = (&standalone_id, &standalone_label) {
let done = current >= total;
let label = if done {
done_label.as_deref().unwrap_or(label)
} else {
label
};
notifier.send_progress(id, label, current, total);
}

View file

@ -87,6 +87,7 @@ impl RequestClient {
}
impl RequestClient {
#[tracing::instrument(level = "debug", skip_all)]
pub async fn send(&self, request: impl Into<HttpRequest>) -> Result<Response, RequestError> {
let request: HttpRequest = request.into();
let mut retries = 0;
@ -103,6 +104,12 @@ impl RequestClient {
let mut active_request = request.request;
tracing::debug!(
method = %active_request.method(),
url = %active_request.url(),
"dispatching http request"
);
let res = loop {
let permit = if request.options.use_semaphore {
self.semaphore.acquire().await.ok()
@ -159,6 +166,7 @@ impl RequestClient {
Ok(res)
}
#[tracing::instrument(level = "debug", skip(self, request, options, services), fields(dest = %dest.as_ref().display()))]
pub async fn download_file(
&self,
request: impl Into<HttpRequest>,
@ -169,12 +177,13 @@ impl RequestClient {
let res = self.send(request).await?;
let http_stream = res.stream(options, &services.notifier).await?;
let http_stream = std::pin::pin!(http_stream);
polyio::write_stream(dest, http_stream).await?;
Ok(())
}
#[tracing::instrument(level = "debug", skip_all)]
pub async fn send_as<T: DeserializeOwned>(&self, request: impl Into<HttpRequest>) -> Result<T, RequestError> {
let res = self.send(request).await?;
let status = res.status();
@ -198,6 +207,7 @@ impl RequestClient {
})
}
#[tracing::instrument(level = "debug", skip(self, body, extra_headers), fields(method = %method, %url))]
pub async fn send_json<T: DeserializeOwned>(
&self,
method: reqwest::Method,

View file

@ -4,7 +4,6 @@ use std::path::PathBuf;
use bytes::Bytes;
use reqwest::Method;
use tokio::sync::Mutex;
use tracing::instrument;
use crate::crypto::sha1_bytes;
use crate::http::RequestError;
@ -25,7 +24,7 @@ impl ImageCacheStore {
Self::default()
}
#[instrument(skip(self, services))]
#[tracing::instrument(level = "debug", skip(self, services))]
pub async fn get(
&self,
services: &LauncherServices,
@ -109,6 +108,7 @@ fn downscale(bytes: &[u8], max_edge: u32) -> Option<Bytes> {
Some(Bytes::from(out))
}
#[tracing::instrument(level = "debug", skip(services))]
async fn download(services: &LauncherServices, url: &str) -> LauncherResult<Bytes> {
let parsed = url.parse().map_err(RequestError::from)?;
let request = reqwest::Request::new(Method::GET, parsed);

View file

@ -2,7 +2,6 @@ use std::{collections::HashMap, path::PathBuf};
use async_tempfile::TempDir;
use tokio::sync::OnceCell;
use tracing::instrument;
use crate::java::{JavaError, JavaResult};
@ -15,7 +14,7 @@ pub struct JavaCheckInfo {
pub os_arch: String,
}
#[instrument(level = "info", skip(absolute_path))]
#[tracing::instrument(level = "debug", skip(absolute_path))]
pub async fn check_java_runtime(absolute_path: String) -> JavaResult<JavaCheckInfo> {
let temp_dir = get_java_info_dir().await?;
@ -33,7 +32,7 @@ pub async fn check_java_runtime(absolute_path: String) -> JavaResult<JavaCheckIn
.map(|arg| arg.to_string_lossy().into_owned())
.collect();
tracing::info!("running command: {} {}", program, args.join(" "));
tracing::debug!("running command: {} {}", program, args.join(" "));
let output = command.output().await
.map_err(|e| JavaError::RuntimeCheckError {
@ -72,6 +71,7 @@ pub async fn check_java_runtime(absolute_path: String) -> JavaResult<JavaCheckIn
static TEMP_JAVA_INFO: OnceCell<TempDir> = OnceCell::const_new();
#[tracing::instrument(level = "debug")]
async fn get_java_info_dir() -> Result<&'static PathBuf, polyio::IOError> {
let dir: Result<&TempDir, polyio::IOError> = TEMP_JAVA_INFO
.get_or_try_init(async || {

View file

@ -1,7 +1,6 @@
use std::path::{Path, PathBuf};
use reqwest::Method;
use tracing::instrument;
use url::Url;
use crate::http::{ResponseNotifyOptions, ResponseOptions};
@ -12,7 +11,7 @@ use crate::paths;
use crate::state::LauncherServices;
use crate::LauncherResult;
#[instrument(skip(services))]
#[tracing::instrument(level = "debug", skip(services))]
pub async fn install_package(
package: &JavaPackage,
services: &LauncherServices,
@ -22,16 +21,19 @@ pub async fn install_package(
let archive_path = java_dir.join(polyio::sanitize_path(&package.name));
let major = package.java_version.first().copied().unwrap_or(0);
services.requester
.download_file(
reqwest::Request::new(Method::GET, Url::parse(&package.download_url)?),
&archive_path,
ResponseOptions {
notify: Some(ResponseNotifyOptions::standalone(format!(
"Installing {} {}",
package.vendor,
package.java_version.first().copied().unwrap_or(0)
))),
notify: Some(
ResponseNotifyOptions::standalone(format!(
"Installing {} {}",
package.vendor, major
))
.done_label(format!("Installed {} {}", package.vendor, major)),
),
},
services,
)
@ -57,10 +59,12 @@ pub async fn install_package(
let _ = tokio::fs::remove_file(&archive_path).await;
tracing::info!(vendor = %package.vendor, major, "installed Java runtime");
Ok(executable)
}
#[instrument]
#[tracing::instrument(level = "debug")]
fn resolve_installed_executable(extract_root: &Path, package: &JavaPackage) -> PathBuf {
let mut base_path = extract_root.to_path_buf();
@ -90,7 +94,7 @@ fn resolve_installed_executable(extract_root: &Path, package: &JavaPackage) -> P
#[cfg(target_os = "macos")]
{
use crate::java::vendors::JavaVendor;
if !base_path.join("Contents").join("Home").exists() {
if let Ok(mut entries) = std::fs::read_dir(&base_path) {
if let Some(dir) = entries.flatten().find(|entry| {

View file

@ -1,11 +1,13 @@
use std::collections::HashMap;
use std::path::PathBuf;
use crate::constants::JAVA_BIN;
use crate::java::checker::{check_java_runtime, JavaCheckInfo};
use crate::java::data::java_executable_relative_path;
use crate::LauncherResult;
#[tracing::instrument(level = "debug")]
pub async fn locate_java() -> LauncherResult<HashMap<PathBuf, JavaCheckInfo>> {
let paths = internal_locate_java();
let mut valid = HashMap::new();
@ -18,10 +20,13 @@ pub async fn locate_java() -> LauncherResult<HashMap<PathBuf, JavaCheckInfo>> {
valid.insert(path, info);
}
tracing::debug!(count = valid.len(), "located Java runtimes");
Ok(valid)
}
#[cfg(target_os = "windows")]
#[tracing::instrument(level = "debug")]
fn internal_locate_java() -> Vec<PathBuf> {
let mut java_homes = Vec::new();
@ -78,6 +83,7 @@ fn internal_locate_java() -> Vec<PathBuf> {
}
#[cfg(target_os = "macos")]
#[tracing::instrument(level = "debug")]
fn internal_locate_java() -> Vec<PathBuf> {
let mut found = Vec::new();
@ -115,6 +121,7 @@ fn internal_locate_java() -> Vec<PathBuf> {
}
#[cfg(target_os = "linux")]
#[tracing::instrument(level = "debug")]
fn internal_locate_java() -> Vec<PathBuf> {
let mut found = Vec::new();

View file

@ -1,3 +1,4 @@
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
@ -23,6 +24,7 @@ pub struct AvailableJava {
pub struct JavaManager;
impl JavaManager {
#[tracing::instrument(skip(state))]
pub async fn prepare_java(
state: &Arc<LauncherState>,
major: u32,
@ -31,6 +33,7 @@ impl JavaManager {
Self::prepare_java_with_services(&state.services, major, search_system, false).await
}
#[tracing::instrument(skip(state))]
pub async fn prepare_java_auto(
state: &Arc<LauncherState>,
major: u32,
@ -39,6 +42,7 @@ impl JavaManager {
Self::prepare_java_with_services(&state.services, major, search_system, true).await
}
#[tracing::instrument(level = "debug", skip(services))]
pub async fn prepare_java_with_services(
services: &LauncherServices,
major: u32,
@ -68,15 +72,18 @@ impl JavaManager {
}
}
#[tracing::instrument(level = "debug", skip(pool))]
pub async fn list_runtimes(pool: &DbPool) -> LauncherResult<Vec<JavaRuntime>> {
let rows = dao::java::list_all(pool).await?;
Ok(rows.into_iter().map(JavaRuntime::from_row).collect())
}
#[tracing::instrument(level = "debug", skip(pool))]
pub async fn rescan(pool: &DbPool) -> LauncherResult<()> {
register_located_runtimes(pool, java::locate::locate_java().await?).await
}
#[tracing::instrument(skip(services))]
pub async fn install_runtime(
services: &LauncherServices,
major: u32,
@ -84,6 +91,7 @@ impl JavaManager {
download_and_register(major, services).await
}
#[tracing::instrument(level = "debug", skip(services))]
pub async fn available_versions(
services: &LauncherServices,
vendor: &JavaVendor,
@ -92,28 +100,29 @@ impl JavaManager {
return Ok(Vec::new());
};
let results = futures_util::future::join_all(INSTALLABLE_MAJORS.iter().map(|&major| {
let provider = &provider;
async move {
(major, provider.latest_package_by_major(major, services).await)
}
}))
.await;
let packages = provider.list_packages(None, services).await?;
let mut available = Vec::new();
for (major, result) in results {
match result {
Ok(Some(package)) => available.push(AvailableJava { major, package }),
Ok(None) => {}
Err(err) => tracing::warn!(?vendor, major, "failed to query packages: {err}"),
}
let mut by_major =
BTreeMap::<u32, JavaPackage>::new();
for package in packages {
let Some(&major) = package.java_version.first() else {
continue;
};
by_major.entry(major).or_insert(package);
}
let mut available: Vec<AvailableJava> = by_major
.into_iter()
.map(|(major, package)| AvailableJava { major, package })
.collect();
available.sort_by_key(|b| std::cmp::Reverse(b.major));
Ok(available)
}
#[tracing::instrument(skip(services))]
pub async fn install_runtime_from(
services: &LauncherServices,
vendor: &JavaVendor,
@ -129,6 +138,7 @@ impl JavaManager {
register_checked_java(&services.db, &executable, Some(major)).await
}
#[tracing::instrument(skip(pool))]
pub async fn add_custom_runtime(
pool: &DbPool,
folder: PathBuf,
@ -137,11 +147,14 @@ impl JavaManager {
register_checked_java(pool, &executable, None).await
}
#[tracing::instrument(skip(pool))]
pub async fn remove_runtime(pool: &DbPool, absolute_path: &str) -> LauncherResult<()> {
dao::java::delete_by_path(pool, absolute_path).await?;
tracing::info!("removed Java runtime");
Ok(())
}
#[tracing::instrument(level = "debug", skip(pool))]
pub async fn java_for_profile(
pool: &DbPool,
java_path: Option<&str>,
@ -164,6 +177,7 @@ fn provider_for_vendor(vendor: &JavaVendor) -> Option<Box<dyn vendors::JavaRunti
.find(|provider| &provider.vendor() == vendor)
}
#[tracing::instrument(level = "debug", skip(services))]
async fn download_and_register(
major: u32,
services: &LauncherServices,
@ -171,10 +185,10 @@ async fn download_and_register(
for provider in vendors::runtime_providers() {
let vendor = provider.vendor();
let packages = provider.list_packages_by_major(major, services).await;
let packages = provider.list_packages(Some(major), services).await;
let package = match &packages {
Ok(packages) => match packages.first() {
Ok(packages) => match packages.iter().find(|p| p.java_version.first() == Some(&major)).or_else(|| packages.first()) {
Some(package) => package,
None => {
tracing::warn!(?vendor, major, "no packages found");
@ -202,6 +216,7 @@ async fn download_and_register(
Err(JavaError::PackageNotFound { major }.into())
}
#[tracing::instrument(level = "debug", skip(pool))]
async fn register_custom_java(
pool: &DbPool,
selection: PathBuf,
@ -211,6 +226,7 @@ async fn register_custom_java(
register_checked_java(pool, &executable, Some(expected_major)).await
}
#[tracing::instrument(level = "debug", skip(pool))]
async fn register_checked_java(
pool: &DbPool,
executable: &Path,
@ -232,11 +248,13 @@ async fn register_checked_java(
persist_runtime(pool, executable, &info).await
}
#[tracing::instrument(level = "debug", skip(pool))]
async fn get_latest_runtime(pool: &DbPool, major: u32) -> LauncherResult<Option<JavaRuntime>> {
let row = dao::java::get_latest_by_major(pool, major).await?;
Ok(row.map(JavaRuntime::from_row))
}
#[tracing::instrument(level = "debug", skip_all)]
async fn register_located_runtimes(
pool: &DbPool,
located: impl IntoIterator<Item = (PathBuf, JavaCheckInfo)>,
@ -247,6 +265,7 @@ async fn register_located_runtimes(
Ok(())
}
#[tracing::instrument(level = "debug", skip(pool))]
async fn persist_runtime(
pool: &DbPool,
executable: &Path,

View file

@ -1,9 +1,11 @@
use std::path::{Path, PathBuf};
use crate::constants::JAVA_BIN;
use crate::java::data::java_executable_relative_path;
use crate::java::{JavaError, JavaResult};
#[tracing::instrument(level = "debug", skip_all)]
pub fn resolve_java_executable(selection: impl AsRef<Path>) -> JavaResult<PathBuf> {
let selection = selection.as_ref();
@ -42,6 +44,8 @@ pub fn resolve_java_executable(selection: impl AsRef<Path>) -> JavaResult<PathBu
}
}
tracing::warn!(path = %selection.display(), "no Java executable found in selection");
Err(JavaError::InvalidJavaPath {
path: selection.display().to_string(),
})

View file

@ -10,6 +10,12 @@ use crate::state::LauncherServices;
pub struct AdoptiumRuntimeProvider;
// `/v3/info/available_releases`
#[derive(Debug, Deserialize)]
struct AdoptiumAvailableReleases {
available_releases: Vec<u32>,
}
#[derive(Debug, Deserialize)]
struct AdoptiumRelease {
version_data: AdoptiumVersionData,
@ -19,6 +25,7 @@ struct AdoptiumRelease {
#[derive(Debug, Deserialize)]
struct AdoptiumVersionData {
semver: String,
major: u32,
}
#[derive(Debug, Deserialize)]
@ -38,30 +45,51 @@ impl JavaRuntimeProvider for AdoptiumRuntimeProvider {
JavaVendor::Adoptium
}
async fn list_packages_by_major(
#[tracing::instrument(level = "debug", skip(self, services))]
async fn list_packages(
&self,
major: u32,
major: Option<u32>,
services: &LauncherServices,
) -> LauncherResult<Vec<JavaPackage>> {
let url = adoptium_url(major)?;
// fetched from `/v3/info/available_releases`.
let (low, high) = match major {
Some(major) => (major, major),
None => {
let info = services
.requester
.send_as::<AdoptiumAvailableReleases>(Request::new(
Method::GET,
Url::parse("https://api.adoptium.net/v3/info/available_releases")?,
))
.await?;
let low = info.available_releases.iter().copied().min().unwrap_or(8);
let high = info.available_releases.iter().copied().max().unwrap_or(low);
(low, high)
}
};
let url = adoptium_url(low, high)?;
let releases = services
.requester
.send_as::<Vec<AdoptiumRelease>>(Request::new(Method::GET, url))
.await?;
let mut packages = Vec::new();
for release in releases {
let java_version: Vec<u32> = release
let mut java_version: Vec<u32> = release
.version_data
.semver
.split(|c: char| !c.is_numeric())
.filter_map(|s| s.parse::<u32>().ok())
.collect();
if java_version.first() != Some(&release.version_data.major) {
java_version.insert(0, release.version_data.major);
}
for binary in release.binaries {
packages.push(JavaPackage {
archive: PackageArchive::Zip,
archive: PackageArchive::from_filename(&binary.package.name),
download_url: binary.package.link,
java_version: java_version.clone(),
name: binary.package.name,
@ -70,14 +98,35 @@ impl JavaRuntimeProvider for AdoptiumRuntimeProvider {
}
}
tracing::debug!(count = packages.len(), "listed Adoptium packages");
Ok(packages)
}
}
fn adoptium_url(major: u32) -> LauncherResult<Url> {
Ok(Url::parse(&format!(
"https://api.adoptium.net/v3/assets/feature_releases/{major}/ga?os={ADOPTIUM_OS}&architecture={ADOPTIUM_ARCH}&image_type=jre&jvm_impl=hotspot&project=jdk&heap_size=normal&vendor=eclipse&page=0&page_size=5"
))?)
// One request spanning every GA major via a version range (`[low,high]`),
// newest build per major, instead of one request per major.
fn adoptium_url(low: u32, high: u32) -> LauncherResult<Url> {
let range = format!("%5B{low}%2C{}%5D", high + 1);
let mut url = Url::parse(&format!(
"https://api.adoptium.net/v3/assets/version/{range}"
))?;
url.query_pairs_mut()
.append_pair("os", ADOPTIUM_OS)
.append_pair("architecture", ADOPTIUM_ARCH)
.append_pair("image_type", "jre")
.append_pair("jvm_impl", "hotspot")
.append_pair("project", "jdk")
.append_pair("heap_size", "normal")
.append_pair("vendor", "eclipse")
.append_pair("release_type", "ga")
.append_pair("sort_method", "DATE")
.append_pair("sort_order", "DESC")
.append_pair("page", "0")
.append_pair("page_size", "50");
Ok(url)
}
const ADOPTIUM_ARCH: &str = cfg_select! {
@ -89,7 +138,7 @@ const ADOPTIUM_ARCH: &str = cfg_select! {
const ADOPTIUM_OS: &str = cfg_select! {
target_os = "windows" => "windows",
target_os = "macos" => "mac",
target_os = "macos" => "mac",
target_os = "linux" => cfg_select! {
target_env = "musl" => "alpine-linux",
_ => "linux",

View file

@ -0,0 +1,105 @@
use reqwest::{Method, Request};
use serde_json::Value;
use url::Url;
use crate::LauncherResult;
use crate::java::PackageArchive;
use crate::java::data::JavaPackage;
use crate::java::vendors::{JavaRuntimeProvider, JavaVendor};
use crate::state::LauncherServices;
pub struct CorrettoRuntimeProvider;
// { os: { arch: { "jdk": { major: { ext: ... } } } } }
const INDEX_URL: &str =
"https://corretto.github.io/corretto-downloads/latest_links/indexmap_with_checksum.json";
#[async_trait::async_trait]
impl JavaRuntimeProvider for CorrettoRuntimeProvider {
fn vendor(&self) -> JavaVendor {
JavaVendor::Corretto
}
#[tracing::instrument(level = "debug", skip(self, services))]
async fn list_packages(
&self,
major: Option<u32>,
services: &LauncherServices,
) -> LauncherResult<Vec<JavaPackage>> {
let index = services
.requester
.send_as::<Value>(Request::new(Method::GET, Url::parse(INDEX_URL)?))
.await?;
// Corretto ships JDK only
let Some(by_major) = index
.get(CORRETTO_OS)
.and_then(|by_arch| by_arch.get(CORRETTO_ARCH))
.and_then(|by_type| by_type.get("jdk"))
.and_then(Value::as_object)
else {
return Ok(Vec::new());
};
let mut packages = Vec::new();
for (major_str, by_ext) in by_major {
let Ok(this_major) = major_str.parse::<u32>() else {
continue;
};
if let Some(filter) = major
&& filter != this_major
{
continue;
}
let has_ext = by_ext
.as_object()
.is_some_and(|exts| exts.contains_key(CORRETTO_EXT.0));
if !has_ext {
continue;
}
let download_url = latest_url(this_major, CORRETTO_EXT.0);
let name = format!(
"amazon-corretto-{this_major}-{CORRETTO_ARCH}-{CORRETTO_OS}-jdk.{}",
CORRETTO_EXT.0
);
packages.push(JavaPackage {
archive: CORRETTO_EXT.1,
download_url,
java_version: vec![this_major],
name,
vendor: JavaVendor::Corretto,
});
}
packages.sort_by_key(|p| std::cmp::Reverse(p.java_version.first().copied().unwrap_or(0)));
tracing::debug!(count = packages.len(), "listed Corretto packages");
Ok(packages)
}
}
fn latest_url(major: u32, ext: &str) -> String {
format!(
"https://corretto.aws/downloads/latest/amazon-corretto-{major}-{CORRETTO_ARCH}-{CORRETTO_OS}-jdk.{ext}"
)
}
const CORRETTO_ARCH: &str = cfg_select! {
target_arch = "x86" => "x86",
target_arch = "x86_64" => "x64",
target_arch = "arm" => "arm",
target_arch = "aarch64" => "aarch64",
};
const CORRETTO_OS: &str = cfg_select! {
target_os = "windows" => "windows",
target_os = "macos" => "macos",
target_os = "linux" => "linux",
};
const CORRETTO_EXT: (&str, PackageArchive) = cfg_select! {
target_os = "windows" => ("zip", PackageArchive::Zip),
_ => ("tar.gz", PackageArchive::TarGz),
};

View file

@ -0,0 +1,126 @@
use reqwest::{Method, Request};
use serde::Deserialize;
use url::Url;
use crate::LauncherResult;
use crate::java::PackageArchive;
use crate::java::data::JavaPackage;
use crate::java::vendors::{JavaRuntimeProvider, JavaVendor};
use crate::state::LauncherServices;
pub struct LibericaRuntimeProvider;
#[derive(Debug, Deserialize)]
struct LibericaRelease {
#[serde(rename = "downloadUrl")]
download_url: String,
filename: String,
#[serde(rename = "featureVersion")]
feature_version: u32,
#[serde(rename = "updateVersion", default)]
update_version: u32,
#[serde(rename = "buildVersion", default)]
build_version: u32,
version: String,
}
#[async_trait::async_trait]
impl JavaRuntimeProvider for LibericaRuntimeProvider {
fn vendor(&self) -> JavaVendor {
JavaVendor::Liberica
}
#[tracing::instrument(level = "debug", skip(self, services))]
async fn list_packages(
&self,
major: Option<u32>,
services: &LauncherServices,
) -> LauncherResult<Vec<JavaPackage>> {
let url = liberica_url(major)?;
let releases = services
.requester
.send_as::<Vec<LibericaRelease>>(Request::new(Method::GET, url))
.await?;
let mut releases = releases;
releases.sort_by_key(|r| {
std::cmp::Reverse((r.feature_version, r.update_version, r.build_version))
});
let packages: Vec<JavaPackage> = releases
.into_iter()
.map(|r| {
let mut java_version: Vec<u32> = r
.version
.split(|c: char| !c.is_numeric())
.filter_map(|s| s.parse::<u32>().ok())
.collect();
if java_version.first() != Some(&r.feature_version) {
java_version.insert(0, r.feature_version);
}
JavaPackage {
archive: PackageArchive::from_filename(&r.filename),
download_url: r.download_url,
java_version,
name: r.filename,
vendor: JavaVendor::Liberica,
}
})
.collect();
tracing::debug!(count = packages.len(), "listed Liberica packages");
Ok(packages)
}
}
fn liberica_url(major: Option<u32>) -> LauncherResult<Url> {
let mut url = Url::parse("https://api.bell-sw.com/v1/liberica/releases")?;
{
let mut q = url.query_pairs_mut();
q.append_pair("os", LIBERICA_OS)
.append_pair("arch", LIBERICA_ARCH)
.append_pair("bundle-type", "jre")
.append_pair("bitness", LIBERICA_BITNESS)
.append_pair("package-type", LIBERICA_PACKAGE)
.append_pair("installation-type", "archive")
.append_pair(
"fields",
"downloadUrl,filename,featureVersion,updateVersion,buildVersion,version",
)
.append_pair("output", "json");
if let Some(major) = major {
q.append_pair("version-feature", &major.to_string());
}
}
Ok(url)
}
const LIBERICA_ARCH: &str = cfg_select! {
target_arch = "x86" => "x86",
target_arch = "x86_64" => "x86",
target_arch = "arm" => "arm",
target_arch = "aarch64" => "arm",
};
const LIBERICA_BITNESS: &str = cfg_select! {
target_arch = "x86" => "32",
target_arch = "x86_64" => "64",
target_arch = "arm" => "32",
target_arch = "aarch64" => "64",
};
const LIBERICA_OS: &str = cfg_select! {
target_os = "windows" => "windows",
target_os = "macos" => "macos",
target_os = "linux" => cfg_select! {
target_env = "musl" => "linux-musl",
_ => "linux"
},
};
const LIBERICA_PACKAGE: &str = cfg_select! {
target_os = "windows" => "zip",
_ => "tar.gz",
};

View file

@ -1,13 +1,18 @@
use std::{path::PathBuf, str::FromStr};
use crate::java::JavaPackage;
use crate::state::LauncherServices;
use crate::LauncherResult;
mod adoptium;
mod corretto;
mod liberica;
mod zulu;
pub use adoptium::AdoptiumRuntimeProvider;
pub use corretto::CorrettoRuntimeProvider;
pub use liberica::LibericaRuntimeProvider;
use serde::{Deserialize, Deserializer, Serialize};
pub use zulu::ZuluRuntimeProvider;
@ -15,6 +20,8 @@ pub fn runtime_providers() -> Vec<Box<dyn JavaRuntimeProvider>> {
vec![
Box::new(ZuluRuntimeProvider),
Box::new(AdoptiumRuntimeProvider),
Box::new(CorrettoRuntimeProvider),
Box::new(LibericaRuntimeProvider),
]
}
@ -22,21 +29,23 @@ pub fn runtime_providers() -> Vec<Box<dyn JavaRuntimeProvider>> {
pub trait JavaRuntimeProvider: Send + Sync {
fn vendor(&self) -> JavaVendor;
async fn list_packages_by_major(
async fn list_packages(
&self,
major: u32,
major: Option<u32>,
services: &LauncherServices,
) -> LauncherResult<Vec<JavaPackage>>;
#[tracing::instrument(level = "debug", skip(self, services))]
async fn latest_package_by_major(
&self,
major: u32,
services: &LauncherServices
) -> LauncherResult<Option<JavaPackage>> {
let packages = self.list_packages_by_major(major, services).await?;
Ok(packages.first().cloned())
let packages = self.list_packages(Some(major), services).await?;
Ok(packages.into_iter().find(|p| p.java_version.first() == Some(&major)))
}
#[tracing::instrument(level = "debug", skip_all)]
async fn install_package(
&self,
package: &JavaPackage,
@ -53,6 +62,7 @@ pub enum JavaVendor {
Oracle,
Microsoft,
Adoptium,
Liberica,
OpenJDK,
Other(String),
}
@ -62,6 +72,7 @@ impl std::fmt::Display for JavaVendor {
match self {
JavaVendor::Zulu => f.write_str("Zulu"),
JavaVendor::Adoptium => f.write_str("Temurin"),
JavaVendor::Liberica => f.write_str("Liberica"),
JavaVendor::Corretto => f.write_str("Corretto"),
JavaVendor::Microsoft => f.write_str("Microsoft"),
JavaVendor::Oracle => f.write_str("Oracle"),
@ -77,8 +88,10 @@ impl FromStr for JavaVendor {
fn from_str(s: &str) -> Result<Self, Self::Err> {
let v = s.to_lowercase();
Ok(if v.contains("adoptium") || v.contains("adoptopenjdk") {
Ok(if v.contains("adoptium") || v.contains("adoptopenjdk") || v.contains("temurin") {
Self::Adoptium
} else if v.contains("liberica") || v.contains("bellsoft") {
Self::Liberica
} else if v.contains("microsoft") {
Self::Microsoft
} else if v.contains("openjdk") {

View file

@ -23,10 +23,13 @@ impl JavaRuntimeProvider for ZuluRuntimeProvider {
JavaVendor::Zulu
}
async fn list_packages_by_major(&self, major: u32, services: &LauncherServices) -> LauncherResult<Vec<JavaPackage>> {
#[tracing::instrument(level = "debug", skip(self, services))]
async fn list_packages(&self, major: Option<u32>, services: &LauncherServices) -> LauncherResult<Vec<JavaPackage>> {
let url = zulu_url(major)?;
let packages = services.requester.send_as::<Vec<ZuluPackage>>(Request::new(Method::GET, url)).await?;
let packages = packages.into_iter().map(map_zulu_package).collect();
let packages: Vec<JavaPackage> = packages.into_iter().map(map_zulu_package).collect();
tracing::debug!(count = packages.len(), "listed Zulu packages");
Ok(packages)
}
@ -42,8 +45,26 @@ fn map_zulu_package(pkg: ZuluPackage) -> JavaPackage {
}
}
fn zulu_url(major: u32) -> LauncherResult<Url> {
Ok(Url::parse(&format!("https://api.azul.com/metadata/v1/zulu/packages/?java_version={major}&os={ZULU_OS}&arch={ZULU_ARCH}&archive_type=zip&java_package_type=jre&javafx_bundled=false&release_status=ga&availability_types=CA&certifications=tck&page=1&page_size=5"))?)
fn zulu_url(major: Option<u32>) -> LauncherResult<Url> {
let mut url = Url::parse("https://api.azul.com/metadata/v1/zulu/packages/")?;
{
let mut q = url.query_pairs_mut();
q.append_pair("os", ZULU_OS)
.append_pair("arch", ZULU_ARCH)
.append_pair("archive_type", "zip")
.append_pair("java_package_type", "jre")
.append_pair("javafx_bundled", "false")
.append_pair("release_status", "ga")
.append_pair("availability_types", "CA")
.append_pair("certifications", "tck")
.append_pair("latest", "true")
.append_pair("page", "1")
.append_pair("page_size", "100");
if let Some(major) = major {
q.append_pair("java_version", &major.to_string());
}
}
Ok(url)
}
const ZULU_ARCH: &str = cfg_select! {

View file

@ -1,3 +1,5 @@
#![recursion_limit = "256"]
#[cfg(debug_assertions)]
pub mod dev;

View file

@ -19,6 +19,7 @@ pub fn init() -> LauncherResult<()> {
pub fn init_filtered(filter: impl FnOnce() -> EnvFilter) -> LauncherResult<()> {
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| filter());
let stdout_layer = tracing_subscriber::fmt::layer().with_writer(io::stdout);
#[cfg(debug_assertions)]

View file

@ -65,6 +65,7 @@ fn collect_dir(
}
}
#[tracing::instrument(level = "debug", skip(cluster), fields(cluster_id = cluster.id))]
pub fn list_cluster_logs(cluster: &Cluster) -> LauncherResult<Vec<LogFileInfo>> {
let mut out = Vec::new();
@ -148,12 +149,14 @@ pub(super) async fn read_file_string(path: &Path) -> LauncherResult<String> {
}
}
#[tracing::instrument(level = "debug", skip(opts))]
pub async fn read_log_at(path: &Path, opts: &ReadOptions) -> LauncherResult<Vec<LogLine>> {
let path = ensure_allowed(path)?;
let content = read_file_string(&path).await?;
Ok(lines_from(&content, opts))
}
#[tracing::instrument(level = "debug")]
pub async fn delete_log_at(path: &Path) -> LauncherResult<()> {
let path = ensure_allowed(path)?;

View file

@ -26,6 +26,7 @@ struct MclogsResponse {
error: Option<String>,
}
#[tracing::instrument(skip(services))]
pub async fn upload_log_at(
services: &LauncherServices,
path: &Path,
@ -62,9 +63,13 @@ pub async fn upload_log_at(
let parsed: MclogsResponse = serde_json::from_slice(&bytes)?;
if !parsed.success {
return Err(LogsError::Upload(parsed.error.unwrap_or_else(|| "unknown error".into())).into());
let reason = parsed.error.unwrap_or_else(|| "unknown error".into());
tracing::warn!(reason = %reason, "mclogs upload failed");
return Err(LogsError::Upload(reason).into());
}
tracing::info!(url = parsed.url.as_deref().unwrap_or(""), "uploaded log to mclo.gs");
Ok(MclogsUploadResponse {
id: parsed.id.unwrap_or_default(),
url: parsed.url.unwrap_or_default(),

View file

@ -6,7 +6,6 @@ use interfrost::api::modded::Manifest as ModdedManifest;
use reqwest::Method;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use tracing::instrument;
use crate::api_config::metadata_api_url;
use crate::metadata::MetadataError;
@ -43,7 +42,7 @@ impl MetadataStore {
Self::default()
}
#[instrument(skip(self, services))]
#[tracing::instrument(level = "debug", skip(self, services))]
pub async fn get_vanilla_or_fetch(
&mut self,
services: &LauncherServices,
@ -55,7 +54,7 @@ impl MetadataStore {
self.get_vanilla()
}
#[instrument(skip(self, services))]
#[tracing::instrument(level = "debug", skip(self, services))]
pub async fn get_modded_or_fetch(
&mut self,
services: &LauncherServices,
@ -95,7 +94,7 @@ impl MetadataStore {
.ok_or_else(|| MetadataError::FetchError.into())
}
#[instrument(skip_all)]
#[tracing::instrument(skip_all)]
pub async fn initialize(&mut self, services: &LauncherServices) -> LauncherResult<()> {
let path = paths::caches_dir()?.join("metadata.json");
let mut save_file = false;
@ -130,7 +129,7 @@ impl MetadataStore {
Ok(())
}
#[instrument(skip_all)]
#[tracing::instrument(level = "debug", skip_all)]
pub async fn refetch_errored(&mut self, services: &LauncherServices) -> u8 {
let mut changed: u8 = 0;
@ -171,7 +170,7 @@ impl MetadataStore {
changed
}
#[instrument(skip_all)]
#[tracing::instrument(level = "debug", skip_all)]
pub async fn fetch_all(&mut self, services: &LauncherServices) {
let (minecraft, forge, neo, fabric, quilt, legacyfabric) = tokio::join!(
fetch_vanilla_manifest(services),
@ -190,6 +189,7 @@ impl MetadataStore {
self.inner.legacyfabric = legacyfabric.ok();
}
#[tracing::instrument(level = "debug", skip(self, services))]
pub async fn get_loaders_for_version(
&mut self,
services: &LauncherServices,
@ -233,6 +233,7 @@ impl MetadataStore {
}
}
#[tracing::instrument(level = "debug", skip(services))]
async fn fetch_vanilla_manifest(services: &LauncherServices) -> LauncherResult<VanillaManifest> {
match fetch_manifest::<VanillaManifest>(services, GameLoader::Vanilla).await {
Ok(manifest) => Ok(manifest),
@ -255,6 +256,7 @@ async fn fetch_vanilla_manifest(services: &LauncherServices) -> LauncherResult<V
}
}
#[tracing::instrument(level = "debug", skip(services))]
async fn fetch_modded_manifest(
services: &LauncherServices,
loader: GameLoader,
@ -262,6 +264,7 @@ async fn fetch_modded_manifest(
fetch_manifest(services, loader).await
}
#[tracing::instrument(level = "debug", skip(services))]
async fn fetch_manifest<T: DeserializeOwned>(
services: &LauncherServices,
loader: GameLoader,

View file

@ -1,7 +1,9 @@
use std::path::{Path, PathBuf};
use crate::LauncherResult;
#[tracing::instrument(level = "debug", skip(exclude_top))]
pub async fn copy_tree(src: &Path, dst: &Path, exclude_top: &[&str]) -> LauncherResult<()> {
let mut stack: Vec<(PathBuf, PathBuf, bool)> =
vec![(src.to_path_buf(), dst.to_path_buf(), true)];

View file

@ -65,6 +65,7 @@ pub enum ImportTarget {
Dedicated { new_cluster_id: i64 },
}
#[tracing::instrument]
pub async fn detect() -> LauncherResult<Option<MigrationDetection>> {
for source in MigrationSource::ALL.iter().copied() {
let detection = match source {
@ -80,6 +81,7 @@ pub async fn detect() -> LauncherResult<Option<MigrationDetection>> {
Ok(None)
}
#[tracing::instrument]
pub async fn import_game_dir(
source: MigrationSource,
folder_name: &str,

View file

@ -2,7 +2,6 @@ use std::path::{Path, PathBuf};
use sqlx::Row;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use tracing::instrument;
use crate::LauncherResult;
use crate::packages::domain::GameLoader;
@ -33,7 +32,7 @@ pub fn category_from_bundle_name(name: &str) -> Option<String> {
(!inner.is_empty()).then(|| inner.to_string())
}
#[instrument]
#[tracing::instrument]
pub async fn detect() -> LauncherResult<Option<MigrationDetection>> {
let Some(root) = old_root() else {
return Ok(None);
@ -60,6 +59,7 @@ pub async fn detect() -> LauncherResult<Option<MigrationDetection>> {
}
}
#[tracing::instrument(level = "debug")]
async fn detect_inner(root: &Path, db_path: &Path) -> LauncherResult<MigrationDetection> {
let opts = SqliteConnectOptions::new()
.filename(db_path)
@ -113,6 +113,7 @@ async fn detect_inner(root: &Path, db_path: &Path) -> LauncherResult<MigrationDe
})
}
#[tracing::instrument(level = "debug", skip(pool))]
async fn fetch_categories(pool: &sqlx::SqlitePool, cluster_id: i64) -> LauncherResult<Vec<String>> {
let rows = sqlx::query(
"SELECT DISTINCT bundle_name FROM cluster_packages \
@ -137,7 +138,7 @@ async fn fetch_categories(pool: &sqlx::SqlitePool, cluster_id: i64) -> LauncherR
Ok(categories)
}
#[instrument(skip(target))]
#[tracing::instrument(skip(target))]
pub async fn import_game_dir(folder_name: &str, target: ImportTarget) -> LauncherResult<()> {
let Some(root) = old_root() else {
return Ok(());

View file

@ -1,4 +1,5 @@
use base64::Engine;
use chrono::{DateTime, TimeDelta, Utc};
use reqwest::Method;
use serde::{Deserialize, Serialize};
@ -126,6 +127,7 @@ fn auth_headers(access_token: &str) -> [(&'static str, String); 1] {
[("Authorization", format!("Bearer {access_token}"))]
}
#[tracing::instrument(level = "debug", skip(client))]
pub async fn fetch_player_profile(
client: &RequestClient,
uuid: &str,
@ -213,6 +215,7 @@ pub async fn fetch_player_profile(
})
}
#[tracing::instrument(level = "debug", skip(client, access_token))]
pub async fn fetch_logged_in_profile(
client: &RequestClient,
access_token: &str,
@ -241,30 +244,75 @@ pub async fn fetch_logged_in_profile(
Ok(profile)
}
/// How long a cached profile is served without revalidating.
///
/// The alternative is a Mojang round trip before the first avatar can paint on
/// every cold start. Skins change rarely, so this trades a bounded amount of
/// staleness for startup that doesn't wait on the network.
const PROFILE_CACHE_TTL_HOURS: i64 = 6;
#[derive(Debug, Serialize, Deserialize)]
struct CachedProfile {
fetched_at: DateTime<Utc>,
view: PlayerProfileView,
}
impl CachedProfile {
fn is_fresh(&self) -> bool {
Utc::now() - self.fetched_at < TimeDelta::hours(PROFILE_CACHE_TTL_HOURS)
}
}
async fn fetch_profile_view(
client: &RequestClient,
uuid: &str,
access_token: Option<&str>,
) -> LauncherResult<PlayerProfileView> {
if let Some(token) = access_token.filter(|t| !t.is_empty()) {
let full = fetch_logged_in_profile(client, token).await?;
return Ok(PlayerProfileView::from(full));
}
let summary = fetch_player_profile(client, uuid).await?;
Ok(PlayerProfileView::from(summary))
}
/// Resolves a player's public profile preferring on-disk cache
#[tracing::instrument(level = "debug", skip(client, access_token))]
pub async fn fetch_player_profile_view(
client: &RequestClient,
uuid: &str,
access_token: Option<&str>,
) -> LauncherResult<PlayerProfileView> {
let fetched = async {
if let Some(token) = access_token.filter(|t| !t.is_empty()) {
let full = fetch_logged_in_profile(client, token).await?;
return Ok(PlayerProfileView::from(full));
}
let summary = fetch_player_profile(client, uuid).await?;
Ok(PlayerProfileView::from(summary))
}
.await;
let authenticated = access_token.is_some_and(|t| !t.is_empty());
match fetched {
if !authenticated && let Some(cached) = read_cached_profile(uuid).await {
if cached.is_fresh() {
return Ok(cached.view);
}
return match fetch_profile_view(client, uuid, access_token).await {
Ok(view) => {
write_cached_profile(uuid, &view).await;
Ok(view)
}
Err(err) => {
tracing::warn!("serving stale cached profile for {uuid}: {err}");
Ok(cached.view)
}
};
}
match fetch_profile_view(client, uuid, access_token).await {
Ok(view) => {
write_cached_profile(uuid, &view).await;
if !authenticated {
write_cached_profile(uuid, &view).await;
}
Ok(view)
}
Err(err) => match read_cached_profile(uuid).await {
Some(cached) => {
tracing::warn!("using cached player profile for {uuid}: {err}");
Ok(cached)
Ok(cached.view)
}
None => Err(err),
},
@ -280,7 +328,11 @@ async fn write_cached_profile(uuid: &str, view: &PlayerProfileView) {
let Some(path) = profile_cache_path(uuid) else {
return;
};
let Ok(bytes) = serde_json::to_vec(view) else {
let entry = CachedProfile {
fetched_at: Utc::now(),
view: view.clone(),
};
let Ok(bytes) = serde_json::to_vec(&entry) else {
return;
};
if let Some(parent) = path.parent() {
@ -291,8 +343,89 @@ async fn write_cached_profile(uuid: &str, view: &PlayerProfileView) {
}
}
async fn read_cached_profile(uuid: &str) -> Option<PlayerProfileView> {
async fn read_cached_profile(uuid: &str) -> Option<CachedProfile> {
let path = profile_cache_path(uuid)?;
let bytes = polyio::read(&path).await.ok()?;
serde_json::from_slice(&bytes).ok()
if let Ok(entry) = serde_json::from_slice::<CachedProfile>(&bytes) {
return Some(entry);
}
let view = serde_json::from_slice::<PlayerProfileView>(&bytes).ok()?;
Some(CachedProfile {
fetched_at: DateTime::UNIX_EPOCH,
view,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_launcher_dir() {
let dir = std::env::temp_dir().join(format!("oneclient-profile-test-{}", uuid::Uuid::new_v4()));
crate::paths::set_launcher_dir(dir);
}
fn sample_view(username: &str) -> PlayerProfileView {
PlayerProfileView {
uuid: "069a79f4-44e9-4726-a5be-fca90e38aaf5".into(),
username: username.into(),
is_slim: true,
skin_url: Some("https://textures.minecraft.net/texture/abc".into()),
cape_url: None,
skins: Vec::new(),
capes: Vec::new(),
}
}
#[tokio::test]
async fn fresh_cache_entry_is_served_without_network() {
temp_launcher_dir();
let uuid = uuid::Uuid::new_v4().to_string();
let view = sample_view("Cached");
write_cached_profile(&uuid, &view).await;
let client = RequestClient::new().unwrap();
let resolved = fetch_player_profile_view(&client, &uuid, None)
.await
.expect("fresh cache entry should resolve offline");
assert_eq!(resolved, view);
}
#[tokio::test]
async fn entry_past_ttl_is_not_fresh() {
let stale = CachedProfile {
fetched_at: Utc::now() - TimeDelta::hours(PROFILE_CACHE_TTL_HOURS + 1),
view: sample_view("Stale"),
};
assert!(!stale.is_fresh());
let recent = CachedProfile {
fetched_at: Utc::now() - TimeDelta::hours(PROFILE_CACHE_TTL_HOURS - 1),
view: sample_view("Recent"),
};
assert!(recent.is_fresh());
}
#[tokio::test]
async fn legacy_cache_entry_loads_and_revalidates() {
temp_launcher_dir();
let uuid = uuid::Uuid::new_v4().to_string();
let view = sample_view("Legacy");
let path = profile_cache_path(&uuid).unwrap();
polyio::create_dir_all(path.parent().unwrap()).await.unwrap();
polyio::write(&path, &serde_json::to_vec(&view).unwrap())
.await
.unwrap();
let cached = read_cached_profile(&uuid)
.await
.expect("legacy entry should still parse");
assert_eq!(cached.view, view);
assert!(!cached.is_fresh(), "legacy entry should be due for revalidation");
}
}

View file

@ -99,6 +99,17 @@ pub enum Notification {
InvalidateJava,
SyncComplete,
/// Live status of Microsoft Auth
/// `None` clears the status
MicrosoftLoginStatus(Option<MicrosoftLoginStatus>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MicrosoftLoginStatus {
pub label: String,
pub current: u64,
pub total: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]

View file

@ -1,6 +1,6 @@
pub use data::{
GroupedProgressEvent, LaunchStage, Notification, NotificationLevel, PromptKind, TaskPhase,
UserChoice,
GroupedProgressEvent, LaunchStage, MicrosoftLoginStatus, Notification, NotificationLevel,
PromptKind, TaskPhase, UserChoice,
};
pub use grouped::{GroupedProgressChild, GroupedProgressSession};
pub use service::NotificationService;
@ -14,5 +14,5 @@ pub enum NotificationError {
#[error("Notification service is down")]
ServiceDown,
#[error("Notification prompt timed out")]
PromptTimedOut
}
PromptTimedOut,
}

Some files were not shown because too many files have changed in this diff Show more