Merge branch 'main' of https://github.com/Polyfrost/OneLauncher
This commit is contained in:
commit
e5cd435ecf
123 changed files with 4319 additions and 1667 deletions
17
Cargo.lock
generated
17
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}
|
||||
|
|
|
|||
257
packages/oneclient_app/src/components/account_switcher.rs
Normal file
257
packages/oneclient_app/src/components/account_switcher.rs
Normal 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(¤t_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")
|
||||
}
|
||||
}
|
||||
267
packages/oneclient_app/src/components/cluster_update_popup.rs
Normal file
267
packages/oneclient_app/src/components/cluster_update_popup.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
})
|
||||
|
|
|
|||
334
packages/oneclient_app/src/components/microsoft_login.rs
Normal file
334
packages/oneclient_app/src/components/microsoft_login.rs
Normal 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()
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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(¤t_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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {}",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
|
||||
#![recursion_limit = "256"]
|
||||
use oneclient_core::clusters::{ClusterManager, CreateClusterOptions};
|
||||
use oneclient_core::dev;
|
||||
use oneclient_core::packages::domain::GameLoader;
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
#![recursion_limit = "256"]
|
||||
|
||||
use oneclient_core::LauncherResult;
|
||||
use oneclient_core::auth;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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, .. },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 || {
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
105
packages/oneclient_core/src/java/vendors/corretto.rs
vendored
Normal file
105
packages/oneclient_core/src/java/vendors/corretto.rs
vendored
Normal 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),
|
||||
};
|
||||
126
packages/oneclient_core/src/java/vendors/liberica.rs
vendored
Normal file
126
packages/oneclient_core/src/java/vendors/liberica.rs
vendored
Normal 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",
|
||||
};
|
||||
23
packages/oneclient_core/src/java/vendors/mod.rs
vendored
23
packages/oneclient_core/src/java/vendors/mod.rs
vendored
|
|
@ -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") {
|
||||
|
|
|
|||
29
packages/oneclient_core/src/java/vendors/zulu.rs
vendored
29
packages/oneclient_core/src/java/vendors/zulu.rs
vendored
|
|
@ -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! {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
#![recursion_limit = "256"]
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
pub mod dev;
|
||||
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(());
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue