use anyhow::Result;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::mpsc::{self, Receiver, Sender};
use std::time::Instant;
use crate::commands::qemu_system::NetworkCapabilities;
use crate::config::Config;
use crate::hardware::{MultiGpuPassthroughStatus, PciDevice, SingleGpuConfig, UsbDevice};
use crate::metadata::{AsciiArtStore, HierarchyConfig, MetadataStore, OsInfo, QemuProfileStore, SettingsHelpStore, SharedFoldersHelpStore};
use crate::ui::widgets::build_visual_order;
use crate::vm::{discover_vms, BootMode, DiscoveredVm, LaunchOptions, QemuProcess, SharedFolder, Snapshot};
use crate::vm::qemu_config::{PortForward, PortProtocol};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Screen {
MainMenu,
Management,
#[allow(dead_code)]
Configuration,
RawScript,
#[allow(dead_code)]
DetailedInfo,
Snapshots,
BootOptions,
DisplayOptions,
UsbDevices,
PciPassthrough,
SharedFolders,
SingleGpuSetup,
SingleGpuInstructions,
MultiGpuSetup,
Confirm(ConfirmAction),
Help,
Search,
FileBrowser,
TextInput(TextInputContext),
ErrorDialog,
CreateWizard,
CreateWizardCustomOs,
#[allow(dead_code)]
CreateWizardDownload,
NetworkSettings,
Settings,
ImportWizard,
EditNotes,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TextInputContext {
SnapshotName,
RenameVm,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfirmAction {
LaunchVm,
ResetVm,
DeleteVm,
DeleteSnapshot(String),
RestoreSnapshot(String),
DiscardScriptChanges,
DiscardNotesChanges,
StopVm,
ForceStopVm,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InputMode {
Normal,
Editing,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FileBrowserMode {
#[default]
Iso,
RecoveryImage,
Disk,
Directory,
ImportConfig,
Bios,
Floppy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DiskAction {
#[default]
Copy,
Move,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum WizardStep {
#[default]
SelectOs,
SelectIso,
ConfigureDisk,
ConfigureQemu,
Confirm,
}
impl WizardStep {
pub fn number(&self) -> u8 {
match self {
WizardStep::SelectOs => 1,
WizardStep::SelectIso => 2,
WizardStep::ConfigureDisk => 3,
WizardStep::ConfigureQemu => 4,
WizardStep::Confirm => 5,
}
}
pub fn title(&self) -> &'static str {
match self {
WizardStep::SelectOs => "Select Operating System",
WizardStep::SelectIso => "Select Install Media",
WizardStep::ConfigureDisk => "Configure Disk",
WizardStep::ConfigureQemu => "Configure QEMU",
WizardStep::Confirm => "Review & Create",
}
}
pub fn next(&self) -> Option<WizardStep> {
match self {
WizardStep::SelectOs => Some(WizardStep::SelectIso),
WizardStep::SelectIso => Some(WizardStep::ConfigureDisk),
WizardStep::ConfigureDisk => Some(WizardStep::ConfigureQemu),
WizardStep::ConfigureQemu => Some(WizardStep::Confirm),
WizardStep::Confirm => None,
}
}
pub fn prev(&self) -> Option<WizardStep> {
match self {
WizardStep::SelectOs => None,
WizardStep::SelectIso => Some(WizardStep::SelectOs),
WizardStep::ConfigureDisk => Some(WizardStep::SelectIso),
WizardStep::ConfigureQemu => Some(WizardStep::ConfigureDisk),
WizardStep::Confirm => Some(WizardStep::ConfigureQemu),
}
}
}
#[derive(Debug, Clone)]
pub struct WizardQemuConfig {
pub emulator: String,
pub memory_mb: u32,
pub cpu_cores: u32,
pub cpu_model: Option<String>,
pub machine: Option<String>,
pub vga: String,
pub audio: Vec<String>,
pub network_model: String,
pub disk_interface: String,
pub enable_kvm: bool,
pub gl_acceleration: bool,
pub uefi: bool,
pub tpm: bool,
pub rtc_localtime: bool,
pub usb_tablet: bool,
pub display: String,
pub network_backend: String,
pub port_forwards: Vec<PortForward>,
pub bridge_name: Option<String>,
pub extra_args: Vec<String>,
pub bios_path: Option<PathBuf>,
}
impl Default for WizardQemuConfig {
fn default() -> Self {
Self {
emulator: "qemu-system-x86_64".to_string(),
memory_mb: 2048,
cpu_cores: 2,
cpu_model: Some("host".to_string()),
machine: Some("q35".to_string()),
vga: "std".to_string(),
audio: vec!["intel-hda".to_string(), "hda-duplex".to_string()],
network_model: "e1000".to_string(),
disk_interface: "ide".to_string(),
enable_kvm: true,
gl_acceleration: false,
uefi: false,
tpm: false,
rtc_localtime: false,
usb_tablet: true,
display: "gtk".to_string(),
network_backend: "user".to_string(),
port_forwards: Vec::new(),
bridge_name: None,
extra_args: Vec::new(),
bios_path: None,
}
}
}
impl WizardQemuConfig {
pub fn from_profile(profile: &crate::metadata::QemuProfile) -> Self {
let gl_acceleration = profile.extra_args.iter().any(|arg|
arg.contains("virtio-vga-gl") || arg.contains("gl=on")
);
Self {
emulator: profile.emulator.clone(),
memory_mb: profile.memory_mb,
cpu_cores: profile.cpu_cores,
cpu_model: profile.cpu_model.clone(),
machine: profile.machine.clone(),
vga: profile.vga.clone(),
audio: profile.audio.clone(),
network_model: profile.network_model.clone(),
disk_interface: profile.disk_interface.clone(),
enable_kvm: profile.enable_kvm,
gl_acceleration,
uefi: profile.uefi,
tpm: profile.tpm,
rtc_localtime: profile.rtc_localtime,
usb_tablet: profile.usb_tablet,
display: profile.display.clone(),
network_backend: profile.network_backend.clone(),
port_forwards: Vec::new(),
bridge_name: None,
extra_args: profile.extra_args.clone(),
bios_path: None,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct CustomOsEntry {
pub id: String,
pub name: String,
pub publisher: String,
#[allow(dead_code)]
pub release_date: Option<String>,
pub architecture: String,
#[allow(dead_code)]
pub short_blurb: String,
#[allow(dead_code)]
pub long_blurb: String,
#[allow(dead_code)]
pub fun_facts: Vec<String>,
pub base_profile: String,
#[allow(dead_code)]
pub save_to_user: bool,
}
#[derive(Debug, Clone)]
pub struct CreateWizardState {
pub step: WizardStep,
pub vm_name: String,
pub folder_name: String,
pub selected_os: Option<String>,
pub custom_os: Option<CustomOsEntry>,
pub iso_path: Option<PathBuf>,
pub is_recovery_image: bool,
pub iso_downloading: bool,
pub iso_download_progress: f32,
pub disk_size_gb: u32,
pub use_existing_disk: bool,
pub existing_disk_path: Option<PathBuf>,
pub existing_disk_action: DiskAction,
pub bios_rom_path: Option<PathBuf>,
pub floppy_path: Option<PathBuf>,
pub qemu_config: WizardQemuConfig,
pub auto_launch: bool,
pub field_focus: usize,
#[allow(dead_code)]
pub os_list_scroll: usize,
pub os_filter: String,
#[allow(dead_code)]
pub selected_category: usize,
pub expanded_categories: Vec<String>,
pub os_list_selected: usize,
pub error_message: Option<String>,
pub editing_field: Option<WizardField>,
pub wizard_edit_buffer: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[allow(dead_code)] pub enum WizardField {
VmName,
OsFilter,
DiskSize,
MemoryMb,
CpuCores,
CustomOsId,
CustomOsName,
CustomOsPublisher,
CustomOsReleaseDate,
CustomOsShortBlurb,
}
impl Default for CreateWizardState {
fn default() -> Self {
Self {
step: WizardStep::SelectOs,
vm_name: String::new(),
folder_name: String::new(),
selected_os: None,
custom_os: None,
iso_path: None,
is_recovery_image: false,
iso_downloading: false,
iso_download_progress: 0.0,
disk_size_gb: 32,
use_existing_disk: false,
existing_disk_path: None,
existing_disk_action: DiskAction::Copy,
bios_rom_path: None,
floppy_path: None,
qemu_config: WizardQemuConfig::default(),
auto_launch: true,
field_focus: 0,
os_list_scroll: 0,
os_filter: String::new(),
selected_category: 0,
expanded_categories: vec![
"windows".to_string(),
"linux".to_string(),
],
os_list_selected: 0,
error_message: None,
editing_field: None,
wizard_edit_buffer: String::new(),
}
}
}
impl CreateWizardState {
pub fn generate_folder_name(display_name: &str) -> String {
display_name
.to_lowercase()
.chars()
.map(|c| {
if c.is_alphanumeric() {
c
} else {
'-'
}
})
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}
pub fn update_folder_name(&mut self, library_path: &std::path::Path) {
let base_name = if let Some(ref os_id) = self.selected_os {
os_id.clone()
} else {
Self::generate_folder_name(&self.vm_name)
};
self.folder_name = Self::find_available_folder_name(library_path, &base_name);
}
pub fn find_available_folder_name(library_path: &std::path::Path, base_name: &str) -> String {
let first_candidate = library_path.join(base_name);
if !first_candidate.exists() {
return base_name.to_string();
}
for suffix in 2..=1000 {
let candidate_name = format!("{}-{}", base_name, suffix);
let candidate_path = library_path.join(&candidate_name);
if !candidate_path.exists() {
return candidate_name;
}
}
format!("{}-error-too-many-vms", base_name)
}
pub fn apply_profile(&mut self, profile: &crate::metadata::QemuProfile) {
self.disk_size_gb = profile.disk_size_gb;
self.qemu_config = WizardQemuConfig::from_profile(profile);
}
pub fn can_proceed(&self) -> Result<(), String> {
match self.step {
WizardStep::SelectOs => {
if self.vm_name.trim().is_empty() {
return Err("Please enter a VM name".to_string());
}
if self.selected_os.is_none() && self.custom_os.is_none() {
return Err("Please select an operating system".to_string());
}
Ok(())
}
WizardStep::SelectIso => {
Ok(())
}
WizardStep::ConfigureDisk => {
if self.use_existing_disk {
match &self.existing_disk_path {
None => return Err("Please select an existing disk".to_string()),
Some(path) => {
if !path.exists() {
return Err(format!("Disk file not found: {}", path.display()));
}
}
}
} else {
if self.disk_size_gb == 0 {
return Err("Disk size must be greater than 0".to_string());
}
if self.disk_size_gb > 10000 {
return Err("Disk size cannot exceed 10TB".to_string());
}
}
Ok(())
}
WizardStep::ConfigureQemu => {
if self.qemu_config.memory_mb == 0 {
return Err("Memory must be greater than 0".to_string());
}
if self.qemu_config.cpu_cores == 0 {
return Err("CPU cores must be greater than 0".to_string());
}
Ok(())
}
WizardStep::Confirm => Ok(()),
}
}
pub fn toggle_category(&mut self, category: &str) {
if let Some(pos) = self.expanded_categories.iter().position(|c| c == category) {
self.expanded_categories.remove(pos);
} else {
self.expanded_categories.push(category.to_string());
}
}
pub fn is_category_expanded(&self, category: &str) -> bool {
self.expanded_categories.iter().any(|c| c == category)
}
}
#[derive(Debug, Clone)]
pub struct NetworkSettingsState {
pub model: String,
pub backend: String,
pub bridge_name: Option<String>,
pub port_forwards: Vec<PortForward>,
pub selected_field: usize,
pub editing_port_forwards: bool,
pub pf_selected: usize,
pub adding_pf: Option<AddingPortForward>,
}
#[derive(Debug, Clone)]
pub struct AddingPortForward {
pub step: AddPfStep,
pub protocol: PortProtocol,
pub host_port_input: String,
pub guest_port_input: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AddPfStep {
Protocol,
HostPort,
GuestPort,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ImportSource {
Libvirt,
Quickemu,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ImportDiskAction {
#[default]
Symlink,
Copy,
Move,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum ImportStep {
#[default]
SelectSource,
SelectVm,
CompatibilityWarnings,
ConfigureDisk,
ReviewAndImport,
}
#[derive(Debug, Clone)]
pub struct ImportableVm {
pub name: String,
pub config_path: PathBuf,
pub source: ImportSource,
pub qemu_config: WizardQemuConfig,
pub disk_paths: Vec<PathBuf>,
pub detected_os_profile: Option<String>,
pub import_notes: Vec<String>,
pub disks_readable: Vec<bool>,
}
#[derive(Debug, Clone)]
pub struct ImportWizardState {
pub step: ImportStep,
pub source: Option<ImportSource>,
pub discovered_vms: Vec<ImportableVm>,
pub selected_vm_index: usize,
pub selected_vm: Option<ImportableVm>,
pub vm_name: String,
pub folder_name: String,
pub disk_action: ImportDiskAction,
pub field_focus: usize,
pub error_message: Option<String>,
pub editing_name: bool,
pub warnings_acknowledged: bool,
}
impl Default for ImportWizardState {
fn default() -> Self {
Self {
step: ImportStep::SelectSource,
source: None,
discovered_vms: Vec::new(),
selected_vm_index: 0,
selected_vm: None,
vm_name: String::new(),
folder_name: String::new(),
disk_action: ImportDiskAction::Symlink,
field_focus: 0,
error_message: None,
editing_name: false,
warnings_acknowledged: false,
}
}
}
pub struct App {
pub screen: Screen,
pub screen_stack: Vec<Screen>,
pub config: Config,
pub vms: Vec<DiscoveredVm>,
pub selected_vm: usize,
pub metadata: MetadataStore,
pub ascii_art: AsciiArtStore,
pub hierarchy: HierarchyConfig,
pub snapshots: Vec<Snapshot>,
pub selected_snapshot: usize,
pub usb_devices: Vec<UsbDevice>,
pub selected_usb_devices: Vec<usize>,
pub pci_devices: Vec<PciDevice>,
pub selected_pci_devices: Vec<usize>,
pub shared_folders: Vec<SharedFolder>,
pub shared_folder_selected: usize,
pub multi_gpu_status: Option<MultiGpuPassthroughStatus>,
pub selected_menu_item: usize,
pub boot_mode: BootMode,
pub search_query: String,
pub input_mode: InputMode,
pub filtered_indices: Vec<usize>,
pub visual_order: Vec<usize>,
pub status_message: Option<String>,
pub status_time: Option<Instant>,
pub should_quit: bool,
pub file_browser_dir: PathBuf,
pub file_browser_entries: Vec<FileBrowserEntry>,
pub file_browser_selected: usize,
pub file_browser_mode: FileBrowserMode,
pub text_input_buffer: String,
pub background_rx: Receiver<BackgroundResult>,
pub background_tx: Sender<BackgroundResult>,
pub loading: bool,
pub error_detail: Option<String>,
pub error_scroll: u16,
pub info_scroll: u16,
pub raw_script_scroll: u16,
pub script_editor_lines: Vec<String>,
pub script_editor_cursor: (usize, usize),
pub script_editor_modified: bool,
pub script_editor_h_scroll: usize,
pub qemu_profiles: QemuProfileStore,
pub settings_help: SettingsHelpStore,
pub shared_folders_help: SharedFoldersHelpStore,
pub wizard_state: Option<CreateWizardState>,
pub import_state: Option<ImportWizardState>,
pub settings_selected: usize,
pub settings_editing: bool,
pub settings_edit_buffer: String,
pub settings_gpu_validation: Option<crate::ui::screens::settings::GpuValidationResult>,
pub display_capabilities: HashMap<String, Vec<String>>,
pub vm_status_rx: Receiver<Vec<QemuProcess>>,
pub running_vms: HashMap<String, u32>,
pub stopping_vms: HashMap<String, Instant>,
pub single_gpu_config: Option<SingleGpuConfig>,
pub single_gpu_selected_field: usize,
pub single_gpu_show_instructions: bool,
pub network_caps: NetworkCapabilities,
pub network_settings_state: Option<NetworkSettingsState>,
pub wizard_editing_port_forwards: bool,
pub wizard_pf_selected: usize,
pub wizard_adding_pf: Option<AddingPortForward>,
}
#[derive(Debug, Clone)]
pub struct FileBrowserEntry {
pub name: String,
pub path: PathBuf,
pub is_dir: bool,
}
pub enum BackgroundResult {
SnapshotCreated { name: String, success: bool, error: Option<String> },
SnapshotRestored { name: String, success: bool, error: Option<String> },
SnapshotDeleted { name: String, success: bool, error: Option<String> },
#[allow(dead_code)]
SnapshotsLoaded { snapshots: Vec<Snapshot>, error: Option<String> },
}
impl App {
pub fn new_with_progress<F>(config: Config, progress: F) -> Result<Self>
where
F: Fn(usize, usize, &str),
{
const TOTAL_STEPS: usize = 6;
progress(1, TOTAL_STEPS, "Discovering VMs...");
let vms = discover_vms(&config.vm_library_path)?;
progress(1, TOTAL_STEPS, &format!("Found {} VMs", vms.len()));
progress(2, TOTAL_STEPS, "Loading OS metadata...");
let mut metadata = MetadataStore::load_embedded();
if let Ok(user_metadata) = MetadataStore::load_from_dir(&config.metadata_path) {
metadata.merge(user_metadata);
}
progress(3, TOTAL_STEPS, "Loading ASCII art...");
let mut ascii_art = AsciiArtStore::load_embedded();
let user_art = AsciiArtStore::load_from_dir(&config.ascii_art_path);
ascii_art.merge(user_art);
progress(4, TOTAL_STEPS, "Loading hierarchy...");
let hierarchy = HierarchyConfig::load_embedded();
progress(5, TOTAL_STEPS, "Loading QEMU profiles...");
let mut qemu_profiles = QemuProfileStore::load_embedded();
let config_dir = Config::config_file_path()
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."));
let user_profiles_path = config_dir.join("qemu_profiles.toml");
qemu_profiles.load_user_overrides(&user_profiles_path);
let mut settings_help = SettingsHelpStore::load_embedded();
let user_help_path = config_dir.join("settings_help.toml");
settings_help.load_user_overrides(&user_help_path);
let mut shared_folders_help = SharedFoldersHelpStore::load_embedded();
shared_folders_help.load_user_overrides(&config_dir.join("shared_folders_help.toml"));
progress(6, TOTAL_STEPS, "Building VM list...");
let filtered_indices: Vec<usize> = (0..vms.len()).collect();
let visual_order = build_visual_order(&vms, &filtered_indices, &hierarchy, &metadata);
let (background_tx, background_rx) = mpsc::channel();
let network_caps = crate::commands::qemu_system::detect_network_capabilities();
let mut display_capabilities = HashMap::new();
for emulator in crate::commands::qemu_system::list_available_emulators() {
let displays = crate::commands::qemu_system::get_supported_displays(&emulator);
if !displays.is_empty() {
display_capabilities.insert(emulator, displays);
}
}
let (vm_status_tx, vm_status_rx) = mpsc::channel();
std::thread::spawn(move || {
loop {
std::thread::sleep(std::time::Duration::from_secs(3));
let processes = crate::vm::detect_qemu_processes();
if vm_status_tx.send(processes).is_err() {
break; }
}
});
Ok(Self {
screen: Screen::MainMenu,
screen_stack: Vec::new(),
config,
vms,
selected_vm: 0,
metadata,
ascii_art,
hierarchy,
snapshots: Vec::new(),
selected_snapshot: 0,
usb_devices: Vec::new(),
selected_usb_devices: Vec::new(),
pci_devices: Vec::new(),
selected_pci_devices: Vec::new(),
shared_folders: Vec::new(),
shared_folder_selected: 0,
multi_gpu_status: None,
selected_menu_item: 0,
boot_mode: BootMode::Normal,
search_query: String::new(),
input_mode: InputMode::Normal,
filtered_indices,
visual_order,
status_message: None,
status_time: None,
should_quit: false,
file_browser_dir: dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")),
file_browser_entries: Vec::new(),
file_browser_selected: 0,
file_browser_mode: FileBrowserMode::Iso,
text_input_buffer: String::new(),
background_rx,
background_tx,
loading: false,
error_detail: None,
error_scroll: 0,
info_scroll: 0,
raw_script_scroll: 0,
script_editor_lines: Vec::new(),
script_editor_cursor: (0, 0),
script_editor_modified: false,
script_editor_h_scroll: 0,
qemu_profiles,
settings_help,
shared_folders_help,
wizard_state: None,
import_state: None,
settings_selected: 0,
settings_editing: false,
settings_edit_buffer: String::new(),
settings_gpu_validation: None,
display_capabilities,
vm_status_rx,
running_vms: HashMap::new(),
stopping_vms: HashMap::new(),
single_gpu_config: None,
single_gpu_selected_field: 0,
single_gpu_show_instructions: false,
network_caps,
network_settings_state: None,
wizard_editing_port_forwards: false,
wizard_pf_selected: 0,
wizard_adding_pf: None,
})
}
pub fn get_display_options_for_emulator(&self, emulator: &str) -> Vec<String> {
let preferred_order = ["gtk", "sdl", "spice-app", "vnc", "none"];
if let Some(detected) = self.display_capabilities.get(emulator) {
let mut result = Vec::new();
for &pref in &preferred_order {
if detected.iter().any(|d| d == pref) {
result.push(pref.to_string());
}
}
for d in detected {
if !result.iter().any(|r| r == d) && d != "spice" {
result.push(d.clone());
}
}
if !result.is_empty() {
return result;
}
}
preferred_order.iter().map(|s| s.to_string()).collect()
}
pub fn get_network_backend_options(&self) -> Vec<(&str, &str)> {
let mut options = vec![
("user", "User/SLIRP (NAT) - Default, works everywhere"),
];
if self.network_caps.passt_available {
options.push(("passt", "passt - Fast NAT, ping works"));
}
if self.network_caps.bridge_helper_path.is_some() {
if !self.network_caps.system_bridges.is_empty() && self.network_caps.bridge_helper_configured {
options.push(("bridge", "Bridge - Full network, own IP"));
} else {
options.push(("bridge", "Bridge - Requires one-time setup"));
}
}
options.push(("none", "None - No networking"));
options
}
pub fn selected_vm(&self) -> Option<&DiscoveredVm> {
if self.visual_order.is_empty() {
return None;
}
let filtered_idx = self.visual_order.get(self.selected_vm)?;
let actual_idx = self.filtered_indices.get(*filtered_idx)?;
self.vms.get(*actual_idx)
}
pub fn selected_vm_info(&self) -> Option<OsInfo> {
let vm = self.selected_vm()?;
self.metadata
.get(&vm.id)
.cloned()
.or_else(|| Some(crate::metadata::default_os_info(&vm.id)))
}
pub fn selected_vm_ascii(&self) -> &str {
self.selected_vm()
.map(|vm| self.ascii_art.get_or_fallback(&vm.id))
.unwrap_or("")
}
pub fn push_screen(&mut self, screen: Screen) {
self.screen_stack.push(self.screen.clone());
self.screen = screen;
self.selected_menu_item = 0;
}
pub fn pop_screen(&mut self) {
if let Some(prev) = self.screen_stack.pop() {
self.screen = prev;
}
}
pub fn select_prev(&mut self) {
if !self.visual_order.is_empty() && self.selected_vm > 0 {
self.selected_vm -= 1;
self.info_scroll = 0; }
}
pub fn select_next(&mut self) {
if !self.visual_order.is_empty() && self.selected_vm < self.visual_order.len() - 1 {
self.selected_vm += 1;
self.info_scroll = 0; }
}
pub fn menu_prev(&mut self) {
if self.selected_menu_item > 0 {
self.selected_menu_item -= 1;
}
}
pub fn menu_next(&mut self, max_items: usize) {
if self.selected_menu_item < max_items.saturating_sub(1) {
self.selected_menu_item += 1;
}
}
pub fn update_filter(&mut self) {
if self.search_query.is_empty() {
self.filtered_indices = (0..self.vms.len()).collect();
} else {
let query = self.search_query.to_lowercase();
self.filtered_indices = self
.vms
.iter()
.enumerate()
.filter(|(_, vm)| {
vm.display_name().to_lowercase().contains(&query)
|| vm.id.to_lowercase().contains(&query)
})
.map(|(i, _)| i)
.collect();
}
self.visual_order = build_visual_order(&self.vms, &self.filtered_indices, &self.hierarchy, &self.metadata);
if self.selected_vm >= self.visual_order.len() {
self.selected_vm = self.visual_order.len().saturating_sub(1);
}
}
pub fn refresh_vms(&mut self) -> Result<()> {
self.vms = discover_vms(&self.config.vm_library_path)?;
self.update_filter();
Ok(())
}
pub fn load_snapshots(&mut self) -> Result<()> {
self.snapshots.clear();
self.selected_snapshot = 0;
if let Some(vm) = self.selected_vm() {
if let Some(disk) = vm.config.primary_disk() {
if disk.format.supports_snapshots() {
self.snapshots = crate::vm::list_snapshots(&disk.path)?;
}
}
}
Ok(())
}
pub fn load_usb_devices(&mut self) -> Result<()> {
self.usb_devices = crate::hardware::enumerate_usb_devices()?;
self.selected_usb_devices.clear();
Ok(())
}
pub fn toggle_usb_device(&mut self, index: usize) {
if let Some(pos) = self.selected_usb_devices.iter().position(|&i| i == index) {
self.selected_usb_devices.remove(pos);
} else {
self.selected_usb_devices.push(index);
}
}
pub fn load_pci_devices(&mut self) -> Result<()> {
self.pci_devices = crate::hardware::enumerate_pci_devices()?;
self.selected_pci_devices.clear();
self.multi_gpu_status = Some(crate::hardware::check_multi_gpu_passthrough_status());
Ok(())
}
pub fn load_shared_folders(&mut self) {
self.shared_folders.clear();
self.shared_folder_selected = 0;
if let Some(vm) = self.selected_vm() {
self.shared_folders = crate::vm::load_shared_folders(vm);
}
}
pub fn add_shared_folder(&mut self, host_path: String) {
if self.shared_folders.iter().any(|f| f.host_path == host_path) {
return;
}
let mut mount_tag = generate_mount_tag(&host_path);
let base_tag = mount_tag.clone();
let mut suffix = 2;
while self.shared_folders.iter().any(|f| f.mount_tag == mount_tag) {
mount_tag = format!("{}_{}", base_tag, suffix);
suffix += 1;
}
self.shared_folders.push(SharedFolder {
host_path,
mount_tag,
});
}
pub fn remove_shared_folder(&mut self) {
if !self.shared_folders.is_empty() && self.shared_folder_selected < self.shared_folders.len()
{
self.shared_folders.remove(self.shared_folder_selected);
if self.shared_folder_selected >= self.shared_folders.len()
&& self.shared_folder_selected > 0
{
self.shared_folder_selected -= 1;
}
}
}
pub fn toggle_pci_device(&mut self, index: usize) {
if let Some(device) = self.pci_devices.get(index) {
if device.is_boot_vga {
return;
}
}
if let Some(pos) = self.selected_pci_devices.iter().position(|&i| i == index) {
self.selected_pci_devices.remove(pos);
} else {
self.selected_pci_devices.push(index);
}
}
pub fn auto_select_gpu(&mut self, gpu_index: usize) {
self.selected_pci_devices.clear();
if let Some(gpu) = self.pci_devices.get(gpu_index) {
if gpu.is_boot_vga {
return;
}
self.selected_pci_devices.push(gpu_index);
if let Some(audio) = crate::hardware::find_gpu_audio_pair(gpu, &self.pci_devices) {
if let Some(audio_idx) = self.pci_devices.iter().position(|d| d.address == audio.address) {
self.selected_pci_devices.push(audio_idx);
}
}
}
}
pub fn reload_selected_vm_script(&mut self) {
if self.visual_order.is_empty() {
return;
}
if let Some(filtered_idx) = self.visual_order.get(self.selected_vm) {
if let Some(actual_idx) = self.filtered_indices.get(*filtered_idx) {
if let Some(vm) = self.vms.get_mut(*actual_idx) {
if let Ok(content) = std::fs::read_to_string(&vm.launch_script) {
vm.config.raw_script = content;
}
}
}
}
}
pub fn get_launch_options(&self) -> LaunchOptions {
let usb_devices = self
.selected_usb_devices
.iter()
.filter_map(|&i| self.usb_devices.get(i))
.map(|d| crate::vm::UsbPassthrough {
vendor_id: d.vendor_id,
product_id: d.product_id,
usb_version: d.usb_version,
})
.collect();
LaunchOptions {
boot_mode: self.boot_mode.clone(),
extra_args: Vec::new(),
usb_devices,
}
}
pub fn set_status(&mut self, msg: impl Into<String>) {
self.status_message = Some(msg.into());
self.status_time = Some(Instant::now());
}
pub fn show_error(&mut self, error: impl Into<String>) {
self.error_detail = Some(error.into());
self.error_scroll = 0;
self.push_screen(Screen::ErrorDialog);
}
pub fn clear_status(&mut self) {
self.status_message = None;
self.status_time = None;
}
pub fn check_status_expiry(&mut self) {
if let Some(time) = self.status_time {
if time.elapsed().as_secs() >= 5 {
self.clear_status();
}
}
}
pub fn check_background_results(&mut self) {
while let Ok(result) = self.background_rx.try_recv() {
self.loading = false;
match result {
BackgroundResult::SnapshotCreated { name, success, error } => {
if success {
self.set_status(format!("Created snapshot: {}", name));
let _ = self.load_snapshots();
} else if let Some(e) = error {
self.set_status(format!("Error creating snapshot: {}", e));
}
}
BackgroundResult::SnapshotRestored { name, success, error } => {
if success {
self.set_status(format!("Restored snapshot: {}", name));
} else if let Some(e) = error {
self.set_status(format!("Error restoring snapshot: {}", e));
}
}
BackgroundResult::SnapshotDeleted { name, success, error } => {
if success {
self.set_status(format!("Deleted snapshot: {}", name));
let _ = self.load_snapshots();
} else if let Some(e) = error {
self.set_status(format!("Error deleting snapshot: {}", e));
}
}
BackgroundResult::SnapshotsLoaded { snapshots, error } => {
if let Some(e) = error {
self.set_status(format!("Error loading snapshots: {}", e));
} else {
self.snapshots = snapshots;
self.selected_snapshot = 0;
}
}
}
}
}
pub fn check_vm_status(&mut self) {
let mut latest = None;
while let Ok(processes) = self.vm_status_rx.try_recv() {
latest = Some(processes);
}
if let Some(processes) = latest {
self.running_vms = self.match_running_vms(&processes);
self.stopping_vms.retain(|id, _| self.running_vms.contains_key(id));
}
}
fn match_running_vms(&self, processes: &[QemuProcess]) -> HashMap<String, u32> {
let mut result = HashMap::new();
for vm in &self.vms {
for proc in processes {
if let Some(ref cwd) = proc.cwd {
if cwd == &vm.path {
result.insert(vm.id.clone(), proc.pid);
break;
}
} else {
if let Some(disk) = vm.config.primary_disk() {
if let Some(disk_path_str) = disk.path.to_str() {
if !disk_path_str.is_empty() && proc.cmdline.contains(disk_path_str) {
result.insert(vm.id.clone(), proc.pid);
break;
}
}
}
}
}
}
result
}
pub fn selected_vm_pid(&self) -> Option<u32> {
let vm = self.selected_vm()?;
self.running_vms.get(&vm.id).copied()
}
pub fn load_file_browser(&mut self, mode: FileBrowserMode) {
self.file_browser_mode = mode;
self.file_browser_entries.clear();
self.file_browser_selected = 0;
let extensions: &[&str] = match mode {
FileBrowserMode::Iso => &[".iso", ".ISO"],
FileBrowserMode::RecoveryImage => &[".dmg", ".DMG", ".qcow2", ".QCOW2"],
FileBrowserMode::Disk => &[".qcow2", ".QCOW2", ".qcow", ".QCOW"],
FileBrowserMode::Directory => &[],
FileBrowserMode::ImportConfig => &[".xml", ".XML", ".conf"],
FileBrowserMode::Bios => &[".bin", ".BIN", ".rom", ".ROM", ".qcow2", ".QCOW2", ".fd", ".FD"],
FileBrowserMode::Floppy => &[".img", ".IMG", ".ima", ".IMA", ".flp", ".FLP", ".vfd", ".VFD"],
};
if mode == FileBrowserMode::Directory {
self.file_browser_entries.push(FileBrowserEntry {
name: "[Select This Directory]".to_string(),
path: self.file_browser_dir.clone(),
is_dir: false, });
}
if let Some(parent) = self.file_browser_dir.parent() {
self.file_browser_entries.push(FileBrowserEntry {
name: "..".to_string(),
path: parent.to_path_buf(),
is_dir: true,
});
}
if let Ok(entries) = std::fs::read_dir(&self.file_browser_dir) {
let mut dirs = Vec::new();
let mut files = Vec::new();
for entry in entries.flatten() {
if let Ok(metadata) = entry.metadata() {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
let entry = FileBrowserEntry {
name,
path: entry.path(),
is_dir: metadata.is_dir(),
};
if metadata.is_dir() {
dirs.push(entry);
} else if mode != FileBrowserMode::Directory
&& extensions.iter().any(|ext| entry.name.ends_with(ext))
{
files.push(entry);
}
}
}
dirs.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
files.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
self.file_browser_entries.extend(dirs);
self.file_browser_entries.extend(files);
}
}
pub fn file_browser_enter(&mut self) -> Option<PathBuf> {
if let Some(entry) = self.file_browser_entries.get(self.file_browser_selected) {
if entry.is_dir {
self.file_browser_dir = entry.path.clone();
let mode = self.file_browser_mode;
self.load_file_browser(mode);
None
} else {
Some(entry.path.clone())
}
} else {
None
}
}
pub fn file_browser_prev(&mut self) {
if self.file_browser_selected > 0 {
self.file_browser_selected -= 1;
}
}
pub fn file_browser_next(&mut self) {
if self.file_browser_selected < self.file_browser_entries.len().saturating_sub(1) {
self.file_browser_selected += 1;
}
}
pub fn load_script_into_editor(&mut self) {
if let Some(vm) = self.selected_vm() {
self.script_editor_lines = vm.config.raw_script.lines().map(String::from).collect();
if self.script_editor_lines.is_empty() {
self.script_editor_lines.push(String::new());
}
self.script_editor_cursor = (0, 0);
self.script_editor_modified = false;
self.script_editor_h_scroll = 0;
self.raw_script_scroll = 0;
}
}
pub fn save_script_from_editor(&mut self) -> Result<()> {
let launch_script_path = self.selected_vm()
.map(|vm| vm.launch_script.clone())
.ok_or_else(|| anyhow::anyhow!("No VM selected"))?;
let content = self.script_editor_lines.join("\n");
let content = if content.ends_with('\n') {
content
} else {
format!("{}\n", content)
};
std::fs::write(&launch_script_path, &content)?;
self.reload_selected_vm_script();
self.script_editor_modified = false;
if let Ok(vms) = discover_vms(&self.config.vm_library_path) {
self.vms = vms;
self.update_filter();
}
if let Some(vm) = self.selected_vm() {
if crate::hardware::scripts_exist(&vm.path) {
let _ = if let Some(config) = self.single_gpu_config.as_ref() {
crate::vm::single_gpu_scripts::regenerate_if_exists(vm, config)
} else {
crate::vm::single_gpu_scripts::regenerate_from_saved_config(vm)
};
}
}
Ok(())
}
pub fn load_notes_into_editor(&mut self) {
if let Some(vm) = self.selected_vm() {
let notes_text = vm.notes.as_deref().unwrap_or("");
self.script_editor_lines = notes_text.lines().map(String::from).collect();
if self.script_editor_lines.is_empty() {
self.script_editor_lines.push(String::new());
}
self.script_editor_cursor = (0, 0);
self.script_editor_modified = false;
self.script_editor_h_scroll = 0;
self.raw_script_scroll = 0;
}
}
pub fn save_notes_from_editor(&mut self) -> Result<()> {
let vm = self.selected_vm()
.ok_or_else(|| anyhow::anyhow!("No VM selected"))?;
let vm_path = vm.path.clone();
let display_name = vm.display_name();
let os_profile = vm.os_profile.clone();
let notes_text = self.script_editor_lines.join("\n");
let notes_text = notes_text.trim_end().to_string();
let notes = if notes_text.is_empty() { None } else { Some(notes_text.as_str()) };
crate::vm::create::write_vm_metadata(
&vm_path,
&display_name,
os_profile.as_deref(),
notes,
)?;
if let Some(filtered_idx) = self.visual_order.get(self.selected_vm) {
if let Some(actual_idx) = self.filtered_indices.get(*filtered_idx) {
if let Some(vm) = self.vms.get_mut(*actual_idx) {
vm.notes = notes.map(String::from);
}
}
}
self.script_editor_modified = false;
Ok(())
}
pub fn start_create_wizard(&mut self) {
let state = CreateWizardState {
disk_size_gb: self.config.default_disk_size_gb,
qemu_config: WizardQemuConfig {
memory_mb: self.config.default_memory_mb,
cpu_cores: self.config.default_cpu_cores,
enable_kvm: self.config.default_enable_kvm,
display: self.config.default_display.clone(),
..WizardQemuConfig::default()
},
..CreateWizardState::default()
};
self.wizard_state = Some(state);
self.push_screen(Screen::CreateWizard);
}
pub fn cancel_wizard(&mut self) {
self.wizard_state = None;
while matches!(
self.screen,
Screen::CreateWizard | Screen::CreateWizardCustomOs | Screen::CreateWizardDownload
) {
self.pop_screen();
}
}
pub fn wizard_next_step(&mut self) -> Result<(), String> {
if let Some(ref mut state) = self.wizard_state {
state.can_proceed()?;
if let Some(next) = state.step.next() {
state.step = next;
state.field_focus = 0;
state.error_message = None;
Ok(())
} else {
Err("Already at final step".to_string())
}
} else {
Err("Wizard not active".to_string())
}
}
pub fn wizard_prev_step(&mut self) {
if let Some(ref mut state) = self.wizard_state {
if let Some(prev) = state.step.prev() {
state.step = prev;
state.field_focus = 0;
state.error_message = None;
}
}
}
pub fn wizard_select_os(&mut self, os_id: &str) {
let library_path = self.config.vm_library_path.clone();
let new_display_name = self.metadata.get(os_id)
.and_then(|info| info.display_name.clone())
.or_else(|| self.qemu_profiles.get(os_id).map(|p| p.display_name.clone()))
.unwrap_or_else(|| os_id.to_string());
let previous_default_name = self.wizard_state.as_ref()
.and_then(|s| s.selected_os.as_ref())
.and_then(|prev_id| {
self.metadata.get(prev_id)
.and_then(|info| info.display_name.clone())
.or_else(|| self.qemu_profiles.get(prev_id).map(|p| p.display_name.clone()))
});
if let Some(ref mut state) = self.wizard_state {
state.selected_os = Some(os_id.to_string());
state.custom_os = None;
if let Some(profile) = self.qemu_profiles.get(os_id) {
state.apply_profile(profile);
let should_update_name = state.vm_name.is_empty()
|| previous_default_name.as_ref().map(|n| n == &state.vm_name).unwrap_or(false);
if should_update_name {
state.vm_name = new_display_name;
}
state.update_folder_name(&library_path);
}
}
}
pub fn wizard_use_custom_os(&mut self) {
if let Some(ref mut state) = self.wizard_state {
state.selected_os = None;
state.custom_os = Some(CustomOsEntry {
base_profile: "generic-other".to_string(),
architecture: "x86_64".to_string(),
..Default::default()
});
self.push_screen(Screen::CreateWizardCustomOs);
}
}
pub fn wizard_vm_path(&self) -> Option<PathBuf> {
self.wizard_state
.as_ref()
.filter(|s| !s.folder_name.is_empty())
.map(|s| self.config.vm_library_path.join(&s.folder_name))
}
pub fn start_import_wizard(&mut self) {
self.import_state = Some(ImportWizardState::default());
self.push_screen(Screen::ImportWizard);
}
pub fn cancel_import_wizard(&mut self) {
self.import_state = None;
while self.screen == Screen::ImportWizard {
self.pop_screen();
}
}
pub fn wizard_selected_profile(&self) -> Option<&crate::metadata::QemuProfile> {
self.wizard_state
.as_ref()
.and_then(|s| s.selected_os.as_ref())
.and_then(|os_id| self.qemu_profiles.get(os_id))
}
}
fn generate_mount_tag(path: &str) -> String {
let folder_name = std::path::Path::new(path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("shared");
let sanitized: String = folder_name
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.collect();
let tag = sanitized
.split('_')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("_");
format!(
"host_{}",
if tag.is_empty() {
"shared".to_string()
} else {
tag
}
)
}