feat: Auto update approval (#5185)

Adds an update prompt when the CLI starts:

<img width="1410" height="608" alt="Screenshot 2025-10-14 at 5 53 17 PM"
src="https://github.com/user-attachments/assets/47c8bafa-7bed-4be8-b597-c4c6c79756b8"
/>
This commit is contained in:
dedrisian-oai
2025-10-15 16:11:20 -07:00
committed by GitHub
parent 18d00e36b9
commit 272e13dd90
9 changed files with 546 additions and 55 deletions

View File

@@ -19,6 +19,7 @@ use codex_exec::Cli as ExecCli;
use codex_responses_api_proxy::Args as ResponsesApiProxyArgs;
use codex_tui::AppExitInfo;
use codex_tui::Cli as TuiCli;
use codex_tui::UpdateAction;
use owo_colors::OwoColorize;
use std::path::PathBuf;
use supports_color::Stream;
@@ -208,6 +209,7 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec<Stri
let AppExitInfo {
token_usage,
conversation_id,
..
} = exit_info;
if token_usage.is_zero() {
@@ -232,11 +234,32 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec<Stri
lines
}
fn print_exit_messages(exit_info: AppExitInfo) {
/// Handle the app exit and print the results. Optionally run the update action.
fn handle_app_exit(exit_info: AppExitInfo) -> anyhow::Result<()> {
let update_action = exit_info.update_action;
let color_enabled = supports_color::on(Stream::Stdout).is_some();
for line in format_exit_messages(exit_info, color_enabled) {
println!("{line}");
}
if let Some(action) = update_action {
run_update_action(action)?;
}
Ok(())
}
/// Run the update action and print the result.
fn run_update_action(action: UpdateAction) -> anyhow::Result<()> {
println!();
let (cmd, args) = action.command_args();
let cmd_str = action.command_str();
println!("Updating Codex via `{cmd_str}`...");
let status = std::process::Command::new(cmd).args(args).status()?;
if !status.success() {
anyhow::bail!("`{cmd_str}` failed with status {status}");
}
println!();
println!("🎉 Update ran successfully! Please restart Codex.");
Ok(())
}
#[derive(Debug, Default, Parser, Clone)]
@@ -321,7 +344,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
root_config_overrides.clone(),
);
let exit_info = codex_tui::run_main(interactive, codex_linux_sandbox_exe).await?;
print_exit_messages(exit_info);
handle_app_exit(exit_info)?;
}
Some(Subcommand::Exec(mut exec_cli)) => {
prepend_config_flags(
@@ -354,7 +377,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
config_overrides,
);
let exit_info = codex_tui::run_main(interactive, codex_linux_sandbox_exe).await?;
print_exit_messages(exit_info);
handle_app_exit(exit_info)?;
}
Some(Subcommand::Login(mut login_cli)) => {
prepend_config_flags(
@@ -595,6 +618,7 @@ mod tests {
conversation_id: conversation
.map(ConversationId::from_string)
.map(Result::unwrap),
update_action: None,
}
}
@@ -603,6 +627,7 @@ mod tests {
let exit_info = AppExitInfo {
token_usage: TokenUsage::default(),
conversation_id: None,
update_action: None,
};
let lines = format_exit_messages(exit_info, false);
assert!(lines.is_empty());

View File

@@ -1,3 +1,4 @@
use crate::UpdateAction;
use crate::app_backtrack::BacktrackState;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
@@ -43,6 +44,7 @@ use tokio::sync::mpsc::unbounded_channel;
pub struct AppExitInfo {
pub token_usage: TokenUsage,
pub conversation_id: Option<ConversationId>,
pub update_action: Option<UpdateAction>,
}
pub(crate) struct App {
@@ -71,6 +73,9 @@ pub(crate) struct App {
// Esc-backtracking state grouped
pub(crate) backtrack: crate::app_backtrack::BacktrackState,
/// Set when the user confirms an update; propagated on exit.
pub(crate) pending_update_action: Option<UpdateAction>,
}
impl App {
@@ -152,6 +157,7 @@ impl App {
has_emitted_history_lines: false,
commit_anim_running: Arc::new(AtomicBool::new(false)),
backtrack: BacktrackState::default(),
pending_update_action: None,
};
let tui_events = tui.event_stream();
@@ -171,6 +177,7 @@ impl App {
Ok(AppExitInfo {
token_usage: app.token_usage(),
conversation_id: app.chat_widget.conversation_id(),
update_action: app.pending_update_action,
})
}
@@ -521,6 +528,7 @@ mod tests {
enhanced_keys_supported: false,
commit_anim_running: Arc::new(AtomicBool::new(false)),
backtrack: BacktrackState::default(),
pending_update_action: None,
}
}

View File

@@ -0,0 +1,14 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend().vt100().screen().contents()
---
New version available! Would you like to update?
Full release notes: https://github.com/openai/codex/releases/latest
1. Yes, update now
2. No, not now
3. Don't remind me
Press enter to confirm or esc to go back

View File

@@ -59,6 +59,7 @@ mod pager_overlay;
pub mod public_widgets;
mod render;
mod resume_picker;
mod selection_list;
mod session_log;
mod shimmer;
mod slash_command;
@@ -70,7 +71,38 @@ mod terminal_palette;
mod text_formatting;
mod tui;
mod ui_consts;
mod update_prompt;
mod version;
/// Update action the CLI should perform after the TUI exits.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpdateAction {
/// Update via `npm install -g @openai/codex@latest`.
NpmGlobalLatest,
/// Update via `bun install -g @openai/codex@latest`.
BunGlobalLatest,
/// Update via `brew upgrade codex`.
BrewUpgrade,
}
impl UpdateAction {
/// Returns the list of command-line arguments for invoking the update.
pub fn command_args(&self) -> (&'static str, &'static [&'static str]) {
match self {
UpdateAction::NpmGlobalLatest => ("npm", &["install", "-g", "@openai/codex@latest"]),
UpdateAction::BunGlobalLatest => ("bun", &["install", "-g", "@openai/codex@latest"]),
UpdateAction::BrewUpgrade => ("brew", &["upgrade", "codex"]),
}
}
/// Returns string representation of the command-line arguments for invoking the update.
pub fn command_str(&self) -> String {
let (command, args) = self.command_args();
let args_str = args.join(" ");
format!("{command} {args_str}")
}
}
mod wrapping;
#[cfg(test)]
@@ -299,6 +331,26 @@ async fn run_ratatui_app(
let mut tui = Tui::new(terminal);
#[cfg(not(debug_assertions))]
{
use crate::update_prompt::UpdatePromptOutcome;
let skip_update_prompt = cli.prompt.as_ref().is_some_and(|prompt| !prompt.is_empty());
if !skip_update_prompt {
match update_prompt::run_update_prompt_if_needed(&mut tui, &config).await? {
UpdatePromptOutcome::Continue => {}
UpdatePromptOutcome::RunUpdate(action) => {
crate::tui::restore()?;
return Ok(AppExitInfo {
token_usage: codex_core::protocol::TokenUsage::default(),
conversation_id: None,
update_action: Some(action),
});
}
}
}
}
// Show update banner in terminal history (instead of stderr) so it is visible
// within the TUI scrollback. Building spans keeps styling consistent.
#[cfg(not(debug_assertions))]
@@ -309,9 +361,6 @@ async fn run_ratatui_app(
use ratatui::text::Line;
let current_version = env!("CARGO_PKG_VERSION");
let exe = std::env::current_exe()?;
let managed_by_bun = std::env::var_os("CODEX_MANAGED_BY_BUN").is_some();
let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some();
let mut content_lines: Vec<Line<'static>> = vec![
Line::from(vec![
@@ -331,27 +380,10 @@ async fn run_ratatui_app(
Line::from(""),
];
if managed_by_bun {
let bun_cmd = "bun install -g @openai/codex@latest";
if let Some(update_action) = get_update_action() {
content_lines.push(Line::from(vec![
"Run ".into(),
bun_cmd.cyan(),
" to update.".into(),
]));
} else if managed_by_npm {
let npm_cmd = "npm install -g @openai/codex@latest";
content_lines.push(Line::from(vec![
"Run ".into(),
npm_cmd.cyan(),
" to update.".into(),
]));
} else if cfg!(target_os = "macos")
&& (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local"))
{
let brew_cmd = "brew upgrade codex";
content_lines.push(Line::from(vec![
"Run ".into(),
brew_cmd.cyan(),
update_action.command_str().cyan(),
" to update.".into(),
]));
} else {
@@ -405,6 +437,7 @@ async fn run_ratatui_app(
return Ok(AppExitInfo {
token_usage: codex_core::protocol::TokenUsage::default(),
conversation_id: None,
update_action: None,
});
}
if should_show_windows_wsl_screen {
@@ -449,6 +482,7 @@ async fn run_ratatui_app(
return Ok(AppExitInfo {
token_usage: codex_core::protocol::TokenUsage::default(),
conversation_id: None,
update_action: None,
});
}
other => other,
@@ -477,6 +511,47 @@ async fn run_ratatui_app(
app_result
}
/// Get the update action from the environment.
/// Returns `None` if not managed by npm, bun, or brew.
#[cfg(not(debug_assertions))]
pub(crate) fn get_update_action() -> Option<UpdateAction> {
let exe = std::env::current_exe().unwrap_or_default();
let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some();
let managed_by_bun = std::env::var_os("CODEX_MANAGED_BY_BUN").is_some();
if managed_by_npm {
Some(UpdateAction::NpmGlobalLatest)
} else if managed_by_bun {
Some(UpdateAction::BunGlobalLatest)
} else if cfg!(target_os = "macos")
&& (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local"))
{
Some(UpdateAction::BrewUpgrade)
} else {
None
}
}
#[test]
#[cfg(not(debug_assertions))]
fn test_get_update_action() {
let prev = std::env::var_os("CODEX_MANAGED_BY_NPM");
// First: no npm var -> expect None (we do not run from brew in CI)
unsafe { std::env::remove_var("CODEX_MANAGED_BY_NPM") };
assert_eq!(get_update_action(), None);
// Then: with npm var -> expect NpmGlobalLatest
unsafe { std::env::set_var("CODEX_MANAGED_BY_NPM", "1") };
assert_eq!(get_update_action(), Some(UpdateAction::NpmGlobalLatest));
// Restore prior value to avoid leaking state
if let Some(v) = prev {
unsafe { std::env::set_var("CODEX_MANAGED_BY_NPM", v) };
} else {
unsafe { std::env::remove_var("CODEX_MANAGED_BY_NPM") };
}
}
#[expect(
clippy::print_stderr,
reason = "TUI should no longer be displayed, so we can write to stderr."

View File

@@ -7,8 +7,6 @@ use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::style::Styled as _;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
@@ -22,11 +20,9 @@ use crate::render::Insets;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableExt as _;
use crate::render::renderable::RowRenderable;
use crate::selection_list::selection_option_row;
use super::onboarding_screen::StepState;
use unicode_width::UnicodeWidthStr;
pub(crate) struct TrustDirectoryWidget {
pub codex_home: PathBuf,
pub cwd: PathBuf,
@@ -88,7 +84,7 @@ impl WidgetRef for &TrustDirectoryWidget {
}
for (idx, (text, selection)) in options.iter().enumerate() {
column.push(new_option_row(
column.push(selection_option_row(
idx,
text.to_string(),
self.highlighted == *selection,
@@ -120,30 +116,6 @@ impl WidgetRef for &TrustDirectoryWidget {
}
}
fn new_option_row(index: usize, label: String, is_selected: bool) -> Box<dyn Renderable> {
let prefix = if is_selected {
format!(" {}. ", index + 1)
} else {
format!(" {}. ", index + 1)
};
let mut style = Style::default();
if is_selected {
style = style.cyan();
}
let mut row = RowRenderable::new();
row.push(prefix.width() as u16, prefix.set_style(style));
row.push(
u16::MAX,
Paragraph::new(label)
.style(style)
.wrap(Wrap { trim: false }),
);
row.into()
}
impl KeyboardHandler for TrustDirectoryWidget {
fn handle_key_event(&mut self, key_event: KeyEvent) {
if key_event.kind == KeyEventKind::Release {

View File

@@ -0,0 +1,35 @@
use crate::render::renderable::Renderable;
use crate::render::renderable::RowRenderable;
use ratatui::style::Style;
use ratatui::style::Styled as _;
use ratatui::style::Stylize as _;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Wrap;
use unicode_width::UnicodeWidthStr;
pub(crate) fn selection_option_row(
index: usize,
label: String,
is_selected: bool,
) -> Box<dyn Renderable> {
let prefix = if is_selected {
format!(" {}. ", index + 1)
} else {
format!(" {}. ", index + 1)
};
let style = if is_selected {
Style::default().cyan()
} else {
Style::default()
};
let prefix_width = UnicodeWidthStr::width(prefix.as_str()) as u16;
let mut row = RowRenderable::new();
row.push(prefix_width, prefix.set_style(style));
row.push(
u16::MAX,
Paragraph::new(label)
.style(style)
.wrap(Wrap { trim: false }),
);
row.into()
}

View File

@@ -0,0 +1,13 @@
---
source: tui/src/update_prompt.rs
expression: terminal.backend()
---
Update available! 0.0.0 -> 9.9.9
Release notes: https://github.com/openai/codex/releases/latest
1. Update now (runs `npm install -g @openai/codex@latest`)
2. Skip
3. Skip until next version
Press enter to continue

View File

@@ -0,0 +1,313 @@
#![cfg(not(debug_assertions))]
use crate::UpdateAction;
use crate::history_cell::padded_emoji;
use crate::key_hint;
use crate::render::Insets;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableExt as _;
use crate::selection_list::selection_option_row;
use crate::tui::FrameRequester;
use crate::tui::Tui;
use crate::tui::TuiEvent;
use crate::updates;
use codex_core::config::Config;
use color_eyre::Result;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Widget;
use ratatui::style::Stylize as _;
use ratatui::text::Line;
use ratatui::widgets::Clear;
use ratatui::widgets::WidgetRef;
use tokio_stream::StreamExt;
pub(crate) enum UpdatePromptOutcome {
Continue,
RunUpdate(UpdateAction),
}
pub(crate) async fn run_update_prompt_if_needed(
tui: &mut Tui,
config: &Config,
) -> Result<UpdatePromptOutcome> {
let Some(latest_version) = updates::get_upgrade_version_for_popup(config) else {
return Ok(UpdatePromptOutcome::Continue);
};
let Some(update_action) = crate::get_update_action() else {
return Ok(UpdatePromptOutcome::Continue);
};
let mut screen =
UpdatePromptScreen::new(tui.frame_requester(), latest_version.clone(), update_action);
tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&screen, frame.area());
})?;
let events = tui.event_stream();
tokio::pin!(events);
while !screen.is_done() {
if let Some(event) = events.next().await {
match event {
TuiEvent::Key(key_event) => screen.handle_key(key_event),
TuiEvent::Paste(_) => {}
TuiEvent::Draw => {
tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&screen, frame.area());
})?;
}
}
} else {
break;
}
}
match screen.selection() {
Some(UpdateSelection::UpdateNow) => {
tui.terminal.clear()?;
Ok(UpdatePromptOutcome::RunUpdate(update_action))
}
Some(UpdateSelection::NotNow) | None => Ok(UpdatePromptOutcome::Continue),
Some(UpdateSelection::DontRemind) => {
if let Err(err) = updates::dismiss_version(config, screen.latest_version()).await {
tracing::error!("Failed to persist update dismissal: {err}");
}
Ok(UpdatePromptOutcome::Continue)
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum UpdateSelection {
UpdateNow,
NotNow,
DontRemind,
}
struct UpdatePromptScreen {
request_frame: FrameRequester,
latest_version: String,
current_version: String,
update_action: UpdateAction,
highlighted: UpdateSelection,
selection: Option<UpdateSelection>,
}
impl UpdatePromptScreen {
fn new(
request_frame: FrameRequester,
latest_version: String,
update_action: UpdateAction,
) -> Self {
Self {
request_frame,
latest_version,
current_version: env!("CARGO_PKG_VERSION").to_string(),
update_action,
highlighted: UpdateSelection::UpdateNow,
selection: None,
}
}
fn handle_key(&mut self, key_event: KeyEvent) {
if key_event.kind == KeyEventKind::Release {
return;
}
if key_event.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key_event.code, KeyCode::Char('c') | KeyCode::Char('d'))
{
self.select(UpdateSelection::NotNow);
return;
}
match key_event.code {
KeyCode::Up | KeyCode::Char('k') => self.set_highlight(self.highlighted.prev()),
KeyCode::Down | KeyCode::Char('j') => self.set_highlight(self.highlighted.next()),
KeyCode::Char('1') => self.select(UpdateSelection::UpdateNow),
KeyCode::Char('2') => self.select(UpdateSelection::NotNow),
KeyCode::Char('3') => self.select(UpdateSelection::DontRemind),
KeyCode::Enter => self.select(self.highlighted),
KeyCode::Esc => self.select(UpdateSelection::NotNow),
_ => {}
}
}
fn set_highlight(&mut self, highlight: UpdateSelection) {
if self.highlighted != highlight {
self.highlighted = highlight;
self.request_frame.schedule_frame();
}
}
fn select(&mut self, selection: UpdateSelection) {
self.highlighted = selection;
self.selection = Some(selection);
self.request_frame.schedule_frame();
}
fn is_done(&self) -> bool {
self.selection.is_some()
}
fn selection(&self) -> Option<UpdateSelection> {
self.selection
}
fn latest_version(&self) -> &str {
self.latest_version.as_str()
}
}
impl UpdateSelection {
fn next(self) -> Self {
match self {
UpdateSelection::UpdateNow => UpdateSelection::NotNow,
UpdateSelection::NotNow => UpdateSelection::DontRemind,
UpdateSelection::DontRemind => UpdateSelection::UpdateNow,
}
}
fn prev(self) -> Self {
match self {
UpdateSelection::UpdateNow => UpdateSelection::DontRemind,
UpdateSelection::NotNow => UpdateSelection::UpdateNow,
UpdateSelection::DontRemind => UpdateSelection::NotNow,
}
}
}
impl WidgetRef for &UpdatePromptScreen {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
let mut column = ColumnRenderable::new();
let update_command = self.update_action.command_str();
column.push("");
column.push(Line::from(vec![
padded_emoji("").bold().cyan(),
"Update available!".bold(),
" ".into(),
format!(
"{current} -> {latest}",
current = self.current_version,
latest = self.latest_version
)
.dim(),
]));
column.push("");
column.push(
Line::from(vec![
"Release notes: ".dim(),
"https://github.com/openai/codex/releases/latest"
.dim()
.underlined(),
])
.inset(Insets::tlbr(0, 2, 0, 0)),
);
column.push("");
column.push(selection_option_row(
0,
format!("Update now (runs `{update_command}`)"),
self.highlighted == UpdateSelection::UpdateNow,
));
column.push(selection_option_row(
1,
"Skip".to_string(),
self.highlighted == UpdateSelection::NotNow,
));
column.push(selection_option_row(
2,
"Skip until next version".to_string(),
self.highlighted == UpdateSelection::DontRemind,
));
column.push("");
column.push(
Line::from(vec![
"Press ".dim(),
key_hint::plain(KeyCode::Enter).into(),
" to continue".dim(),
])
.inset(Insets::tlbr(0, 2, 0, 0)),
);
column.render(area, buf);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_backend::VT100Backend;
use crate::tui::FrameRequester;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use ratatui::Terminal;
fn new_prompt() -> UpdatePromptScreen {
UpdatePromptScreen::new(
FrameRequester::test_dummy(),
"9.9.9".into(),
UpdateAction::NpmGlobalLatest,
)
}
#[test]
fn update_prompt_snapshot() {
let screen = new_prompt();
let mut terminal = Terminal::new(VT100Backend::new(80, 12)).expect("terminal");
terminal
.draw(|frame| frame.render_widget_ref(&screen, frame.area()))
.expect("render update prompt");
insta::assert_snapshot!("update_prompt_modal", terminal.backend());
}
#[test]
fn update_prompt_confirm_selects_update() {
let mut screen = new_prompt();
screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(screen.is_done());
assert_eq!(screen.selection(), Some(UpdateSelection::UpdateNow));
}
#[test]
fn update_prompt_dismiss_option_leaves_prompt_in_normal_state() {
let mut screen = new_prompt();
screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(screen.is_done());
assert_eq!(screen.selection(), Some(UpdateSelection::NotNow));
}
#[test]
fn update_prompt_dont_remind_selects_dismissal() {
let mut screen = new_prompt();
screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(screen.is_done());
assert_eq!(screen.selection(), Some(UpdateSelection::DontRemind));
}
#[test]
fn update_prompt_ctrl_c_skips_update() {
let mut screen = new_prompt();
screen.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
assert!(screen.is_done());
assert_eq!(screen.selection(), Some(UpdateSelection::NotNow));
}
#[test]
fn update_prompt_navigation_wraps_between_entries() {
let mut screen = new_prompt();
screen.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(screen.highlighted, UpdateSelection::DontRemind);
screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
assert_eq!(screen.highlighted, UpdateSelection::UpdateNow);
}
}

View File

@@ -45,6 +45,8 @@ struct VersionInfo {
latest_version: String,
// ISO-8601 timestamp (RFC3339)
last_checked_at: DateTime<Utc>,
#[serde(default)]
dismissed_version: Option<String>,
}
#[derive(Deserialize, Debug, Clone)]
@@ -75,12 +77,15 @@ async fn check_for_update(version_file: &Path) -> anyhow::Result<()> {
.json::<ReleaseInfo>()
.await?;
// Preserve any previously dismissed version if present.
let prev_info = read_version_info(version_file).ok();
let info = VersionInfo {
latest_version: latest_tag_name
.strip_prefix("rust-v")
.ok_or_else(|| anyhow::anyhow!("Failed to parse latest tag name '{latest_tag_name}'"))?
.into(),
last_checked_at: Utc::now(),
dismissed_version: prev_info.and_then(|p| p.dismissed_version),
};
let json_line = format!("{}\n", serde_json::to_string(&info)?);
@@ -98,6 +103,37 @@ fn is_newer(latest: &str, current: &str) -> Option<bool> {
}
}
/// Returns the latest version to show in a popup, if it should be shown.
/// This respects the user's dismissal choice for the current latest version.
pub fn get_upgrade_version_for_popup(config: &Config) -> Option<String> {
let version_file = version_filepath(config);
let latest = get_upgrade_version(config)?;
// If the user dismissed this exact version previously, do not show the popup.
if let Ok(info) = read_version_info(&version_file)
&& info.dismissed_version.as_deref() == Some(latest.as_str())
{
return None;
}
Some(latest)
}
/// Persist a dismissal for the current latest version so we don't show
/// the update popup again for this version.
pub async fn dismiss_version(config: &Config, version: &str) -> anyhow::Result<()> {
let version_file = version_filepath(config);
let mut info = match read_version_info(&version_file) {
Ok(info) => info,
Err(_) => return Ok(()),
};
info.dismissed_version = Some(version.to_string());
let json_line = format!("{}\n", serde_json::to_string(&info)?);
if let Some(parent) = version_file.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(version_file, json_line).await?;
Ok(())
}
fn parse_version(v: &str) -> Option<(u64, u64, u64)> {
let mut iter = v.trim().split('.');
let maj = iter.next()?.parse::<u64>().ok()?;