fix: migration issues, cluster preparation and sentry logging
This commit is contained in:
parent
d45153a41c
commit
8c5812d652
24 changed files with 890 additions and 226 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -4704,6 +4704,7 @@ dependencies = [
|
|||
name = "oneclient_db"
|
||||
version = "2.2.1"
|
||||
dependencies = [
|
||||
"async-tempfile",
|
||||
"chrono",
|
||||
"dunce",
|
||||
"serde",
|
||||
|
|
|
|||
|
|
@ -7,8 +7,7 @@ impl AppAssets {
|
|||
AppAssets::get(path).map(|file| match file.data {
|
||||
std::borrow::Cow::Borrowed(slice) => bytes::Bytes::copy_from_slice(slice),
|
||||
std::borrow::Cow::Owned(vec) => {
|
||||
#[cfg(not(debug_assertions))]
|
||||
tracing::warn!("Cloning asset data for '{}'", path);
|
||||
tracing::debug!("Cloning asset data for '{}'", path);
|
||||
bytes::Bytes::from(vec)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -90,15 +90,18 @@ pub fn use_art_bytes(
|
|||
|
||||
let image_query = use_cached_image(art_url.clone(), max_edge);
|
||||
|
||||
// Subscribed unconditionally to keep hook order stable same query when no preview size
|
||||
let preview_edge = preview_edge.unwrap_or(max_edge);
|
||||
let preview_query = use_cached_image(art_url.clone(), preview_edge);
|
||||
let preview_edge = preview_edge.filter(|edge| *edge != max_edge);
|
||||
let preview_query = match preview_edge {
|
||||
Some(edge) => use_cached_image(art_url.clone(), edge),
|
||||
None => use_cached_image(None, 0),
|
||||
};
|
||||
|
||||
let full = loaded_image(art_url.as_deref(), &image_query)
|
||||
.map(|(url, bytes)| (format!("{max_edge}|{url}"), bytes));
|
||||
let preview = || {
|
||||
let edge = preview_edge?;
|
||||
loaded_image(art_url.as_deref(), &preview_query)
|
||||
.map(|(url, bytes)| (format!("{preview_edge}|{url}"), bytes))
|
||||
.map(|(url, bytes)| (format!("{edge}|{url}"), bytes))
|
||||
};
|
||||
|
||||
full.or_else(preview).unwrap_or_else(|| {
|
||||
|
|
|
|||
|
|
@ -323,6 +323,7 @@ pub async fn start_launcher(
|
|||
fetching: true,
|
||||
syncing_bundles: true,
|
||||
error: None,
|
||||
snapshots: 0,
|
||||
data_dir,
|
||||
};
|
||||
}
|
||||
|
|
@ -345,11 +346,17 @@ pub fn report_startup_failure(
|
|||
let message = err.to_string();
|
||||
tracing::error!("launcher init failed: {err:#}");
|
||||
|
||||
let data_dir = oneclient_common::paths::launcher_dir()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut station = *station;
|
||||
station
|
||||
.write_channel(AppChannel::Launcher)
|
||||
.launcher
|
||||
.error = Some(message.clone());
|
||||
{
|
||||
let mut guard = station.write_channel(AppChannel::Launcher);
|
||||
guard.launcher.error = Some(message.clone());
|
||||
guard.launcher.data_dir = data_dir;
|
||||
guard.launcher.snapshots = crate::recovery::snapshots().len();
|
||||
}
|
||||
|
||||
let mut guard = station.write_channel(AppChannel::Notifications);
|
||||
let AppState {
|
||||
|
|
|
|||
|
|
@ -74,15 +74,32 @@ pub enum PumpSignal {
|
|||
pub struct Actions {
|
||||
station: RadioStation<AppState, AppChannel>,
|
||||
pump: mpsc::UnboundedSender<PumpSignal>,
|
||||
events: oneclient_events::EventBus,
|
||||
}
|
||||
|
||||
impl Actions {
|
||||
#[must_use]
|
||||
pub fn station(&self) -> RadioStation<AppState, AppChannel> {
|
||||
self.station
|
||||
}
|
||||
|
||||
/// A channel handle, so a clone still feeds the pump set up at launch
|
||||
#[must_use]
|
||||
pub fn events(&self) -> oneclient_events::EventBus {
|
||||
self.events.clone()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
station: RadioStation<AppState, AppChannel>,
|
||||
pump: mpsc::UnboundedSender<PumpSignal>,
|
||||
events: oneclient_events::EventBus,
|
||||
) -> Self {
|
||||
Self { station, pump }
|
||||
Self {
|
||||
station,
|
||||
pump,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
fn nudge(&self, signal: PumpSignal) {
|
||||
|
|
|
|||
|
|
@ -44,15 +44,13 @@ pub fn loaded_image(url: Option<&str>, query: &UseQuery<CachedImageQuery>) -> Op
|
|||
}
|
||||
|
||||
pub fn use_cached_image(url: Option<String>, max_edge: u32) -> UseQuery<CachedImageQuery> {
|
||||
let url = url.unwrap_or_default();
|
||||
let enabled = !url.is_empty();
|
||||
|
||||
use_query(
|
||||
Query::new(
|
||||
CachedImageKeys {
|
||||
url: url.unwrap_or_default(),
|
||||
max_edge,
|
||||
},
|
||||
CachedImageQuery,
|
||||
)
|
||||
.stale_time(IMAGE_STALE)
|
||||
.clean_time(IMAGE_CLEAN),
|
||||
Query::new(CachedImageKeys { url, max_edge }, CachedImageQuery)
|
||||
.enable(enabled)
|
||||
.stale_time(IMAGE_STALE)
|
||||
.clean_time(IMAGE_CLEAN),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ mod transfer;
|
|||
mod layout;
|
||||
mod notifications;
|
||||
pub mod platform;
|
||||
pub mod recovery;
|
||||
mod routes;
|
||||
pub mod theme;
|
||||
mod ui;
|
||||
|
|
|
|||
|
|
@ -18,13 +18,12 @@ struct OneClientApp;
|
|||
impl App for OneClientApp {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
// Radio state is `!Send` so every writer runs on the UI thread `spawn_forever`
|
||||
// rather than `spawn` because 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);
|
||||
let actions = Actions::new(station, signals_tx, events_bus.clone());
|
||||
|
||||
// Started first so nothing emitted during startup waits on a consumer
|
||||
spawn_forever(
|
||||
|
|
|
|||
70
packages/oneclient_app/src/recovery.rs
Normal file
70
packages/oneclient_app/src/recovery.rs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use freya::prelude::spawn_forever;
|
||||
|
||||
use crate::hooks::Actions;
|
||||
|
||||
pub fn database_path() -> Option<PathBuf> {
|
||||
oneclient_common::paths::database_file().ok()
|
||||
}
|
||||
|
||||
pub fn snapshots() -> Vec<PathBuf> {
|
||||
database_path()
|
||||
.map(|path| oneclient_db::backup::list(&path))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn open_data_folder() {
|
||||
let Ok(dir) = oneclient_common::paths::launcher_dir() else {
|
||||
return;
|
||||
};
|
||||
|
||||
crate::platform::open_url(&dir.to_string_lossy());
|
||||
}
|
||||
|
||||
pub fn restore_latest(actions: &Actions) {
|
||||
let Some(path) = database_path() else { return };
|
||||
let Some(snapshot) = snapshots().into_iter().next() else {
|
||||
set_error(actions, "There are no snapshots to restore.".to_owned());
|
||||
return;
|
||||
};
|
||||
|
||||
match oneclient_db::backup::restore(&path, &snapshot) {
|
||||
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}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn retry(actions: &Actions) {
|
||||
let station = actions.station();
|
||||
let events = actions.events();
|
||||
|
||||
{
|
||||
let mut guard = station.clone().write_channel(crate::state::AppChannel::Launcher);
|
||||
guard.launcher.error = None;
|
||||
guard.launcher.snapshots = snapshots().len();
|
||||
}
|
||||
|
||||
spawn_forever(async move {
|
||||
if let Err(err) = crate::events::start_launcher(station, events).await {
|
||||
crate::events::report_startup_failure(&station, &err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn set_error(actions: &Actions, message: String) {
|
||||
let mut guard = actions
|
||||
.station()
|
||||
.write_channel(crate::state::AppChannel::Launcher);
|
||||
guard.launcher.error = Some(message);
|
||||
guard.launcher.snapshots = snapshots().len();
|
||||
}
|
||||
|
|
@ -73,6 +73,8 @@ pub struct LauncherInit {
|
|||
pub syncing_bundles: bool,
|
||||
pub error: Option<String>,
|
||||
pub data_dir: String,
|
||||
/// Restorable database snapshots, for the startup failure screen
|
||||
pub snapshots: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
|
|
|
|||
|
|
@ -171,114 +171,121 @@ impl Component for Clusters {
|
|||
),
|
||||
)
|
||||
.child(match cluster {
|
||||
Some(cluster) => detail_sidebar(
|
||||
Some(cluster) => DetailSidebar {
|
||||
line,
|
||||
cluster,
|
||||
&clusters_for_line,
|
||||
cluster_id: cluster.id,
|
||||
clusters_for_line,
|
||||
selected_version,
|
||||
selected_loader,
|
||||
),
|
||||
}
|
||||
.into_element(),
|
||||
None => sidebar_error(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn detail_sidebar(
|
||||
#[derive(PartialEq)]
|
||||
struct DetailSidebar {
|
||||
line: ReleaseLine,
|
||||
cluster: Cluster,
|
||||
clusters_for_line: &[Cluster],
|
||||
cluster_id: i64,
|
||||
clusters_for_line: Vec<Cluster>,
|
||||
selected_version: State<Option<VersionKey>>,
|
||||
selected_loader: State<Option<GameLoader>>,
|
||||
) -> Element {
|
||||
let active_id = use_active_cluster_id();
|
||||
let dispatch = use_dispatch();
|
||||
let game = use_game_snapshot();
|
||||
let launcher = use_launcher();
|
||||
let syncing = launcher.fetching || launcher.syncing_bundles;
|
||||
let keys = version_keys(clusters_for_line);
|
||||
let loaders = loaders_for_line(clusters_for_line);
|
||||
let cluster_id = cluster.id;
|
||||
let version_title = line_title(line, clusters_for_line);
|
||||
let version_value = *selected_version.read();
|
||||
let loader_value = *selected_loader.read();
|
||||
}
|
||||
|
||||
let metadata = use_version_metadata(Some(line.major), version_value, loader_value);
|
||||
impl Component for DetailSidebar {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let active_id = use_active_cluster_id();
|
||||
let dispatch = use_dispatch();
|
||||
let game = use_game_snapshot();
|
||||
let launcher = use_launcher();
|
||||
|
||||
let heading = metadata
|
||||
.as_ref()
|
||||
.map(|m| m.name.clone())
|
||||
.unwrap_or_else(|| format!("Version {version_title}"));
|
||||
let description = metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.long_description.clone())
|
||||
.unwrap_or_else(|| PLACEHOLDER_VERSION_INFO.to_string());
|
||||
let tags = metadata
|
||||
.as_ref()
|
||||
.map(|m| m.tags.clone())
|
||||
.unwrap_or_default();
|
||||
let clusters_for_line = self.clusters_for_line.as_slice();
|
||||
let syncing = launcher.fetching || launcher.syncing_bundles;
|
||||
let keys = version_keys(clusters_for_line);
|
||||
let loaders = loaders_for_line(clusters_for_line);
|
||||
let version_title = line_title(self.line, clusters_for_line);
|
||||
let version_value = (self.selected_version)();
|
||||
let loader_value = (self.selected_loader)();
|
||||
|
||||
rect()
|
||||
.width(Size::px(SIDEBAR_WIDTH_PX))
|
||||
.min_width(Size::px(SIDEBAR_WIDTH_PX))
|
||||
.height(Size::fill())
|
||||
.vertical()
|
||||
.spacing(8.)
|
||||
.padding(8.)
|
||||
.corner_radius(CornerRadius::new_all(12.))
|
||||
.background(colors::page_elevated())
|
||||
.border(border_all_color(1., colors::component_border()))
|
||||
.overflow(Overflow::Clip)
|
||||
.child(rect().width(Size::fill()).max_height(Size::px(140.)).child(
|
||||
ClusterLandscapeArt::for_version(line.major, version_value, loader_value, false),
|
||||
))
|
||||
.child(
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::fill())
|
||||
.height(Size::flex(1.0))
|
||||
.content(Content::Flex)
|
||||
.padding(Gaps::new_all(8.))
|
||||
.spacing(8.)
|
||||
.child(
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::fill())
|
||||
.height(Size::flex(1.0))
|
||||
.spacing(4.)
|
||||
.child(
|
||||
label()
|
||||
.text(heading)
|
||||
.font_size(24.)
|
||||
.font_weight(FontWeight::SEMI_BOLD)
|
||||
.color(colors::fg_primary()),
|
||||
)
|
||||
.maybe_child(tags_row(&tags))
|
||||
.child(
|
||||
label()
|
||||
.text(description)
|
||||
.font_size(12.)
|
||||
.color(colors::fg_secondary()),
|
||||
)
|
||||
.children(version_rows(line, &keys, version_value, selected_version))
|
||||
.children(loader_rows(&loaders, loader_value, selected_loader)),
|
||||
)
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.content(Content::Flex)
|
||||
.main_align(Alignment::Center)
|
||||
.spacing(8.)
|
||||
.child(play_button(
|
||||
cluster_id,
|
||||
dispatch,
|
||||
launch_button_state(&game, cluster_id, syncing),
|
||||
))
|
||||
.child(view_button(cluster_id, active_id)),
|
||||
),
|
||||
)
|
||||
.into_element()
|
||||
let metadata = use_version_metadata(Some(self.line.major), version_value, loader_value);
|
||||
|
||||
let heading = metadata
|
||||
.as_ref()
|
||||
.map(|m| m.name.clone())
|
||||
.unwrap_or_else(|| format!("Version {version_title}"));
|
||||
let description = metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.long_description.clone())
|
||||
.unwrap_or_else(|| PLACEHOLDER_VERSION_INFO.to_string());
|
||||
let tags = metadata
|
||||
.as_ref()
|
||||
.map(|m| m.tags.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
rect()
|
||||
.width(Size::px(SIDEBAR_WIDTH_PX))
|
||||
.min_width(Size::px(SIDEBAR_WIDTH_PX))
|
||||
.height(Size::fill())
|
||||
.vertical()
|
||||
.spacing(8.)
|
||||
.padding(8.)
|
||||
.corner_radius(CornerRadius::new_all(12.))
|
||||
.background(colors::page_elevated())
|
||||
.border(border_all_color(1., colors::component_border()))
|
||||
.overflow(Overflow::Clip)
|
||||
.child(rect().width(Size::fill()).max_height(Size::px(140.)).child(
|
||||
ClusterLandscapeArt::for_version(self.line.major, version_value, loader_value, false),
|
||||
))
|
||||
.child(
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::fill())
|
||||
.height(Size::flex(1.0))
|
||||
.content(Content::Flex)
|
||||
.padding(Gaps::new_all(8.))
|
||||
.spacing(8.)
|
||||
.child(
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::fill())
|
||||
.height(Size::flex(1.0))
|
||||
.spacing(4.)
|
||||
.child(
|
||||
label()
|
||||
.text(heading)
|
||||
.font_size(24.)
|
||||
.font_weight(FontWeight::SEMI_BOLD)
|
||||
.color(colors::fg_primary()),
|
||||
)
|
||||
.maybe_child(tags_row(&tags))
|
||||
.child(
|
||||
label()
|
||||
.text(description)
|
||||
.font_size(12.)
|
||||
.color(colors::fg_secondary()),
|
||||
)
|
||||
.children(version_rows(self.line, &keys, version_value, self.selected_version))
|
||||
.children(loader_rows(&loaders, loader_value, self.selected_loader)),
|
||||
)
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.content(Content::Flex)
|
||||
.main_align(Alignment::Center)
|
||||
.spacing(8.)
|
||||
.child(play_button(
|
||||
self.cluster_id,
|
||||
dispatch,
|
||||
launch_button_state(&game, self.cluster_id, syncing),
|
||||
))
|
||||
.child(view_button(self.cluster_id, active_id)),
|
||||
),
|
||||
)
|
||||
.into_element()
|
||||
}
|
||||
}
|
||||
|
||||
fn tags_row(tags: &[String]) -> Option<Element> {
|
||||
|
|
|
|||
|
|
@ -7,8 +7,54 @@ use crate::hooks::{
|
|||
terms_document, terms_is_loading, use_launcher, use_notifications_snapshot, use_settings_snapshot,
|
||||
use_splash, use_terms,
|
||||
};
|
||||
use crate::hooks::use_dispatch;
|
||||
use crate::components::Button;
|
||||
use crate::theme::colors;
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct RecoveryActions {
|
||||
snapshots: usize,
|
||||
}
|
||||
|
||||
impl Component for RecoveryActions {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let actions = use_dispatch();
|
||||
let snapshots = self.snapshots;
|
||||
|
||||
let restore = actions.clone();
|
||||
|
||||
let mut row = rect()
|
||||
.horizontal()
|
||||
.cross_align(Alignment::Center)
|
||||
.spacing(8.)
|
||||
.child(
|
||||
Button::new()
|
||||
.secondary()
|
||||
.small()
|
||||
.on_press(|_| crate::recovery::open_data_folder())
|
||||
.text("Open data folder"),
|
||||
);
|
||||
|
||||
if snapshots > 0 {
|
||||
row = row.child(
|
||||
Button::new()
|
||||
.primary()
|
||||
.small()
|
||||
.on_press(move |_| crate::recovery::restore_latest(&restore))
|
||||
.text("Restore last backup"),
|
||||
);
|
||||
}
|
||||
|
||||
row.child(
|
||||
Button::new()
|
||||
.danger()
|
||||
.small()
|
||||
.on_press(move |_| crate::recovery::reset(&actions))
|
||||
.text("Reset database"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub struct Startup;
|
||||
|
||||
|
|
@ -153,11 +199,27 @@ impl Component for Startup {
|
|||
label()
|
||||
.text(detail)
|
||||
.font_size(12.)
|
||||
.max_lines(1)
|
||||
.max_lines(if is_error { 4 } else { 1 })
|
||||
.color(colors::fg_secondary().with_a(180)),
|
||||
);
|
||||
}
|
||||
|
||||
if is_error {
|
||||
content = content
|
||||
.child(rect().height(Size::px(6.)))
|
||||
.child(RecoveryActions { snapshots: launcher.snapshots })
|
||||
.child(
|
||||
label()
|
||||
.text(format!(
|
||||
"Your data stays in {}. Nothing here deletes it.",
|
||||
launcher.data_dir
|
||||
))
|
||||
.font_size(11.)
|
||||
.max_lines(2)
|
||||
.color(colors::fg_secondary().with_a(130)),
|
||||
);
|
||||
}
|
||||
|
||||
let content = content
|
||||
.position(Position::new_absolute())
|
||||
.width(Size::fill())
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
//! Adapted from Modrinth's `minecraft-auth-errors.ts`
|
||||
//! (GPL-3.0)
|
||||
|
||||
use oneclient_net::{NetworkFailure, classify_network_failure};
|
||||
use reqwest::StatusCode;
|
||||
|
||||
use super::error::{friendly_xbox_error, MinecraftAuthError, MinecraftAuthStep};
|
||||
|
|
@ -302,58 +303,6 @@ fn xerr(code: u64) -> MinecraftAuthError {
|
|||
}
|
||||
}
|
||||
|
||||
/// reqwest reports all of these as a connect error so the source chain text is
|
||||
/// the only thing separating them anything unrecognised falls back to Generic
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum NetworkFailure {
|
||||
Certificate,
|
||||
Handshake,
|
||||
Dns,
|
||||
Generic,
|
||||
}
|
||||
|
||||
fn classify_network_failure(err: &reqwest::Error) -> NetworkFailure {
|
||||
let chain = oneclient_net::error_chain(err).to_ascii_lowercase();
|
||||
|
||||
// Certificate before handshake a rejected certificate also aborts the
|
||||
// handshake and the certificate wording is the more specific of the two
|
||||
const CERTIFICATE: &[&str] = &[
|
||||
"invalid peer certificate",
|
||||
"unknownissuer",
|
||||
"certificate",
|
||||
"certexpired",
|
||||
"notvalidforname",
|
||||
];
|
||||
const HANDSHAKE: &[&str] = &[
|
||||
"handshake",
|
||||
"received fatal alert",
|
||||
"peer misbehaved",
|
||||
"no cipher suites in common",
|
||||
"protocol version",
|
||||
"unsupported protocol",
|
||||
];
|
||||
const DNS: &[&str] = &[
|
||||
"dns error",
|
||||
"failed to lookup address",
|
||||
"no record found",
|
||||
"nodename nor servname",
|
||||
"name or service not known",
|
||||
"no such host",
|
||||
];
|
||||
|
||||
let matches = |markers: &[&str]| markers.iter().any(|marker| chain.contains(marker));
|
||||
|
||||
if matches(CERTIFICATE) {
|
||||
NetworkFailure::Certificate
|
||||
} else if matches(HANDSHAKE) {
|
||||
NetworkFailure::Handshake
|
||||
} else if matches(DNS) {
|
||||
NetworkFailure::Dns
|
||||
} else {
|
||||
NetworkFailure::Generic
|
||||
}
|
||||
}
|
||||
|
||||
fn network_guidance(failure: NetworkFailure) -> AuthErrorGuidance {
|
||||
match failure {
|
||||
NetworkFailure::Certificate => AuthErrorGuidance::new(
|
||||
|
|
|
|||
|
|
@ -88,7 +88,16 @@ pub async fn prepare_cluster(
|
|||
}
|
||||
|
||||
if let Err(err) = result {
|
||||
tracing::error!(cluster_id, error = %err, "cluster preparation failed");
|
||||
if err.is_cancelled() {
|
||||
tracing::info!(
|
||||
cluster_id,
|
||||
sentry = false,
|
||||
error = %err,
|
||||
"cluster preparation cancelled by the user"
|
||||
);
|
||||
} else {
|
||||
tracing::error!(cluster_id, error = %err, "cluster preparation failed");
|
||||
}
|
||||
if !continuing {
|
||||
let _ = state.clusters.set_stage(cluster_id, ClusterStage::NotReady).await;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,12 +92,18 @@ impl LauncherError {
|
|||
)
|
||||
}
|
||||
|
||||
/// Whether the install is missing pieces and so is worth repairing
|
||||
///
|
||||
/// Deliberately narrow only a genuine not-found qualifies
|
||||
/// Permission errors full disks and unreachable shares all survive a
|
||||
/// repair unchanged
|
||||
/// A dialog the user backed out of rather than anything that went wrong
|
||||
#[must_use]
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.is_login_cancelled()
|
||||
|| matches!(
|
||||
self,
|
||||
LauncherError::JavaError(oneclient_java::JavaError::Cancelled)
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether the install is missing pieces and so is worth repairing
|
||||
#[must_use]
|
||||
pub fn indicates_missing_files(&self) -> bool {
|
||||
let mut source: Option<&(dyn std::error::Error + 'static)> = Some(self);
|
||||
|
||||
|
|
@ -217,6 +223,39 @@ mod tests {
|
|||
assert!(err.indicates_missing_files(), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dismissing_a_dialog_is_a_cancellation_not_a_failure() {
|
||||
assert!(
|
||||
LauncherError::JavaError(oneclient_java::JavaError::Cancelled).is_cancelled()
|
||||
);
|
||||
assert!(
|
||||
LauncherError::AuthError(oneclient_auth::AuthError::LoginCancelled).is_cancelled()
|
||||
);
|
||||
}
|
||||
|
||||
/// The whole point of the split anything that is not a dismissal keeps
|
||||
/// reporting at `error`
|
||||
#[test]
|
||||
fn real_java_failures_are_not_cancellations() {
|
||||
for err in [
|
||||
oneclient_java::JavaError::MissingJava,
|
||||
oneclient_java::JavaError::PackageNotFound { major: 21 },
|
||||
oneclient_java::JavaError::InvalidJavaPath {
|
||||
path: "/opt/not-java".to_string(),
|
||||
},
|
||||
oneclient_java::JavaError::VersionMismatch {
|
||||
expected: 21,
|
||||
found: 8,
|
||||
},
|
||||
] {
|
||||
let err = LauncherError::JavaError(err);
|
||||
assert!(!err.is_cancelled(), "{err}");
|
||||
}
|
||||
|
||||
assert!(!LauncherError::NotInitialized.is_cancelled());
|
||||
assert!(!not_found("metadata/assets").is_cancelled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failures_a_repair_could_not_fix_are_left_alone() {
|
||||
for kind in [
|
||||
|
|
|
|||
|
|
@ -56,6 +56,36 @@ const NOISY_TARGETS: &[&str] = &[
|
|||
"zbus",
|
||||
];
|
||||
|
||||
/// Whether `target` belongs to one of [`APP_TARGETS`] rather than a dependency
|
||||
fn is_app_target(target: &str) -> bool {
|
||||
APP_TARGETS.iter().any(|app| {
|
||||
target
|
||||
.strip_prefix(app)
|
||||
.is_some_and(|rest| rest.is_empty() || rest.starts_with("::"))
|
||||
})
|
||||
}
|
||||
|
||||
fn sentry_event_filter(metadata: &tracing::Metadata<'_>) -> sentry_tracing::EventFilter {
|
||||
dependency_filter(*metadata.level(), metadata.target())
|
||||
.unwrap_or_else(|| sentry_tracing::default_event_filter(metadata))
|
||||
}
|
||||
|
||||
/// `None` when the target is one of ours, meaning the default filter decides
|
||||
fn dependency_filter(
|
||||
level: tracing::Level,
|
||||
target: &str,
|
||||
) -> Option<sentry_tracing::EventFilter> {
|
||||
if is_app_target(target) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(match level {
|
||||
// Kept as context for a report of ours, but never a report of its own
|
||||
tracing::Level::ERROR => sentry_tracing::EventFilter::Breadcrumb,
|
||||
_ => sentry_tracing::EventFilter::Ignore,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn default_directives() -> String {
|
||||
directives("info", "warn")
|
||||
}
|
||||
|
|
@ -146,7 +176,7 @@ pub fn init_filtered(filter: impl FnOnce() -> String) -> LauncherResult<()> {
|
|||
|
||||
let stdout_layer = tracing_subscriber::fmt::layer().with_writer(io::stdout);
|
||||
|
||||
let sentry_layer = sentry_tracing::layer();
|
||||
let sentry_layer = sentry_tracing::layer().event_filter(sentry_event_filter);
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use std::panic;
|
|||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use sentry::protocol::{Context, Event};
|
||||
use sentry::protocol::{Breadcrumb, Context, Event};
|
||||
use sentry::{ClientInitGuard, ClientOptions};
|
||||
|
||||
use oneclient_common::constants::SENTRY_DSN;
|
||||
|
|
@ -31,6 +31,16 @@ fn drop_opted_out_events(event: Event<'static>) -> Option<Event<'static>> {
|
|||
if opted_out { None } else { Some(event) }
|
||||
}
|
||||
|
||||
fn drop_opted_out_breadcrumbs(breadcrumb: Breadcrumb) -> Option<Breadcrumb> {
|
||||
let opted_out = breadcrumb
|
||||
.data
|
||||
.get("sentry")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
== Some(false);
|
||||
|
||||
if opted_out { None } else { Some(breadcrumb) }
|
||||
}
|
||||
|
||||
pub fn init(enabled: bool) -> Option<ClientInitGuard> {
|
||||
if !enabled {
|
||||
tracing::debug!("crash reporting disabled by settings");
|
||||
|
|
@ -50,6 +60,7 @@ pub fn init(enabled: bool) -> Option<ClientInitGuard> {
|
|||
attach_stacktrace: true,
|
||||
send_default_pii: false,
|
||||
before_send: Some(Arc::new(drop_opted_out_events)),
|
||||
before_breadcrumb: Some(Arc::new(drop_opted_out_breadcrumbs)),
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
|
|
@ -75,46 +86,4 @@ fn install_panic_flush() {
|
|||
client.flush(Some(PANIC_FLUSH_TIMEOUT));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bundled_dsn_parses() {
|
||||
SENTRY_DSN
|
||||
.parse::<sentry::types::Dsn>()
|
||||
.expect("bundled sentry DSN should be valid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opting_out_skips_the_client() {
|
||||
assert!(init(false).is_none());
|
||||
}
|
||||
|
||||
/// Builds an event the way `sentry-tracing` would
|
||||
fn event_with_sentry_field(value: Option<bool>) -> Event<'static> {
|
||||
let mut event = Event::default();
|
||||
if let Some(value) = value {
|
||||
let mut fields = std::collections::BTreeMap::new();
|
||||
fields.insert("sentry".to_owned(), serde_json::Value::Bool(value));
|
||||
event
|
||||
.contexts
|
||||
.insert(TRACING_FIELDS_CONTEXT.to_owned(), Context::Other(fields));
|
||||
}
|
||||
event
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_events_flagged_sentry_false() {
|
||||
assert!(drop_opted_out_events(event_with_sentry_field(Some(false))).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_events_without_opt_out() {
|
||||
assert!(drop_opted_out_events(event_with_sentry_field(Some(true))).is_some());
|
||||
assert!(drop_opted_out_events(event_with_sentry_field(None)).is_some());
|
||||
assert!(drop_opted_out_events(Event::default()).is_some());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ path = "src/lib.rs"
|
|||
|
||||
[dev-dependencies]
|
||||
tokio.workspace = true
|
||||
async-tempfile.workspace = true
|
||||
|
||||
[dependencies]
|
||||
sqlx.workspace = true
|
||||
|
|
|
|||
268
packages/oneclient_db/src/backup.rs
Normal file
268
packages/oneclient_db/src/backup.rs
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
//! Snapshots taken before a migration
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::{DbError, DbPool};
|
||||
|
||||
pub const KEEP: usize = 5;
|
||||
|
||||
const SIDECARS: &[&str] = &["-wal", "-shm", "-journal"];
|
||||
|
||||
pub fn dir(database_path: &Path) -> PathBuf {
|
||||
database_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.join("backups")
|
||||
}
|
||||
|
||||
fn stem(database_path: &Path) -> String {
|
||||
database_path
|
||||
.file_stem()
|
||||
.map(|stem| stem.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "database".to_owned())
|
||||
}
|
||||
|
||||
fn timestamp() -> String {
|
||||
chrono::Local::now().format("%Y%m%d-%H%M%S").to_string()
|
||||
}
|
||||
|
||||
/// A consistent copy without blocking on the journal, unlike a file copy
|
||||
pub async fn snapshot(pool: &DbPool, database_path: &Path) -> Result<PathBuf, DbError> {
|
||||
let dir = dir(database_path);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
|
||||
let target = dir.join(format!("{}-{}.db", stem(database_path), timestamp()));
|
||||
|
||||
sqlx::query("VACUUM INTO ?")
|
||||
.bind(target.to_string_lossy().as_ref())
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
/// Newest first
|
||||
pub fn list(database_path: &Path) -> Vec<PathBuf> {
|
||||
let prefix = format!("{}-", stem(database_path));
|
||||
|
||||
let Ok(entries) = std::fs::read_dir(dir(database_path)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut found: Vec<PathBuf> = entries
|
||||
.flatten()
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| {
|
||||
name.ends_with(".db")
|
||||
&& name.strip_prefix(&prefix).is_some_and(|rest| {
|
||||
rest.starts_with(|c: char| c.is_ascii_digit())
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
found.sort();
|
||||
found.reverse();
|
||||
found
|
||||
}
|
||||
|
||||
pub fn prune(database_path: &Path, keep: usize) {
|
||||
for old in list(database_path).into_iter().skip(keep) {
|
||||
if let Err(err) = std::fs::remove_file(&old) {
|
||||
tracing::warn!(path = %old.display(), %err, "could not remove old snapshot");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the live database into `backups/` and returns where it went. The
|
||||
/// caller is left with no database at `database_path`, which is what both
|
||||
/// restore and reset want before they continue.
|
||||
pub fn set_aside(database_path: &Path) -> Result<Option<PathBuf>, DbError> {
|
||||
if !database_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let dir = dir(database_path);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
|
||||
let target = dir.join(format!(
|
||||
"{}-replaced-{}.db",
|
||||
stem(database_path),
|
||||
timestamp()
|
||||
));
|
||||
std::fs::rename(database_path, &target)?;
|
||||
|
||||
for suffix in SIDECARS {
|
||||
let sidecar = sidecar(database_path, suffix);
|
||||
if sidecar.exists() {
|
||||
let _ = std::fs::remove_file(sidecar);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Some(target))
|
||||
}
|
||||
|
||||
fn sidecar(database_path: &Path, suffix: &str) -> PathBuf {
|
||||
let mut name = database_path.as_os_str().to_os_string();
|
||||
name.push(suffix);
|
||||
PathBuf::from(name)
|
||||
}
|
||||
|
||||
/// Puts `snapshot` back, keeping the current database in `backups/` first
|
||||
pub fn restore(database_path: &Path, snapshot: &Path) -> Result<Option<PathBuf>, DbError> {
|
||||
let replaced = set_aside(database_path)?;
|
||||
std::fs::copy(snapshot, database_path)?;
|
||||
|
||||
tracing::info!(
|
||||
snapshot = %snapshot.display(),
|
||||
"restored the database from a snapshot"
|
||||
);
|
||||
|
||||
Ok(replaced)
|
||||
}
|
||||
|
||||
/// Starts over. The old database is moved aside, never deleted, so it is
|
||||
/// recoverable by hand afterwards.
|
||||
pub fn reset(database_path: &Path) -> Result<Option<PathBuf>, DbError> {
|
||||
let replaced = set_aside(database_path)?;
|
||||
|
||||
tracing::warn!(
|
||||
replaced = ?replaced.as_ref().map(|path| path.display().to_string()),
|
||||
"reset the database"
|
||||
);
|
||||
|
||||
Ok(replaced)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_tempfile::TempDir;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
async fn pool_at(path: &Path) -> SqlitePool {
|
||||
SqlitePool::connect(&format!("sqlite://{}?mode=rwc", path.display()))
|
||||
.await
|
||||
.expect("open sqlite")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_snapshot_is_a_usable_database() {
|
||||
let dir = TempDir::new().await.expect("tempdir");
|
||||
let db = dir.dir_path().join("user_data.db");
|
||||
|
||||
let pool = pool_at(&db).await;
|
||||
sqlx::query("CREATE TABLE demo (id INTEGER PRIMARY KEY)")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
let snapshot = snapshot(&pool, &db).await.expect("snapshot");
|
||||
assert!(snapshot.exists());
|
||||
|
||||
let restored = pool_at(&snapshot).await;
|
||||
let found: Option<i64> =
|
||||
sqlx::query_scalar("SELECT 1 FROM sqlite_master WHERE name = 'demo'")
|
||||
.fetch_optional(&restored)
|
||||
.await
|
||||
.expect("probe");
|
||||
assert!(found.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restoring_keeps_the_replaced_database() {
|
||||
let dir = TempDir::new().await.expect("tempdir");
|
||||
let db = dir.dir_path().join("user_data.db");
|
||||
|
||||
let pool = pool_at(&db).await;
|
||||
sqlx::query("CREATE TABLE before_snapshot (id INTEGER PRIMARY KEY)")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("create");
|
||||
let snapshot = snapshot(&pool, &db).await.expect("snapshot");
|
||||
|
||||
sqlx::query("CREATE TABLE after_snapshot (id INTEGER PRIMARY KEY)")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("create");
|
||||
pool.close().await;
|
||||
|
||||
let replaced = restore(&db, &snapshot).expect("restore").expect("moved");
|
||||
|
||||
let now = pool_at(&db).await;
|
||||
let after: Option<i64> =
|
||||
sqlx::query_scalar("SELECT 1 FROM sqlite_master WHERE name = 'after_snapshot'")
|
||||
.fetch_optional(&now)
|
||||
.await
|
||||
.expect("probe");
|
||||
assert!(after.is_none(), "the snapshot should be what is live now");
|
||||
|
||||
let kept = pool_at(&replaced).await;
|
||||
let after: Option<i64> =
|
||||
sqlx::query_scalar("SELECT 1 FROM sqlite_master WHERE name = 'after_snapshot'")
|
||||
.fetch_optional(&kept)
|
||||
.await
|
||||
.expect("probe");
|
||||
assert!(after.is_some(), "the replaced database must still be intact");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_reset_moves_the_database_rather_than_deleting_it() {
|
||||
let dir = TempDir::new().await.expect("tempdir");
|
||||
let db = dir.dir_path().join("user_data.db");
|
||||
|
||||
let pool = pool_at(&db).await;
|
||||
sqlx::query("CREATE TABLE demo (id INTEGER PRIMARY KEY)")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("create");
|
||||
pool.close().await;
|
||||
|
||||
let moved = reset(&db).expect("reset").expect("moved");
|
||||
|
||||
assert!(!db.exists());
|
||||
assert!(moved.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_set_aside_file_is_not_offered_as_a_snapshot() {
|
||||
let dir = TempDir::new().await.expect("tempdir");
|
||||
let db = dir.dir_path().join("user_data.db");
|
||||
|
||||
let pool = pool_at(&db).await;
|
||||
sqlx::query("CREATE TABLE demo (id INTEGER PRIMARY KEY)")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("create");
|
||||
let snapshot = snapshot(&pool, &db).await.expect("snapshot");
|
||||
pool.close().await;
|
||||
|
||||
reset(&db).expect("reset");
|
||||
|
||||
assert_eq!(list(&db), vec![snapshot]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pruning_keeps_the_newest() {
|
||||
let dir = TempDir::new().await.expect("tempdir");
|
||||
let db = dir.dir_path().join("user_data.db");
|
||||
let backups = self::dir(&db);
|
||||
std::fs::create_dir_all(&backups).expect("mkdir");
|
||||
|
||||
for stamp in ["20260101-000000", "20260102-000000", "20260103-000000"] {
|
||||
std::fs::write(backups.join(format!("user_data-{stamp}.db")), b"").expect("write");
|
||||
}
|
||||
|
||||
prune(&db, 2);
|
||||
|
||||
let left = list(&db);
|
||||
assert_eq!(left.len(), 2);
|
||||
assert!(
|
||||
left[0].ends_with("user_data-20260103-000000.db"),
|
||||
"the newest must survive"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
mod error;
|
||||
mod migrate;
|
||||
mod pool;
|
||||
|
||||
pub mod backup;
|
||||
pub mod console;
|
||||
pub mod dao;
|
||||
pub mod models;
|
||||
|
|
|
|||
133
packages/oneclient_db/src/migrate.rs
Normal file
133
packages/oneclient_db/src/migrate.rs
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
use std::path::Path;
|
||||
|
||||
use sqlx::migrate::{MigrateError, Migrator};
|
||||
|
||||
use crate::{DbError, DbPool, backup};
|
||||
|
||||
const MAX_ATTEMPTS: u32 = 3;
|
||||
|
||||
pub async fn run(pool: &DbPool, database_path: &Path) -> Result<(), DbError> {
|
||||
let mut migrator = sqlx::migrate!();
|
||||
migrator.set_ignore_missing(true);
|
||||
|
||||
if has_pending(pool, &migrator).await {
|
||||
match backup::snapshot(pool, database_path).await {
|
||||
Ok(path) => {
|
||||
tracing::info!(path = %path.display(), "snapshotted the database before migrating");
|
||||
backup::prune(database_path, backup::KEEP);
|
||||
}
|
||||
// A migration that then succeeds is the common case, so a failed
|
||||
// snapshot is not worth refusing to start over
|
||||
Err(err) => tracing::warn!(%err, "could not snapshot the database before migrating"),
|
||||
}
|
||||
}
|
||||
|
||||
let mut attempt = 1;
|
||||
loop {
|
||||
let error = match migrator.run(pool).await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(error) => error,
|
||||
};
|
||||
|
||||
if attempt >= MAX_ATTEMPTS || !matches!(error, MigrateError::ExecuteMigration(..)) {
|
||||
return Err(error.into());
|
||||
}
|
||||
|
||||
tracing::warn!(attempt, %error, "migration failed, retrying");
|
||||
attempt += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// False on a fresh database, where there is nothing yet worth snapshotting
|
||||
async fn has_pending(pool: &DbPool, migrator: &Migrator) -> bool {
|
||||
let applied: Vec<i64> = sqlx::query_scalar("SELECT version FROM _sqlx_migrations")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
!applied.is_empty() && migrator.iter().any(|m| !applied.contains(&m.version))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_tempfile::TempDir;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
async fn migrated() -> (TempDir, std::path::PathBuf, SqlitePool) {
|
||||
let dir = TempDir::new().await.expect("tempdir");
|
||||
let path = dir.dir_path().join("user_data.db");
|
||||
let pool = SqlitePool::connect(&format!("sqlite://{}?mode=rwc", path.display()))
|
||||
.await
|
||||
.expect("open sqlite");
|
||||
|
||||
run(&pool, &path).await.expect("migrations run");
|
||||
(dir, path, pool)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn migrates_a_fresh_database() {
|
||||
let (_dir, _path, pool) = migrated().await;
|
||||
|
||||
let column: Option<i64> =
|
||||
sqlx::query_scalar("SELECT 1 FROM pragma_table_info('setting_profiles') WHERE name = ?")
|
||||
.bind("browser_update_mode")
|
||||
.fetch_optional(&pool)
|
||||
.await
|
||||
.expect("probe");
|
||||
|
||||
assert!(column.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn running_twice_is_a_no_op() {
|
||||
let (_dir, path, pool) = migrated().await;
|
||||
run(&pool, &path).await.expect("second run");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tolerates_a_migration_this_build_does_not_ship() {
|
||||
let (_dir, path, pool) = migrated().await;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO _sqlx_migrations
|
||||
(version, description, success, checksum, execution_time)
|
||||
VALUES (?, 'from a newer build', TRUE, x'00', -1)",
|
||||
)
|
||||
.bind(20990101120000i64)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("record a migration from the future");
|
||||
|
||||
run(&pool, &path).await.expect("a downgrade must still start");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_fresh_database_is_not_snapshotted() {
|
||||
let (_dir, path, _pool) = migrated().await;
|
||||
assert!(backup::list(&path).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nothing_outstanding_once_migrated() {
|
||||
let (_dir, _path, pool) = migrated().await;
|
||||
assert!(!has_pending(&pool, &sqlx::migrate!()).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unapplied_migration_is_outstanding() {
|
||||
let (_dir, _path, pool) = migrated().await;
|
||||
|
||||
let latest: i64 = sqlx::query_scalar("SELECT MAX(version) FROM _sqlx_migrations")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("latest version");
|
||||
sqlx::query("DELETE FROM _sqlx_migrations WHERE version = ?")
|
||||
.bind(latest)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("forget it");
|
||||
|
||||
assert!(has_pending(&pool, &sqlx::migrate!()).await);
|
||||
}
|
||||
}
|
||||
|
|
@ -30,7 +30,7 @@ pub async fn connect(database_path: impl AsRef<Path>) -> Result<DbPool, DbError>
|
|||
.connect_with(options)
|
||||
.await?;
|
||||
|
||||
sqlx::migrate!().run(&pool).await?;
|
||||
crate::migrate::run(&pool, database_path).await?;
|
||||
|
||||
tracing::info!("database ready");
|
||||
|
||||
|
|
|
|||
|
|
@ -73,17 +73,118 @@ pub enum RequestError {
|
|||
InvalidHeaderValue(#[from] reqwest::header::InvalidHeaderValue),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NetworkFailure {
|
||||
Certificate,
|
||||
Handshake,
|
||||
Dns,
|
||||
Generic,
|
||||
}
|
||||
|
||||
impl NetworkFailure {
|
||||
#[must_use]
|
||||
pub fn user_message(self) -> &'static str {
|
||||
match self {
|
||||
Self::Certificate => {
|
||||
"Something on this network or machine is intercepting secure connections, so OneClient cannot verify it is really talking to the servers it needs. This is usually antivirus HTTPS scanning, a school or workplace filter, or a VPN. Try another network, turn off HTTPS/SSL scanning in your antivirus, or ask your network administrator to allow OneClient."
|
||||
}
|
||||
Self::Handshake => {
|
||||
"OneClient reached the server but the two could not agree on a secure connection. A proxy, filter, or antivirus in between is usually using outdated encryption settings. Try another network or turn off HTTPS/SSL scanning in your antivirus."
|
||||
}
|
||||
Self::Dns => {
|
||||
"OneClient could not look up the address of the server. Check your internet connection, disable any VPN or proxy, and try a different DNS server such as 1.1.1.1."
|
||||
}
|
||||
Self::Generic => {
|
||||
"OneClient could not reach the server. Check your internet connection, and any firewall, proxy, or VPN that could be blocking it."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrying or waiting cannot clear these the user has to change something
|
||||
#[must_use]
|
||||
pub fn is_tls_interception(self) -> bool {
|
||||
matches!(self, Self::Certificate | Self::Handshake)
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn classify_network_failure(err: &reqwest::Error) -> NetworkFailure {
|
||||
let chain = error_chain(err).to_ascii_lowercase();
|
||||
|
||||
// Certificate before handshake a rejected certificate also aborts the
|
||||
// handshake and the certificate wording is the more specific of the two
|
||||
const CERTIFICATE: &[&str] = &[
|
||||
"invalid peer certificate",
|
||||
"unknownissuer",
|
||||
"certificate",
|
||||
"certexpired",
|
||||
"notvalidforname",
|
||||
];
|
||||
const HANDSHAKE: &[&str] = &[
|
||||
"handshake",
|
||||
"received fatal alert",
|
||||
"peer misbehaved",
|
||||
"no cipher suites in common",
|
||||
"protocol version",
|
||||
"unsupported protocol",
|
||||
];
|
||||
const DNS: &[&str] = &[
|
||||
"dns error",
|
||||
"failed to lookup address",
|
||||
"no record found",
|
||||
"nodename nor servname",
|
||||
"name or service not known",
|
||||
"no such host",
|
||||
];
|
||||
|
||||
let matches = |markers: &[&str]| markers.iter().any(|marker| chain.contains(marker));
|
||||
|
||||
if matches(CERTIFICATE) {
|
||||
NetworkFailure::Certificate
|
||||
} else if matches(HANDSHAKE) {
|
||||
NetworkFailure::Handshake
|
||||
} else if matches(DNS) {
|
||||
NetworkFailure::Dns
|
||||
} else {
|
||||
NetworkFailure::Generic
|
||||
}
|
||||
}
|
||||
|
||||
impl RequestError {
|
||||
/// Whether this is the user's environment rather than a launcher bug offline
|
||||
/// connection refused timed out disk full
|
||||
/// connection refused timed out disk full intercepted TLS
|
||||
#[must_use]
|
||||
pub fn is_transient(&self) -> bool {
|
||||
match self {
|
||||
Self::ReqwestError(source) => source.is_timeout() || source.is_connect(),
|
||||
Self::ReqwestError(source) => {
|
||||
source.is_timeout()
|
||||
|| source.is_connect()
|
||||
|| classify_network_failure(source).is_tls_interception()
|
||||
}
|
||||
Self::IOError(source) => is_transient_io(source),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn network_failure(&self) -> Option<NetworkFailure> {
|
||||
let Self::ReqwestError(source) = self else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let failure = classify_network_failure(source);
|
||||
|
||||
if failure != NetworkFailure::Generic || source.is_connect() || source.is_timeout() {
|
||||
Some(failure)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn user_message(&self) -> Option<&'static str> {
|
||||
self.network_failure().map(NetworkFailure::user_message)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_transient_io(error: &polyio::IOError) -> bool {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
//! Endpoints and credentials arrive as data ([`NetConfig`]) so the transport
|
||||
//! does not depend on the launcher state that owns the client
|
||||
|
||||
mod cache;
|
||||
mod config;
|
||||
mod download;
|
||||
|
|
@ -14,7 +11,7 @@ pub mod status;
|
|||
pub use cache::{EtagPolicy, Fetched, commit_etag, fetch_cached};
|
||||
pub use config::NetConfig;
|
||||
pub use download::{download_verified, fetch_verified, matches_on_disk};
|
||||
pub use error::{RequestError, error_chain};
|
||||
pub use error::{NetworkFailure, RequestError, classify_network_failure, error_chain};
|
||||
pub use request::*;
|
||||
pub use response::*;
|
||||
pub use service::RequestClient;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue