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:
parent
62aafc3537
commit
f03f6e9ad7
13 changed files with 191 additions and 43 deletions
8
Cargo.lock
generated
8
Cargo.lock
generated
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"] }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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,32 +104,66 @@ 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);
|
||||
|
||||
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> {
|
||||
let session_path = atuin_common::utils::data_dir().join("session");
|
||||
|
@ -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")?
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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"#;
|
||||
|
||||
|
|
|
@ -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`.
|
||||
|
|
|
@ -15,6 +15,7 @@ use atuin_client::{
|
|||
|
||||
#[cfg(feature = "sync")]
|
||||
use atuin_client::sync;
|
||||
use log::debug;
|
||||
|
||||
use super::search::format_duration;
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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(
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -4,9 +4,6 @@
|
|||
use clap::{AppSettings, Parser};
|
||||
use eyre::Result;
|
||||
|
||||
#[macro_use]
|
||||
extern crate log;
|
||||
|
||||
use command::AtuinCmd;
|
||||
mod command;
|
||||
|
||||
|
|
Loading…
Reference in a new issue