Skip to content

Commit 5b75003

Browse files
committed
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
1 parent e6658f8 commit 5b75003

File tree

18 files changed

+403
-46
lines changed

18 files changed

+403
-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: 107 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,68 @@ 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+
/// Currently checking for updates
35+
Checking,
36+
/// An update is available
37+
Available {
38+
/// The new version available
39+
version: String,
40+
},
41+
/// Currently downloading the update
42+
Downloading {
43+
/// The version being downloaded
44+
version: String,
45+
/// Download progress percentage (0-100)
46+
progress: u8,
47+
},
48+
/// Download complete, restart required
49+
ReadyToRestart {
50+
/// The version that was downloaded
51+
version: String,
52+
},
53+
/// Update check failed (network error, etc.)
54+
CheckFailed {
55+
/// Error message
56+
error: String,
57+
},
58+
/// Already on the latest version
59+
UpToDate,
60+
}
61+
62+
impl UpdateStatus {
63+
/// Returns true if an update notification should be shown
64+
pub fn should_show_banner(&self) -> bool {
65+
matches!(
66+
self,
67+
UpdateStatus::Available { .. }
68+
| UpdateStatus::Downloading { .. }
69+
| UpdateStatus::ReadyToRestart { .. }
70+
)
71+
}
72+
73+
/// Get the banner text for the current status
74+
pub fn banner_text(&self) -> Option<String> {
75+
match self {
76+
UpdateStatus::Available { version } => {
77+
Some(format!("A new version ({}) is available", version))
78+
}
79+
UpdateStatus::Downloading { progress, .. } => {
80+
Some(format!("Downloading update... {}%", progress))
81+
}
82+
UpdateStatus::ReadyToRestart { .. } => {
83+
Some("You must restart to run the latest version".to_string())
84+
}
85+
_ => None,
86+
}
87+
}
88+
}
89+
2590
/// Main application state
2691
pub struct AppState {
2792
pub view: AppView,
@@ -172,6 +237,10 @@ pub struct AppState {
172237
pub user_email: Option<String>,
173238
/// Organization name for welcome screen
174239
pub org_name: Option<String>,
240+
/// Current update status for the banner notification
241+
pub update_status: UpdateStatus,
242+
/// Cached update info when an update is available
243+
pub update_info: Option<cortex_update::UpdateInfo>,
175244
}
176245

177246
impl AppState {
@@ -272,6 +341,8 @@ impl AppState {
272341
user_name: None,
273342
user_email: None,
274343
org_name: None,
344+
update_status: UpdateStatus::default(),
345+
update_info: None,
275346
}
276347
}
277348

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

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)