Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fe8af2962 | ||
|
|
65e189c913 | ||
|
|
cb85dfa039 | ||
|
|
9600e49385 |
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1123,7 +1123,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ostiary"
|
name = "ostiary"
|
||||||
version = "1.0.1"
|
version = "1.0.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ostiary"
|
name = "ostiary"
|
||||||
version = "1.0.1"
|
version = "1.0.5"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
104
src/app.rs
104
src/app.rs
@@ -19,7 +19,8 @@ pub enum Event {
|
|||||||
StructuredFinished(i32),
|
StructuredFinished(i32),
|
||||||
UpdateAvailable(updater::UpdateInfo),
|
UpdateAvailable(updater::UpdateInfo),
|
||||||
UpdateProgress(u64),
|
UpdateProgress(u64),
|
||||||
UpdateDone,
|
/// Carries the exe path captured before the binary was replaced.
|
||||||
|
UpdateDone(std::path::PathBuf),
|
||||||
UpdateError(String),
|
UpdateError(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +41,8 @@ pub struct App {
|
|||||||
pub event_tx: UnboundedSender<Event>,
|
pub event_tx: UnboundedSender<Event>,
|
||||||
/// When set, main loop suspends ratatui, runs the command, then restores.
|
/// When set, main loop suspends ratatui, runs the command, then restores.
|
||||||
pub pending_exec: Option<PendingExec>,
|
pub pending_exec: Option<PendingExec>,
|
||||||
pub pending_restart: bool,
|
/// Exe path to exec after update; captured before the binary was replaced.
|
||||||
|
pub pending_restart: Option<std::path::PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum Popup {
|
pub enum Popup {
|
||||||
@@ -104,7 +106,7 @@ impl App {
|
|||||||
popup: None,
|
popup: None,
|
||||||
event_tx,
|
event_tx,
|
||||||
pending_exec: None,
|
pending_exec: None,
|
||||||
pending_restart: false,
|
pending_restart: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,11 +243,11 @@ impl App {
|
|||||||
}
|
}
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
Event::UpdateDone => {
|
Event::UpdateDone(exe_path) => {
|
||||||
if let Some(Popup::Updating { status, .. }) = &mut self.popup {
|
if let Some(Popup::Updating { status, .. }) = &mut self.popup {
|
||||||
*status = UpdatingStatus::Done;
|
*status = UpdatingStatus::Done;
|
||||||
}
|
}
|
||||||
self.pending_restart = true;
|
self.pending_restart = Some(exe_path);
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
Event::UpdateError(msg) => {
|
Event::UpdateError(msg) => {
|
||||||
@@ -297,6 +299,11 @@ impl App {
|
|||||||
Some(executor::StructuredCommand::Confirm { .. })
|
Some(executor::StructuredCommand::Confirm { .. })
|
||||||
);
|
);
|
||||||
let has_command = current_command.is_some();
|
let has_command = current_command.is_some();
|
||||||
|
// Vim motions are disabled only when free text input is active.
|
||||||
|
let is_text_input = matches!(
|
||||||
|
current_command,
|
||||||
|
Some(executor::StructuredCommand::Input { .. })
|
||||||
|
);
|
||||||
|
|
||||||
match key.code {
|
match key.code {
|
||||||
// ── Log scrolling (PageUp / PageDown always work) ─
|
// ── Log scrolling (PageUp / PageDown always work) ─
|
||||||
@@ -352,7 +359,14 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Text input ────────────────────────────────────
|
// ── Text input ────────────────────────────────────
|
||||||
KeyCode::Char(c) if !is_menu && !is_confirm => {
|
// Vim motion keys (j/k/l/h) are excluded here so they
|
||||||
|
// fall through to the vim-motion arms below.
|
||||||
|
KeyCode::Char(c)
|
||||||
|
if !is_menu
|
||||||
|
&& !is_confirm
|
||||||
|
&& (is_text_input
|
||||||
|
|| !matches!(c, 'j' | 'k' | 'l' | 'h')) =>
|
||||||
|
{
|
||||||
input_buffer.push(c);
|
input_buffer.push(c);
|
||||||
}
|
}
|
||||||
KeyCode::Backspace if !is_menu => {
|
KeyCode::Backspace if !is_menu => {
|
||||||
@@ -394,7 +408,66 @@ impl App {
|
|||||||
let _ = current_command.take();
|
let _ = current_command.take();
|
||||||
let _ = reply_tx.send("n".to_string());
|
let _ = reply_tx.send("n".to_string());
|
||||||
} else {
|
} else {
|
||||||
// Kill the script if still running.
|
if let Some(kx) = kill_tx.take() {
|
||||||
|
let _ = kx.send(());
|
||||||
|
}
|
||||||
|
self.popup = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Vim motions (off during text input) ───────────
|
||||||
|
KeyCode::Char('k') if !is_text_input => {
|
||||||
|
if is_menu {
|
||||||
|
if *menu_selected_index > 0 {
|
||||||
|
*menu_selected_index -= 1;
|
||||||
|
}
|
||||||
|
} else if !has_command {
|
||||||
|
*log_scroll_pos = log_scroll_pos.saturating_sub(1);
|
||||||
|
*log_follow_bottom = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('j') if !is_text_input => {
|
||||||
|
if is_menu {
|
||||||
|
if let Some(executor::StructuredCommand::Menu {
|
||||||
|
options, ..
|
||||||
|
}) = current_command
|
||||||
|
{
|
||||||
|
if *menu_selected_index + 1 < options.len() {
|
||||||
|
*menu_selected_index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if !has_command {
|
||||||
|
*log_scroll_pos = log_scroll_pos.saturating_add(1);
|
||||||
|
*log_follow_bottom =
|
||||||
|
*log_scroll_pos + 1 >= log_buffer.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('l') if !is_text_input => {
|
||||||
|
// Forward / confirm — same logic as Enter.
|
||||||
|
if let Some(cmd) = current_command.take() {
|
||||||
|
let response = match &cmd {
|
||||||
|
executor::StructuredCommand::Confirm { .. } => {
|
||||||
|
"y".to_string()
|
||||||
|
}
|
||||||
|
executor::StructuredCommand::Menu {
|
||||||
|
options, ..
|
||||||
|
} => options
|
||||||
|
.get(*menu_selected_index)
|
||||||
|
.map(|o| o.id.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
let _ = reply_tx.send(response);
|
||||||
|
input_buffer.clear();
|
||||||
|
*menu_selected_index = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('h') if !is_text_input => {
|
||||||
|
// Back / cancel — same logic as Esc.
|
||||||
|
if is_confirm {
|
||||||
|
let _ = current_command.take();
|
||||||
|
let _ = reply_tx.send("n".to_string());
|
||||||
|
} else {
|
||||||
if let Some(kx) = kill_tx.take() {
|
if let Some(kx) = kill_tx.take() {
|
||||||
let _ = kx.send(());
|
let _ = kx.send(());
|
||||||
}
|
}
|
||||||
@@ -414,7 +487,7 @@ impl App {
|
|||||||
|
|
||||||
Popup::UpdateConfirm { info } => {
|
Popup::UpdateConfirm { info } => {
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Char('y') | KeyCode::Char('Y') => {
|
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Char('l') => {
|
||||||
let info = info.clone();
|
let info = info.clone();
|
||||||
self.popup = Some(Popup::Updating {
|
self.popup = Some(Popup::Updating {
|
||||||
info: info.clone(),
|
info: info.clone(),
|
||||||
@@ -443,8 +516,8 @@ impl App {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(()) => {
|
Ok(exe_path) => {
|
||||||
let _ = tx2.send(Event::UpdateDone);
|
let _ = tx2.send(Event::UpdateDone(exe_path));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ =
|
let _ =
|
||||||
@@ -453,7 +526,8 @@ impl App {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
|
KeyCode::Char('n') | KeyCode::Char('N')
|
||||||
|
| KeyCode::Esc | KeyCode::Char('h') => {
|
||||||
self.popup = None;
|
self.popup = None;
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -478,24 +552,24 @@ impl App {
|
|||||||
// ── Main menu navigation ─────────────────────────────────────────
|
// ── Main menu navigation ─────────────────────────────────────────
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Char('q') => return Ok(true),
|
KeyCode::Char('q') => return Ok(true),
|
||||||
KeyCode::Up => {
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
let len = self.current_items().len();
|
let len = self.current_items().len();
|
||||||
if len > 0 {
|
if len > 0 {
|
||||||
self.selected_index = (self.selected_index + len - 1) % len;
|
self.selected_index = (self.selected_index + len - 1) % len;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Down => {
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
let len = self.current_items().len();
|
let len = self.current_items().len();
|
||||||
if len > 0 {
|
if len > 0 {
|
||||||
self.selected_index = (self.selected_index + 1) % len;
|
self.selected_index = (self.selected_index + 1) % len;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter | KeyCode::Char('l') => {
|
||||||
if let Some(item) = self.selected_item().cloned() {
|
if let Some(item) = self.selected_item().cloned() {
|
||||||
self.activate_item(item).await?;
|
self.activate_item(item).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc | KeyCode::Char('h') => {
|
||||||
if !self.breadcrumbs.is_empty() {
|
if !self.breadcrumbs.is_empty() {
|
||||||
self.breadcrumbs.pop();
|
self.breadcrumbs.pop();
|
||||||
self.selected_index = 0;
|
self.selected_index = 0;
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ async fn run_app(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for pending restart (after update applied) — before pending_exec.
|
// Check for pending restart (after update applied) — before pending_exec.
|
||||||
if app.pending_restart {
|
if let Some(exe_path) = app.pending_restart.take() {
|
||||||
disable_raw_mode()?;
|
disable_raw_mode()?;
|
||||||
execute!(
|
execute!(
|
||||||
terminal.backend_mut(),
|
terminal.backend_mut(),
|
||||||
@@ -83,7 +83,7 @@ async fn run_app(
|
|||||||
)?;
|
)?;
|
||||||
terminal.show_cursor()?;
|
terminal.show_cursor()?;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
updater::exec_updated();
|
updater::exec_updated(&exe_path);
|
||||||
// fallback for non-unix or if exec failed
|
// fallback for non-unix or if exec failed
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,11 +94,12 @@ pub async fn check(api_base: &str, timeout_sec: u64) -> Result<Option<UpdateInfo
|
|||||||
|
|
||||||
/// Downloads binary to temp file with streaming progress, atomically replaces
|
/// Downloads binary to temp file with streaming progress, atomically replaces
|
||||||
/// current exe, sets chmod 755.
|
/// current exe, sets chmod 755.
|
||||||
/// progress_tx receives bytes downloaded so far.
|
/// Returns the path of the replaced executable (captured before rename so it
|
||||||
|
/// remains valid even after the old inode is marked "(deleted)" by the kernel).
|
||||||
pub async fn download_and_apply(
|
pub async fn download_and_apply(
|
||||||
info: &UpdateInfo,
|
info: &UpdateInfo,
|
||||||
progress_tx: UnboundedSender<u64>,
|
progress_tx: UnboundedSender<u64>,
|
||||||
) -> Result<()> {
|
) -> Result<std::path::PathBuf> {
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
@@ -144,25 +145,28 @@ pub async fn download_and_apply(
|
|||||||
std::fs::set_permissions(&tmp_path, perms).context("failed to set permissions")?;
|
std::fs::set_permissions(&tmp_path, perms).context("failed to set permissions")?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atomically replace current exe
|
// Atomically replace current exe.
|
||||||
|
// current_exe is captured BEFORE this rename — after rename, /proc/self/exe
|
||||||
|
// on Linux returns the path with " (deleted)" appended, but the PathBuf we
|
||||||
|
// hold still refers to the correct filesystem path of the new binary.
|
||||||
std::fs::rename(&tmp_path, ¤t_exe).context("failed to replace current exe")?;
|
std::fs::rename(&tmp_path, ¤t_exe).context("failed to replace current exe")?;
|
||||||
|
|
||||||
Ok(())
|
Ok(current_exe)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replaces current process via execv (Unix). Never returns on success.
|
/// Replaces current process via execv (Unix). Never returns on success.
|
||||||
|
/// `exe_path` must be the path captured *before* the binary was replaced —
|
||||||
|
/// do NOT call std::env::current_exe() here, it returns "(deleted)" on Linux.
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
pub fn exec_updated() -> ! {
|
pub fn exec_updated(exe_path: &std::path::Path) -> ! {
|
||||||
use std::os::unix::process::CommandExt;
|
use std::os::unix::process::CommandExt;
|
||||||
|
|
||||||
let exe = std::env::current_exe().expect("failed to get current exe path");
|
|
||||||
let args: Vec<String> = std::env::args().collect();
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
|
||||||
let err = std::process::Command::new(&exe)
|
let err = std::process::Command::new(exe_path)
|
||||||
.args(&args[1..])
|
.args(&args[1..])
|
||||||
.exec();
|
.exec();
|
||||||
|
|
||||||
// exec only returns if it failed
|
|
||||||
eprintln!("Failed to exec updated binary: {}", err);
|
eprintln!("Failed to exec updated binary: {}", err);
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user