Skip to content

Commit bc0d2cc

Browse files
authored
feat(tui): add auto-update notification system (#124)
* feat(tui): add auto-update notification system Add background update checking on TUI startup with visual notification: - Add UpdateStatus enum tracking: Checking, Available, Downloading, Downloaded, UpToDate, and Error states - Integrate update check in app runner on startup (background async) - Display update banner in MinimalSessionView input area showing: - 'Checking for updates...' during check - 'A new version (vX.X.X) is available' when update found - 'Downloading update...' during download - 'Restart to apply update' after download completes - Add security hardening to cortex-update: - Enforce HTTPS for update URLs (reject insecure except localhost) - Add path traversal protection in archive extraction - Use cryptographically secure random bytes for temp directories - Fix TOCTOU race condition in tar/zip extraction - Add SAFETY comments for unsafe Windows API calls - Address clippy warnings and code quality improvements * fix(tui): remove unused UpdateStatus enum variants Removed unused variants (Checking, CheckFailed, UpToDate) from UpdateStatus enum as per Greptile code review recommendation. Only variants that are actually used in the codebase are retained: NotChecked, Available, Downloading, and ReadyToRestart.
1 parent e6658f8 commit bc0d2cc

File tree

18 files changed

+394
-46
lines changed

18 files changed

+394
-46
lines changed

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/cortex-tui/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ audio = ["dep:rodio"]
1616
# Cortex TUI core (the TUI engine)
1717
cortex-core = { path = "../cortex-core" }
1818

19+
# Auto-update system
20+
cortex-update = { path = "../cortex-update" }
21+
1922
# Centralized TUI components
2023
cortex-tui-components = { path = "../cortex-tui-components" }
2124

src/cortex-tui/src/app/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ mod types;
1515
pub use approval::{ApprovalState, PendingToolResult};
1616
pub use autocomplete::{AutocompleteItem, AutocompleteState};
1717
pub use session::{ActiveModal, SessionSummary};
18-
pub use state::AppState;
18+
pub use state::{AppState, UpdateStatus};
1919
pub use streaming::StreamingState;
2020
pub use subagent::{
2121
SubagentDisplayStatus, SubagentTaskDisplay, SubagentTodoItem, SubagentTodoStatus,

src/cortex-tui/src/app/state.rs

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ use cortex_core::{
77
style::ThemeColors,
88
widgets::{CortexInput, Message},
99
};
10+
// DownloadProgress and UpdateInfo are used in future download tracking feature
11+
#[allow(unused_imports)]
12+
use cortex_update::{DownloadProgress, UpdateInfo};
1013
use uuid::Uuid;
1114

1215
use crate::permissions::PermissionMode;
@@ -22,6 +25,59 @@ use super::streaming::StreamingState;
2225
use super::subagent::SubagentTaskDisplay;
2326
use super::types::{AppView, FocusTarget, OperationMode};
2427

28+
/// Status of the auto-update system
29+
#[derive(Debug, Clone, Default, PartialEq, Eq)]
30+
pub enum UpdateStatus {
31+
/// No update check performed yet
32+
#[default]
33+
NotChecked,
34+
/// An update is available
35+
Available {
36+
/// The new version available
37+
version: String,
38+
},
39+
/// Currently downloading the update
40+
Downloading {
41+
/// The version being downloaded
42+
version: String,
43+
/// Download progress percentage (0-100)
44+
progress: u8,
45+
},
46+
/// Download complete, restart required
47+
ReadyToRestart {
48+
/// The version that was downloaded
49+
version: String,
50+
},
51+
}
52+
53+
impl UpdateStatus {
54+
/// Returns true if an update notification should be shown
55+
pub fn should_show_banner(&self) -> bool {
56+
matches!(
57+
self,
58+
UpdateStatus::Available { .. }
59+
| UpdateStatus::Downloading { .. }
60+
| UpdateStatus::ReadyToRestart { .. }
61+
)
62+
}
63+
64+
/// Get the banner text for the current status
65+
pub fn banner_text(&self) -> Option<String> {
66+
match self {
67+
UpdateStatus::Available { version } => {
68+
Some(format!("A new version ({}) is available", version))
69+
}
70+
UpdateStatus::Downloading { progress, .. } => {
71+
Some(format!("Downloading update... {}%", progress))
72+
}
73+
UpdateStatus::ReadyToRestart { .. } => {
74+
Some("You must restart to run the latest version".to_string())
75+
}
76+
_ => None,
77+
}
78+
}
79+
}
80+
2581
/// Main application state
2682
pub struct AppState {
2783
pub view: AppView,
@@ -172,6 +228,10 @@ pub struct AppState {
172228
pub user_email: Option<String>,
173229
/// Organization name for welcome screen
174230
pub org_name: Option<String>,
231+
/// Current update status for the banner notification
232+
pub update_status: UpdateStatus,
233+
/// Cached update info when an update is available
234+
pub update_info: Option<cortex_update::UpdateInfo>,
175235
}
176236

177237
impl AppState {
@@ -272,6 +332,8 @@ impl AppState {
272332
user_name: None,
273333
user_email: None,
274334
org_name: None,
335+
update_status: UpdateStatus::default(),
336+
update_info: None,
275337
}
276338
}
277339

@@ -679,3 +741,39 @@ impl AppState {
679741
self.diff_scroll = (self.diff_scroll + delta).max(0);
680742
}
681743
}
744+
745+
// ============================================================================
746+
// APPSTATE METHODS - Update Status
747+
// ============================================================================
748+
749+
impl AppState {
750+
/// Set the update status
751+
pub fn set_update_status(&mut self, status: UpdateStatus) {
752+
self.update_status = status;
753+
}
754+
755+
/// Set update info when an update is available
756+
pub fn set_update_info(&mut self, info: Option<cortex_update::UpdateInfo>) {
757+
self.update_info = info;
758+
}
759+
760+
/// Check if an update banner should be shown
761+
pub fn should_show_update_banner(&self) -> bool {
762+
self.update_status.should_show_banner()
763+
}
764+
765+
/// Get the update banner text if one should be shown
766+
pub fn get_update_banner_text(&self) -> Option<String> {
767+
self.update_status.banner_text()
768+
}
769+
770+
/// Update download progress
771+
pub fn update_download_progress(&mut self, progress: u8) {
772+
if let UpdateStatus::Downloading { version, .. } = &self.update_status {
773+
self.update_status = UpdateStatus::Downloading {
774+
version: version.clone(),
775+
progress,
776+
};
777+
}
778+
}
779+
}

src/cortex-tui/src/runner/app_runner/runner.rs

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use super::auth_status::AuthStatus;
44
use super::exit_info::{AppExitInfo, ExitReason};
55
use super::trusted_workspaces::{is_workspace_trusted, mark_workspace_trusted};
66

7-
use crate::app::AppState;
7+
use crate::app::{AppState, UpdateStatus};
88
use crate::bridge::SessionBridge;
99
use crate::providers::ProviderManager;
1010
use crate::runner::event_loop::EventLoop;
@@ -15,7 +15,9 @@ use anyhow::Result;
1515
use cortex_engine::Config;
1616
use cortex_login::{CredentialsStoreMode, load_auth, logout_with_fallback};
1717
use cortex_protocol::ConversationId;
18+
use cortex_update::UpdateManager;
1819
use std::path::PathBuf;
20+
use std::time::Duration;
1921
use tracing;
2022

2123
// ============================================================================
@@ -552,6 +554,23 @@ impl AppRunner {
552554
let session_history_task =
553555
tokio::task::spawn_blocking(|| CortexSession::list_recent(50).ok());
554556

557+
// 2. Background update check task - check for new versions without blocking startup
558+
let update_check_task = tokio::spawn(async move {
559+
match UpdateManager::new() {
560+
Ok(manager) => match manager.check_update().await {
561+
Ok(info) => info,
562+
Err(e) => {
563+
tracing::debug!("Update check failed: {}", e);
564+
None
565+
}
566+
},
567+
Err(e) => {
568+
tracing::debug!("Failed to create update manager: {}", e);
569+
None
570+
}
571+
}
572+
});
573+
555574
// 3. Models prefetch and session validation - spawn in background
556575
// We use a channel to receive results and update provider_manager later
557576
let models_and_validation_task = {
@@ -640,6 +659,23 @@ impl AppRunner {
640659
);
641660
}
642661

662+
// Collect update check result (with short timeout to not block startup)
663+
if let Ok(Ok(Some(info))) =
664+
tokio::time::timeout(Duration::from_secs(3), update_check_task).await
665+
{
666+
tracing::info!(
667+
"Update available: {} -> {}",
668+
info.current_version,
669+
info.latest_version
670+
);
671+
app_state.set_update_status(UpdateStatus::Available {
672+
version: info.latest_version.clone(),
673+
});
674+
app_state.set_update_info(Some(info));
675+
} else {
676+
tracing::debug!("Update check did not complete in time or no update available");
677+
}
678+
643679
// Check validation result (with short timeout - don't block TUI)
644680
// We'll handle models update after event loop is created
645681
let validation_result = tokio::time::timeout(

src/cortex-tui/src/views/minimal_session/rendering.rs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -960,3 +960,65 @@ pub fn render_motd_compact(
960960

961961
Paragraph::new(lines).render(text_area, buf);
962962
}
963+
964+
/// Renders an update notification banner above the input box.
965+
/// Shows different states: Available, Downloading (with progress), ReadyToRestart
966+
pub fn render_update_banner(
967+
area: Rect,
968+
buf: &mut Buffer,
969+
colors: &AdaptiveColors,
970+
update_status: &crate::app::UpdateStatus,
971+
) {
972+
use crate::app::UpdateStatus;
973+
974+
if area.is_empty() || area.height < 1 {
975+
return;
976+
}
977+
978+
let (icon, text, style) = match update_status {
979+
UpdateStatus::Available { version } => {
980+
let icon = "↑";
981+
let text = format!(" A new version ({}) is available ", version);
982+
let style = Style::default()
983+
.fg(colors.accent)
984+
.add_modifier(Modifier::BOLD);
985+
(icon, text, style)
986+
}
987+
UpdateStatus::Downloading {
988+
version: _,
989+
progress,
990+
} => {
991+
let icon = "⟳";
992+
let text = format!(" Downloading update... {}% ", progress);
993+
let style = Style::default().fg(colors.warning);
994+
(icon, text, style)
995+
}
996+
UpdateStatus::ReadyToRestart { version: _ } => {
997+
let icon = "✓";
998+
let text = " You must restart to run the latest version ".to_string();
999+
let style = Style::default()
1000+
.fg(colors.success)
1001+
.add_modifier(Modifier::BOLD);
1002+
(icon, text, style)
1003+
}
1004+
_ => return, // Don't render for other states
1005+
};
1006+
1007+
// Calculate banner width
1008+
let banner_width = (icon.len() + text.len() + 2) as u16; // +2 for spacing
1009+
1010+
// Position at left side of the area with some padding
1011+
let x = area.x + 2;
1012+
let y = area.y;
1013+
1014+
// Ensure we don't overflow
1015+
if x + banner_width > area.right() {
1016+
return;
1017+
}
1018+
1019+
// Render icon
1020+
buf.set_string(x, y, icon, style);
1021+
1022+
// Render text
1023+
buf.set_string(x + icon.len() as u16 + 1, y, &text, style);
1024+
}

src/cortex-tui/src/views/minimal_session/view.rs

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,8 @@ impl<'a> Widget for MinimalSessionView<'a> {
569569
self.app_state.autocomplete.visible && self.app_state.autocomplete.has_items();
570570
let autocomplete_height: u16 = if autocomplete_visible { 10 } else { 0 };
571571
let status_height: u16 = if is_task_running { 1 } else { 0 };
572+
let show_update_banner = self.app_state.should_show_update_banner();
573+
let update_banner_height: u16 = if show_update_banner { 1 } else { 0 };
572574
let input_height: u16 = 3;
573575
let hints_height: u16 = 1;
574576

@@ -584,7 +586,12 @@ impl<'a> Widget for MinimalSessionView<'a> {
584586
layout.gap(1);
585587

586588
// Calculate available height for scrollable content (before input/hints)
587-
let bottom_reserved = status_height + input_height + autocomplete_height + hints_height + 2; // +2 for gaps
589+
let bottom_reserved = status_height
590+
+ update_banner_height
591+
+ input_height
592+
+ autocomplete_height
593+
+ hints_height
594+
+ 2; // +2 for gaps
588595
let available_height = area.height.saturating_sub(1 + bottom_reserved); // 1 for top margin
589596

590597
// Render scrollable content area (welcome cards + messages together)
@@ -608,7 +615,19 @@ impl<'a> Widget for MinimalSessionView<'a> {
608615
next_y += status_height;
609616
}
610617

611-
// 6. Input area - follows status (or content if no status)
618+
// 5.5 Update banner (if applicable) - above input
619+
if show_update_banner {
620+
let banner_area = Rect::new(area.x, next_y, area.width, update_banner_height);
621+
super::rendering::render_update_banner(
622+
banner_area,
623+
buf,
624+
&self.colors,
625+
&self.app_state.update_status,
626+
);
627+
next_y += update_banner_height;
628+
}
629+
630+
// 6. Input area - follows update banner (or status if no banner)
612631
let input_y = next_y;
613632
let input_area = Rect::new(area.x, input_y, area.width, input_height);
614633

src/cortex-update/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ flate2 = "1.0"
3939
zip = "2.2"
4040
tar = "0.4"
4141

42+
# Secure random
43+
getrandom = "0.2"
44+
4245
# Self-replacement
4346
self-replace = "1.5"
4447

0 commit comments

Comments
 (0)