refactor: split core into modular crates

This commit is contained in:
LynithDev 2026-07-28 12:10:37 +02:00
parent 1962e9c0c4
commit e689a43a87
288 changed files with 9533 additions and 7486 deletions

230
Cargo.lock generated
View file

@ -431,6 +431,12 @@ dependencies = [
"x11rb",
]
[[package]]
name = "arc-swap"
version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457"
[[package]]
name = "arg_enum_proc_macro"
version = "0.3.4"
@ -2121,6 +2127,7 @@ dependencies = [
"freya-markdown",
"freya-performance-plugin",
"freya-query",
"freya-radio",
"freya-router",
"freya-sdk",
"freya-winit",
@ -2276,6 +2283,14 @@ dependencies = [
"futures-util",
]
[[package]]
name = "freya-radio"
version = "0.4.0-rc.23"
source = "git+https://github.com/marc2332/freya.git?branch=fix%2Fblur#be57ee3184e047ac932057cd063f7858741d41d9"
dependencies = [
"freya-core",
]
[[package]]
name = "freya-router"
version = "0.4.0-rc.23"
@ -4496,9 +4511,18 @@ dependencies = [
"freya-skia-safe",
"image",
"mimalloc",
"oneclient_auth",
"oneclient_cluster",
"oneclient_common",
"oneclient_content",
"oneclient_core",
"oneclient_db",
"oneclient_events",
"oneclient_java",
"oneclient_macro",
"oneclient_mc",
"oneclient_net",
"oneclient_polyplus",
"open",
"polyio",
"regex",
@ -4512,6 +4536,89 @@ dependencies = [
"winresource",
]
[[package]]
name = "oneclient_auth"
version = "2.1.0"
dependencies = [
"base64",
"chrono",
"md-5 0.10.6",
"oneclient_common",
"oneclient_events",
"oneclient_net",
"polyio",
"rand 0.10.1",
"reqwest 0.13.3",
"serde",
"serde_json",
"sha2 0.10.9",
"thiserror 2.0.18",
"tokio",
"tracing",
"url",
"uuid 1.23.1",
]
[[package]]
name = "oneclient_cluster"
version = "2.1.0"
dependencies = [
"bytes 1.11.1",
"chrono",
"image",
"oneclient_common",
"oneclient_db",
"oneclient_net",
"polyio",
"reqwest 0.13.3",
"serde",
"serde_json",
"serde_repr",
"sqlx",
"strum",
"thiserror 2.0.18",
"tokio",
"tracing",
"trash",
]
[[package]]
name = "oneclient_common"
version = "2.1.0"
dependencies = [
"directories",
"interfrost",
"serde",
"strum",
"thiserror 2.0.18",
]
[[package]]
name = "oneclient_content"
version = "2.1.0"
dependencies = [
"async-trait",
"async_zip",
"chrono",
"futures-lite",
"futures-util",
"htmd",
"oneclient_common",
"oneclient_db",
"oneclient_events",
"oneclient_net",
"polyio",
"reqwest 0.13.3",
"serde",
"serde_json",
"sqlx",
"thiserror 2.0.18",
"tokio",
"tracing",
"url",
"uuid 1.23.1",
]
[[package]]
name = "oneclient_core"
version = "2.1.0"
@ -4536,7 +4643,17 @@ dependencies = [
"indicatif",
"interfrost",
"md-5 0.10.6",
"oneclient_auth",
"oneclient_cluster",
"oneclient_common",
"oneclient_content",
"oneclient_db",
"oneclient_discord",
"oneclient_events",
"oneclient_java",
"oneclient_mc",
"oneclient_net",
"oneclient_polyplus",
"parking_lot",
"polyio",
"rand 0.10.1",
@ -4580,6 +4697,50 @@ dependencies = [
"tracing",
]
[[package]]
name = "oneclient_discord"
version = "2.1.0"
dependencies = [
"chrono",
"discord-rich-presence",
"oneclient_common",
"tokio",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "oneclient_events"
version = "2.1.0"
dependencies = [
"thiserror 2.0.18",
"tokio",
"tracing",
"uuid 1.23.1",
]
[[package]]
name = "oneclient_java"
version = "2.1.0"
dependencies = [
"astral-tokio-tar",
"async-tempfile",
"async-trait",
"async_zip",
"futures-lite",
"oneclient_common",
"oneclient_events",
"oneclient_net",
"polyio",
"reqwest 0.13.3",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tracing",
"url",
]
[[package]]
name = "oneclient_macro"
version = "2.1.0"
@ -4589,6 +4750,73 @@ dependencies = [
"syn",
]
[[package]]
name = "oneclient_mc"
version = "2.1.0"
dependencies = [
"base64",
"chrono",
"futures-util",
"interfrost",
"oneclient_common",
"oneclient_events",
"oneclient_net",
"polyio",
"reqwest 0.13.3",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tracing",
"url",
"uuid 1.23.1",
]
[[package]]
name = "oneclient_net"
version = "2.1.0"
dependencies = [
"arc-swap",
"async-trait",
"bytes 1.11.1",
"futures-lite",
"futures-util",
"oneclient_common",
"oneclient_events",
"polyio",
"reqwest 0.13.3",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tracing",
"url",
"uuid 1.23.1",
]
[[package]]
name = "oneclient_polyplus"
version = "2.1.0"
dependencies = [
"async-tungstenite",
"bytes 1.11.1",
"futures-util",
"oneclient_auth",
"oneclient_common",
"polyio",
"rand 0.10.1",
"reqwest 0.13.3",
"reqwest-websocket",
"serde",
"serde_json",
"sha1 0.10.6",
"thiserror 2.0.18",
"tokio",
"tracing",
"url",
"uuid 1.23.1",
]
[[package]]
name = "open"
version = "5.4.0"
@ -4853,6 +5081,7 @@ dependencies = [
"async-tempfile",
"async_zip",
"bytes 1.11.1",
"digest 0.10.7",
"dunce",
"futures-lite",
"futures-util",
@ -4860,6 +5089,7 @@ dependencies = [
"sanitize-filename",
"serde",
"serde_json",
"sha1 0.10.6",
"thiserror 2.0.18",
"tokio",
"tokio-util",

View file

@ -21,12 +21,21 @@ version = "2.1.0"
[workspace.dependencies]
# core
polyio = { path = "./packages/polyio" }
oneclient_auth = { path = "./packages/oneclient_auth" }
oneclient_cluster = { path = "./packages/oneclient_cluster" }
oneclient_common = { path = "./packages/oneclient_common" }
oneclient_content = { path = "./packages/oneclient_content" }
oneclient_discord = { path = "./packages/oneclient_discord" }
oneclient_events = { path = "./packages/oneclient_events" }
oneclient_java = { path = "./packages/oneclient_java" }
oneclient_mc = { path = "./packages/oneclient_mc" }
oneclient_net = { path = "./packages/oneclient_net" }
oneclient_polyplus = { path = "./packages/oneclient_polyplus" }
oneclient_core = { path = "./packages/oneclient_core" }
oneclient_db = { path = "./packages/oneclient_db" }
oneclient_macro = { path = "./packages/oneclient_macro" }
# core (extra)
gamemode_rs = {path = "./packages/gamemode_rs"}
interfrost = {version = "=1.3.2"}
discord-rich-presence = {version = "=1.1.0"}
@ -107,6 +116,7 @@ uuid = {version = "=1.23.1", features = ["serde", "v4"]}
sanitize-filename = { version = "=0.6.0" }
regex = { version = "=1.12.3" }
parking_lot = { version = "=0.12.5" }
arc-swap = { version = "=1.7.1" }
dunce = { version = "=1.0.5" }
junction = { version = "=2.0.0" }
bytes = { version = "=1.11.1" }
@ -128,7 +138,7 @@ trash = {version = "=5.2.6"}
# required for the SKSL player renderer
freya-skia-safe = "=0.98.0"
freya = { git = "https://github.com/marc2332/freya.git", branch = "fix/blur", features = ["engine", "winit", "router", "remote-asset", "sdk", "markdown", "query", "titlebar"] }
freya = { git = "https://github.com/marc2332/freya.git", branch = "fix/blur", features = ["engine", "winit", "router", "remote-asset", "sdk", "markdown", "query", "radio", "titlebar"] }
[patch.crates-io]
freya = { git = "https://github.com/marc2332/freya.git", branch = "fix/blur" }

View file

@ -14,6 +14,15 @@ authors = { workspace = true }
default = []
[dependencies]
oneclient_auth.workspace = true
oneclient_cluster.workspace = true
oneclient_common.workspace = true
oneclient_content.workspace = true
oneclient_events.workspace = true
oneclient_java.workspace = true
oneclient_mc.workspace = true
oneclient_polyplus.workspace = true
oneclient_net.workspace = true
oneclient_core.workspace = true
oneclient_db.workspace = true
chrono.workspace = true
@ -62,6 +71,7 @@ formats = ["nsis", "app", "dmg", "deb", "appimage"]
binaries = [{ path = "oneclient_app", main = true }]
icons = [
"icons/32x32.png",
"icons/64x64.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
@ -131,6 +141,7 @@ assets = [
{ source = "target/release/oneclient_app", dest = "/usr/bin/oneclient_app", mode = "755" },
{ source = "target/release/.cargo-packager/deb/*/data/usr/share/applications/oneclient_app.desktop", dest = "/usr/share/applications/", mode = "644" },
{ source = "icons/32x32.png", dest = "/usr/share/icons/hicolor/32x32/apps/oneclient_app.png", mode = "644" },
{ source = "icons/64x64.png", dest = "/usr/share/icons/hicolor/64x64/apps/oneclient_app.png", mode = "644" },
{ source = "icons/128x128.png", dest = "/usr/share/icons/hicolor/128x128/apps/oneclient_app.png", mode = "644" },
{ source = "icons/512x512.png", dest = "/usr/share/icons/hicolor/512x512/apps/oneclient_app.png", mode = "644" },
]

View file

@ -1,29 +0,0 @@
use freya::prelude::*;
struct MinApp;
impl App for MinApp {
fn render(&self) -> impl IntoElement {
rect()
.width(Size::fill())
.height(Size::fill())
.child(label().text("hello"))
}
}
fn main() {
let cache_mb = std::env::var("ONECLIENT_GPU_CACHE_MB")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(128);
let config = LaunchConfig::new()
.with_window(
WindowConfig::new_app(MinApp)
.with_title("min_freya")
.with_size(1200., 800.),
)
.with_gpu_resource_cache_limit(cache_mb * 1024 * 1024);
launch(config);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View file

@ -1,144 +0,0 @@
use std::path::PathBuf;
use uuid::Uuid;
use oneclient_core::ApplyBundleUpdatesResult;
use oneclient_core::notification::UserChoice;
use oneclient_core::packages::{ContentType, ProviderId};
use oneclient_core::settings::{GameSettingsProfile, LauncherSettings, ProfileUpdate};
use oneclient_db::models::ClusterId;
use crate::notifications::{ClusterUpdateSummary, NotificationSpec};
#[derive(Debug)]
pub enum BridgeCommand {
ReloadSettings,
SaveSettings,
SetSettings {
settings: LauncherSettings,
},
RecordSeenVersion {
version: String,
},
SetSeenChangelogVersion {
version: String,
},
MarkOnboardingSeen,
AcceptTos {
terms_version: u32,
privacy_version: u32,
},
SaveGlobalProfile {
profile: GameSettingsProfile,
},
UpdateGlobalProfile {
update: ProfileUpdate,
},
CreateSettingsProfile {
name: String,
},
CreateProfileFromGlobal {
name: String,
mem_max: Option<u32>,
force_fullscreen: Option<bool>,
},
UpsertNamedProfile {
profile: GameSettingsProfile,
},
UpdateNamedProfile {
name: String,
update: ProfileUpdate,
},
DeleteNamedProfile {
name: String,
},
UpdateClusterProfile {
cluster_id: ClusterId,
update: ProfileUpdate,
},
CreateAndAssignClusterProfile {
cluster_id: ClusterId,
profile_name: String,
},
SetClusterLoaderVersion {
cluster_id: ClusterId,
version: String,
},
InstallJavaRuntime {
vendor: oneclient_core::java::JavaVendor,
major: u32,
},
AddCustomJavaRuntime {
path: PathBuf,
},
RemoveJavaRuntime {
path: String,
},
ToggleNotificationCenter,
CloseNotificationCenter,
ToggleAccountSwitcher,
CloseAccountSwitcher,
ClearNotificationInbox,
DismissToast(u64),
PauseToasts,
ResumeToasts,
MarkNotificationRead(u64),
DismissNotification(u64),
AnswerPrompt(UserChoice),
OpenClusterUpdate(Vec<ClusterUpdateSummary>),
CloseClusterUpdate,
SendNotification {
spec: NotificationSpec,
},
SendTestProgress {
current: u64,
total: u64,
},
LaunchCluster {
cluster_id: ClusterId,
},
KillCluster {
cluster_id: ClusterId,
},
DismissGameError,
ImportLocalFile {
cluster_id: ClusterId,
content_type: ContentType,
path: PathBuf,
},
InstallPackage {
cluster_id: ClusterId,
provider: ProviderId,
project_id: String,
version_id: String,
},
InstallBundle {
cluster_id: ClusterId,
bundle_name: String,
skip_compatibility: bool,
},
ApplyBundleUpdates {
cluster_id: ClusterId,
},
SyncBundles,
/// Emitted by the background bundle sync task when it finishes; carries the
/// per-cluster changes so the loop can raise "view update" notifications.
BundleSyncComplete {
changed: Vec<(ClusterId, ApplyBundleUpdatesResult)>,
session_id: Uuid,
},
ImportLauncher {
source: oneclient_core::MigrationSource,
folder_name: String,
target: oneclient_core::ImportTarget,
},
}

View file

@ -1,64 +0,0 @@
mod commands;
mod runtime;
mod snapshot;
pub use commands::BridgeCommand;
pub use snapshot::{
AsyncStatus, BridgeSnapshot, ClustersSnapshot, GameSnapshot, JavaSnapshot, LauncherInit,
ProfilesSnapshot, SettingsSnapshot,
};
use runtime::CoreBridgeRuntime;
use tokio::sync::{mpsc, watch};
#[derive(Clone)]
pub struct OneClientBridge {
pub snapshots: watch::Receiver<BridgeSnapshot>,
commands: mpsc::UnboundedSender<BridgeCommand>,
}
impl OneClientBridge {
pub fn new() -> (Self, CoreBridgeHandle) {
let (snapshots_tx, snapshots_rx) = watch::channel(BridgeSnapshot::default());
let (commands_tx, commands_rx) = mpsc::unbounded_channel();
(
Self {
snapshots: snapshots_rx,
commands: commands_tx.clone(),
},
CoreBridgeHandle {
snapshots_tx,
commands_rx,
commands_tx: commands_tx.clone(),
},
)
}
pub fn send(&self, command: BridgeCommand) {
let _ = self.commands.send(command);
}
}
pub struct CoreBridgeHandle {
snapshots_tx: watch::Sender<BridgeSnapshot>,
commands_rx: mpsc::UnboundedReceiver<BridgeCommand>,
commands_tx: mpsc::UnboundedSender<BridgeCommand>,
}
impl CoreBridgeHandle {
pub fn spawn_runtime(self) {
let runtime = CoreBridgeRuntime {
snapshots_tx: self.snapshots_tx,
commands_rx: self.commands_rx,
commands_tx: self.commands_tx,
};
tokio::spawn(runtime.run());
}
}
pub fn use_bridge_snapshot(bridge: &OneClientBridge) -> BridgeSnapshot {
freya::sdk::use_track_watcher(&bridge.snapshots);
bridge.snapshots.borrow().clone()
}

File diff suppressed because it is too large Load diff

View file

@ -1,103 +0,0 @@
use std::collections::HashMap;
use std::sync::Arc;
use oneclient_core::notification::{LaunchStage, MicrosoftLoginStatus};
use oneclient_core::settings::LauncherSettings;
#[derive(Clone, Debug, Default, PartialEq)]
pub struct GameSnapshot {
pub stages: HashMap<i64, LaunchStage>,
pub error: Option<String>,
pub logs: HashMap<i64, Arc<Vec<Arc<str>>>>,
}
impl GameSnapshot {
pub fn stage(&self, cluster_id: i64) -> Option<LaunchStage> {
self.stages.get(&cluster_id).copied()
}
pub fn is_busy(&self, cluster_id: i64) -> bool {
self.stage(cluster_id).is_some_and(LaunchStage::is_busy)
}
pub fn is_running(&self, cluster_id: i64) -> bool {
self.stage(cluster_id) == Some(LaunchStage::Running)
}
pub fn is_active(&self, cluster_id: i64) -> bool {
matches!(
self.stage(cluster_id),
Some(s) if s != LaunchStage::Exited
)
}
pub fn logs_for(&self, cluster_id: i64) -> Arc<Vec<Arc<str>>> {
self.logs.get(&cluster_id).cloned().unwrap_or_default()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AsyncStatus {
Idle,
Loading,
Ready,
Error,
}
#[derive(Clone, Debug, Default)]
pub struct LauncherInit {
pub ready: bool,
pub fetching: bool,
/// True while the background cluster-bundle download is running. Launch is
/// disabled until it clears.
pub syncing_bundles: bool,
pub error: Option<String>,
pub data_dir: String,
}
#[derive(Clone, Debug)]
pub struct SettingsSnapshot {
pub settings: LauncherSettings,
pub status: AsyncStatus,
pub saving: bool,
pub error: Option<String>,
}
impl Default for SettingsSnapshot {
fn default() -> Self {
Self {
settings: LauncherSettings::default(),
status: AsyncStatus::Idle,
saving: false,
error: None,
}
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct ProfilesSnapshot {
pub generation: u64,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct ClustersSnapshot {
pub generation: u64,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct JavaSnapshot {
pub generation: u64,
}
#[derive(Clone, Debug, Default)]
pub struct BridgeSnapshot {
pub launcher: LauncherInit,
pub settings: SettingsSnapshot,
pub profiles: ProfilesSnapshot,
pub clusters: ClustersSnapshot,
pub java: JavaSnapshot,
pub notifications: crate::notifications::NotificationSnapshot,
pub game: GameSnapshot,
pub account_switcher_open: bool,
pub microsoft_login: Option<MicrosoftLoginStatus>,
}

View file

@ -3,9 +3,10 @@ use freya::{
prelude::*,
router::RouterContext,
};
use oneclient_core::auth::MinecraftAccount;
use oneclient_auth::MinecraftAccount;
use crate::{
ui::{divider},
Route,
components::{Avatar, Button, Icon, IconType, OverlayPopup},
hooks::{
@ -226,13 +227,6 @@ impl Component for AccountRow {
}
}
fn divider() -> impl IntoElement {
rect()
.width(Size::fill())
.height(Size::px(1.))
.background(colors::component_border())
}
#[derive(PartialEq)]
struct Footer;

View file

@ -1,15 +1,11 @@
use freya::animation::*;
use freya::prelude::*;
use freya::query::QueryStateData;
use freya::router::RouterContext;
use crate::components::{Button, Icon, IconType};
use oneclient_core::parse_mc_version;
use oneclient_common::parse_mc_version;
use crate::hooks::{
use_active_cluster_id, use_clusters, use_dispatch, use_game_snapshot, use_launcher,
use_version_metadata,
};
use crate::hooks::{settled_or_loading, use_active_cluster_id, use_clusters, use_dispatch, use_game_snapshot, use_launcher, use_version_metadata};
use crate::routes::Route;
use crate::theme::colors;
use crate::utils::sort_clusters_for_home;
@ -27,16 +23,7 @@ impl Component for ActiveClusterPanel {
let launcher = use_launcher();
let syncing = launcher.fetching || launcher.syncing_bundles;
let clusters = {
let reader = clusters_query.read();
match &*reader.state() {
QueryStateData::Settled { res: Ok(list), .. } => list.clone(),
QueryStateData::Loading {
res: Some(Ok(list)),
} => list.clone(),
_ => Vec::new(),
}
};
let clusters = settled_or_loading(&clusters_query).unwrap_or_default();
let sorted = sort_clusters_for_home(clusters);
@ -131,7 +118,7 @@ impl Component for ActiveClusterPanel {
fn launch_button(
cluster_id: i64,
dispatch: crate::BridgeDispatch,
dispatch: crate::Actions,
state: (&'static str, bool),
) -> impl IntoElement {
let (label, enabled) = state;

View file

@ -1,6 +1,6 @@
use freya::prelude::*;
use oneclient_core::VersionKey;
use oneclient_core::packages::domain::GameLoader;
use oneclient_common::VersionKey;
use oneclient_common::domain::GameLoader;
use crate::components::DynamicArt;
use crate::theme::colors;

View file

@ -1,7 +1,7 @@
use std::collections::HashMap;
use freya::{prelude::*, router::RouterContext};
use oneclient_core::packages::{CachedPackageMeta, ProviderId};
use oneclient_content::packages::{CachedPackageMeta, ProviderId};
use crate::components::{Button, Icon, IconType, OverlayPopup, ScrollArea, TabBar, TabItem};
use crate::hooks::{package_meta_batch, use_dispatch, use_notifications_snapshot, use_package_meta_batch};
@ -139,7 +139,7 @@ fn dialog(
summaries: &[ClusterUpdateSummary],
meta: &MetaMap,
active: State<UpdateTab>,
dispatch: crate::BridgeDispatch,
dispatch: crate::Actions,
) -> impl IntoElement {
rect()
.vertical()
@ -164,7 +164,7 @@ fn content(
summaries: &[ClusterUpdateSummary],
meta: &MetaMap,
active: State<UpdateTab>,
dispatch: crate::BridgeDispatch,
dispatch: crate::Actions,
) -> impl IntoElement {
let dismiss = dispatch.clone();
let total: usize = summaries.iter().map(|s| s.total()).sum();
@ -280,7 +280,7 @@ fn content(
)
}
fn open_cluster(dispatch: &crate::BridgeDispatch, cluster_id: i64) {
fn open_cluster(dispatch: &crate::Actions, cluster_id: i64) {
dispatch.close_cluster_update();
let _ = RouterContext::get().push(Route::ClusterOverview { cluster_id });
}
@ -289,7 +289,7 @@ fn change_list(
tab: UpdateTab,
groups: &[ClusterGroup],
show_headers: bool,
dispatch: &crate::BridgeDispatch,
dispatch: &crate::Actions,
) -> impl IntoElement {
let accent = tab.accent();
@ -350,7 +350,7 @@ fn change_list(
fn cluster_header(
group: &ClusterGroup,
first: bool,
dispatch: crate::BridgeDispatch,
dispatch: crate::Actions,
) -> impl IntoElement {
let cluster_id = group.cluster_id;
let count = group.names.len();

View file

@ -1,13 +1,12 @@
use bytes::Bytes;
use freya::prelude::*;
use freya::query::QueryStateData;
use oneclient_core::clusters::Cluster;
use oneclient_core::images::DEFAULT_IMAGE_EDGE;
use oneclient_core::packages::domain::GameLoader;
use oneclient_core::{VersionKey, parse_mc_version};
use oneclient_common::domain::GameLoader;
use oneclient_common::{VersionKey, parse_mc_version};
use crate::AppAssets;
use crate::hooks::{use_cached_image, use_version_metadata};
use crate::hooks::{loaded_image, use_cached_image, use_version_metadata};
use crate::layout::HOME_BACKGROUND_ASSET;
#[derive(PartialEq, Clone)]
@ -70,17 +69,9 @@ pub fn use_art_bytes(
let image_query = use_cached_image(art_url.clone(), max_edge);
let reader = image_query.read();
let state = reader.state();
match (&art_url, &*state) {
(Some(url), QueryStateData::Settled { res: Ok(bytes), .. })
| (
Some(url),
QueryStateData::Loading {
res: Some(Ok(bytes)),
},
) => (format!("{max_edge}|{url}"), bytes.clone()),
_ => (
match loaded_image(art_url.as_deref(), &image_query) {
Some((url, bytes)) => (format!("{max_edge}|{url}"), bytes),
None => (
format!("{max_edge}|{HOME_BACKGROUND_ASSET}"),
fallback.read().clone(),
),

View file

@ -15,15 +15,14 @@ use std::path::{Path, PathBuf};
use freya::animation::{AnimNum, Ease, Function, OnCreation, use_animation};
use freya::prelude::*;
use freya::query::QueryStateData;
use freya::router::use_route;
use oneclient_core::clusters::Cluster;
use oneclient_core::packages::ContentType;
use oneclient_content::packages::ContentType;
use oneclient_db::models::ClusterId;
use crate::Route;
use crate::components::{Button, Dropdown, Icon, IconType, OverlayPopup, ScrollArea};
use crate::hooks::{use_active_cluster_id, use_clusters, use_dispatch};
use crate::hooks::{settled_or_loading, use_active_cluster_id, use_clusters, use_dispatch};
use crate::theme::colors;
use crate::ui::border_all_color;
@ -41,7 +40,7 @@ const IMPORTABLE: [ContentType; 3] = [
ContentType::Shader,
];
/// Names that give away a shaderpack. Only a starting guess — every row has a
/// Names that give away a shaderpack. Only a starting guess; every row has a
/// dropdown, so a wrong sniff costs the user one click.
const SHADER_HINTS: [&str; 5] = ["shader", "bsl", "seus", "complementary", "sildur"];
@ -90,10 +89,6 @@ fn route_target(route: &Route) -> Option<(ClusterId, ContentType)> {
}
}
// ---------------------------------------------------------------------------
// Overlay
// ---------------------------------------------------------------------------
/// Renders the shell's drop affordance and, once files land, the prompt asking
/// where they should go.
///
@ -197,10 +192,6 @@ fn icon_badge(icon: IconType, size: f32, icon_size: f32) -> impl IntoElement {
.child(Icon::new(icon).size(icon_size).color(colors::brand()))
}
// ---------------------------------------------------------------------------
// Prompt
// ---------------------------------------------------------------------------
#[derive(PartialEq)]
struct DropPrompt {
files: Vec<PathBuf>,
@ -224,17 +215,7 @@ impl Component for DropPrompt {
// the inferred type, so a second drop landing mid-prompt still gets one.
let overrides = use_state(HashMap::<PathBuf, ContentType>::new);
let clusters: Vec<Cluster> = {
let reader = clusters_query.read();
let state = reader.state();
match &*state {
QueryStateData::Settled { res: Ok(list), .. } => list.clone(),
QueryStateData::Loading {
res: Some(Ok(list)),
} => list.clone(),
_ => Vec::new(),
}
};
let clusters: Vec<Cluster> = settled_or_loading(&clusters_query).unwrap_or_default();
let body = if clusters.is_empty() {
no_clusters_body(pending)
@ -343,7 +324,7 @@ fn prompt_body(
route_type: Option<ContentType>,
selected_cluster: State<Option<ClusterId>>,
overrides: State<HashMap<PathBuf, ContentType>>,
dispatch: crate::BridgeDispatch,
dispatch: crate::Actions,
mut pending: State<Vec<PathBuf>>,
) -> Element {
// The remembered cluster may have been deleted while the prompt was open.

View file

@ -1,5 +1,5 @@
use freya::prelude::*;
use oneclient_core::packages::ProviderId;
use oneclient_content::packages::ProviderId;
use crate::{AppAssets, theme::colors};

View file

@ -1,5 +1,5 @@
use freya::prelude::*;
use oneclient_core::java::{AvailableJava, JavaVendor};
use oneclient_java::{AvailableJava, JavaVendor};
use crate::components::{Button, Dropdown, Icon, IconType, OverlayPopup, ScrollArea};
use crate::hooks::{provider_versions, use_provider_versions};

View file

@ -1,6 +1,6 @@
use freya::prelude::*;
use oneclient_core::java::JavaVendor;
use oneclient_core::notification::{PromptKind, UserChoice};
use oneclient_java::{JAVA_CHOICE_DOWNLOAD, JAVA_CHOICE_FOLDER, JavaVendor};
use oneclient_events::Answer;
use crate::components::{Button, Icon, IconType, JavaInstallManager, OverlayPopup};
use crate::hooks::{use_dispatch, use_notifications_snapshot};
@ -24,9 +24,23 @@ impl Component for JavaPromptOverlay {
return rect().into_element();
};
let PromptKind::JavaInstall { major } = prompt.kind else {
// Identified by the choices it offers rather than a prompt-kind enum.
if !prompt.has_choice(JAVA_CHOICE_DOWNLOAD) {
return rect().into_element();
};
}
// The vendor list needs a major to highlight. It is not on the prompt (the
// core only sends display text) so recover it from the folder choice's dialog
// title, which the core builds from the same number.
let major = prompt
.choice(JAVA_CHOICE_FOLDER)
.and_then(|choice| match &choice.input {
Some(oneclient_events::ChoiceInput::Folder { title }) => title
.split_whitespace()
.find_map(|word| word.parse::<u32>().ok()),
_ => None,
})
.unwrap_or(0);
// Download opens the install manager with the required major highlighted.
if *show_manager.read() {
@ -35,7 +49,9 @@ impl Component for JavaPromptOverlay {
.suggested(Some(major))
.on_install(move |(vendor, _row_major): (JavaVendor, u32)| {
// The launch needs `major`; the manager only picks the vendor.
install.answer_prompt(UserChoice::Install { vendor, major });
install.answer_prompt(
Answer::new(JAVA_CHOICE_DOWNLOAD).with_selection(vendor.to_string()),
);
show_manager.set(false);
})
.on_close(move |()| show_manager.set(false))
@ -54,13 +70,15 @@ impl Component for JavaPromptOverlay {
.pick_folder()
.await
{
dispatch.answer_prompt(UserChoice::Folder(handle.path().to_path_buf()));
dispatch.answer_prompt(
Answer::new(JAVA_CHOICE_FOLDER).with_folder(handle.path()),
);
}
});
};
OverlayPopup::new()
.on_close(move |_| close.answer_prompt(UserChoice::Cancel))
.on_close(move |_| close.dismiss_prompt())
.child(
rect()
.width(Size::window_percent(100.))
@ -112,7 +130,7 @@ impl Component for JavaPromptOverlay {
Button::new()
.secondary()
.on_press(move |_| {
cancel.answer_prompt(UserChoice::Cancel)
cancel.dismiss_prompt()
})
.text("Cancel"),
)

View file

@ -7,9 +7,8 @@ use freya::animation::{
use freya::elements::image::{AspectRatio, ImageCover, ImageHandle, image};
use freya::engine::prelude::{SkData, SkImage};
use freya::prelude::*;
use freya::query::QueryStateData;
use crate::hooks::use_local_image;
use crate::hooks::{settled_or_loading, use_local_image};
use crate::theme::colors;
#[derive(PartialEq)]
@ -40,11 +39,7 @@ impl Component for LocalImage {
fn render(&self) -> impl IntoElement {
let query = use_local_image(self.path.clone(), self.max_edge);
let bytes: Option<Bytes> = match &*query.read().state() {
QueryStateData::Settled { res: Ok(b), .. }
| QueryStateData::Loading { res: Some(Ok(b)) } => Some(b.clone()),
_ => None,
};
let bytes: Option<Bytes> = settled_or_loading(&query);
let mut cache = use_state(|| None::<(usize, ImageHandle)>);
let holder = bytes.and_then(|bytes| {

View file

@ -2,8 +2,8 @@ use freya::prelude::*;
use freya::query::{MutationCapability, MutationStateData, UseMutation};
use freya::text_edit::Clipboard;
use oneclient_core::LauncherError;
use oneclient_core::auth::{AuthErrorGuidance, MicrosoftLoginSession};
use oneclient_core::notification::MicrosoftLoginStatus;
use oneclient_auth::{AuthErrorGuidance, MicrosoftLoginSession};
use crate::state::LoginProgress;
use crate::components::{Button, Icon, IconType, OverlayPopup};
use crate::hooks::{
@ -21,7 +21,7 @@ pub struct MicrosoftLogin {
cancel: UseMutation<crate::hooks::CancelMicrosoftLoginMutation>,
pending_login: State<Option<MicrosoftLoginSession>>,
cancelled: State<bool>,
status: Option<MicrosoftLoginStatus>,
status: Option<LoginProgress>,
locked: bool,
pub pending: bool,
pub error: Option<String>,
@ -151,7 +151,7 @@ where
fn microsoft_dialog(
login: MicrosoftLoginSession,
handle: MicrosoftLogin,
status: Option<MicrosoftLoginStatus>,
status: Option<LoginProgress>,
error: Option<String>,
guidance: Option<AuthErrorGuidance>,
) -> impl IntoElement {
@ -174,7 +174,7 @@ pub(crate) fn login_dialog(
user_code: String,
verification_uri: String,
locked: bool,
status: Option<MicrosoftLoginStatus>,
status: Option<LoginProgress>,
error: Option<String>,
guidance: Option<AuthErrorGuidance>,
on_close: impl FnMut() + Clone + 'static,
@ -268,7 +268,7 @@ fn dialog_divider() -> impl IntoElement {
.into_element()
}
fn status_row(status: Option<MicrosoftLoginStatus>, error: Option<String>) -> impl IntoElement {
fn status_row(status: Option<LoginProgress>, error: Option<String>) -> impl IntoElement {
if let Some(error) = error {
return rect()
.horizontal()

View file

@ -2,13 +2,15 @@ use freya::{
animation::{AnimNum, Ease, OnCreation, use_animation},
prelude::*,
};
use oneclient_core::notification::NotificationLevel;
use oneclient_events::Level;
use crate::{
ui::{divider, relative_time},
components::{Button, ButtonVariant, Icon, IconType, OverlayPopup, ScrollArea},
hooks::{use_dispatch, use_notifications_snapshot},
notifications::{InboxEntry, NotificationActionKind, TransferStats},
notifications::{InboxEntry, NotificationActionKind},
theme::colors,
transfer::TransferStats,
utils::{format_duration_hms, format_size},
};
@ -328,7 +330,7 @@ fn phase_badge(phase: &str) -> impl IntoElement {
.into_element()
}
fn row_body(entry: &InboxEntry, dispatch: crate::BridgeDispatch) -> impl IntoElement {
fn row_body(entry: &InboxEntry, dispatch: crate::Actions) -> impl IntoElement {
rect()
.vertical()
.width(Size::flex(1.0))
@ -354,7 +356,7 @@ fn row_body(entry: &InboxEntry, dispatch: crate::BridgeDispatch) -> impl IntoEle
.spacing(6.)
.child(
label()
.text(relative_time(entry))
.text(relative_time(entry.created_at))
.font_size(10.)
.color(colors::fg_secondary()),
)
@ -378,7 +380,7 @@ fn row_body(entry: &InboxEntry, dispatch: crate::BridgeDispatch) -> impl IntoEle
.child(row_extra(entry, dispatch))
}
fn row_extra(entry: &InboxEntry, dispatch: crate::BridgeDispatch) -> impl IntoElement {
fn row_extra(entry: &InboxEntry, dispatch: crate::Actions) -> impl IntoElement {
if let Some((current, total)) = entry.progress.filter(|_| entry.is_loading) {
let frac = (current as f32 / total.max(1) as f32).clamp(0.0, 1.0);
let bar = rect()
@ -475,7 +477,7 @@ fn transfer_footer(stats: TransferStats) -> Element {
.into_element()
}
fn run_action(dispatch: &crate::BridgeDispatch, kind: &NotificationActionKind) {
fn run_action(dispatch: &crate::Actions, kind: &NotificationActionKind) {
match kind {
NotificationActionKind::OpenClusterUpdate(summaries) => {
dispatch.open_cluster_update(summaries.clone());
@ -483,13 +485,6 @@ fn run_action(dispatch: &crate::BridgeDispatch, kind: &NotificationActionKind) {
}
}
fn divider() -> impl IntoElement {
rect()
.width(Size::fill())
.height(Size::px(1.))
.background(colors::component_border())
}
#[derive(PartialEq)]
struct Footer;
@ -527,27 +522,15 @@ fn icon_for(entry: &InboxEntry) -> IconType {
return IconType::DownloadCloud02;
}
match entry.level {
NotificationLevel::Error => IconType::AlertTriangle,
NotificationLevel::Info => IconType::InfoCircle,
Level::Error => IconType::AlertTriangle,
Level::Info => IconType::InfoCircle,
}
}
fn level_color(level: &NotificationLevel) -> Color {
fn level_color(level: &Level) -> Color {
match level {
NotificationLevel::Info => colors::fg_primary(),
NotificationLevel::Error => colors::danger(),
Level::Info => colors::fg_primary(),
Level::Error => colors::danger(),
}
}
fn relative_time(entry: &InboxEntry) -> String {
let secs = entry.created_at.elapsed().as_secs();
if secs < 60 {
"Just now".to_string()
} else if secs < 3600 {
format!("{}m ago", secs / 60)
} else if secs < 86400 {
format!("{}h ago", secs / 3600)
} else {
format!("{}d ago", secs / 86400)
}
}

View file

@ -1,10 +1,9 @@
use freya::prelude::*;
use freya::query::QueryStateData;
use freya::router::RouterContext;
use oneclient_core::packages::ProviderId;
use oneclient_content::packages::ProviderId;
use crate::components::{Icon, IconType, toggle_controlled};
use crate::hooks::{ClusterAction, use_cached_image, use_cluster_mutation};
use crate::hooks::{ClusterAction, loaded_image, use_cached_image, use_cluster_mutation};
use crate::routes::Route;
use crate::theme::colors;
use crate::ui::border_all_color;
@ -391,17 +390,7 @@ fn remote_icon(
icon_query: &freya::query::UseQuery<crate::hooks::CachedImageQuery>,
size: f32,
) -> Element {
let reader = icon_query.read();
let loaded = match (icon_url, &*reader.state()) {
(Some(url), QueryStateData::Settled { res: Ok(bytes), .. })
| (
Some(url),
QueryStateData::Loading {
res: Some(Ok(bytes)),
},
) => Some((url.clone(), bytes.clone())),
_ => None,
};
let loaded = loaded_image(icon_url.as_deref(), icon_query);
match loaded {
Some((url, bytes)) => ImageViewer::new((url, bytes))

View file

@ -1,11 +1,10 @@
use freya::animation::*;
use freya::prelude::*;
use freya::query::QueryStateData;
use freya::router::RouterContext;
use oneclient_core::clusters::Cluster;
use crate::components::{DynamicArt, Icon, IconType};
use crate::hooks::{use_active_cluster_id, use_clusters};
use crate::hooks::{settled_or_loading, use_active_cluster_id, use_clusters};
use crate::routes::Route;
use crate::theme::colors;
use crate::ui::{border_all, border_all_color};
@ -28,16 +27,7 @@ impl Component for RecentsRow {
let clusters_query = use_clusters();
let mut visible_slots = use_state(|| 1_usize);
let clusters = {
let reader = clusters_query.read();
match &*reader.state() {
QueryStateData::Settled { res: Ok(list), .. } => list.clone(),
QueryStateData::Loading {
res: Some(Ok(list)),
} => list.clone(),
_ => Vec::new(),
}
};
let clusters = settled_or_loading(&clusters_query).unwrap_or_default();
let sorted: Vec<Cluster> = sort_clusters_for_home(clusters);
let slots = *visible_slots.read();

View file

@ -2,7 +2,7 @@ use std::collections::HashSet;
use freya::animation::{AnimNum, Ease, Function, OnCreation, use_animation};
use freya::prelude::*;
use oneclient_core::status::{self, ServiceStatus};
use oneclient_net::status::{self, ServiceStatus};
use crate::components::{Icon, IconType};
use crate::theme::colors;

View file

@ -7,9 +7,10 @@ use freya::{
},
prelude::*,
};
use oneclient_core::notification::NotificationLevel;
use oneclient_events::Level;
use crate::{
ui::{divider},
components::{Button, ButtonSize, Icon, IconType},
hooks::{use_dispatch, use_notifications_snapshot},
notifications::{InboxEntry, NotificationActionKind},
@ -45,8 +46,8 @@ fn entry_icon(entry: &InboxEntry) -> (IconType, Color) {
}
match entry.level {
NotificationLevel::Error => (IconType::AlertTriangle, colors::danger()),
NotificationLevel::Info => (IconType::DownloadCloud02, colors::fg_primary()),
Level::Error => (IconType::AlertTriangle, colors::danger()),
Level::Info => (IconType::DownloadCloud02, colors::fg_primary()),
}
}
@ -218,8 +219,8 @@ impl Component for ToastCard {
fn toast_shell(entry: &InboxEntry) -> Rect {
let border_color = match entry.level {
NotificationLevel::Error => colors::danger(),
NotificationLevel::Info => colors::component_border(),
Level::Error => colors::danger(),
Level::Info => colors::component_border(),
};
rect()
.width(Size::px(TOAST_W))
@ -246,8 +247,8 @@ fn icon_badge(entry: &InboxEntry) -> impl IntoElement {
fn header(entry: &InboxEntry) -> impl IntoElement {
let title_color = match entry.level {
NotificationLevel::Error => colors::danger(),
NotificationLevel::Info => colors::fg_primary(),
Level::Error => colors::danger(),
Level::Info => colors::fg_primary(),
};
let body_color = colors::fg_secondary();
@ -273,8 +274,8 @@ fn header(entry: &InboxEntry) -> impl IntoElement {
fn ttl_bar(entry: &InboxEntry, pct: f32) -> impl IntoElement {
let color = match entry.level {
NotificationLevel::Error => colors::danger(),
NotificationLevel::Info => colors::brand(),
Level::Error => colors::danger(),
Level::Info => colors::brand(),
};
rect()
.width(Size::fill())
@ -289,7 +290,7 @@ fn ttl_bar(entry: &InboxEntry, pct: f32) -> impl IntoElement {
)
}
fn close_button(dispatch: crate::BridgeDispatch, id: u64) -> impl IntoElement {
fn close_button(dispatch: crate::Actions, id: u64) -> impl IntoElement {
rect()
.position(Position::new_absolute().top(8.).right(8.))
.child(
@ -309,7 +310,7 @@ fn close_button(dispatch: crate::BridgeDispatch, id: u64) -> impl IntoElement {
fn message_card(
entry: &InboxEntry,
dispatch: crate::BridgeDispatch,
dispatch: crate::Actions,
id: u64,
ttl_pct: f32,
) -> Rect {
@ -329,7 +330,7 @@ fn message_card(
.child(ttl_bar(entry, ttl_pct))
}
fn action_card(entry: &InboxEntry, dispatch: crate::BridgeDispatch, id: u64, ttl_pct: f32) -> Rect {
fn action_card(entry: &InboxEntry, dispatch: crate::Actions, id: u64, ttl_pct: f32) -> Rect {
let action = entry.actions.first().cloned();
toast_shell(entry)
@ -357,7 +358,7 @@ fn action_card(entry: &InboxEntry, dispatch: crate::BridgeDispatch, id: u64, ttl
fn action_row(
action: Option<crate::notifications::NotificationAction>,
dispatch: crate::BridgeDispatch,
dispatch: crate::Actions,
id: u64,
) -> impl IntoElement {
let dismiss = dispatch.clone();
@ -396,7 +397,7 @@ fn action_row(
)
}
fn run_action(dispatch: &crate::BridgeDispatch, kind: &NotificationActionKind) {
fn run_action(dispatch: &crate::Actions, kind: &NotificationActionKind) {
match kind {
NotificationActionKind::OpenClusterUpdate(summaries) => {
dispatch.open_cluster_update(summaries.clone());
@ -456,10 +457,3 @@ fn progress_card(entry: &InboxEntry) -> Rect {
),
)
}
fn divider() -> impl IntoElement {
rect()
.width(Size::fill())
.height(Size::px(1.))
.background(colors::component_border())
}

View file

@ -1,5 +1,7 @@
use freya::prelude::*;
use oneclient_core::notification::{PromptKind, UserChoice};
use oneclient_events::Answer;
use crate::updater::UPDATE_CHOICE_INSTALL;
use crate::components::{Button, Icon, IconType, OverlayPopup};
use crate::hooks::{use_dispatch, use_notifications_snapshot};
@ -20,16 +22,27 @@ impl Component for UpdatePromptOverlay {
return rect().into_element();
};
if prompt.kind != PromptKind::Update {
// Recognise the update prompt by the choice it offers rather than by a
// prompt-kind enum, so the event layer needn't enumerate every prompt.
if !prompt.has_choice(UPDATE_CHOICE_INSTALL) {
return rect().into_element();
}
let dismiss_label = prompt
.dismiss
.clone()
.unwrap_or_else(|| "Not now".to_string());
let install_label = prompt
.choice(UPDATE_CHOICE_INSTALL)
.map(|choice| choice.label.clone())
.unwrap_or_else(|| "Download".to_string());
let close = dispatch.clone();
let cancel = dispatch.clone();
let accept = dispatch.clone();
OverlayPopup::new()
.on_close(move |_| close.answer_prompt(UserChoice::Cancel))
.on_close(move |_| close.dismiss_prompt())
.child(
rect()
.width(Size::window_percent(100.))
@ -80,19 +93,19 @@ impl Component for UpdatePromptOverlay {
.child(
Button::new()
.secondary()
.on_press(move |_| {
cancel.answer_prompt(UserChoice::Cancel)
})
.text("Not now"),
.on_press(move |_| cancel.dismiss_prompt())
.text(dismiss_label),
)
.child(
Button::new()
.primary()
.on_press(move |_| {
accept.answer_prompt(UserChoice::Accept)
accept.answer_prompt(Answer::new(
UPDATE_CHOICE_INSTALL,
))
})
.child(Icon::new(IconType::DownloadCloud02).size(14.))
.text("Download"),
.text(install_label),
),
),
),

View file

@ -1,5 +1,5 @@
use freya::prelude::*;
use oneclient_core::VersionKey;
use oneclient_common::VersionKey;
use oneclient_core::clusters::Cluster;
use crate::components::ClusterLandscapeArt;

View file

@ -0,0 +1,390 @@
//! Drains the core's event stream into the radio station.
//!
//! An event drain, the toast expiry timers, and the game-log flush. It runs
//! under freya's `spawn`, not `tokio::spawn`, because radio state is `!Send`.
//!
//! The batching matters. During a download the core emits tens of thousands of
//! progress events, and a radio write wakes every subscriber to that channel, so
//! events are drained into a batch, folded, and written once per channel rather
//! than once per event.
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use freya::prelude::spawn_forever;
use freya::radio::RadioStation;
use oneclient_events::{Event, EventReceiver, GameEvent, LaunchStage, ProgressEvent, Signal};
use tokio::sync::mpsc;
use crate::hooks::PumpSignal;
use crate::notifications::{MESSAGE_TOAST_TTL, PendingPromptView};
use crate::state::{AppChannel, AppState, LoginProgress};
/// How long game log lines accumulate before being written.
///
/// A launch emits lines continuously; without this every line would wake the
/// log view.
const GAME_LOG_FLUSH: Duration = Duration::from_millis(120);
/// Cap on how many queued events are folded into a single write.
const COALESCE_BUDGET: usize = 1024;
pub struct EventPump {
pub events: EventReceiver,
pub signals: mpsc::UnboundedReceiver<PumpSignal>,
pub station: RadioStation<AppState, AppChannel>,
}
impl EventPump {
pub async fn run(mut self) {
let mut armed: HashMap<u64, ToastTimer> = HashMap::new();
// Hovering any toast pauses every toast, including ones that arrive
// while hovering.
let mut paused = false;
let mut game_flush: Option<tokio::time::Instant> = None;
loop {
let next_deadline = armed
.values()
.filter_map(ToastTimer::deadline)
.min()
.into_iter()
.chain(game_flush)
.min();
let timer = async {
match next_deadline {
Some(deadline) => tokio::time::sleep_until(deadline).await,
None => std::future::pending::<()>().await,
}
};
tokio::select! {
() = timer => {
let now = tokio::time::Instant::now();
let due: Vec<u64> = armed
.iter()
.filter(|(_, t)| t.deadline().is_some_and(|d| d <= now))
.map(|(id, _)| *id)
.collect();
if !due.is_empty() {
{
let mut guard = self.station.write_channel(AppChannel::Notifications);
let AppState {
notifications, inbox, ..
} = &mut **guard;
for id in &due {
notifications.expire_toast(inbox, *id);
}
}
for id in due {
armed.remove(&id);
}
reconcile(&self.station, &mut armed, paused);
}
if game_flush.is_some_and(|d| d <= now) {
game_flush = None;
// Nothing to write: log lines were folded in as they
// arrived; this only ends the quiet period.
self.station.write_channel(AppChannel::Game);
}
}
event = self.events.recv() => {
let Some(first) = event else { break };
let mut batch = vec![first];
while batch.len() < COALESCE_BUDGET {
match self.events.try_recv() {
Ok(next) => batch.push(next),
Err(_) => break,
}
}
let folded = self.apply(batch);
if folded.touched_engine {
reconcile(&self.station, &mut armed, paused);
}
if folded.saw_game_log && game_flush.is_none() {
game_flush = Some(tokio::time::Instant::now() + GAME_LOG_FLUSH);
}
}
signal = self.signals.recv() => {
let Some(signal) = signal else { break };
match signal {
PumpSignal::Reconcile => reconcile(&self.station, &mut armed, paused),
PumpSignal::PauseToasts => {
paused = true;
for timer in armed.values_mut() {
timer.pause();
}
}
PumpSignal::ResumeToasts => {
paused = false;
for timer in armed.values_mut() {
timer.resume();
}
}
}
}
}
}
}
/// Folds a batch of events into the station, one write per channel touched.
fn apply(&mut self, batch: Vec<Event>) -> Folded {
let mut folded = Folded::default();
let mut engine_events = Vec::new();
let mut stages: Vec<(i64, LaunchStage)> = Vec::new();
let mut logs: Vec<(i64, String)> = Vec::new();
let mut failed: Option<(i64, String)> = None;
let mut login: Option<Option<LoginProgress>> = None;
let mut sync_complete = false;
for event in batch {
match event {
Event::Signal(Signal::ClustersChanged) => folded.clusters = true,
Event::Signal(Signal::JavaChanged) => folded.java = true,
Event::Signal(Signal::SyncComplete) => {
sync_complete = true;
folded.clusters = true;
}
Event::Game(GameEvent::Stage { cluster_id, stage }) => {
stages.push((cluster_id, stage));
}
Event::Game(GameEvent::Log { cluster_id, line }) => {
folded.saw_game_log = true;
logs.push((cluster_id, line));
}
Event::Game(GameEvent::Failed {
cluster_id,
message,
}) => failed = Some((cluster_id, message)),
// The sign-in modal renders this inline rather than as a toast,
// so it is lifted out here and never reaches the engine.
Event::Progress(ProgressEvent::Update {
id,
ref label,
current,
total,
}) if id == oneclient_auth::MICROSOFT_LOGIN_PROGRESS => {
login = Some((current < total).then(|| LoginProgress {
label: label.clone(),
current,
total,
}));
}
other => engine_events.push(other),
}
}
if !stages.is_empty() || !logs.is_empty() || failed.is_some() {
let mut guard = self.station.write_channel(AppChannel::Game);
for (cluster_id, stage) in stages {
guard.game.stages.insert(cluster_id, stage);
if stage == LaunchStage::Checking {
guard.game.error = None;
guard.game.logs.insert(cluster_id, std::sync::Arc::new(Vec::new()));
}
}
for (cluster_id, line) in logs {
let buffer = guard.game.logs.entry(cluster_id).or_default();
std::sync::Arc::make_mut(buffer).push(std::sync::Arc::from(line));
}
if let Some((cluster_id, message)) = failed {
guard.game.stages.insert(cluster_id, LaunchStage::Exited);
guard.game.error = Some(message);
}
}
if sync_complete {
self.station
.write_channel(AppChannel::Launcher)
.launcher
.fetching = false;
}
if let Some(progress) = login {
self.station
.write_channel(AppChannel::MicrosoftLogin)
.microsoft_login = progress;
}
if !engine_events.is_empty() {
folded.touched_engine = true;
let mut guard = self.station.write_channel(AppChannel::Notifications);
let AppState {
notifications,
inbox,
prompt,
..
} = &mut **guard;
for event in engine_events {
if let (_timers, Some(pending)) = notifications.dispatch(inbox, event) {
*prompt = Some(pending);
}
}
}
if folded.clusters {
spawn_forever(async move { crate::hooks::invalidate_cluster_queries().await });
}
if folded.java {
spawn_forever(async move { crate::hooks::invalidate_java_queries().await });
}
folded
}
}
#[derive(Default)]
struct Folded {
touched_engine: bool,
saw_game_log: bool,
clusters: bool,
java: bool,
}
/// Arms a timer for every toast that should have one, and drops timers for
/// toasts that have gone.
fn reconcile(
station: &RadioStation<AppState, AppChannel>,
armed: &mut HashMap<u64, ToastTimer>,
paused: bool,
) {
let state = station.peek();
let want: HashSet<u64> = state
.notifications
.active_toast_entry_ids()
.into_iter()
.filter(|id| state.inbox.iter().any(|e| e.id == *id && !e.is_loading))
.collect();
for id in &want {
armed.entry(*id).or_insert_with(|| ToastTimer::armed(paused));
}
armed.retain(|id, _| want.contains(id));
}
/// A toast's time-to-live, which hovering pauses and un-hovering resumes.
enum ToastTimer {
Running(tokio::time::Instant),
Paused(Duration),
}
impl ToastTimer {
fn armed(paused: bool) -> Self {
if paused {
Self::Paused(MESSAGE_TOAST_TTL)
} else {
Self::Running(tokio::time::Instant::now() + MESSAGE_TOAST_TTL)
}
}
/// The instant this toast expires, or `None` while paused.
fn deadline(&self) -> Option<tokio::time::Instant> {
match self {
Self::Running(deadline) => Some(*deadline),
Self::Paused(_) => None,
}
}
fn pause(&mut self) {
if let Self::Running(deadline) = self {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
*self = Self::Paused(remaining);
}
}
fn resume(&mut self) {
if let Self::Paused(remaining) = self {
let remaining = *remaining;
*self = Self::Running(tokio::time::Instant::now() + remaining);
}
}
}
/// Builds the view the prompt overlays render.
#[must_use]
pub fn prompt_view(state: &AppState) -> Option<PendingPromptView> {
state.prompt.as_ref().map(|prompt| PendingPromptView {
title: prompt.title.clone(),
question: prompt.question.clone(),
choices: prompt.choices.clone(),
dismiss: prompt.dismiss.clone(),
})
}
/// Everything the app needs before the UI can do anything: build the launcher,
/// publish the handle, then start the background work.
pub async fn start_launcher(
station: RadioStation<AppState, AppChannel>,
events: oneclient_events::EventBus,
) -> Result<(), anyhow::Error> {
let state = crate::launcher::install(oneclient_core::LauncherState::new(events).await?);
oneclient_net::status::start(state.services.requester.clone());
oneclient_polyplus::start(std::sync::Arc::clone(&state.auth));
oneclient_core::run_startup_tasks(&state);
let data_dir = oneclient_common::paths::launcher_dir()
.map(|p| p.display().to_string())
.unwrap_or_default();
let settings = state.settings.read().clone();
let auto_update = settings.auto_update;
let mut station = station;
{
let mut guard = station.write_channel(AppChannel::Launcher);
guard.launcher = crate::state::LauncherInit {
ready: true,
fetching: true,
syncing_bundles: true,
error: None,
data_dir,
};
}
{
let mut guard = station.write_channel(AppChannel::Settings);
guard.settings.settings = settings;
guard.settings.status = crate::state::AsyncStatus::Ready;
guard.settings.error = None;
}
crate::hooks::invalidate_profile_queries().await;
crate::updater::spawn_update_check(auto_update);
Ok(())
}
/// Records a startup failure where the user can see it.
pub fn report_startup_failure(
station: &RadioStation<AppState, AppChannel>,
err: &anyhow::Error,
) {
let message = err.to_string();
tracing::error!("launcher init failed: {err:#}");
let mut station = *station;
station
.write_channel(AppChannel::Launcher)
.launcher
.error = Some(message.clone());
let mut guard = station.write_channel(AppChannel::Notifications);
let AppState {
notifications, inbox, ..
} = &mut **guard;
notifications.dispatch(
inbox,
Event::Notification(oneclient_events::Notification::Message(
oneclient_events::Message {
title: "Launcher failed to start".into(),
body: message,
level: oneclient_events::Level::Error,
},
)),
);
}

View file

@ -0,0 +1,872 @@
//! Everything the UI can ask for.
//!
//! A method per operation, each free to return a result. Core operations go
//! onto freya's UI-thread executor, since radio state is `!Send` and anything
//! that writes it must run there; pure UI toggles write it immediately.
//!
//! Always `spawn_forever`, never `spawn`. Freya's `spawn` binds a task to the
//! calling component's scope and cancels it on unmount; these are started from
//! event handlers, so a launch kicked off from a panel that then re-renders
//! would have its log tail and exit watcher killed underneath it. The work is
//! app-scoped, not component-scoped.
use std::path::PathBuf;
use freya::prelude::spawn_forever;
use freya::radio::RadioStation;
use oneclient_cluster::{ClusterStage, ClusterUpdate, GameSettingsProfile, ProfileUpdate};
use oneclient_common::domain::{ContentType, ProviderId};
use oneclient_core::settings::LauncherSettings;
use oneclient_core::settings::store::{save_global_profile, save_settings_and_apply};
use oneclient_db::models::ClusterId;
use oneclient_events::{Answer, Level};
use tokio::sync::mpsc;
use crate::components::IconType;
use crate::launcher;
use crate::notifications::{
ClusterUpdateSummary, NotificationAction, NotificationSpec, PendingPrompt,
};
use crate::state::{AppChannel, AppState, AsyncStatus};
/// Asks the event pump to do something it alone owns.
///
/// The pump holds the toast timers, so an action that adds or removes a toast
/// has to tell it to re-arm; and hover-pause is a property of the timers, not of
/// any state a component reads.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PumpSignal {
/// The notification engine changed; re-arm toast timers.
Reconcile,
PauseToasts,
ResumeToasts,
}
#[derive(Clone)]
pub struct Actions {
station: RadioStation<AppState, AppChannel>,
pump: mpsc::UnboundedSender<PumpSignal>,
}
impl Actions {
#[must_use]
pub fn new(
station: RadioStation<AppState, AppChannel>,
pump: mpsc::UnboundedSender<PumpSignal>,
) -> Self {
Self { station, pump }
}
fn nudge(&self, signal: PumpSignal) {
let _ = self.pump.send(signal);
}
/// Folds a change into the notification engine, then re-arms toast timers.
fn with_engine(&self, mutate: impl FnOnce(&mut AppState)) {
{
let mut guard = self.station.clone().write_channel(AppChannel::Notifications);
mutate(&mut guard);
}
self.nudge(PumpSignal::Reconcile);
}
fn set_settings_error(&self, error: Option<String>) {
self.station
.clone()
.write_channel(AppChannel::Settings)
.settings
.error = error;
}
// --- launcher settings -------------------------------------------------
pub fn reload_settings(&self) {
let station = self.station;
spawn_forever(async move {
let Ok(state) = launcher::state() else { return };
{
let mut guard = station.clone().write_channel(AppChannel::Settings);
guard.settings.status = AsyncStatus::Loading;
guard.settings.error = None;
}
let loaded = oneclient_core::settings::store::load_settings(Some(
&state.services.events,
))
.await;
let discord_enabled = loaded.discord_enabled;
*state.settings.write() = loaded.clone();
state.discord.set_enabled(discord_enabled);
let mut guard = station.clone().write_channel(AppChannel::Settings);
guard.settings.settings = loaded;
guard.settings.status = AsyncStatus::Ready;
guard.settings.error = None;
});
}
pub fn save_settings(&self) {
let actions = self.clone();
spawn_forever(async move {
let Ok(state) = launcher::state() else { return };
{
let mut guard = actions.station.clone().write_channel(AppChannel::Settings);
guard.settings.saving = true;
guard.settings.error = None;
}
let settings = state.settings.read().clone();
let result = save_settings_and_apply(&state.services, &settings).await;
let mut guard = actions.station.clone().write_channel(AppChannel::Settings);
guard.settings.error = result.err().map(|err| err.to_string());
guard.settings.saving = false;
guard.settings.status = AsyncStatus::Ready;
});
}
/// Applies a settings change in memory and publishes it, returning the
/// snapshot to persist.
fn mutate_settings(&self, mutate: impl FnOnce(&mut LauncherSettings)) -> Option<LauncherSettings> {
let state = launcher::state().ok()?;
let updated = {
let mut lock = state.settings.write();
mutate(&mut lock);
lock.clone()
};
let mut guard = self.station.clone().write_channel(AppChannel::Settings);
guard.settings.settings = updated.clone();
guard.settings.status = AsyncStatus::Ready;
Some(updated)
}
fn persist(&self, settings: LauncherSettings) {
let actions = self.clone();
spawn_forever(async move {
let Ok(state) = launcher::state() else { return };
if let Err(err) = save_settings_and_apply(&state.services, &settings).await {
actions.set_settings_error(Some(err.to_string()));
}
});
}
pub fn set_settings(&self, settings: LauncherSettings) {
let Some(updated) = self.mutate_settings(|s| *s = settings) else {
return;
};
if let Ok(state) = launcher::state() {
state.discord.set_enabled(updated.discord_enabled);
}
self.persist(updated);
}
/// Not persisted: "seen" versions are only used to decide whether to show
/// the changelog this run, and the next real save carries them along.
pub fn record_seen_version(&self, version: impl Into<String>) {
let version = version.into();
self.mutate_settings(|settings| {
if !settings.seen_versions.iter().any(|v| v == &version) {
settings.seen_versions.push(version);
}
});
}
pub fn set_seen_changelog_version(&self, version: impl Into<String>) {
let version = version.into();
let mut changed = false;
let updated = self.mutate_settings(|settings| {
if settings.seen_changelog_version.as_deref() != Some(version.as_str()) {
settings.seen_changelog_version = Some(version);
changed = true;
}
});
if let (true, Some(updated)) = (changed, updated) {
self.persist(updated);
}
}
pub fn mark_onboarding_seen(&self) {
if let Some(updated) = self.mutate_settings(|settings| settings.seen_onboarding = true) {
self.persist(updated);
}
}
pub fn accept_tos(&self, terms_version: u32, privacy_version: u32) {
if let Some(updated) = self.mutate_settings(|settings| {
settings.accepted_tos_version = terms_version;
settings.accepted_privacy_version = privacy_version;
}) {
self.persist(updated);
}
}
// --- settings profiles -------------------------------------------------
pub fn save_global_profile(&self, profile: GameSettingsProfile) {
let actions = self.clone();
spawn_forever(async move {
let Ok(state) = launcher::state() else { return };
if let Err(err) = save_global_profile(&state.settings, profile).await {
tracing::error!("failed to save the global profile: {err:#}");
return;
}
actions.mutate_settings(|_| {});
super::invalidate_profile_queries().await;
});
}
pub fn update_global_profile(&self, update: ProfileUpdate) {
let actions = self.clone();
spawn_forever(async move {
let Ok(state) = launcher::state() else { return };
let mut global = state.settings.read().global_game_settings.clone();
update.apply(&mut global);
if let Err(err) = save_global_profile(&state.settings, global).await {
tracing::error!("failed to update the global profile: {err:#}");
return;
}
actions.mutate_settings(|_| {});
super::invalidate_profile_queries().await;
});
}
pub fn create_settings_profile(&self, name: impl Into<String>) {
let name = name.into();
spawn_forever(async move {
let Ok(state) = launcher::state() else { return };
let global = state.settings.read().global_game_settings.clone();
match oneclient_cluster::profiles::create_settings_profile(
&state.services.db,
&global,
&name,
)
.await
{
Ok(_) => super::invalidate_profile_queries().await,
Err(err) => tracing::error!("failed to create settings profile: {err:#}"),
}
});
}
pub fn create_profile_from_global(
&self,
name: impl Into<String>,
mem_max: Option<u32>,
force_fullscreen: Option<bool>,
) {
let name = name.into();
spawn_forever(async move {
let Ok(state) = launcher::state() else { return };
let global = state.settings.read().global_game_settings.clone();
match oneclient_cluster::profiles::create_profile_from_global(
&state.services.db,
&global,
&name,
mem_max,
force_fullscreen,
)
.await
{
Ok(_) => super::invalidate_profile_queries().await,
Err(err) => tracing::error!("failed to create profile: {err:#}"),
}
});
}
pub fn upsert_named_profile(&self, profile: GameSettingsProfile) {
spawn_forever(async move {
let Ok(state) = launcher::state() else { return };
match oneclient_cluster::profiles::upsert_named_profile(&state.services.db, &profile)
.await
{
Ok(_) => super::invalidate_profile_queries().await,
Err(err) => tracing::error!("failed to save profile: {err:#}"),
}
});
}
pub fn update_named_profile(&self, name: impl Into<String>, update: ProfileUpdate) {
let name = name.into();
spawn_forever(async move {
let Ok(state) = launcher::state() else { return };
match oneclient_cluster::profiles::update_named_profile(
&state.services.db,
&name,
update,
)
.await
{
Ok(_) => super::invalidate_profile_queries().await,
Err(err) => tracing::error!("failed to update profile: {err:#}"),
}
});
}
pub fn delete_named_profile(&self, name: impl Into<String>) {
let name = name.into();
spawn_forever(async move {
let Ok(state) = launcher::state() else { return };
match oneclient_cluster::profiles::delete_named_profile(&state.services.db, &name).await
{
Ok(()) => super::invalidate_profile_queries().await,
Err(err) => tracing::error!("failed to delete profile: {err:#}"),
}
});
}
pub fn update_cluster_profile(&self, cluster_id: ClusterId, update: ProfileUpdate) {
spawn_forever(async move {
let Ok(state) = launcher::state() else { return };
match state.clusters.update_profile(cluster_id, update).await {
Ok(_) => super::invalidate_profile_queries().await,
Err(err) => tracing::error!("failed to update cluster profile: {err:#}"),
}
});
}
pub fn create_and_assign_cluster_profile(
&self,
cluster_id: ClusterId,
profile_name: impl Into<String>,
) {
let profile_name = profile_name.into();
spawn_forever(async move {
let Ok(state) = launcher::state() else { return };
let global = state.settings.read().global_game_settings.clone();
match state
.clusters
.create_and_assign_profile(&global, cluster_id, &profile_name)
.await
{
Ok(_) => super::invalidate_profile_queries().await,
Err(err) => tracing::error!("failed to assign cluster profile: {err:#}"),
}
});
}
pub fn set_cluster_loader_version(&self, cluster_id: ClusterId, version: impl Into<String>) {
let version = version.into();
spawn_forever(async move {
let Ok(state) = launcher::state() else { return };
if let Err(err) = state
.clusters
.update(cluster_id, ClusterUpdate::default().loader_version(version))
.await
{
tracing::error!("failed to set loader version: {err:#}");
return;
}
// Best effort: the cluster is still usable if the stage reset fails,
// it just will not re-download on next launch.
let _ = state
.clusters
.set_stage(cluster_id, ClusterStage::NotReady)
.await;
super::invalidate_cluster_queries().await;
});
}
// --- migration ---------------------------------------------------------
pub fn import_launcher(
&self,
source: oneclient_core::MigrationSource,
folder_name: impl Into<String>,
target: oneclient_core::ImportTarget,
) {
let folder_name = folder_name.into();
spawn_forever(async move {
let Ok(state) = launcher::state() else { return };
let events = state.services.events.clone();
match oneclient_core::import_migration_game_dir(&state, source, &folder_name, target)
.await
{
Ok(()) => {
events
.notify("Import complete")
.body(format!("Copied your files from {}.", source.display_name()))
.send();
events.signal(oneclient_events::Signal::ClustersChanged);
}
Err(err) => events
.notify("Import failed")
.body(err.to_string())
.error()
.send(),
}
});
}
// --- java --------------------------------------------------------------
pub fn install_java_runtime(&self, vendor: oneclient_java::JavaVendor, major: u32) {
spawn_forever(async move {
let Ok(state) = launcher::state() else { return };
let events = state.services.events.clone();
match state.java.install_runtime_from(&vendor, major).await {
Ok(_) => events.signal(oneclient_events::Signal::JavaChanged),
Err(err) => events
.notify("Java install failed")
.body(err.to_string())
.error()
.send(),
}
});
}
pub fn add_custom_java_runtime(&self, path: PathBuf) {
spawn_forever(async move {
let Ok(state) = launcher::state() else { return };
let events = state.services.events.clone();
match state.java.add_custom_runtime(path).await {
Ok(runtime) => {
events
.notify("Java added")
.body(format!("Java {} ({})", runtime.major, runtime.vendor))
.send();
events.signal(oneclient_events::Signal::JavaChanged);
}
Err(err) => events
.notify("Failed to add Java")
.body(err.to_string())
.error()
.send(),
}
});
}
pub fn remove_java_runtime(&self, path: impl Into<String>) {
let path = path.into();
spawn_forever(async move {
let Ok(state) = launcher::state() else { return };
match state.java.remove_runtime(&path).await {
Ok(()) => super::invalidate_java_queries().await,
Err(err) => tracing::error!("failed to remove java runtime: {err:#}"),
}
});
}
// --- notifications (pure UI) -------------------------------------------
pub fn toggle_notification_center(&self) {
self.with_engine(|state| {
state.center_open = state
.notifications
.toggle_center(&mut state.inbox, state.center_open);
});
}
pub fn toggle_account_switcher(&self) {
let open = self.station.peek().account_switcher_open;
self.station
.clone()
.write_channel(AppChannel::AccountSwitcher)
.account_switcher_open = !open;
}
pub fn close_account_switcher(&self) {
self.station
.clone()
.write_channel(AppChannel::AccountSwitcher)
.account_switcher_open = false;
}
pub fn close_notification_center(&self) {
self.with_engine(|state| state.center_open = false);
}
pub fn clear_notification_inbox(&self) {
self.with_engine(|state| {
state.inbox.clear();
state.notifications.clear_inbox();
});
}
pub fn dismiss_toast(&self, entry_id: u64) {
self.with_engine(|state| state.notifications.dismiss_toast(&mut state.inbox, entry_id));
}
pub fn mark_notification_read(&self, entry_id: u64) {
self.with_engine(|state| state.notifications.mark_read(&mut state.inbox, entry_id));
}
pub fn dismiss_notification(&self, entry_id: u64) {
self.with_engine(|state| {
state
.notifications
.dismiss_notification(&mut state.inbox, entry_id);
});
}
pub fn open_cluster_update(&self, summaries: Vec<ClusterUpdateSummary>) {
self.with_engine(|state| {
state.notifications.open_cluster_update(summaries);
state.center_open = false;
});
}
pub fn close_cluster_update(&self) {
self.with_engine(|state| state.notifications.close_cluster_update());
}
/// Hovering any toast pauses every toast, including ones that arrive while
/// hovering, which is why this is a pump signal rather than a state flag.
pub fn pause_toasts(&self) {
self.nudge(PumpSignal::PauseToasts);
}
pub fn resume_toasts(&self) {
self.nudge(PumpSignal::ResumeToasts);
}
pub fn answer_prompt(&self, answer: Answer) {
self.reply_to_prompt(Some(answer));
}
pub fn dismiss_prompt(&self) {
self.reply_to_prompt(None);
}
fn reply_to_prompt(&self, answer: Option<Answer>) {
let taken = self
.station
.clone()
.write_channel(AppChannel::Notifications)
.prompt
.take();
if let Some(PendingPrompt {
reply_tx: Some(reply_tx),
..
}) = taken
{
let _ = reply_tx.send(answer);
}
}
pub fn notify(&self, title: impl Into<String>) -> NotificationBuilder {
NotificationBuilder {
actions: self.clone(),
spec: NotificationSpec {
title: title.into(),
body: String::new(),
level: Level::Info,
icon: None,
progress: None,
actions: Vec::new(),
},
}
}
fn push_notification(&self, spec: NotificationSpec) {
self.with_engine(|state| {
state.notifications.push_custom(&mut state.inbox, spec);
});
}
/// Debug-only: drives a fake progress notification through the real path.
pub fn send_test_progress(&self, current: u64, total: u64) {
let Ok(state) = launcher::state() else { return };
let id = uuid::Uuid::from_u128(0x0CE0_0CE0_0CE0_0CE0_0CE0_0CE0_0CE0_0CE0);
state
.services
.events
.progress(id, "Downloading assets", current, total);
}
// --- game --------------------------------------------------------------
pub fn launch_cluster(&self, cluster_id: ClusterId) {
spawn_forever(async move {
let Ok(state) = launcher::state() else { return };
let events = state.services.events.clone();
// Renews a lapsed token before launching.
let account = match state.auth.default_account_for_launch().await {
Ok(account) => account,
Err(err) => {
events.game_failed(cluster_id, format!("{err:#}"));
return;
}
};
match account {
Some(account) => {
if let Err(err) =
oneclient_core::launch_cluster(&state, cluster_id, &account, true).await
{
events.game_failed(cluster_id, format!("{err:#}"));
}
}
None => events.game_failed(
cluster_id,
"Add a Minecraft account before launching.".to_string(),
),
}
});
}
pub fn kill_cluster(&self, cluster_id: ClusterId) {
let Ok(state) = launcher::state() else { return };
if !state.games.kill(cluster_id) {
tracing::debug!(cluster_id, "kill requested but no tracked process");
}
}
pub fn dismiss_game_error(&self) {
self.station
.clone()
.write_channel(AppChannel::Game)
.game
.error = None;
}
// --- content -----------------------------------------------------------
pub fn import_local_file(&self, cluster_id: ClusterId, content_type: ContentType, path: PathBuf) {
spawn_forever(async move {
let Ok(state) = launcher::state() else { return };
let events = state.services.events.clone();
match oneclient_content::packages::PackageStore::import_local_file(
&path,
content_type,
cluster_id,
&state.services.content(),
)
.await
{
Ok(row) => {
events
.notify("Imported")
.body(format!("Added {}", row.file_name))
.send();
super::invalidate_cluster_queries().await;
}
Err(err) => events
.notify("Import failed")
.body(err.to_string())
.error()
.send(),
}
});
}
pub fn install_package(
&self,
cluster_id: ClusterId,
provider: ProviderId,
project_id: impl Into<String>,
version_id: impl Into<String>,
) {
let (project_id, version_id) = (project_id.into(), version_id.into());
spawn_forever(async move {
let Ok(state) = launcher::state() else { return };
let events = state.services.events.clone();
match crate::install::install_package(
&state,
provider,
&project_id,
&version_id,
cluster_id,
)
.await
{
Ok(name) => {
events.notify("Installed").body(format!("Added {name}")).send();
events.signal(oneclient_events::Signal::ClustersChanged);
}
Err(err) => events
.notify("Install failed")
.body(err.to_string())
.error()
.send(),
}
});
}
pub fn install_bundle(
&self,
cluster_id: ClusterId,
bundle_name: impl Into<String>,
skip_compatibility: bool,
) {
let bundle_name = bundle_name.into();
spawn_forever(async move {
let Ok(state) = launcher::state() else { return };
let events = state.services.events.clone();
match oneclient_core::install_bundle(
cluster_id,
&bundle_name,
skip_compatibility,
state.bundles.as_ref(),
&state.services.content(),
)
.await
{
Ok(_) => {
events
.notify("Installed")
.body(format!("Added {bundle_name}"))
.send();
events.signal(oneclient_events::Signal::ClustersChanged);
}
Err(err) => events
.notify("Install failed")
.body(err.to_string())
.error()
.send(),
}
});
}
pub fn apply_bundle_updates(&self, cluster_id: ClusterId) {
let actions = self.clone();
spawn_forever(async move {
let Ok(state) = launcher::state() else { return };
let result = match oneclient_core::apply_bundle_updates(
cluster_id,
state.bundles.as_ref(),
&state.services.content(),
)
.await
{
Ok(result) => result,
Err(err) => {
state
.services
.events
.notify("Update failed")
.body(err.to_string())
.error()
.send();
return;
}
};
super::invalidate_cluster_queries().await;
if let Some(spec) =
crate::install::cluster_update_notification(cluster_id, &result, &state.services)
.await
{
actions.push_notification(spec);
}
});
}
/// Syncs the bundle catalog and brings every cluster up to date.
///
/// `syncing_bundles` gates every launch button, so the flag must not be
/// raised unless this can actually clear it, hence the readiness check before
/// the write rather than inside the task.
pub fn sync_bundles(&self) {
let state = match launcher::state() {
Ok(state) => state,
Err(err) => {
tracing::error!("bundle sync skipped, launcher not ready: {err:#}");
return;
}
};
let actions = self.clone();
self.station
.clone()
.write_channel(AppChannel::Launcher)
.launcher
.syncing_bundles = true;
spawn_forever(async move {
if let Err(err) = state.bundles.sync(&state.services.content()).await {
tracing::error!("bundle catalog sync failed: {err:#}");
}
if let Err(err) = oneclient_core::clusters::apply_remote_migrations(&state).await {
tracing::error!("cluster migrations failed: {err:#}");
}
if let Err(err) = oneclient_core::clusters::ensure_from_bundles(&state).await {
tracing::error!("bundle cluster provisioning failed: {err:#}");
}
// One shared grouped session for the whole batch, so every cluster's
// downloads surface as a single "update in progress" notification.
// `detach` hands that entry over so it can be converted to the
// finished "view changes" state in place rather than replaced.
let session = oneclient_events::GroupedProgressSession::start(
&state.services.events,
"Updating mods",
);
let changed = oneclient_content::bundles::sync_all_cluster_bundles(
state.bundles.as_ref(),
&state.services.content(),
Some(&session),
)
.await;
let session_id = session.detach();
actions
.station
.clone()
.write_channel(AppChannel::Launcher)
.launcher
.syncing_bundles = false;
super::invalidate_cluster_queries().await;
let spec = crate::install::combined_cluster_update_spec(&changed, &state.services).await;
actions.with_engine(|app| {
app.notifications
.finish_grouped_as_actions(&mut app.inbox, session_id, spec);
});
});
}
}
#[must_use = "the notification is not raised until `.send()` is called"]
pub struct NotificationBuilder {
actions: Actions,
spec: NotificationSpec,
}
impl NotificationBuilder {
pub fn body(mut self, body: impl Into<String>) -> Self {
self.spec.body = body.into();
self
}
pub fn level(mut self, level: Level) -> Self {
self.spec.level = level;
self
}
pub fn info(self) -> Self {
self.level(Level::Info)
}
pub fn error(self) -> Self {
self.level(Level::Error)
}
pub fn icon(mut self, icon: IconType) -> Self {
self.spec.icon = Some(icon);
self
}
pub fn progress(mut self, current: u64, total: u64) -> Self {
self.spec.progress = Some((current, total));
self
}
pub fn action(mut self, action: NotificationAction) -> Self {
self.spec.actions.push(action);
self
}
pub fn actions(mut self, actions: impl IntoIterator<Item = NotificationAction>) -> Self {
self.spec.actions = actions.into_iter().collect();
self
}
pub fn send(self) {
self.actions.push_notification(self.spec);
}
}

View file

@ -1,7 +1,7 @@
use std::collections::{HashMap, HashSet};
use freya::prelude::*;
use oneclient_core::packages::ProviderId;
use oneclient_content::packages::ProviderId;
use oneclient_db::models::ClusterId;
#[derive(Clone)]
@ -17,7 +17,7 @@ pub fn use_active_cluster_id() -> State<Option<ClusterId>> {
/// Drives the startup splash "curtain": a full-screen cover that stays up after
/// the router leaves the Startup route until the home view's clusters/art have
/// settled, then fades out — so the app never reveals a half-populated home.
/// settled, then fades out, so the app never reveals a half-populated home.
#[derive(Clone, Copy)]
pub struct SplashState {
/// The curtain is covering the screen (raised when heading into Home).

View file

@ -1,326 +0,0 @@
use oneclient_core::notification::{NotificationLevel, UserChoice};
use oneclient_core::settings::{GameSettingsProfile, LauncherSettings, ProfileUpdate};
use oneclient_db::models::ClusterId;
use crate::bridge::{BridgeCommand, OneClientBridge};
use crate::components::IconType;
use crate::notifications::{ClusterUpdateSummary, NotificationAction, NotificationSpec};
#[derive(Clone)]
pub struct BridgeDispatch {
bridge: OneClientBridge,
}
impl BridgeDispatch {
pub(crate) fn new(bridge: OneClientBridge) -> Self {
Self { bridge }
}
pub fn send(&self, command: BridgeCommand) {
self.bridge.send(command);
}
pub fn reload_settings(&self) {
self.send(BridgeCommand::ReloadSettings);
}
pub fn save_settings(&self) {
self.send(BridgeCommand::SaveSettings);
}
pub fn set_settings(&self, settings: LauncherSettings) {
self.send(BridgeCommand::SetSettings { settings });
}
pub fn record_seen_version(&self, version: impl Into<String>) {
self.send(BridgeCommand::RecordSeenVersion {
version: version.into(),
});
}
pub fn set_seen_changelog_version(&self, version: impl Into<String>) {
self.send(BridgeCommand::SetSeenChangelogVersion {
version: version.into(),
});
}
pub fn mark_onboarding_seen(&self) {
self.send(BridgeCommand::MarkOnboardingSeen);
}
pub fn accept_tos(&self, terms_version: u32, privacy_version: u32) {
self.send(BridgeCommand::AcceptTos {
terms_version,
privacy_version,
});
}
pub fn import_launcher(
&self,
source: oneclient_core::MigrationSource,
folder_name: impl Into<String>,
target: oneclient_core::ImportTarget,
) {
self.send(BridgeCommand::ImportLauncher {
source,
folder_name: folder_name.into(),
target,
});
}
pub fn save_global_profile(&self, profile: GameSettingsProfile) {
self.send(BridgeCommand::SaveGlobalProfile { profile });
}
pub fn update_global_profile(&self, update: ProfileUpdate) {
self.send(BridgeCommand::UpdateGlobalProfile { update });
}
pub fn create_settings_profile(&self, name: impl Into<String>) {
self.send(BridgeCommand::CreateSettingsProfile { name: name.into() });
}
pub fn create_profile_from_global(
&self,
name: impl Into<String>,
mem_max: Option<u32>,
force_fullscreen: Option<bool>,
) {
self.send(BridgeCommand::CreateProfileFromGlobal {
name: name.into(),
mem_max,
force_fullscreen,
});
}
pub fn upsert_named_profile(&self, profile: GameSettingsProfile) {
self.send(BridgeCommand::UpsertNamedProfile { profile });
}
pub fn update_named_profile(&self, name: impl Into<String>, update: ProfileUpdate) {
self.send(BridgeCommand::UpdateNamedProfile {
name: name.into(),
update,
});
}
pub fn delete_named_profile(&self, name: impl Into<String>) {
self.send(BridgeCommand::DeleteNamedProfile { name: name.into() });
}
pub fn update_cluster_profile(&self, cluster_id: ClusterId, update: ProfileUpdate) {
self.send(BridgeCommand::UpdateClusterProfile { cluster_id, update });
}
pub fn create_and_assign_cluster_profile(
&self,
cluster_id: ClusterId,
profile_name: impl Into<String>,
) {
self.send(BridgeCommand::CreateAndAssignClusterProfile {
cluster_id,
profile_name: profile_name.into(),
});
}
pub fn set_cluster_loader_version(&self, cluster_id: ClusterId, version: impl Into<String>) {
self.send(BridgeCommand::SetClusterLoaderVersion {
cluster_id,
version: version.into(),
});
}
pub fn install_java_runtime(&self, vendor: oneclient_core::java::JavaVendor, major: u32) {
self.send(BridgeCommand::InstallJavaRuntime { vendor, major });
}
pub fn add_custom_java_runtime(&self, path: std::path::PathBuf) {
self.send(BridgeCommand::AddCustomJavaRuntime { path });
}
pub fn remove_java_runtime(&self, path: impl Into<String>) {
self.send(BridgeCommand::RemoveJavaRuntime { path: path.into() });
}
pub fn toggle_notification_center(&self) {
self.send(BridgeCommand::ToggleNotificationCenter);
}
pub fn close_notification_center(&self) {
self.send(BridgeCommand::CloseNotificationCenter);
}
pub fn toggle_account_switcher(&self) {
self.send(BridgeCommand::ToggleAccountSwitcher);
}
pub fn close_account_switcher(&self) {
self.send(BridgeCommand::CloseAccountSwitcher);
}
pub fn clear_notification_inbox(&self) {
self.send(BridgeCommand::ClearNotificationInbox);
}
pub fn dismiss_toast(&self, entry_id: u64) {
self.send(BridgeCommand::DismissToast(entry_id));
}
pub fn pause_toasts(&self) {
self.send(BridgeCommand::PauseToasts);
}
pub fn resume_toasts(&self) {
self.send(BridgeCommand::ResumeToasts);
}
pub fn mark_notification_read(&self, entry_id: u64) {
self.send(BridgeCommand::MarkNotificationRead(entry_id));
}
pub fn dismiss_notification(&self, entry_id: u64) {
self.send(BridgeCommand::DismissNotification(entry_id));
}
pub fn answer_prompt(&self, choice: UserChoice) {
self.send(BridgeCommand::AnswerPrompt(choice));
}
pub fn open_cluster_update(&self, summaries: Vec<ClusterUpdateSummary>) {
self.send(BridgeCommand::OpenClusterUpdate(summaries));
}
pub fn close_cluster_update(&self) {
self.send(BridgeCommand::CloseClusterUpdate);
}
pub fn notify(&self, title: impl Into<String>) -> NotificationBuilder {
NotificationBuilder {
dispatch: self.clone(),
spec: NotificationSpec {
title: title.into(),
body: String::new(),
level: NotificationLevel::Info,
icon: None,
progress: None,
actions: Vec::new(),
},
}
}
pub fn send_test_progress(&self, current: u64, total: u64) {
self.send(BridgeCommand::SendTestProgress { current, total });
}
pub fn launch_cluster(&self, cluster_id: ClusterId) {
self.send(BridgeCommand::LaunchCluster { cluster_id });
}
pub fn kill_cluster(&self, cluster_id: ClusterId) {
self.send(BridgeCommand::KillCluster { cluster_id });
}
pub fn dismiss_game_error(&self) {
self.send(BridgeCommand::DismissGameError);
}
pub fn import_local_file(
&self,
cluster_id: ClusterId,
content_type: oneclient_core::packages::ContentType,
path: std::path::PathBuf,
) {
self.send(BridgeCommand::ImportLocalFile {
cluster_id,
content_type,
path,
});
}
pub fn install_package(
&self,
cluster_id: ClusterId,
provider: oneclient_core::packages::ProviderId,
project_id: impl Into<String>,
version_id: impl Into<String>,
) {
self.send(BridgeCommand::InstallPackage {
cluster_id,
provider,
project_id: project_id.into(),
version_id: version_id.into(),
});
}
pub fn install_bundle(
&self,
cluster_id: ClusterId,
bundle_name: impl Into<String>,
skip_compatibility: bool,
) {
self.send(BridgeCommand::InstallBundle {
cluster_id,
bundle_name: bundle_name.into(),
skip_compatibility,
});
}
pub fn apply_bundle_updates(&self, cluster_id: ClusterId) {
self.send(BridgeCommand::ApplyBundleUpdates { cluster_id });
}
pub fn sync_bundles(&self) {
self.send(BridgeCommand::SyncBundles);
}
}
#[must_use = "the notification is not dispatched until `.send()` is called"]
pub struct NotificationBuilder {
dispatch: BridgeDispatch,
spec: NotificationSpec,
}
impl NotificationBuilder {
pub fn body(mut self, body: impl Into<String>) -> Self {
self.spec.body = body.into();
self
}
pub fn level(mut self, level: NotificationLevel) -> Self {
self.spec.level = level;
self
}
pub fn info(self) -> Self {
self.level(NotificationLevel::Info)
}
pub fn error(self) -> Self {
self.level(NotificationLevel::Error)
}
pub fn icon(mut self, icon: IconType) -> Self {
self.spec.icon = Some(icon);
self
}
pub fn progress(mut self, current: u64, total: u64) -> Self {
self.spec.progress = Some((current, total));
self
}
pub fn action(mut self, action: NotificationAction) -> Self {
self.spec.actions.push(action);
self
}
pub fn actions(mut self, actions: impl IntoIterator<Item = NotificationAction>) -> Self {
self.spec.actions = actions.into_iter().collect();
self
}
pub fn send(self) {
self.dispatch
.send(BridgeCommand::SendNotification { spec: self.spec });
}
}

View file

@ -1,6 +1,6 @@
mod active_cluster;
mod debounce;
mod dispatch;
mod actions;
mod queries;
mod view_state;
@ -15,7 +15,7 @@ pub use active_cluster::{
use_provide_onboarding_selection, use_provide_splash, use_splash,
};
pub use dispatch::BridgeDispatch;
pub use actions::{Actions, NotificationBuilder, PumpSignal};
pub use queries::{
AddOfflineAccountKeys, BROWSE_PAGE_SIZE, BeginMicrosoftLoginMutation, CachedImageQuery,
CancelMicrosoftLoginKeys, CancelMicrosoftLoginMutation, ClusterAction, ClusterBundles,
@ -27,18 +27,19 @@ pub use queries::{
category_list, changelog_error, changelog_groups, changelog_is_loading, cluster_content_items,
content_type_for_slug, has_migration_data, invalidate_cluster_queries, invalidate_java_queries,
invalidate_logs_queries, invalidate_profile_queries, invalidate_screenshots_queries,
java_runtimes, latest_changelog_version, loader_versions, login_code_already_handled,
migration_detection,
java_runtimes, latest_changelog_version, loaded_image, loader_versions,
login_code_already_handled, migration_detection,
mutation_error, mutation_is_pending, onboarding_bundles_items, package_meta_batch,
pick_version_metadata, project_detail, provider_versions, reset_login_code_dedup, search_items,
search_pending, search_total, terms_document, terms_error, terms_is_loading, try_account,
pick_version_metadata, project_detail, provider_versions, query_error, query_is_busy,
query_is_loading, reset_login_code_dedup, search_items, search_pending, search_total,
settled_or_loading, terms_document, terms_error, terms_is_loading, try_account,
try_accounts, try_cluster_analytics, try_cluster_logs, try_cluster_screenshots,
try_default_account, try_game_profile, try_global_analytics, try_log_content, use_account,
use_accounts, use_add_microsoft_account, use_add_offline_account, use_begin_microsoft_login,
use_bundle_overrides, use_bundle_updates, use_bundles_with_status, use_cached_image,
use_cancel_microsoft_login, use_changelog, use_cluster_analytics, use_cluster_content,
use_cluster_logs, use_cluster_mutation, use_cluster_profile, use_cluster_screenshots,
use_cluster_settings, use_clusters, use_current_account, use_default_account,
use_cluster, use_cluster_settings, use_clusters, use_current_account, use_default_account,
use_finish_microsoft_login, use_game_profile, use_global_analytics, use_java_runtimes,
use_loader_versions, use_local_image, use_log_action, use_log_content, use_migration,
use_named_profiles, use_onboarding_bundles, use_package_categories, use_package_meta_batch,
@ -48,97 +49,63 @@ pub use queries::{
use_version_metadata, use_versions, version_list, versions_metadata, versions_total,
};
use crate::{
bridge::{
BridgeSnapshot, ClustersSnapshot, GameSnapshot, JavaSnapshot, LauncherInit,
OneClientBridge, ProfilesSnapshot, SettingsSnapshot, use_bridge_snapshot,
},
notifications::NotificationSnapshot,
};
use crate::notifications::NotificationSnapshot;
use crate::state::{AppChannel, GameState, LauncherInit, LoginProgress, SettingsState};
use freya::prelude::*;
use freya::radio::use_radio;
pub fn use_provide_bridge(bridge: &OneClientBridge) {
let bridge = bridge.clone();
use_provide_root_context(move || bridge.clone());
/// Publishes the actions handle so components can reach it without prop
/// drilling. Provided once, at the root.
pub fn use_provide_actions(actions: &Actions) {
let actions = actions.clone();
use_provide_root_context(move || actions.clone());
}
pub fn use_bridge() -> OneClientBridge {
consume_root_context::<OneClientBridge>()
}
pub fn use_snapshots() -> BridgeSnapshot {
let bridge = use_bridge();
use_bridge_snapshot(&bridge)
}
pub fn use_dispatch() -> BridgeDispatch {
let bridge = use_bridge();
use_hook(move || BridgeDispatch::new(bridge.clone()))
pub fn use_dispatch() -> Actions {
consume_root_context::<Actions>()
}
/// Subscribes to one concern of the app state.
///
/// Each of these wakes its component only when *that* channel is written, so a
/// toast timer tick does not re-render a component that reads only `data_dir`.
pub fn use_launcher() -> LauncherInit {
use_snapshots().launcher
use_radio(AppChannel::Launcher).read().launcher.clone()
}
pub fn use_settings_snapshot() -> SettingsSnapshot {
use_snapshots().settings
}
pub fn use_profiles_snapshot() -> ProfilesSnapshot {
use_snapshots().profiles
}
pub fn use_clusters_snapshot() -> ClustersSnapshot {
use_snapshots().clusters
pub fn use_settings_snapshot() -> SettingsState {
use_radio(AppChannel::Settings).read().settings.clone()
}
/// Derives the render view from the engine.
///
/// Built on read rather than published on write: during a download the engine
/// changes tens of thousands of times, and cloning the inbox each time was the
/// snapshot channel's main cost.
pub fn use_notifications_snapshot() -> NotificationSnapshot {
use_snapshots().notifications
let radio = use_radio(AppChannel::Notifications);
let state = radio.read();
state.notifications.snapshot(
&state.inbox,
state.center_open,
crate::events::prompt_view(&state),
)
}
pub fn use_account_switcher_open() -> bool {
use_snapshots().account_switcher_open
use_radio(AppChannel::AccountSwitcher)
.read()
.account_switcher_open
}
pub fn use_game_snapshot() -> GameSnapshot {
use_snapshots().game
pub fn use_game_snapshot() -> GameState {
use_radio(AppChannel::Game).read().game.clone()
}
pub fn use_microsoft_login_status() -> Option<oneclient_core::notification::MicrosoftLoginStatus> {
use_snapshots().microsoft_login
pub fn use_microsoft_login_status() -> Option<LoginProgress> {
use_radio(AppChannel::MicrosoftLogin)
.read()
.microsoft_login
.clone()
}
pub fn use_java_snapshot() -> JavaSnapshot {
use_snapshots().java
}
#[derive(PartialEq)]
pub struct DataSync;
impl Component for DataSync {
fn render(&self) -> impl IntoElement {
let clusters_pulse = use_clusters_snapshot().generation;
let profiles_pulse = use_profiles_snapshot().generation;
let java_pulse = use_java_snapshot().generation;
let mut last_clusters = use_state(|| clusters_pulse);
let mut last_profiles = use_state(|| profiles_pulse);
let mut last_java = use_state(|| java_pulse);
if *last_clusters.peek() != clusters_pulse {
last_clusters.set(clusters_pulse);
spawn(async move { invalidate_cluster_queries().await });
}
if *last_profiles.peek() != profiles_pulse {
last_profiles.set(profiles_pulse);
spawn(async move { invalidate_profile_queries().await });
}
if *last_java.peek() != java_pulse {
last_java.set(java_pulse);
spawn(async move { invalidate_java_queries().await });
}
rect().width(Size::px(0.)).height(Size::px(0.))
}
}

View file

@ -1,4 +1,4 @@
use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query};
use freya::query::{Query, QueryCapability, UseQuery, use_query};
use oneclient_core::LauncherError;
use oneclient_core::game::{Analytics, cluster_analytics, global_analytics};
@ -14,7 +14,8 @@ impl QueryCapability for GlobalAnalyticsQuery {
type Keys = GlobalAnalyticsKeys;
async fn run(&self, _keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
global_analytics().await
let state = crate::launcher::state()?;
global_analytics(&state.services.db).await
}
}
@ -23,13 +24,7 @@ pub fn use_global_analytics() -> UseQuery<GlobalAnalyticsQuery> {
}
pub fn try_global_analytics(query: &UseQuery<GlobalAnalyticsQuery>) -> Option<Analytics> {
match &*query.read().state() {
QueryStateData::Settled { res: Ok(value), .. } => Some(value.clone()),
QueryStateData::Loading {
res: Some(Ok(value)),
} => Some(value.clone()),
_ => None,
}
super::state::settled_or_loading(query)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@ -46,7 +41,8 @@ impl QueryCapability for ClusterAnalyticsQuery {
type Keys = ClusterAnalyticsKeys;
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
cluster_analytics(keys.cluster_id).await
let state = crate::launcher::state()?;
cluster_analytics(&state.services.db, keys.cluster_id).await
}
}
@ -58,11 +54,5 @@ pub fn use_cluster_analytics(cluster_id: i64) -> UseQuery<ClusterAnalyticsQuery>
}
pub fn try_cluster_analytics(query: &UseQuery<ClusterAnalyticsQuery>) -> Option<Analytics> {
match &*query.read().state() {
QueryStateData::Settled { res: Ok(value), .. } => Some(value.clone()),
QueryStateData::Loading {
res: Some(Ok(value)),
} => Some(value.clone()),
_ => None,
}
super::state::settled_or_loading(query)
}

View file

@ -1,11 +1,10 @@
use std::sync::Mutex;
use freya::query::{
Mutation, MutationCapability, MutationStateData, QueriesStorage, Query, QueryCapability,
QueryStateData, UseMutation, UseQuery, use_mutation, use_query,
Mutation, MutationCapability, MutationStateData, QueriesStorage, Query, QueryCapability, UseMutation, UseQuery, use_mutation, use_query,
};
use oneclient_core::LauncherError;
use oneclient_core::auth::{self, AccountKind, MicrosoftLoginSession, MinecraftAccount};
use oneclient_auth::{AccountKind, MicrosoftLoginSession, MinecraftAccount};
use uuid::Uuid;
static HANDLED_LOGIN_CODE: Mutex<Option<String>> = Mutex::new(None);
@ -45,7 +44,7 @@ impl QueryCapability for ListAccountsQuery {
type Keys = ListAccountsKeys;
async fn run(&self, _keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
auth::list_accounts().await
Ok(crate::launcher::state()?.auth.list_accounts().await)
}
}
@ -58,12 +57,13 @@ impl QueryCapability for DefaultAccountQuery {
type Keys = DefaultAccountKeys;
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let account = auth::get_default_account().await?;
let state = crate::launcher::state()?;
let account = state.auth.default_account().await?;
if account.is_some() || !keys.fallback {
return Ok(account);
}
let accounts = auth::list_accounts().await?;
let accounts = state.auth.list_accounts().await;
Ok(accounts.into_iter().next())
}
}
@ -77,7 +77,7 @@ impl QueryCapability for AccountQuery {
type Keys = AccountKeys;
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
auth::get_account(keys.id).await
Ok(crate::launcher::state()?.auth.get_account(keys.id).await)
}
}
@ -101,30 +101,15 @@ pub fn use_account(id: Uuid) -> UseQuery<AccountQuery> {
}
pub fn try_accounts(query: &UseQuery<ListAccountsQuery>) -> Option<Vec<MinecraftAccount>> {
settled_ok(query)
super::state::settled_or_loading(query)
}
pub fn try_default_account(query: &UseQuery<DefaultAccountQuery>) -> Option<MinecraftAccount> {
settled_ok(query).flatten()
super::state::settled_or_loading(query).flatten()
}
pub fn try_account(query: &UseQuery<AccountQuery>) -> Option<MinecraftAccount> {
settled_ok(query).flatten()
}
fn settled_ok<Q>(query: &UseQuery<Q>) -> Option<Q::Ok>
where
Q: QueryCapability,
Q::Ok: Clone,
{
let reader = query.read();
match &*reader.state() {
QueryStateData::Settled { res: Ok(value), .. } => Some(value.clone()),
QueryStateData::Loading {
res: Some(Ok(value)),
} => Some(value.clone()),
_ => None,
}
super::state::settled_or_loading(query).flatten()
}
async fn invalidate_auth_queries(account_id: Option<Uuid>) {
@ -149,7 +134,7 @@ impl MutationCapability for BeginMicrosoftLoginMutation {
type Keys = ();
async fn run(&self, _keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
auth::begin_microsoft_login().await
Ok(crate::launcher::state()?.auth.begin_microsoft_login().await?)
}
}
@ -162,7 +147,7 @@ impl MutationCapability for FinishMicrosoftLoginMutation {
type Keys = MicrosoftLoginSession;
async fn run(&self, flow: &Self::Keys) -> Result<Self::Ok, Self::Err> {
auth::finish_microsoft_login(flow.clone()).await
Ok(crate::launcher::state()?.auth.finish_microsoft_login(flow.clone()).await?)
}
async fn on_settled(&self, _keys: &Self::Keys, result: &Result<Self::Ok, Self::Err>) {
@ -188,7 +173,7 @@ impl MutationCapability for CancelMicrosoftLoginMutation {
type Keys = CancelMicrosoftLoginKeys;
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
auth::cancel_microsoft_login(&keys.state_token).await;
crate::launcher::state()?.auth.cancel_microsoft_login(&keys.state_token).await;
Ok(())
}
}
@ -202,8 +187,9 @@ impl MutationCapability for AddMicrosoftAccountMutation {
type Keys = ();
async fn run(&self, _keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let flow = auth::begin_microsoft_login().await?;
auth::finish_microsoft_login(flow).await
let state = crate::launcher::state()?;
let flow = state.auth.begin_microsoft_login().await?;
Ok(state.auth.finish_microsoft_login(flow).await?)
}
async fn on_settled(&self, _keys: &Self::Keys, result: &Result<Self::Ok, Self::Err>) {
@ -227,7 +213,7 @@ impl MutationCapability for AddOfflineAccountMutation {
type Keys = AddOfflineAccountKeys;
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
auth::add_offline_account(keys.username.clone()).await
Ok(crate::launcher::state()?.auth.add_offline_account(keys.username.clone()).await?)
}
async fn on_settled(&self, _keys: &Self::Keys, result: &Result<Self::Ok, Self::Err>) {
@ -251,7 +237,7 @@ impl MutationCapability for RemoveAccountMutation {
type Keys = RemoveAccountKeys;
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
auth::remove_account(keys.id).await
Ok(crate::launcher::state()?.auth.remove_account(keys.id).await?)
}
async fn on_settled(&self, keys: &Self::Keys, result: &Result<Self::Ok, Self::Err>) {
@ -275,7 +261,7 @@ impl MutationCapability for SetDefaultAccountMutation {
type Keys = SetDefaultAccountKeys;
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
auth::set_default_account(keys.id).await
Ok(crate::launcher::state()?.auth.set_default_account(keys.id).await?)
}
async fn on_settled(&self, keys: &Self::Keys, result: &Result<Self::Ok, Self::Err>) {
@ -299,7 +285,7 @@ impl MutationCapability for RefreshAccountMutation {
type Keys = RefreshAccountKeys;
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
auth::refresh_account(keys.id).await
Ok(crate::launcher::state()?.auth.refresh_account(keys.id).await?)
}
async fn on_settled(&self, keys: &Self::Keys, result: &Result<Self::Ok, Self::Err>) {
@ -318,7 +304,7 @@ impl MutationCapability for RefreshAllAccountsMutation {
type Keys = ();
async fn run(&self, _keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
auth::refresh_all_accounts().await
Ok(crate::launcher::state()?.auth.refresh_all_accounts().await?)
}
async fn on_settled(&self, _keys: &Self::Keys, result: &Result<Self::Ok, Self::Err>) {

View file

@ -1,9 +1,9 @@
use std::collections::HashMap;
use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query};
use oneclient_core::clusters::{Cluster, ClusterManager};
use freya::query::{Query, QueryCapability, UseQuery, use_query};
use oneclient_core::clusters::Cluster;
use oneclient_core::{
BundleArchive, BundleUpdateCheckResult, BundleWithUpdateStatus, LauncherError, LauncherState,
BundleArchive, BundleUpdateCheckResult, BundleWithUpdateStatus, LauncherError,
get_bundles_with_update_status, list_cluster_bundle_overrides,
};
use oneclient_db::models::ClusterId;
@ -26,14 +26,14 @@ impl QueryCapability for OnboardingBundlesQuery {
type Keys = OnboardingBundlesKeys;
async fn run(&self, _keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let state = LauncherState::get()?;
let clusters = ClusterManager::list(&state).await?;
let state = crate::launcher::state()?;
let clusters = state.clusters.list().await?;
let mut out = Vec::with_capacity(clusters.len());
for cluster in clusters {
let archives = state
.bundles
.archives_for(&state.services, &cluster.mc_version, cluster.mc_loader)
.archives_for(&state.services.content(), &cluster.mc_version, cluster.mc_loader)
.await
.unwrap_or_default();
out.push(ClusterBundles { cluster, archives });
@ -49,14 +49,7 @@ pub fn use_onboarding_bundles() -> UseQuery<OnboardingBundlesQuery> {
pub fn onboarding_bundles_items(
query: &UseQuery<OnboardingBundlesQuery>,
) -> Option<Vec<ClusterBundles>> {
let reader = query.read();
match &*reader.state() {
QueryStateData::Settled { res: Ok(list), .. } => Some(list.clone()),
QueryStateData::Loading {
res: Some(Ok(list)),
} => Some(list.clone()),
_ => None,
}
super::state::settled_or_loading(query)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@ -76,23 +69,22 @@ impl QueryCapability for BundlesWithStatusQuery {
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let _ = keys;
let state = LauncherState::get()?;
get_bundles_with_update_status(self.cluster_id, state.bundles.as_ref(), &state.services)
.await
let state = crate::launcher::state()?;
Ok(
get_bundles_with_update_status(
self.cluster_id,
state.bundles.as_ref(),
&state.services.content(),
)
.await?,
)
}
}
pub fn bundles_with_status_items(
query: &UseQuery<BundlesWithStatusQuery>,
) -> Vec<BundleWithUpdateStatus> {
let reader = query.read();
match &*reader.state() {
QueryStateData::Settled { res: Ok(list), .. } => list.clone(),
QueryStateData::Loading {
res: Some(Ok(list)),
} => list.clone(),
_ => Vec::new(),
}
super::state::settled_or_loading(query).unwrap_or_default()
}
pub fn use_bundles_with_status(cluster_id: ClusterId) -> UseQuery<BundlesWithStatusQuery> {
@ -119,8 +111,8 @@ impl QueryCapability for BundleOverridesQuery {
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let _ = keys;
let state = LauncherState::get()?;
let rows = list_cluster_bundle_overrides(self.cluster_id, &state.services).await?;
let state = crate::launcher::state()?;
let rows = list_cluster_bundle_overrides(self.cluster_id, &state.services.content()).await?;
Ok(rows
.into_iter()
.map(|(bundle, pkg, ty)| ((bundle, pkg), ty))
@ -138,12 +130,7 @@ pub fn use_bundle_overrides(cluster_id: ClusterId) -> UseQuery<BundleOverridesQu
pub fn bundle_overrides_map(
query: &UseQuery<BundleOverridesQuery>,
) -> HashMap<(String, String), String> {
let reader = query.read();
match &*reader.state() {
QueryStateData::Settled { res: Ok(map), .. } => map.clone(),
QueryStateData::Loading { res: Some(Ok(map)) } => map.clone(),
_ => HashMap::new(),
}
super::state::settled_or_loading(query).unwrap_or_default()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@ -163,13 +150,13 @@ impl QueryCapability for BundleUpdatesQuery {
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let _ = keys;
let state = LauncherState::get()?;
oneclient_core::check_bundle_updates(
let state = crate::launcher::state()?;
Ok(oneclient_core::check_bundle_updates(
self.cluster_id,
state.bundles.as_ref(),
&state.services,
&state.services.content(),
)
.await
.await?)
}
}

View file

@ -1,10 +1,8 @@
use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query};
use freya::query::{Query, QueryCapability, UseQuery, use_query};
use oneclient_core::{
ChangelogGroup, LauncherError, LauncherState, fetch_changelog, parse_changelog,
ChangelogGroup, LauncherError, fetch_changelog, parse_changelog,
};
use crate::hooks::use_settings_snapshot;
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct ChangelogKeys {
pub meta_url_base: String,
@ -19,34 +17,20 @@ impl QueryCapability for ChangelogQuery {
type Keys = ChangelogKeys;
async fn run(&self, _keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let state = LauncherState::get()?;
let markdown = fetch_changelog(&state.services).await?;
let state = crate::launcher::state()?;
let markdown = fetch_changelog(&state.services.requester).await?;
Ok(parse_changelog(&markdown))
}
}
pub fn use_changelog() -> UseQuery<ChangelogQuery> {
let settings = use_settings_snapshot().settings;
let meta_url_base = settings
.custom_meta_url_base
.clone()
.unwrap_or_default()
.trim()
.to_string();
let meta_url_base = super::use_meta_url_key();
use_query(Query::new(ChangelogKeys { meta_url_base }, ChangelogQuery))
}
pub fn changelog_groups(query: &UseQuery<ChangelogQuery>) -> Option<Vec<ChangelogGroup>> {
match &*query.read().state() {
QueryStateData::Settled {
res: Ok(groups), ..
} => Some(groups.clone()),
QueryStateData::Loading {
res: Some(Ok(groups)),
} => Some(groups.clone()),
_ => None,
}
super::state::settled_or_loading(query)
}
pub fn latest_changelog_version(query: &UseQuery<ChangelogQuery>) -> Option<String> {
@ -54,15 +38,9 @@ pub fn latest_changelog_version(query: &UseQuery<ChangelogQuery>) -> Option<Stri
}
pub fn changelog_error(query: &UseQuery<ChangelogQuery>) -> Option<String> {
match &*query.read().state() {
QueryStateData::Settled { res: Err(err), .. } => Some(err.to_string()),
_ => None,
}
super::state::query_error(query)
}
pub fn changelog_is_loading(query: &UseQuery<ChangelogQuery>) -> bool {
matches!(
&*query.read().state(),
QueryStateData::Pending | QueryStateData::Loading { res: None }
)
super::state::query_is_loading(query)
}

View file

@ -1,6 +1,6 @@
use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query};
use oneclient_core::packages::{ContentType, PackageStore};
use oneclient_core::{LauncherError, LauncherState, LinkedArtifactInfo};
use freya::query::{Query, QueryCapability, UseQuery, use_query};
use oneclient_content::packages::{ContentType, PackageStore};
use oneclient_core::{LauncherError, LinkedArtifactInfo};
use oneclient_db::models::ClusterId;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@ -21,8 +21,8 @@ impl QueryCapability for ClusterContentQuery {
type Keys = ClusterContentKeys;
async fn run(&self, _keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let state = LauncherState::get()?;
let all = PackageStore::list_linked_artifacts(self.cluster_id, &state.services).await?;
let state = crate::launcher::state()?;
let all = PackageStore::list_linked_artifacts(self.cluster_id, &state.services.content()).await?;
Ok(all
.into_iter()
.filter(|item| item.content_type == self.content_type)
@ -47,12 +47,5 @@ pub fn use_cluster_content(
}
pub fn cluster_content_items(query: &UseQuery<ClusterContentQuery>) -> Vec<LinkedArtifactInfo> {
let reader = query.read();
match &*reader.state() {
QueryStateData::Settled { res: Ok(list), .. } => list.clone(),
QueryStateData::Loading {
res: Some(Ok(list)),
} => list.clone(),
_ => Vec::new(),
}
super::state::settled_or_loading(query).unwrap_or_default()
}

View file

@ -1,7 +1,6 @@
use freya::query::{Query, QueryCapability, UseQuery, use_query};
use oneclient_core::LauncherError;
use oneclient_core::LauncherState;
use oneclient_core::clusters::{Cluster, ClusterManager};
use oneclient_core::clusters::Cluster;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct ListClustersQuery;
@ -15,11 +14,21 @@ impl QueryCapability for ListClustersQuery {
type Keys = ListClustersKeys;
async fn run(&self, _keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let state = LauncherState::get()?;
ClusterManager::list(&state).await
let state = crate::launcher::state()?;
Ok(state.clusters.list().await?)
}
}
pub fn use_clusters() -> UseQuery<ListClustersQuery> {
use_query(Query::new(ListClustersKeys, ListClustersQuery))
}
/// The one cluster a route is about, pulled out of the list query.
///
/// There is no per-cluster query, since the list is always loaded and small, so
/// this is a lookup, not a fetch. It is a hook because it subscribes to the
/// list on the caller's behalf.
pub fn use_cluster(cluster_id: i64) -> Option<Cluster> {
let clusters = super::state::settled_or_loading(&use_clusters()).unwrap_or_default();
clusters.into_iter().find(|c| c.id == cluster_id)
}

View file

@ -2,7 +2,7 @@ use std::time::Duration;
use bytes::Bytes;
use freya::query::{Query, QueryCapability, UseQuery, use_query};
use oneclient_core::{LauncherError, LauncherState};
use oneclient_core::LauncherError;
const IMAGE_STALE: Duration = Duration::from_secs(60 * 60);
const IMAGE_CLEAN: Duration = Duration::from_secs(6 * 60 * 60);
@ -27,14 +27,24 @@ impl QueryCapability for CachedImageQuery {
return Err(LauncherError::Minecraft("no image url".to_string()));
}
let state = LauncherState::get()?;
let state = crate::launcher::state()?;
state
.images
.get(&state.services, &keys.url, keys.max_edge)
.get(&state.services.requester, &keys.url, keys.max_edge)
.await
}
}
/// The bytes of a cached image, once they are there.
///
/// The url comes back with them because callers key their image handle on it;
/// a stale url paired with fresh bytes renders the wrong picture for a frame.
pub fn loaded_image(url: Option<&str>, query: &UseQuery<CachedImageQuery>) -> Option<(String, Bytes)> {
let url = url?;
let bytes = super::state::settled_or_loading(query)?;
Some((url.to_string(), bytes))
}
pub fn use_cached_image(url: Option<String>, max_edge: u32) -> UseQuery<CachedImageQuery> {
use_query(
Query::new(

View file

@ -1,6 +1,5 @@
use freya::query::{QueriesStorage, Query, QueryCapability, QueryStateData, UseQuery, use_query};
use oneclient_core::LauncherState;
use oneclient_core::java::{AvailableJava, JavaManager, JavaRuntime, JavaVendor};
use freya::query::{QueriesStorage, Query, QueryCapability, UseQuery, use_query};
use oneclient_java::{AvailableJava, JavaRuntime, JavaVendor};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct ListJavaRuntimesQuery;
@ -14,8 +13,8 @@ impl QueryCapability for ListJavaRuntimesQuery {
type Keys = ListJavaRuntimesKeys;
async fn run(&self, _keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let state = LauncherState::get().map_err(|e| e.to_string())?;
JavaManager::list_runtimes(&state.services.db)
let state = crate::launcher::state().map_err(|e| e.to_string())?;
state.java.list_runtimes()
.await
.map_err(|e| e.to_string())
}
@ -26,14 +25,7 @@ pub fn use_java_runtimes() -> UseQuery<ListJavaRuntimesQuery> {
}
pub fn java_runtimes(query: &UseQuery<ListJavaRuntimesQuery>) -> Vec<JavaRuntime> {
let reader = query.read();
match &*reader.state() {
QueryStateData::Settled { res: Ok(list), .. } => list.clone(),
QueryStateData::Loading {
res: Some(Ok(list)),
} => list.clone(),
_ => Vec::new(),
}
super::state::settled_or_loading(query).unwrap_or_default()
}
pub async fn invalidate_java_queries() {
@ -54,8 +46,8 @@ impl QueryCapability for ProviderVersionsQuery {
type Keys = ProviderVersionsKeys;
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let state = LauncherState::get().map_err(|e| e.to_string())?;
JavaManager::available_versions(&state.services, &keys.vendor)
let state = crate::launcher::state().map_err(|e| e.to_string())?;
state.java.available_versions(&keys.vendor)
.await
.map_err(|e| e.to_string())
}
@ -69,13 +61,8 @@ pub fn use_provider_versions(vendor: JavaVendor) -> UseQuery<ProviderVersionsQue
}
pub fn provider_versions(query: &UseQuery<ProviderVersionsQuery>) -> (Vec<AvailableJava>, bool) {
let reader = query.read();
match &*reader.state() {
QueryStateData::Settled { res: Ok(list), .. } => (list.clone(), false),
QueryStateData::Settled { res: Err(_), .. } => (Vec::new(), false),
QueryStateData::Loading {
res: Some(Ok(list)),
} => (list.clone(), true),
_ => (Vec::new(), true),
}
(
super::state::settled_or_loading(query).unwrap_or_default(),
super::state::query_is_busy(query),
)
}

View file

@ -1,12 +1,11 @@
use std::path::PathBuf;
use freya::query::{
Mutation, MutationCapability, QueriesStorage, Query, QueryCapability, QueryStateData,
Mutation, MutationCapability, QueriesStorage, Query, QueryCapability,
UseMutation, UseQuery, use_mutation, use_query,
};
use oneclient_core::clusters::ClusterManager;
use oneclient_core::{
LauncherError, LauncherState, LogFileInfo, LogLevel, LogLine, MclogsUploadResponse, ReadOptions,
LauncherError, LogFileInfo, LogLevel, LogLine, MclogsUploadResponse, ReadOptions,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@ -23,9 +22,9 @@ impl QueryCapability for ClusterLogsQuery {
type Keys = ClusterLogsKeys;
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let state = LauncherState::get()?;
let cluster = ClusterManager::get(&state, keys.cluster_id).await?;
oneclient_core::list_cluster_logs(&cluster)
let state = crate::launcher::state()?;
let cluster = state.clusters.get(keys.cluster_id).await?;
Ok(oneclient_core::list_cluster_logs(&cluster)?)
}
}
@ -49,7 +48,7 @@ impl QueryCapability for LogContentQuery {
if keys.path.as_os_str().is_empty() {
return Ok(Vec::new());
}
oneclient_core::read_log_at(
Ok(oneclient_core::read_log_at(
&keys.path,
&ReadOptions {
level_filter: keys.level,
@ -57,7 +56,7 @@ impl QueryCapability for LogContentQuery {
max_lines: keys.max_lines,
},
)
.await
.await?)
}
}
@ -82,26 +81,12 @@ pub fn use_log_content(
))
}
fn settled_ok<Q>(query: &UseQuery<Q>) -> Option<Q::Ok>
where
Q: QueryCapability,
Q::Ok: Clone,
{
match &*query.read().state() {
QueryStateData::Settled { res: Ok(value), .. } => Some(value.clone()),
QueryStateData::Loading {
res: Some(Ok(value)),
} => Some(value.clone()),
_ => None,
}
}
pub fn try_cluster_logs(query: &UseQuery<ClusterLogsQuery>) -> Option<Vec<LogFileInfo>> {
settled_ok(query)
super::state::settled_or_loading(query)
}
pub fn try_log_content(query: &UseQuery<LogContentQuery>) -> Option<Vec<LogLine>> {
settled_ok(query)
super::state::settled_or_loading(query)
}
pub async fn invalidate_logs_queries() {
@ -123,8 +108,8 @@ impl MutationCapability for UploadLogMutation {
type Keys = UploadLogKeys;
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let state = LauncherState::get()?;
oneclient_core::upload_log_at(&state.services, &keys.path).await
let state = crate::launcher::state()?;
Ok(oneclient_core::upload_log_at(&state.services.requester, &keys.path).await?)
}
}
@ -149,7 +134,7 @@ impl MutationCapability for LogActionMutation {
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
match keys {
LogAction::Delete { path } => oneclient_core::delete_log_at(path).await,
LogAction::Delete { path } => Ok(oneclient_core::delete_log_at(path).await?),
}
}

View file

@ -1,6 +1,6 @@
use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query};
use freya::query::{Query, QueryCapability, UseQuery, use_query};
use oneclient_core::{
LauncherError, LauncherState, MigrationDetection, detect_migration, resolve_migration_chain,
LauncherError, MigrationDetection, detect_migration, resolve_migration_chain,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@ -22,7 +22,7 @@ impl QueryCapability for MigrationQuery {
// remote cluster migrations run on startup (so before we even get to launcher migrations)
// this pretty much tries to migrate the source instance to the latest version in the migration chain,
// so that we can import it into the correct cluster
let rules = LauncherState::get()?.versions.migrations().await;
let rules = crate::launcher::state()?.versions.migrations().await;
if !rules.is_empty() {
for instance in &mut detection.instances {
if instance.mc_version.is_empty() {
@ -45,12 +45,7 @@ pub fn use_migration() -> UseQuery<MigrationQuery> {
}
pub fn migration_detection(query: &UseQuery<MigrationQuery>) -> Option<MigrationDetection> {
let reader = query.read();
match &*reader.state() {
QueryStateData::Settled { res: Ok(opt), .. } => opt.clone(),
QueryStateData::Loading { res: Some(Ok(opt)) } => opt.clone(),
_ => None,
}
super::state::settled_or_loading(query).flatten()
}
pub fn has_migration_data(query: &UseQuery<MigrationQuery>) -> bool {

View file

@ -1,3 +1,20 @@
use crate::hooks::use_settings_snapshot;
/// The cache key for anything fetched from the metadata server.
///
/// Not the url itself, just enough of the setting to notice when it changes,
/// so pointing the launcher at a different meta host refetches instead of
/// serving the old host's answer. The queries read the real base out of
/// settings when they run.
fn use_meta_url_key() -> String {
use_settings_snapshot()
.settings
.custom_meta_url_base
.unwrap_or_default()
.trim()
.to_string()
}
mod analytics;
mod auth;
mod bundles;
@ -14,6 +31,7 @@ mod player_profile;
mod screenshots;
mod settings_profiles;
mod skin;
mod state;
mod tos;
mod version_metadata;
mod versions;
@ -41,8 +59,8 @@ pub use changelog::{
changelog_error, changelog_groups, changelog_is_loading, latest_changelog_version, use_changelog,
};
pub use cluster_content::{cluster_content_items, use_cluster_content};
pub use clusters::use_clusters;
pub use image::{CachedImageQuery, use_cached_image};
pub use clusters::{use_cluster, use_clusters};
pub use image::{CachedImageQuery, loaded_image, use_cached_image};
pub use java::{
invalidate_java_queries, java_runtimes, provider_versions, use_java_runtimes,
use_provider_versions,
@ -72,6 +90,7 @@ pub use settings_profiles::{
use_named_profiles,
};
pub use skin::use_player_skin;
pub use state::{query_error, query_is_busy, query_is_loading, settled_or_loading};
pub use tos::{TermsQuery, terms_document, terms_error, terms_is_loading, use_terms};
pub use version_metadata::{pick_version_metadata, use_version_metadata};
pub use versions::{loader_versions, use_loader_versions, use_versions, versions_metadata};

View file

@ -1,8 +1,7 @@
use std::path::PathBuf;
use freya::query::{Mutation, MutationCapability, QueriesStorage, UseMutation, use_mutation};
use oneclient_core::LauncherState;
use oneclient_core::packages::{ContentType, PackageStore};
use oneclient_content::packages::{ContentType, PackageStore};
use oneclient_db::models::{ClusterId, OverrideType};
use super::bundles::{BundleOverridesQuery, BundleUpdatesQuery, BundlesWithStatusQuery};
@ -75,19 +74,20 @@ impl MutationCapability for ClusterMutation {
type Keys = ClusterAction;
async fn run(&self, keys: &ClusterAction) -> Result<(), String> {
let state = LauncherState::get().map_err(|e| e.to_string())?;
let state = crate::launcher::state().map_err(|e| e.to_string())?;
let services = &state.services;
let content = &state.services.content();
let result = match keys {
ClusterAction::ToggleArtifact { cluster_id, hash } => {
PackageStore::toggle_artifact_enabled(*cluster_id, hash, services)
oneclient_core::toggle_artifact_enabled(*cluster_id, hash, content)
.await
.map(|_| ())
}
ClusterAction::RemoveArtifact { cluster_id, hash } => {
PackageStore::unlink_from_cluster(hash, *cluster_id, services).await
oneclient_core::remove_artifact_from_cluster(*cluster_id, hash, true, content).await
}
ClusterAction::RemoveBundlePackageFromDisk { cluster_id, hash } => {
PackageStore::unlink_from_cluster_system(hash, *cluster_id, services).await
oneclient_core::remove_artifact_from_cluster(*cluster_id, hash, false, content).await
}
ClusterAction::SetBundlePackageEnabled {
cluster_id,
@ -108,7 +108,7 @@ impl MutationCapability for ClusterMutation {
bundle_name,
package_id,
override_type,
services,
content,
)
.await
}
@ -116,23 +116,28 @@ impl MutationCapability for ClusterMutation {
cluster_id,
content_type,
path,
} => PackageStore::import_local_file(path, *content_type, *cluster_id, services)
} => PackageStore::import_local_file(path, *content_type, *cluster_id, content)
.await
.map(|row| {
services
.notifier
.send_info("Imported", &format!("Added {}", row.file_name));
.events
.notify("Imported").body(format!("Added {}", row.file_name)).send();
}),
ClusterAction::SetDedicatedDir {
cluster_id,
dedicated,
} => {
oneclient_core::clusters::ClusterManager::set_dedicated_dir(
&state,
*cluster_id,
*dedicated,
)
.await
state
.clusters
.set_dedicated_dir(
*cluster_id,
*dedicated,
state.games.is_active(*cluster_id),
)
.await
.map_err(|err| oneclient_content::ContentError::InvalidData {
reason: err.to_string(),
})
}
};
result.map_err(|e| e.to_string())
@ -140,9 +145,9 @@ impl MutationCapability for ClusterMutation {
async fn on_settled(&self, _keys: &ClusterAction, result: &Result<(), String>) {
if let Err(err) = result
&& let Ok(state) = LauncherState::get()
&& let Ok(state) = crate::launcher::state()
{
state.services.notifier.send_error("Action failed", err);
state.services.events.notify("Action failed").body(err).error().send();
}
invalidate_cluster_queries().await;
}

View file

@ -1,11 +1,11 @@
use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query};
use oneclient_core::packages::domain::GameLoader;
use oneclient_core::packages::types::{
use freya::query::{Query, QueryCapability, UseQuery, use_query};
use oneclient_common::domain::GameLoader;
use oneclient_content::packages::types::{
DEFAULT_PAGE_SIZE, Page, ProjectDetail, ProjectSummary, SearchFilters, SearchSort,
VersionSummary,
};
use oneclient_core::packages::{CachedPackageMeta, ContentType, ProviderId};
use oneclient_core::{LauncherError, LauncherState};
use oneclient_content::packages::{CachedPackageMeta, ContentType, ProviderId};
use oneclient_core::LauncherError;
pub const BROWSE_PAGE_SIZE: usize = DEFAULT_PAGE_SIZE;
pub const VERSIONS_PAGE_SIZE: usize = 20;
@ -40,9 +40,9 @@ impl QueryCapability for PackageSearchQuery {
#[tracing::instrument(name = "package_search", level = "debug", skip(self, keys), fields(provider = ?keys.provider, page = keys.page))]
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let state = LauncherState::get()?;
let state = crate::launcher::state()?;
let provider = state.services.packages.get(keys.provider)?;
provider
Ok(provider
.search(
&SearchFilters {
query: (!keys.query.trim().is_empty()).then(|| keys.query.trim().to_string()),
@ -55,9 +55,9 @@ impl QueryCapability for PackageSearchQuery {
offset: Some(keys.page * BROWSE_PAGE_SIZE),
limit: Some(BROWSE_PAGE_SIZE),
},
&state.services,
&state.services.content(),
)
.await
.await?)
}
}
@ -88,32 +88,19 @@ pub fn use_package_search(
}
pub fn search_items(query: &UseQuery<PackageSearchQuery>) -> Vec<ProjectSummary> {
let reader = query.read();
match &*reader.state() {
QueryStateData::Settled { res: Ok(page), .. } => page.items.clone(),
QueryStateData::Loading {
res: Some(Ok(page)),
} => page.items.clone(),
_ => Vec::new(),
}
super::state::settled_or_loading(query)
.map(|page| page.items)
.unwrap_or_default()
}
pub fn search_total(query: &UseQuery<PackageSearchQuery>) -> usize {
let reader = query.read();
match &*reader.state() {
QueryStateData::Settled { res: Ok(page), .. } => page.total,
QueryStateData::Loading {
res: Some(Ok(page)),
} => page.total,
_ => 0,
}
super::state::settled_or_loading(query)
.map(|page| page.total)
.unwrap_or(0)
}
pub fn search_pending(query: &UseQuery<PackageSearchQuery>) -> bool {
matches!(
&*query.read().state(),
QueryStateData::Pending | QueryStateData::Loading { .. }
)
super::state::query_is_busy(query)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@ -131,11 +118,11 @@ impl QueryCapability for PackageProjectQuery {
type Keys = PackageProjectKeys;
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let state = LauncherState::get()?;
let state = crate::launcher::state()?;
let provider = state.services.packages.get(keys.provider)?;
provider
.get_project_with_body(&keys.project_id, &state.services)
.await
Ok(provider
.get_project_with_body(&keys.project_id, &state.services.content())
.await?)
}
}
@ -153,16 +140,7 @@ pub fn use_package_project(
}
pub fn project_detail(query: &UseQuery<PackageProjectQuery>) -> Option<ProjectDetail> {
let reader = query.read();
match &*reader.state() {
QueryStateData::Settled {
res: Ok(detail), ..
} => Some(detail.clone()),
QueryStateData::Loading {
res: Some(Ok(detail)),
} => Some(detail.clone()),
_ => None,
}
super::state::settled_or_loading(query)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@ -181,13 +159,13 @@ impl QueryCapability for PackageMetaBatchQuery {
#[tracing::instrument(name = "package_meta_batch", level = "debug", skip(self, keys), fields(provider = ?keys.provider, ids = keys.project_ids.len()))]
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let state = LauncherState::get()?;
oneclient_core::packages::fetch_package_meta(
&state.services,
let state = crate::launcher::state()?;
Ok(oneclient_content::packages::fetch_package_meta(
&state.services.content(),
keys.provider,
&keys.project_ids,
)
.await
.await?)
}
}
@ -209,12 +187,7 @@ pub fn use_package_meta_batch(
pub fn package_meta_batch(
query: &UseQuery<PackageMetaBatchQuery>,
) -> std::collections::HashMap<String, CachedPackageMeta> {
let reader = query.read();
match &*reader.state() {
QueryStateData::Settled { res: Ok(map), .. } => map.clone(),
QueryStateData::Loading { res: Some(Ok(map)) } => map.clone(),
_ => std::collections::HashMap::new(),
}
super::state::settled_or_loading(query).unwrap_or_default()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@ -235,18 +208,18 @@ impl QueryCapability for PackageVersionsQuery {
type Keys = PackageVersionsKeys;
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let state = LauncherState::get()?;
let state = crate::launcher::state()?;
let provider = state.services.packages.get(keys.provider)?;
provider
Ok(provider
.list_versions(
&keys.project_id,
keys.game_version.as_deref(),
keys.loader,
keys.page * VERSIONS_PAGE_SIZE,
VERSIONS_PAGE_SIZE,
&state.services,
&state.services.content(),
)
.await
.await?)
}
}
@ -270,25 +243,15 @@ pub fn use_package_versions(
}
pub fn version_list(query: &UseQuery<PackageVersionsQuery>) -> Vec<VersionSummary> {
let reader = query.read();
match &*reader.state() {
QueryStateData::Settled { res: Ok(page), .. } => page.items.clone(),
QueryStateData::Loading {
res: Some(Ok(page)),
} => page.items.clone(),
_ => Vec::new(),
}
super::state::settled_or_loading(query)
.map(|page| page.items)
.unwrap_or_default()
}
pub fn versions_total(query: &UseQuery<PackageVersionsQuery>) -> usize {
let reader = query.read();
match &*reader.state() {
QueryStateData::Settled { res: Ok(page), .. } => page.total,
QueryStateData::Loading {
res: Some(Ok(page)),
} => page.total,
_ => 0,
}
super::state::settled_or_loading(query)
.map(|page| page.total)
.unwrap_or(0)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@ -307,11 +270,11 @@ impl QueryCapability for PackageCategoriesQuery {
#[tracing::instrument(name = "package_categories", level = "debug", skip(self, keys), fields(provider = ?keys.provider))]
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let state = LauncherState::get()?;
let state = crate::launcher::state()?;
let provider = state.services.packages.get(keys.provider)?;
provider
.list_categories(keys.content_type, &state.services)
.await
Ok(provider
.list_categories(keys.content_type, &state.services.content())
.await?)
}
}
@ -329,12 +292,5 @@ pub fn use_package_categories(
}
pub fn category_list(query: &UseQuery<PackageCategoriesQuery>) -> Vec<String> {
let reader = query.read();
match &*reader.state() {
QueryStateData::Settled { res: Ok(list), .. } => list.clone(),
QueryStateData::Loading {
res: Some(Ok(list)),
} => list.clone(),
_ => Vec::new(),
}
super::state::settled_or_loading(query).unwrap_or_default()
}

View file

@ -1,10 +1,8 @@
use std::time::Duration;
use freya::query::{Query, QueryCapability, UseQuery, use_query};
use oneclient_core::{
LauncherError, LauncherState,
minecraft::{self, PlayerProfileView},
};
use oneclient_core::LauncherError;
use oneclient_mc::{self as minecraft, PlayerProfileView};
// image cache
const PROFILE_STALE: Duration = Duration::from_secs(30 * 60);
@ -27,10 +25,10 @@ impl QueryCapability for FetchPlayerProfileQuery {
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let access_token = keys.access_token.clone();
let state = LauncherState::get()?;
let state = crate::launcher::state()?;
let client = &state.services.requester;
minecraft::fetch_player_profile_view(client, &keys.uuid, access_token.as_deref()).await
Ok(minecraft::fetch_player_profile_view(client, &keys.uuid, access_token.as_deref()).await?)
}
// TODO: Cache

View file

@ -3,11 +3,10 @@ use std::sync::{Arc, OnceLock};
use bytes::Bytes;
use freya::query::{
Mutation, MutationCapability, QueriesStorage, Query, QueryCapability, QueryStateData,
Mutation, MutationCapability, QueriesStorage, Query, QueryCapability,
UseMutation, UseQuery, use_mutation, use_query,
};
use oneclient_core::clusters::ClusterManager;
use oneclient_core::{LauncherError, LauncherState, ScreenshotInfo};
use oneclient_core::{LauncherError, ScreenshotInfo};
use tokio::sync::Semaphore;
static LOCAL_IMAGE_SEMAPHORE: OnceLock<Arc<Semaphore>> = OnceLock::new();
@ -26,9 +25,9 @@ impl QueryCapability for ClusterScreenshotsQuery {
type Keys = ClusterScreenshotsKeys;
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let state = LauncherState::get()?;
let cluster = ClusterManager::get(&state, keys.cluster_id).await?;
oneclient_core::list_cluster_screenshots(&cluster)
let state = crate::launcher::state()?;
let cluster = state.clusters.get(keys.cluster_id).await?;
Ok(oneclient_core::list_cluster_screenshots(&cluster)?)
}
}
@ -63,9 +62,11 @@ impl QueryCapability for LocalImageQuery {
None
};
tokio::task::spawn_blocking(move || oneclient_core::load_screenshot(&path, max_edge))
.await
.map_err(|e| LauncherError::Minecraft(e.to_string()))?
Ok(
tokio::task::spawn_blocking(move || oneclient_core::load_screenshot(&path, max_edge))
.await
.map_err(|e| LauncherError::Minecraft(e.to_string()))??,
)
}
}
@ -86,13 +87,7 @@ pub fn use_local_image(path: PathBuf, max_edge: u32) -> UseQuery<LocalImageQuery
pub fn try_cluster_screenshots(
query: &UseQuery<ClusterScreenshotsQuery>,
) -> Option<Vec<ScreenshotInfo>> {
match &*query.read().state() {
QueryStateData::Settled { res: Ok(value), .. } => Some(value.clone()),
QueryStateData::Loading {
res: Some(Ok(value)),
} => Some(value.clone()),
_ => None,
}
super::state::settled_or_loading(query)
}
pub async fn invalidate_screenshots_queries() {
@ -115,7 +110,7 @@ impl MutationCapability for ScreenshotActionMutation {
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
match keys {
ScreenshotAction::Delete { path } => oneclient_core::delete_screenshot(path),
ScreenshotAction::Delete { path } => Ok(oneclient_core::delete_screenshot(path)?),
}
}

View file

@ -1,9 +1,8 @@
use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query};
use freya::query::{Query, QueryCapability, UseQuery, use_query};
use oneclient_core::settings::GameSettingsProfile;
use oneclient_core::settings::store::{
use oneclient_cluster::profiles::{
get_profile_or_default, list_named_profiles, resolve_cluster_profile,
};
use oneclient_core::{ClusterManager, LauncherState};
use oneclient_db::models::ClusterId;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@ -18,7 +17,7 @@ impl QueryCapability for ListNamedProfilesQuery {
type Keys = ListNamedProfilesKeys;
async fn run(&self, _keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let state = LauncherState::get().map_err(|e| e.to_string())?;
let state = crate::launcher::state().map_err(|e| e.to_string())?;
list_named_profiles(&state.services.db)
.await
.map_err(|e| e.to_string())
@ -44,9 +43,9 @@ impl QueryCapability for GameProfileQuery {
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let name = keys.name.clone();
let state = LauncherState::get().map_err(|e| e.to_string())?;
let settings = state.settings.read().clone();
get_profile_or_default(&state.services.db, &settings, name.as_deref())
let state = crate::launcher::state().map_err(|e| e.to_string())?;
let global = state.settings.read().global_game_settings.clone();
get_profile_or_default(&state.services.db, &global, name.as_deref())
.await
.map_err(|e| e.to_string())
}
@ -76,9 +75,9 @@ impl QueryCapability for ClusterProfileQuery {
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let profile_name = keys.profile_name.clone();
let state = LauncherState::get().map_err(|e| e.to_string())?;
let settings = state.settings.read().clone();
resolve_cluster_profile(&state.services.db, &settings, profile_name.as_deref())
let state = crate::launcher::state().map_err(|e| e.to_string())?;
let global = state.settings.read().global_game_settings.clone();
resolve_cluster_profile(&state.services.db, &global, profile_name.as_deref())
.await
.map_err(|e| e.to_string())
}
@ -110,11 +109,12 @@ impl QueryCapability for ClusterSettingsQuery {
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let cluster_id = keys.cluster_id;
let state = LauncherState::get().map_err(|e| e.to_string())?;
let cluster = ClusterManager::get(&state, cluster_id)
let state = crate::launcher::state().map_err(|e| e.to_string())?;
let cluster = state.clusters.get(cluster_id)
.await
.map_err(|e| e.to_string())?;
ClusterManager::resolve_settings(&state, &cluster)
let global = state.settings.read().global_game_settings.clone();
state.clusters.resolve_settings(&global, &cluster)
.await
.map_err(|e| e.to_string())
}
@ -128,14 +128,5 @@ pub fn use_cluster_settings(cluster_id: ClusterId) -> UseQuery<ClusterSettingsQu
}
pub fn try_game_profile(query: &UseQuery<GameProfileQuery>) -> Option<GameSettingsProfile> {
let reader = query.read();
match &*reader.state() {
QueryStateData::Settled {
res: Ok(profile), ..
} => Some(profile.clone()),
QueryStateData::Loading {
res: Some(Ok(profile)),
} => Some(profile.clone()),
_ => None,
}
super::state::settled_or_loading(query)
}

View file

@ -1,18 +1,13 @@
use bytes::Bytes;
use freya::prelude::*;
use freya::query::QueryStateData;
use crate::AppAssets;
pub fn use_player_skin(uuid: String) -> (Bytes, bool) {
let profile = super::use_player_profile(uuid.clone(), None::<String>);
let (skin_url, is_slim) = match &*profile.read().state() {
QueryStateData::Settled {
res: Ok(profile), ..
} => (profile.skin_url.clone(), profile.is_slim),
_ => (None, false),
};
let (skin_url, is_slim) = crate::hooks::settled_or_loading(&profile)
.map_or((None, false), |profile| (profile.skin_url, profile.is_slim));
let skin_query = super::use_cached_image(skin_url.clone(), 256);
@ -22,17 +17,10 @@ pub fn use_player_skin(uuid: String) -> (Bytes, bool) {
// No custom skin: pick alex (slim) or steve (classic) from the UUID.
let default_slim = (java_string_hash(&uuid) & 1) == 1;
let reader = skin_query.read();
match (&skin_url, &*reader.state()) {
(Some(_), QueryStateData::Settled { res: Ok(bytes), .. })
| (
Some(_),
QueryStateData::Loading {
res: Some(Ok(bytes)),
},
) => (bytes.clone(), is_slim),
_ if default_slim => (alex.read().clone(), true),
_ => (steve.read().clone(), false),
match crate::hooks::loaded_image(skin_url.as_deref(), &skin_query) {
Some((_, bytes)) => (bytes, is_slim),
None if default_slim => (alex.read().clone(), true),
None => (steve.read().clone(), false),
}
}

View file

@ -0,0 +1,69 @@
//! Reading a query's state without spelling out the match every time.
//!
//! `QueryStateData` has three variants and the useful cases straddle two of
//! them: a query that is refetching still holds the previous value, and the UI
//! wants to keep showing it rather than flash a spinner. Written out by hand
//! that is a five-line match repeated over a hundred times, with enough small
//! divergences (does a settled `Err` read as empty or as absent? does
//! "loading" include a refetch?) that the differences were impossible to see.
use std::fmt::Display;
use freya::query::{QueryCapability, QueryStateData, UseQuery};
/// The query's value, keeping the previous one visible across a refetch.
///
/// `None` means there has never been a successful result: either the query
/// has not finished yet or the only outcome so far is an error. Callers that
/// treat "failed" the same as "empty" finish with `.unwrap_or_default()`.
pub fn settled_or_loading<Q>(query: &UseQuery<Q>) -> Option<Q::Ok>
where
Q: QueryCapability,
Q::Ok: Clone,
{
let reader = query.read();
let state = reader.state();
state.ok().cloned()
}
/// The error from a settled query, rendered for display.
///
/// Not reported while loading: a refetch that is still in flight has not
/// failed yet, even if the previous attempt did.
pub fn query_error<Q>(query: &UseQuery<Q>) -> Option<String>
where
Q: QueryCapability,
Q::Err: Display,
{
let reader = query.read();
let state = reader.state();
match &*state {
QueryStateData::Settled { res: Err(err), .. } => Some(err.to_string()),
_ => None,
}
}
/// Whether the query is working with nothing to show yet.
///
/// A refetch that still holds a previous value is *not* loading by this
/// definition, since the UI has something to render and a spinner would be a
/// step backwards. Use [`query_is_busy`] where the distinction matters.
pub fn query_is_loading<Q: QueryCapability>(query: &UseQuery<Q>) -> bool {
let reader = query.read();
let state = reader.state();
matches!(
&*state,
QueryStateData::Pending | QueryStateData::Loading { res: None }
)
}
/// Whether the query is working at all, including a refetch over a value that
/// is already on screen.
pub fn query_is_busy<Q: QueryCapability>(query: &UseQuery<Q>) -> bool {
let reader = query.read();
let state = reader.state();
matches!(
&*state,
QueryStateData::Pending | QueryStateData::Loading { .. }
)
}

View file

@ -1,7 +1,5 @@
use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query};
use oneclient_core::{LauncherError, LauncherState, TermsDocument, fetch_terms};
use crate::hooks::use_settings_snapshot;
use freya::query::{Query, QueryCapability, UseQuery, use_query};
use oneclient_core::{LauncherError, TermsDocument, fetch_terms};
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct TermsKeys {
@ -17,45 +15,25 @@ impl QueryCapability for TermsQuery {
type Keys = TermsKeys;
async fn run(&self, _keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let state = LauncherState::get()?;
fetch_terms(&state.services).await
let state = crate::launcher::state()?;
fetch_terms(&state.services.requester).await
}
}
pub fn use_terms() -> UseQuery<TermsQuery> {
let settings = use_settings_snapshot().settings;
let meta_url_base = settings
.custom_meta_url_base
.clone()
.unwrap_or_default()
.trim()
.to_string();
let meta_url_base = super::use_meta_url_key();
use_query(Query::new(TermsKeys { meta_url_base }, TermsQuery))
}
pub fn terms_document(query: &UseQuery<TermsQuery>) -> Option<TermsDocument> {
match &*query.read().state() {
QueryStateData::Settled {
res: Ok(document), ..
} => Some(document.clone()),
QueryStateData::Loading {
res: Some(Ok(document)),
} => Some(document.clone()),
_ => None,
}
super::state::settled_or_loading(query)
}
pub fn terms_error(query: &UseQuery<TermsQuery>) -> Option<String> {
match &*query.read().state() {
QueryStateData::Settled { res: Err(err), .. } => Some(err.to_string()),
_ => None,
}
super::state::query_error(query)
}
pub fn terms_is_loading(query: &UseQuery<TermsQuery>) -> bool {
matches!(
&*query.read().state(),
QueryStateData::Pending | QueryStateData::Loading { res: None }
)
super::state::query_is_loading(query)
}

View file

@ -1,7 +1,6 @@
use freya::query::QueryStateData;
use oneclient_core::VersionKey;
use oneclient_common::VersionKey;
use oneclient_core::VersionMetadata;
use oneclient_core::packages::domain::GameLoader;
use oneclient_common::domain::GameLoader;
use super::use_versions;
@ -51,15 +50,10 @@ pub fn use_version_metadata(
let versions_query = use_versions();
let major = major?;
// Borrowed, not cloned: the manifest is long and this runs per render.
let reader = versions_query.read();
let state = reader.state();
let list = match &*state {
QueryStateData::Settled { res: Ok(list), .. } => list,
QueryStateData::Loading {
res: Some(Ok(list)),
} => list,
_ => return None,
};
let list = state.ok()?;
pick_version_metadata(list, major, key, loader)
}

View file

@ -1,6 +1,6 @@
use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query};
use oneclient_core::packages::domain::GameLoader;
use oneclient_core::{LauncherError, LauncherState, VersionMetadata};
use oneclient_common::domain::GameLoader;
use oneclient_core::{LauncherError, VersionMetadata};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct VersionsMetadataQuery;
@ -14,13 +14,13 @@ impl QueryCapability for VersionsMetadataQuery {
type Keys = VersionsMetadataKeys;
async fn run(&self, _keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let state = LauncherState::get()?;
let metadata = state.versions.metadata().await;
let state = crate::launcher::state()?;
let metadata = state.versions.metadata(&state.services.requester.config().meta_url_base).await;
if !metadata.is_empty() {
return Ok(metadata);
}
state.versions.sync(&state.services).await?;
Ok(state.versions.metadata().await)
Ok(state.versions.metadata(&state.services.requester.config().meta_url_base).await)
}
}
@ -33,13 +33,12 @@ pub fn use_versions() -> UseQuery<VersionsMetadataQuery> {
/// callers that block on this don't wait forever when the network is down.
pub fn versions_metadata(query: &UseQuery<VersionsMetadataQuery>) -> Option<Vec<VersionMetadata>> {
let reader = query.read();
match &*reader.state() {
QueryStateData::Settled { res: Ok(list), .. } => Some(list.clone()),
let state = reader.state();
match &*state {
// A failed fetch reads as an empty list rather than `None`, so callers
// that block on this don't wait forever when the network is down.
QueryStateData::Settled { res: Err(_), .. } => Some(Vec::new()),
QueryStateData::Loading {
res: Some(Ok(list)),
} => Some(list.clone()),
_ => None,
other => other.ok().cloned(),
}
}
@ -58,15 +57,15 @@ impl QueryCapability for LoaderVersionsQuery {
type Keys = LoaderVersionsKeys;
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let state = LauncherState::get()?;
let state = crate::launcher::state()?;
let mut metadata = state.metadata.lock().await;
oneclient_core::get_loader_versions(
Ok(oneclient_core::get_loader_versions(
&mut metadata,
&state.services,
&state.services.mc(),
&keys.mc_version,
keys.loader,
)
.await
.await?)
}
}
@ -81,12 +80,5 @@ pub fn use_loader_versions(
}
pub fn loader_versions(query: &UseQuery<LoaderVersionsQuery>) -> Vec<String> {
let reader = query.read();
match &*reader.state() {
QueryStateData::Settled { res: Ok(list), .. } => list.clone(),
QueryStateData::Loading {
res: Some(Ok(list)),
} => list.clone(),
_ => Vec::new(),
}
super::state::settled_or_loading(query).unwrap_or_default()
}

View file

@ -0,0 +1,197 @@
//! Shared helpers for content installs and the notifications they raise.
//!
//! Lifted out of the bridge runtime so the actions layer can use them without
//! the command loop existing.
use std::sync::Arc;
use oneclient_core::LauncherState;
use oneclient_content::packages::PackageStore;
use oneclient_events::Level;
use crate::components::IconType;
use crate::notifications::{
ClusterUpdateItem, ClusterUpdateSummary, NotificationAction, NotificationActionKind,
NotificationSpec,
};
pub fn item_from_bundle_file(file: &oneclient_core::BundleFile) -> ClusterUpdateItem {
match &file.kind {
oneclient_core::BundleFileKind::Managed {
provider,
project_id,
..
} => ClusterUpdateItem {
provider: *provider,
project_id: Some(project_id.clone()),
fallback: file.display_name(),
},
oneclient_core::BundleFileKind::External(_) => ClusterUpdateItem {
provider: oneclient_content::packages::ProviderId::Local,
project_id: None,
fallback: file.display_name(),
},
}
}
async fn cluster_update_summary(
cluster_id: i64,
result: &oneclient_core::ApplyBundleUpdatesResult,
services: &oneclient_core::LauncherServices,
) -> Option<ClusterUpdateSummary> {
let updated: Vec<ClusterUpdateItem> = result
.updates_applied
.iter()
.map(|u| item_from_bundle_file(&u.new_file))
.collect();
let added: Vec<ClusterUpdateItem> = result
.additions_applied
.iter()
.map(|a| item_from_bundle_file(&a.new_file))
.collect();
let removed: Vec<ClusterUpdateItem> = result
.removals_applied
.iter()
.map(|r| ClusterUpdateItem {
provider: r
.provider
.unwrap_or(oneclient_content::packages::ProviderId::Local),
project_id: r.project_id.clone(),
fallback: r
.display_name
.clone()
.unwrap_or_else(|| r.package_id.clone()),
})
.collect();
if updated.is_empty() && added.is_empty() && removed.is_empty() {
return None;
}
let cluster_name =
oneclient_content::packages::PackageStore::get_cluster(cluster_id, &services.content())
.await
.map(|c| c.name)
.unwrap_or_else(|_| "Cluster".to_string());
Some(ClusterUpdateSummary {
cluster_id,
cluster_name,
updated,
added,
removed,
})
}
pub async fn cluster_update_notification(
cluster_id: i64,
result: &oneclient_core::ApplyBundleUpdatesResult,
services: &oneclient_core::LauncherServices,
) -> Option<NotificationSpec> {
let summary = cluster_update_summary(cluster_id, result, services).await?;
let total = summary.total();
let body = format!(
"{total} package{} changed in {}",
if total == 1 { "" } else { "s" },
summary.cluster_name
);
Some(NotificationSpec {
title: "Cluster updated".to_string(),
body,
level: Level::Info,
icon: Some(IconType::DownloadCloud02),
progress: None,
actions: vec![NotificationAction {
label: "View changes".to_string(),
kind: NotificationActionKind::OpenClusterUpdate(vec![summary]),
}],
})
}
/// Builds a single notification summarising a batch bundle sync. Every changed
/// cluster rides along on one "View changes" action so the notification keeps
/// exactly two buttons no matter how many clusters moved; the modal does the
/// per-cluster breakdown. `None` when nothing changed.
pub async fn combined_cluster_update_spec(
changed: &[(i64, oneclient_core::ApplyBundleUpdatesResult)],
services: &oneclient_core::LauncherServices,
) -> Option<NotificationSpec> {
let mut summaries = Vec::new();
let mut total_changes = 0usize;
for (cluster_id, result) in changed {
if let Some(summary) = cluster_update_summary(*cluster_id, result, services).await {
total_changes += summary.total();
summaries.push(summary);
}
}
if summaries.is_empty() {
return None;
}
let cluster_count = summaries.len();
let body = format!(
"{total_changes} package{} updated across {cluster_count} cluster{}",
if total_changes == 1 { "" } else { "s" },
if cluster_count == 1 { "" } else { "s" }
);
Some(NotificationSpec {
title: "Mods updated".to_string(),
body,
level: Level::Info,
icon: Some(IconType::DownloadCloud02),
progress: None,
actions: vec![NotificationAction {
label: "View changes".to_string(),
kind: NotificationActionKind::OpenClusterUpdate(summaries),
}],
})
}
pub async fn install_package(
state: &Arc<LauncherState>,
provider: oneclient_content::packages::ProviderId,
project_id: &str,
version_id: &str,
cluster_id: i64,
) -> anyhow::Result<String> {
let provider_impl = state.services.packages.get(provider)?;
let project = provider_impl
.get_project(project_id, &state.services.content())
.await?;
let version = provider_impl
.get_version(project_id, version_id, &state.services.content())
.await?;
let session = oneclient_events::GroupedProgressSession::start(
&state.services.events,
format!("Installing {}", project.name),
);
let size = version.primary_file().map(|f| f.size).unwrap_or(0);
let child = session.child(
project.name.clone(),
size,
oneclient_events::TaskCategory::Packages,
);
let result = PackageStore::install_to_cluster(
provider,
&project,
&version,
cluster_id,
false,
false,
Some(&child),
&state.services.content(),
)
.await;
child.finish();
session.finish();
result?;
Ok(project.name)
}

View file

@ -0,0 +1,38 @@
//! The app's handle on the launcher core.
//!
//! The core keeps no process-wide `OnceCell`; a library holding ambient state
//! cannot be constructed twice, or once in a test. The handle lives here
//! instead, in the executable, which is the one place a global is defensible:
//! this *is* the composition root.
//!
//! It exists because `freya::query`'s `QueryCapability::run` and
//! `MutationCapability::run` take only their keys, with no context and no
//! hooks, so there is nowhere else to thread the state through.
use std::sync::{Arc, OnceLock};
use oneclient_core::{LauncherError, LauncherResult, LauncherState};
static LAUNCHER: OnceLock<Arc<LauncherState>> = OnceLock::new();
/// Installs the launcher handle. Called once, from the startup path.
///
/// Returns the existing handle if one was already installed, rather than
/// failing: a second call is a bug in startup ordering, not something a user
/// can trigger, and silently keeping the first is safer than panicking.
pub fn install(state: Arc<LauncherState>) -> Arc<LauncherState> {
if let Err(existing) = LAUNCHER.set(Arc::clone(&state)) {
tracing::warn!("launcher state was already installed; keeping the first");
return existing;
}
state
}
/// The launcher handle, or [`LauncherError::NotInitialized`] before startup has
/// finished installing it.
pub fn state() -> LauncherResult<Arc<LauncherState>> {
LAUNCHER
.get()
.cloned()
.ok_or(LauncherError::NotInitialized)
}

View file

@ -23,7 +23,7 @@ use crate::hooks::{
use_provide_browser_state, use_splash,
};
use crate::theme::colors;
use oneclient_core::notification::LaunchStage;
use oneclient_events::LaunchStage;
use std::collections::HashMap;
use std::path::PathBuf;
@ -206,7 +206,7 @@ impl Component for LaunchErrorDialog {
}
}
fn copy_error_button(message: &str, dispatch: crate::BridgeDispatch) -> impl IntoElement {
fn copy_error_button(message: &str, dispatch: crate::Actions) -> impl IntoElement {
let message = message.to_string();
Button::new()
.secondary()
@ -271,7 +271,7 @@ impl Component for AppHomeBackground {
let splash = use_splash();
// Once the cluster list has settled and the startup fetch is done, the
// home view is populated — let the splash curtain fade out.
// home view is populated, so let the splash curtain fade out.
let clusters_settled = matches!(
&*clusters_query.read().state(),
QueryStateData::Settled { .. }
@ -289,13 +289,7 @@ impl Component for AppHomeBackground {
let (art, art_dep) = {
let reader = clusters_query.read();
let state = reader.state();
let clusters = match &*state {
QueryStateData::Settled { res: Ok(list), .. } => list.as_slice(),
QueryStateData::Loading {
res: Some(Ok(list)),
} => list.as_slice(),
_ => &[],
};
let clusters = state.ok().map_or(&[][..], Vec::as_slice);
let chosen = (*active_id.read())
.and_then(|id| clusters.iter().find(|c| c.id == id))
.or_else(|| clusters.first());

View file

@ -1,14 +1,10 @@
use freya::animation::*;
use freya::prelude::*;
use freya::query::QueryStateData;
use freya::router::*;
use oneclient_core::clusters::Cluster;
use oneclient_core::parse_mc_version;
use oneclient_common::parse_mc_version;
use crate::components::{Button, Icon, IconType, TabBar, TabItem};
use crate::hooks::{
use_clusters, use_dispatch, use_game_snapshot, use_launcher, use_version_metadata,
};
use crate::hooks::{use_cluster, use_dispatch, use_game_snapshot, use_launcher, use_version_metadata};
use crate::routes::Route;
use crate::theme::colors;
use crate::ui::entrance_motion_layer;
@ -91,19 +87,6 @@ fn route_cluster(route: &Route) -> Option<(i64, ClusterViewShellTab)> {
})
}
fn load_cluster(cluster_id: i64) -> Option<Cluster> {
let clusters_query = use_clusters();
let reader = clusters_query.read();
let list = match &*reader.state() {
QueryStateData::Settled { res: Ok(list), .. }
| QueryStateData::Loading {
res: Some(Ok(list)),
} => list.clone(),
_ => Vec::new(),
};
list.into_iter().find(|c| c.id == cluster_id)
}
#[derive(PartialEq)]
pub struct ClusterShell;
@ -117,7 +100,7 @@ impl Component for ClusterShell {
let game = use_game_snapshot();
let launcher = use_launcher();
let syncing = launcher.fetching || launcher.syncing_bundles;
let cluster = load_cluster(cluster_id);
let cluster = use_cluster(cluster_id);
let show_game_log = game.is_active(cluster_id);
let launch_state = launch_button_state(&game, cluster_id, syncing);
@ -246,7 +229,7 @@ fn cluster_header(
title: String,
subtitle: String,
cluster_id: i64,
dispatch: crate::BridgeDispatch,
dispatch: crate::Actions,
launch_state: (&'static str, bool),
running: bool,
folder: Option<std::path::PathBuf>,

View file

@ -26,7 +26,7 @@ impl Component for RootLayout {
// Other platforms are borderless: Freya rounds, squared when maximized so
// there's no gap to the screen edge. No reactive maximized signal exists,
// so mirror it — root_size changes on every maximize/restore/resize, which
// so mirror it: root_size changes on every maximize/restore/resize, which
// re-runs the effect to re-query.
#[cfg(not(target_os = "macos"))]
let corner = {

View file

@ -4,9 +4,13 @@
#![recursion_limit = "256"]
mod assets;
mod bridge;
mod components;
pub mod hooks;
pub mod events;
mod install;
mod launcher;
pub mod state;
mod transfer;
mod layout;
mod notifications;
pub mod platform;
@ -20,7 +24,8 @@ mod view;
pub mod constants;
pub use assets::AppAssets;
pub use bridge::*;
pub use events::EventPump;
pub use state::{AppChannel, AppState};
pub use components::ConfirmLinkOverlay;
pub use hooks::*;
pub use routes::{Route, router};

View file

@ -2,21 +2,58 @@
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
#![recursion_limit = "256"]
use freya::prelude::*;
use freya::radio::use_init_radio_station;
use oneclient_app::state::{AppChannel, AppState};
use oneclient_app::{
ConfirmLinkOverlay, CoreBridgeHandle, DataSync, LinkConfirmState, OneClientBridge, constants,
router, theme, use_provide_link_confirm,
Actions, ConfirmLinkOverlay, EventPump, LinkConfirmState, constants, events, router, theme,
use_provide_actions, use_provide_link_confirm,
};
use tokio::runtime::Builder;
struct OneClientApp {
bridge: OneClientBridge,
}
struct OneClientApp;
impl App for OneClientApp {
fn render(&self) -> impl IntoElement {
oneclient_app::use_provide_bridge(&self.bridge);
// The station holds all app state, and radio state is `!Send`, so
// everything that writes it runs on the UI thread. `spawn_forever`, not
// `spawn`: these must outlive any component scope.
let station = use_init_radio_station::<AppState, AppChannel>(AppState::default);
let actions = use_hook(move || {
let (signals_tx, signals_rx) = tokio::sync::mpsc::unbounded_channel();
let (events_bus, events_rx) = oneclient_events::EventBus::channel();
let actions = Actions::new(station, signals_tx);
// Started first so nothing emitted during startup waits on a
// consumer, though the bus is unbounded either way.
spawn_forever(
EventPump {
events: events_rx,
signals: signals_rx,
station,
}
.run(),
);
let startup = actions.clone();
spawn_forever(async move {
match events::start_launcher(station, events_bus).await {
// Chained rather than called alongside: `sync_bundles` needs
// the launcher handle, and it sets `syncing_bundles` before
// spawning its work, so firing it early would both fail and
// leave the flag stuck, disabling every launch button.
Ok(()) => startup.sync_bundles(),
Err(err) => events::report_startup_failure(&station, &err),
}
});
actions
});
use_provide_actions(&actions);
let link_confirm = use_state(|| None::<String>);
use_provide_link_confirm(LinkConfirmState(link_confirm));
@ -24,7 +61,6 @@ impl App for OneClientApp {
rect()
.width(Size::fill())
.height(Size::fill())
.child(DataSync)
.child(ConfirmLinkOverlay)
.child(router())
}
@ -35,9 +71,9 @@ fn main() {
builder.enable_all().max_blocking_threads(64);
// Debug builds emit unoptimized async code, which can cause stack overflows in some cases.
// Default stack size is 2MB, so we'll increase it to 4MB for debug builds
// Default stack size is 2MB, so we'll increase it to 3MB for debug builds
#[cfg(debug_assertions)]
builder.thread_stack_size(4 * 1024 * 1024);
builder.thread_stack_size(3 * 1024 * 1024);
let rt = builder.build().unwrap();
let _tokio_guard = rt.enter();
@ -53,13 +89,11 @@ fn main() {
let _sentry_guard = oneclient_core::reporting::init(settings.crash_reporting);
let (bridge, handle): (OneClientBridge, CoreBridgeHandle) = OneClientBridge::new();
handle.spawn_runtime();
#[cfg(target_os = "macos")]
oneclient_app::platform::macos::loop_memory_collector();
let window_config = WindowConfig::new_app(OneClientApp { bridge })
let window_config = WindowConfig::new_app(OneClientApp)
.with_title(constants::WINDOW_TITLE)
.with_app_id(constants::WINDOW_APP_ID)
.with_icon(LaunchConfig::window_icon(include_bytes!(

View file

@ -1,15 +1,16 @@
use std::collections::HashMap;
use std::time::{Duration, Instant};
use oneclient_core::notification::{
GroupedProgressEvent, Notification, NotificationLevel, PromptKind, TaskCategory, UserChoice,
use oneclient_events::{
Answer, Choice, Event, GroupedProgressEvent, Level, Notification, ProgressEvent, TaskCategory,
};
use oneclient_core::packages::ProviderId;
use oneclient_content::packages::ProviderId;
use oneclient_db::models::ClusterId;
use tokio::sync::oneshot;
use uuid::Uuid;
use crate::components::IconType;
use crate::transfer::{TransferMeter, TransferStats};
pub const MESSAGE_TOAST_TTL: Duration = Duration::from_secs(5);
@ -64,7 +65,7 @@ impl ClusterUpdateSummary {
#[derive(Clone, Debug, PartialEq)]
pub struct InboxEntry {
pub level: NotificationLevel,
pub level: Level,
pub id: u64,
pub title: String,
pub body: String,
@ -79,13 +80,7 @@ pub struct InboxEntry {
pub transfer: Option<TransferStats>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TransferStats {
pub speed_bps: f64,
pub eta_secs: Option<u64>,
}
/// One row in the expandable task list — an aggregate over all children of a
/// One row in the expandable task list, an aggregate over all children of a
/// single [`TaskCategory`] (e.g. all libraries collapse into one "Libraries" row).
#[derive(Clone, Debug, PartialEq)]
pub struct TaskView {
@ -101,7 +96,7 @@ pub struct TaskView {
pub struct NotificationSpec {
pub title: String,
pub body: String,
pub level: NotificationLevel,
pub level: Level,
pub icon: Option<IconType>,
pub progress: Option<(u64, u64)>,
pub actions: Vec<NotificationAction>,
@ -128,15 +123,33 @@ pub struct ActiveToast {
pub struct PendingPrompt {
pub title: String,
pub question: String,
pub kind: PromptKind,
pub reply_tx: Option<oneshot::Sender<UserChoice>>,
pub choices: Vec<Choice>,
pub dismiss: Option<String>,
/// Taken when answered, so a second answer cannot fire the oneshot twice.
pub reply_tx: Option<oneshot::Sender<Option<Answer>>>,
}
/// The renderable half of a [`PendingPrompt`]: everything except the reply
/// channel, which cannot be cloned into the snapshot.
#[derive(Clone, Debug)]
pub struct PendingPromptView {
pub title: String,
pub question: String,
pub kind: PromptKind,
pub choices: Vec<Choice>,
pub dismiss: Option<String>,
}
impl PendingPromptView {
/// Whether this prompt offers a choice with the given id. Lets a
/// specialised overlay (the Java one) recognise a prompt it knows how to
/// render richly, without the event layer naming the subsystem.
pub fn has_choice(&self, id: &str) -> bool {
self.choices.iter().any(|choice| choice.id == id)
}
pub fn choice(&self, id: &str) -> Option<&Choice> {
self.choices.iter().find(|choice| choice.id == id)
}
}
#[derive(Clone, Debug)]
@ -201,9 +214,8 @@ struct GroupedTasks {
/// Expected counts/bytes announced up-front so totals don't climb mid-download.
reserved_count: HashMap<TaskCategory, u64>,
reserved_units: HashMap<TaskCategory, u64>,
/// (timestamp, completed bytes) of the last speed sample.
last_sample: Option<(Instant, u64)>,
speed_bps: f64,
/// Transfer rate and ETA for this group's downloads.
meter: TransferMeter,
}
impl GroupedTasks {
@ -285,6 +297,12 @@ impl NotificationState {
}
}
/// Entry ids with a toast currently showing.
#[must_use]
pub fn active_toast_entry_ids(&self) -> Vec<u64> {
self.active_toasts.iter().map(|t| t.entry_id).collect()
}
pub fn open_cluster_update(&mut self, summaries: Vec<ClusterUpdateSummary>) {
if summaries.is_empty() {
return;
@ -300,54 +318,58 @@ impl NotificationState {
inbox.iter().filter(|entry| !entry.read).count()
}
/// Folds one notification into the engine state. Deliberately does not build
/// a [`NotificationSnapshot`]: during a download this runs tens of thousands
/// Folds one notification into the engine state. Does not build a
/// [`NotificationSnapshot`]: during a download this runs tens of thousands
/// of times, and every snapshot deep-clones the whole inbox only for the
/// caller to discard all but the last. Callers snapshot once, after draining.
pub fn dispatch(
&mut self,
inbox: &mut Vec<InboxEntry>,
notification: Notification,
event: Event,
) -> (Vec<ToastDismissTimer>, Option<PendingPrompt>) {
self.pending_timers.clear();
match notification {
Notification::Message { title, body, level } => {
let entry_id = self.push_inbox(inbox, title, body, level, None, false);
match event {
Event::Notification(Notification::Message(message)) => {
let entry_id = self.push_inbox(
inbox,
message.title,
message.body,
message.level,
None,
false,
);
self.push_ephemeral_toast(entry_id, MESSAGE_TOAST_TTL);
}
Notification::Progress {
// The Microsoft login renders its own progress in the sign-in
// modal, so it must not also become a toast. This is a front-end
// policy decision about a progress id the core merely names.
Event::Progress(ProgressEvent::Update { id, .. })
if id == oneclient_auth::MICROSOFT_LOGIN_PROGRESS => {}
Event::Progress(ProgressEvent::Update {
id,
label,
current,
total,
} => {
}) => {
self.handle_progress(inbox, id, label, current, total);
}
Notification::ProgressComplete { id, title, body } => {
Event::Progress(ProgressEvent::Complete { id, title, body }) => {
self.handle_progress_complete(inbox, id, title, body);
}
Notification::GroupedProgress(event) => {
Event::Progress(ProgressEvent::Grouped(event)) => {
self.handle_grouped_progress(inbox, event);
}
Notification::InvalidateClusters => {}
Notification::InvalidateJava => {}
Notification::SyncComplete => {}
Notification::MicrosoftLoginStatus(_) => {}
Notification::GameStage { .. }
| Notification::GameLog { .. }
| Notification::GameFailed { .. } => {}
Notification::Prompt {
title,
question,
kind,
reply_tx,
} => {
// Signals and game events drive the snapshot directly in the
// runtime loop; they never become inbox entries.
Event::Signal(_) | Event::Game(_) => {}
Event::Notification(Notification::Prompt(request)) => {
let pending_prompt = Some(PendingPrompt {
title,
question,
kind,
reply_tx: Some(reply_tx),
title: request.title,
question: request.body,
choices: request.choices,
dismiss: request.dismiss,
reply_tx: Some(request.reply),
});
let timers = std::mem::take(&mut self.pending_timers);
return (timers, pending_prompt);
@ -433,7 +455,7 @@ impl NotificationState {
inbox: &mut Vec<InboxEntry>,
title: String,
body: String,
level: NotificationLevel,
level: Level,
progress: Option<(u64, u64)>,
is_loading: bool,
) -> u64 {
@ -562,7 +584,7 @@ impl NotificationState {
inbox,
label.clone(),
body.clone(),
NotificationLevel::Info,
Level::Info,
progress,
!done,
);
@ -573,10 +595,10 @@ impl NotificationState {
self.update_inbox_entry(inbox, entry_id, label, body, progress, !done);
self.ensure_progress_toast(entry_id);
// NOTE: the id→entry mapping is intentionally kept even once `done`, so a
// later `ProgressComplete` can convert this same card into its finished state
// (see `handle_progress_complete`). The mapping is cleaned up when the entry is
// dismissed or the engine is reset.
// The id->entry mapping is kept even once `done`, so a later
// `ProgressComplete` can convert this same card into its finished state (see
// `handle_progress_complete`). It is cleaned up when the entry is dismissed
// or the engine is reset.
}
/// Convert an in-flight progress card into a finished message in place. Used so an
@ -594,7 +616,7 @@ impl NotificationState {
self.ensure_progress_toast(entry_id);
} else {
let entry_id =
self.push_inbox(inbox, title, body, NotificationLevel::Info, None, false);
self.push_inbox(inbox, title, body, Level::Info, None, false);
self.push_ephemeral_toast(entry_id, MESSAGE_TOAST_TTL);
}
}
@ -610,7 +632,7 @@ impl NotificationState {
inbox,
title.clone(),
"Preparing...".to_string(),
NotificationLevel::Info,
Level::Info,
None,
true,
);
@ -806,34 +828,11 @@ impl NotificationState {
let completed: u64 = tasks.iter().map(|t| t.current.min(t.total)).sum();
let total: u64 = tasks.iter().map(|t| t.total).sum::<u64>().max(1);
// Smooth the transfer rate with an EMA so the readout doesn't jitter.
let now = Instant::now();
let transfer = match group.last_sample {
Some((t0, b0)) => {
let dt = now.duration_since(t0).as_secs_f64();
if dt >= 0.25 {
let inst = completed.saturating_sub(b0) as f64 / dt;
group.speed_bps = if group.speed_bps <= 0.0 {
inst
} else {
group.speed_bps * 0.7 + inst * 0.3
};
group.last_sample = Some((now, completed));
}
let speed = group.speed_bps;
(speed >= 1.0).then(|| {
let remaining = total.saturating_sub(completed);
TransferStats {
speed_bps: speed,
eta_secs: Some((remaining as f64 / speed) as u64),
}
})
}
None => {
group.last_sample = Some((now, completed));
None
}
};
// Below 1 B/s the estimate is noise, not information.
let transfer = group
.meter
.sample(completed, total)
.filter(|stats| stats.speed_bps >= 1.0);
let Some(entry) = inbox.iter_mut().find(|e| e.id == entry_id) else {
return;

View file

@ -0,0 +1,132 @@
//! The app's own state, held in a freya-radio station.
//!
//! One struct republished over a `watch` channel woke every consumer on every
//! change (a toast timer tick re-rendering components that only read
//! `data_dir`) and deep-cloned the whole notification inbox on publish, tens of
//! thousands of times during a download.
//!
//! [`AppChannel`] gives per-concern subscription instead, and because the
//! station only requires `'static`, this owns [`NotificationState`] outright
//! rather than cloning a snapshot out of it on every event.
use std::collections::HashMap;
use std::sync::Arc;
use freya::radio::RadioChannel;
use oneclient_core::settings::LauncherSettings;
use oneclient_events::LaunchStage;
use crate::notifications::{InboxEntry, NotificationState, PendingPrompt};
/// What a component can subscribe to. Writing through a channel wakes only the
/// components that asked for it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum AppChannel {
/// Startup progress and the resolved data directory.
Launcher,
/// Launcher settings and their save state.
Settings,
/// Inbox, toasts and the pending prompt.
Notifications,
/// Per-cluster launch stage and live log lines.
Game,
/// Whether the account switcher popover is open.
AccountSwitcher,
/// Live progress of an in-flight Microsoft sign-in.
MicrosoftLogin,
}
impl RadioChannel<AppState> for AppChannel {}
#[derive(Default)]
pub struct AppState {
pub launcher: LauncherInit,
pub settings: SettingsState,
/// The notification engine itself, not a snapshot of it.
///
/// Both the event pump and UI actions fold into this, so it has to live where
/// both can reach it, and nothing has to clone the inbox per event.
pub notifications: NotificationState,
/// Held beside the engine because it folds events into the inbox by `&mut`
/// reference; keeping them together lets the engine stay a plain
/// state machine with no channels of its own.
pub inbox: Vec<InboxEntry>,
pub prompt: Option<PendingPrompt>,
/// Whether the notification centre panel is open.
pub center_open: bool,
pub game: GameState,
pub account_switcher_open: bool,
pub microsoft_login: Option<LoginProgress>,
}
#[derive(Clone, Debug, Default)]
pub struct LauncherInit {
pub ready: bool,
pub fetching: bool,
/// True while the background cluster-bundle download is running. Launch is
/// disabled until it clears.
pub syncing_bundles: bool,
pub error: Option<String>,
pub data_dir: String,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum AsyncStatus {
#[default]
Idle,
Loading,
Ready,
Error,
}
#[derive(Clone, Debug, Default)]
pub struct SettingsState {
pub settings: LauncherSettings,
pub status: AsyncStatus,
pub saving: bool,
pub error: Option<String>,
}
/// Live progress of an in-flight Microsoft sign-in, rendered inside the sign-in
/// modal rather than as a toast. UI state: the core reports this as ordinary
/// progress and has no opinion about where it is shown.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LoginProgress {
pub label: String,
pub current: u64,
pub total: u64,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct GameState {
pub stages: HashMap<i64, LaunchStage>,
pub error: Option<String>,
pub logs: HashMap<i64, Arc<Vec<Arc<str>>>>,
}
impl GameState {
#[must_use]
pub fn stage(&self, cluster_id: i64) -> Option<LaunchStage> {
self.stages.get(&cluster_id).copied()
}
#[must_use]
pub fn is_busy(&self, cluster_id: i64) -> bool {
self.stage(cluster_id).is_some_and(LaunchStage::is_busy)
}
#[must_use]
pub fn is_running(&self, cluster_id: i64) -> bool {
self.stage(cluster_id) == Some(LaunchStage::Running)
}
#[must_use]
pub fn is_active(&self, cluster_id: i64) -> bool {
matches!(self.stage(cluster_id), Some(s) if s != LaunchStage::Exited)
}
#[must_use]
pub fn logs_for(&self, cluster_id: i64) -> Arc<Vec<Arc<str>>> {
self.logs.get(&cluster_id).cloned().unwrap_or_default()
}
}

View file

@ -0,0 +1,133 @@
//! Download speed and time-remaining estimation.
//!
//! One shared estimator, so the notification engine and the onboarding
//! installer show the same numbers arrived at the same way.
use std::time::Instant;
/// Weight given to the existing average when a new sample arrives.
///
/// Downloads are bursty (a single asset finishing can spike the instantaneous
/// rate several-fold) so the average is kept sluggish on purpose.
const SMOOTHING: f64 = 0.7;
/// An exponentially-weighted average of transfer rate, plus the ETA that falls
/// out of it.
#[derive(Debug, Clone, Copy, Default)]
pub struct TransferMeter {
/// Timestamp and byte count of the last sample.
last_sample: Option<(Instant, u64)>,
speed_bps: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TransferStats {
pub speed_bps: f64,
/// `None` until there is enough signal to estimate, or when the total is
/// unknown.
pub eta_secs: Option<u64>,
}
impl TransferMeter {
/// Records progress and returns the current estimate.
///
/// `completed` and `total` are cumulative byte counts. Samples closer
/// together than a few hundred milliseconds are ignored rather than folded
/// in: the elapsed divisor gets small enough to swamp the average with
/// noise, which is what the fixed-tick version was working around by
/// sampling on a timer instead.
pub fn sample(&mut self, completed: u64, total: u64) -> Option<TransferStats> {
let now = Instant::now();
let Some((last_at, last_completed)) = self.last_sample else {
self.last_sample = Some((now, completed));
return None;
};
let elapsed = now.duration_since(last_at).as_secs_f64();
if elapsed < 0.25 {
return self.stats(completed, total);
}
let delta = completed.saturating_sub(last_completed) as f64;
let instant = delta / elapsed;
self.speed_bps = if self.speed_bps <= 0.0 {
instant
} else {
self.speed_bps * SMOOTHING + instant * (1.0 - SMOOTHING)
};
self.last_sample = Some((now, completed));
self.stats(completed, total)
}
fn stats(&self, completed: u64, total: u64) -> Option<TransferStats> {
if self.speed_bps <= 0.0 {
return None;
}
let remaining = total.saturating_sub(completed);
Some(TransferStats {
speed_bps: self.speed_bps,
// No total means no estimate, which beats a countdown derived from a
// denominator that is still climbing.
eta_secs: (total > 0).then(|| (remaining as f64 / self.speed_bps) as u64),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_first_sample_only_establishes_a_baseline() {
let mut meter = TransferMeter::default();
assert!(meter.sample(0, 1000).is_none());
}
#[test]
fn a_total_of_zero_yields_speed_without_an_eta() {
let mut meter = TransferMeter::default();
meter.sample(0, 0);
std::thread::sleep(std::time::Duration::from_millis(300));
let stats = meter.sample(1_000, 0).expect("should have a rate by now");
assert!(stats.speed_bps > 0.0);
assert_eq!(stats.eta_secs, None);
}
#[test]
fn eta_falls_as_the_download_completes() {
let mut meter = TransferMeter::default();
meter.sample(0, 10_000);
std::thread::sleep(std::time::Duration::from_millis(300));
let early = meter.sample(1_000, 10_000).unwrap().eta_secs.unwrap();
std::thread::sleep(std::time::Duration::from_millis(300));
let late = meter.sample(9_000, 10_000).unwrap().eta_secs.unwrap();
assert!(late < early, "eta should shrink: {early} -> {late}");
}
/// Rapid updates are the common case during an asset fan-out; they must not
/// divide by a near-zero elapsed and blow the average up.
#[test]
fn samples_taken_too_close_together_do_not_distort_the_average() {
let mut meter = TransferMeter::default();
meter.sample(0, 10_000);
std::thread::sleep(std::time::Duration::from_millis(300));
let settled = meter.sample(1_000, 10_000).unwrap().speed_bps;
for i in 1..50 {
meter.sample(1_000 + i, 10_000);
}
let after = meter.speed_bps;
assert!(
(after - settled).abs() < f64::EPSILON,
"burst of close samples changed the rate: {settled} -> {after}"
);
}
}

View file

@ -1,4 +1,3 @@
#![allow(dead_code)]
use std::time::Instant;
use freya::prelude::*;
@ -25,39 +24,53 @@ pub fn border_all_color(width: f32, color: Color) -> Border {
})
}
pub fn border_left(width: f32) -> Border {
Border::new()
.fill(theme::colors::component_border())
.width(BorderWidth {
left: width,
..Default::default()
})
}
pub fn border_right(width: f32) -> Border {
Border::new()
.fill(theme::colors::component_border())
.width(BorderWidth {
right: width,
..Default::default()
})
}
pub fn fmt_date(ts: chrono::DateTime<chrono::Utc>) -> String {
ts.format("%Y-%m-%d %H:%M").to_string()
}
/// How long ago, for a timestamp shown next to something that happened.
///
/// Nothing re-renders on a timer, so the shortest band is a whole minute: a
/// seconds counter would freeze at whatever it read when the row was drawn and
/// quietly lie from then on. The version this replaced had a seconds band and
/// no callers.
pub fn relative_time(created_at: Instant) -> String {
let secs = created_at.elapsed().as_secs();
match secs {
0..=9 => "Just now".to_string(),
10..=59 => format!("{secs}s ago"),
0..=59 => "Just now".to_string(),
60..=3599 => format!("{}m ago", secs / 60),
3600..=86_399 => format!("{}h ago", secs / 3600),
_ => format!("{}d ago", secs / 86_400),
}
}
/// A one-pixel rule in the component-border colour.
///
/// Returns the `Rect` rather than an `Element` so a caller that wants it inset
/// or rounded can say so without copying the whole thing out again.
pub fn divider() -> Rect {
rect()
.width(Size::fill())
.height(Size::px(1.))
.background(theme::colors::component_border())
}
/// A quiet line of text centred in a fixed block: "nothing here yet", or a
/// failure where a chart would otherwise be.
pub fn centered_note(text: &str) -> Element {
rect()
.width(Size::fill())
.height(Size::px(240.))
.center()
.child(
label()
.text(text.to_string())
.font_size(14.)
.color(theme::colors::fg_secondary()),
)
.into_element()
}
pub fn entrance_motion_layer(
slide_x: f32,
slide_y: f32,

View file

@ -1,10 +1,32 @@
use std::cell::Cell;
use cargo_packager_updater::{Config, Update, check_update};
use oneclient_core::LauncherState;
use oneclient_core::notification::{NotificationService, UserChoice};
use oneclient_events::{Choice, EventBus, Prompt};
use uuid::Uuid;
/// Choice id for the update prompt, so the overlay can recognise it among any
/// other pending prompt.
pub const UPDATE_CHOICE_INSTALL: &str = "update.install";
/// The only affirmative answer the update prompt offers; dismissing means "no".
enum UpdateAnswer {
Install,
}
/// The update prompt, shared by the real check and the debug simulation so the
/// two cannot drift.
fn update_prompt(version: &str) -> Prompt<UpdateAnswer> {
Prompt::new(
"Update available",
format!("OneClient {version} is ready to install. Download and install it now?"),
)
.option(
Choice::primary(UPDATE_CHOICE_INSTALL, "Install"),
UpdateAnswer::Install,
)
.dismiss("Not now")
}
pub const UPDATER_PUBKEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDFGODk3MkMyMjg0MjFDMDUKUldRRkhFSW93bktKSHpkWjNEMXNzaDVINVpCTU8xSnhuK2RnV0dTZ2FkcFJWbG1zUkhGYTNjaUkK";
pub const UPDATER_ENDPOINT: &str =
@ -22,8 +44,8 @@ pub fn spawn_update_check(auto_install: bool) {
});
}
/// Debug-only: drive the full auto-update UX — prompt, download progress, then the
/// "restart to apply" notification — against a fake release, without hitting the
/// Debug-only: drives the full auto-update UX (prompt, download progress, then the
/// "restart to apply" notification) against a fake release, without hitting the
/// network or touching disk. Mirrors [`run_check`] + [`download_and_install`] so the
/// debug page exercises the real notification path.
pub fn spawn_simulated_update() {
@ -38,38 +60,35 @@ async fn run_simulated_update() -> anyhow::Result<()> {
const FAKE_VERSION: &str = "9.9.9";
const FAKE_TOTAL: u64 = 48 * 1024 * 1024; // arbitrary 48 MiB "download"
let notifier = LauncherState::get()?.services.notifier.clone();
let events = crate::launcher::state()?.services.events.clone();
match notifier.prompt_update(FAKE_VERSION).await? {
UserChoice::Accept => {}
_ => {
tracing::info!("user declined simulated update");
return Ok(());
}
if events.ask(update_prompt(FAKE_VERSION)).await?.is_none() {
tracing::info!("user declined simulated update");
return Ok(());
}
let progress_id = Uuid::new_v4();
let label = format!("Downloading OneClient {FAKE_VERSION}");
let mut downloaded = 0u64;
notifier.send_progress(&progress_id, &label, downloaded, FAKE_TOTAL);
events.progress(progress_id, &label, downloaded, FAKE_TOTAL);
while downloaded < FAKE_TOTAL {
downloaded = (downloaded + PROGRESS_STEP * 8).min(FAKE_TOTAL);
notifier.send_progress(&progress_id, &label, downloaded, FAKE_TOTAL);
events.progress(progress_id, &label, downloaded, FAKE_TOTAL);
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
notifier.finish_progress(
&progress_id,
events.finish_progress(
progress_id,
"Finished Downloading",
&format!("OneClient {FAKE_VERSION} is ready — restart to apply."),
format!("OneClient {FAKE_VERSION} is ready. Restart to apply."),
);
Ok(())
}
async fn run_check(auto_install: bool) -> anyhow::Result<()> {
let notifier = LauncherState::get()?.services.notifier.clone();
let events = crate::launcher::state()?.services.events.clone();
// `check_update` performs a blocking HTTP request, so offload it to a thread pool.
let Some(update) = tokio::task::spawn_blocking(check_for_update).await?? else {
@ -82,40 +101,35 @@ async fn run_check(auto_install: bool) -> anyhow::Result<()> {
// The bundled self-updater can only replace an AppImage in place: on Linux
// cargo-packager-updater rejects every format except AppImage and locates the
// target via the `APPIMAGE` env var. deb/rpm installs run from a package-managed
// path (e.g. /usr/bin) we can't overwrite without root — attempting an install
// path (e.g. /usr/bin) we can't overwrite without root. Attempting an install
// would fail with a permission error, or clobber the system binary if elevated.
// Detect that case and point the user at the release page instead.
if !can_self_update() {
tracing::info!("install is not self-updatable (non-AppImage Linux); notifying only");
notifier.send_info(
"Update available",
&format!(
events.notify("Update available")
.body(format!(
"OneClient {} is available. Download the latest package from {} to update.",
update.version, RELEASES_URL
),
);
))
.send();
return Ok(());
}
if !auto_install {
match notifier.prompt_update(&update.version).await? {
UserChoice::Accept => {}
_ => {
tracing::info!("user declined update {}", update.version);
return Ok(());
}
if !auto_install
&& events.ask(update_prompt(&update.version)).await?.is_none() {
tracing::info!("user declined update {}", update.version);
return Ok(());
}
}
download_and_install(update, notifier).await
download_and_install(update, events).await
}
/// Whether the running install can be updated in place by the bundled updater.
///
/// Windows (NSIS) and macOS (.app) installs are always self-updatable. On Linux
/// only AppImage installs are: cargo-packager-updater replaces the file named by
/// the `APPIMAGE` env var and rejects every other format, so deb/rpm installs —
/// which run from a package-manager-owned path and never set `APPIMAGE` — are not.
/// the `APPIMAGE` env var and rejects every other format, so deb/rpm installs are
/// not: they run from a package-manager-owned path and never set `APPIMAGE`.
fn can_self_update() -> bool {
#[cfg(target_os = "linux")]
{
@ -138,12 +152,12 @@ fn check_for_update() -> anyhow::Result<Option<Update>> {
Ok(check_update(current, config)?)
}
async fn download_and_install(update: Update, notifier: NotificationService) -> anyhow::Result<()> {
async fn download_and_install(update: Update, events: EventBus) -> anyhow::Result<()> {
let progress_id = Uuid::new_v4();
let version = update.version.clone();
let label = format!("Downloading OneClient {version}");
notifier.send_progress(&progress_id, &label, 0, 0);
events.progress(progress_id, &label, 0, 0);
tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
let downloaded = Cell::new(0u64);
@ -160,23 +174,23 @@ async fn download_and_install(update: Update, notifier: NotificationService) ->
|| now - last_sent.get() >= PROGRESS_STEP
{
last_sent.set(now);
notifier.send_progress(&progress_id, &label, now, total);
events.progress(progress_id, &label, now, total);
}
},
|| {},
)?;
let total = downloaded.get().max(1);
notifier.send_progress(&progress_id, &label, total, total);
events.progress(progress_id, &label, total, total);
update.install(bytes)?;
// Convert the same download card into its finished state instead of emitting a
// second notification, so the user sees one notification through to completion.
notifier.finish_progress(
&progress_id,
events.finish_progress(
progress_id,
"Finished Downloading",
&format!("OneClient {version} is ready — restart to apply."),
format!("OneClient {version} is ready. Restart to apply."),
);
Ok(())
})

View file

@ -3,15 +3,15 @@ use std::collections::BTreeMap;
use chrono::{Datelike, NaiveDate};
use oneclient_core::clusters::Cluster;
use oneclient_core::packages::domain::GameLoader;
use oneclient_core::{ParsedMcVersion, VersionKey, format_mc_version, parse_mc_version};
use oneclient_common::domain::GameLoader;
use oneclient_common::{ParsedMcVersion, VersionKey, format_mc_version, parse_mc_version};
pub type ClusterGroups = BTreeMap<ReleaseLine, Vec<Cluster>>;
/// One entry on the versions page. The modern scheme puts a full release in the
/// first two components (`26.1`, `26.2`), so each minor is its own line; legacy
/// `1.x` versions keep the whole major (`1.21`) as one line. A line that holds a
/// single concrete version is presented as that version — see [`line_title`].
/// single concrete version is presented as that version (see [`line_title`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ReleaseLine {
pub major: u32,

View file

@ -2,7 +2,7 @@ use chrono::{DateTime, Utc};
use freya::animation::{AnimNum, Function, OnFinish, use_animation};
use freya::prelude::*;
use freya::query::{MutationCapability, MutationStateData, UseMutation};
use oneclient_core::auth::{AccountKind, MinecraftAccount};
use oneclient_auth::{AccountKind, MinecraftAccount};
use uuid::Uuid;
use crate::components::{
@ -60,7 +60,7 @@ impl Component for Accounts {
let offline_name = username.read().trim().to_string();
let offline_uuid = (!offline_name.is_empty())
.then(|| oneclient_core::auth::offline_uuid(&offline_name).to_string());
.then(|| oneclient_auth::offline_uuid(&offline_name).to_string());
let offline_error = mutation_err_text(&add_offline);

View file

@ -1,12 +1,11 @@
use freya::prelude::*;
use freya::query::QueryStateData;
use oneclient_core::game::ServerStat;
use crate::components::{
Button, Icon, IconType, OverlayPopup, PieChart, ScrollArea, ValueUnit, slice_color,
};
use crate::hooks::use_cached_image;
use crate::hooks::{loaded_image, use_cached_image};
use crate::theme::colors;
use crate::ui::border_all_color;
use crate::utils::{format_duration_hm, plural};
@ -168,7 +167,7 @@ fn legend(values: &[i64], labels: &[String], active: Option<usize>) -> Element {
}
/// A Component (not a plain builder) because it mounts a `ScrollArea`, whose
/// hooks run in the caller's render scope — mounting it conditionally from
/// hooks run in the caller's render scope. Mounting it conditionally from
/// `ServersSection` would change that scope's hook count and panic Freya.
#[derive(PartialEq)]
struct ServerDetailsPopup {
@ -372,17 +371,7 @@ impl Component for ServerIcon {
fn render(&self) -> impl IntoElement {
let size = self.size;
let query = use_cached_image(self.url.clone(), SERVER_ICON_EDGE);
let reader = query.read();
let loaded = match (&self.url, &*reader.state()) {
(Some(url), QueryStateData::Settled { res: Ok(bytes), .. })
| (
Some(url),
QueryStateData::Loading {
res: Some(Ok(bytes)),
},
) => Some((url.clone(), bytes.clone())),
_ => None,
};
let loaded = loaded_image(self.url.as_deref(), &query);
match loaded {
Some((url, bytes)) => ImageViewer::new((url, bytes))

View file

@ -1,8 +1,8 @@
use super::*;
use freya::router::RouterContext;
use oneclient_core::packages::ProviderId;
use oneclient_core::packages::types::ProjectSummary;
use oneclient_content::packages::ProviderId;
use oneclient_content::packages::types::ProjectSummary;
use crate::components::{Icon, IconType};
use crate::routes::Route;

View file

@ -4,9 +4,9 @@ use freya::animation::{
AnimNum, Ease, Function, OnChange, OnCreation, use_animation_with_dependencies,
};
use freya::prelude::*;
use oneclient_core::packages::types::ProjectSummary;
use oneclient_core::packages::types::SearchSort;
use oneclient_core::packages::{ContentType, ProviderId};
use oneclient_content::packages::types::ProjectSummary;
use oneclient_content::packages::types::SearchSort;
use oneclient_content::packages::{ContentType, ProviderId};
use oneclient_core::settings::ViewLayout;
use crate::components::{
@ -18,7 +18,7 @@ use crate::hooks::{
use_package_categories, use_package_search, use_view_state,
};
use crate::theme::colors;
use crate::view::app::cluster::load_cluster;
use crate::hooks::use_cluster;
use super::{PackageBanner, Thumbnail};
use crate::utils::abbreviate_number;
@ -96,7 +96,7 @@ impl Component for Browser {
Duration::from_millis(SEARCH_DEBOUNCE_MS),
);
let cluster = load_cluster(cluster_id);
let cluster = use_cluster(cluster_id);
let provider_id = *provider.read();
let compat = *compatible_only.read();
let cats = selected_categories.read().clone();

View file

@ -5,10 +5,9 @@ pub use index::Browser;
pub use package::BrowserPackage;
use freya::prelude::*;
use freya::query::QueryStateData;
use crate::components::{Icon, IconType};
use crate::hooks::use_cached_image;
use crate::hooks::{loaded_image, use_cached_image};
use crate::theme::colors;
const BANNER_BG: Color = Color::from_rgb(21, 28, 34);
@ -48,17 +47,7 @@ impl Component for Thumbnail {
let size = self.size;
let radius = self.radius;
let query = use_cached_image(self.icon_url.clone(), 256);
let reader = query.read();
let loaded = match (&self.icon_url, &*reader.state()) {
(Some(url), QueryStateData::Settled { res: Ok(bytes), .. })
| (
Some(url),
QueryStateData::Loading {
res: Some(Ok(bytes)),
},
) => Some((url.clone(), bytes.clone())),
_ => None,
};
let loaded = loaded_image(self.icon_url.as_deref(), &query);
match loaded {
Some((url, bytes)) => ImageViewer::new((url, bytes))
@ -111,17 +100,7 @@ impl Component for PackageBanner {
let h = self.height;
let icon = h * 0.62;
let query = use_cached_image(self.icon_url.clone(), 512);
let reader = query.read();
let loaded = match (&self.icon_url, &*reader.state()) {
(Some(url), QueryStateData::Settled { res: Ok(bytes), .. })
| (
Some(url),
QueryStateData::Loading {
res: Some(Ok(bytes)),
},
) => Some((url.clone(), bytes.clone())),
_ => None,
};
let loaded = loaded_image(self.icon_url.as_deref(), &query);
let banner = rect()
.width(Size::fill())

View file

@ -1,5 +1,5 @@
use freya::prelude::*;
use oneclient_core::packages::{ContentType, ProviderId};
use oneclient_content::packages::{ContentType, ProviderId};
use crate::components::ScrollArea;
use crate::hooks::{
@ -8,7 +8,7 @@ use crate::hooks::{
};
use crate::theme::colors;
use crate::ui::border_all_color;
use crate::view::app::cluster::load_cluster;
use crate::hooks::use_cluster;
use super::{PackageBanner, Thumbnail};
use crate::utils::abbreviate_number;
@ -108,7 +108,7 @@ impl Component for BrowserPackage {
let dispatch = use_dispatch();
let confirm = use_link_confirm();
let cluster = load_cluster(cluster_id);
let cluster = use_cluster(cluster_id);
let compat = *compatible_only.read();
let (game_version, loader) = match (compat, &cluster) {
(true, Some(c)) => (

View file

@ -1,14 +1,13 @@
use super::*;
use freya::query::QueryStateData;
use oneclient_core::packages::ProviderId;
use oneclient_core::packages::types::{
use oneclient_content::packages::ProviderId;
use oneclient_content::packages::types::{
GalleryImage, PackageBody, ProjectDetail, ReleaseType, VersionSummary,
};
use crate::BridgeDispatch;
use crate::Actions;
use crate::components::{Button, Icon, IconType, Segment, SegmentedControl};
use crate::hooks::{VERSIONS_PAGE_SIZE, use_cached_image};
use crate::hooks::{VERSIONS_PAGE_SIZE, loaded_image, use_cached_image};
use crate::theme::colors;
use crate::ui::border_all_color;
use crate::utils::format_size;
@ -116,14 +115,7 @@ impl KeyExt for GalleryTile {
impl Component for GalleryTile {
fn render(&self) -> impl IntoElement {
let query = use_cached_image(Some(self.image.url.clone()), GALLERY_EDGE);
let reader = query.read();
let loaded = match &*reader.state() {
QueryStateData::Settled { res: Ok(bytes), .. }
| QueryStateData::Loading {
res: Some(Ok(bytes)),
} => Some((self.image.url.clone(), bytes.clone())),
_ => None,
};
let loaded = loaded_image(Some(&self.image.url), &query);
let preview = rect()
.width(Size::fill())
@ -172,7 +164,7 @@ pub(super) fn versions_panel(
provider: ProviderId,
project_id: String,
cluster_id: i64,
dispatch: BridgeDispatch,
dispatch: Actions,
) -> impl IntoElement {
let current = *versions_page.read();
let total_pages = total_versions.div_ceil(VERSIONS_PAGE_SIZE).max(1);
@ -271,7 +263,7 @@ fn version_row(
provider: ProviderId,
project_id: String,
cluster_id: i64,
dispatch: BridgeDispatch,
dispatch: Actions,
) -> impl IntoElement {
let version_id = v.version_id.clone();
let mut chips: Vec<String> = v.loaders.iter().map(|l| l.to_string()).collect();

View file

@ -1,9 +1,9 @@
use super::*;
use oneclient_core::packages::ProviderId;
use oneclient_core::packages::types::{ProjectDetail, ProjectMember};
use oneclient_content::packages::ProviderId;
use oneclient_content::packages::types::{ProjectDetail, ProjectMember};
use crate::BridgeDispatch;
use crate::Actions;
use crate::components::{Button, Icon, IconType};
use crate::theme::colors;
use crate::ui::border_all_color;
@ -13,7 +13,7 @@ pub(super) fn sidebar(
latest_version: Option<String>,
provider: ProviderId,
cluster_id: i64,
dispatch: BridgeDispatch,
dispatch: Actions,
confirm: State<Option<String>>,
) -> impl IntoElement {
let Some(project) = project else {

View file

@ -1,7 +1,7 @@
use freya::prelude::*;
use oneclient_core::Patch;
use oneclient_core::java::JavaRuntime;
use oneclient_core::packages::domain::GameLoader;
use oneclient_common::Patch;
use oneclient_java::JavaRuntime;
use oneclient_common::domain::GameLoader;
use oneclient_core::settings::{GameSettingsProfile, ProfileUpdate, Resolution};
use crate::components::{
@ -16,7 +16,8 @@ use crate::layout::cluster_content;
use crate::theme::colors;
use crate::view::app::settings::{section_header, settings_row};
use super::{cluster_not_found, load_cluster};
use super::cluster_not_found;
use crate::hooks::use_cluster;
#[derive(PartialEq)]
pub struct ClusterSettings {
@ -28,7 +29,7 @@ impl Component for ClusterSettings {
let cluster_id = self.cluster_id;
let global = use_settings_snapshot().settings.global_game_settings;
let cluster = load_cluster(cluster_id);
let cluster = use_cluster(cluster_id);
let profile_name = cluster
.as_ref()
.and_then(|c| c.setting_profile_name.clone());

View file

@ -20,7 +20,8 @@ use crate::theme::colors;
use crate::ui;
use crate::utils::format_size;
use super::{cluster_not_found, load_cluster};
use super::cluster_not_found;
use crate::hooks::use_cluster;
const CARD_BG: Color = Color::from_rgb(26, 34, 41);
const PICKER_ROW_H: f32 = 44.;
const PICKER_ROW_SPACING: f32 = 2.;
@ -160,7 +161,7 @@ impl Component for ClusterLogs {
None,
);
let Some(cluster) = load_cluster(cluster_id) else {
let Some(cluster) = use_cluster(cluster_id) else {
return cluster_not_found();
};
let folder = cluster.dir().ok().map(|d| d.join("logs"));

View file

@ -20,25 +20,9 @@ pub use shaders::ClusterShaders;
pub use textures::ClusterTextures;
use freya::prelude::*;
use freya::query::QueryStateData;
use oneclient_core::clusters::Cluster;
use crate::hooks::use_clusters;
use crate::theme::colors;
pub(crate) fn load_cluster(cluster_id: i64) -> Option<Cluster> {
let clusters_query = use_clusters();
let reader = clusters_query.read();
let list = match &*reader.state() {
QueryStateData::Settled { res: Ok(list), .. }
| QueryStateData::Loading {
res: Some(Ok(list)),
} => list.clone(),
_ => Vec::new(),
};
list.into_iter().find(|c| c.id == cluster_id)
}
pub(crate) fn cluster_not_found() -> Element {
rect()
.width(Size::fill())

View file

@ -1,5 +1,5 @@
use freya::prelude::*;
use oneclient_core::packages::ContentType;
use oneclient_content::packages::ContentType;
use crate::hooks::{
bundle_overrides_map, bundles_with_status_items, cluster_content_items, use_bundle_overrides,
@ -10,7 +10,8 @@ use crate::layout::cluster_content;
use super::package_manager::{
PackageManager, bundle_categories, bundle_packages, use_content_meta,
};
use super::{cluster_not_found, load_cluster};
use super::cluster_not_found;
use crate::hooks::use_cluster;
#[derive(PartialEq)]
pub struct ClusterMods {
@ -26,7 +27,7 @@ impl Component for ClusterMods {
let content_items = cluster_content_items(&content);
let meta = use_content_meta(&content_items, &bundle_items, ContentType::Mod);
let Some(_cluster) = load_cluster(self.cluster_id) else {
let Some(_cluster) = use_cluster(self.cluster_id) else {
return cluster_not_found();
};

View file

@ -4,9 +4,11 @@ use crate::components::ScrollArea;
use crate::hooks::{try_cluster_analytics, use_cluster_analytics};
use crate::layout::cluster_content;
use crate::theme::colors;
use crate::ui::{centered_note};
use crate::view::app::{analytics_body, analytics_placeholder};
use super::{cluster_not_found, load_cluster};
use super::cluster_not_found;
use crate::hooks::use_cluster;
#[derive(PartialEq)]
pub struct ClusterOverview {
@ -17,7 +19,7 @@ impl Component for ClusterOverview {
fn render(&self) -> impl IntoElement {
let cluster_id = self.cluster_id;
let Some(cluster) = load_cluster(cluster_id) else {
let Some(cluster) = use_cluster(cluster_id) else {
return cluster_content().child(cluster_not_found()).into_element();
};
@ -69,17 +71,3 @@ fn overview_header(name: &str) -> Element {
)
.into_element()
}
fn centered_note(text: &str) -> Element {
rect()
.width(Size::fill())
.height(Size::px(240.))
.center()
.child(
label()
.text(text.to_string())
.font_size(14.)
.color(colors::fg_secondary()),
)
.into_element()
}

View file

@ -1,7 +1,7 @@
use std::collections::{HashMap, HashSet};
use freya::prelude::*;
use oneclient_core::packages::{CachedPackageMeta, ContentType, ProviderId};
use oneclient_content::packages::{CachedPackageMeta, ContentType, ProviderId};
use oneclient_core::{BundleFileKind, BundleWithUpdateStatus, LinkedArtifactInfo};
use oneclient_db::models::OverrideType;

View file

@ -2,7 +2,7 @@ use super::*;
use freya::animation::{AnimNum, Ease, OnCreation, use_animation};
use freya::router::RouterContext;
use oneclient_core::packages::ContentType;
use oneclient_content::packages::ContentType;
use oneclient_core::settings::ViewLayout;
use crate::components::{
@ -12,7 +12,7 @@ use crate::components::{
use crate::hooks::use_dispatch;
use crate::routes::Route;
use crate::theme::colors;
use crate::{BridgeDispatch, utils};
use crate::{Actions, utils};
#[derive(Clone, Copy, PartialEq)]
pub(super) enum SortMode {
@ -404,7 +404,7 @@ impl Component for ChoiceRow {
fn add_from_file_button(
cluster_id: i64,
content_type: ContentType,
dispatch: BridgeDispatch,
dispatch: Actions,
) -> impl IntoElement {
Button::new()
.primary()
@ -550,8 +550,8 @@ impl Component for ContentBox {
let mut bottom_corners = CornerRadius::new_all(0.);
bottom_corners.fill_bottom(12.);
// File drops are handled window-wide by the app shell, which prompts for
// a destination — this box deliberately doesn't claim them.
// File drops are handled window-wide by the app shell, which prompts for a
// destination, so this box does not claim them.
rect()
.vertical()
.width(Size::fill())
@ -680,7 +680,7 @@ fn external_empty(cluster_id: i64, package_type: &'static str) -> impl IntoEleme
fn local_empty(
cluster_id: i64,
content_type: ContentType,
dispatch: BridgeDispatch,
dispatch: Actions,
noun_plural: &'static str,
) -> impl IntoElement {
let is_wayland = utils::is_wayland();

View file

@ -5,7 +5,8 @@ use crate::hooks::use_game_snapshot;
use crate::layout::cluster_content;
use crate::theme::colors;
use super::{cluster_not_found, load_cluster};
use super::cluster_not_found;
use crate::hooks::use_cluster;
#[derive(PartialEq)]
pub struct ProcessLogs {
@ -15,7 +16,7 @@ pub struct ProcessLogs {
impl Component for ProcessLogs {
fn render(&self) -> impl IntoElement {
let game = use_game_snapshot();
let Some(_cluster) = load_cluster(self.cluster_id) else {
let Some(_cluster) = use_cluster(self.cluster_id) else {
return cluster_not_found();
};

View file

@ -2,7 +2,6 @@ use std::collections::HashSet;
use std::path::PathBuf;
use freya::prelude::*;
use freya::query::QueryStateData;
use oneclient_core::ScreenshotInfo;
use oneclient_core::settings::ViewLayout;
@ -10,16 +9,14 @@ use crate::components::{
Button, ContextMenu, Icon, IconType, LocalImage, OverlayPopup, ScreenshotViewer, ScrollArea,
Segment, SegmentedControl, open_folder_button,
};
use crate::hooks::{
ScreenshotAction, try_cluster_screenshots, use_cluster_screenshots, use_dispatch,
use_screenshot_action, use_view_state,
};
use crate::hooks::{ScreenshotAction, query_is_loading, try_cluster_screenshots, use_cluster_screenshots, use_dispatch, use_screenshot_action, use_view_state};
use crate::layout::cluster_content;
use crate::theme::colors;
use crate::ui::{border_all_color, fmt_date};
use crate::utils::{format_res, format_size};
use super::{cluster_not_found, load_cluster};
use super::cluster_not_found;
use crate::hooks::use_cluster;
const THUMB_EDGE: u32 = 480;
const MAX_COL_W: f32 = 400.;
@ -36,7 +33,7 @@ pub struct ClusterScreenshots {
impl Component for ClusterScreenshots {
fn render(&self) -> impl IntoElement {
let Some(cluster) = load_cluster(self.cluster_id) else {
let Some(cluster) = use_cluster(self.cluster_id) else {
return cluster_not_found();
};
let folder = cluster.game_dir().ok().map(|d| d.join("screenshots"));
@ -66,10 +63,7 @@ impl Component for ClusterScreenshots {
);
let content: Element = if shots.is_empty() {
empty_state(matches!(
&*query.read().state(),
QueryStateData::Loading { res: None }
))
empty_state(query_is_loading(&query))
.into_element()
} else {
let mut items: Vec<Element> = Vec::new();

View file

@ -1,5 +1,5 @@
use freya::prelude::*;
use oneclient_core::packages::ContentType;
use oneclient_content::packages::ContentType;
use crate::hooks::{
bundle_overrides_map, bundles_with_status_items, cluster_content_items, use_bundle_overrides,
@ -10,7 +10,8 @@ use crate::layout::cluster_content;
use super::package_manager::{
PackageManager, bundle_categories, bundle_packages, use_content_meta,
};
use super::{cluster_not_found, load_cluster};
use super::cluster_not_found;
use crate::hooks::use_cluster;
#[derive(PartialEq)]
pub struct ClusterShaders {
@ -26,7 +27,7 @@ impl Component for ClusterShaders {
let content_items = cluster_content_items(&content);
let meta = use_content_meta(&content_items, &bundle_items, ContentType::Shader);
let Some(_cluster) = load_cluster(self.cluster_id) else {
let Some(_cluster) = use_cluster(self.cluster_id) else {
return cluster_not_found();
};

View file

@ -1,5 +1,5 @@
use freya::prelude::*;
use oneclient_core::packages::ContentType;
use oneclient_content::packages::ContentType;
use crate::hooks::{
bundle_overrides_map, bundles_with_status_items, cluster_content_items, use_bundle_overrides,
@ -10,7 +10,8 @@ use crate::layout::cluster_content;
use super::package_manager::{
PackageManager, bundle_categories, bundle_packages, use_content_meta,
};
use super::{cluster_not_found, load_cluster};
use super::cluster_not_found;
use crate::hooks::use_cluster;
#[derive(PartialEq)]
pub struct ClusterTextures {
@ -26,7 +27,7 @@ impl Component for ClusterTextures {
let content_items = cluster_content_items(&content);
let meta = use_content_meta(&content_items, &bundle_items, ContentType::ResourcePack);
let Some(_cluster) = load_cluster(self.cluster_id) else {
let Some(_cluster) = use_cluster(self.cluster_id) else {
return cluster_not_found();
};

View file

@ -1,17 +1,13 @@
use freya::prelude::*;
use freya::query::QueryStateData;
use freya::router::RouterContext;
use oneclient_core::VersionKey;
use oneclient_common::VersionKey;
use oneclient_core::clusters::Cluster;
use oneclient_core::packages::domain::GameLoader;
use oneclient_common::domain::GameLoader;
use crate::components::{
Button, ClusterLandscapeArt, Dropdown, Icon, IconType, ScrollArea, VersionCard,
};
use crate::hooks::{
use_active_cluster_id, use_clusters, use_dispatch, use_game_snapshot, use_launcher,
use_version_metadata,
};
use crate::hooks::{settled_or_loading, use_active_cluster_id, use_clusters, use_dispatch, use_game_snapshot, use_launcher, use_version_metadata};
use crate::routes::Route;
use crate::theme::colors;
use crate::ui::border_all_color;
@ -39,16 +35,7 @@ impl Component for Clusters {
let mut selected_loader = use_state(|| None::<GameLoader>);
let mut grid_columns = use_state(|| 2_usize);
let clusters = {
let reader = clusters_query.read();
match &*reader.state() {
QueryStateData::Settled { res: Ok(list), .. } => list.clone(),
QueryStateData::Loading {
res: Some(Ok(list)),
} => list.clone(),
_ => Vec::new(),
}
};
let clusters = settled_or_loading(&clusters_query).unwrap_or_default();
let groups = group_clusters_by_release(&clusters);
// Newest version first.
@ -98,7 +85,7 @@ impl Component for Clusters {
if selected_version.read().is_none() {
let preferred = active_cluster
.as_ref()
.and_then(|c| oneclient_core::parse_mc_version(&c.mc_version))
.and_then(|c| oneclient_common::parse_mc_version(&c.mc_version))
.and_then(|p| p.key());
*selected_version.write() = default_version_key(&clusters_for_line, preferred);
}
@ -322,7 +309,7 @@ fn tags_row(tags: &[String]) -> Option<Element> {
)
}
/// Only shown when the line holds more than one version — a single-version line
/// Only shown when the line holds more than one version; a single-version line
/// already names it on the card and in the heading.
fn version_rows(
line: ReleaseLine,
@ -400,7 +387,7 @@ fn info_row(label_text: impl Into<String>, control: impl IntoElement) -> Element
fn play_button(
cluster_id: i64,
dispatch: crate::BridgeDispatch,
dispatch: crate::Actions,
state: (&'static str, bool),
) -> impl IntoElement {
let (label, enabled) = state;

View file

@ -1,9 +1,8 @@
use freya::prelude::*;
use freya::router::RouterContext;
use oneclient_core::LauncherState;
use oneclient_db::console::{ConsoleQueryResult, run_console_query};
use oneclient_core::auth::preview_samples;
use oneclient_auth::preview_samples;
use crate::components::{Button, Icon, IconType, TextInput, login_dialog, toggle};
use crate::hooks::use_dispatch;
@ -266,7 +265,7 @@ impl Component for ClusterUpdateSimulator {
)
.child(
label()
.text("Presets — every shape the notification and modal can take.")
.text("Presets: every shape the notification and modal can take.")
.font_size(13.)
.color(colors::fg_secondary()),
)
@ -350,7 +349,7 @@ const CLUSTER_UPDATE_PRESETS: [(&str, IconType, ClusterUpdatePreset); 6] = [
}),
];
fn cluster_update_preset_rows(dispatch: &crate::BridgeDispatch) -> Vec<Element> {
fn cluster_update_preset_rows(dispatch: &crate::Actions) -> Vec<Element> {
CLUSTER_UPDATE_PRESETS
.chunks(3)
.map(|chunk| {
@ -398,7 +397,7 @@ fn cluster_update_items(names: &[impl AsRef<str>]) -> Vec<ClusterUpdateItem> {
/// Mirrors the copy the bridge builds for real syncs so the simulated
/// notification is indistinguishable from the real one.
fn send_cluster_update(dispatch: &crate::BridgeDispatch, summaries: Vec<ClusterUpdateSummary>) {
fn send_cluster_update(dispatch: &crate::Actions, summaries: Vec<ClusterUpdateSummary>) {
if summaries.is_empty() {
return;
}
@ -441,7 +440,7 @@ impl Component for LauncherUpdateSimulator {
.spacing(10.)
.child(
label()
.text("Drives the real auto-update UX (prompt → download progress → \"restart to apply\") against a fake release — no network or disk. \"Check for Updates Now\" runs the real check against the release feed.")
.text("Drives the real auto-update UX (prompt → download progress → \"restart to apply\") against a fake release, with no network or disk. \"Check for Updates Now\" runs the real check against the release feed.")
.font_size(13.)
.color(colors::fg_secondary()),
)
@ -584,7 +583,7 @@ fn run_sql(query: State<String>, mut result: State<SqlResult>, mut running: Stat
running.set(true);
spawn(async move {
let res = match LauncherState::get() {
let res = match crate::launcher::state() {
Ok(state) => run_console_query(&state.services.db, &sql)
.await
.map_err(|e| e.to_string()),
@ -616,7 +615,7 @@ fn sql_result(state: &SqlResult) -> Element {
)
.into_element(),
Some(Ok(res)) if !res.is_select => label()
.text(format!("OK — {} row(s) affected.", res.rows_affected))
.text(format!("OK, {} row(s) affected.", res.rows_affected))
.font_size(13.)
.color(colors::success())
.into_element(),
@ -705,13 +704,12 @@ fn split_csv(raw: &str) -> Vec<String> {
.collect()
}
/// The debug page's rule: the shared one, but inset and rounded to sit
/// between its loose stacks of rows rather than butt up against them.
fn divider() -> impl IntoElement {
rect()
.width(Size::fill())
.height(Size::px(1.))
crate::ui::divider()
.margin(Gaps::new_symmetric(8., 0.))
.corner_radius(CornerRadius::new_all(1.))
.background(colors::component_border())
.into_element()
}

View file

@ -10,8 +10,8 @@ pub mod browser;
pub mod cluster;
pub mod settings;
use crate::bridge::GameSnapshot;
use oneclient_core::notification::LaunchStage;
use crate::state::GameState as GameSnapshot;
use oneclient_events::LaunchStage;
pub fn launch_button_state(
game: &GameSnapshot,

View file

@ -1,9 +1,9 @@
use freya::prelude::*;
use oneclient_core::java::{JavaRuntime, JavaVendor};
use oneclient_java::{JavaRuntime, JavaVendor};
use super::settings_page;
use crate::components::{Button, Icon, IconType, JavaInstallManager, ScrollArea};
use crate::hooks::{BridgeDispatch, java_runtimes, use_dispatch, use_java_runtimes};
use crate::hooks::{Actions, java_runtimes, use_dispatch, use_java_runtimes};
use crate::theme::colors;
use crate::ui::border_all_color;
use crate::view::app::settings::section_header;
@ -234,7 +234,7 @@ fn path_content_width(path: &str) -> f32 {
(path.chars().count() as f32 * 7.0).max(1.0)
}
fn remove_button(dispatch: BridgeDispatch, path: String) -> impl IntoElement {
fn remove_button(dispatch: Actions, path: String) -> impl IntoElement {
Button::new()
.ghost()
.small()

View file

@ -1,5 +1,5 @@
use freya::prelude::*;
use oneclient_core::Patch;
use oneclient_common::Patch;
use oneclient_core::settings::{ProfileUpdate, Resolution};
use super::settings_page;

View file

@ -3,6 +3,7 @@ use freya::prelude::*;
use crate::components::ScrollArea;
use crate::hooks::{try_global_analytics, use_global_analytics};
use crate::theme::colors;
use crate::ui::{centered_note};
use crate::view::app::{analytics_body, analytics_placeholder};
#[derive(PartialEq)]
@ -66,17 +67,3 @@ fn header() -> Element {
fn loading_state() -> Element {
centered_note("Crunching your playtime…")
}
fn centered_note(text: &str) -> Element {
rect()
.width(Size::fill())
.height(Size::px(240.))
.center()
.child(
label()
.text(text.to_string())
.font_size(14.)
.color(colors::fg_secondary()),
)
.into_element()
}

View file

@ -1,5 +1,5 @@
use freya::prelude::*;
use oneclient_core::auth::MinecraftAccount;
use oneclient_auth::MinecraftAccount;
use crate::components::{Avatar, Button, Icon, IconType, use_microsoft_login};
use crate::hooks::{try_default_account, use_current_account};

View file

@ -1,18 +1,14 @@
use std::collections::{HashMap, HashSet};
use freya::prelude::*;
use freya::query::QueryStateData;
use oneclient_core::clusters::Cluster;
use oneclient_core::packages::ProviderId;
use oneclient_content::packages::ProviderId;
use oneclient_core::{BundleArchive, BundleFile, BundleFileKind};
use crate::components::ScrollArea;
use crate::hooks::{
ClusterBundles, onboarding_bundles_items, package_meta_batch, use_onboarding_bundles,
use_onboarding_selection, use_package_meta_batch,
};
use crate::hooks::{ClusterBundles, onboarding_bundles_items, package_meta_batch, query_error, query_is_loading, use_onboarding_bundles, use_onboarding_selection, use_package_meta_batch};
type MetaMap = HashMap<String, oneclient_core::packages::CachedPackageMeta>;
type MetaMap = HashMap<String, oneclient_content::packages::CachedPackageMeta>;
use crate::routes::Route;
use crate::theme::colors;
use crate::ui::border_all_color;
@ -212,17 +208,10 @@ impl Component for OnboardingBundles {
let clusters = onboarding_bundles_items(&bundles_query).unwrap_or_default();
let bundles_loaded = onboarding_bundles_items(&bundles_query).is_some();
let catalog_msg = {
let reader = bundles_query.read();
match &*reader.state() {
QueryStateData::Settled { res: Err(err), .. } => {
format!("Couldn't load bundles: {err}")
}
QueryStateData::Pending | QueryStateData::Loading { res: None } => {
"Loading bundles...".to_string()
}
_ => "No optional bundles available".to_string(),
}
let catalog_msg = match query_error(&bundles_query) {
Some(err) => format!("Couldn't load bundles: {err}"),
None if query_is_loading(&bundles_query) => "Loading bundles...".to_string(),
None => "No optional bundles available".to_string(),
};
let selected_set = selected.read().clone();

View file

@ -1,9 +1,8 @@
use freya::prelude::*;
use freya::query::QueryStateData;
use oneclient_core::packages::ProviderId;
use oneclient_content::packages::ProviderId;
use crate::components::{Icon, provider_badge};
use crate::hooks::use_cached_image;
use crate::hooks::{loaded_image, use_cached_image};
use crate::theme::colors;
use crate::ui::border_all_color;
use crate::utils::format_size;
@ -29,19 +28,7 @@ pub(super) struct OnboardingModCard {
impl Component for OnboardingModCard {
fn render(&self) -> impl IntoElement {
let icon_query = use_cached_image(self.icon_url.clone(), 128);
let loaded = {
let reader = icon_query.read();
match (&self.icon_url, &*reader.state()) {
(Some(url), QueryStateData::Settled { res: Ok(bytes), .. })
| (
Some(url),
QueryStateData::Loading {
res: Some(Ok(bytes)),
},
) => Some((url.clone(), bytes.clone())),
_ => None,
}
};
let loaded = loaded_image(self.icon_url.as_deref(), &icon_query);
let icon = match loaded {
Some((url, bytes)) => ImageViewer::new((url, bytes))

View file

@ -4,8 +4,8 @@ use std::time::{Duration, Instant};
use freya::animation::*;
use freya::prelude::*;
use freya::router::RouterContext;
use oneclient_core::notification::{
GroupedProgressEvent, GroupedProgressSession, Notification, NotificationService, TaskCategory,
use oneclient_events::{
EventBus, GroupedProgressEvent, GroupedProgressSession, ProgressEvent, TaskCategory,
};
use oneclient_core::{
BundleArchive, BundleFile, Cluster, ImportTarget, MigrationSource, SentryExclusion,
@ -16,13 +16,14 @@ use tokio::sync::mpsc;
use uuid::Uuid;
use crate::hooks::{
BridgeDispatch, ClusterBundles, invalidate_cluster_queries, migration_detection,
Actions, ClusterBundles, invalidate_cluster_queries, migration_detection,
onboarding_bundles_items, try_default_account, use_current_account, use_dispatch,
use_migration, use_onboarding_bundles, use_onboarding_selection, use_settings_snapshot,
use_versions, versions_metadata,
};
use crate::routes::Route;
use crate::theme::colors;
use crate::transfer::{TransferMeter, TransferStats};
use crate::view::onboarding::{matching_new_cluster_id, pkg_key};
mod backdrop;
@ -101,7 +102,7 @@ impl GroupedAgg {
/// One lane per category, taken round-robin.
///
/// Assets run 32-at-a-time and sort before everything alphabetically, so a
/// flat sorted list showed nothing but assets for the whole game download —
/// flat sorted list showed nothing but assets for the whole game download;
/// libraries, natives and the client were downloading at the same time and
/// never got a row. Round-robin gives every active category a slot before
/// any category gets a second one.
@ -178,11 +179,6 @@ impl GroupedAgg {
}
}
#[derive(Clone, Copy, PartialEq, Default)]
struct Meter {
speed_bps: f64,
}
fn categorize(label: &str) -> &'static str {
if label.starts_with("Assets index") {
"asset index"
@ -225,7 +221,7 @@ struct InstallHandles {
stage: State<DownloadStage>,
activity: State<String>,
started_at: State<Option<Instant>>,
meter: State<Meter>,
meter: State<Option<TransferStats>>,
total_estimate: State<u64>,
failures: State<Vec<InstallFailure>>,
running: State<bool>,
@ -258,7 +254,7 @@ impl Component for OnboardingDownloading {
let stage = use_state(DownloadStage::default);
let activity = use_state(String::new);
let started_at = use_state(|| None::<Instant>);
let meter = use_state(Meter::default);
let meter = use_state(|| None::<TransferStats>);
let total_estimate = use_state(|| 0u64);
let failures = use_state(Vec::<InstallFailure>::new);
let running = use_state(|| false);
@ -309,7 +305,7 @@ impl Component for OnboardingDownloading {
let stage_now = stage.read().clone();
let agg_now = agg.read().clone();
let activity_now = activity.read().clone();
let meter_now = *meter.read();
let transfer = *meter.read();
let total_estimate_now = *total_estimate.read();
let elapsed_secs = (*started_at.read()).map(|s| s.elapsed().as_secs());
let predownload = *predownload_state.read();
@ -488,7 +484,7 @@ impl Component for OnboardingDownloading {
stage: &stage_now,
agg: &agg_now,
activity: &activity_now,
speed_bps: meter_now.speed_bps,
transfer,
total_estimate: total_estimate_now,
elapsed_secs,
done,
@ -645,37 +641,25 @@ fn run_install_batch(plans: Vec<ClusterPlan>, predownload: bool, handles: Instal
complete.set(false);
failures.set(Vec::new());
agg.set(GroupedAgg::default());
meter.set(Meter::default());
meter.set(None);
total_estimate.set(0);
activity.set("Getting started...".to_string());
started_at.set(Some(Instant::now()));
let total = plans.len();
progress.set((0, total));
let (notif_tx, mut notif_rx) = mpsc::unbounded_channel::<Notification>();
let notifier = NotificationService::new(notif_tx);
let (events, mut notif_rx) = EventBus::channel();
let (ui_tx, mut ui_rx) = mpsc::unbounded_channel::<InstallUiEvent>();
spawn(async move {
let mut prev = 0u64;
let mut speed = 0f64;
let mut transfer = TransferMeter::default();
loop {
tokio::time::sleep(Duration::from_millis(1000)).await;
let downloaded = agg.peek().downloaded_bytes();
let delta = downloaded.saturating_sub(prev);
let inst = delta as f64;
speed = if speed <= 0.0 {
inst
} else {
speed * 0.6 + inst * 0.4
};
prev = downloaded;
meter.set(Meter { speed_bps: speed });
meter.set(transfer.sample(downloaded, *total_estimate.peek()));
if !*running.peek() {
break;
@ -689,12 +673,12 @@ fn run_install_batch(plans: Vec<ClusterPlan>, predownload: bool, handles: Instal
let Some(first) = notif_rx.recv().await else {
break;
};
if let Notification::GroupedProgress(event) = first {
if let oneclient_events::Event::Progress(ProgressEvent::Grouped(event)) = first {
apply_grouped(&mut local, event);
}
// Drain everything already queued in one pass.
while let Ok(notification) = notif_rx.try_recv() {
if let Notification::GroupedProgress(event) = notification {
if let oneclient_events::Event::Progress(ProgressEvent::Grouped(event)) = notification {
apply_grouped(&mut local, event);
}
}
@ -730,7 +714,7 @@ fn run_install_batch(plans: Vec<ClusterPlan>, predownload: bool, handles: Instal
let _ = ui_tx.send(InstallUiEvent::Activity(
"Calculating download size...".to_string(),
));
if let Ok(state) = oneclient_core::LauncherState::get() {
if let Ok(state) = crate::launcher::state() {
let mut sum = 0u64;
for plan in plans.iter().filter(|p| p.predownload) {
match oneclient_core::estimate_cluster_download(
@ -759,7 +743,7 @@ fn run_install_batch(plans: Vec<ClusterPlan>, predownload: bool, handles: Instal
}));
let fetch_now = predownload && plan.predownload;
if let Err(err) = install_one(&plan, fetch_now, &notifier, &ui_tx).await {
if let Err(err) = install_one(&plan, fetch_now, &events, &ui_tx).await {
// Environmental / expected failures (out of disk, offline, no Java
// build for this version, ...) are surfaced to the user but aren't
// crashes, so log them at warn! (breadcrumb) instead of error!
@ -785,7 +769,7 @@ fn run_install_batch(plans: Vec<ClusterPlan>, predownload: bool, handles: Instal
let _ = ui_tx.send(InstallUiEvent::Progress(index + 1, total));
}
drop(notifier);
drop(events);
let _ = ui_tx.send(InstallUiEvent::Finished(failed));
});
}
@ -793,17 +777,17 @@ fn run_install_batch(plans: Vec<ClusterPlan>, predownload: bool, handles: Instal
async fn install_one(
plan: &ClusterPlan,
predownload: bool,
notifier: &NotificationService,
events: &EventBus,
ui_tx: &mpsc::UnboundedSender<InstallUiEvent>,
) -> oneclient_core::LauncherResult<()> {
let state = oneclient_core::LauncherState::get()?;
let state = crate::launcher::state()?;
if !plan.overrides.is_empty() {
let _ = ui_tx.send(InstallUiEvent::Activity(
"Saving your package choices...".to_string(),
));
}
oneclient_core::set_bundle_package_overrides(plan.cluster_id, &plan.overrides, &state.services)
oneclient_core::set_bundle_package_overrides(plan.cluster_id, &plan.overrides, &state.services.content())
.await?;
if !predownload {
@ -811,14 +795,14 @@ async fn install_one(
}
let session =
GroupedProgressSession::start(notifier, format!("Downloading {}", plan.mc_version));
GroupedProgressSession::start(events, format!("Downloading {}", plan.mc_version));
let _ = ui_tx.send(InstallUiEvent::Activity(format!(
"Downloading Minecraft {}...",
plan.mc_version
)));
let prepared = oneclient_core::ClusterManager::prepare(
let prepared = oneclient_core::clusters::prepare_cluster_locked(
&state,
plan.cluster_id,
false,
@ -837,7 +821,7 @@ async fn install_one(
plan.cluster_id,
state.bundles.as_ref(),
Some(&session),
&state.services,
&state.services.content(),
)
.await
} else {
@ -913,7 +897,7 @@ fn apply_grouped(agg: &mut GroupedAgg, event: GroupedProgressEvent) {
}
fn finish_onboarding(
dispatch: BridgeDispatch,
dispatch: Actions,
bundles_query: &freya::query::UseQuery<crate::hooks::OnboardingBundlesQuery>,
) {
let versions: Vec<String> = onboarding_bundles_items(bundles_query)

View file

@ -94,7 +94,8 @@ pub(super) struct ProgressView<'a> {
pub(super) stage: &'a DownloadStage,
pub(super) agg: &'a GroupedAgg,
pub(super) activity: &'a str,
pub(super) speed_bps: f64,
/// `None` until the meter has two samples to work from.
pub(super) transfer: Option<TransferStats>,
pub(super) total_estimate: u64,
pub(super) elapsed_secs: Option<u64>,
pub(super) done: usize,
@ -109,7 +110,7 @@ pub(super) fn progress_panel(view: ProgressView) -> impl IntoElement {
stage,
agg,
activity,
speed_bps,
transfer,
total_estimate,
elapsed_secs,
done,
@ -124,7 +125,7 @@ pub(super) fn progress_panel(view: ProgressView) -> impl IntoElement {
"Done".to_string()
} else if predownload && stage.total > 0 {
format!(
"Version {} of {} — {}",
"Version {} of {}: {}",
(stage.index + 1).min(stage.total),
stage.total,
stage.label
@ -151,7 +152,7 @@ pub(super) fn progress_panel(view: ProgressView) -> impl IntoElement {
let stats = build_stats(StatsView {
global,
speed_bps,
transfer,
total_estimate,
elapsed_secs,
running,
@ -221,7 +222,7 @@ pub(super) fn progress_panel(view: ProgressView) -> impl IntoElement {
struct StatsView {
global: f32,
speed_bps: f64,
transfer: Option<TransferStats>,
total_estimate: u64,
elapsed_secs: Option<u64>,
running: bool,
@ -232,7 +233,7 @@ struct StatsView {
fn build_stats(view: StatsView) -> Option<Element> {
let StatsView {
global,
speed_bps,
transfer,
total_estimate,
elapsed_secs,
running,
@ -248,18 +249,18 @@ fn build_stats(view: StatsView) -> Option<Element> {
if finished {
parts.push("Complete".to_string());
} else {
if global > 2.0 && global < 100.0 {
let remaining = (elapsed as f64) * ((100.0 - global as f64) / global as f64);
parts.push(format!(
// Both readings come off the same smoothed rate, so the countdown and
// the throughput beside it always agree.
match transfer.and_then(|t| t.eta_secs) {
Some(remaining) => parts.push(format!(
"~{} left",
format_duration_hms(remaining.round() as i64)
));
} else {
parts.push("~— left".to_string());
format_duration_hms(remaining as i64)
)),
None => parts.push("~— left".to_string()),
}
if speed_bps >= 1.0 {
parts.push(format!("{}/s", format_size(speed_bps as u64)));
if let Some(speed) = transfer.map(|t| t.speed_bps).filter(|s| *s >= 1.0) {
parts.push(format!("{}/s", format_size(speed as u64)));
}
}

View file

@ -47,7 +47,7 @@ pub(super) fn use_onboarding_tip() -> String {
use_hook(move || {
spawn(async move {
let Ok(state) = oneclient_core::LauncherState::get() else {
let Ok(state) = crate::launcher::state() else {
return;
};
match state

View file

@ -72,7 +72,7 @@ impl Component for OnboardingMigration {
"Bring over your Minecraft files",
"We found a Minecraft installation on this PC. We can copy your worlds, \
settings, screenshots and servers over. Mods, resource packs and shaders stay \
behind — OneClient manages those for you."
behind; OneClient manages those for you."
.to_string(),
)
} else {
@ -97,9 +97,9 @@ impl Component for OnboardingMigration {
import_choice_card(
"Don't import files",
if is_vanilla {
"Start fresh — nothing is copied over."
"Start fresh. Nothing is copied over."
} else {
"Start fresh — only your bundle selection carries over."
"Start fresh. Only your bundle selection carries over."
},
None,
chosen_folder.is_none(),

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