fix: move (almost) all IO off the UI thread (#886)
This commit is contained in:
parent
1574d1afb1
commit
bdb9780794
14 changed files with 521 additions and 339 deletions
|
|
@ -340,7 +340,9 @@ 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?);
|
||||
let state = crate::launcher::install(
|
||||
crate::launcher::off_ui(oneclient_core::LauncherState::new(events)).await?,
|
||||
);
|
||||
|
||||
oneclient_net::status::start(state.services.requester.clone());
|
||||
oneclient_polyplus::start(std::sync::Arc::clone(&state.auth));
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ use oneclient_events::{Answer, Level};
|
|||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::components::IconType;
|
||||
use crate::launcher::off_ui;
|
||||
use crate::notifications::{
|
||||
ClusterUpdateSummary, NotificationAction, NotificationSpec, OptionalModRef, OptionalModsGroup,
|
||||
OptionalModsOutcome, PackageUpdateGroup, PendingPrompt,
|
||||
|
|
@ -548,8 +549,14 @@ impl Actions {
|
|||
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
|
||||
match off_ui({
|
||||
let state = state.clone();
|
||||
async move {
|
||||
oneclient_core::import_migration_game_dir(&state, source, &folder_name, target)
|
||||
.await
|
||||
}
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
events
|
||||
|
|
@ -571,7 +578,12 @@ impl Actions {
|
|||
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 {
|
||||
match off_ui({
|
||||
let state = state.clone();
|
||||
async move { state.java.install_runtime_from(&vendor, major).await }
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
events.signal(oneclient_events::Signal::JavaChanged);
|
||||
invalidate_java_queries().await
|
||||
|
|
@ -788,87 +800,95 @@ impl Actions {
|
|||
actions.close_optional_mods(OptionalModsOutcome::Launch);
|
||||
return;
|
||||
};
|
||||
let content = state.services.content();
|
||||
let events = state.services.events.clone();
|
||||
let session =
|
||||
oneclient_events::GroupedProgressSession::start(&events, "Adding optional mods");
|
||||
let opt_in = session.child(
|
||||
"Enabling mods",
|
||||
mods.len() as u64,
|
||||
oneclient_events::TaskCategory::Packages,
|
||||
);
|
||||
opt_in.set_phase(oneclient_events::TaskPhase::Installing);
|
||||
let (installed, failed) = off_ui(async move {
|
||||
let content = state.services.content();
|
||||
let session = oneclient_events::GroupedProgressSession::start(
|
||||
&state.services.events,
|
||||
"Adding optional mods",
|
||||
);
|
||||
let opt_in = session.child(
|
||||
"Enabling mods",
|
||||
mods.len() as u64,
|
||||
oneclient_events::TaskCategory::Packages,
|
||||
);
|
||||
opt_in.set_phase(oneclient_events::TaskPhase::Installing);
|
||||
|
||||
let mut clusters: Vec<ClusterId> = Vec::new();
|
||||
let mut enabled: Vec<(ClusterId, OptionalModRef)> = Vec::new();
|
||||
let mut failed = 0usize;
|
||||
for (cluster_id, (bundle_name, package_id)) in &mods {
|
||||
match oneclient_core::set_bundle_package_enabled(
|
||||
*cluster_id,
|
||||
bundle_name,
|
||||
package_id,
|
||||
true,
|
||||
false,
|
||||
&content,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
if !clusters.contains(cluster_id) {
|
||||
clusters.push(*cluster_id);
|
||||
let mut clusters: Vec<ClusterId> = Vec::new();
|
||||
let mut enabled: Vec<(ClusterId, OptionalModRef)> = Vec::new();
|
||||
let mut failed = 0usize;
|
||||
for (cluster_id, (bundle_name, package_id)) in &mods {
|
||||
match oneclient_core::set_bundle_package_enabled(
|
||||
*cluster_id,
|
||||
bundle_name,
|
||||
package_id,
|
||||
true,
|
||||
false,
|
||||
&content,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
if !clusters.contains(cluster_id) {
|
||||
clusters.push(*cluster_id);
|
||||
}
|
||||
enabled.push((*cluster_id, (bundle_name.clone(), package_id.clone())));
|
||||
}
|
||||
Err(err) => {
|
||||
failed += 1;
|
||||
tracing::warn!(
|
||||
cluster_id,
|
||||
%bundle_name,
|
||||
%package_id,
|
||||
error = %err,
|
||||
"failed to opt in to an optional mod"
|
||||
);
|
||||
}
|
||||
enabled.push((*cluster_id, (bundle_name.clone(), package_id.clone())));
|
||||
}
|
||||
Err(err) => {
|
||||
failed += 1;
|
||||
tracing::warn!(
|
||||
cluster_id,
|
||||
%bundle_name,
|
||||
%package_id,
|
||||
error = %err,
|
||||
"failed to opt in to an optional mod"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
opt_in.finish();
|
||||
opt_in.finish();
|
||||
|
||||
let mut installed = 0usize;
|
||||
let mut applied: Vec<ClusterId> = Vec::new();
|
||||
for cluster_id in &clusters {
|
||||
match oneclient_core::apply_bundle_updates_with(
|
||||
*cluster_id,
|
||||
state.bundles.as_ref(),
|
||||
&content,
|
||||
Some(&session),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
installed += result.additions_applied.len();
|
||||
failed += result.additions_failed.len();
|
||||
applied.push(*cluster_id);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(cluster_id, error = %err, "failed to install optional mods");
|
||||
let mut installed = 0usize;
|
||||
let mut applied: Vec<ClusterId> = Vec::new();
|
||||
for cluster_id in &clusters {
|
||||
match oneclient_core::apply_bundle_updates_with(
|
||||
*cluster_id,
|
||||
state.bundles.as_ref(),
|
||||
&content,
|
||||
Some(&session),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
installed += result.additions_applied.len();
|
||||
failed += result.additions_failed.len();
|
||||
applied.push(*cluster_id);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(cluster_id, error = %err, "failed to install optional mods");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An offer only counts as answered once its mod really was enabled
|
||||
// and installed otherwise the click is lost and never comes back
|
||||
enabled.retain(|(cluster_id, _)| applied.contains(cluster_id));
|
||||
for (cluster_id, package_ids) in group_by_cluster(&enabled) {
|
||||
if let Err(err) =
|
||||
oneclient_core::resolve_optional_mods(cluster_id, &package_ids, &content).await
|
||||
{
|
||||
tracing::warn!(cluster_id, error = %err, "failed to clear answered offers");
|
||||
// An offer only counts as answered once its mod really was enabled
|
||||
// and installed otherwise the click is lost and never comes back
|
||||
enabled.retain(|(cluster_id, _)| applied.contains(cluster_id));
|
||||
for (cluster_id, package_ids) in group_by_cluster(&enabled) {
|
||||
if let Err(err) =
|
||||
oneclient_core::resolve_optional_mods(cluster_id, &package_ids, &content)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(cluster_id, error = %err, "failed to clear answered offers");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
session.finish();
|
||||
session.finish();
|
||||
(installed, failed)
|
||||
})
|
||||
.await;
|
||||
|
||||
super::invalidate_cluster_queries().await;
|
||||
|
||||
if installed > 0 {
|
||||
|
|
@ -1030,14 +1050,16 @@ impl Actions {
|
|||
spawn_forever(async move {
|
||||
let Ok(state) = launcher::state() else { return };
|
||||
let events = state.services.events.clone();
|
||||
match oneclient_content::packages::PackageStore::import_local_files(
|
||||
&files,
|
||||
cluster_id,
|
||||
&state.services.content(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(report) => {
|
||||
let imported = off_ui({
|
||||
let state = state.clone();
|
||||
async move {
|
||||
let report = oneclient_content::packages::PackageStore::import_local_files(
|
||||
&files,
|
||||
cluster_id,
|
||||
&state.services.content(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut deferred = false;
|
||||
for row in &report.imported {
|
||||
let live = oneclient_content::packages::PackageStore::sync_live_content(
|
||||
|
|
@ -1050,7 +1072,13 @@ impl Actions {
|
|||
|
||||
deferred |= live == LiveSync::Deferred;
|
||||
}
|
||||
Ok::<_, oneclient_content::ContentError>((report, deferred))
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
match imported {
|
||||
Ok((report, deferred)) => {
|
||||
notify_import(
|
||||
&events,
|
||||
&report,
|
||||
|
|
@ -1137,15 +1165,24 @@ impl Actions {
|
|||
return;
|
||||
};
|
||||
|
||||
let install = match crate::install::install_package(
|
||||
&state,
|
||||
provider,
|
||||
&project_id,
|
||||
&version_id,
|
||||
cluster_id,
|
||||
world.clone(),
|
||||
allow_flagged,
|
||||
)
|
||||
let install = match off_ui({
|
||||
let state = state.clone();
|
||||
let project_id = project_id.clone();
|
||||
let version_id = version_id.clone();
|
||||
let world = world.clone();
|
||||
async move {
|
||||
crate::install::install_package(
|
||||
&state,
|
||||
provider,
|
||||
&project_id,
|
||||
&version_id,
|
||||
cluster_id,
|
||||
world,
|
||||
allow_flagged,
|
||||
)
|
||||
.await
|
||||
}
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(install) => install,
|
||||
|
|
@ -1299,13 +1336,20 @@ impl Actions {
|
|||
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(),
|
||||
)
|
||||
match off_ui({
|
||||
let state = state.clone();
|
||||
let bundle_name = bundle_name.clone();
|
||||
async move {
|
||||
oneclient_core::install_bundle(
|
||||
cluster_id,
|
||||
&bundle_name,
|
||||
skip_compatibility,
|
||||
state.bundles.as_ref(),
|
||||
&state.services.content(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
|
|
@ -1328,12 +1372,18 @@ impl Actions {
|
|||
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(),
|
||||
None,
|
||||
)
|
||||
let result = match off_ui({
|
||||
let state = state.clone();
|
||||
async move {
|
||||
oneclient_core::apply_bundle_updates(
|
||||
cluster_id,
|
||||
state.bundles.as_ref(),
|
||||
&state.services.content(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
|
|
@ -1388,14 +1438,26 @@ impl Actions {
|
|||
|
||||
let mut station = self.station;
|
||||
spawn_forever(async move {
|
||||
// The copy is on this thread's executor, so it can write the count
|
||||
// straight into the channel the move screen reads
|
||||
let result = oneclient_core::relocate::relocate(&state, &plan, |copied, total| {
|
||||
let (progress, mut seen) = tokio::sync::watch::channel((0, 0));
|
||||
let mut copy = std::pin::pin!(off_ui(async move {
|
||||
oneclient_core::relocate::relocate(&state, &plan, |copied, total| {
|
||||
let _ = progress.send((copied, total));
|
||||
})
|
||||
.await
|
||||
}));
|
||||
|
||||
let mut show_progress = |(copied, total)| {
|
||||
let mut guard = station.write_channel(AppChannel::Relocation);
|
||||
guard.relocation.copied = copied;
|
||||
guard.relocation.total = total;
|
||||
})
|
||||
.await;
|
||||
};
|
||||
let result = loop {
|
||||
tokio::select! {
|
||||
result = &mut copy => break result,
|
||||
Ok(()) = seen.changed() => show_progress(*seen.borrow_and_update()),
|
||||
}
|
||||
};
|
||||
show_progress(*seen.borrow());
|
||||
|
||||
if let Err(message) = &result {
|
||||
tracing::error!("moving the data folder failed: {message}");
|
||||
|
|
@ -1445,15 +1507,18 @@ impl Actions {
|
|||
.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:#}");
|
||||
}
|
||||
off_ui(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:#}");
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
actions
|
||||
.station
|
||||
|
|
@ -1488,22 +1553,28 @@ impl Actions {
|
|||
.launcher
|
||||
.syncing_bundles = true;
|
||||
|
||||
let synced =
|
||||
match tokio::time::timeout(BUNDLE_SYNC_BUDGET, state.bundles.sync(&content)).await {
|
||||
Ok(Ok(_)) => true,
|
||||
Ok(Err(err)) => {
|
||||
tracing::warn!(
|
||||
cluster_id,
|
||||
error = %err,
|
||||
"bundle catalog sync failed, launching against the cached catalog"
|
||||
);
|
||||
false
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
tracing::debug!(cluster_id, "bundle catalog sync exceeded its launch budget");
|
||||
false
|
||||
}
|
||||
};
|
||||
let sync = {
|
||||
let bundles = state.bundles.clone();
|
||||
let content = content.clone();
|
||||
off_ui(
|
||||
async move { tokio::time::timeout(BUNDLE_SYNC_BUDGET, bundles.sync(&content)).await },
|
||||
)
|
||||
};
|
||||
let synced = match sync.await {
|
||||
Ok(Ok(_)) => true,
|
||||
Ok(Err(err)) => {
|
||||
tracing::warn!(
|
||||
cluster_id,
|
||||
error = %err,
|
||||
"bundle catalog sync failed, launching against the cached catalog"
|
||||
);
|
||||
false
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
tracing::debug!(cluster_id, "bundle catalog sync exceeded its launch budget");
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
self.station
|
||||
.clone()
|
||||
|
|
@ -1513,12 +1584,19 @@ impl Actions {
|
|||
|
||||
// Bundle content is forced, so the update mode never gates it and the
|
||||
// modal never lists it
|
||||
let result = match oneclient_core::apply_bundle_updates(
|
||||
cluster_id,
|
||||
state.bundles.as_ref(),
|
||||
&content,
|
||||
Some(Instant::now() + BUNDLE_APPLY_BUDGET),
|
||||
)
|
||||
let deadline = Instant::now() + BUNDLE_APPLY_BUDGET;
|
||||
let result = match off_ui({
|
||||
let state = state.clone();
|
||||
async move {
|
||||
oneclient_core::apply_bundle_updates(
|
||||
cluster_id,
|
||||
state.bundles.as_ref(),
|
||||
&state.services.content(),
|
||||
Some(deadline),
|
||||
)
|
||||
.await
|
||||
}
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
|
|
@ -1567,10 +1645,13 @@ impl Actions {
|
|||
|
||||
// Bounded a hanging network would otherwise hold the launch for
|
||||
// reqwest's much longer timeouts
|
||||
let check = match tokio::time::timeout(
|
||||
UPDATE_CHECK_BUDGET,
|
||||
oneclient_core::refresh_browser_package_updates(cluster_id, &content),
|
||||
)
|
||||
let check = match off_ui(async move {
|
||||
tokio::time::timeout(
|
||||
UPDATE_CHECK_BUDGET,
|
||||
oneclient_core::refresh_browser_package_updates(cluster_id, &content),
|
||||
)
|
||||
.await
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(check)) => check,
|
||||
|
|
@ -1642,39 +1723,45 @@ impl Actions {
|
|||
state: &Arc<oneclient_core::LauncherState>,
|
||||
updates: &[oneclient_core::BrowserPackageUpdate],
|
||||
) {
|
||||
let content = state.services.content();
|
||||
let session = oneclient_events::GroupedProgressSession::start(
|
||||
&state.services.events,
|
||||
"Updating packages",
|
||||
);
|
||||
session.expect(
|
||||
oneclient_events::TaskCategory::Packages,
|
||||
updates.len() as u64,
|
||||
updates.len() as u64,
|
||||
);
|
||||
|
||||
let mut applied = 0usize;
|
||||
for update in updates {
|
||||
let child = session.child(
|
||||
update.display_name.clone(),
|
||||
1,
|
||||
oneclient_events::TaskCategory::Packages,
|
||||
let state = state.clone();
|
||||
let updates = updates.to_vec();
|
||||
let (applied, session_id) = off_ui(async move {
|
||||
let content = state.services.content();
|
||||
let session = oneclient_events::GroupedProgressSession::start(
|
||||
&state.services.events,
|
||||
"Updating packages",
|
||||
);
|
||||
session.expect(
|
||||
oneclient_events::TaskCategory::Packages,
|
||||
updates.len() as u64,
|
||||
updates.len() as u64,
|
||||
);
|
||||
match oneclient_core::apply_browser_package_update(update, Some(&child), &content).await
|
||||
{
|
||||
Ok(_) => applied += 1,
|
||||
// Not a reason to hold the game back the cache row survives
|
||||
// so the next launch offers it again
|
||||
Err(err) => tracing::warn!(
|
||||
package = %update.display_name,
|
||||
error = %err,
|
||||
"automatic package update failed"
|
||||
),
|
||||
}
|
||||
child.finish();
|
||||
}
|
||||
|
||||
let session_id = session.detach();
|
||||
let mut applied = 0usize;
|
||||
for update in &updates {
|
||||
let child = session.child(
|
||||
update.display_name.clone(),
|
||||
1,
|
||||
oneclient_events::TaskCategory::Packages,
|
||||
);
|
||||
match oneclient_core::apply_browser_package_update(update, Some(&child), &content)
|
||||
.await
|
||||
{
|
||||
Ok(_) => applied += 1,
|
||||
// Not a reason to hold the game back the cache row survives
|
||||
// so the next launch offers it again
|
||||
Err(err) => tracing::warn!(
|
||||
package = %update.display_name,
|
||||
error = %err,
|
||||
"automatic package update failed"
|
||||
),
|
||||
}
|
||||
child.finish();
|
||||
}
|
||||
|
||||
(applied, session.detach())
|
||||
})
|
||||
.await;
|
||||
let spec = (applied > 0).then(|| NotificationSpec {
|
||||
title: "Packages updated".to_string(),
|
||||
body: format!(
|
||||
|
|
@ -1705,11 +1792,17 @@ impl Actions {
|
|||
state: &Arc<oneclient_core::LauncherState>,
|
||||
cluster_id: ClusterId,
|
||||
) -> OptionalModsOutcome {
|
||||
let pending = match oneclient_core::pending_optional_mods(
|
||||
cluster_id,
|
||||
state.bundles.as_ref(),
|
||||
&state.services.content(),
|
||||
)
|
||||
let pending = match off_ui({
|
||||
let state = state.clone();
|
||||
async move {
|
||||
oneclient_core::pending_optional_mods(
|
||||
cluster_id,
|
||||
state.bundles.as_ref(),
|
||||
&state.services.content(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(pending) => pending,
|
||||
|
|
@ -1762,28 +1855,33 @@ impl Actions {
|
|||
let actions = self.clone();
|
||||
spawn_forever(async move {
|
||||
let Ok(state) = launcher::state() else { return };
|
||||
let events = state.services.events.clone();
|
||||
|
||||
let session = oneclient_events::GroupedProgressSession::start(
|
||||
&events,
|
||||
format!("Updating {}", update.display_name),
|
||||
);
|
||||
let child = session.child(
|
||||
update.display_name.clone(),
|
||||
1,
|
||||
oneclient_events::TaskCategory::Packages,
|
||||
);
|
||||
let (result, session_id) = off_ui({
|
||||
let update = update.clone();
|
||||
async move {
|
||||
let session = oneclient_events::GroupedProgressSession::start(
|
||||
&state.services.events,
|
||||
format!("Updating {}", update.display_name),
|
||||
);
|
||||
let child = session.child(
|
||||
update.display_name.clone(),
|
||||
1,
|
||||
oneclient_events::TaskCategory::Packages,
|
||||
);
|
||||
|
||||
let result = oneclient_core::apply_browser_package_update(
|
||||
&update,
|
||||
Some(&child),
|
||||
&state.services.content(),
|
||||
)
|
||||
let result = oneclient_core::apply_browser_package_update(
|
||||
&update,
|
||||
Some(&child),
|
||||
&state.services.content(),
|
||||
)
|
||||
.await;
|
||||
|
||||
child.finish();
|
||||
(result, session.detach())
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
child.finish();
|
||||
let session_id = session.detach();
|
||||
|
||||
let spec = match &result {
|
||||
Ok(_) => NotificationSpec {
|
||||
title: "Updated".to_string(),
|
||||
|
|
@ -1912,16 +2010,19 @@ async fn launch(actions: &Actions, cluster_id: ClusterId) {
|
|||
return;
|
||||
}
|
||||
|
||||
if let Err(err) = oneclient_core::launch_cluster(&state, cluster_id, &account, true).await {
|
||||
// A missing file is the one failure the launcher can fix itself and a
|
||||
// path inside our metadata folder gives the user nothing to act on
|
||||
if err.indicates_missing_files() {
|
||||
repair_and_relaunch(&state, cluster_id, &account, err).await;
|
||||
return;
|
||||
}
|
||||
off_ui(async move {
|
||||
if let Err(err) = oneclient_core::launch_cluster(&state, cluster_id, &account, true).await {
|
||||
// A missing file is the one failure the launcher can fix itself and a
|
||||
// path inside our metadata folder gives the user nothing to act on
|
||||
if err.indicates_missing_files() {
|
||||
repair_and_relaunch(&state, cluster_id, &account, err).await;
|
||||
return;
|
||||
}
|
||||
|
||||
events.game_failed(cluster_id, format!("{err:#}"));
|
||||
}
|
||||
events.game_failed(cluster_id, format!("{err:#}"));
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Only ever one retry if the game still will not start after a full rehash and
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ use oneclient_core::{
|
|||
};
|
||||
use oneclient_db::models::ClusterId;
|
||||
|
||||
use crate::launcher::off_ui;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ClusterBundles {
|
||||
pub cluster: Cluster,
|
||||
|
|
@ -27,22 +29,25 @@ impl QueryCapability for OnboardingBundlesQuery {
|
|||
|
||||
async fn run(&self, _keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
|
||||
let state = crate::launcher::state()?;
|
||||
let clusters = state.clusters.list().await?;
|
||||
off_ui(async move {
|
||||
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.content(),
|
||||
&cluster.mc_version,
|
||||
cluster.mc_loader,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
out.push(ClusterBundles { cluster, archives });
|
||||
}
|
||||
Ok(out)
|
||||
let mut out = Vec::with_capacity(clusters.len());
|
||||
for cluster in clusters {
|
||||
let archives = state
|
||||
.bundles
|
||||
.archives_for(
|
||||
&state.services.content(),
|
||||
&cluster.mc_version,
|
||||
cluster.mc_loader,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
out.push(ClusterBundles { cluster, archives });
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -74,11 +79,15 @@ impl QueryCapability for BundlesWithStatusQuery {
|
|||
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
|
||||
let _ = keys;
|
||||
let state = crate::launcher::state()?;
|
||||
Ok(get_bundles_with_update_status(
|
||||
self.cluster_id,
|
||||
state.bundles.as_ref(),
|
||||
&state.services.content(),
|
||||
)
|
||||
let cluster_id = self.cluster_id;
|
||||
Ok(off_ui(async move {
|
||||
get_bundles_with_update_status(
|
||||
cluster_id,
|
||||
state.bundles.as_ref(),
|
||||
&state.services.content(),
|
||||
)
|
||||
.await
|
||||
})
|
||||
.await?)
|
||||
}
|
||||
}
|
||||
|
|
@ -154,11 +163,15 @@ impl QueryCapability for BundleUpdatesQuery {
|
|||
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
|
||||
let _ = keys;
|
||||
let state = crate::launcher::state()?;
|
||||
Ok(oneclient_core::check_bundle_updates(
|
||||
self.cluster_id,
|
||||
state.bundles.as_ref(),
|
||||
&state.services.content(),
|
||||
)
|
||||
let cluster_id = self.cluster_id;
|
||||
Ok(off_ui(async move {
|
||||
oneclient_core::check_bundle_updates(
|
||||
cluster_id,
|
||||
state.bundles.as_ref(),
|
||||
&state.services.content(),
|
||||
)
|
||||
.await
|
||||
})
|
||||
.await?)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ use oneclient_core::{
|
|||
LauncherError, LogFileInfo, LogLevel, LogLine, MclogsUploadResponse, ReadOptions,
|
||||
};
|
||||
|
||||
use crate::launcher::{off_ui, off_ui_blocking};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct ClusterLogsKeys {
|
||||
pub cluster_id: i64,
|
||||
|
|
@ -24,7 +26,7 @@ impl QueryCapability for ClusterLogsQuery {
|
|||
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
|
||||
let state = crate::launcher::state()?;
|
||||
let cluster = state.clusters.get(keys.cluster_id).await?;
|
||||
Ok(oneclient_core::list_cluster_logs(&cluster)?)
|
||||
Ok(off_ui_blocking(move || oneclient_core::list_cluster_logs(&cluster)).await?)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -48,15 +50,13 @@ impl QueryCapability for LogContentQuery {
|
|||
if keys.path.as_os_str().is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Ok(oneclient_core::read_log_at(
|
||||
&keys.path,
|
||||
&ReadOptions {
|
||||
level_filter: keys.level,
|
||||
search: keys.search.clone(),
|
||||
max_lines: keys.max_lines,
|
||||
},
|
||||
)
|
||||
.await?)
|
||||
let path = keys.path.clone();
|
||||
let options = ReadOptions {
|
||||
level_filter: keys.level,
|
||||
search: keys.search.clone(),
|
||||
max_lines: keys.max_lines,
|
||||
};
|
||||
Ok(off_ui(async move { oneclient_core::read_log_at(&path, &options).await }).await?)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -109,7 +109,11 @@ impl MutationCapability for UploadLogMutation {
|
|||
|
||||
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
|
||||
let state = crate::launcher::state()?;
|
||||
Ok(oneclient_core::upload_log_at(&state.services.requester, &keys.path).await?)
|
||||
let path = keys.path.clone();
|
||||
Ok(off_ui(
|
||||
async move { oneclient_core::upload_log_at(&state.services.requester, &path).await },
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -205,7 +205,12 @@ impl MutationCapability for ClusterMutation {
|
|||
ClusterAction::VerifyFiles { cluster_id } => {
|
||||
// Reports its own outcome not the generic failure toast a
|
||||
// verify that finds nothing wrong is still a useful result
|
||||
match oneclient_core::verify_cluster_files(&state, *cluster_id).await {
|
||||
let (state, cluster_id) = (state.clone(), *cluster_id);
|
||||
match crate::launcher::off_ui(async move {
|
||||
oneclient_core::verify_cluster_files(&state, cluster_id).await
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(report) => {
|
||||
let notify = services.events.notify("Verification complete");
|
||||
let notify = notify.body(report.summary());
|
||||
|
|
|
|||
|
|
@ -217,17 +217,23 @@ impl QueryCapability for PackageVersionsQuery {
|
|||
|
||||
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
|
||||
let state = crate::launcher::state()?;
|
||||
let provider = state.services.packages.get(keys.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.content(),
|
||||
)
|
||||
.await?)
|
||||
let keys = keys.clone();
|
||||
crate::launcher::off_ui(async move {
|
||||
Ok(state
|
||||
.services
|
||||
.packages
|
||||
.get(keys.provider)?
|
||||
.list_versions(
|
||||
&keys.project_id,
|
||||
keys.game_version.as_deref(),
|
||||
keys.loader,
|
||||
keys.page * VERSIONS_PAGE_SIZE,
|
||||
VERSIONS_PAGE_SIZE,
|
||||
&state.services.content(),
|
||||
)
|
||||
.await?)
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ use notify::{Event, EventKind};
|
|||
use oneclient_core::{LauncherError, ScreenshotInfo};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
use crate::launcher::off_ui_blocking;
|
||||
|
||||
static LOCAL_IMAGE_SEMAPHORE: OnceLock<Arc<Semaphore>> = OnceLock::new();
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
|
|
@ -29,7 +31,7 @@ impl QueryCapability for ClusterScreenshotsQuery {
|
|||
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
|
||||
let state = crate::launcher::state()?;
|
||||
let cluster = state.clusters.get(keys.cluster_id).await?;
|
||||
Ok(oneclient_core::list_cluster_screenshots(&cluster)?)
|
||||
Ok(off_ui_blocking(move || oneclient_core::list_cluster_screenshots(&cluster)).await?)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -133,7 +135,10 @@ impl MutationCapability for ScreenshotActionMutation {
|
|||
|
||||
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
|
||||
match keys {
|
||||
ScreenshotAction::Delete { path } => Ok(oneclient_core::delete_screenshot(path)?),
|
||||
ScreenshotAction::Delete { path } => {
|
||||
let path = path.clone();
|
||||
Ok(off_ui_blocking(move || oneclient_core::delete_screenshot(&path)).await?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ use freya::query::{
|
|||
use_mutation, use_query,
|
||||
};
|
||||
use oneclient_core::LauncherError;
|
||||
|
||||
use crate::launcher::off_ui;
|
||||
use oneclient_core::relocate::{Leftovers, discard_leftovers, leftovers};
|
||||
use oneclient_core::storage::{StorageReport, storage_report};
|
||||
|
||||
|
|
@ -21,7 +23,7 @@ impl QueryCapability for StorageReportQuery {
|
|||
|
||||
async fn run(&self, _keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
|
||||
let state = crate::launcher::state()?;
|
||||
storage_report(&state).await
|
||||
off_ui(async move { storage_report(&state).await }).await
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -55,19 +57,23 @@ impl MutationCapability for StorageActionMutation {
|
|||
|
||||
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
|
||||
let state = crate::launcher::state().map_err(|e| e.to_string())?;
|
||||
let keys = *keys;
|
||||
|
||||
match keys {
|
||||
StorageAction::CleanUnreferencedCache => {
|
||||
oneclient_core::storage::clean_unreferenced_cache(&state)
|
||||
.await
|
||||
.map(|_| ())
|
||||
off_ui(async move {
|
||||
match keys {
|
||||
StorageAction::CleanUnreferencedCache => {
|
||||
oneclient_core::storage::clean_unreferenced_cache(&state)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
StorageAction::CleanLegacyClusterContent => {
|
||||
oneclient_core::storage::clean_cluster_leftovers(&state)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
}
|
||||
StorageAction::CleanLegacyClusterContent => {
|
||||
oneclient_core::storage::clean_cluster_leftovers(&state)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
|
|
@ -97,7 +103,7 @@ impl QueryCapability for LeftoversQuery {
|
|||
|
||||
async fn run(&self, _keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
|
||||
let state = crate::launcher::state()?;
|
||||
Ok(leftovers(&state).await)
|
||||
Ok(off_ui(async move { leftovers(&state).await }).await)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -126,7 +132,7 @@ impl MutationCapability for DiscardLeftoversMutation {
|
|||
|
||||
async fn run(&self, _keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
|
||||
let state = crate::launcher::state().map_err(|err| err.to_string())?;
|
||||
discard_leftovers(&state).await
|
||||
off_ui(async move { discard_leftovers(&state).await }).await
|
||||
}
|
||||
|
||||
async fn on_settled(&self, _keys: &Self::Keys, result: &Result<Self::Ok, Self::Err>) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query};
|
|||
use oneclient_common::domain::GameLoader;
|
||||
use oneclient_core::{LauncherError, VersionMetadata};
|
||||
|
||||
use crate::launcher::off_ui;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct VersionsMetadataQuery;
|
||||
|
||||
|
|
@ -15,18 +17,21 @@ impl QueryCapability for VersionsMetadataQuery {
|
|||
|
||||
async fn run(&self, _keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
|
||||
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(&state.services.requester.config().meta_url_base)
|
||||
.await)
|
||||
off_ui(async move {
|
||||
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(&state.services.requester.config().meta_url_base)
|
||||
.await)
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -61,13 +66,17 @@ impl QueryCapability for LoaderVersionsQuery {
|
|||
|
||||
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
|
||||
let state = crate::launcher::state()?;
|
||||
let mut metadata = state.metadata.lock().await;
|
||||
Ok(oneclient_core::get_loader_versions(
|
||||
&mut metadata,
|
||||
&state.services.mc(),
|
||||
&keys.mc_version,
|
||||
keys.loader,
|
||||
)
|
||||
let keys = keys.clone();
|
||||
Ok(off_ui(async move {
|
||||
let mut metadata = state.metadata.lock().await;
|
||||
oneclient_core::get_loader_versions(
|
||||
&mut metadata,
|
||||
&state.services.mc(),
|
||||
&keys.mc_version,
|
||||
keys.loader,
|
||||
)
|
||||
.await
|
||||
})
|
||||
.await?)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,3 +20,17 @@ pub fn install(state: Arc<LauncherState>) -> Arc<LauncherState> {
|
|||
pub fn state() -> LauncherResult<Arc<LauncherState>> {
|
||||
LAUNCHER.get().cloned().ok_or(LauncherError::NotInitialized)
|
||||
}
|
||||
|
||||
pub async fn off_ui<T: Send + 'static>(work: impl Future<Output = T> + Send + 'static) -> T {
|
||||
match tokio::spawn(work).await {
|
||||
Ok(value) => value,
|
||||
Err(err) => std::panic::resume_unwind(err.into_panic()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn off_ui_blocking<T: Send + 'static>(work: impl FnOnce() -> T + Send + 'static) -> T {
|
||||
match tokio::task::spawn_blocking(work).await {
|
||||
Ok(value) => value,
|
||||
Err(err) => std::panic::resume_unwind(err.into_panic()),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ use tokio::sync::mpsc::UnboundedSender;
|
|||
|
||||
use crate::hooks::Actions;
|
||||
use crate::launcher;
|
||||
use crate::launcher::off_ui;
|
||||
|
||||
/// Front-ends match on these
|
||||
pub const MICROSOFT_JAVA_CHOICE_INSTALL: &str = "java.microsoft.install";
|
||||
|
|
@ -97,49 +98,58 @@ pub async fn offer_for_pinned_cluster(actions: &Actions, cluster_id: ClusterId)
|
|||
return;
|
||||
};
|
||||
|
||||
if opted_out(&state) {
|
||||
return;
|
||||
}
|
||||
let offer = off_ui({
|
||||
let state = state.clone();
|
||||
async move {
|
||||
if opted_out(&state) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let Some(pinned) = pinned_runtime(&state, cluster_id).await else {
|
||||
let pinned = pinned_runtime(&state, cluster_id).await?;
|
||||
|
||||
if pinned.vendor == JavaVendor::Microsoft {
|
||||
return None;
|
||||
}
|
||||
|
||||
let major = match oneclient_core::required_java_major(&state, cluster_id).await {
|
||||
Ok(Some(major)) => major,
|
||||
Ok(None) => {
|
||||
tracing::info!(cluster_id, "the cluster's manifest names no Java version");
|
||||
return None;
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
cluster_id,
|
||||
"could not read the cluster's Java version: {err:#}"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let installed = match state
|
||||
.java
|
||||
.has_vendor_runtime(&JavaVendor::Microsoft, Some(major))
|
||||
.await
|
||||
{
|
||||
Ok(installed) => installed,
|
||||
Err(err) => {
|
||||
tracing::warn!("could not check for a Microsoft Java runtime: {err:#}");
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if !installed && !publishes(&state, major).await {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((major, installed))
|
||||
}
|
||||
})
|
||||
.await;
|
||||
let Some((major, installed)) = offer else {
|
||||
return;
|
||||
};
|
||||
|
||||
if pinned.vendor == JavaVendor::Microsoft {
|
||||
return;
|
||||
}
|
||||
|
||||
let major = match oneclient_core::required_java_major(&state, cluster_id).await {
|
||||
Ok(Some(major)) => major,
|
||||
Ok(None) => {
|
||||
tracing::info!(cluster_id, "the cluster's manifest names no Java version");
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
cluster_id,
|
||||
"could not read the cluster's Java version: {err:#}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let installed = match state
|
||||
.java
|
||||
.has_vendor_runtime(&JavaVendor::Microsoft, Some(major))
|
||||
.await
|
||||
{
|
||||
Ok(installed) => installed,
|
||||
Err(err) => {
|
||||
tracing::warn!("could not check for a Microsoft Java runtime: {err:#}");
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if !installed && !publishes(&state, major).await {
|
||||
return;
|
||||
}
|
||||
|
||||
if !claim_ask(cluster_id) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use std::path::PathBuf;
|
|||
use freya::prelude::spawn_forever;
|
||||
|
||||
use crate::hooks::Actions;
|
||||
use crate::launcher::off_ui_blocking;
|
||||
|
||||
pub fn database_path() -> Option<PathBuf> {
|
||||
oneclient_common::paths::database_file().ok()
|
||||
|
|
@ -29,19 +30,25 @@ pub fn restore_latest(actions: &Actions) {
|
|||
return;
|
||||
};
|
||||
|
||||
match oneclient_db::backup::restore(&path, &snapshot) {
|
||||
Ok(_) => retry(actions),
|
||||
Err(err) => set_error(actions, format!("Couldn't restore the snapshot: {err}")),
|
||||
}
|
||||
let actions = actions.clone();
|
||||
spawn_forever(async move {
|
||||
match off_ui_blocking(move || oneclient_db::backup::restore(&path, &snapshot)).await {
|
||||
Ok(_) => retry(&actions),
|
||||
Err(err) => set_error(&actions, format!("Couldn't restore the snapshot: {err}")),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn reset(actions: &Actions) {
|
||||
let Some(path) = database_path() else { return };
|
||||
|
||||
match oneclient_db::backup::reset(&path) {
|
||||
Ok(_) => retry(actions),
|
||||
Err(err) => set_error(actions, format!("Couldn't reset the database: {err}")),
|
||||
}
|
||||
let actions = actions.clone();
|
||||
spawn_forever(async move {
|
||||
match off_ui_blocking(move || oneclient_db::backup::reset(&path)).await {
|
||||
Ok(_) => retry(&actions),
|
||||
Err(err) => set_error(&actions, format!("Couldn't reset the database: {err}")),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn retry(actions: &Actions) {
|
||||
|
|
|
|||
|
|
@ -319,7 +319,7 @@ fn plan(
|
|||
checking.set(true);
|
||||
error.set(None);
|
||||
|
||||
match plan_move(picked.as_deref()).await {
|
||||
match crate::launcher::off_ui(async move { plan_move(picked.as_deref()).await }).await {
|
||||
Ok(planned) => pending.set(Some(planned)),
|
||||
Err(message) => error.set(Some(message)),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -898,8 +898,8 @@ async fn addition_eligible_bundles(
|
|||
candidate_keys_by_bundle: &HashMap<String, HashSet<String>>,
|
||||
overrides: &[ClusterBundleOverrideRow],
|
||||
) -> ContentResult<HashSet<String>> {
|
||||
let (live_managed_keys, _) =
|
||||
installed_bundle_keys(ctx, all_linked.iter().filter(|item| item.enabled)).await?;
|
||||
let live: Vec<_> = all_linked.iter().filter(|item| item.enabled).collect();
|
||||
let (live_managed_keys, _) = installed_bundle_keys(ctx, live).await?;
|
||||
|
||||
let mut eligible: HashSet<String> = bundle_packages
|
||||
.iter()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue