use crossterm::event::{KeyCode, KeyEvent};
use crate::state::AppState;
pub(super) fn open_setup_package(app: &mut AppState, package: &str) {
let row = crate::state::types::OptionalDepRow {
label: package.to_string(),
package: package.to_string(),
installed: false,
selectable: true,
note: Some("Setup".to_string()),
};
let (new_modal, _) = handle_optional_deps_enter(app, &row);
app.modal = new_modal;
}
pub(super) fn handle_optional_deps(
ke: KeyEvent,
app: &mut AppState,
rows: &[crate::state::types::OptionalDepRow],
selected: &mut usize,
selected_pkg_names: &mut std::collections::HashSet<String>,
) -> Option<bool> {
match ke.code {
KeyCode::Esc | KeyCode::Char('q') => {
app.modal = crate::state::Modal::None;
if !app.pending_startup_setup_steps.is_empty() {
super::common::show_next_startup_setup_step(app);
}
Some(false)
}
KeyCode::Up | KeyCode::Char('k') => {
if *selected > 0 {
*selected -= 1;
}
Some(false)
}
KeyCode::Down | KeyCode::Char('j') => {
if *selected + 1 < rows.len() {
*selected += 1;
}
Some(false)
}
KeyCode::Char(' ') => {
if let Some(row) = rows.get(*selected)
&& row.selectable
&& !row.installed
&& !is_setup_package(&row.package)
{
if selected_pkg_names.contains(&row.package) {
selected_pkg_names.remove(&row.package);
} else {
selected_pkg_names.insert(row.package.clone());
}
}
Some(false)
}
KeyCode::Enter | KeyCode::Char('\n' | '\r') => {
if let Some(row) = rows.get(*selected) {
if row.selectable && !row.installed && !is_setup_package(&row.package) {
let selected_rows: Vec<crate::state::types::OptionalDepRow> = rows
.iter()
.filter(|r| {
r.selectable
&& !r.installed
&& !is_setup_package(&r.package)
&& selected_pkg_names.contains(&r.package)
})
.cloned()
.collect();
if !selected_rows.is_empty() {
let (new_modal, should_stop) =
handle_optional_deps_batch_install(app, &selected_rows);
app.modal = new_modal;
selected_pkg_names.clear();
return Some(should_stop);
}
}
match handle_optional_deps_enter(app, row) {
(new_modal, true) => {
app.modal = new_modal;
Some(true)
}
(new_modal, false) => {
app.modal = new_modal;
Some(false)
}
}
} else {
Some(false)
}
}
_ => None,
}
}
#[must_use]
fn is_setup_package(package: &str) -> bool {
matches!(
package,
"aur-ssh-setup"
| "virustotal-setup"
| "sudo-timestamp-setup"
| "doas-persist-setup"
| "aur-sleuth-setup"
)
}
fn handle_optional_deps_batch_install(
app: &mut AppState,
selected_rows: &[crate::state::types::OptionalDepRow],
) -> (crate::state::Modal, bool) {
use crate::state::{PackageItem, Source};
if selected_rows.len() == 1 {
return handle_optional_deps_enter(app, &selected_rows[0]);
}
let packages: Vec<PackageItem> = selected_rows
.iter()
.map(|row| {
crate::index::find_package_by_name(&row.package).unwrap_or_else(|| PackageItem {
name: row.package.clone(),
version: String::new(),
description: String::new(),
source: Source::Aur,
popularity: None,
out_of_date: None,
orphaned: false,
})
})
.collect();
let aur_count = packages
.iter()
.filter(|item| matches!(item.source, Source::Aur))
.count();
let header_chips = crate::state::modal::PreflightHeaderChips {
package_count: packages.len(),
download_bytes: 0,
install_delta_bytes: 0,
aur_count,
risk_score: 0,
risk_level: crate::state::modal::RiskLevel::Low,
};
let _ = crate::events::preflight::keys::handle_proceed_install(app, packages, header_chips);
(app.modal.clone(), false)
}
#[allow(clippy::too_many_lines)] fn handle_optional_deps_enter(
app: &mut AppState,
row: &crate::state::types::OptionalDepRow,
) -> (crate::state::Modal, bool) {
use crate::state::{PackageItem, Source};
if row.package == "aur-ssh-setup" {
let mut status_lines = vec![
"This setup will create '~/.ssh/aur_key' if missing.".to_string(),
"It will add/update Host aur.archlinux.org in '~/.ssh/config'.".to_string(),
"Then it validates with: ssh aur@aur.archlinux.org help".to_string(),
format!(
"After setup, upload '~/.ssh/aur_key.pub' to {}",
crate::logic::ssh_setup::AUR_ACCOUNT_URL
),
"Press Enter to run, O to open account page, Esc to cancel.".to_string(),
];
if !crate::logic::ssh_setup::is_openssh_installed() {
status_lines.push(
"Warning: openssh is not installed. Install openssh first, then run setup."
.to_string(),
);
}
return (
crate::state::Modal::SshAurSetup {
step: crate::state::SshSetupStep::Intro,
status_lines,
existing_host_block: None,
},
false,
);
}
if row.package == "virustotal-setup" {
let current = crate::theme::settings().virustotal_api_key;
let cur_len = current.len();
return (
crate::state::Modal::VirusTotalSetup {
input: current,
cursor: cur_len,
},
false,
);
}
if row.package == "sudo-timestamp-setup" {
return (
crate::state::Modal::SudoTimestampSetup {
setup: crate::state::modal::SudoTimestampSetupModalState {
phase: crate::state::modal::SudoTimestampSetupPhase::Select,
select_cursor: 0,
},
},
false,
);
}
if row.package == "doas-persist-setup" {
return (
crate::state::Modal::DoasPersistSetup {
setup: crate::state::modal::DoasPersistSetupModalState {
phase: crate::state::modal::DoasPersistSetupPhase::Select,
select_cursor: 0,
},
},
false,
);
}
if row.package == "aur-sleuth-setup" {
let cmd = r##"(set -e
if ! command -v aur-sleuth >/dev/null 2>&1; then
echo "aur-sleuth not found."
echo
echo "Install aur-sleuth:"
echo " 1) system (/usr/local) requires sudo"
echo " 2) user (~/.local)"
echo " 3) cancel"
read -rp "Choose [1/2/3]: " choice
case "$choice" in
1)
tmp="$(mktemp -d)"; cd "$tmp"
git clone https://github.com/mgalgs/aur-sleuth.git
cd aur-sleuth
sudo make install
;;
2)
tmp="$(mktemp -d)"; cd "$tmp"
git clone https://github.com/mgalgs/aur-sleuth.git
cd aur-sleuth
make install PREFIX="$HOME/.local"
;;
*)
echo "Cancelled."; echo "Press any key to close..."; read -rn1 -s _; exit 0;;
esac
else
echo "aur-sleuth already installed; continuing to setup"
fi
conf="${XDG_CONFIG_HOME:-$HOME/.config}/aur-sleuth.conf"
mkdir -p "$(dirname "$conf")"
echo "# aur-sleuth configuration" > "$conf"
echo "[default]" >> "$conf"
read -rp "OPENAI_BASE_URL (e.g. https://openrouter.ai/api/v1 or http://localhost:11434/v1): " base
read -rp "OPENAI_MODEL (e.g. qwen/qwen3-30b-a3b-instruct-2507 or llama3.1:8b): " model
read -rp "OPENAI_API_KEY: " key
read -rp "MAX_LLM_JOBS (default 3): " jobs
read -rp "AUDIT_FAILURE_FATAL (true/false) [true]: " fatal
jobs=${jobs:-3}
fatal=${fatal:-true}
[ -n "$base" ] && echo "OPENAI_BASE_URL = $base" >> "$conf"
[ -n "$model" ] && echo "OPENAI_MODEL = $model" >> "$conf"
echo "OPENAI_API_KEY = $key" >> "$conf"
echo "MAX_LLM_JOBS = $jobs" >> "$conf"
echo "AUDIT_FAILURE_FATAL = $fatal" >> "$conf"
echo; echo "Wrote $conf"
echo "Tip: You can run 'aur-sleuth package-name' or audit a local pkgdir with '--pkgdir .'"
echo; echo "Press any key to close..."; read -rn1 -s _)"##
.to_string();
let to_run = if app.dry_run {
use crate::install::shell_single_quote;
let quoted = shell_single_quote(&cmd);
vec![format!("echo DRY RUN: {quoted}")]
} else {
vec![cmd]
};
crate::install::spawn_shell_commands_in_terminal(&to_run);
return (crate::state::Modal::None, true);
}
if row.installed {
let pkg = row.package.clone();
let package_item = crate::index::find_package_by_name(&pkg).unwrap_or_else(|| {
PackageItem {
name: pkg.clone(),
version: String::new(),
description: String::new(),
source: Source::Aur,
popularity: None,
out_of_date: None,
orphaned: false,
}
});
return (
crate::state::Modal::ConfirmReinstall {
items: vec![package_item.clone()],
all_items: vec![package_item],
header_chips: crate::state::modal::PreflightHeaderChips::default(),
},
false,
);
}
if !row.installed && row.selectable {
let pkg = row.package.clone();
if pkg == "paru" || pkg == "yay" {
let cmd = if pkg == "paru" {
"tmp=$(mktemp -d) && cd \"$tmp\" && git clone https://aur.archlinux.org/paru.git && cd paru && makepkg -si"
.to_string()
} else {
"tmp=$(mktemp -d) && cd \"$tmp\" && git clone https://aur.archlinux.org/yay.git && cd yay && makepkg -si"
.to_string()
};
let item = PackageItem {
name: pkg,
version: String::new(),
description: String::new(),
source: Source::Aur,
popularity: None,
out_of_date: None,
orphaned: false,
};
let username = std::env::var("USER").unwrap_or_else(|_| "user".to_string());
if let Some(lockout_msg) =
crate::logic::faillock::get_lockout_message_if_locked(&username, app)
{
app.modal = crate::state::Modal::Alert {
message: lockout_msg,
};
return (crate::state::Modal::None, false);
}
let header_chips = crate::state::modal::PreflightHeaderChips {
package_count: 1,
download_bytes: 0,
install_delta_bytes: 0,
aur_count: 1,
risk_score: 0,
risk_level: crate::state::modal::RiskLevel::Low,
};
let settings = crate::theme::settings();
let proceed_no_password =
|app: &mut AppState,
item: PackageItem,
cmd: String,
header_chips: crate::state::modal::PreflightHeaderChips| {
app.pending_custom_command = Some(cmd);
app.modal = crate::state::Modal::PreflightExec {
items: vec![item],
action: crate::state::PreflightAction::Install,
tab: crate::state::PreflightTab::Summary,
verbose: false,
log_lines: Vec::new(),
abortable: false,
header_chips,
success: None,
};
app.pending_executor_request =
Some(crate::install::ExecutorRequest::CustomCommand {
command: app.pending_custom_command.take().unwrap_or_default(),
password: None,
dry_run: app.dry_run,
});
};
if crate::logic::password::should_use_interactive_auth_handoff(&settings) {
match crate::events::try_interactive_auth_handoff() {
Ok(true) => proceed_no_password(app, item, cmd, header_chips),
Ok(false) => {
app.modal = crate::state::Modal::Alert {
message: crate::i18n::t(app, "app.errors.authentication_failed"),
};
}
Err(e) => {
app.modal = crate::state::Modal::Alert { message: e };
}
}
} else if crate::logic::password::resolve_auth_mode(&settings)
== crate::logic::privilege::AuthMode::PasswordlessOnly
&& crate::logic::password::should_use_passwordless_sudo(&settings)
{
proceed_no_password(app, item, cmd, header_chips);
} else {
app.modal = crate::state::Modal::PasswordPrompt {
purpose: crate::state::modal::PasswordPurpose::Install,
items: vec![item],
input: crate::state::SecureString::default(),
cursor: 0,
error: None,
};
app.pending_custom_command = Some(cmd);
app.pending_exec_header_chips = Some(header_chips);
}
return (app.modal.clone(), false);
}
let (package_item, is_aur) = crate::index::find_package_by_name(&pkg).map_or_else(
|| {
(
PackageItem {
name: pkg.clone(),
version: String::new(),
description: String::new(),
source: Source::Aur,
popularity: None,
out_of_date: None,
orphaned: false,
},
true,
)
},
|official_item| (official_item, false),
);
let use_aur_helper = is_aur || pkg == "rate-mirrors" || pkg == "semgrep-bin";
let header_chips = crate::state::modal::PreflightHeaderChips {
package_count: 1,
download_bytes: 0,
install_delta_bytes: 0,
aur_count: usize::from(use_aur_helper),
risk_score: 0,
risk_level: crate::state::modal::RiskLevel::Low,
};
let _ = crate::events::preflight::keys::handle_proceed_install(
app,
vec![package_item],
header_chips,
);
return (app.modal.clone(), false);
}
(crate::state::Modal::None, false)
}
pub(super) fn handle_ssh_setup_modal(
ke: KeyEvent,
app: &mut AppState,
step: &mut crate::state::SshSetupStep,
status_lines: &mut Vec<String>,
existing_host_block: &mut Option<String>,
) -> Option<bool> {
match (*step, ke.code) {
(_, KeyCode::Char('o' | 'O')) => {
crate::util::open_url(crate::logic::ssh_setup::AUR_ACCOUNT_URL);
Some(false)
}
(_, KeyCode::Char('c' | 'C')) => {
match crate::logic::ssh_setup::try_copy_aur_ssh_public_key_from_status_lines(
status_lines,
) {
None => None,
Some(Ok(())) => {
app.toast_message = Some(crate::i18n::t(app, "app.toasts.copied_to_clipboard"));
app.toast_expires_at =
Some(std::time::Instant::now() + std::time::Duration::from_secs(3));
Some(false)
}
Some(Err(msg)) => {
app.toast_message = Some(msg);
app.toast_expires_at =
Some(std::time::Instant::now() + std::time::Duration::from_secs(5));
Some(false)
}
}
}
(
crate::state::SshSetupStep::Intro
| crate::state::SshSetupStep::ApplyKeyOnAur
| crate::state::SshSetupStep::Result,
KeyCode::Esc | KeyCode::Char('q'),
) => {
app.modal = crate::state::Modal::None;
if !app.pending_startup_setup_steps.is_empty() {
super::common::show_next_startup_setup_step(app);
}
Some(true)
}
(crate::state::SshSetupStep::Intro, KeyCode::Enter | KeyCode::Char('\n' | '\r')) => {
if !crate::logic::ssh_setup::is_openssh_installed() {
let warning =
"Warning: openssh is not installed. Install openssh first, then run setup."
.to_string();
if !status_lines.iter().any(|line| line == &warning) {
status_lines.push(warning);
}
tracing::warn!("SSH setup blocked: openssh is not installed");
return Some(false);
}
match crate::logic::ssh_setup::run_aur_ssh_setup(false) {
crate::logic::ssh_setup::AurSshSetupResult::Completed(report) => {
let failed = !report.success;
*status_lines = report.lines;
*existing_host_block = None;
if failed {
*step = crate::state::SshSetupStep::Result;
tracing::error!("SSH setup failed: {}", status_lines.join(" | "));
app.toast_message = Some(
"SSH setup failed. Review details in the SSH setup window.".to_string(),
);
app.toast_expires_at =
Some(std::time::Instant::now() + std::time::Duration::from_secs(4));
} else {
*step = crate::state::SshSetupStep::ApplyKeyOnAur;
}
}
crate::logic::ssh_setup::AurSshSetupResult::NeedsOverwrite {
existing_block,
lines,
} => {
*step = crate::state::SshSetupStep::ConfirmOverwrite;
*status_lines = lines;
*existing_host_block = Some(existing_block);
}
}
Some(false)
}
(
crate::state::SshSetupStep::ConfirmOverwrite,
KeyCode::Char('n' | 'N' | 'q') | KeyCode::Esc,
) => {
app.modal = crate::state::Modal::None;
app.toast_message = Some("SSH setup cancelled (existing config kept).".to_string());
app.toast_expires_at =
Some(std::time::Instant::now() + std::time::Duration::from_secs(4));
if !app.pending_startup_setup_steps.is_empty() {
super::common::show_next_startup_setup_step(app);
}
Some(true)
}
(
crate::state::SshSetupStep::ConfirmOverwrite,
KeyCode::Char('y' | 'Y' | '\n' | '\r') | KeyCode::Enter,
) => {
let report = match crate::logic::ssh_setup::run_aur_ssh_setup(true) {
crate::logic::ssh_setup::AurSshSetupResult::Completed(report) => report,
crate::logic::ssh_setup::AurSshSetupResult::NeedsOverwrite { lines, .. } => {
crate::logic::ssh_setup::AurSshSetupReport {
success: false,
lines,
}
}
};
let failed = !report.success;
*status_lines = report.lines;
*existing_host_block = None;
if failed {
*step = crate::state::SshSetupStep::Result;
tracing::error!(
"SSH setup failed after overwrite confirm: {}",
status_lines.join(" | ")
);
app.toast_message =
Some("SSH setup failed. Review details in the SSH setup window.".to_string());
app.toast_expires_at =
Some(std::time::Instant::now() + std::time::Duration::from_secs(4));
} else {
*step = crate::state::SshSetupStep::ApplyKeyOnAur;
}
Some(false)
}
(
crate::state::SshSetupStep::ApplyKeyOnAur,
KeyCode::Char('y' | 'Y' | '\n' | '\r') | KeyCode::Enter,
) => {
let ssh_command = crate::theme::settings().aur_vote_ssh_command;
let report = crate::logic::ssh_setup::validate_aur_ssh_setup_connection(&ssh_command);
let failed = !report.success;
*step = crate::state::SshSetupStep::Result;
*status_lines = report.lines;
*existing_host_block = None;
if failed {
tracing::error!(
"SSH setup validation failed after user confirmation: {}",
status_lines.join(" | ")
);
app.toast_message = Some(
"SSH validation failed. Review details in the SSH setup window.".to_string(),
);
} else {
app.toast_message = Some("AUR SSH connection verified successfully.".to_string());
}
app.toast_expires_at =
Some(std::time::Instant::now() + std::time::Duration::from_secs(4));
Some(true)
}
_ => None,
}
}
#[cfg(test)]
mod tests;