use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::storage::{Ecosystem, atomic_write};
use crate::widgets::Toast;
const CRATES_IO: &str = "https://index.crates.io";
const ONCE_A_DAY: Duration = Duration::from_secs(24 * 60 * 60);
const PATIENCE: Duration = Duration::from_secs(10);
const LAST_ASKED: &str = "update-check";
pub struct UpdateCheck<Msg> {
ecosystem: Ecosystem,
app: String,
package: String,
current: String,
config_dir: Option<PathBuf>,
state_dir: Option<PathBuf>,
registry: String,
on_newer: Box<dyn FnOnce(Update) -> Msg + Send>,
}
impl<Msg: Send + 'static> UpdateCheck<Msg> {
#[must_use]
pub fn new(
ecosystem: Ecosystem,
app: impl Into<String>,
package: impl Into<String>,
current: impl Into<String>,
on_newer: impl FnOnce(Update) -> Msg + Send + 'static,
) -> Self {
Self {
ecosystem,
app: app.into(),
package: package.into(),
current: current.into(),
config_dir: None,
state_dir: None,
registry: CRATES_IO.to_owned(),
on_newer: Box::new(on_newer),
}
}
#[must_use]
pub fn in_folders(mut self, config_dir: impl Into<PathBuf>, state_dir: impl Into<PathBuf>) -> Self {
self.config_dir = Some(config_dir.into());
self.state_dir = Some(state_dir.into());
self
}
#[must_use]
pub fn registry(mut self, address: impl Into<String>) -> Self {
self.registry = address.into().trim_end_matches('/').to_owned();
self
}
pub(crate) fn map<B: Send + 'static>(self, map: impl FnOnce(Msg) -> B + Send + 'static) -> UpdateCheck<B> {
let on_newer = self.on_newer;
UpdateCheck {
ecosystem: self.ecosystem,
app: self.app,
package: self.package,
current: self.current,
config_dir: self.config_dir,
state_dir: self.state_dir,
registry: self.registry,
on_newer: Box::new(move |update| map(on_newer(update))),
}
}
pub(crate) fn request(&self) -> UpdateCheckRequest {
UpdateCheckRequest { package: self.package.clone(), current: self.current.clone() }
}
pub(crate) fn answer(self, latest: &str) -> Option<Msg> {
self.newer(latest)
}
pub(crate) fn ask(self, now: SystemTime) -> Option<Msg> {
self.ask_with(now, fetch)
}
fn ask_with(self, now: SystemTime, fetch: impl FnOnce(&str, &str) -> Option<String>) -> Option<Msg> {
let config_dir = self.config_dir.clone().or_else(|| self.ecosystem.config_dir())?;
if !self.ecosystem.update_notice_in(&config_dir) {
return None;
}
let state_dir = self.state_dir.clone().or_else(|| self.ecosystem.state_dir(&self.app))?;
if !due(&state_dir, now) {
return None;
}
remember(&state_dir, now)?;
let agent = format!("{}/{}", self.package, self.current);
let index = fetch(&index_address(&self.registry, &self.package), &agent)?;
let latest = newest(&index, &self.current)?;
self.newer(&latest)
}
fn newer(self, latest: &str) -> Option<Msg> {
let (running, found) = (Version::parse(&self.current)?, Version::parse(latest)?);
(found.offered_to(&running) && found > running).then(|| {
(self.on_newer)(Update {
ecosystem: self.ecosystem,
package: self.package,
current: self.current,
latest: latest.to_owned(),
})
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateCheckRequest {
package: String,
current: String,
}
impl UpdateCheckRequest {
#[must_use]
pub fn package(&self) -> &str {
&self.package
}
#[must_use]
pub fn current(&self) -> &str {
&self.current
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Update {
ecosystem: Ecosystem,
package: String,
current: String,
latest: String,
}
impl Update {
#[must_use]
pub fn new(
ecosystem: Ecosystem,
package: impl Into<String>,
current: impl Into<String>,
latest: impl Into<String>,
) -> Self {
Self { ecosystem, package: package.into(), current: current.into(), latest: latest.into() }
}
#[must_use]
pub fn package(&self) -> &str {
&self.package
}
#[must_use]
pub fn current(&self) -> &str {
&self.current
}
#[must_use]
pub fn latest(&self) -> &str {
&self.latest
}
#[must_use]
pub fn toast<Msg>(&self) -> Toast<Msg> {
let title = crate::t!("quvyta.update.title", package = self.package.as_str(), latest = self.latest.as_str());
let body = crate::t!(
"quvyta.update.body",
current = self.current.as_str(),
launcher = self.ecosystem.id(),
package = self.package.as_str()
);
Toast::info(title).body(body).key("quvyta-update").duration(Duration::from_secs(12))
}
}
fn due(state_dir: &Path, now: SystemTime) -> bool {
let Some(last) = std::fs::read_to_string(state_dir.join(LAST_ASKED))
.ok()
.and_then(|text| text.trim().parse::<u64>().ok())
.map(|seconds| UNIX_EPOCH + Duration::from_secs(seconds))
else {
return true;
};
now.duration_since(last).map_or(true, |since| since >= ONCE_A_DAY)
}
fn remember(state_dir: &Path, now: SystemTime) -> Option<()> {
let seconds = now.duration_since(UNIX_EPOCH).ok()?.as_secs();
std::fs::create_dir_all(state_dir).ok()?;
atomic_write(&state_dir.join(LAST_ASKED), format!("{seconds}\n").as_bytes()).ok()
}
fn index_address(registry: &str, package: &str) -> String {
let name = package.to_lowercase();
let prefix = match name.len() {
0 => String::new(),
1 => "1".to_owned(),
2 => "2".to_owned(),
3 => format!("3/{}", &name[..1]),
_ => format!("{}/{}", &name[..2], &name[2..4]),
};
format!("{registry}/{prefix}/{name}")
}
fn fetch(address: &str, agent: &str) -> Option<String> {
let config = ureq::Agent::config_builder().timeout_global(Some(PATIENCE)).build();
let agent_of_requests: ureq::Agent = config.into();
let mut response = agent_of_requests.get(address).header("User-Agent", agent).call().ok()?;
response.body_mut().read_to_string().ok()
}
fn newest(index: &str, current: &str) -> Option<String> {
let running = Version::parse(current)?;
index
.lines()
.filter(|line| field(line, "yanked") != Some("true"))
.filter_map(|line| field(line, "vers"))
.filter_map(|text| Version::parse(text).map(|parsed| (parsed, text)))
.filter(|(parsed, _)| parsed.offered_to(&running))
.max_by(|(a, _), (b, _)| a.cmp(b))
.map(|(_, text)| text.to_owned())
}
fn field<'a>(line: &'a str, name: &str) -> Option<&'a str> {
let key = format!("\"{name}\"");
let after = line[line.find(&key)? + key.len()..].trim_start().strip_prefix(':')?.trim_start();
match after.strip_prefix('"') {
Some(text) => Some(&text[..text.find('"')?]),
None => Some(after[..after.find([',', '}']).unwrap_or(after.len())].trim()),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Version {
release: (u64, u64, u64),
pre: Vec<PrePart>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
enum PrePart {
Number(u64),
Word(String),
}
impl Version {
fn parse(text: &str) -> Option<Self> {
let text = text.split('+').next()?;
let (release, pre) = match text.split_once('-') {
Some((release, pre)) => (release, Some(pre)),
None => (text, None),
};
let mut parts = release.split('.').map(|part| part.parse::<u64>().ok());
let release = (parts.next()??, parts.next()??, parts.next()??);
if parts.next().is_some() {
return None;
}
let pre = match pre {
None => Vec::new(),
Some(pre) => pre
.split('.')
.map(|part| {
let valid = !part.is_empty() && part.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-');
valid.then(|| part.parse().map_or_else(|_| PrePart::Word(part.to_owned()), PrePart::Number))
})
.collect::<Option<Vec<_>>>()?,
};
Some(Self { release, pre })
}
fn is_pre_release(&self) -> bool {
!self.pre.is_empty()
}
fn offered_to(&self, running: &Self) -> bool {
!self.is_pre_release() || running.is_pre_release()
}
}
impl Ord for Version {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.release.cmp(&other.release).then_with(|| match (self.pre.is_empty(), other.pre.is_empty()) {
(true, true) => std::cmp::Ordering::Equal,
(true, false) => std::cmp::Ordering::Greater,
(false, true) => std::cmp::Ordering::Less,
(false, false) => self.pre.cmp(&other.pre),
})
}
}
impl PartialOrd for Version {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
#[cfg(test)]
#[path = "update_check_tests.rs"]
mod tests;