decdn_cli/commands/self_update/
check.rs1use std::io::{IsTerminal, Write};
13use std::path::{Path, PathBuf};
14use std::time::{Duration, SystemTime, UNIX_EPOCH};
15
16use decdn_common::config::load_file_config;
17
18use super::release::{
19 Clients, Upgrade, decide, releases_base, resolve_latest, running_version, version_from_tag,
20};
21
22const INTERVAL: Duration = Duration::from_hours(24);
24
25const LOOKUP_TIMEOUT: Duration = Duration::from_secs(2);
27
28const GRACE: Duration = Duration::from_secs(1);
31
32pub(crate) const ENV_CHECK: &str = "DECDN_SELF_UPDATE_CHECK";
34
35#[derive(Clone, Copy, Debug)]
38pub struct NudgePolicy {
39 pub json: bool,
41 pub excluded: bool,
43}
44
45pub(crate) fn stamp_path(data_dir: &Path) -> PathBuf {
47 data_dir.join("self-update-check")
48}
49
50pub(crate) fn is_due(stamp: Option<&str>, now_secs: u64) -> bool {
53 let Some(last) = stamp.and_then(|s| s.trim().parse::<u64>().ok()) else {
54 return true;
55 };
56 last > now_secs || now_secs - last >= INTERVAL.as_secs()
57}
58
59pub(crate) fn env_flag(raw: Option<&str>) -> Option<bool> {
62 match raw?.trim().to_ascii_lowercase().as_str() {
63 "1" | "true" | "yes" | "on" => Some(true),
64 "0" | "false" | "no" | "off" => Some(false),
65 _ => None,
66 }
67}
68
69pub(crate) fn check_enabled(env: Option<bool>, file_check: Result<Option<bool>, ()>) -> bool {
72 match (env, file_check) {
73 (Some(v), _) => v,
74 (None, Ok(v)) => v.unwrap_or(true),
75 (None, Err(())) => false,
76 }
77}
78
79pub(crate) async fn notice(
84 stamp: &Path,
85 base: &str,
86 timeout: Duration,
87 now_secs: u64,
88) -> Option<String> {
89 write_stamp(stamp, now_secs).ok()?;
90 let clients = Clients::build(timeout).ok()?;
91 let tag = tokio::time::timeout(timeout, resolve_latest(&clients, base))
92 .await
93 .ok()?
94 .ok()?;
95 let latest = version_from_tag(&tag).ok()?;
96 let running = running_version().ok()?;
97 match decide(&running, &latest) {
98 Upgrade::Newer => Some(format!(
99 "decdn {latest} is available; run `decdn self-update`"
100 )),
101 Upgrade::UpToDate | Upgrade::Downgrade | Upgrade::PreRelease => None,
102 }
103}
104
105fn write_stamp(stamp: &Path, now_secs: u64) -> std::io::Result<()> {
109 if let Some(dir) = stamp.parent() {
110 let mut builder = std::fs::DirBuilder::new();
111 builder.recursive(true);
112 #[cfg(unix)]
113 {
114 use std::os::unix::fs::DirBuilderExt;
115 builder.mode(0o700);
116 }
117 builder.create(dir)?;
118 }
119 let mut options = std::fs::OpenOptions::new();
120 options.write(true).create(true).truncate(true);
121 #[cfg(unix)]
122 {
123 use std::os::unix::fs::OpenOptionsExt;
124 options.mode(0o600);
125 }
126 let mut file = options.open(stamp)?;
127 writeln!(file, "{now_secs}")
128}
129
130#[derive(Debug)]
132pub struct Pending {
133 handle: tokio::task::JoinHandle<Option<String>>,
134}
135
136impl Pending {
137 pub async fn finish(self) {
140 if let Some(line) = settle(self.handle, GRACE).await {
141 eprintln!("{line}");
142 }
143 }
144
145 pub fn cancel(self) {
147 self.handle.abort();
148 }
149}
150
151pub(crate) async fn settle(
153 handle: tokio::task::JoinHandle<Option<String>>,
154 grace: Duration,
155) -> Option<String> {
156 let abort = handle.abort_handle();
157 match tokio::time::timeout(grace, handle).await {
158 Ok(Ok(line)) => line,
159 Ok(Err(_)) => None,
160 Err(_elapsed) => {
161 abort.abort();
162 None
163 }
164 }
165}
166
167pub fn start(policy: NudgePolicy, config_path: Option<&Path>) -> Option<Pending> {
171 if policy.excluded
172 || policy.json
173 || !std::io::stdout().is_terminal()
174 || !std::io::stderr().is_terminal()
175 {
176 return None;
177 }
178 let env = env_flag(std::env::var(ENV_CHECK).ok().as_deref());
179 let file_check = load_file_config(config_path)
180 .map(|f| f.self_update.and_then(|s| s.check))
181 .map_err(|_| ());
182 if !check_enabled(env, file_check) {
183 return None;
184 }
185 let data_dir = decdn_common::cli::default_data_dir()?;
186 let stamp = stamp_path(&data_dir);
187 let now_secs = SystemTime::now()
188 .duration_since(UNIX_EPOCH)
189 .map_or(0, |d| d.as_secs());
190 if !is_due(std::fs::read_to_string(&stamp).ok().as_deref(), now_secs) {
191 return None;
192 }
193 let base = releases_base();
194 let handle = tokio::spawn(async move { notice(&stamp, &base, LOOKUP_TIMEOUT, now_secs).await });
195 Some(Pending { handle })
196}
197
198#[cfg(test)]
199#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod tests;