use std::{collections::HashMap, fs, path::Path, time::Instant};
use crate::index as pkgindex;
use crate::state::{AppState, PackageDetails, PackageItem};
use super::super::deps_cache;
use super::super::files_cache;
use super::super::sandbox_cache;
use super::super::services_cache;
pub fn initialize_locale_system(
app: &mut AppState,
locale_pref: &str,
_prefs: &crate::theme::Settings,
) {
let locales_dir = crate::i18n::find_locales_dir().unwrap_or_else(|| {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("config")
.join("locales")
});
let Some(i18n_config_path) = crate::i18n::find_config_file("i18n.yml") else {
tracing::error!(
"i18n config file not found in development or installed locations. Using default locale 'en-US'."
);
app.locale = "en-US".to_string();
app.translations = std::collections::HashMap::new();
app.translations_fallback = std::collections::HashMap::new();
return;
};
let resolver = crate::i18n::LocaleResolver::new(&i18n_config_path);
let resolved_locale = resolver.resolve(locale_pref);
tracing::info!(
"Resolved locale: '{}' (from settings: '{}')",
&resolved_locale,
if locale_pref.trim().is_empty() {
"<auto-detect>"
} else {
locale_pref
}
);
app.locale.clone_from(&resolved_locale);
let mut loader = crate::i18n::LocaleLoader::new(locales_dir);
match loader.load("en-US") {
Ok(fallback) => {
let key_count = fallback.len();
app.translations_fallback = fallback;
tracing::debug!("Loaded English fallback translations ({} keys)", key_count);
}
Err(e) => {
tracing::error!(
"Failed to load English fallback translations: {}. Application may show untranslated keys.",
e
);
app.translations_fallback = std::collections::HashMap::new();
}
}
if resolved_locale == "en-US" {
app.translations = app.translations_fallback.clone();
tracing::debug!("Using English as primary locale");
} else {
match loader.load(&resolved_locale) {
Ok(translations) => {
let key_count = translations.len();
app.translations = translations;
tracing::info!(
"Loaded translations for locale '{}' ({} keys)",
resolved_locale,
key_count
);
let test_keys = [
"app.details.footer.search_hint",
"app.details.footer.confirm_installation",
];
for key in &test_keys {
if app.translations.contains_key(*key) {
tracing::debug!(" ✓ Key '{}' found in translations", key);
} else {
tracing::debug!(" ✗ Key '{}' NOT found in translations", key);
}
}
}
Err(e) => {
tracing::warn!(
"Failed to load translations for locale '{}': {}. Using English fallback.",
resolved_locale,
e
);
app.translations = std::collections::HashMap::new();
}
}
}
}
pub fn run_startup_config_preflight() -> crate::theme::Settings {
crate::theme::maybe_migrate_legacy_confs();
crate::theme::ensure_theme_keys_present();
let prefs = crate::theme::settings();
crate::theme::ensure_settings_keys_present(&prefs);
prefs
}
#[allow(clippy::struct_excessive_bools)]
pub struct InitFlags {
pub needs_deps_resolution: bool,
pub needs_files_resolution: bool,
pub needs_services_resolution: bool,
pub needs_sandbox_resolution: bool,
}
fn load_cache_with_signature<T>(
install_list: &[crate::state::PackageItem],
cache_path: &std::path::PathBuf,
compute_signature: impl Fn(&[crate::state::PackageItem]) -> Vec<String>,
load_cache: impl Fn(&std::path::PathBuf, &[String]) -> Option<T>,
cache_name: &str,
) -> (Option<T>, bool) {
if install_list.is_empty() {
return (None, false);
}
let signature = compute_signature(install_list);
load_cache(cache_path, &signature).map_or_else(
|| {
tracing::info!(
"{} cache missing or invalid, will trigger background resolution",
cache_name
);
(None, true)
},
|cached| (Some(cached), false),
)
}
fn ensure_cache_parent_dir(path: &Path) {
if let Some(parent) = path.parent()
&& let Err(error) = fs::create_dir_all(parent)
{
tracing::warn!(
path = %parent.display(),
%error,
"[Init] Failed to create cache directory"
);
}
}
fn initialize_cache_files(app: &AppState) {
let empty_signature: Vec<String> = Vec::new();
if !app.deps_cache_path.exists() {
ensure_cache_parent_dir(&app.deps_cache_path);
deps_cache::save_cache(&app.deps_cache_path, &empty_signature, &[]);
tracing::debug!(
path = %app.deps_cache_path.display(),
"[Init] Created empty dependency cache"
);
}
if !app.files_cache_path.exists() {
ensure_cache_parent_dir(&app.files_cache_path);
files_cache::save_cache(&app.files_cache_path, &empty_signature, &[]);
tracing::debug!(
path = %app.files_cache_path.display(),
"[Init] Created empty file cache"
);
}
if !app.services_cache_path.exists() {
ensure_cache_parent_dir(&app.services_cache_path);
services_cache::save_cache(&app.services_cache_path, &empty_signature, &[]);
tracing::debug!(
path = %app.services_cache_path.display(),
"[Init] Created empty service cache"
);
}
if !app.sandbox_cache_path.exists() {
ensure_cache_parent_dir(&app.sandbox_cache_path);
sandbox_cache::save_cache(&app.sandbox_cache_path, &empty_signature, &[]);
tracing::debug!(
path = %app.sandbox_cache_path.display(),
"[Init] Created empty sandbox cache"
);
}
}
pub fn apply_settings_to_app_state(app: &mut AppState, prefs: &crate::theme::Settings) {
app.layout_left_pct = prefs.layout_left_pct;
app.layout_center_pct = prefs.layout_center_pct;
app.layout_right_pct = prefs.layout_right_pct;
app.main_pane_order = prefs.main_pane_order;
app.vertical_layout_limits = crate::state::VerticalLayoutLimits::from_u16s(
prefs.vertical_min_results,
prefs.vertical_max_results,
prefs.vertical_min_middle,
prefs.vertical_max_middle,
prefs.vertical_min_package_info,
);
app.keymap = prefs.keymap.clone();
app.sort_mode = prefs.sort_mode;
app.package_marker = prefs.package_marker;
app.show_recent_pane = prefs.show_recent_pane;
app.show_install_pane = prefs.show_install_pane;
app.show_keybinds_footer = prefs.show_keybinds_footer;
app.search_normal_mode = prefs.search_startup_mode;
app.fuzzy_search_enabled = prefs.fuzzy_search;
app.installed_packages_mode = prefs.installed_packages_mode;
app.app_mode = if prefs.start_in_news {
crate::state::types::AppMode::News
} else {
crate::state::types::AppMode::Package
};
app.news_filter_show_arch_news = prefs.news_filter_show_arch_news;
app.news_filter_show_advisories = prefs.news_filter_show_advisories;
app.news_filter_show_pkg_updates = prefs.news_filter_show_pkg_updates;
app.news_filter_show_aur_updates = prefs.news_filter_show_aur_updates;
app.news_filter_show_aur_comments = prefs.news_filter_show_aur_comments;
app.news_filter_installed_only = prefs.news_filter_installed_only;
app.news_max_age_days = prefs.news_max_age_days;
app.refresh_news_results();
crate::logic::repos::refresh_dynamic_filters_in_app(app, prefs);
}
fn check_gnome_terminal(app: &mut AppState, headless: bool) {
if headless {
return;
}
let is_gnome = std::env::var("XDG_CURRENT_DESKTOP")
.ok()
.is_some_and(|v| v.to_uppercase().contains("GNOME"));
if !is_gnome {
return;
}
let has_gterm = crate::install::command_on_path("gnome-terminal");
let has_gconsole =
crate::install::command_on_path("gnome-console") || crate::install::command_on_path("kgx");
if !(has_gterm || has_gconsole) {
app.modal = crate::state::Modal::GnomeTerminalPrompt;
}
}
fn load_details_cache(app: &mut AppState) {
if let Ok(s) = std::fs::read_to_string(&app.cache_path)
&& let Ok(map) = serde_json::from_str::<HashMap<String, PackageDetails>>(&s)
{
app.details_cache = map;
tracing::info!(path = %app.cache_path.display(), "loaded details cache");
}
}
fn load_recent_searches(app: &mut AppState) {
if let Ok(s) = std::fs::read_to_string(&app.recent_path)
&& let Ok(list) = serde_json::from_str::<Vec<String>>(&s)
{
let count = list.len();
app.load_recent_items(&list);
if count > 0 {
app.history_state.select(Some(0));
}
tracing::info!(
path = %app.recent_path.display(),
count = count,
"loaded recent searches"
);
}
}
fn load_install_list(app: &mut AppState) {
if let Ok(s) = std::fs::read_to_string(&app.install_path)
&& let Ok(list) = serde_json::from_str::<Vec<PackageItem>>(&s)
{
app.install_list = list;
if !app.install_list.is_empty() {
app.install_state.select(Some(0));
}
tracing::info!(
path = %app.install_path.display(),
count = app.install_list.len(),
"loaded install list"
);
}
}
fn load_news_read_urls(app: &mut AppState) {
if let Ok(s) = std::fs::read_to_string(&app.news_read_path)
&& let Ok(set) = serde_json::from_str::<std::collections::HashSet<String>>(&s)
{
app.news_read_urls = set;
tracing::info!(
path = %app.news_read_path.display(),
count = app.news_read_urls.len(),
"loaded read news urls"
);
}
}
fn load_news_read_ids(app: &mut AppState) {
if let Ok(s) = std::fs::read_to_string(&app.news_read_ids_path)
&& let Ok(set) = serde_json::from_str::<std::collections::HashSet<String>>(&s)
{
app.news_read_ids = set;
tracing::info!(
path = %app.news_read_ids_path.display(),
count = app.news_read_ids.len(),
"loaded read news ids"
);
return;
}
if app.news_read_ids.is_empty() && !app.news_read_urls.is_empty() {
app.news_read_ids.extend(app.news_read_urls.iter().cloned());
tracing::info!(
copied = app.news_read_ids.len(),
"seeded news read ids from legacy URL set"
);
app.news_read_ids_dirty = true;
}
}
fn load_announcement_state(app: &mut AppState) {
#[derive(serde::Deserialize)]
struct OldAnnouncementReadState {
hash: Option<String>,
}
if let Ok(s) = std::fs::read_to_string(&app.announcement_read_path) {
if let Ok(ids) = serde_json::from_str::<std::collections::HashSet<String>>(&s) {
app.announcements_read_ids = ids;
tracing::info!(
path = %app.announcement_read_path.display(),
count = app.announcements_read_ids.len(),
"loaded announcement read IDs"
);
return;
}
if let Ok(old_state) = serde_json::from_str::<OldAnnouncementReadState>(&s)
&& let Some(hash) = old_state.hash
{
app.announcements_read_ids.insert(format!("hash:{hash}"));
app.announcement_dirty = true; tracing::info!(
path = %app.announcement_read_path.display(),
"migrated old announcement read state"
);
}
}
}
fn check_version_announcement(app: &mut AppState) {
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
let current_base_version = crate::announcements::extract_base_version(CURRENT_VERSION);
if let Some(announcement) = crate::announcements::VERSION_ANNOUNCEMENTS
.iter()
.find(|a| {
let announcement_base_version = crate::announcements::extract_base_version(a.version);
announcement_base_version == current_base_version
})
{
let version_id = format!("v{CURRENT_VERSION}");
if app.announcements_read_ids.contains(&version_id) {
tracing::info!(
current_version = CURRENT_VERSION,
base_version = %current_base_version,
"version announcement already marked as read"
);
return;
}
if matches!(app.modal, crate::state::Modal::None) {
app.modal = crate::state::Modal::Announcement {
title: announcement.title.to_string(),
content: announcement.content.to_string(),
id: version_id,
scroll: 0,
};
tracing::info!(
current_version = CURRENT_VERSION,
base_version = %current_base_version,
announcement_version = announcement.version,
"showing version announcement modal"
);
} else {
app.pending_announcements
.push(crate::announcements::RemoteAnnouncement {
id: version_id,
title: announcement.title.to_string(),
content: announcement.content.to_string(),
min_version: None,
max_version: None,
expires: None,
});
tracing::info!(
current_version = CURRENT_VERSION,
base_version = %current_base_version,
announcement_version = announcement.version,
queue_size = app.pending_announcements.len(),
"queued version announcement modal because another modal is open"
);
}
}
}
pub fn initialize_app_state(
app: &mut AppState,
dry_run_flag: bool,
headless: bool,
prefs: &crate::theme::Settings,
) -> InitFlags {
app.dry_run = if dry_run_flag {
true
} else {
prefs.app_dry_run_default
};
app.last_input_change = Instant::now();
tracing::info!(
recent = %app.recent_path.display(),
install = %app.install_path.display(),
details_cache = %app.cache_path.display(),
index = %app.official_index_path.display(),
news_read = %app.news_read_path.display(),
news_read_ids = %app.news_read_ids_path.display(),
announcement_read = %app.announcement_read_path.display(),
"resolved state file paths"
);
crate::logic::repos::load_repos_config_into_app(app, crate::theme::resolve_repos_config_path());
apply_settings_to_app_state(app, prefs);
initialize_locale_system(app, &prefs.locale, prefs);
check_gnome_terminal(app, headless);
if !headless && !prefs.startup_news_configured {
if matches!(app.modal, crate::state::Modal::None) {
let ssh_command = crate::theme::settings().aur_vote_ssh_command;
app.pending_aur_ssh_help_check_result = Some(
crate::logic::ssh_setup::spawn_aur_ssh_help_check(ssh_command),
);
app.aur_ssh_help_ready = None;
app.modal = crate::state::Modal::StartupSetupSelector {
cursor: 0,
selected: std::collections::HashSet::new(),
active_privilege_tool: crate::logic::privilege::active_tool().ok(),
};
}
} else if !headless && prefs.startup_news_configured {
app.news_loading = true;
app.toast_message = Some(crate::i18n::t(app, "app.news_button.loading"));
app.toast_expires_at = None; }
if !headless {
let username = std::env::var("USER").unwrap_or_else(|_| "user".to_string());
let (is_locked, lockout_until, remaining_minutes) =
crate::logic::faillock::get_lockout_info(&username);
app.faillock_locked = is_locked;
app.faillock_lockout_until = lockout_until;
app.faillock_remaining_minutes = remaining_minutes;
}
load_details_cache(app);
load_recent_searches(app);
load_install_list(app);
initialize_cache_files(app);
let (deps_cache, needs_deps_resolution) = load_cache_with_signature(
&app.install_list,
&app.deps_cache_path,
deps_cache::compute_signature,
deps_cache::load_cache,
"dependency",
);
if let Some(cached_deps) = deps_cache {
app.install_list_deps = cached_deps;
tracing::info!(
path = %app.deps_cache_path.display(),
count = app.install_list_deps.len(),
"loaded dependency cache"
);
}
let (files_cache, needs_files_resolution) = load_cache_with_signature(
&app.install_list,
&app.files_cache_path,
files_cache::compute_signature,
files_cache::load_cache,
"file",
);
if let Some(cached_files) = files_cache {
app.install_list_files = cached_files;
tracing::info!(
path = %app.files_cache_path.display(),
count = app.install_list_files.len(),
"loaded file cache"
);
}
let (services_cache, needs_services_resolution) = load_cache_with_signature(
&app.install_list,
&app.services_cache_path,
services_cache::compute_signature,
services_cache::load_cache,
"service",
);
if let Some(cached_services) = services_cache {
app.install_list_services = cached_services;
tracing::info!(
path = %app.services_cache_path.display(),
count = app.install_list_services.len(),
"loaded service cache"
);
}
let (sandbox_cache, needs_sandbox_resolution) = load_cache_with_signature(
&app.install_list,
&app.sandbox_cache_path,
sandbox_cache::compute_signature,
sandbox_cache::load_cache,
"sandbox",
);
if let Some(cached_sandbox) = sandbox_cache {
app.install_list_sandbox = cached_sandbox;
tracing::info!(
path = %app.sandbox_cache_path.display(),
count = app.install_list_sandbox.len(),
"loaded sandbox cache"
);
}
load_news_read_urls(app);
load_news_read_ids(app);
load_announcement_state(app);
pkgindex::load_from_disk(&app.official_index_path);
check_version_announcement(app);
tracing::info!(
path = %app.official_index_path.display(),
"attempted to load official index from disk"
);
InitFlags {
needs_deps_resolution,
needs_files_resolution,
needs_services_resolution,
needs_sandbox_resolution,
}
}
pub fn trigger_initial_resolutions(
app: &mut AppState,
flags: &InitFlags,
deps_req_tx: &tokio::sync::mpsc::UnboundedSender<(
Vec<PackageItem>,
crate::state::modal::PreflightAction,
)>,
files_req_tx: &tokio::sync::mpsc::UnboundedSender<(
Vec<PackageItem>,
crate::state::modal::PreflightAction,
)>,
services_req_tx: &tokio::sync::mpsc::UnboundedSender<(
Vec<PackageItem>,
crate::state::modal::PreflightAction,
)>,
sandbox_req_tx: &tokio::sync::mpsc::UnboundedSender<Vec<PackageItem>>,
) {
if flags.needs_deps_resolution && !app.install_list.is_empty() {
app.deps_resolving = true;
let _ = deps_req_tx.send((
app.install_list.clone(),
crate::state::modal::PreflightAction::Install,
));
}
if flags.needs_files_resolution && !app.install_list.is_empty() {
app.files_resolving = true;
let _ = files_req_tx.send((
app.install_list.clone(),
crate::state::modal::PreflightAction::Install,
));
}
if flags.needs_services_resolution && !app.install_list.is_empty() {
app.services_resolving = true;
let _ = services_req_tx.send((
app.install_list.clone(),
crate::state::modal::PreflightAction::Install,
));
}
if flags.needs_sandbox_resolution && !app.install_list.is_empty() {
app.sandbox_resolving = true;
let _ = sandbox_req_tx.send(app.install_list.clone());
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::runtime::background::Channels;
fn new_app() -> AppState {
AppState::default()
}
#[test]
fn initialize_locale_system_fallback_when_config_missing() {
let mut app = new_app();
let prefs = crate::theme::Settings::default();
initialize_locale_system(&mut app, "", &prefs);
assert!(!app.locale.is_empty());
assert!(app.translations.is_empty() || !app.translations.is_empty());
assert!(app.translations_fallback.is_empty() || !app.translations_fallback.is_empty());
}
#[test]
fn initialize_app_state_sets_dry_run_flag() {
let mut app = new_app();
let prefs = crate::theme::settings();
let flags = initialize_app_state(&mut app, true, false, &prefs);
assert!(app.dry_run);
let _ = flags;
}
#[test]
fn initialize_app_state_loads_settings() {
let mut app = new_app();
let prefs = crate::theme::settings();
let _flags = initialize_app_state(&mut app, false, false, &prefs);
assert!(app.layout_left_pct > 0);
assert!(app.layout_center_pct > 0);
assert!(app.layout_right_pct > 0);
}
#[test]
fn initialize_app_state_shows_startup_selector_when_news_unconfigured() {
let mut app = new_app();
let mut prefs = crate::theme::settings();
prefs.startup_news_configured = false;
let _flags = initialize_app_state(&mut app, false, false, &prefs);
assert!(matches!(
app.modal,
crate::state::Modal::StartupSetupSelector { .. }
));
}
#[test]
fn check_version_announcement_queues_when_modal_already_open() {
let mut app = new_app();
app.modal = crate::state::Modal::StartupSetupSelector {
cursor: 0,
selected: std::collections::HashSet::new(),
active_privilege_tool: None,
};
let pending_before = app.pending_announcements.len();
check_version_announcement(&mut app);
assert!(matches!(
app.modal,
crate::state::Modal::StartupSetupSelector { .. }
));
assert_eq!(
app.pending_announcements.len(),
pending_before.saturating_add(1)
);
}
#[test]
fn initialize_cache_files_creates_empty_placeholders() {
let mut app = new_app();
let mut deps_path = std::env::temp_dir();
deps_path.push(format!(
"pacsea_init_deps_cache_{}_{}.json",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("System time is before UNIX epoch")
.as_nanos()
));
let mut files_path = deps_path.clone();
files_path.set_file_name("pacsea_init_files_cache.json");
let mut services_path = deps_path.clone();
services_path.set_file_name("pacsea_init_services_cache.json");
let mut sandbox_path = deps_path.clone();
sandbox_path.set_file_name("pacsea_init_sandbox_cache.json");
app.deps_cache_path = deps_path.clone();
app.files_cache_path = files_path.clone();
app.services_cache_path = services_path.clone();
app.sandbox_cache_path = sandbox_path.clone();
let _ = std::fs::remove_file(&app.deps_cache_path);
let _ = std::fs::remove_file(&app.files_cache_path);
let _ = std::fs::remove_file(&app.services_cache_path);
let _ = std::fs::remove_file(&app.sandbox_cache_path);
initialize_cache_files(&app);
let deps_body = std::fs::read_to_string(&app.deps_cache_path)
.expect("Dependency cache file should exist");
let deps_cache: crate::app::deps_cache::DependencyCache =
serde_json::from_str(&deps_body).expect("Dependency cache should parse");
assert!(deps_cache.install_list_signature.is_empty());
assert!(deps_cache.dependencies.is_empty());
let files_body =
std::fs::read_to_string(&app.files_cache_path).expect("File cache file should exist");
let files_cache: crate::app::files_cache::FileCache =
serde_json::from_str(&files_body).expect("File cache should parse");
assert!(files_cache.install_list_signature.is_empty());
assert!(files_cache.files.is_empty());
let services_body = std::fs::read_to_string(&app.services_cache_path)
.expect("Service cache file should exist");
let services_cache: crate::app::services_cache::ServiceCache =
serde_json::from_str(&services_body).expect("Service cache should parse");
assert!(services_cache.install_list_signature.is_empty());
assert!(services_cache.services.is_empty());
let sandbox_body = std::fs::read_to_string(&app.sandbox_cache_path)
.expect("Sandbox cache file should exist");
let sandbox_cache: crate::app::sandbox_cache::SandboxCache =
serde_json::from_str(&sandbox_body).expect("Sandbox cache should parse");
assert!(sandbox_cache.install_list_signature.is_empty());
assert!(sandbox_cache.sandbox_info.is_empty());
let _ = std::fs::remove_file(&app.deps_cache_path);
let _ = std::fs::remove_file(&app.files_cache_path);
let _ = std::fs::remove_file(&app.services_cache_path);
let _ = std::fs::remove_file(&app.sandbox_cache_path);
}
#[tokio::test]
async fn trigger_initial_resolutions_skips_when_install_list_empty() {
let mut app = new_app();
app.install_list.clear();
let flags = InitFlags {
needs_deps_resolution: true,
needs_files_resolution: true,
needs_services_resolution: true,
needs_sandbox_resolution: true,
};
let channels = Channels::new(std::path::PathBuf::from("/tmp"));
trigger_initial_resolutions(
&mut app,
&flags,
&channels.deps_req_tx,
&channels.files_req_tx,
&channels.services_req_tx,
&channels.sandbox_req_tx,
);
assert!(!app.deps_resolving);
assert!(!app.files_resolving);
assert!(!app.services_resolving);
assert!(!app.sandbox_resolving);
}
#[tokio::test]
async fn trigger_initial_resolutions_triggers_when_needed() {
let mut app = new_app();
app.install_list.push(crate::state::PackageItem {
name: "test-package".to_string(),
version: "1.0.0".to_string(),
description: "Test".to_string(),
source: crate::state::Source::Aur,
popularity: None,
out_of_date: None,
orphaned: false,
});
let flags = InitFlags {
needs_deps_resolution: true,
needs_files_resolution: false,
needs_services_resolution: false,
needs_sandbox_resolution: false,
};
let channels = Channels::new(std::path::PathBuf::from("/tmp"));
trigger_initial_resolutions(
&mut app,
&flags,
&channels.deps_req_tx,
&channels.files_req_tx,
&channels.services_req_tx,
&channels.sandbox_req_tx,
);
assert!(app.deps_resolving);
assert!(!app.files_resolving);
assert!(!app.services_resolving);
assert!(!app.sandbox_resolving);
}
}