Add automatic update checking (#555)

* Add automatic update checking

* Add setting to opt out of update checks

* Document options

* no

* no

* also no

* Make clippy happy

* Update atuin-client/src/settings.rs

Co-authored-by: Conrad Ludgate <conradludgate@gmail.com>

* fix features

Co-authored-by: Conrad Ludgate <conradludgate@gmail.com>
Co-authored-by: Conrad Ludgate <conrad.ludgate@truelayer.com>
This commit is contained in:
Ellie Huxtable 2022-10-14 10:59:21 +01:00 committed by GitHub
parent 62aafc3537
commit f03f6e9ad7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 191 additions and 43 deletions

8
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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"] }

View file

@ -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

View file

@ -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<LoginResponse> {
Ok(session)
}
pub async fn latest_version() -> Result<Version> {
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::<ErrorResponse>().await?;
bail!("failed to check latest version: {}", error.reason);
}
let index = resp.json::<IndexResponse>().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<Self> {
let mut headers = HeaderMap::new();

View file

@ -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<chrono::DateTime<Utc>> {
fn read_from_data_dir(filename: &str) -> Option<String> {
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<chrono::DateTime<Utc>> {
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<chrono::DateTime<Utc>> {
Settings::load_time_from_file(LAST_SYNC_FILENAME)
}
pub fn last_version_check() -> Result<chrono::DateTime<Utc>> {
Settings::load_time_from_file(LAST_VERSION_CHECK_FILENAME)
}
pub fn should_sync(&self) -> Result<bool> {
@ -142,6 +181,67 @@ impl Settings {
}
}
fn needs_update_check(&self) -> Result<bool> {
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<Version> {
// 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<Version> {
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<Self> {
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")?

View file

@ -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,
}

View file

@ -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<IndexResponse> {
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"#;

View file

@ -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`.

View file

@ -15,6 +15,7 @@ use atuin_client::{
#[cfg(feature = "sync")]
use atuin_client::sync;
use log::debug;
use super::search::format_duration;

View file

@ -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);

View file

@ -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<Version>,
}
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<String> {
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)
}

View file

@ -4,9 +4,6 @@
use clap::{AppSettings, Parser};
use eyre::Result;
#[macro_use]
extern crate log;
use command::AtuinCmd;
mod command;