diff --git a/Cargo.lock b/Cargo.lock index 5bd71fe..a130707 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -100,6 +100,7 @@ dependencies = [ "log", "pretty_env_logger", "rpassword", + "semver", "serde", "serde_json", "termion", @@ -133,6 +134,7 @@ dependencies = [ "regex", "reqwest", "rmp-serde", + "semver", "serde", "serde_json", "sha2", @@ -1635,6 +1637,12 @@ dependencies = [ "untrusted", ] +[[package]] +name = "semver" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e25dfac463d778e353db5be2449d1cce89bd6fd23c9f1ea21310ce6e5a1b29c4" + [[package]] name = "serde" version = "1.0.144" diff --git a/Cargo.toml b/Cargo.toml index 70fcd1d..71af5d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,6 +71,7 @@ clap_complete = "3.1.4" fs-err = "2.7" whoami = "1.1.2" rpassword = "6.0" +semver = "1.0.14" [dependencies.tracing-subscriber] version = "0.3" diff --git a/atuin-client/Cargo.toml b/atuin-client/Cargo.toml index 7441640..dff8931 100644 --- a/atuin-client/Cargo.toml +++ b/atuin-client/Cargo.toml @@ -63,6 +63,7 @@ sha2 = { version = "0.10", optional = true } rmp-serde = { version = "1.0.0", optional = true } base64 = { version = "0.13.0", optional = true } tokio = { version = "1", features = ["full"] } +semver = "1.0.14" [dev-dependencies] tokio = { version = "1", features = ["full"] } diff --git a/atuin-client/config.toml b/atuin-client/config.toml index 0d82ac2..43b5e24 100644 --- a/atuin-client/config.toml +++ b/atuin-client/config.toml @@ -15,6 +15,9 @@ ## enable or disable automatic sync # auto_sync = true +## enable or disable automatic update checks +# update_check = true + ## how often to sync history. note that this is only triggered when a command ## is ran, so sync intervals may well be longer ## set it to 0 to sync after every command diff --git a/atuin-client/src/api_client.rs b/atuin-client/src/api_client.rs index 5692fea..b20d937 100644 --- a/atuin-client/src/api_client.rs +++ b/atuin-client/src/api_client.rs @@ -9,9 +9,10 @@ use reqwest::{ use sodiumoxide::crypto::secretbox; use atuin_common::api::{ - AddHistoryRequest, CountResponse, ErrorResponse, LoginRequest, LoginResponse, RegisterResponse, - SyncHistoryResponse, + AddHistoryRequest, CountResponse, ErrorResponse, IndexResponse, LoginRequest, LoginResponse, + RegisterResponse, SyncHistoryResponse, }; +use semver::Version; use crate::{ encryption::{decode_key, decrypt}, @@ -86,6 +87,27 @@ pub async fn login(address: &str, req: LoginRequest) -> Result { Ok(session) } +pub async fn latest_version() -> Result { + let url = "https://api.atuin.sh"; + let client = reqwest::Client::new(); + + let resp = client + .get(url) + .header(USER_AGENT, APP_USER_AGENT) + .send() + .await?; + + if resp.status() != reqwest::StatusCode::OK { + let error = resp.json::().await?; + bail!("failed to check latest version: {}", error.reason); + } + + let index = resp.json::().await?; + let version = Version::parse(index.version.as_str())?; + + Ok(version) +} + impl<'a> Client<'a> { pub fn new(sync_addr: &'a str, session_token: &'a str, key: String) -> Result { let mut headers = HeaderMap::new(); diff --git a/atuin-client/src/settings.rs b/atuin-client/src/settings.rs index f836ce0..b743a15 100644 --- a/atuin-client/src/settings.rs +++ b/atuin-client/src/settings.rs @@ -8,9 +8,13 @@ use config::{Config, Environment, File as ConfigFile, FileFormat}; use eyre::{eyre, Context, Result}; use fs_err::{create_dir_all, File}; use parse_duration::parse; +use semver::Version; use serde::Deserialize; pub const HISTORY_PAGE_SIZE: i64 = 100; +pub const LAST_SYNC_FILENAME: &str = "last_sync_time"; +pub const LAST_VERSION_CHECK_FILENAME: &str = "last_version_check_time"; +pub const LATEST_VERSION_FILENAME: &str = "latest_version"; #[derive(Clone, Debug, Deserialize, Copy)] pub enum SearchMode { @@ -86,6 +90,7 @@ pub struct Settings { pub dialect: Dialect, pub style: Style, pub auto_sync: bool, + pub update_check: bool, pub sync_address: String, pub sync_frequency: String, pub db_path: String, @@ -99,31 +104,65 @@ pub struct Settings { } impl Settings { - pub fn save_sync_time() -> Result<()> { + fn save_to_data_dir(filename: &str, value: &str) -> Result<()> { let data_dir = atuin_common::utils::data_dir(); let data_dir = data_dir.as_path(); - let sync_time_path = data_dir.join("last_sync_time"); + let path = data_dir.join(filename); - fs_err::write(sync_time_path, Utc::now().to_rfc3339())?; + fs_err::write(path, value)?; Ok(()) } - pub fn last_sync() -> Result> { + fn read_from_data_dir(filename: &str) -> Option { let data_dir = atuin_common::utils::data_dir(); let data_dir = data_dir.as_path(); - let sync_time_path = data_dir.join("last_sync_time"); + let path = data_dir.join(filename); - if !sync_time_path.exists() { - return Ok(Utc.ymd(1970, 1, 1).and_hms(0, 0, 0)); + if !path.exists() { + return None; } - let time = fs_err::read_to_string(sync_time_path)?; - let time = chrono::DateTime::parse_from_rfc3339(time.as_str())?; + let value = fs_err::read_to_string(path); - Ok(time.with_timezone(&Utc)) + value.ok() + } + + fn save_current_time(filename: &str) -> Result<()> { + Settings::save_to_data_dir(filename, Utc::now().to_rfc3339().as_str())?; + + Ok(()) + } + + fn load_time_from_file(filename: &str) -> Result> { + let value = Settings::read_from_data_dir(filename); + + match value { + Some(v) => { + let time = chrono::DateTime::parse_from_rfc3339(v.as_str())?; + + Ok(time.with_timezone(&Utc)) + } + None => Ok(Utc.ymd(1970, 1, 1).and_hms(0, 0, 0)), + } + } + + pub fn save_sync_time() -> Result<()> { + Settings::save_current_time(LAST_SYNC_FILENAME) + } + + pub fn save_version_check_time() -> Result<()> { + Settings::save_current_time(LAST_VERSION_CHECK_FILENAME) + } + + pub fn last_sync() -> Result> { + Settings::load_time_from_file(LAST_SYNC_FILENAME) + } + + pub fn last_version_check() -> Result> { + Settings::load_time_from_file(LAST_VERSION_CHECK_FILENAME) } pub fn should_sync(&self) -> Result { @@ -142,6 +181,67 @@ impl Settings { } } + fn needs_update_check(&self) -> Result { + let last_check = Settings::last_version_check()?; + let diff = Utc::now() - last_check; + + // Check a max of once per hour + Ok(diff.num_hours() >= 1) + } + + async fn latest_version(&self) -> Result { + // Default to the current version, and if that doesn't parse, a version so high it's unlikely to ever + // suggest upgrading. + let current = + Version::parse(env!("CARGO_PKG_VERSION")).unwrap_or(Version::new(100000, 0, 0)); + + if !self.needs_update_check()? { + // Worst case, we don't want Atuin to fail to start because something funky is going on with + // version checking. + let version = match Settings::read_from_data_dir(LATEST_VERSION_FILENAME) { + Some(v) => Version::parse(&v).unwrap_or(current), + None => current, + }; + + return Ok(version); + } + + #[cfg(feature = "sync")] + let latest = crate::api_client::latest_version().await.unwrap_or(current); + + #[cfg(not(feature = "sync"))] + let latest = current; + + Settings::save_version_check_time()?; + Settings::save_to_data_dir(LATEST_VERSION_FILENAME, latest.to_string().as_str())?; + + Ok(latest) + } + + // Return Some(latest version) if an update is needed. Otherwise, none. + pub async fn needs_update(&self) -> Option { + if !self.update_check { + return None; + } + + let current = + Version::parse(env!("CARGO_PKG_VERSION")).unwrap_or(Version::new(100000, 0, 0)); + + let latest = self.latest_version().await; + + if latest.is_err() { + return None; + } + + let latest = latest.unwrap(); + + if latest > current { + return Some(latest); + } + + None + } + pub fn new() -> Result { let config_dir = atuin_common::utils::config_dir(); @@ -172,6 +272,7 @@ impl Settings { .set_default("session_path", session_path.to_str())? .set_default("dialect", "us")? .set_default("auto_sync", true)? + .set_default("update_check", true)? .set_default("sync_frequency", "1h")? .set_default("sync_address", "https://api.atuin.sh")? .set_default("search_mode", "prefix")? diff --git a/atuin-common/src/api.rs b/atuin-common/src/api.rs index ba04fd7..f17cfd5 100644 --- a/atuin-common/src/api.rs +++ b/atuin-common/src/api.rs @@ -59,3 +59,9 @@ pub struct SyncHistoryResponse { pub struct ErrorResponse<'a> { pub reason: Cow<'a, str>, } + +#[derive(Debug, Serialize, Deserialize)] +pub struct IndexResponse { + pub homage: String, + pub version: String, +} diff --git a/atuin-server/src/handlers/mod.rs b/atuin-server/src/handlers/mod.rs index 4be3e38..082ae47 100644 --- a/atuin-server/src/handlers/mod.rs +++ b/atuin-server/src/handlers/mod.rs @@ -1,18 +1,11 @@ -use atuin_common::api::ErrorResponse; +use atuin_common::api::{ErrorResponse, IndexResponse}; use axum::{response::IntoResponse, Json}; -use serde::{Deserialize, Serialize}; pub mod history; pub mod user; const VERSION: &str = env!("CARGO_PKG_VERSION"); -#[derive(Debug, Serialize, Deserialize)] -pub struct IndexResponse { - pub homage: String, - pub version: String, -} - pub async fn index() -> Json { let homage = r#""Through the fathomless deeps of space swims the star turtle Great A'Tuin, bearing on its back the four giant elephants who carry on their shoulders the mass of the Discworld." -- Sir Terry Pratchett"#; diff --git a/docs/config.md b/docs/config.md index 42d8b0b..13aabac 100644 --- a/docs/config.md +++ b/docs/config.md @@ -47,6 +47,15 @@ true auto_sync = true/false ``` +### `update_check` + +Configures whether or not to automatically check for updates. Defaults to +true. + +``` +auto_sync = true/false +``` + ### `sync_address` The address of the server to sync with! Defaults to `https://api.atuin.sh`. diff --git a/src/command/client/history.rs b/src/command/client/history.rs index fe5bfcb..07f4b31 100644 --- a/src/command/client/history.rs +++ b/src/command/client/history.rs @@ -15,6 +15,7 @@ use atuin_client::{ #[cfg(feature = "sync")] use atuin_client::sync; +use log::debug; use super::search::format_duration; diff --git a/src/command/client/search.rs b/src/command/client/search.rs index eda20ac..1cef1ff 100644 --- a/src/command/client/search.rs +++ b/src/command/client/search.rs @@ -63,14 +63,7 @@ pub struct Cmd { impl Cmd { pub async fn run(self, db: &mut impl Database, settings: &Settings) -> Result<()> { if self.interactive { - let item = interactive::history( - &self.query, - settings.search_mode, - settings.filter_mode, - settings.style, - db, - ) - .await?; + let item = interactive::history(&self.query, settings, db).await?; eprintln!("{}", item); } else { let list_mode = ListMode::from_flags(self.human, self.cmd_only); diff --git a/src/command/client/search/interactive.rs b/src/command/client/search/interactive.rs index 069b7f3..950a971 100644 --- a/src/command/client/search/interactive.rs +++ b/src/command/client/search/interactive.rs @@ -1,6 +1,7 @@ use std::io::stdout; use eyre::Result; +use semver::Version; use termion::{ event::Event as TermEvent, event::Key, event::MouseButton, event::MouseEvent, input::MouseTerminal, raw::IntoRawMode, screen::AlternateScreen, @@ -20,7 +21,7 @@ use atuin_client::{ database::Context, database::Database, history::History, - settings::{FilterMode, SearchMode}, + settings::{FilterMode, SearchMode, Settings}, }; use super::{ @@ -36,6 +37,7 @@ struct State { filter_mode: FilterMode, results_state: ListState, context: Context, + update_needed: Option, } impl State { @@ -143,10 +145,19 @@ impl State { .constraints([Constraint::Length(1); 3]) .split(top_chunks[1]); - let title = Paragraph::new(Text::from(Span::styled( - format!(" Atuin v{VERSION}"), - Style::default().add_modifier(Modifier::BOLD), - ))); + let title = if self.update_needed.is_some() { + let version = self.update_needed.clone().unwrap(); + + Paragraph::new(Text::from(Span::styled( + format!(" Atuin v{VERSION} - UPDATE AVAILABLE {version}"), + Style::default().add_modifier(Modifier::BOLD).fg(Color::Red), + ))) + } else { + Paragraph::new(Text::from(Span::styled( + format!(" Atuin v{VERSION}"), + Style::default().add_modifier(Modifier::BOLD), + ))) + }; let help = vec![ Span::raw(" Press "), @@ -277,9 +288,7 @@ impl State { #[allow(clippy::cast_possible_truncation)] pub async fn history( query: &[String], - search_mode: SearchMode, - filter_mode: FilterMode, - style: atuin_client::settings::Style, + settings: &Settings, db: &mut impl Database, ) -> Result { let stdout = stdout().into_raw_mode()?; @@ -294,15 +303,19 @@ pub async fn history( let mut input = Cursor::from(query.join(" ")); // Put the cursor at the end of the query by default input.end(); + + let update_needed = settings.needs_update().await; + let mut app = State { history_count: db.history_count().await?, input, results_state: ListState::default(), context: current_context(), - filter_mode, + filter_mode: settings.filter_mode, + update_needed, }; - let mut results = app.query_results(search_mode, db).await?; + let mut results = app.query_results(settings.search_mode, db).await?; let index = 'render: loop { let initial_input = app.input.as_str().to_owned(); @@ -323,10 +336,10 @@ pub async fn history( } if initial_input != app.input.as_str() || initial_filter_mode != app.filter_mode { - results = app.query_results(search_mode, db).await?; + results = app.query_results(settings.search_mode, db).await?; } - let compact = match style { + let compact = match settings.style { atuin_client::settings::Style::Auto => { terminal.size().map(|size| size.height < 14).unwrap_or(true) } diff --git a/src/main.rs b/src/main.rs index bffb724..798f7a2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,9 +4,6 @@ use clap::{AppSettings, Parser}; use eyre::Result; -#[macro_use] -extern crate log; - use command::AtuinCmd; mod command;