diff --git a/src/app/install/flow.rs b/src/app/install/flow.rs index c217661..3ac5d04 100644 --- a/src/app/install/flow.rs +++ b/src/app/install/flow.rs @@ -17,12 +17,36 @@ impl AppState { self.open_info_popup("Installation already running. Please wait...".into()); return; } + + let original_bootloader_index = self.bootloader_index; + let mut bootloader_autocorrected = false; + + // Auto-correct invalid bootloader selection on legacy (non-UEFI) systems. + // - systemd-boot requires UEFI + // - efistub (experimental) requires UEFI + if !self.is_uefi() && matches!(self.bootloader_index, 0 | 2) { + // GRUB is index 1 in `bootloader_index`. + self.debug_log(&format!( + "start_install: legacy detected (uefi=false), switching bootloader_index {} -> 1 (grub)", + self.bootloader_index + )); + self.bootloader_index = 1; + bootloader_autocorrected = true; + } + // Guard: Desktop (KDE/GNOME) requires NetworkManager let desktop_selected = self.experience_mode_index == 0; let selects_kde = self.selected_desktop_envs.contains("KDE Plasma"); let selects_gnome = self.selected_desktop_envs.contains("GNOME"); let needs_nm = desktop_selected && (selects_kde || selects_gnome); if needs_nm && self.network_mode_index != 2 { + if bootloader_autocorrected { + self.debug_log(&format!( + "start_install: reverting bootloader_index {} -> {} (install did not proceed)", + self.bootloader_index, original_bootloader_index + )); + self.bootloader_index = original_bootloader_index; + } self.popup_kind = Some(crate::core::types::PopupKind::NetworkManagerSwitchConfirm); self.popup_open = true; self.popup_items = vec![ @@ -39,6 +63,13 @@ impl AppState { if let Some(msg) = self.validate_install_requirements() { self.debug_log("start_install: rejected, requirements not met"); self.open_info_popup(msg); + if bootloader_autocorrected { + self.debug_log(&format!( + "start_install: reverting bootloader_index {} -> {} (install did not proceed)", + self.bootloader_index, original_bootloader_index + )); + self.bootloader_index = original_bootloader_index; + } return; } self.debug_log("start_install: starting install flow"); diff --git a/src/app/swap_partition.rs b/src/app/swap_partition.rs index 9fa7637..1c58e8a 100644 --- a/src/app/swap_partition.rs +++ b/src/app/swap_partition.rs @@ -21,6 +21,14 @@ pub fn draw_swap_partition(frame: &mut ratatui::Frame, app: &mut AppState, area: let mut lines: Vec = vec![Line::from(title), Line::from("")]; + let ram_gib = app.detected_ram_mib as f64 / 1024.0; + let swap_gib = app.swap_size_mib as f64 / 1024.0; + lines.push(Line::from(Span::styled( + format!(" Detected RAM: {ram_gib:.1} GiB | Planned swap: {swap_gib:.2} GiB"), + Style::default().fg(Color::DarkGray), + ))); + lines.push(Line::from("")); + let options = vec![( "Swapon", if app.swap_enabled { diff --git a/src/common/utils.rs b/src/common/utils.rs index c9f619b..b91ca4f 100644 --- a/src/common/utils.rs +++ b/src/common/utils.rs @@ -159,6 +159,38 @@ pub fn strip_ansi_escape_codes(input: &str) -> String { out } +/// Detect total system RAM by reading `/proc/meminfo`. +/// Returns MiB. Falls back to 4096 MiB (4 GiB) if detection fails. +pub fn detect_ram_mib() -> u64 { + if let Ok(contents) = std::fs::read_to_string("/proc/meminfo") { + for line in contents.lines() { + if let Some(rest) = line.strip_prefix("MemTotal:") { + let trimmed = rest.trim(); + if let Some(kb_str) = trimmed.strip_suffix(" kB") { + if let Ok(kb) = kb_str.trim().parse::() { + return kb / 1024; + } + } + } + } + } + 4096 +} + +/// Compute optimal swap partition size in MiB based on detected RAM. +/// - RAM <= 8 GiB: swap = RAM (at least 1 GiB) +/// - RAM > 8 GiB: swap = 8 GiB + half of the excess +/// - Maximum: 16 GiB +pub fn compute_swap_size_mib(ram_mib: u64) -> u64 { + let ram_mib = ram_mib.max(1024); + let swap_mib = if ram_mib <= 8192 { + ram_mib + } else { + 8192 + (ram_mib - 8192) / 2 + }; + swap_mib.min(16384) +} + /// Sanitize a terminal output line for safe rendering inside the TUI. /// Removes carriage returns, backspaces and ANSI escape sequences. pub fn sanitize_terminal_output_line(input: &str) -> String { diff --git a/src/core/services/partitioning.rs b/src/core/services/partitioning.rs index 01c3262..a73a316 100644 --- a/src/core/services/partitioning.rs +++ b/src/core/services/partitioning.rs @@ -92,7 +92,10 @@ impl PartitioningService { "parted -s {device} mkpart ESP fat32 {next_start} 1025MiB" )); part_cmds.push(format!("parted -s {device} set 1 esp on")); - part_cmds.push(format!("mkfs.fat -F 32 {device}1")); + part_cmds.push(format!( + "mkfs.fat -F 32 {}", + Self::get_partition_path(device, 1) + )); next_start = "1025MiB".into(); } else { part_cmds.push(format!( @@ -102,17 +105,20 @@ impl PartitioningService { next_start = "2MiB".into(); } + let swap_part_num: u32 = 2; + let root_part_num: u32 = if state.swap_enabled { 3 } else { 2 }; + let swap_part_path = Self::get_partition_path(device, swap_part_num); + let root_part_path = Self::get_partition_path(device, root_part_num); + if state.swap_enabled { - let swap_end = if state.is_uefi() { - "5121MiB" - } else { - "4098MiB" - }; + let swap_start_mib: u64 = if state.is_uefi() { 1025 } else { 2 }; + let swap_end_mib = swap_start_mib + state.swap_size_mib; + let swap_end = format!("{swap_end_mib}MiB"); part_cmds.push(format!( "parted -s {device} mkpart swap linux-swap {next_start} {swap_end}" )); - part_cmds.push(format!("mkswap {device}2")); - next_start = swap_end.into(); + part_cmds.push(format!("mkswap {swap_part_path}")); + next_start = swap_end; } part_cmds.push(format!( @@ -125,12 +131,16 @@ impl PartitioningService { "modprobe -q dm_crypt 2>/dev/null || modprobe -q dm-crypt 2>/dev/null || true" .into(), ); - part_cmds.push(format!("cryptsetup luksFormat --type luks2 -q {device}3")); + part_cmds.push(format!( + "cryptsetup luksFormat --type luks2 -q {root_part_path}" + )); part_cmds.push("udevadm settle".into()); - part_cmds.push(format!("cryptsetup open --type luks {device}3 cryptroot")); + part_cmds.push(format!( + "cryptsetup open --type luks {root_part_path} cryptroot" + )); part_cmds.push("mkfs.btrfs -f /dev/mapper/cryptroot".into()); } else { - part_cmds.push(format!("mkfs.btrfs -f {device}3")); + part_cmds.push(format!("mkfs.btrfs -f {root_part_path}")); } } diff --git a/src/core/services/sysconfig.rs b/src/core/services/sysconfig.rs index 002df1a..7624bbe 100644 --- a/src/core/services/sysconfig.rs +++ b/src/core/services/sysconfig.rs @@ -101,6 +101,21 @@ impl SysConfigService { cmds.push("systemctl --root=/mnt enable systemd-timesyncd".into()); } + // Enable SSH daemon when the SSH server type is selected + // (openssh installs the `sshd.service` unit on Arch). + let wants_sshd = state.selected_server_types.contains("sshd"); + let openssh_in_server_pkgs = state + .selected_server_packages + .get("sshd") + .map_or(false, |set| set.contains("openssh")); + let openssh_in_additional_pkgs = state + .additional_packages + .iter() + .any(|p| p.name == "openssh"); + if wants_sshd && (openssh_in_server_pkgs || openssh_in_additional_pkgs) { + cmds.push("systemctl --root=/mnt enable sshd".into()); + } + // Root password (set only if provided and confirmed) if !state.root_password.is_empty() && state.root_password == state.root_password_confirm { if state.dry_run { diff --git a/src/core/services/system.rs b/src/core/services/system.rs index 311c73c..fa3698d 100644 --- a/src/core/services/system.rs +++ b/src/core/services/system.rs @@ -116,6 +116,8 @@ impl SystemService { let mut package_set: BTreeSet = BTreeSet::new(); let mut missing: Vec = Vec::new(); + let desktop_mode = state.experience_mode_index == 0; // 0: Desktop, 1: Minimal, 2: Server, 3: Xorg + // Essentials for p in [ "base", @@ -173,28 +175,33 @@ impl SystemService { _ => {} } - // Polkit for Hyprland or Sway - if state - .selected_desktop_envs - .iter() - .any(|e| e == "Hyprland" || e == "Sway") + // Polkit for Hyprland or Sway (desktop-only) + if desktop_mode + && state + .selected_desktop_envs + .iter() + .any(|e| e == "Hyprland" || e == "Sway") { package_set.insert("polkit".into()); } - // Login manager (if set and not none) - if let Some(lm) = state.selected_login_manager.clone() - && !lm.is_empty() - && lm != "none" - { - package_set.insert(lm); + // Login manager (if set and not none) - desktop-only + if desktop_mode { + if let Some(lm) = state.selected_login_manager.clone() + && !lm.is_empty() + && lm != "none" + { + package_set.insert(lm); + } } - // Desktop environment packages (only for selected environments) - for env in state.selected_desktop_envs.iter() { - if let Some(set) = state.selected_env_packages.get(env) { - for p in set.iter() { - package_set.insert(p.clone()); + // Desktop environment packages (only for selected environments) - desktop-only + if desktop_mode { + for env in state.selected_desktop_envs.iter() { + if let Some(set) = state.selected_env_packages.get(env) { + for p in set.iter() { + package_set.insert(p.clone()); + } } } } diff --git a/src/core/state.rs b/src/core/state.rs index a68ca4a..635d295 100644 --- a/src/core/state.rs +++ b/src/core/state.rs @@ -127,6 +127,8 @@ pub struct AppState { // Swap Partition state pub swap_focus_index: usize, // 0: toggle, 1: Continue pub swap_enabled: bool, + pub detected_ram_mib: u64, + pub swap_size_mib: u64, // Unified Kernel Images state pub uki_focus_index: usize, // 0: toggle, 1: Continue @@ -395,6 +397,9 @@ impl AppState { let mut list_state = ListState::default(); list_state.select(Some(0)); + let detected_ram = crate::common::utils::detect_ram_mib(); + let swap_size = crate::common::utils::compute_swap_size_mib(detected_ram); + let mut s = Self { install_completed: false, reboot_prompt_open: false, @@ -499,6 +504,8 @@ impl AppState { swap_focus_index: 0, swap_enabled: true, + detected_ram_mib: detected_ram, + swap_size_mib: swap_size, uki_focus_index: 0, uki_enabled: false, diff --git a/src/core/storage/planner.rs b/src/core/storage/planner.rs index 4f93e27..5008abb 100644 --- a/src/core/storage/planner.rs +++ b/src/core/storage/planner.rs @@ -82,15 +82,15 @@ impl StoragePlanner { part_num += 1; } - let swap_start = if is_uefi { "1025MiB" } else { "2MiB" }; + let swap_start_mib: u64 = if is_uefi { 1025 } else { 2 }; + let swap_end_mib = swap_start_mib + state.swap_size_mib; if state.swap_enabled { - let swap_end = if is_uefi { "5121MiB" } else { "4098MiB" }; partitions.push(PlannedPartition { number: part_num, role: PartitionRole::Swap, - start: swap_start.into(), - end: swap_end.into(), + start: format!("{swap_start_mib}MiB"), + end: format!("{swap_end_mib}MiB"), filesystem: FilesystemSpec { fstype: "linux-swap".into(), mkfs_options: vec![], @@ -112,9 +112,9 @@ impl StoragePlanner { } let root_start = if state.swap_enabled { - if is_uefi { "5121MiB" } else { "4098MiB" } + format!("{swap_end_mib}MiB") } else { - swap_start + format!("{swap_start_mib}MiB") }; let encryption = if luks { diff --git a/src/render/sections/info/disks.rs b/src/render/sections/info/disks.rs index 8d85b4b..3aa7102 100644 --- a/src/render/sections/info/disks.rs +++ b/src/render/sections/info/disks.rs @@ -213,10 +213,11 @@ pub(super) fn render(frame: &mut Frame, app: &mut AppState, area: Rect) { info_lines.push(Line::from(format!("Selected drive: {dev}"))); } info_lines.push(Line::from("Planned layout:")); + let swap_gib = app.swap_size_mib as f64 / 1024.0; if app.is_uefi() { info_lines.push(Line::from("- gpt: 1024MiB EFI (FAT, ESP) -> /boot")); if app.swap_enabled { - info_lines.push(Line::from("- swap: 4GiB")); + info_lines.push(Line::from(format!("- swap: {swap_gib:.2}GiB"))); } let enc = if app.disk_encryption_type_index == 1 { " (LUKS)" @@ -227,7 +228,7 @@ pub(super) fn render(frame: &mut Frame, app: &mut AppState, area: Rect) { } else { info_lines.push(Line::from("- gpt: 1MiB bios_boot [bios_grub]")); if app.swap_enabled { - info_lines.push(Line::from("- swap: 4GiB")); + info_lines.push(Line::from(format!("- swap: {swap_gib:.2}GiB"))); } let enc = if app.disk_encryption_type_index == 1 { " (LUKS)" diff --git a/src/render/sections/info/swap.rs b/src/render/sections/info/swap.rs index c0f041e..93ae9ab 100644 --- a/src/render/sections/info/swap.rs +++ b/src/render/sections/info/swap.rs @@ -20,6 +20,11 @@ pub(super) fn render(frame: &mut Frame, app: &mut AppState, area: Rect) { "Disabled" }; info_lines.push(Line::from(format!("Swapon: {swap}"))); + let ram_gib = app.detected_ram_mib as f64 / 1024.0; + let swap_gib = app.swap_size_mib as f64 / 1024.0; + info_lines.push(Line::from(format!( + "Detected RAM: {ram_gib:.1} GiB | Planned swap size: {swap_gib:.2} GiB" + ))); info_lines.push(Line::from( "Swapon can be used to activate the swap partition.", )); diff --git a/tests/logic.rs b/tests/logic.rs index 0aec36b..ca639d1 100644 --- a/tests/logic.rs +++ b/tests/logic.rs @@ -114,6 +114,49 @@ fn sysconfig_enables_networkmanager_when_selected() { ); } +#[test] +fn sysconfig_enables_sshd_when_selected() { + let mut state = make_state(); + state.disks_selected_device = Some("/dev/sda".into()); + state.selected_server_types.insert("sshd".into()); + state.selected_server_packages.insert( + "sshd".into(), + std::collections::BTreeSet::from(["openssh".to_string()]), + ); + + let storage_plan = ai::core::storage::planner::StoragePlanner::compile(&state) + .expect("auto plan should compile"); + let plan = ai::core::services::sysconfig::SysConfigService::build_plan(&state, &storage_plan); + let joined = plan.commands.join("\n"); + + assert!( + joined.contains("systemctl --root=/mnt enable sshd"), + "{joined}" + ); +} + +#[test] +fn sysconfig_does_not_enable_sshd_if_openssh_not_planned() { + let mut state = make_state(); + state.disks_selected_device = Some("/dev/sda".into()); + state.selected_server_types.insert("sshd".into()); + // Intentionally omit "openssh" from planned server packages. + state.selected_server_packages.insert( + "sshd".into(), + std::collections::BTreeSet::from(["rsync".to_string()]), + ); + + let storage_plan = ai::core::storage::planner::StoragePlanner::compile(&state) + .expect("auto plan should compile"); + let plan = ai::core::services::sysconfig::SysConfigService::build_plan(&state, &storage_plan); + let joined = plan.commands.join("\n"); + + assert!( + !joined.contains("systemctl --root=/mnt enable sshd"), + "should not enable sshd unless openssh is planned: {joined}" + ); +} + #[test] fn sysconfig_luks_uses_mkinitcpio_warning_guard() { let mut state = make_state(); @@ -162,6 +205,29 @@ fn partitioning_auto_includes_root_and_format() { ); } +#[test] +fn partitioning_auto_uses_partition_path_helper_for_nvme_devices() { + let mut state = make_state(); + state.swap_enabled = true; + state.swap_size_mib = 512; + state.disk_encryption_type_index = 0; // no luks + state.firmware_uefi_override = Some(true); + + let device = "/dev/nvme0n1"; + let plan = ai::core::services::partitioning::PartitioningService::build_plan(&state, device); + let joined = plan.commands.join("\n"); + + assert!( + joined.contains("mkfs.fat -F 32 /dev/nvme0n1p1"), + "{joined}" + ); + assert!(joined.contains("mkswap /dev/nvme0n1p2"), "{joined}"); + assert!( + joined.contains("mkfs.btrfs -f /dev/nvme0n1p3"), + "{joined}" + ); +} + #[test] fn partitioning_manual_converts_bytes_to_mib() { let mut state = make_state(); @@ -222,6 +288,82 @@ fn system_pacstrap_plan_includes_pacstrap_when_not_dry_run() { assert!(joined.contains(" pacstrap -K /mnt "), "{joined}"); } +#[test] +fn system_pacstrap_plan_skips_desktop_packages_when_mode_is_server() { + let mut state = make_state(); + state.experience_mode_index = 2; // Server + + let plan = ai::core::services::system::SystemService::build_pacstrap_plan(&state); + let joined = plan.commands.join("\n"); + + // Sanity: we still build a pacstrap command. + assert!(joined.contains(" pacstrap -K /mnt "), "{joined}"); + + // Desktop-only packages should not be pulled in just because they're selected. + assert!(!joined.contains("sddm"), "{joined}"); + assert!(!joined.contains("plasma-meta"), "{joined}"); + assert!(!joined.contains("plasma-workspace"), "{joined}"); +} + +#[test] +fn system_pacstrap_plan_skips_login_manager_and_polkit_outside_desktop_mode() { + let mut state = make_state(); + state.experience_mode_index = 3; // Xorg + + // Force a desktop env that triggers polkit, and keep a non-none login manager selected. + state.selected_desktop_envs.clear(); + state.selected_desktop_envs.insert("Sway".into()); + state.selected_login_manager = Some("sddm".into()); + + let plan = ai::core::services::system::SystemService::build_pacstrap_plan(&state); + let joined = plan.commands.join("\n"); + + assert!(joined.contains(" pacstrap -K /mnt "), "{joined}"); + assert!(!joined.contains("sddm"), "{joined}"); + assert!(!joined.contains("polkit"), "{joined}"); +} + +#[test] +fn start_install_does_not_persist_legacy_bootloader_autocorrect_on_early_return() { + // Ensure we don't rely on host firmware (tests run on whatever CI/host). + // Use the explicit override instead. + let mut state = ai::app::AppState::new(true); + state.firmware_uefi_override = Some(false); + + state.bootloader_index = 0; // systemd-boot (invalid on legacy) + state.start_install(); + assert_eq!( + state.bootloader_index, 0, + "auto-correction should not persist if start_install returns early" + ); + + state.bootloader_index = 2; // efistub (invalid on legacy) + state.start_install(); + assert_eq!( + state.bootloader_index, 2, + "auto-correction should not persist if start_install returns early" + ); +} + +#[test] +fn start_install_does_not_persist_legacy_bootloader_autocorrect_when_nm_confirm_popup_opens() { + let mut state = ai::app::AppState::new(true); + state.firmware_uefi_override = Some(false); + state.bootloader_index = 0; // systemd-boot (invalid on legacy) + + // Trigger the NetworkManager confirmation guard: + // Desktop + KDE/GNOME requires NetworkManager, but anything other than index 2 is non-NM. + state.experience_mode_index = 0; // Desktop + state.selected_desktop_envs.insert("KDE Plasma".into()); + state.network_mode_index = 0; // not NetworkManager + + state.start_install(); + assert_eq!( + state.bootloader_index, 0, + "auto-correction should not persist if a confirmation popup interrupts the install" + ); +} + #[test] fn bootloader_systemd_boot_writes_loader_and_entries() { let mut state = make_state(); // default bootloader_index = 0 (systemd-boot) @@ -839,3 +981,35 @@ fn kernel_artifacts_returns_correct_names() { assert_eq!(ka_lts.uki_default, "arch-linux-lts.efi"); assert_eq!(ka_lts.preset, "linux-lts.preset"); } + +#[test] +fn compute_swap_size_mib_formula() { + use ai::common::utils::compute_swap_size_mib; + + // Tiny RAM (< 1 GiB) -> clamp to 1 GiB swap + assert_eq!(compute_swap_size_mib(256), 1024); + + // 1 GiB RAM -> 1 GiB swap + assert_eq!(compute_swap_size_mib(1024), 1024); + + // 4 GiB RAM -> 4 GiB swap + assert_eq!(compute_swap_size_mib(4096), 4096); + + // 8 GiB RAM -> 8 GiB swap (boundary) + assert_eq!(compute_swap_size_mib(8192), 8192); + + // 8.5 GiB RAM -> 8 GiB + half(0.5 GiB) = 8.25 GiB + assert_eq!(compute_swap_size_mib(8704), 8448); + + // 10 GiB RAM -> 8 GiB + half(2 GiB) = 9 GiB + assert_eq!(compute_swap_size_mib(10240), 9216); + + // 16 GiB RAM -> 8 + (16-8)/2 = 12 GiB swap + assert_eq!(compute_swap_size_mib(16384), 12288); + + // 32 GiB RAM -> 8 + (32-8)/2 = 20 -> capped at 16 GiB + assert_eq!(compute_swap_size_mib(32768), 16384); + + // 64 GiB RAM -> capped at 16 GiB + assert_eq!(compute_swap_size_mib(65536), 16384); +}