diff --git a/Cargo.lock b/Cargo.lock index 326f065..228669d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1123,7 +1123,7 @@ dependencies = [ [[package]] name = "ostiary" -version = "1.0.0" +version = "1.0.1" dependencies = [ "anyhow", "crossterm", diff --git a/Cargo.toml b/Cargo.toml index 653ed54..7b41531 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ostiary" -version = "1.0.0" +version = "1.0.1" edition = "2024" [dependencies] diff --git a/src/app.rs b/src/app.rs index b4b4950..7614bbd 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,6 +4,7 @@ use crate::error::Result; use crate::executor; use crate::menu::{Action, InteractionMode, MenuItem, MenuItemKind, MenuRoot}; use crate::network; +use crate::updater; use crossterm::event::{Event as CrosstermEvent, KeyCode, KeyEventKind}; use tokio::sync::mpsc::UnboundedSender; @@ -16,6 +17,10 @@ pub enum Event { StructuredCommand(executor::StructuredCommand), StructuredOutput(String), StructuredFinished(i32), + UpdateAvailable(updater::UpdateInfo), + UpdateProgress(u64), + UpdateDone, + UpdateError(String), } /// A pending request to hand the terminal to an external interactive process. @@ -35,6 +40,7 @@ pub struct App { pub event_tx: UnboundedSender, /// When set, main loop suspends ratatui, runs the command, then restores. pub pending_exec: Option, + pub pending_restart: bool, } pub enum Popup { @@ -62,6 +68,22 @@ pub enum Popup { }, Downloading { progress: f32, message: String }, Message { text: String, level: MessageLevel }, + UpdateConfirm { + info: updater::UpdateInfo, + }, + Updating { + info: updater::UpdateInfo, + downloaded: u64, + status: UpdatingStatus, + }, +} + +#[derive(Debug, PartialEq)] +pub enum UpdatingStatus { + Downloading, + Applying, + Done, + Failed(String), } #[derive(Debug, Clone, Copy)] @@ -82,9 +104,25 @@ impl App { popup: None, event_tx, pending_exec: None, + pending_restart: false, } } + pub fn check_update(&mut self) { + let api_base = match &self.config.update_api { + Some(url) => url.clone(), + None => return, + }; + let timeout = self.config.timeout_sec; + let tx = self.event_tx.clone(); + + tokio::spawn(async move { + if let Ok(Some(info)) = updater::check(&api_base, timeout).await { + let _ = tx.send(Event::UpdateAvailable(info)); + } + }); + } + pub async fn load_menu(&mut self) { let server_url = self.config.server_url.clone(); let timeout = self.config.timeout_sec; @@ -190,6 +228,32 @@ impl App { // script), we silently discard the exit event. Ok(false) } + Event::UpdateAvailable(info) => { + // Only show if no popup is currently open (don't interrupt running scripts) + if self.popup.is_none() { + self.popup = Some(Popup::UpdateConfirm { info }); + } + Ok(false) + } + Event::UpdateProgress(bytes) => { + if let Some(Popup::Updating { downloaded, .. }) = &mut self.popup { + *downloaded = bytes; + } + Ok(false) + } + Event::UpdateDone => { + if let Some(Popup::Updating { status, .. }) = &mut self.popup { + *status = UpdatingStatus::Done; + } + self.pending_restart = true; + Ok(false) + } + Event::UpdateError(msg) => { + if let Some(Popup::Updating { status, .. }) = &mut self.popup { + *status = UpdatingStatus::Failed(msg); + } + Ok(false) + } } } @@ -347,6 +411,66 @@ impl App { self.popup = None; return Ok(false); } + + Popup::UpdateConfirm { info } => { + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + let info = info.clone(); + self.popup = Some(Popup::Updating { + info: info.clone(), + downloaded: 0, + status: UpdatingStatus::Downloading, + }); + + let (progress_tx, mut progress_rx) = + tokio::sync::mpsc::unbounded_channel::(); + let tx = self.event_tx.clone(); + let info_clone = info.clone(); + + // Forward progress events + tokio::spawn(async move { + while let Some(bytes) = progress_rx.recv().await { + let _ = tx.send(Event::UpdateProgress(bytes)); + } + }); + + // Download and apply + let tx2 = self.event_tx.clone(); + tokio::spawn(async move { + match updater::download_and_apply( + &info_clone, + progress_tx, + ) + .await + { + Ok(()) => { + let _ = tx2.send(Event::UpdateDone); + } + Err(e) => { + let _ = + tx2.send(Event::UpdateError(e.to_string())); + } + } + }); + } + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + self.popup = None; + } + _ => {} + } + return Ok(false); + } + + Popup::Updating { status, .. } => { + if key.code == KeyCode::Esc { + if matches!(status, UpdatingStatus::Failed(_)) { + self.popup = None; + } + // Ignore Esc while downloading/applying + } + return Ok(false); + } + _ => {} } } diff --git a/src/config.rs b/src/config.rs index b92d422..57d4dec 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,6 +9,8 @@ pub struct Config { pub timeout_sec: u64, #[serde(default = "default_theme")] pub theme: Theme, + #[serde(default)] + pub update_api: Option, } #[derive(Debug, Deserialize, Serialize, Clone)] @@ -29,9 +31,16 @@ fn default_theme() -> Theme { impl Config { pub fn load() -> Result { - let config_path = dirs::config_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("tui-client") + // Respect XDG_CONFIG_HOME; fall back to ~/.config on all platforms. + let config_base = std::env::var("XDG_CONFIG_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".config") + }); + let config_path = config_base + .join(env!("CARGO_PKG_NAME")) .join("config.toml"); if !config_path.exists() { @@ -39,6 +48,7 @@ impl Config { server_url: "http://localhost:8080/api/menu".to_string(), timeout_sec: 10, theme: default_theme(), + update_api: None, }; fs::create_dir_all(config_path.parent().unwrap())?; fs::write(config_path, toml::to_string_pretty(&default)?)?; diff --git a/src/main.rs b/src/main.rs index 454912a..07d52a9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ mod executor; mod menu; mod network; mod ui; +mod updater; use std::io; use anyhow::Result; @@ -30,6 +31,7 @@ async fn main() -> Result<()> { let (event_tx, mut event_rx) = mpsc::unbounded_channel(); let mut app = app::App::new(config, event_tx.clone()); app.load_menu().await; + app.check_update(); let res = run_app(&mut terminal, &mut app, &mut event_rx).await; @@ -71,6 +73,21 @@ async fn run_app( break; } + // Check for pending restart (after update applied) — before pending_exec. + if app.pending_restart { + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + #[cfg(unix)] + updater::exec_updated(); + // fallback for non-unix or if exec failed + break; + } + // After handling events, check if a script requested an exec. if let Some(exec) = app.pending_exec.take() { run_exec(terminal, exec).await?; diff --git a/src/ui.rs b/src/ui.rs index 6135bcf..94ebd3a 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,6 +1,6 @@ use crate::ansi; use crate::executor; -use crate::app::{App, MessageLevel, Popup}; +use crate::app::{App, MessageLevel, Popup, UpdatingStatus}; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, @@ -69,7 +69,12 @@ fn render_description(f: &mut Frame, app: &App, area: Rect) { fn render_popup(f: &mut Frame, popup: &mut Popup) { let area = f.area(); - let popup_area = centered_rect(80, 90, area); + + // Use a smaller area for update-related popups + let popup_area = match popup { + Popup::UpdateConfirm { .. } | Popup::Updating { .. } => centered_rect(50, 40, area), + _ => centered_rect(80, 90, area), + }; f.render_widget(Clear, popup_area); @@ -235,6 +240,110 @@ fn render_popup(f: &mut Frame, popup: &mut Popup) { f.render_widget(paragraph, popup_area); } + Popup::UpdateConfirm { info } => { + let mut lines = vec![ + Line::from(""), + Line::from(Span::raw(format!( + " {} → {}", + info.current_version, info.new_version + ))), + ]; + if info.size > 0 { + let mb = info.size as f64 / 1_048_576.0; + lines.push(Line::from(Span::raw(format!(" Размер: {:.1} МБ", mb)))); + } + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " [Y] Обновить [N / Esc] Пропустить", + Style::default().fg(Color::Yellow), + ))); + + let block = Block::default() + .borders(Borders::ALL) + .title(" Доступно обновление ") + .border_style(Style::default().fg(Color::Cyan)); + let paragraph = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: true }); + f.render_widget(paragraph, popup_area); + } + + Popup::Updating { + info, + downloaded, + status, + } => { + let (title, border_color) = match status { + UpdatingStatus::Downloading => (" Загрузка обновления… ", Color::Cyan), + UpdatingStatus::Applying => (" Применение обновления… ", Color::Yellow), + UpdatingStatus::Done => (" Обновление завершено ", Color::Green), + UpdatingStatus::Failed(_) => (" Ошибка обновления ", Color::Red), + }; + + let bar_width = popup_area.width.saturating_sub(4) as usize; + + let progress_line = if info.size > 0 { + let percent = ((*downloaded as f64 / info.size as f64) * 100.0) + .min(100.0) as usize; + let filled = (percent * bar_width) / 100; + format!( + "[{}{}] {}%", + "█".repeat(filled), + "░".repeat(bar_width.saturating_sub(filled)), + percent + ) + } else { + // Indeterminate: animate based on downloaded bytes + let pos = ((*downloaded / 4096) as usize) % bar_width.max(1); + let thumb = 4.min(bar_width); + let mut bar = vec!['░'; bar_width]; + for i in pos..((pos + thumb).min(bar_width)) { + bar[i] = '█'; + } + format!("[{}]", bar.iter().collect::()) + }; + + let status_text = match status { + UpdatingStatus::Downloading => { + if info.size > 0 { + let mb_done = *downloaded as f64 / 1_048_576.0; + let mb_total = info.size as f64 / 1_048_576.0; + format!(" {:.1} / {:.1} МБ", mb_done, mb_total) + } else { + let kb = *downloaded / 1024; + format!(" {} КБ загружено", kb) + } + } + UpdatingStatus::Applying => " Применяется…".to_string(), + UpdatingStatus::Done => " Обновление успешно установлено. Перезапуск…".to_string(), + UpdatingStatus::Failed(msg) => format!(" Ошибка: {}", msg), + }; + + let mut lines = vec![ + Line::from(""), + Line::from(Span::raw(progress_line)), + Line::from(""), + Line::from(Span::raw(status_text)), + ]; + + if matches!(status, UpdatingStatus::Failed(_)) { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " [Esc] Закрыть", + Style::default().fg(Color::Yellow), + ))); + } + + let block = Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(Style::default().fg(border_color)); + let paragraph = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: true }); + f.render_widget(paragraph, popup_area); + } + _ => {} } } diff --git a/src/updater.rs b/src/updater.rs new file mode 100644 index 0000000..ca274c4 --- /dev/null +++ b/src/updater.rs @@ -0,0 +1,168 @@ +// src/updater.rs +use anyhow::{Context, Result}; +use futures::StreamExt; +use serde::Deserialize; +use tokio::io::AsyncWriteExt; +use tokio::sync::mpsc::UnboundedSender; + +#[derive(Debug, Clone)] +pub struct UpdateInfo { + pub current_version: String, + pub new_version: String, + pub download_url: String, + pub size: u64, +} + +#[derive(Deserialize)] +struct Release { + tag_name: String, + prerelease: bool, + assets: Vec, +} + +#[derive(Deserialize)] +struct ReleaseAsset { + name: String, + browser_download_url: String, + size: u64, +} + +/// Checks Gitea releases API for latest stable (non-prerelease) release. +/// Returns Ok(None) if already up to date or no matching asset found. +pub async fn check(api_base: &str, timeout_sec: u64) -> Result> { + let url = format!("{}/releases?limit=10&page=1", api_base); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(timeout_sec)) + .build() + .context("failed to build HTTP client")?; + + let releases: Vec = client + .get(&url) + .send() + .await + .context("failed to fetch releases")? + .json() + .await + .context("failed to parse releases JSON")?; + + let current_version = env!("CARGO_PKG_VERSION").to_string(); + + // Find the latest non-prerelease release + let latest = releases.into_iter().find(|r| !r.prerelease); + let release = match latest { + Some(r) => r, + None => return Ok(None), + }; + + // Strip "v" prefix and anything after "@" (e.g. "v1.0.0@master" → "1.0.0") + let tag = release.tag_name.as_str(); + let tag = tag.strip_prefix('v').unwrap_or(tag); + let tag = tag.split('@').next().unwrap_or(tag).trim(); + let new_version = tag.to_string(); + + // Already up to date + if new_version == current_version { + return Ok(None); + } + + // Find a matching asset + let asset_prefix = format!( + "{}-{}-{}", + env!("CARGO_PKG_NAME"), + std::env::consts::OS, + std::env::consts::ARCH + ); + + let asset = release + .assets + .into_iter() + .find(|a| a.name.starts_with(&asset_prefix)); + + let asset = match asset { + Some(a) => a, + None => return Ok(None), + }; + + Ok(Some(UpdateInfo { + current_version, + new_version, + download_url: asset.browser_download_url, + size: asset.size, + })) +} + +/// Downloads binary to temp file with streaming progress, atomically replaces +/// current exe, sets chmod 755. +/// progress_tx receives bytes downloaded so far. +pub async fn download_and_apply( + info: &UpdateInfo, + progress_tx: UnboundedSender, +) -> Result<()> { + let client = reqwest::Client::new(); + + let response = client + .get(&info.download_url) + .send() + .await + .context("failed to start download")? + .error_for_status() + .context("download request failed with error status")?; + + // Write to a temp file next to the current exe so rename is atomic + let current_exe = std::env::current_exe().context("failed to get current exe path")?; + let exe_dir = current_exe + .parent() + .context("current exe has no parent directory")?; + + let tmp_path = exe_dir.join(format!(".{}.tmp", env!("CARGO_PKG_NAME"))); + + let mut file = tokio::fs::File::create(&tmp_path) + .await + .context("failed to create temp file")?; + + let mut stream = response.bytes_stream(); + let mut downloaded: u64 = 0; + + while let Some(chunk) = stream.next().await { + let chunk = chunk.context("error reading download chunk")?; + downloaded += chunk.len() as u64; + file.write_all(&chunk) + .await + .context("failed to write to temp file")?; + let _ = progress_tx.send(downloaded); + } + + // Flush and close + drop(file); + + // chmod 755 + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o755); + std::fs::set_permissions(&tmp_path, perms).context("failed to set permissions")?; + } + + // Atomically replace current exe + std::fs::rename(&tmp_path, ¤t_exe).context("failed to replace current exe")?; + + Ok(()) +} + +/// Replaces current process via execv (Unix). Never returns on success. +#[cfg(unix)] +pub fn exec_updated() -> ! { + use std::os::unix::process::CommandExt; + + let exe = std::env::current_exe().expect("failed to get current exe path"); + let args: Vec = std::env::args().collect(); + + let err = std::process::Command::new(&exe) + .args(&args[1..]) + .exec(); + + // exec only returns if it failed + eprintln!("Failed to exec updated binary: {}", err); + std::process::exit(1); +}