253 lines
8.3 KiB
Rust
253 lines
8.3 KiB
Rust
use thiserror::Error;
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum LauncherError {
|
|
#[error("Unable to parse JSON: {0}")]
|
|
JsonError(#[from] serde_json::Error),
|
|
|
|
#[error("Database execution failed: {0}")]
|
|
SqlError(#[from] sqlx::Error),
|
|
|
|
#[error(transparent)]
|
|
IoError(#[from] polyio::IOError),
|
|
|
|
#[error(transparent)]
|
|
StdIoError(#[from] std::io::Error),
|
|
|
|
#[error(transparent)]
|
|
RequestError(#[from] oneclient_net::RequestError),
|
|
|
|
#[error(transparent)]
|
|
PathsError(#[from] oneclient_common::PathsError),
|
|
|
|
#[error(transparent)]
|
|
DbError(#[from] oneclient_db::DbError),
|
|
|
|
#[error("launcher core is not initialized")]
|
|
NotInitialized,
|
|
|
|
#[error("launcher core is already initialized")]
|
|
AlreadyInitialized,
|
|
|
|
#[error("invalid settings profile: {reason}")]
|
|
InvalidSettingsProfile { reason: String },
|
|
|
|
#[error(transparent)]
|
|
JavaError(#[from] oneclient_java::JavaError),
|
|
|
|
#[error(transparent)]
|
|
UrlError(#[from] url::ParseError),
|
|
|
|
#[error(transparent)]
|
|
EventError(#[from] oneclient_events::EventError),
|
|
|
|
#[error(transparent)]
|
|
PackageError(#[from] oneclient_content::packages::PackageError),
|
|
|
|
#[error(transparent)]
|
|
AuthError(#[from] oneclient_auth::AuthError),
|
|
|
|
#[error(transparent)]
|
|
ClusterError(#[from] crate::clusters::ClusterError),
|
|
|
|
#[error(transparent)]
|
|
McError(#[from] oneclient_mc::McError),
|
|
|
|
#[error(transparent)]
|
|
ContentError(#[from] oneclient_content::ContentError),
|
|
|
|
#[error(transparent)]
|
|
BundleError(#[from] oneclient_content::bundles::BundleError),
|
|
|
|
#[error(transparent)]
|
|
GameError(#[from] crate::game::GameError),
|
|
|
|
#[error(transparent)]
|
|
LogsError(#[from] oneclient_cluster::logs::LogsError),
|
|
|
|
#[error(transparent)]
|
|
ScreenshotsError(#[from] oneclient_cluster::screenshots::ScreenshotsError),
|
|
|
|
#[error("minecraft: {0}")]
|
|
Minecraft(String),
|
|
}
|
|
|
|
impl LauncherError {
|
|
#[must_use]
|
|
pub fn auth_guidance(&self) -> Option<oneclient_auth::AuthErrorGuidance> {
|
|
match self {
|
|
LauncherError::AuthError(oneclient_auth::AuthError::Minecraft(err)) => {
|
|
oneclient_auth::diagnose_auth_error(err)
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Whether this failure reads as "the install is missing pieces", and so is
|
|
/// worth repairing rather than just reporting.
|
|
///
|
|
/// Something like
|
|
/// `An error occurred whilst accessing path '.../metadata/assets': No such
|
|
/// file or directory (os error 2)` is a dead end as a message: it names a
|
|
/// path the user did not create and cannot fix, when the actual answer is
|
|
/// that a file the install needs is not there and should be downloaded
|
|
/// again.
|
|
///
|
|
/// Deliberately narrow. Only a genuine not-found reaches here: a permission
|
|
/// error, a full disk, or an unreachable network share would all survive a
|
|
/// repair unchanged, and re-downloading the game to rediscover that would
|
|
/// waste a lot of the user's time.
|
|
#[must_use]
|
|
pub fn indicates_missing_files(&self) -> bool {
|
|
let mut source: Option<&(dyn std::error::Error + 'static)> = Some(self);
|
|
|
|
// Walk the chain rather than matching the top variant: by the time this
|
|
// surfaces it has usually been wrapped two or three layers deep, and the
|
|
// `io::Error` that actually carries the kind is at the bottom.
|
|
while let Some(err) = source {
|
|
if let Some(io) = err.downcast_ref::<std::io::Error>()
|
|
&& io.kind() == std::io::ErrorKind::NotFound
|
|
{
|
|
return true;
|
|
}
|
|
source = err.source();
|
|
}
|
|
|
|
false
|
|
}
|
|
}
|
|
|
|
pub trait SentryExclusion {
|
|
/// Whether this error is expected/environmental noise that should be kept out
|
|
/// of Sentry rather than reported as a crash.
|
|
fn is_sentry_excluded(&self) -> bool {
|
|
false
|
|
}
|
|
}
|
|
|
|
impl SentryExclusion for LauncherError {
|
|
fn is_sentry_excluded(&self) -> bool {
|
|
match self {
|
|
LauncherError::StdIoError(e) => e.is_sentry_excluded(),
|
|
LauncherError::IoError(e) => e.is_sentry_excluded(),
|
|
LauncherError::RequestError(e) => e.is_transient(),
|
|
LauncherError::JavaError(e) => e.is_transient(),
|
|
_ => false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl SentryExclusion for std::io::Error {
|
|
fn is_sentry_excluded(&self) -> bool {
|
|
use std::io::ErrorKind;
|
|
|
|
// Out of disk space. `ErrorKind::StorageFull` is still unstable, so match
|
|
// the raw OS codes instead. Codes are platform-gated so a Unix errno can't
|
|
// collide with a Windows code (112 is the unrelated EHOSTDOWN on Linux).
|
|
if let Some(code) = self.raw_os_error() {
|
|
#[cfg(unix)]
|
|
if code == 28 {
|
|
// ENOSPC
|
|
return true;
|
|
}
|
|
#[cfg(windows)]
|
|
if code == 112 || code == 39 {
|
|
// ERROR_DISK_FULL / ERROR_HANDLE_DISK_FULL
|
|
return true;
|
|
}
|
|
let _ = code;
|
|
}
|
|
|
|
// Lost/refused network connections during downloads.
|
|
matches!(
|
|
self.kind(),
|
|
ErrorKind::ConnectionRefused
|
|
| ErrorKind::ConnectionReset
|
|
| ErrorKind::ConnectionAborted
|
|
| ErrorKind::NotConnected
|
|
| ErrorKind::TimedOut
|
|
)
|
|
}
|
|
}
|
|
|
|
impl SentryExclusion for reqwest::Error {
|
|
fn is_sentry_excluded(&self) -> bool {
|
|
// Connectivity problems (offline, connection refused, timed out) are the
|
|
// user's network, not a launcher bug.
|
|
self.is_timeout() || self.is_connect()
|
|
}
|
|
}
|
|
|
|
impl SentryExclusion for polyio::IOError {
|
|
fn is_sentry_excluded(&self) -> bool {
|
|
match self {
|
|
polyio::IOError::IOError(source)
|
|
| polyio::IOError::PathIOError { source, .. } => source.is_sentry_excluded(),
|
|
_ => false,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub type LauncherResult<T> = Result<T, LauncherError>;
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// The OS-level "no such file or directory" the filesystem actually
|
|
/// returns, rather than a synthesized `ErrorKind`: they map to the same
|
|
/// kind, but only this one renders the `(os error 2)` text users report.
|
|
fn not_found(path: &str) -> LauncherError {
|
|
LauncherError::IoError(polyio::IOError::PathIOError {
|
|
source: std::io::Error::from_raw_os_error(2),
|
|
path: path.to_string(),
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn a_missing_asset_directory_asks_for_a_repair() {
|
|
// The exact shape users report: a path deep in the launcher's own
|
|
// metadata that they never created and cannot fix by hand.
|
|
let path =
|
|
"/Users/someone/Library/Application Support/org.Polyfrost.OneClient-dev/metadata/assets";
|
|
let err = not_found(path);
|
|
|
|
assert!(err.indicates_missing_files(), "{err}");
|
|
assert!(err.to_string().contains(path), "{err}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_not_found_nested_several_layers_deep_is_still_found() {
|
|
// By the time this surfaces it has usually been wrapped on its way up,
|
|
// so matching only the outermost variant would miss it.
|
|
let err = LauncherError::McError(oneclient_mc::McError::Io(polyio::IOError::PathIOError {
|
|
source: std::io::Error::from(std::io::ErrorKind::NotFound),
|
|
path: "metadata/libraries/foo.jar".to_string(),
|
|
}));
|
|
|
|
assert!(err.indicates_missing_files(), "{err}");
|
|
}
|
|
|
|
#[test]
|
|
fn failures_a_repair_could_not_fix_are_left_alone() {
|
|
// Re-downloading the entire game to rediscover that the disk is full,
|
|
// or that the folder is not writable, would waste a lot of the user's
|
|
// time and end at the same error.
|
|
for kind in [
|
|
std::io::ErrorKind::PermissionDenied,
|
|
std::io::ErrorKind::ConnectionRefused,
|
|
std::io::ErrorKind::TimedOut,
|
|
] {
|
|
let err = LauncherError::IoError(polyio::IOError::PathIOError {
|
|
source: std::io::Error::from(kind),
|
|
path: "metadata/assets".to_string(),
|
|
});
|
|
assert!(!err.indicates_missing_files(), "{kind:?} should not repair");
|
|
}
|
|
|
|
assert!(!LauncherError::NotInitialized.indicates_missing_files());
|
|
assert!(
|
|
!LauncherError::Minecraft("bad manifest".into()).indicates_missing_files()
|
|
);
|
|
}
|
|
}
|