diff --git a/src/app.rs b/src/app.rs index f19fa52..4a5ce23 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2254,29 +2254,47 @@ fn extract_base_image_zip( let _ = fs::remove_dir_all(&extract_dir); fs::create_dir_all(&extract_dir)?; - let tar_status = Command::new("tar.exe") - .arg("-xf") - .arg(archive) - .arg("-C") - .arg(&extract_dir) - .status(); - - let extracted = match tar_status { - Ok(status) if status.success() => true, - _ => { - let script = "Expand-Archive -LiteralPath $args[0] -DestinationPath $args[1] -Force"; - let status = Command::new("powershell.exe") - .args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script]) - .arg(archive) - .arg(&extract_dir) - .status() - .map_err(|error| { - AppError::message(format!( - "Could not extract the base image archive with tar.exe or PowerShell: {error}" - )) - })?; - status.success() + // Windows' bundled tar.exe (bsdtar) and GNU tar both fail silently on a .zip; the archive + // here is always a zip (extract_base_image_zip is only called for BaseImagePayloadKind::Zip), + // so unzip it directly rather than going through tar at all. + let extracted = if cfg!(windows) { + let status = Command::new("tar.exe") + .arg("-xf") + .arg(archive) + .arg("-C") + .arg(&extract_dir) + .status(); + match status { + Ok(status) if status.success() => true, + _ => { + let script = + "Expand-Archive -LiteralPath $args[0] -DestinationPath $args[1] -Force"; + let status = Command::new("powershell.exe") + .args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script]) + .arg(archive) + .arg(&extract_dir) + .status() + .map_err(|error| { + AppError::message(format!( + "Could not extract the base image archive with tar.exe or PowerShell: {error}" + )) + })?; + status.success() + } } + } else { + Command::new("unzip") + .args(["-o", "-q"]) + .arg(archive) + .arg("-d") + .arg(&extract_dir) + .status() + .map_err(|error| { + AppError::message(format!( + "Could not run unzip to extract the base image archive: {error}" + )) + })? + .success() }; if !extracted { @@ -2313,10 +2331,25 @@ fn register_base_image_payload( /// Ensure a base OS image is registered for the QEMU engine, downloading it on first run /// (curl, SHA-verified, then registered) when a hosting URL is configured. When no URL is /// set, give an actionable import instruction instead of a cryptic failure. -fn ensure_base_image(runtime_paths: &RuntimePaths) -> AppResult<()> { +/// +/// The bundled-image and download-on-first-run conveniences are Arch-only (they point at +/// Pane's official `arch-base.paneimg[.zip]` artifact) — for any other family, downloading +/// would silently register the wrong distro's image under this session's filename, so those +/// families always require manual `--register-base-image`. +fn ensure_base_image( + runtime_paths: &RuntimePaths, + family: crate::model::DistroFamily, +) -> AppResult<()> { if runtime_paths.base_os_image.exists() { return Ok(()); } + if family != crate::model::DistroFamily::Arch { + return Err(AppError::message(format!( + "No base OS image is registered for this session. Pane expected it at {}. Import one with `pane runtime --prepare --session-name --family {} --register-base-image --expected-sha256 --require-native-root-disk`.", + runtime_paths.base_os_image.display(), + family.slug() + ))); + } for candidate in bundled_base_image_candidates() { if candidate.is_file() { println!( @@ -2349,7 +2382,7 @@ fn ensure_base_image(runtime_paths: &RuntimePaths) -> AppResult<()> { fs::create_dir_all(parent)?; } println!("Downloading the Pane base OS image from {url} (one-time first-run fetch)..."); - let status = std::process::Command::new("curl.exe") + let status = std::process::Command::new("curl") .args(["-L", "--fail", "-o", &download.display().to_string(), &url]) .status() .map_err(|error| { @@ -2372,11 +2405,11 @@ fn launch_qemu_whpx_runtime(args: LaunchArgs) -> AppResult<()> { let session_name = crate::plan::sanitize_session_name(&args.session_name); // Ensure the runtime boundary (dirs, contracts, user-disk descriptor) exists. prepare_native_runtime_boundary(&session_name, DEFAULT_RUNTIME_CAPACITY_GIB)?; - let runtime_paths = crate::plan::runtime_for(&session_name); + let runtime_paths = crate::plan::runtime_for_family(&session_name, args.family); // Preflight so a single command works on a fresh machine: install QEMU if missing, and // make sure a base image is registered (the kernel + initramfs are derived from it). crate::qemu::ensure_qemu_available().map_err(AppError::message)?; - ensure_base_image(&runtime_paths)?; + ensure_base_image(&runtime_paths, args.family)?; let serial_path = runtime_paths.logs.join("qemu-whpx.serial"); let config = build_qemu_engine_config( &runtime_paths, @@ -2390,7 +2423,10 @@ fn launch_qemu_whpx_runtime(args: LaunchArgs) -> AppResult<()> { println!("Pane Launch — {}", args.runtime.display_name()); println!(" Session {session_name}"); - println!(" Engine qemu-system-x86_64 -accel whpx"); + println!( + " Engine qemu-system-x86_64 -accel {}", + if cfg!(windows) { "whpx" } else { "kvm" } + ); println!(" Kernel {}", config.kernel.display()); println!(" Initramfs {}", config.initramfs.display()); println!(" Root disk {}", config.base_disk.display()); @@ -2726,7 +2762,7 @@ fn app_status(args: AppStatusArgs) -> AppResult<()> { fn runtime(args: RuntimeArgs) -> AppResult<()> { let session_name = crate::plan::sanitize_session_name(&args.session_name); - let paths = crate::plan::runtime_for(&session_name); + let paths = crate::plan::runtime_for_family(&session_name, args.family); let budget = runtime_storage_budget(args.capacity_gib); let registering_native_boot_set = args.register_native_boot_set || args.register_native_boot_set_manifest.is_some(); @@ -3003,6 +3039,7 @@ fn base_image_partition_offset(runtime_paths: &crate::plan::RuntimePaths) -> u64 fn extract_from_base_image( runtime_paths: &crate::plan::RuntimePaths, guest_path: &str, + fallback_prefix_suffix: (&str, &str), cache: &Path, label: &str, ) -> AppResult { @@ -3015,12 +3052,27 @@ fn extract_from_base_image( ))); } let offset = base_image_partition_offset(runtime_paths); - let data = crate::ext4::extract_file(&runtime_paths.base_os_image, offset, guest_path) - .map_err(|error| { - AppError::message(format!( - "Could not extract the {label} ({guest_path}) from the base image: {error}.", - )) - })?; + // Arch ships a fixed filename (vmlinuz-linux / initramfs-linux.img); other distros + // (e.g. Void) version their kernel/initramfs by build, so fall back to a /boot listing + // matched by prefix+suffix when the exact name isn't there. + let data = match crate::ext4::extract_file(&runtime_paths.base_os_image, offset, guest_path) { + Ok(data) => data, + Err(exact_error) => { + let (prefix, suffix) = fallback_prefix_suffix; + crate::ext4::extract_file_by_name( + &runtime_paths.base_os_image, + offset, + "/boot", + prefix, + suffix, + ) + .map_err(|fallback_error| { + AppError::message(format!( + "Could not extract the {label} from the base image: tried {guest_path} ({exact_error}) and /boot/{prefix}*{suffix} ({fallback_error}).", + )) + })? + } + }; if let Some(parent) = cache.parent() { fs::create_dir_all(parent)?; } @@ -3044,6 +3096,7 @@ fn resolve_distro_initramfs( extract_from_base_image( runtime_paths, "/boot/initramfs-linux.img", + ("initramfs-", ".img"), &cache, "distro initramfs", ) @@ -3059,6 +3112,7 @@ fn resolve_distro_kernel(runtime_paths: &crate::plan::RuntimePaths) -> AppResult extract_from_base_image( runtime_paths, "/boot/vmlinuz-linux", + ("vmlinuz-", ""), &cache, "distro kernel", ) @@ -3195,9 +3249,9 @@ fn valid_username(name: &str) -> bool { fn provision(args: ProvisionArgs) -> AppResult<()> { let session_name = crate::plan::sanitize_session_name(&args.session_name); prepare_native_runtime_boundary(&session_name, DEFAULT_RUNTIME_CAPACITY_GIB)?; - let runtime_paths = crate::plan::runtime_for(&session_name); + let runtime_paths = crate::plan::runtime_for_family(&session_name, args.family); crate::qemu::ensure_qemu_available().map_err(AppError::message)?; - ensure_base_image(&runtime_paths)?; + ensure_base_image(&runtime_paths, args.family)?; if let Some(name) = args.username.as_deref() { if !valid_username(name) { @@ -3231,12 +3285,22 @@ fn provision(args: ProvisionArgs) -> AppResult<()> { // Build root-shell commands. Passwords are generated from a shell-safe charset, so // single-quoting in the `chpasswd` here is sufficient. - let mut commands = vec![format!("echo 'root:{root_password}' | chpasswd")]; + let shell = if args.family == crate::model::DistroFamily::Void { + "/bin/bash" + } else { + "/usr/bin/bash" + }; + // -c SHA512 is required: without an explicit crypt method, chpasswd silently no-ops + // (exit 0, /etc/shadow unchanged) on images with no /etc/login.defs ENCRYPT_METHOD + // default (e.g. Void's base-minimal) instead of falling back to a sane default. + let mut commands = vec![format!( + "echo 'root:{root_password}' | chpasswd -c SHA512" + )]; if let (Some(name), Some(password)) = (args.username.as_deref(), user_password.as_deref()) { commands.push(format!( - "useradd -m -G wheel -s /usr/bin/bash {name} 2>/dev/null || true" + "useradd -m -G wheel -s {shell} {name} 2>/dev/null || true" )); - commands.push(format!("echo '{name}:{password}' | chpasswd")); + commands.push(format!("echo '{name}:{password}' | chpasswd -c SHA512")); commands.push("install -d -m 755 /etc/sudoers.d".to_string()); commands.push( "printf '%%wheel ALL=(ALL:ALL) ALL\\n' > /etc/sudoers.d/wheel && chmod 440 /etc/sudoers.d/wheel" @@ -3305,45 +3369,29 @@ fn workspace(args: WorkspaceArgs) -> AppResult<()> { Ok(()) } -fn install_desktop(args: InstallDesktopArgs) -> AppResult<()> { - let session_name = crate::plan::sanitize_session_name(&args.session_name); - prepare_native_runtime_boundary(&session_name, DEFAULT_RUNTIME_CAPACITY_GIB)?; - let runtime_paths = crate::plan::runtime_for(&session_name); - crate::qemu::ensure_qemu_available().map_err(AppError::message)?; - ensure_base_image(&runtime_paths)?; - - let serial_path = runtime_paths.logs.join("qemu-install-desktop.serial"); - // Persist to the root overlay so the installed desktop survives reboots. - let config = build_qemu_engine_config( - &runtime_paths, - None, - serial_path.clone(), - std::time::Duration::from_secs(180), - crate::model::DisplayMode::Serial, - true, - QemuLaunchTuning::default(), - )?; - - // Grow the root overlay so a heavier desktop fits (the partition + fs are extended in - // the guest below). qemu-img resize only grows. - let disk_gib = args.disk_gib.unwrap_or_else(|| args.de.default_disk_gib()); - if let Some(overlay) = config.root_overlay.as_ref() { - crate::qemu::resize_qcow2(overlay, disk_gib).map_err(AppError::message)?; - } +/// Shared disk-resize commands: extend partition 1 + the ext4 root onto the enlarged disk +/// (best effort; the root is mounted, so update the kernel's partition view then resize +/// online). Identical across distro families — filesystem-level, not package-manager-level. +fn resize_root_disk_commands() -> Vec { + vec![ + "echo ', +' | sfdisk --no-reread --force -N 1 /dev/vda || true".to_string(), + "partx -u /dev/vda 2>/dev/null || partprobe /dev/vda 2>/dev/null || true".to_string(), + "resize2fs /dev/vda1 || true".to_string(), + ] +} - let packages = args.de.packages(); +/// pacman-based provisioning commands (Arch): mirror + DHCP networking, keyring bootstrap, +/// package install, display-manager enablement via systemd. +fn arch_install_desktop_commands(args: &InstallDesktopArgs, packages: &str) -> Vec { let display_manager = args.de.display_manager(); let mut commands: Vec = vec![ - format!("echo PANE_DESKTOP_REQUEST de={:?} disk_gib={disk_gib}", args.de), "rm -f /var/lib/pacman/db.lck".to_string(), // Recover from interrupted/partial sync downloads. A corrupt core.db previously // made the GUI look like GNOME/KDE were installing while pacman had already failed. "rm -f /var/lib/pacman/sync/*.db /var/lib/pacman/sync/*.db.sig".to_string(), - // Extend partition 1 + the ext4 root to use the enlarged disk (best effort; the - // root is mounted, so update the kernel's partition view then resize online). - "echo ', +' | sfdisk --no-reread --force -N 1 /dev/vda || true".to_string(), - "partx -u /dev/vda 2>/dev/null || partprobe /dev/vda 2>/dev/null || true".to_string(), - "resize2fs /dev/vda1 || true".to_string(), + ]; + commands.extend(resize_root_disk_commands()); + commands.extend([ // Guaranteed-good mirror + DHCP networking so pacman can reach the repositories. "echo 'Server = https://geo.mirror.pkgbuild.com/$repo/os/$arch' > /etc/pacman.d/mirrorlist".to_string(), "printf '[Match]\\nName=en* eth*\\n\\n[Network]\\nDHCP=yes\\n' > /etc/systemd/network/20-pane-dhcp.network".to_string(), @@ -3357,7 +3405,7 @@ fn install_desktop(args: InstallDesktopArgs) -> AppResult<()> { "pacman -Syy --noconfirm --needed archlinux-keyring".to_string(), // Desktop environment + display manager + browser (Firefox) + NetworkManager. format!("pacman -S --noconfirm --needed {packages}"), - ]; + ]); if args.de == crate::model::DesktopChoice::Gnome { commands.extend([ // Prefer GNOME's normal Wayland session. Keeping custom.conf absent avoids @@ -3379,6 +3427,84 @@ fn install_desktop(args: InstallDesktopArgs) -> AppResult<()> { // Confirm the desktop is wired in the boot transcript. "echo PANE_DM_STATE enabled=$(systemctl is-enabled display-manager 2>/dev/null) default=$(systemctl get-default) dm=$(readlink -f /etc/systemd/system/display-manager.service 2>/dev/null)".to_string(), ]); + commands +} + +/// xbps-based provisioning commands (Void): dhcpcd networking (Void's default), xbps +/// self-update + sync, package install, display-manager enablement via runit symlinks +/// into /var/service (Void has no systemd targets/units). +fn void_install_desktop_commands(args: &InstallDesktopArgs, packages: &str) -> Vec { + let display_manager = args.de.display_manager(); + let mut commands: Vec = resize_root_disk_commands(); + commands.extend([ + // dhcpcd ships enabled by default on Void's base image; make sure it's actually up + // before xbps needs the network. + "ln -sf /etc/sv/dhcpcd /var/service/ 2>/dev/null || true".to_string(), + "for i in $(seq 1 60); do getent hosts repo-default.voidlinux.org >/dev/null 2>&1 && break; sleep 2; done".to_string(), + // xbps must update itself before a normal sync reliably picks up newer repodata. + "xbps-install -Suy xbps".to_string(), + "xbps-install -Suy".to_string(), + format!("xbps-install -Sy {packages}"), + ]); + if args.de == crate::model::DesktopChoice::Gnome { + commands.extend([ + "install -d -m 755 /etc/gdm".to_string(), + "rm -f /etc/gdm/custom.conf".to_string(), + ]); + } + commands.extend([ + // runit has no "disable" step for a service that was never symlinked into + // /var/service; only the chosen display manager + NetworkManager get enabled. + // dbus is a hard prerequisite for lightdm/gdm/sddm (D-Bus system bus) -- without + // it the display manager crash-loops with "Failed to get D-Bus connection". + "ln -sf /etc/sv/dbus /var/service/ 2>/dev/null || true".to_string(), + format!("ln -sf /etc/sv/{display_manager} /var/service/"), + "ln -sf /etc/sv/NetworkManager /var/service/ 2>/dev/null || true".to_string(), + // Confirm the desktop is wired in the boot transcript. + format!( + "echo PANE_DM_STATE enabled=$(test -L /var/service/{display_manager} && echo yes || echo no) dm={display_manager}" + ), + ]); + commands +} + +fn install_desktop(args: InstallDesktopArgs) -> AppResult<()> { + let session_name = crate::plan::sanitize_session_name(&args.session_name); + prepare_native_runtime_boundary(&session_name, DEFAULT_RUNTIME_CAPACITY_GIB)?; + let runtime_paths = crate::plan::runtime_for_family(&session_name, args.family); + crate::qemu::ensure_qemu_available().map_err(AppError::message)?; + ensure_base_image(&runtime_paths, args.family)?; + + let serial_path = runtime_paths.logs.join("qemu-install-desktop.serial"); + // Persist to the root overlay so the installed desktop survives reboots. + let config = build_qemu_engine_config( + &runtime_paths, + None, + serial_path.clone(), + std::time::Duration::from_secs(180), + crate::model::DisplayMode::Serial, + true, + QemuLaunchTuning::default(), + )?; + + // Grow the root overlay so a heavier desktop fits (the partition + fs are extended in + // the guest below). qemu-img resize only grows. + let disk_gib = args.disk_gib.unwrap_or_else(|| args.de.default_disk_gib()); + if let Some(overlay) = config.root_overlay.as_ref() { + crate::qemu::resize_qcow2(overlay, disk_gib).map_err(AppError::message)?; + } + + let packages = args.de.packages(args.family); + let display_manager = args.de.display_manager(); + let mut commands: Vec = vec![format!( + "echo PANE_DESKTOP_REQUEST de={:?} family={} disk_gib={disk_gib}", + args.de, + args.family.slug() + )]; + commands.extend(match args.family { + crate::model::DistroFamily::Void => void_install_desktop_commands(&args, packages), + _ => arch_install_desktop_commands(&args, packages), + }); println!( "Installing the {:?} desktop (+ Firefox) into the guest image (persisted, root grown to {disk_gib} GiB).", @@ -15293,6 +15419,7 @@ mod tests { fn default_runtime_args() -> RuntimeArgs { RuntimeArgs { session_name: "pane".to_string(), + family: crate::model::DistroFamily::Arch, capacity_gib: DEFAULT_RUNTIME_CAPACITY_GIB, prepare: false, register_base_image: None, @@ -18928,11 +19055,13 @@ mod tests { #[test] fn environment_catalog_report_reflects_first_three_managed_environments() { let report = build_environment_catalog_report(); - assert_eq!(report.environments.len(), 3); + assert_eq!(report.environments.len(), 4); assert_eq!(report.environments[0].id, "arch"); assert!(report.environments[0].launchable_now); - assert_eq!(report.environments[1].id, "ubuntu-lts"); - assert_eq!(report.environments[2].id, "debian"); + assert_eq!(report.environments[1].id, "void"); + assert!(report.environments[1].launchable_now); + assert_eq!(report.environments[2].id, "ubuntu-lts"); + assert_eq!(report.environments[3].id, "debian"); assert!(report.notes.iter().any(|note| note.contains("Kali"))); } diff --git a/src/cli.rs b/src/cli.rs index dbb2445..e00beb5 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -3,7 +3,10 @@ use std::path::PathBuf; use clap::{Args, Parser, Subcommand}; use crate::{ - model::{DesktopChoice, DesktopEnvironment, DisplayMode, RuntimeMode, SharedStorageMode}, + model::{ + DesktopChoice, DesktopEnvironment, DisplayMode, DistroFamily, RuntimeMode, + SharedStorageMode, + }, plan::DEFAULT_RUNTIME_CAPACITY_GIB, }; @@ -145,6 +148,10 @@ pub struct LaunchArgs { /// WSL distro name. Defaults to the Pane-managed Arch distro. Pass this explicitly only to override. #[arg(long)] pub distro: Option, + /// For --runtime qemu-whpx: managed environment family whose registered base image to + /// boot (arch, void, ...). + #[arg(long, value_enum, default_value_t = DistroFamily::Arch)] + pub family: DistroFamily, /// Runtime backend to use. wsl-bridge is current; pane-owned is the native runtime preflight path. #[arg(long, value_enum, default_value_t = RuntimeMode::Auto)] pub runtime: RuntimeMode, @@ -276,6 +283,10 @@ pub struct RuntimeArgs { /// Session slug for the Pane-owned runtime reservation. #[arg(long, default_value = "pane")] pub session_name: String, + /// Managed environment family this runtime reservation (and any --register-base-image + /// call) targets. Determines the registered base image's filename. + #[arg(long, value_enum, default_value_t = DistroFamily::Arch)] + pub family: DistroFamily, /// Target dedicated runtime capacity in GiB for OS image, packages, user data, and snapshots. #[arg(long, default_value_t = DEFAULT_RUNTIME_CAPACITY_GIB)] pub capacity_gib: u64, @@ -407,6 +418,9 @@ pub struct ProvisionArgs { /// Session slug for the Pane-owned runtime reservation. #[arg(long, default_value = "pane")] pub session_name: String, + /// Managed environment family whose registered base image is being provisioned. + #[arg(long, value_enum, default_value_t = DistroFamily::Arch)] + pub family: DistroFamily, /// Root password to set. If omitted, Pane generates a strong one and prints it. #[arg(long)] pub root_password: Option, @@ -439,6 +453,9 @@ pub struct InstallDesktopArgs { /// Session slug for the Pane-owned runtime reservation. #[arg(long, default_value = "pane")] pub session_name: String, + /// Managed environment family whose registered base image is being provisioned. + #[arg(long, value_enum, default_value_t = DistroFamily::Arch)] + pub family: DistroFamily, /// Desktop environment to install (xfce, gnome, or kde). Includes a browser (Firefox). #[arg(long, value_enum, default_value_t = DesktopChoice::Xfce)] pub de: DesktopChoice, diff --git a/src/ext4.rs b/src/ext4.rs index c365ee2..5a1fbff 100644 --- a/src/ext4.rs +++ b/src/ext4.rs @@ -164,10 +164,20 @@ impl Ext4Reader { /// Find a child entry's inode number within a directory inode by name. fn lookup_in_dir(&mut self, dir_inode: &[u8], name: &str) -> Result, String> { + self.dir_entry_names(dir_inode)? + .into_iter() + .find(|(entry_name, _)| entry_name == name) + .map(|(_, ino)| Ok(ino)) + .transpose() + } + + /// List (name, inode) pairs of every entry in a directory inode, skipping `.`/`..`. + fn dir_entry_names(&mut self, dir_inode: &[u8]) -> Result, String> { let flags = rd_u32(dir_inode, 32); if flags & EXT4_EXTENTS_FL == 0 { return Err("directory inode is not extent-mapped (unsupported)".into()); } + let mut entries = Vec::new(); let runs = self.extent_runs(&dir_inode[40..40 + 60])?; for (start, len) in runs { for b in 0..len { @@ -182,15 +192,17 @@ impl Ext4Reader { } if child != 0 && name_len > 0 && pos + 8 + name_len <= block.len() { let entry_name = &block[pos + 8..pos + 8 + name_len]; - if entry_name == name.as_bytes() { - return Ok(Some(child)); + if entry_name != b"." && entry_name != b".." { + if let Ok(name) = std::str::from_utf8(entry_name) { + entries.push((name.to_string(), child)); + } } } pos += rec_len; } } } - Ok(None) + Ok(entries) } } @@ -212,6 +224,45 @@ pub fn extract_file(image: &Path, part_offset: u64, path: &str) -> Result` +/// versus Arch's fixed `vmlinuz-linux`, for example) need this instead of an exact +/// [`extract_file`] path. +pub fn extract_file_by_name( + image: &Path, + part_offset: u64, + dir_path: &str, + prefix: &str, + suffix: &str, +) -> Result, String> { + let mut reader = Ext4Reader::open(image, part_offset)?; + let mut current = reader.read_inode(ROOT_INODE)?; + for component in dir_path.split('/').filter(|c| !c.is_empty()) { + let child = reader + .lookup_in_dir(¤t, component)? + .ok_or_else(|| format!("path component not found: /{component}"))?; + current = reader.read_inode(child)?; + } + let mut candidates: Vec<(String, u32)> = reader + .dir_entry_names(¤t)? + .into_iter() + .filter(|(name, _)| name.starts_with(prefix) && name.ends_with(suffix)) + .collect(); + candidates.sort_by(|a, b| { + a.0.contains("fallback") + .cmp(&b.0.contains("fallback")) + .then_with(|| a.0.cmp(&b.0)) + }); + let (name, ino) = candidates.into_iter().next().ok_or_else(|| { + format!("no file matching '{prefix}*{suffix}' found under {dir_path}") + })?; + let inode = reader.read_inode(ino)?; + let data = reader.read_file_data(&inode)?; + let _ = name; + Ok(data) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/model.rs b/src/model.rs index d21fd36..9bbe8c5 100644 --- a/src/model.rs +++ b/src/model.rs @@ -44,7 +44,13 @@ impl RuntimeMode { match self { Self::WslBridge => "WSL2 + XRDP bridge", Self::PaneOwned => "Pane-owned OS runtime", - Self::QemuWhpx => "QEMU + WHPX accelerator", + Self::QemuWhpx => { + if cfg!(windows) { + "QEMU + WHPX accelerator" + } else { + "QEMU + KVM accelerator" + } + } Self::Auto => "auto-select", } } @@ -64,19 +70,32 @@ pub enum DesktopChoice { } impl DesktopChoice { - /// pacman packages to install (Xorg + display manager + desktop + a browser). - pub fn packages(self) -> &'static str { + /// Packages to install (Xorg + display manager + desktop + a browser), for the given + /// distro family's package manager. + pub fn packages(self, family: DistroFamily) -> &'static str { // mesa provides the software (llvmpipe) + VirGL GL drivers so the desktop renders. - match self { - Self::Xfce => { - "xorg-server lightdm lightdm-gtk-greeter xfce4 xfce4-goodies firefox networkmanager mesa" - } - Self::Gnome => "xorg-server gdm gnome gnome-terminal firefox networkmanager mesa", - Self::Kde => "xorg-server sddm plasma-meta konsole dolphin firefox networkmanager mesa", + match family { + DistroFamily::Void => match self { + Self::Xfce => { + // Void has no "xfce4-goodies" meta-package (unlike Arch); the xfce4 + // meta-package already covers the core panel/session/settings set. + "xorg-minimal xorg-fonts xorg-input-drivers xorg-video-drivers lightdm lightdm-gtk-greeter xfce4 firefox NetworkManager mesa-dri" + } + Self::Gnome => "xorg-minimal xorg-fonts xorg-input-drivers xorg-video-drivers gdm gnome firefox NetworkManager mesa-dri", + Self::Kde => "xorg-minimal xorg-fonts xorg-input-drivers xorg-video-drivers sddm kde5 firefox NetworkManager mesa-dri", + }, + // Arch (and any other pacman-based family, for now). + _ => match self { + Self::Xfce => { + "xorg-server lightdm lightdm-gtk-greeter xfce4 xfce4-goodies firefox networkmanager mesa" + } + Self::Gnome => "xorg-server gdm gnome gnome-terminal firefox networkmanager mesa", + Self::Kde => "xorg-server sddm plasma-meta konsole dolphin firefox networkmanager mesa", + }, } } - /// Display managers to disable so the chosen one wins display-manager.service. + /// Display managers to disable so the chosen one wins. pub fn other_display_managers(self) -> &'static str { match self { Self::Xfce => "gdm sddm", @@ -85,7 +104,8 @@ impl DesktopChoice { } } - /// systemd display-manager unit to enable. + /// Display manager unit/service to enable (systemd unit name on Arch; runit service + /// name under /etc/sv on Void — same string works for both). pub fn display_manager(self) -> &'static str { match self { Self::Xfce => "lightdm", @@ -130,13 +150,14 @@ impl DisplayMode { } } -#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, ValueEnum)] #[serde(rename_all = "kebab-case")] pub enum DistroFamily { Ubuntu, Debian, Fedora, Arch, + Void, #[default] Unknown, } @@ -148,12 +169,26 @@ impl DistroFamily { Self::Debian => "Debian", Self::Fedora => "Fedora", Self::Arch => "Arch", + Self::Void => "Void", Self::Unknown => "Unknown", } } + /// Short lowercase identifier used to derive per-family artifact filenames + /// (e.g. base image names) and CLI values. + pub fn slug(self) -> &'static str { + match self { + Self::Ubuntu => "ubuntu", + Self::Debian => "debian", + Self::Fedora => "fedora", + Self::Arch => "arch", + Self::Void => "void", + Self::Unknown => "unknown", + } + } + pub fn is_mvp_supported(self) -> bool { - matches!(self, Self::Arch) + matches!(self, Self::Arch | Self::Void) } } @@ -216,6 +251,17 @@ pub fn managed_environment_catalog() -> Vec { summary: "Flagship managed environment. Current first-class path and reference distro for Pane." .to_string(), }, + ManagedEnvironment { + id: "void".to_string(), + display_name: "Void Linux".to_string(), + family: DistroFamily::Void, + stage: ManagedEnvironmentStage::Current, + tier: ManagedEnvironmentTier::FirstClass, + launchable_now: true, + starter_profile: Some("XFCE".to_string()), + summary: "Second first-class managed environment, xbps/runit-based, for users who want a rolling-release alternative to Arch." + .to_string(), + }, ManagedEnvironment { id: "ubuntu-lts".to_string(), display_name: "Ubuntu LTS".to_string(), @@ -274,7 +320,7 @@ mod tests { #[test] fn managed_environment_catalog_is_ordered_and_curated() { let catalog = managed_environment_catalog(); - assert_eq!(catalog.len(), 3); + assert_eq!(catalog.len(), 4); assert_eq!(catalog[0].id, "arch"); assert_eq!(catalog[0].family, DistroFamily::Arch); @@ -282,16 +328,22 @@ mod tests { assert_eq!(catalog[0].tier, ManagedEnvironmentTier::FirstClass); assert!(catalog[0].launchable_now); - assert_eq!(catalog[1].id, "ubuntu-lts"); - assert_eq!(catalog[1].family, DistroFamily::Ubuntu); - assert_eq!(catalog[1].stage, ManagedEnvironmentStage::Next); + assert_eq!(catalog[1].id, "void"); + assert_eq!(catalog[1].family, DistroFamily::Void); + assert_eq!(catalog[1].stage, ManagedEnvironmentStage::Current); assert_eq!(catalog[1].tier, ManagedEnvironmentTier::FirstClass); - assert!(!catalog[1].launchable_now); + assert!(catalog[1].launchable_now); - assert_eq!(catalog[2].id, "debian"); - assert_eq!(catalog[2].family, DistroFamily::Debian); - assert_eq!(catalog[2].stage, ManagedEnvironmentStage::Later); - assert_eq!(catalog[2].tier, ManagedEnvironmentTier::CuratedPreview); + assert_eq!(catalog[2].id, "ubuntu-lts"); + assert_eq!(catalog[2].family, DistroFamily::Ubuntu); + assert_eq!(catalog[2].stage, ManagedEnvironmentStage::Next); + assert_eq!(catalog[2].tier, ManagedEnvironmentTier::FirstClass); assert!(!catalog[2].launchable_now); + + assert_eq!(catalog[3].id, "debian"); + assert_eq!(catalog[3].family, DistroFamily::Debian); + assert_eq!(catalog[3].stage, ManagedEnvironmentStage::Later); + assert_eq!(catalog[3].tier, ManagedEnvironmentTier::CuratedPreview); + assert!(!catalog[3].launchable_now); } } diff --git a/src/plan.rs b/src/plan.rs index 12c1e41..eb5c75a 100644 --- a/src/plan.rs +++ b/src/plan.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use crate::{ error::AppResult, - model::{DesktopEnvironment, DistroRecord, SharedStorageMode}, + model::{DesktopEnvironment, DistroFamily, DistroRecord, SharedStorageMode}, }; pub const DEFAULT_RUNTIME_CAPACITY_GIB: u64 = 8; @@ -103,10 +103,25 @@ pub fn app_root() -> PathBuf { env::var_os("PANE_HOME") .map(PathBuf::from) .or_else(|| env::var_os("LOCALAPPDATA").map(PathBuf::from)) + .or_else(linux_data_home) .unwrap_or_else(|| env::current_dir().unwrap_or_else(|_| PathBuf::from("."))) .join("Pane") } +/// `$XDG_DATA_HOME` or `~/.local/share` on Linux/Unix, so Pane has a sane default data dir +/// without requiring `PANE_HOME` to be set explicitly. +#[cfg(not(windows))] +fn linux_data_home() -> Option { + env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share"))) +} + +#[cfg(windows)] +fn linux_data_home() -> Option { + None +} + pub fn managed_distro_install_root(distro_name: &str) -> PathBuf { app_root() .join("distros") @@ -137,6 +152,22 @@ pub fn workspace_for_with_shared_storage( } } +/// Base OS image filename for a distro family. Arch always resolves to the historical +/// `arch-base.paneimg` name so existing installs/registrations are unaffected. +pub fn base_image_file_name(family: DistroFamily) -> String { + format!("{}-base.paneimg", family.slug()) +} + +/// Like [`runtime_for`], but with `base_os_image` derived from the given distro family +/// instead of always defaulting to the Arch image name. Callers that launch, install into, +/// or register a base image for a specific family should use this so multiple families can +/// have independently registered images under the same session root. +pub fn runtime_for_family(session_name: &str, family: DistroFamily) -> RuntimePaths { + let mut paths = runtime_for(session_name); + paths.base_os_image = paths.images.join(base_image_file_name(family)); + paths +} + pub fn runtime_for(session_name: &str) -> RuntimePaths { let normalized = sanitize_session_name(session_name); let root = app_root().join("runtime").join(&normalized); @@ -255,9 +286,11 @@ mod tests { use std::path::{Path, PathBuf}; use super::{ - managed_distro_install_root, runtime_for, sanitize_session_name, shared_dir_for_workspace, - windows_to_wsl_path, workspace_for, workspace_for_with_shared_storage, + managed_distro_install_root, runtime_for, runtime_for_family, sanitize_session_name, + shared_dir_for_workspace, windows_to_wsl_path, workspace_for, + workspace_for_with_shared_storage, }; + use crate::model::DistroFamily; fn suffix(components: &[&str]) -> PathBuf { let mut path = PathBuf::new(); @@ -332,6 +365,15 @@ mod tests { assert_path_ends_with(&root, &["Pane", "distros", "pane-arch"]); } + #[test] + fn runtime_for_family_derives_base_image_name() { + let arch = runtime_for_family("Pane Session", DistroFamily::Arch); + assert_path_ends_with(&arch.base_os_image, &["images", "arch-base.paneimg"]); + + let void = runtime_for_family("Pane Session", DistroFamily::Void); + assert_path_ends_with(&void.base_os_image, &["images", "void-base.paneimg"]); + } + #[test] fn runtime_paths_include_native_engine_boundaries() { let runtime = runtime_for("Pane Session"); diff --git a/src/qemu.rs b/src/qemu.rs index 7fca450..f69d7e5 100644 --- a/src/qemu.rs +++ b/src/qemu.rs @@ -29,7 +29,17 @@ pub fn host_memory_mb() -> u64 { #[cfg(not(windows))] pub fn host_memory_mb() -> u64 { - 4096 + // Parse `MemTotal: kB` out of /proc/meminfo. + std::fs::read_to_string("/proc/meminfo") + .ok() + .and_then(|meminfo| { + meminfo.lines().find_map(|line| { + let rest = line.strip_prefix("MemTotal:")?; + rest.trim().split_whitespace().next()?.parse::().ok() + }) + }) + .map(|kb| kb / 1024) + .unwrap_or(4096) } /// vCPU count and guest RAM (MB) scaled to the host with sane caps, so Pane runs fast on @@ -98,16 +108,21 @@ pub struct QemuBootReport { pub detail: String, } -/// Locate the QEMU engine: a bundled `pane-engine.exe` shipped next to pane.exe wins (so -/// the process shows as Pane and works offline); then PATH; then the standard installer -/// paths (winget fallback). +/// Locate the QEMU engine: a bundled `pane-engine(.exe)` shipped next to the Pane binary wins +/// (so the process shows as Pane and works offline); then PATH; then the standard installer +/// paths (winget on Windows; package-manager installs on Linux already land on PATH). pub fn locate_qemu() -> Option { + let bundled_name = if cfg!(windows) { + "pane-engine.exe" + } else { + "pane-engine" + }; // Bundled engine next to the running executable (and a couple of common layouts). if let Ok(exe) = std::env::current_exe() { if let Some(dir) = exe.parent() { for bundled in [ - dir.join("pane-engine.exe"), - dir.join("engine").join("pane-engine.exe"), + dir.join(bundled_name), + dir.join("engine").join(bundled_name), ] { if bundled.exists() { return Some(bundled); @@ -124,14 +139,17 @@ pub fn locate_qemu() -> Option { { return Some(PathBuf::from("qemu-system-x86_64")); } - let candidates = [ - r"C:\Program Files\qemu\qemu-system-x86_64.exe", - r"C:\Program Files\QEMU\qemu-system-x86_64.exe", - ]; - candidates - .iter() - .map(PathBuf::from) - .find(|path| path.exists()) + if cfg!(windows) { + let candidates = [ + r"C:\Program Files\qemu\qemu-system-x86_64.exe", + r"C:\Program Files\QEMU\qemu-system-x86_64.exe", + ]; + return candidates + .iter() + .map(PathBuf::from) + .find(|path| path.exists()); + } + None } /// Build a `-drive` spec. `snapshot=on` makes writes copy-on-write and discarded at exit @@ -172,12 +190,20 @@ pub fn graceful_shutdown(qmp_port: u16) -> Result<(), String> { Ok(()) } -/// Ensure QEMU is available, installing it via winget on first use if absent. Returns the -/// resolved qemu-system path. winget output is shown so the user sees install progress. +/// Ensure QEMU is available. On Windows this installs it via winget on first use (output is +/// shown so the user sees install progress). On Linux, Pane does not run a package manager +/// with elevated privileges on the user's behalf, so it just returns an actionable error. pub fn ensure_qemu_available() -> Result { if let Some(qemu) = locate_qemu() { return Ok(qemu); } + if !cfg!(windows) { + return Err( + "qemu-system-x86_64 not found. Install QEMU, e.g. `sudo pacman -S qemu-desktop` \ + (or `qemu-full` for the complete emulator set), then rerun this command." + .to_string(), + ); + } println!("QEMU not found. Installing it via winget (SoftwareFreedomConservancy.QEMU)..."); let status = Command::new("winget") .args([ @@ -278,21 +304,40 @@ fn display_args_for(backend: &str, gpu_acceleration: bool) -> Vec { } } -/// Push the machine definition shared by every boot mode: WHPX accel, memory, the kernel + +/// Hardware-accelerated hypervisor backend: WHPX on Windows, KVM everywhere else (`/dev/kvm`, +/// the standard Linux accelerator QEMU already supports out of the box). +fn accel_flag() -> &'static str { + if cfg!(windows) { + "whpx" + } else { + "kvm" + } +} + +/// CPU model for the accelerator in use. WHPX rejects "host"/"max" (APX/MPX feature conflicts +/// kill the guest before it boots), so Windows pins to the feature-rich, WHPX-compatible +/// Skylake-Client model. KVM has no such restriction, so Linux passes the host's real CPU +/// through for full feature/perf parity with the machine it is running on. +fn cpu_model() -> &'static str { + if cfg!(windows) { + "Skylake-Client" + } else { + "host" + } +} + +/// Push the machine definition shared by every boot mode: hypervisor accel, memory, the kernel + /// distro initramfs, the base disk (virtio root) and optional user disk (virtio vdb), the /// kernel cmdline, and copy-on-write snapshot of the base image. fn push_machine_args(command: &mut Command, config: &QemuBootConfig) { command.args([ "-accel", - "whpx", + accel_flag(), // Brand the guest window/title as Pane (not "QEMU"). "-name", "Pane", - // Modern CPU model (AVX2/SSE4 etc.) for speed. WHPX rejects "host"/"max" (APX/MPX - // feature conflicts kill the guest before it boots); Skylake-Client is feature-rich - // and WHPX-compatible. "-cpu", - "Skylake-Client", + cpu_model(), "-m", &config.memory_mb.to_string(), // Scale vCPUs to the host so the desktop is responsive. @@ -419,13 +464,20 @@ pub fn ensure_qcow2_overlay(overlay: &Path, base_image: &Path) -> Result<(), Str Ok(()) } +/// Human-readable hint for a missing `qemu-system-x86_64`, tailored per platform. +fn qemu_not_found_message() -> String { + if cfg!(windows) { + "qemu-system-x86_64 not found on PATH or in C:\\Program Files\\qemu. Install QEMU (winget install SoftwareFreedomConservancy.QEMU).".to_string() + } else { + "qemu-system-x86_64 not found on PATH. Install QEMU (sudo pacman -S qemu-desktop).".to_string() + } +} + /// Boot the configured artifacts through QEMU-WHPX as an interactive session: the guest /// serial console is wired straight to this process's stdio, so the user gets a live Linux /// shell. Blocks until QEMU exits (Ctrl-A X). Returns QEMU's exit status. pub fn boot_interactive(config: &QemuBootConfig) -> Result { - let qemu = locate_qemu().ok_or_else(|| { - "qemu-system-x86_64 not found on PATH or in C:\\Program Files\\qemu. Install QEMU (winget install SoftwareFreedomConservancy.QEMU).".to_string() - })?; + let qemu = locate_qemu().ok_or_else(qemu_not_found_message)?; for required in [&config.kernel, &config.initramfs, &config.base_disk] { if !required.exists() { return Err(format!("Required artifact missing: {}", required.display())); @@ -458,9 +510,7 @@ pub fn boot_interactive(config: &QemuBootConfig) -> Result Result { - let qemu = locate_qemu().ok_or_else(|| { - "qemu-system-x86_64 not found on PATH or in C:\\Program Files\\qemu. Install QEMU (winget install SoftwareFreedomConservancy.QEMU).".to_string() - })?; + let qemu = locate_qemu().ok_or_else(qemu_not_found_message)?; for required in [&config.kernel, &config.initramfs, &config.base_disk] { if !required.exists() { return Err(format!("Required artifact missing: {}", required.display())); @@ -502,17 +552,29 @@ pub fn boot_detached(config: &QemuBootConfig) -> Result { // launcher exiting (otherwise a job/console teardown closes it). command.creation_flags(0x0000_0008 | 0x0100_0000); } + #[cfg(not(windows))] + { + use std::os::unix::process::CommandExt; + // New session/process group so the child survives the launching terminal closing or + // sending SIGHUP/SIGINT to its foreground process group. + command.process_group(0); + } let child = command .spawn() .map_err(|error| format!("Failed to launch QEMU: {error}"))?; Ok(child.id()) } -/// Locate `qemu-img.exe` next to `qemu-system-x86_64`, or on PATH. +/// Locate `qemu-img(.exe)` next to `qemu-system-x86_64`, or on PATH. pub fn locate_qemu_img() -> Option { + let sibling_name = if cfg!(windows) { + "qemu-img.exe" + } else { + "qemu-img" + }; if let Some(system) = locate_qemu() { if let Some(dir) = system.parent() { - let sibling = dir.join("qemu-img.exe"); + let sibling = dir.join(sibling_name); if sibling.exists() { return Some(sibling); } @@ -581,8 +643,7 @@ pub fn boot_via_qemu_whpx(config: &QemuBootConfig) -> QemuBootReport { }; let Some(qemu) = locate_qemu() else { - report.detail = - "qemu-system-x86_64 not found on PATH or in C:\\Program Files\\qemu. Install QEMU (winget install SoftwareFreedomConservancy.QEMU).".to_string(); + report.detail = qemu_not_found_message(); return report; }; report.qemu_path = Some(qemu.display().to_string()); @@ -793,13 +854,32 @@ pub fn provision_via_serial( } }; + // Flow-controlled send: wait for the guest's terminal echo of this line to actually + // show up before sending the next one, instead of a blind fixed delay. QEMU's serial + // port emulates a classic 16550 UART with a tiny 16-byte hardware receive FIFO; under + // boot-time CPU contention (other getty/daemon startup racing the shell's read loop) + // a fixed delay let bytes arrive faster than the guest could drain them, silently + // corrupting mid-heredoc commands. Falls back to a short fixed delay if the echo never + // appears (e.g. an unprintable line) so provisioning still makes progress. let mut send = |line: &str| -> Result<(), String> { + let before_len = buffer.lock().map(|text| text.len()).unwrap_or(0); writer .write_all(line.as_bytes()) .and_then(|_| writer.write_all(b"\n")) .map_err(|error| format!("Could not write to the guest serial: {error}"))?; let _ = writer.flush(); - std::thread::sleep(Duration::from_millis(700)); + let echo_deadline = Instant::now() + Duration::from_secs(5); + loop { + let echoed = buffer + .lock() + .map(|text| text.len() > before_len && text[before_len..].contains(line)) + .unwrap_or(false); + if echoed || Instant::now() >= echo_deadline { + break; + } + std::thread::sleep(Duration::from_millis(50)); + } + std::thread::sleep(Duration::from_millis(150)); Ok(()) }; @@ -808,6 +888,31 @@ pub fn provision_via_serial( let _ = child.kill(); return Err("Guest did not reach the autologin root shell in time.".to_string()); } + // Some getty setups (e.g. a respawn-throttled init service) print the autologin marker a + // few times in quick succession before the serial client here fully attaches, each + // instance getting torn down and restarted. Writing commands into one of those + // about-to-die sessions garbles them, so wait for the marker to stop reappearing (no new + // occurrence for 2s, capped at 15s total) before trusting the shell is stable. + { + let mut last_count = 0usize; + let mut stable_since = Instant::now(); + let settle_deadline = Instant::now() + Duration::from_secs(15); + loop { + let count = buffer + .lock() + .map(|text| text.matches("automatic login").count()) + .unwrap_or(last_count); + if count != last_count { + last_count = count; + stable_since = Instant::now(); + } + if stable_since.elapsed() >= Duration::from_secs(2) || Instant::now() >= settle_deadline + { + break; + } + std::thread::sleep(Duration::from_millis(300)); + } + } std::thread::sleep(Duration::from_secs(3)); // Execute provisioning as one fail-fast script. Sending commands one-by-one allowed a