diff --git a/LinuxGuestAdditions/DESIGN.md b/LinuxGuestAdditions/DESIGN.md new file mode 100644 index 00000000..0ff96b1f --- /dev/null +++ b/LinuxGuestAdditions/DESIGN.md @@ -0,0 +1,307 @@ +# Linux Guest Tools - Phase 2 Design + +## Overview + +This document describes the implementation of auto-mountable Linux guest tools for VirtualBuddy, following the same pattern as VMware Tools, VirtualBox Guest Additions, and Parallels Tools. + +## Goals + +1. **Zero-copy installation** - User doesn't need to download or transfer files +2. **One-command install** - Single command to install all guest tools +3. **Update detection** - Guest can detect when newer tools are available +4. **Cross-distro support** - Works on Fedora, Ubuntu, Debian, Arch, etc. +5. **Offline operation** - No network required for installation + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ VirtualBuddy Host │ +├─────────────────────────────────────────────────────────────────┤ +│ LinuxGuestAdditionsDiskImage.swift │ +│ ├── Monitors LinuxGuestAdditions/ directory │ +│ ├── Generates ISO image on app launch (if needed) │ +│ └── Stores ISO in ~/Library/Application Support/VirtualBuddy/ │ +├─────────────────────────────────────────────────────────────────┤ +│ LinuxVirtualMachineConfigurationHelper.swift │ +│ └── Attaches ISO as virtio block device when VM starts │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Linux Guest VM │ +├─────────────────────────────────────────────────────────────────┤ +│ /dev/vdX (VirtualBuddy Tools ISO) │ +│ └── Mount and run install.sh │ +├─────────────────────────────────────────────────────────────────┤ +│ Installed Components: │ +│ ├── /usr/local/bin/virtualbuddy-growfs │ +│ ├── /etc/systemd/system/virtualbuddy-growfs.service │ +│ └── /etc/virtualbuddy/version │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Disk Image Format + +**ISO 9660 with Joliet extensions** (recommended) +- Universal read support across all Linux distributions +- Read-only by design (prevents accidental modifications) +- Standard pattern used by VMware, VirtualBox, Parallels +- Can be created on macOS using `hdiutil makehybrid` + +Volume label: `VBTOOLS` + +## ISO Contents + +``` +VirtualBuddyLinuxTools.iso (VBTOOLS) +├── autorun.sh # Quick-start script +├── install.sh # Full installer (existing) +├── uninstall.sh # Uninstaller (existing) +├── virtualbuddy-growfs # Main resize script (existing) +├── virtualbuddy-growfs.service # systemd service (existing) +├── README.md # Documentation (existing) +├── VERSION # Version string for update detection +└── extras/ + └── 99-virtualbuddy.rules # Optional udev rule +``` + +## Implementation Components + +### 1. Host-Side: LinuxGuestAdditionsDiskImage.swift + +New file similar to `GuestAdditionsDiskImage.swift`: + +```swift +public final class LinuxGuestAdditionsDiskImage: ObservableObject { + public static let current = LinuxGuestAdditionsDiskImage() + + // Source directory within VirtualCore bundle + private var embeddedToolsURL: URL { + Bundle.virtualCore.url(forResource: "LinuxGuestAdditions", withExtension: nil) + } + + // Destination for generated ISO + public var installedImageURL: URL { + GuestAdditionsDiskImage.imagesRootURL + .appendingPathComponent("VirtualBuddyLinuxTools") + .appendingPathExtension("iso") + } + + // Generate ISO using CreateLinuxGuestImage.sh + public func installIfNeeded() async throws { ... } +} +``` + +### 2. Host-Side: CreateLinuxGuestImage.sh + +```bash +#!/bin/sh +# Creates ISO from LinuxGuestAdditions directory + +SOURCE_DIR="$1" +DEST_PATH="$2" +VERSION="$3" + +# Write version file +echo "$VERSION" > "$SOURCE_DIR/VERSION" + +# Create ISO with hdiutil +hdiutil makehybrid \ + -iso \ + -joliet \ + -joliet-volume-name "VBTOOLS" \ + -o "$DEST_PATH" \ + "$SOURCE_DIR" +``` + +### 3. Host-Side: LinuxVirtualMachineConfigurationHelper Changes + +Add `createAdditionalBlockDevices()` override: + +```swift +func createAdditionalBlockDevices() async throws -> [VZVirtioBlockDeviceConfiguration] { + var devices = try storageDeviceContainer.additionalBlockDevices(guestType: .linux) + + // Attach Linux guest tools ISO if enabled + if vm.configuration.guestAdditionsEnabled, + let disk = try? VZVirtioBlockDeviceConfiguration.linuxGuestToolsDisk { + devices.append(disk) + } + + return devices +} +``` + +### 4. Guest-Side: autorun.sh + +Simple entry point for users: + +```bash +#!/bin/bash +# VirtualBuddy Linux Guest Tools - Quick Start +# +# Run this script with: sudo /mnt/autorun.sh +# Or use the full installer: sudo /mnt/install.sh + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +echo "VirtualBuddy Linux Guest Tools" +echo "==============================" +echo "" +echo "This will install:" +echo " - Automatic disk resize on boot (virtualbuddy-growfs)" +echo "" + +# Check if already installed and compare versions +if [[ -f /etc/virtualbuddy/version ]]; then + INSTALLED_VERSION=$(cat /etc/virtualbuddy/version) + NEW_VERSION=$(cat "$SCRIPT_DIR/VERSION") + + if [[ "$INSTALLED_VERSION" == "$NEW_VERSION" ]]; then + echo "Guest tools v$INSTALLED_VERSION already installed and up to date." + exit 0 + else + echo "Updating from v$INSTALLED_VERSION to v$NEW_VERSION..." + fi +fi + +# Run the full installer +exec "$SCRIPT_DIR/install.sh" +``` + +### 5. Guest-Side: Enhanced install.sh + +Update existing install.sh to: +1. Create `/etc/virtualbuddy/` directory +2. Write version file after successful install +3. Support `--quiet` flag for non-interactive install + +```bash +# Add to install.sh after successful installation: +mkdir -p /etc/virtualbuddy +cp "$SCRIPT_DIR/VERSION" /etc/virtualbuddy/version +``` + +## User Experience + +### First Boot Flow + +1. User creates new Linux VM in VirtualBuddy +2. VM boots with install ISO + guest tools ISO attached +3. After OS installation completes, user sees guest tools disk +4. User runs one command: + +```bash +# Option 1: If disk is auto-mounted (most distros with desktop) +sudo /run/media/$USER/VBTOOLS/install.sh + +# Option 2: Manual mount +sudo mount -L VBTOOLS /mnt +sudo /mnt/install.sh +sudo umount /mnt + +# Option 3: One-liner +sudo sh -c 'mkdir -p /mnt/vbtools && mount -L VBTOOLS /mnt/vbtools && /mnt/vbtools/install.sh; umount /mnt/vbtools 2>/dev/null; rmdir /mnt/vbtools 2>/dev/null' +``` + +### Update Flow + +1. User updates VirtualBuddy (new guest tools included) +2. On next VM boot, new ISO is attached +3. User can check for updates: + +```bash +# Check if update available +INSTALLED=$(cat /etc/virtualbuddy/version 2>/dev/null || echo "none") +AVAILABLE=$(cat /run/media/$USER/VBTOOLS/VERSION 2>/dev/null || echo "none") +echo "Installed: $INSTALLED, Available: $AVAILABLE" +``` + +4. Re-run install.sh to update + +## Configuration + +### VM Settings + +Add new configuration option in VBMacConfiguration: + +```swift +/// Whether to attach Linux guest tools ISO to Linux VMs. +/// Defaults to true for Linux VMs. +@DecodableDefault.True +public var linuxGuestToolsEnabled = true +``` + +This is separate from `guestAdditionsEnabled` which controls macOS guest tools. + +### UI Integration + +Add toggle in VM settings: +- "Attach guest tools disk" (checkbox, default: on) +- Tooltip: "Attaches VirtualBuddy guest tools ISO for easy installation" + +## File Locations + +### Host (macOS) + +| File | Location | +|------|----------| +| Source scripts | `VirtualCore/Resources/LinuxGuestAdditions/` | +| Generated ISO | `~/Library/Application Support/VirtualBuddy/_GuestImage/VirtualBuddyLinuxTools.iso` | +| Version digest | `~/Library/Application Support/VirtualBuddy/_GuestImage/.VirtualBuddyLinuxTools.digest` | + +### Guest (Linux) + +| File | Location | +|------|----------| +| Resize script | `/usr/local/bin/virtualbuddy-growfs` | +| systemd service | `/etc/systemd/system/virtualbuddy-growfs.service` | +| Version file | `/etc/virtualbuddy/version` | +| Config (future) | `/etc/virtualbuddy/config` | + +## Implementation Phases + +### Phase 2a: Basic ISO Attachment (MVP) +1. Create `CreateLinuxGuestImage.sh` +2. Create `LinuxGuestAdditionsDiskImage.swift` +3. Modify `LinuxVirtualMachineConfigurationHelper` to attach ISO +4. Update `install.sh` to write version file +5. Add `autorun.sh` convenience script + +### Phase 2b: Polish +1. Add UI toggle for guest tools attachment +2. Add version checking in guest +3. Desktop notification on mount (optional udev rule) +4. Update documentation + +### Phase 2c: Future Enhancements +1. virtio-vsock communication channel +2. Host-triggered resize signal +3. Clipboard integration (if virtio-clipboard becomes available) +4. Time synchronization helper + +## Testing Checklist + +- [ ] ISO generates correctly on app launch +- [ ] ISO attaches to Linux VMs +- [ ] ISO does NOT attach to macOS VMs +- [ ] Install works on Fedora (LUKS+LVM+Btrfs) +- [ ] Install works on Ubuntu (ext4) +- [ ] Install works on Debian (ext4/LVM) +- [ ] Version detection works +- [ ] Update flow works +- [ ] Resize works after reboot + +## Security Considerations + +1. **ISO is read-only** - Prevents tampering from guest +2. **Scripts run as root** - Required for system modifications +3. **No network required** - Reduces attack surface +4. **Version verification** - Ensures tools match host version + +## References + +- [VirtualBox Guest Additions](https://www.virtualbox.org/manual/ch04.html) +- [VMware Tools](https://docs.vmware.com/en/VMware-Tools/index.html) +- [cloud-init NoCloud](https://cloudinit.readthedocs.io/en/latest/reference/datasources/nocloud.html) diff --git a/LinuxGuestAdditions/INSTALL.md b/LinuxGuestAdditions/INSTALL.md new file mode 100644 index 00000000..329e67e8 --- /dev/null +++ b/LinuxGuestAdditions/INSTALL.md @@ -0,0 +1,265 @@ +# VirtualBuddy Linux Guest Additions + +This package provides automatic filesystem resizing for Linux virtual machines running in VirtualBuddy. + +When you resize a disk in VirtualBuddy, the guest additions will automatically expand the partition and filesystem on the next boot. + +## Features + +- **Automatic partition resize** using `growpart` +- **LVM support** - automatically extends physical volumes and logical volumes +- **LUKS support** - automatically resizes encrypted containers +- **LVM on LUKS** - full support for Fedora Workstation's default layout +- **Multiple filesystems** - supports ext4, XFS, and Btrfs +- **Safe operation** - only runs when free space is detected +- **Desktop notifications** - shows a notification when disk is resized (desktop environments) +- **Colorful terminal output** - easy to follow installation and resize progress + +## Supported Distributions + +Any systemd-based Linux distribution, including: + +- Fedora Workstation / Server +- Ubuntu +- Debian +- Arch Linux +- openSUSE +- Rocky Linux / AlmaLinux + +## Installation + +### Quick Install (from inside the VM) + +```bash +# Download and extract +curl -L https://github.com/insidegui/VirtualBuddy/releases/latest/download/LinuxGuestAdditions.tar.gz | tar xz + +# Install +cd LinuxGuestAdditions +sudo ./install.sh +``` + +### Manual Install + +1. Copy the files to your VM +2. Run the installer: + +```bash +sudo ./install.sh +``` + +The installer will: +- Check for required dependencies (`growpart`, `resize2fs`/`xfs_growfs`) +- Install the `virtualbuddy-growfs` script to `/usr/local/bin/` +- Install the `virtualbuddy-notify` script for desktop notifications +- Install and enable the systemd services +- Optionally run the resize immediately + +## Desktop Notifications + +On desktop distributions (GNOME, KDE, Xfce, etc.), the guest additions will show a notification when the disk has been resized: + +- **Installation notification** - Shown when you run the installer +- **Resize notification** - Shown after login if the disk was resized during boot + +The notification shows: +- Previous disk size +- New disk size + +This makes it easy to confirm that your disk expansion worked, even though the resize happens early in the boot process. + +**Requirements for notifications:** +- X11 or Wayland display server +- `notify-send` command (usually provided by `libnotify`) + +## Dependencies + +The following packages are required: + +| Distribution | Package | +|-------------|---------| +| Fedora/RHEL | `cloud-utils-growpart` | +| Ubuntu/Debian | `cloud-guest-utils` | +| Arch Linux | `cloud-guest-utils` (AUR) | +| openSUSE | `growpart` | + +Install with: + +```bash +# Fedora +sudo dnf install cloud-utils-growpart + +# Ubuntu/Debian +sudo apt install cloud-guest-utils + +# Arch (from AUR) +yay -S cloud-guest-utils +``` + +## Usage + +### Automatic (Recommended) + +After installation, the service runs automatically on each boot. If VirtualBuddy has expanded the disk, the partition and filesystem will be resized. + +### Manual + +You can also run the resize manually: + +```bash +# Run with verbose output +sudo virtualbuddy-growfs --verbose + +# Dry run (show what would happen) +sudo virtualbuddy-growfs --dry-run --verbose +``` + +### Check Status + +```bash +# Service status +systemctl status virtualbuddy-growfs + +# View logs +journalctl -u virtualbuddy-growfs +``` + +## How It Works + +1. **Detect storage stack** - Walks from root filesystem back through LVM, LUKS, to the partition +2. **Find free space** - Checks if partition can be grown +3. **Grow partition** - Uses `growpart` to extend the GPT partition +4. **Resize LUKS** - If encrypted, runs `cryptsetup resize` +5. **Resize LVM** - If using LVM: + - `pvresize` to extend the physical volume + - `lvextend` to extend the logical volume +6. **Resize filesystem** - Runs the appropriate tool: + - ext4: `resize2fs` + - XFS: `xfs_growfs` + - Btrfs: `btrfs filesystem resize max` + +## LVM Support + +For distributions using LVM (with or without encryption), the guest additions automatically handle: + +1. Extending the physical volume (`pvresize`) +2. Extending the logical volume (`lvextend -l +100%FREE`) +3. Resizing the filesystem + +This works for both: +- **LVM on partition** - direct partition → LVM → filesystem +- **LVM on LUKS** - partition → LUKS → LVM → filesystem (Fedora Workstation default) + +## LUKS Encrypted Disks + +For LUKS-encrypted root partitions (common with Fedora Workstation), the guest additions will: + +1. Grow the GPT partition containing LUKS +2. Run `cryptsetup resize` to expand the LUKS container +3. If LVM is on top of LUKS, extend PV and LV +4. Resize the inner filesystem + +No manual intervention required! + +## Uninstall + +```bash +sudo ./uninstall.sh +``` + +Or manually: + +```bash +# Disable services +sudo systemctl disable --now virtualbuddy-growfs.service +sudo systemctl --global disable virtualbuddy-notify.service + +# Remove files +sudo rm /etc/systemd/system/virtualbuddy-growfs.service +sudo rm /etc/systemd/user/virtualbuddy-notify.service +sudo rm /usr/local/bin/virtualbuddy-growfs +sudo rm /usr/local/bin/virtualbuddy-notify +sudo rm -rf /etc/virtualbuddy + +# Reload systemd +sudo systemctl daemon-reload +``` + +## Troubleshooting + +### "growpart not found" + +Install the cloud-utils package for your distribution (see Dependencies section). + +### Partition not growing + +Check if there's actually free space after the partition: + +```bash +sudo parted /dev/vda print free +``` + +If the "Free Space" at the end is very small (< 1MB), VirtualBuddy may not have resized the disk yet. + +### LUKS resize fails + +Ensure the LUKS container is unlocked (you should be booted into the system). The resize requires the container to be open. + +### Filesystem resize fails + +Check the filesystem type and ensure the appropriate tools are installed: + +```bash +# Check filesystem type +df -T / + +# For ext4 +sudo apt install e2fsprogs # or dnf install e2fsprogs + +# For XFS +sudo apt install xfsprogs # or dnf install xfsprogs + +# For Btrfs +sudo apt install btrfs-progs # or dnf install btrfs-progs +``` + +### LVM not detected + +Ensure LVM tools are installed: + +```bash +# Fedora/RHEL +sudo dnf install lvm2 + +# Ubuntu/Debian +sudo apt install lvm2 +``` + +Check your storage stack: + +```bash +# View LVM layout +sudo lsblk +sudo lvs +sudo pvs +sudo vgs +``` + +### LV not extending + +If the logical volume isn't growing, check for free space in the volume group: + +```bash +sudo vgs +``` + +If `VFree` is 0, the physical volume may not have been resized. Try running manually: + +```bash +sudo pvresize /dev/mapper/luks-xxx # or your PV device +sudo lvextend -l +100%FREE /dev/mapper/fedora-root +``` + +## License + +MIT License - Same as VirtualBuddy diff --git a/LinuxGuestAdditions/autorun.sh b/LinuxGuestAdditions/autorun.sh new file mode 100755 index 00000000..d60d1e58 --- /dev/null +++ b/LinuxGuestAdditions/autorun.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# +# VirtualBuddy Linux Guest Tools - Quick Start +# +# Run this script with: sudo /path/to/autorun.sh +# Or use the full installer: sudo /path/to/install.sh +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +echo "VirtualBuddy Linux Guest Tools" +echo "==============================" +echo "" + +# Check if running as root +if [[ $EUID -ne 0 ]]; then + echo "This script must be run as root." + echo "" + echo "Usage: sudo $0" + exit 1 +fi + +# Check if already installed and compare versions +if [[ -f /etc/virtualbuddy/version ]]; then + INSTALLED_VERSION=$(cat /etc/virtualbuddy/version 2>/dev/null || echo "unknown") + + if [[ -f "$SCRIPT_DIR/VERSION" ]]; then + NEW_VERSION=$(cat "$SCRIPT_DIR/VERSION" 2>/dev/null || echo "unknown") + else + # Fallback to version from install.sh + NEW_VERSION=$(grep '^VERSION=' "$SCRIPT_DIR/install.sh" 2>/dev/null | cut -d'"' -f2 || echo "unknown") + fi + + if [[ "$INSTALLED_VERSION" == "$NEW_VERSION" ]]; then + echo "Guest tools already installed and up to date." + echo "Version: $INSTALLED_VERSION" + echo "" + echo "To reinstall, run: sudo $SCRIPT_DIR/install.sh" + echo "To uninstall, run: sudo $SCRIPT_DIR/uninstall.sh" + exit 0 + else + echo "Update available!" + echo " Installed: $INSTALLED_VERSION" + echo " Available: $NEW_VERSION" + echo "" + fi +else + echo "Guest tools not yet installed." + echo "" +fi + +echo "This will install:" +echo " - virtualbuddy-growfs: Automatic disk resize on boot" +echo " - systemd service to run resize automatically" +echo "" + +# Run the full installer +exec "$SCRIPT_DIR/install.sh" diff --git a/LinuxGuestAdditions/install.sh b/LinuxGuestAdditions/install.sh new file mode 100755 index 00000000..a25faa26 --- /dev/null +++ b/LinuxGuestAdditions/install.sh @@ -0,0 +1,249 @@ +#!/bin/bash +# +# VirtualBuddy Linux Guest Additions Installer +# +# This script installs the VirtualBuddy guest additions for Linux, +# which provides automatic filesystem resize after disk expansion. +# +# Supports: Fedora, Ubuntu, Debian, Arch, and other systemd-based distros +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VERSION="1.2.0" + +# Colors for terminal output (disabled if not a TTY) +if [[ -t 1 ]]; then + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[0;33m' + BLUE='\033[0;34m' + CYAN='\033[0;36m' + BOLD='\033[1m' + NC='\033[0m' # No Color +else + RED='' + GREEN='' + YELLOW='' + BLUE='' + CYAN='' + BOLD='' + NC='' +fi + +# Check if we're in a desktop environment +HAS_DESKTOP=false +if [[ -n "${DISPLAY:-}" ]] || [[ -n "${WAYLAND_DISPLAY:-}" ]]; then + HAS_DESKTOP=true +fi + +log() { + echo -e "${CYAN}[virtualbuddy]${NC} $*" +} + +log_step() { + echo -e "${BLUE}${BOLD}==>${NC} $*" +} + +log_success() { + echo -e "${GREEN}✓${NC} $*" +} + +log_warning() { + echo -e "${YELLOW}⚠${NC} $*" +} + +die() { + echo -e "${RED}${BOLD}ERROR:${NC} $*" >&2 + exit 1 +} + +# Send desktop notification if available +# Note: When running as root (sudo), D-Bus session may not be accessible, +# so we use timeout to prevent hanging +notify() { + local title="$1" + local message="$2" + local urgency="${3:-normal}" # low, normal, critical + + if $HAS_DESKTOP && command -v notify-send &>/dev/null; then + # Use timeout to prevent hanging if D-Bus session is inaccessible (common with sudo) + timeout 2s notify-send -u "$urgency" -i "drive-harddisk" "VirtualBuddy: $title" "$message" 2>/dev/null || true + fi +} + +check_root() { + if [[ $EUID -ne 0 ]]; then + die "This script must be run as root (try: sudo $0)" + fi +} + +check_systemd() { + if ! command -v systemctl &>/dev/null; then + die "systemd not found. This installer requires a systemd-based distribution." + fi +} + +check_dependencies() { + log_step "Checking dependencies..." + local missing=() + + # Check for required tools + if ! command -v growpart &>/dev/null; then + missing+=("growpart (cloud-guest-utils)") + else + log_success "growpart found" + fi + + if command -v resize2fs &>/dev/null; then + log_success "resize2fs found" + elif command -v xfs_growfs &>/dev/null; then + log_success "xfs_growfs found" + else + missing+=("resize2fs or xfs_growfs (e2fsprogs or xfsprogs)") + fi + + if ! command -v cryptsetup &>/dev/null; then + log_warning "cryptsetup not found - LUKS support will be disabled" + else + log_success "cryptsetup found" + fi + + if [[ ${#missing[@]} -gt 0 ]]; then + echo "" + echo -e "${RED}Missing dependencies:${NC}" + for dep in "${missing[@]}"; do + echo " - $dep" + done + echo "" + echo "Install them with:" + + if command -v dnf &>/dev/null; then + echo -e " ${BOLD}sudo dnf install cloud-utils-growpart${NC}" + elif command -v apt-get &>/dev/null; then + echo -e " ${BOLD}sudo apt-get install cloud-guest-utils${NC}" + elif command -v pacman &>/dev/null; then + echo -e " ${BOLD}sudo pacman -S cloud-guest-utils${NC}" + elif command -v zypper &>/dev/null; then + echo -e " ${BOLD}sudo zypper install growpart${NC}" + fi + + die "Please install missing dependencies and try again." + fi + echo "" +} + +install_files() { + log_step "Installing VirtualBuddy Guest Additions v$VERSION..." + + # Install the growfs script + log "Installing virtualbuddy-growfs to /usr/local/bin/" + install -m 755 "$SCRIPT_DIR/virtualbuddy-growfs" /usr/local/bin/virtualbuddy-growfs + log_success "Installed virtualbuddy-growfs" + + # Install the notification script + log "Installing notification script..." + install -m 755 "$SCRIPT_DIR/virtualbuddy-notify" /usr/local/bin/virtualbuddy-notify + log_success "Installed virtualbuddy-notify" + + # Install the systemd system service + log "Installing systemd system service..." + install -m 644 "$SCRIPT_DIR/virtualbuddy-growfs.service" /etc/systemd/system/virtualbuddy-growfs.service + log_success "Installed growfs service" + + # Install the systemd user service for notifications + log "Installing systemd user service for notifications..." + mkdir -p /etc/systemd/user + install -m 644 "$SCRIPT_DIR/virtualbuddy-notify.service" /etc/systemd/user/virtualbuddy-notify.service + log_success "Installed notification service" + + # Reload systemd + log "Reloading systemd daemon..." + systemctl daemon-reload + log_success "Reloaded systemd" + + # Enable the system service + log "Enabling virtualbuddy-growfs service..." + systemctl enable virtualbuddy-growfs.service + log_success "Enabled growfs service for automatic startup" + + # Enable the user service globally (for all users) + log "Enabling notification service for desktop users..." + systemctl --global enable virtualbuddy-notify.service 2>/dev/null || true + log_success "Enabled notification service" + + # Write version file for update detection + mkdir -p /etc/virtualbuddy + if [[ -f "$SCRIPT_DIR/VERSION" ]]; then + cp "$SCRIPT_DIR/VERSION" /etc/virtualbuddy/version + else + echo "$VERSION" > /etc/virtualbuddy/version + fi + log_success "Saved version info" + echo "" +} + +run_now() { + echo "" + echo -e "${BOLD}Would you like to resize the filesystem now?${NC}" + echo "This will expand the root partition if the disk has been enlarged." + echo "" + read -p "Resize now? [y/N] " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + echo "" + log_step "Running filesystem resize..." + /usr/local/bin/virtualbuddy-growfs --verbose + else + echo "" + log "Skipped. The filesystem will be automatically resized on next boot." + fi +} + +show_status() { + echo "" + echo -e "${GREEN}${BOLD}╔══════════════════════════════════════════════════════════════╗${NC}" + echo -e "${GREEN}${BOLD}║ VirtualBuddy Guest Additions Installed! ║${NC}" + echo -e "${GREEN}${BOLD}╚══════════════════════════════════════════════════════════════╝${NC}" + echo "" + echo "The virtualbuddy-growfs service will automatically run on each boot" + echo "to expand the filesystem if the disk has been resized in VirtualBuddy." + echo "" + echo -e "${BOLD}Manual commands:${NC}" + echo -e " ${CYAN}Check status:${NC} systemctl status virtualbuddy-growfs" + echo -e " ${CYAN}Run manually:${NC} sudo virtualbuddy-growfs --verbose" + echo -e " ${CYAN}View logs:${NC} journalctl -u virtualbuddy-growfs" + echo -e " ${CYAN}Uninstall:${NC} sudo $SCRIPT_DIR/uninstall.sh" + + # Send desktop notification + notify "Installation Complete" "VirtualBuddy Guest Additions have been installed successfully." +} + +show_banner() { + echo "" + echo -e "${BLUE}${BOLD}╔══════════════════════════════════════════════════════════════╗${NC}" + echo -e "${BLUE}${BOLD}║ VirtualBuddy Linux Guest Additions v$VERSION ║${NC}" + echo -e "${BLUE}${BOLD}╚══════════════════════════════════════════════════════════════╝${NC}" + echo "" + echo "This will install automatic disk resize support for your VM." + echo "When you resize the disk in VirtualBuddy, the filesystem will" + echo "automatically expand on the next boot." + echo "" +} + +main() { + show_banner + check_root + check_systemd + check_dependencies + install_files + show_status + run_now + + echo "" + echo -e "${GREEN}${BOLD}All done!${NC} Enjoy using VirtualBuddy." + echo "" +} + +main "$@" diff --git a/LinuxGuestAdditions/uninstall.sh b/LinuxGuestAdditions/uninstall.sh new file mode 100755 index 00000000..692317e1 --- /dev/null +++ b/LinuxGuestAdditions/uninstall.sh @@ -0,0 +1,125 @@ +#!/bin/bash +# +# VirtualBuddy Linux Guest Additions Uninstaller +# + +set -euo pipefail + +# Colors for terminal output (disabled if not a TTY) +if [[ -t 1 ]]; then + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[0;33m' + BLUE='\033[0;34m' + CYAN='\033[0;36m' + BOLD='\033[1m' + NC='\033[0m' # No Color +else + RED='' + GREEN='' + YELLOW='' + BLUE='' + CYAN='' + BOLD='' + NC='' +fi + +log() { + echo -e "${CYAN}[virtualbuddy]${NC} $*" +} + +log_step() { + echo -e "${BLUE}${BOLD}==>${NC} $*" +} + +log_success() { + echo -e "${GREEN}✓${NC} $*" +} + +die() { + echo -e "${RED}${BOLD}ERROR:${NC} $*" >&2 + exit 1 +} + +check_root() { + if [[ $EUID -ne 0 ]]; then + die "This script must be run as root (try: sudo $0)" + fi +} + +main() { + echo "" + echo -e "${YELLOW}${BOLD}╔══════════════════════════════════════════════════════════════╗${NC}" + echo -e "${YELLOW}${BOLD}║ VirtualBuddy Guest Additions Uninstaller ║${NC}" + echo -e "${YELLOW}${BOLD}╚══════════════════════════════════════════════════════════════╝${NC}" + echo "" + + check_root + + log_step "Disabling services..." + + # Disable and stop the growfs service + if systemctl is-enabled virtualbuddy-growfs.service &>/dev/null; then + systemctl disable virtualbuddy-growfs.service + log_success "Disabled virtualbuddy-growfs service" + fi + + if systemctl is-active virtualbuddy-growfs.service &>/dev/null; then + systemctl stop virtualbuddy-growfs.service + log_success "Stopped virtualbuddy-growfs service" + fi + + # Disable the user notification service globally + if systemctl --global is-enabled virtualbuddy-notify.service &>/dev/null 2>&1; then + systemctl --global disable virtualbuddy-notify.service 2>/dev/null || true + log_success "Disabled notification service" + fi + + echo "" + log_step "Removing files..." + + # Remove system service files + if [[ -f /etc/systemd/system/virtualbuddy-growfs.service ]]; then + rm -f /etc/systemd/system/virtualbuddy-growfs.service + log_success "Removed growfs service file" + fi + + # Remove user service file + if [[ -f /etc/systemd/user/virtualbuddy-notify.service ]]; then + rm -f /etc/systemd/user/virtualbuddy-notify.service + log_success "Removed notification service file" + fi + + # Remove scripts + if [[ -f /usr/local/bin/virtualbuddy-growfs ]]; then + rm -f /usr/local/bin/virtualbuddy-growfs + log_success "Removed virtualbuddy-growfs" + fi + + if [[ -f /usr/local/bin/virtualbuddy-notify ]]; then + rm -f /usr/local/bin/virtualbuddy-notify + log_success "Removed virtualbuddy-notify" + fi + + # Remove status file + rm -f /var/run/virtualbuddy-growfs.status 2>/dev/null || true + + # Remove version/config directory + if [[ -d /etc/virtualbuddy ]]; then + rm -rf /etc/virtualbuddy + log_success "Removed VirtualBuddy config directory" + fi + + # Reload systemd + log "Reloading systemd..." + systemctl daemon-reload + log_success "Reloaded systemd" + + echo "" + echo -e "${GREEN}${BOLD}Uninstallation complete!${NC}" + echo "" + echo "VirtualBuddy Guest Additions have been removed from your system." + echo "" +} + +main "$@" diff --git a/LinuxGuestAdditions/virtualbuddy-growfs b/LinuxGuestAdditions/virtualbuddy-growfs new file mode 100755 index 00000000..ba5b226f --- /dev/null +++ b/LinuxGuestAdditions/virtualbuddy-growfs @@ -0,0 +1,616 @@ +#!/bin/bash +# +# VirtualBuddy Linux Guest Additions - Filesystem Grow Script +# +# This script automatically resizes the root partition and filesystem +# after VirtualBuddy has expanded the virtual disk. +# +# Supports: +# - Unencrypted ext4/xfs/btrfs partitions +# - LUKS-encrypted partitions (with ext4/xfs/btrfs inside) +# - LVM logical volumes (with or without LUKS) +# - GPT partition tables +# +# Usage: virtualbuddy-growfs [--dry-run] [--verbose] +# + +set -euo pipefail + +VERSION="1.2.0" +DRY_RUN=false +VERBOSE=false +STATUS_FILE="/var/run/virtualbuddy-growfs.status" + +# Colors for terminal output (disabled if not a TTY) +if [[ -t 1 ]]; then + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[0;33m' + BLUE='\033[0;34m' + CYAN='\033[0;36m' + BOLD='\033[1m' + NC='\033[0m' # No Color +else + RED='' + GREEN='' + YELLOW='' + BLUE='' + CYAN='' + BOLD='' + NC='' +fi + +log() { + echo -e "${CYAN}[virtualbuddy-growfs]${NC} $*" +} + +log_step() { + echo -e "${BLUE}${BOLD}==>${NC} $*" +} + +log_success() { + echo -e "${GREEN}✓${NC} $*" +} + +log_warning() { + echo -e "${YELLOW}⚠${NC} $*" +} + +log_verbose() { + if $VERBOSE; then + log "$*" + fi +} + +die() { + echo -e "${RED}${BOLD}ERROR:${NC} $*" >&2 + exit 1 +} + +# Write status to file for notification service +write_status() { + local status="$1" + local message="$2" + local old_size="${3:-}" + local new_size="${4:-}" + + if ! $DRY_RUN; then + cat > "$STATUS_FILE" </dev/null +} + +# Get the underlying device for a device-mapper device (LUKS or LVM) +get_dm_backing_device() { + local mapper_name="$1" + # Get the slave device from sysfs + local dm_name + dm_name=$(basename "$mapper_name") + + if [[ -d "/sys/block/$dm_name/slaves" ]]; then + local slave + slave=$(ls "/sys/block/$dm_name/slaves" 2>/dev/null | head -1) + if [[ -n "$slave" ]]; then + echo "/dev/$slave" + return 0 + fi + fi + + # Fallback: try to get it from dmsetup + local backing + backing=$(dmsetup deps -o devname "$mapper_name" 2>/dev/null | grep -oP '\(\K[^)]+' | head -1) + if [[ -n "$backing" ]]; then + echo "/dev/$backing" + return 0 + fi + + return 1 +} + +# Alias for backward compatibility +get_luks_backing_device() { + get_dm_backing_device "$1" +} + +# Check if device is an LVM logical volume +is_lvm_lv() { + local device="$1" + if command -v lvs &>/dev/null; then + lvs "$device" &>/dev/null + return $? + fi + return 1 +} + +# Get LVM volume group name from logical volume +get_lvm_vg() { + local lv_device="$1" + lvs --noheadings -o vg_name "$lv_device" 2>/dev/null | tr -d ' ' +} + +# Get LVM logical volume name +get_lvm_lv_name() { + local lv_device="$1" + lvs --noheadings -o lv_name "$lv_device" 2>/dev/null | tr -d ' ' +} + +# Get physical volume device(s) for a volume group +get_lvm_pv() { + local vg_name="$1" + # Get the first PV (most common case is single PV) + pvs --noheadings -o pv_name -S "vg_name=$vg_name" 2>/dev/null | head -1 | tr -d ' ' +} + +# Resize LVM physical volume +resize_lvm_pv() { + local pv_device="$1" + + log "Resizing LVM physical volume $pv_device..." + + if $DRY_RUN; then + log "[DRY-RUN] Would run: pvresize $pv_device" + return 0 + fi + + pvresize "$pv_device" +} + +# Extend LVM logical volume to use all free space +resize_lvm_lv() { + local lv_device="$1" + + log "Extending LVM logical volume $lv_device..." + + if $DRY_RUN; then + log "[DRY-RUN] Would run: lvextend -l +100%FREE $lv_device" + return 0 + fi + + # Check if there's free space in the VG first + local vg_name + vg_name=$(get_lvm_vg "$lv_device") + local free_extents + free_extents=$(vgs --noheadings -o vg_free_count "$vg_name" 2>/dev/null | tr -d ' ') + + if [[ -z "$free_extents" ]] || [[ "$free_extents" -eq 0 ]]; then + log_verbose "No free extents in VG $vg_name, skipping LV extend" + return 0 + fi + + lvextend -l +100%FREE "$lv_device" +} + +# Get the disk device from a partition (e.g., /dev/vda2 -> /dev/vda) +get_disk_from_partition() { + local partition="$1" + # Remove partition number suffix + echo "$partition" | sed 's/[0-9]*$//' | sed 's/p$//' +} + +# Get partition number from device (e.g., /dev/vda2 -> 2) +get_partition_number() { + local partition="$1" + echo "$partition" | grep -oE '[0-9]+$' +} + +# Check if partition can be grown (has free space after it) +check_growable() { + local disk="$1" + local partition_num="$2" + + # Use growpart --dry-run to check + if command -v growpart &>/dev/null; then + if growpart --dry-run "$disk" "$partition_num" 2>&1 | grep -q "NOCHANGE"; then + return 1 # No change needed + fi + return 0 # Can grow + fi + + # Fallback: check with parted + if command -v parted &>/dev/null; then + local part_end free_space + part_end=$(parted -s "$disk" unit s print 2>/dev/null | awk "/^ *$partition_num / {print \$3}" | tr -d 's') + free_space=$(parted -s "$disk" unit s print free 2>/dev/null | tail -1 | awk '{print $3}' | tr -d 's') + + if [[ -n "$free_space" ]] && [[ "$free_space" -gt 2048 ]]; then + return 0 # Has free space + fi + fi + + return 1 # No free space or can't determine +} + +# Grow the partition +grow_partition() { + local disk="$1" + local partition_num="$2" + + log "Growing partition ${disk}${partition_num}..." + + if $DRY_RUN; then + log "[DRY-RUN] Would run: growpart $disk $partition_num" + return 0 + fi + + if command -v growpart &>/dev/null; then + growpart "$disk" "$partition_num" + else + die "growpart not found. Install cloud-guest-utils package." + fi +} + +# Resize LUKS container +resize_luks() { + local luks_device="$1" + local mapper_name="$2" + + log "Resizing LUKS container $mapper_name..." + + if $DRY_RUN; then + log "[DRY-RUN] Would run: cryptsetup resize $mapper_name" + return 0 + fi + + cryptsetup resize "$mapper_name" +} + +# Detect filesystem type +detect_fs_type() { + local device="$1" + blkid -s TYPE -o value "$device" 2>/dev/null +} + +# Resize filesystem +resize_filesystem() { + local device="$1" + local fs_type="$2" + + log "Resizing $fs_type filesystem on $device..." + + if $DRY_RUN; then + case "$fs_type" in + ext4|ext3|ext2) + log "[DRY-RUN] Would run: resize2fs $device" + ;; + xfs) + log "[DRY-RUN] Would run: xfs_growfs /" + ;; + btrfs) + log "[DRY-RUN] Would run: btrfs filesystem resize max /" + ;; + esac + return 0 + fi + + case "$fs_type" in + ext4|ext3|ext2) + resize2fs "$device" + ;; + xfs) + xfs_growfs / + ;; + btrfs) + btrfs filesystem resize max / + ;; + *) + log "WARNING: Unknown filesystem type '$fs_type', skipping resize" + return 1 + ;; + esac +} + +# Get human-readable disk size for root filesystem +get_root_size() { + df -h / | tail -1 | awk '{print $2}' +} + +# Main logic +main() { + echo "" + echo -e "${BLUE}${BOLD}╔══════════════════════════════════════════════════════════════╗${NC}" + echo -e "${BLUE}${BOLD}║ VirtualBuddy Filesystem Resize v$VERSION ║${NC}" + echo -e "${BLUE}${BOLD}╚══════════════════════════════════════════════════════════════╝${NC}" + echo "" + + if $DRY_RUN; then + log_warning "Running in dry-run mode - no changes will be made" + echo "" + fi + + # Save initial size + local initial_size + initial_size=$(get_root_size) + + # Find root filesystem + log_step "Detecting storage configuration..." + local root_device + root_device=$(find_root_device) + log_verbose "Root device: $root_device" + + local fs_device="$root_device" + local lv_device="" + local pv_device="" + local luks_mapper_name="" + local partition_device="" + + # Detect storage stack: partition -> [LUKS] -> [LVM] -> filesystem + # We need to walk backwards from the filesystem to find the partition + + if [[ "$root_device" == /dev/mapper/* ]]; then + # Root is on a device-mapper device (could be LVM, LUKS, or both) + + # Check if it's an LVM logical volume + if is_lvm_lv "$root_device"; then + lv_device="$root_device" + local vg_name + vg_name=$(get_lvm_vg "$lv_device") + local lv_name + lv_name=$(get_lvm_lv_name "$lv_device") + log_success "Detected LVM logical volume: $lv_name (VG: $vg_name)" + + # Find the physical volume + pv_device=$(get_lvm_pv "$vg_name") + if [[ -z "$pv_device" ]]; then + die "Could not determine physical volume for VG $vg_name" + fi + log_verbose "LVM physical volume: $pv_device" + + # Check if PV is on LUKS + if [[ "$pv_device" == /dev/mapper/* ]]; then + luks_mapper_name=$(basename "$pv_device") + partition_device=$(get_dm_backing_device "$luks_mapper_name") + if [[ -z "$partition_device" ]]; then + die "Could not determine backing device for LUKS container" + fi + log_success "Detected LUKS encryption on $partition_device" + else + # PV is directly on a partition (no LUKS) + partition_device="$pv_device" + fi + else + # Not LVM, assume it's LUKS directly on a partition + luks_mapper_name=$(basename "$root_device") + partition_device=$(get_dm_backing_device "$luks_mapper_name") + if [[ -z "$partition_device" ]]; then + die "Could not determine backing device for LUKS container" + fi + log_success "Detected LUKS encryption on $partition_device" + fi + else + # Root is directly on a partition (no LUKS, no LVM) + partition_device="$root_device" + log_success "Detected direct partition: $partition_device" + fi + + # Get disk and partition number + local disk partition_num + disk=$(get_disk_from_partition "$partition_device") + partition_num=$(get_partition_number "$partition_device") + + log_verbose "Disk: $disk, Partition: $partition_num" + echo "" + + # Check if partition can be grown + log_step "Checking for available space..." + local partition_grew=false + local lvm_can_extend=false + local fs_can_extend=false + + if check_growable "$disk" "$partition_num"; then + log_success "Free space detected after partition!" + partition_grew=true + else + log "Partition is already at maximum size." + # VirtualBuddy may have resized the partition at host level, + # but LVM/filesystem might still need extending + fi + + # Check if LVM has free space that needs extending + if [[ -n "$lv_device" ]]; then + local vg_name + vg_name=$(get_lvm_vg "$lv_device") + + # First, we need to ensure PV knows about the full device size + # by checking if pvresize would add space + if [[ -n "$pv_device" ]]; then + # Get current PV size and compare to device size + local pv_size_bytes dev_size_bytes + pv_size_bytes=$(pvs --noheadings --units b -o pv_size "$pv_device" 2>/dev/null | tr -d ' bB') + if [[ -b "$pv_device" ]]; then + dev_size_bytes=$(blockdev --getsize64 "$pv_device" 2>/dev/null || echo "0") + else + dev_size_bytes="0" + fi + + # Allow some tolerance (1MB) for metadata + local size_diff=$((dev_size_bytes - pv_size_bytes)) + if [[ $size_diff -gt 1048576 ]]; then + log_verbose "PV $pv_device can be extended (${size_diff} bytes available)" + lvm_can_extend=true + fi + fi + + # Also check for existing free extents in the VG + local free_extents + free_extents=$(vgs --noheadings -o vg_free_count "$vg_name" 2>/dev/null | tr -d ' ') + if [[ -n "$free_extents" ]] && [[ "$free_extents" -gt 0 ]]; then + log_verbose "VG $vg_name has $free_extents free extents" + lvm_can_extend=true + fi + fi + + # Check if filesystem can be extended (for LUKS-without-LVM case) + # This handles when VirtualBuddy resizes partition and LUKS auto-expands, + # but filesystem inside still needs resizing + if [[ -z "$lv_device" ]] && [[ -n "$fs_device" ]]; then + local fs_size_bytes device_size_bytes + # Get filesystem size from df (in 1K blocks, convert to bytes) + fs_size_bytes=$(df --block-size=1 "$fs_device" 2>/dev/null | tail -1 | awk '{print $2}') + # Get underlying device size + if [[ -b "$fs_device" ]]; then + device_size_bytes=$(blockdev --getsize64 "$fs_device" 2>/dev/null || echo "0") + else + device_size_bytes="0" + fi + + if [[ -n "$fs_size_bytes" ]] && [[ -n "$device_size_bytes" ]] && [[ "$device_size_bytes" -gt 0 ]]; then + # Allow 5% tolerance for filesystem overhead + local threshold=$((device_size_bytes * 95 / 100)) + if [[ "$fs_size_bytes" -lt "$threshold" ]]; then + local unused_bytes=$((device_size_bytes - fs_size_bytes)) + local unused_gb=$((unused_bytes / 1073741824)) + log_verbose "Filesystem can be extended (~${unused_gb}GB available)" + fs_can_extend=true + fi + fi + fi + + # Exit early only if nothing needs to be done + if ! $partition_grew && ! $lvm_can_extend && ! $fs_can_extend; then + echo "" + log "No resize needed - all storage layers already at maximum size." + log "Current root filesystem size: $initial_size" + echo "" + write_status "unchanged" "All storage layers already at maximum size" "$initial_size" "$initial_size" + exit 0 + fi + + echo "" + log_step "Resizing storage layers..." + echo "" + + # Step 1: Grow the partition (if needed) + if $partition_grew; then + log "Step 1/4: Growing partition..." + grow_partition "$disk" "$partition_num" + log_success "Partition grown" + + # Inform kernel of partition change + if ! $DRY_RUN; then + partprobe "$disk" 2>/dev/null || true + sleep 1 + fi + else + log_verbose "Step 1/4: Partition already at max - skipping growpart" + fi + + # Step 2: If LUKS, resize the container + if [[ -n "$luks_mapper_name" ]]; then + log "Step 2/4: Resizing LUKS container..." + resize_luks "$partition_device" "$luks_mapper_name" + log_success "LUKS container resized" + else + log_verbose "Step 2/4: No LUKS - skipping" + fi + + # Step 3: If LVM, resize PV and extend LV + if [[ -n "$lv_device" ]]; then + log "Step 3/4: Resizing LVM volumes..." + # Always try to resize the physical volume (it's safe even if no change needed) + if [[ -n "$pv_device" ]]; then + resize_lvm_pv "$pv_device" + fi + + # Extend the logical volume + resize_lvm_lv "$lv_device" + log_success "LVM volumes resized" + else + log_verbose "Step 3/4: No LVM - skipping" + fi + + # Step 4: Resize the filesystem + local fs_type + fs_type=$(detect_fs_type "$fs_device") + log "Step 4/4: Resizing $fs_type filesystem..." + + if [[ -n "$fs_type" ]]; then + resize_filesystem "$fs_device" "$fs_type" + log_success "Filesystem resized" + fi + + # Get new size + local new_size + new_size=$(get_root_size) + + echo "" + echo -e "${GREEN}${BOLD}╔══════════════════════════════════════════════════════════════╗${NC}" + echo -e "${GREEN}${BOLD}║ Filesystem Resize Complete! ║${NC}" + echo -e "${GREEN}${BOLD}╚══════════════════════════════════════════════════════════════╝${NC}" + echo "" + echo -e " Previous size: ${YELLOW}$initial_size${NC}" + echo -e " New size: ${GREEN}${BOLD}$new_size${NC}" + echo "" + + # Show disk usage + if ! $DRY_RUN; then + echo "Current disk usage:" + df -h / | tail -1 | awk '{printf " Total: %s Used: %s Available: %s Usage: %s\n", $2, $3, $4, $5}' + echo "" + + # Write status for notification service + write_status "resized" "Filesystem resized from $initial_size to $new_size" "$initial_size" "$new_size" + fi +} + +main "$@" diff --git a/LinuxGuestAdditions/virtualbuddy-growfs.service b/LinuxGuestAdditions/virtualbuddy-growfs.service new file mode 100644 index 00000000..87d78472 --- /dev/null +++ b/LinuxGuestAdditions/virtualbuddy-growfs.service @@ -0,0 +1,17 @@ +[Unit] +Description=VirtualBuddy Guest Additions - Grow Filesystem +Documentation=https://github.com/insidegui/VirtualBuddy +After=local-fs.target +Before=systemd-user-sessions.service +ConditionVirtualization=vm +DefaultDependencies=no + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/virtualbuddy-growfs --verbose +RemainAfterExit=yes +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/LinuxGuestAdditions/virtualbuddy-notify b/LinuxGuestAdditions/virtualbuddy-notify new file mode 100644 index 00000000..185b00d6 --- /dev/null +++ b/LinuxGuestAdditions/virtualbuddy-notify @@ -0,0 +1,61 @@ +#!/bin/bash +# +# VirtualBuddy Linux Guest Additions - Desktop Notification Script +# +# This script is run at user login to display notifications about +# disk resize operations that occurred during boot. +# +# Usage: virtualbuddy-notify +# + +STATUS_FILE="/var/run/virtualbuddy-growfs.status" +NOTIFICATION_SENT_FILE="/tmp/virtualbuddy-notify-sent-$$" + +# Check if we're in a desktop session +if [[ -z "${DISPLAY:-}" ]] && [[ -z "${WAYLAND_DISPLAY:-}" ]]; then + exit 0 +fi + +# Check if notify-send is available +if ! command -v notify-send &>/dev/null; then + exit 0 +fi + +# Check if status file exists +if [[ ! -f "$STATUS_FILE" ]]; then + exit 0 +fi + +# Read status file +source "$STATUS_FILE" 2>/dev/null || exit 0 + +# Check if we already sent a notification for this status +if [[ -f "/tmp/virtualbuddy-notified-$(echo "$TIMESTAMP" | md5sum | cut -d' ' -f1)" ]]; then + exit 0 +fi + +# Send appropriate notification based on status +case "$STATUS" in + resized) + notify-send \ + -u normal \ + -i "drive-harddisk" \ + -t 10000 \ + "VirtualBuddy: Disk Resized" \ + "Your disk has been automatically expanded.\n\nPrevious: $OLD_SIZE\nNew: $NEW_SIZE" + ;; + unchanged) + # Don't notify if nothing changed - that's expected + ;; + error) + notify-send \ + -u critical \ + -i "dialog-error" \ + -t 15000 \ + "VirtualBuddy: Resize Failed" \ + "$MESSAGE\n\nRun 'journalctl -u virtualbuddy-growfs' for details." + ;; +esac + +# Mark as notified +touch "/tmp/virtualbuddy-notified-$(echo "$TIMESTAMP" | md5sum | cut -d' ' -f1)" 2>/dev/null || true diff --git a/LinuxGuestAdditions/virtualbuddy-notify.service b/LinuxGuestAdditions/virtualbuddy-notify.service new file mode 100644 index 00000000..4d2a912b --- /dev/null +++ b/LinuxGuestAdditions/virtualbuddy-notify.service @@ -0,0 +1,12 @@ +[Unit] +Description=VirtualBuddy Guest Additions - Desktop Notification +Documentation=https://github.com/insidegui/VirtualBuddy +After=graphical-session.target + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/virtualbuddy-notify +RemainAfterExit=no + +[Install] +WantedBy=graphical-session.target diff --git a/VirtualBuddy.xcodeproj/project.pbxproj b/VirtualBuddy.xcodeproj/project.pbxproj index 3dd511d9..1290697c 100644 --- a/VirtualBuddy.xcodeproj/project.pbxproj +++ b/VirtualBuddy.xcodeproj/project.pbxproj @@ -25,6 +25,9 @@ /* Begin PBXBuildFile section */ 0196B45329292B2A00614EF1 /* LinuxVirtualMachineConfigurationHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0196B45229292B2A00614EF1 /* LinuxVirtualMachineConfigurationHelper.swift */; }; 4BA6BE7D293D22E500F396AE /* VirtualMachineConfigurationHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BA6BE7C293D22E500F396AE /* VirtualMachineConfigurationHelper.swift */; }; + E85386982F0578690032FB67 /* LinuxGuestAdditionsDiskImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E85386972F0578690032FB67 /* LinuxGuestAdditionsDiskImage.swift */; }; + E853869A2F0578A60032FB67 /* CreateLinuxGuestImage.sh in Resources */ = {isa = PBXBuildFile; fileRef = E85386992F0578A60032FB67 /* CreateLinuxGuestImage.sh */; }; + E85386AA2F0579000032FB67 /* LinuxGuestAdditions in Resources */ = {isa = PBXBuildFile; fileRef = E85386A22F0578E60032FB67 /* LinuxGuestAdditions */; }; F40A1E9D2C1873CA0033E47D /* VBBuildType.swift in Sources */ = {isa = PBXBuildFile; fileRef = F40A1E9C2C1873C60033E47D /* VBBuildType.swift */; }; F413696229916F6E002CE8D3 /* StatusItemButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F413695129916F6E002CE8D3 /* StatusItemButton.swift */; }; F413696329916F6E002CE8D3 /* StatusBarPanelChrome.swift in Sources */ = {isa = PBXBuildFile; fileRef = F413695229916F6E002CE8D3 /* StatusBarPanelChrome.swift */; }; @@ -342,6 +345,7 @@ F4FC98392BB386A000E511C9 /* ContinuousProgressIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4FC98382BB386A000E511C9 /* ContinuousProgressIndicator.swift */; }; F4FC983B2BB386B500E511C9 /* MaskProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4FC983A2BB386B500E511C9 /* MaskProgressView.swift */; }; F4FC983D2BB386DD00E511C9 /* VMProgressOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4FC983C2BB386DD00E511C9 /* VMProgressOverlay.swift */; }; + VB01DISKRESIZ00002A0102 /* VBDiskResizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = VB01DISKRESIZ00001A0101 /* VBDiskResizer.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -545,6 +549,9 @@ /* Begin PBXFileReference section */ 0196B45229292B2A00614EF1 /* LinuxVirtualMachineConfigurationHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LinuxVirtualMachineConfigurationHelper.swift; sourceTree = ""; }; 4BA6BE7C293D22E500F396AE /* VirtualMachineConfigurationHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VirtualMachineConfigurationHelper.swift; sourceTree = ""; }; + E85386972F0578690032FB67 /* LinuxGuestAdditionsDiskImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinuxGuestAdditionsDiskImage.swift; sourceTree = ""; }; + E85386992F0578A60032FB67 /* CreateLinuxGuestImage.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = CreateLinuxGuestImage.sh; sourceTree = ""; }; + E85386A22F0578E60032FB67 /* LinuxGuestAdditions */ = {isa = PBXFileReference; lastKnownFileType = folder; path = LinuxGuestAdditions; sourceTree = SOURCE_ROOT; }; F40A1E9C2C1873C60033E47D /* VBBuildType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VBBuildType.swift; sourceTree = ""; }; F413695129916F6E002CE8D3 /* StatusItemButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusItemButton.swift; sourceTree = ""; }; F413695229916F6E002CE8D3 /* StatusBarPanelChrome.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusBarPanelChrome.swift; sourceTree = ""; }; @@ -853,6 +860,7 @@ F4FC98382BB386A000E511C9 /* ContinuousProgressIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContinuousProgressIndicator.swift; sourceTree = ""; }; F4FC983A2BB386B500E511C9 /* MaskProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaskProgressView.swift; sourceTree = ""; }; F4FC983C2BB386DD00E511C9 /* VMProgressOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMProgressOverlay.swift; sourceTree = ""; }; + VB01DISKRESIZ00001A0101 /* VBDiskResizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VBDiskResizer.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1259,6 +1267,9 @@ F443620D29B79D6800745B43 /* GuestSupport */ = { isa = PBXGroup; children = ( + E85386992F0578A60032FB67 /* CreateLinuxGuestImage.sh */, + E85386A22F0578E60032FB67 /* LinuxGuestAdditions */, + E85386972F0578690032FB67 /* LinuxGuestAdditionsDiskImage.swift */, F443620E29B7A0C600745B43 /* CreateGuestImage.sh */, F443620929B7947A00745B43 /* GuestAdditionsDiskImage.swift */, ); @@ -1884,6 +1895,7 @@ F485B91E2BB2F4AC004B3C2B /* Bundle+Version.swift */, F444D1332BB478AD00AB786F /* VBMemoryLeakDebugAssertions.swift */, F453C4BA2DF231B7007EAD5F /* PreventTerminationAssertion.swift */, + VB01DISKRESIZ00001A0101 /* VBDiskResizer.swift */, ); path = Utilities; sourceTree = ""; @@ -2360,7 +2372,9 @@ buildActionMask = 2147483647; files = ( F453C4922DF1D213007EAD5F /* FakeRestoreImage.ipsw in Resources */, + E85386AA2F0579000032FB67 /* LinuxGuestAdditions in Resources */, F453C45D2DF0D28A007EAD5F /* README.md in Resources */, + E853869A2F0578A60032FB67 /* CreateLinuxGuestImage.sh in Resources */, F417257428877478004FF8A7 /* VirtualCore.xcassets in Resources */, F443620F29B7A0C600745B43 /* CreateGuestImage.sh in Resources */, ); @@ -2638,6 +2652,7 @@ files = ( F4E7DF922BB3338900C459FC /* NSImage+HEIC.swift in Sources */, F417257128877121004FF8A7 /* DiskImageGenerator.swift in Sources */, + E85386982F0578690032FB67 /* LinuxGuestAdditionsDiskImage.swift in Sources */, F4F9B416284CE0F900F21737 /* VBSettings.swift in Sources */, F42CF4A82DF5FEC3001DE049 /* BlurHashToken.swift in Sources */, F41725762887758A004FF8A7 /* RandomNameGenerator.swift in Sources */, @@ -2677,6 +2692,7 @@ F485B91D2BB2F0D9004B3C2B /* ProcessInfo+ECID.swift in Sources */, F444D1342BB478AD00AB786F /* VBMemoryLeakDebugAssertions.swift in Sources */, F4C237502888AF67001FF286 /* LogStreamer.swift in Sources */, + VB01DISKRESIZ00002A0102 /* VBDiskResizer.swift in Sources */, F4F9B41A284CE37C00F21737 /* Logging.swift in Sources */, F4B5C5D728870619005AA632 /* ConfigurationModels+Validation.swift in Sources */, F4A7FB3B2BB5E79100E4C12A /* DirectoryObserver.swift in Sources */, diff --git a/VirtualBuddy/Bootstrap/VirtualBuddyAppDelegate.swift b/VirtualBuddy/Bootstrap/VirtualBuddyAppDelegate.swift index dbead78c..085c4515 100644 --- a/VirtualBuddy/Bootstrap/VirtualBuddyAppDelegate.swift +++ b/VirtualBuddy/Bootstrap/VirtualBuddyAppDelegate.swift @@ -49,8 +49,21 @@ import SwiftUI } .store(in: &cancellables) + LinuxGuestAdditionsDiskImage.current.$state.sink { state in + switch state { + case .ready: + self.logger.debug("Linux guest tools ISO ready") + case .installing: + self.logger.debug("Linux guest tools ISO generating") + case .installFailed(let error): + self.logger.debug("Linux guest tools ISO generation failed - \(error, privacy: .public)") + } + } + .store(in: &cancellables) + Task { try? await GuestAdditionsDiskImage.current.installIfNeeded() + try? await LinuxGuestAdditionsDiskImage.current.installIfNeeded() } #if DEBUG diff --git a/VirtualCore/Source/GuestSupport/CreateLinuxGuestImage.sh b/VirtualCore/Source/GuestSupport/CreateLinuxGuestImage.sh new file mode 100644 index 00000000..6ffe1e91 --- /dev/null +++ b/VirtualCore/Source/GuestSupport/CreateLinuxGuestImage.sh @@ -0,0 +1,134 @@ +#!/bin/sh + +: ' +This script is used by VirtualBuddy to dynamically generate an ISO disk image +containing the Linux guest tools that can be mounted in a Linux virtual machine. + +Images are stored in ~/Library/Application Support/VirtualBuddy/_GuestImage. + +Alongside the images, the app stores a digest of the contents, +so that it can be automatically updated whenever something changes. +' + +SOURCE_DIR="$1" +DEST_PATH="$2" +DIGEST="$3" + +if [ -z "$SOURCE_DIR" ]; then + echo "Shell script invocation error: missing SOURCE_DIR value as first argument" 1>&2 + exit 7 +fi + +if [ -z "$DEST_PATH" ]; then + echo "Shell script invocation error: missing DEST_PATH value as second argument" 1>&2 + exit 7 +fi + +if [ -z "$DIGEST" ]; then + echo "Shell script invocation error: missing DIGEST value as third argument" 1>&2 + exit 7 +fi + +if [ ! -d "$SOURCE_DIR" ]; then + echo "Shell script invocation error: source directory doesn't exist at $SOURCE_DIR" 1>&2 + exit 7 +fi + +VBROOT="$HOME/Library/Application Support/VirtualBuddy" +GUEST_ISO_DEST_PATH="$VBROOT/_GuestImage" +STAGING_DIR="$GUEST_ISO_DEST_PATH/staging-linux" + +# Ensure destination directory exists +mkdir -p "$GUEST_ISO_DEST_PATH" 2>/dev/null || true + +# Clean up any previous staging directory +rm -rf "$STAGING_DIR" 2>/dev/null || true +mkdir -p "$STAGING_DIR" + +# Copy source files to staging (excluding DESIGN.md and other non-essential files) +cp "$SOURCE_DIR/install.sh" "$STAGING_DIR/" +cp "$SOURCE_DIR/uninstall.sh" "$STAGING_DIR/" +cp "$SOURCE_DIR/virtualbuddy-growfs" "$STAGING_DIR/" +cp "$SOURCE_DIR/virtualbuddy-growfs.service" "$STAGING_DIR/" +cp "$SOURCE_DIR/virtualbuddy-notify" "$STAGING_DIR/" +cp "$SOURCE_DIR/virtualbuddy-notify.service" "$STAGING_DIR/" +cp "$SOURCE_DIR/INSTALL.md" "$STAGING_DIR/" + +# Write version/digest file +echo "$DIGEST" > "$STAGING_DIR/VERSION" + +# Create autorun.sh if it doesn't exist in source +if [ ! -f "$SOURCE_DIR/autorun.sh" ]; then + cat > "$STAGING_DIR/autorun.sh" << 'AUTORUN_EOF' +#!/bin/bash +# +# VirtualBuddy Linux Guest Tools - Quick Start +# +# Run this script with: sudo /path/to/autorun.sh +# Or use the full installer: sudo /path/to/install.sh + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +echo "VirtualBuddy Linux Guest Tools" +echo "==============================" +echo "" + +# Check if already installed and compare versions +if [[ -f /etc/virtualbuddy/version ]]; then + INSTALLED_VERSION=$(cat /etc/virtualbuddy/version 2>/dev/null) + NEW_VERSION=$(cat "$SCRIPT_DIR/VERSION" 2>/dev/null) + + if [[ "$INSTALLED_VERSION" == "$NEW_VERSION" ]]; then + echo "Guest tools already installed and up to date." + echo "Version: $INSTALLED_VERSION" + echo "" + echo "To reinstall, run: sudo $SCRIPT_DIR/install.sh" + exit 0 + else + echo "Update available!" + echo "Installed: $INSTALLED_VERSION" + echo "Available: $NEW_VERSION" + echo "" + fi +fi + +# Run the full installer +exec "$SCRIPT_DIR/install.sh" +AUTORUN_EOF + chmod +x "$STAGING_DIR/autorun.sh" +else + cp "$SOURCE_DIR/autorun.sh" "$STAGING_DIR/" +fi + +# Make scripts executable +chmod +x "$STAGING_DIR/install.sh" +chmod +x "$STAGING_DIR/uninstall.sh" +chmod +x "$STAGING_DIR/virtualbuddy-growfs" +chmod +x "$STAGING_DIR/virtualbuddy-notify" + +# Remove any existing ISO at destination +rm -f "$DEST_PATH" 2>/dev/null || true + +# Create ISO using hdiutil +# -iso: Create ISO 9660 filesystem +# -joliet: Add Joliet extensions for longer filenames +# -joliet-volume-name: Set the volume name +hdiutil makehybrid \ + -iso \ + -joliet \ + -joliet-volume-name "VBTOOLS" \ + -o "$DEST_PATH" \ + "$STAGING_DIR" || { + echo "Failed to create Linux guest tools ISO: hdiutil exit code $?" 1>&2 + rm -rf "$STAGING_DIR" 2>/dev/null || true + exit 1 + } + +# Write digest file alongside the ISO +DIGEST_PATH="${DEST_PATH%.iso}.digest" +echo "$DIGEST" > "$DIGEST_PATH" + +# Cleanup staging directory +rm -rf "$STAGING_DIR" 2>/dev/null || true + +echo "OK" diff --git a/VirtualCore/Source/GuestSupport/LinuxGuestAdditionsDiskImage.swift b/VirtualCore/Source/GuestSupport/LinuxGuestAdditionsDiskImage.swift new file mode 100644 index 00000000..9160f826 --- /dev/null +++ b/VirtualCore/Source/GuestSupport/LinuxGuestAdditionsDiskImage.swift @@ -0,0 +1,258 @@ +// +// LinuxGuestAdditionsDiskImage.swift +// VirtualCore +// +// Created by VirtualBuddy on 2024. +// + +import Foundation +import Virtualization +import CryptoKit +import OSLog +import Combine + +/// Manages the Linux guest tools ISO disk image that gets attached to Linux VMs. +/// Similar to `GuestAdditionsDiskImage` but creates an ISO instead of DMG. +public final class LinuxGuestAdditionsDiskImage: ObservableObject { + + private lazy var logger = Logger(subsystem: VirtualCoreConstants.subsystemName, category: String(describing: Self.self)) + + public static let current = LinuxGuestAdditionsDiskImage() + + public enum State: CustomStringConvertible { + case ready + case installing + case installFailed(Error) + + public var description: String { + switch self { + case .ready: "Ready" + case .installing: "Installing" + case .installFailed(let error): "Failed: \(error)" + } + } + } + + @MainActor + @Published public private(set) var state = State.ready + + public func installIfNeeded() async throws { + do { + logger.debug(#function) + + func performInstall(with digest: String) async throws { + await MainActor.run { state = .installing } + + try await writeGuestImage(with: digest) + + await MainActor.run { state = .ready } + } + + let embeddedDigest = try computeEmbeddedToolsDigest() + + if let currentlyInstalledDigest { + logger.debug("Embedded Linux tools digest: \(embeddedDigest, privacy: .public) / Library digest: \(currentlyInstalledDigest, privacy: .public)") + + guard embeddedDigest != currentlyInstalledDigest else { + logger.debug("Linux tools digests match, skipping ISO generation") + + await MainActor.run { state = .ready } + + return + } + + logger.debug("Linux tools digests don't match, generating new ISO") + + try await performInstall(with: embeddedDigest) + } else { + logger.debug("No digest for currently installed Linux tools, assuming not installed. Embedded digest: \(embeddedDigest, privacy: .public)") + + try await performInstall(with: embeddedDigest) + } + } catch { + logger.error("Linux guest tools ISO generation failed. \(error, privacy: .public)") + + await MainActor.run { state = .installFailed(error) } + + throw error + } + } + + // MARK: File Paths + + private var embeddedToolsURL: URL { + get throws { + guard let url = Bundle.virtualCore.url(forResource: "LinuxGuestAdditions", withExtension: nil) else { + throw Failure("Couldn't get LinuxGuestAdditions URL within VirtualCore bundle") + } + + guard FileManager.default.fileExists(atPath: url.path) else { + throw Failure("LinuxGuestAdditions doesn't exist at \(url.path)") + } + + return url + } + } + + private var generatorScriptURL: URL { + get throws { + guard let url = Bundle.virtualCore.url(forResource: "CreateLinuxGuestImage", withExtension: "sh") else { + throw Failure("Couldn't get CreateLinuxGuestImage.sh URL within VirtualCore bundle") + } + + guard FileManager.default.fileExists(atPath: url.path) else { + throw Failure("CreateLinuxGuestImage.sh doesn't exist at \(url.path)") + } + + return url + } + } + + private var _imageBaseName: String { "VirtualBuddyLinuxTools" } + + private var imageName: String { + if let suffix = VBBuildType.current.guestAdditionsImageSuffix { + _imageBaseName + suffix + } else { + _imageBaseName + } + } + + private var imagesRootURL: URL { GuestAdditionsDiskImage.imagesRootURL } + + private var installedImageDigestURL: URL { + imagesRootURL + .appendingPathComponent(imageName) + .appendingPathExtension("digest") + } + + public var installedImageURL: URL { + imagesRootURL + .appendingPathComponent(imageName) + .appendingPathExtension("iso") + } + + // MARK: Digest + + private var currentlyInstalledDigest: String? { + guard FileManager.default.fileExists(atPath: installedImageDigestURL.path) else { + return nil + } + do { + return try String(contentsOf: installedImageDigestURL) + .trimmingCharacters(in: .whitespacesAndNewlines) + } catch { + logger.error("Failed to read installed Linux tools digest at \(self.installedImageDigestURL.path): \(error, privacy: .public)") + + return nil + } + } + + private func computeEmbeddedToolsDigest() throws -> String { + let url = try embeddedToolsURL + guard let enumerator = FileManager.default.enumerator(at: url, includingPropertiesForKeys: [.contentTypeKey, .isRegularFileKey]) else { + throw Failure("Couldn't instantiate file enumerator for computing Linux tools digest") + } + + var hash = SHA256() + + while let fileURL = enumerator.nextObject() as? URL { + guard let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey]), + values.isRegularFile == true + else { continue } + + // Skip design documents and other non-essential files + let filename = fileURL.lastPathComponent + guard !filename.hasSuffix(".md") || filename == "INSTALL.md" else { continue } + guard !filename.hasPrefix(".") else { continue } + + #if DEBUG + logger.debug("Computing hash for \(fileURL.lastPathComponent)") + #endif + + do { + let data = try Data(contentsOf: fileURL, options: .mappedIfSafe) + hash.update(data: data) + } catch { + logger.warning("Couldn't compute hash for \(fileURL.lastPathComponent): \(error, privacy: .public)") + } + } + + let digest = hash.finalize() + let hashStr = digest.map { String(format: "%02x", $0) }.joined() + + return hashStr + } + + // MARK: Installation + + private func writeGuestImage(with digest: String) async throws { + let scriptPath = try generatorScriptURL.path + let toolsPath = try embeddedToolsURL.path + let destPath = installedImageURL.path + + // Ensure destination directory exists + try FileManager.default.createDirectory(at: imagesRootURL, withIntermediateDirectories: true) + + let args: [String] = [ + scriptPath, + toolsPath, + destPath, + digest + ] + + let p = Process() + p.executableURL = URL(fileURLWithPath: "/bin/sh") + p.arguments = args + let outPipe = Pipe() + let errPipe = Pipe() + p.standardOutput = outPipe + p.standardError = errPipe + + try p.run() + p.waitUntilExit() + + let outData = try outPipe.fileHandleForReading.readToEnd() + let errData = try errPipe.fileHandleForReading.readToEnd() + + #if DEBUG + if let outData, !outData.isEmpty { + logger.debug("#### Linux ISO generator script output (stdout): ####") + logger.debug("\(String(decoding: outData, as: UTF8.self), privacy: .public)") + } + if let errData, !errData.isEmpty { + logger.debug("#### Linux ISO generator script output (stderr): ####") + logger.debug("\(String(decoding: errData, as: UTF8.self), privacy: .public)") + } + #endif + + guard p.terminationStatus == 0 else { + if let message = errData.flatMap({ String(decoding: $0, as: UTF8.self) }) { + throw Failure(message) + } else { + throw Failure("Linux guest tools ISO generator failed with exit code \(p.terminationStatus)") + } + } + + logger.notice("Linux guest tools ISO generated at \(self.installedImageURL.path, privacy: .public)") + } + +} + +// MARK: - Virtualization Extensions + +extension VZVirtioBlockDeviceConfiguration { + + static var linuxGuestToolsDisk: VZVirtioBlockDeviceConfiguration? { + get throws { + let isoURL = LinuxGuestAdditionsDiskImage.current.installedImageURL + + guard FileManager.default.fileExists(atPath: isoURL.path) else { return nil } + + let attachment = try VZDiskImageStorageDeviceAttachment(url: isoURL, readOnly: true) + + return VZVirtioBlockDeviceConfiguration(attachment: attachment) + } + } + +} diff --git a/VirtualCore/Source/Models/Configuration/ConfigurationModels.swift b/VirtualCore/Source/Models/Configuration/ConfigurationModels.swift index 552b87a1..b4fc701c 100644 --- a/VirtualCore/Source/Models/Configuration/ConfigurationModels.swift +++ b/VirtualCore/Source/Models/Configuration/ConfigurationModels.swift @@ -111,6 +111,19 @@ public struct VBManagedDiskImage: Identifiable, Hashable, Codable { } } } + + public var displayName: String { + switch self { + case .raw: + return "Raw Image" + case .dmg: + return "Disk Image (DMG)" + case .sparse: + return "Sparse Image" + case .asif: + return "Apple Sparse Image Format (ASIF)" + } + } } public var id: String = UUID().uuidString @@ -135,6 +148,15 @@ public struct VBManagedDiskImage: Identifiable, Hashable, Codable { format: .raw ) } + + public var canBeResized: Bool { + switch format { + case .raw, .dmg, .sparse: + return true + case .asif: + return false + } + } } /// Configures a storage device. @@ -202,6 +224,11 @@ public struct VBStorageDevice: Identifiable, Hashable, Codable { ) } + public var canBeResized: Bool { + guard case .managedImage(let image) = backing else { return false } + return image.canBeResized + } + public var displayName: String { guard !isBootVolume else { return "Boot" } diff --git a/VirtualCore/Source/Models/Configuration/VBManagedDiskImage+Resize.swift b/VirtualCore/Source/Models/Configuration/VBManagedDiskImage+Resize.swift new file mode 100644 index 00000000..c0a34cce --- /dev/null +++ b/VirtualCore/Source/Models/Configuration/VBManagedDiskImage+Resize.swift @@ -0,0 +1,93 @@ +// +// VBManagedDiskImage+Resize.swift +// VirtualCore +// +// Created by VirtualBuddy on 22/08/25. +// + +import Foundation + +extension VBManagedDiskImage { + + public var canBeResized: Bool { + VBDiskResizer.canResizeFormat(format) + } + + public var displayName: String { + format.displayName + } + + public func resized(to newSize: UInt64) -> VBManagedDiskImage { + var copy = self + copy.size = newSize + return copy + } + + public mutating func resize(to newSize: UInt64, at container: any VBStorageDeviceContainer, guestType: VBGuestType = .mac) async throws { + guard canBeResized else { + throw VBDiskResizeError.unsupportedImageFormat(format) + } + + guard newSize > size else { + throw VBDiskResizeError.cannotShrinkDisk + } + + guard newSize <= Self.maximumExtraDiskImageSize else { + throw VBDiskResizeError.invalidSize(newSize) + } + + let imageURL = container.diskImageURL(for: self) + + try await VBDiskResizer.resizeDiskImage( + at: imageURL, + format: format, + newSize: newSize, + guestType: guestType + ) + + self.size = newSize + } + +} + +extension VBManagedDiskImage.Format { + + public var displayName: String { + switch self { + case .raw: + return "Raw Image" + case .dmg: + return "Disk Image (DMG)" + case .sparse: + return "Sparse Image" + case .asif: + return "Apple Sparse Image Format (ASIF)" + } + } + + public var supportsResize: Bool { + VBDiskResizer.canResizeFormat(self) + } + +} + +extension VBStorageDevice { + + public func canBeResized(in container: any VBStorageDeviceContainer) -> Bool { + guard let managedImage = managedImage else { return false } + guard managedImage.canBeResized else { return false } + + let imageURL = container.diskImageURL(for: self) + return FileManager.default.fileExists(atPath: imageURL.path) + } + + public func resizeDisk(to newSize: UInt64, in container: any VBStorageDeviceContainer, guestType: VBGuestType = .mac) async throws { + guard var managedImage = managedImage else { + throw VBDiskResizeError.unsupportedImageFormat(.raw) + } + + try await managedImage.resize(to: newSize, at: container, guestType: guestType) + backing = .managedImage(managedImage) + } + +} \ No newline at end of file diff --git a/VirtualCore/Source/Models/VBVirtualMachine+Metadata.swift b/VirtualCore/Source/Models/VBVirtualMachine+Metadata.swift index 8820aa7c..a1d1138a 100644 --- a/VirtualCore/Source/Models/VBVirtualMachine+Metadata.swift +++ b/VirtualCore/Source/Models/VBVirtualMachine+Metadata.swift @@ -69,3 +69,119 @@ extension URL { return current } } + +// MARK: - Disk Resize Support + +public extension VBVirtualMachine { + + typealias DiskResizeProgressHandler = @MainActor (_ message: String) -> Void + + /// Checks if any disk images need resizing based on configuration vs actual size + func checkAndResizeDiskImages(progressHandler: DiskResizeProgressHandler? = nil) async throws { + let config = configuration + + func report(_ message: String) async { + guard let progressHandler else { return } + await MainActor.run { + progressHandler(message) + } + } + + let resizableDevices = config.hardware.storageDevices.compactMap { device -> (VBStorageDevice, VBManagedDiskImage)? in + guard case .managedImage(let image) = device.backing else { return nil } + guard image.canBeResized else { return nil } + return (device, image) + } + + guard !resizableDevices.isEmpty else { + await report("Disk images already match their configured sizes.") + return + } + + let formatter: ByteCountFormatter = { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useGB, .useMB, .useTB] + formatter.countStyle = .binary + formatter.includesUnit = true + return formatter + }() + + for (index, entry) in resizableDevices.enumerated() { + let (device, image) = entry + let position = index + 1 + let total = resizableDevices.count + let deviceName = device.displayName + + await report("Checking \(deviceName) (\(position)/\(total))...") + + let imageURL = diskImageURL(for: image) + + guard FileManager.default.fileExists(atPath: imageURL.path) else { + await report("Skipping \(deviceName): disk image not found.") + continue + } + + let attributes = try FileManager.default.attributesOfItem(atPath: imageURL.path) + let actualSize = attributes[.size] as? UInt64 ?? 0 + + if image.size > actualSize { + let targetDescription = formatter.string(fromByteCount: Int64(image.size)) + await report("Expanding \(deviceName) to \(targetDescription) (\(position)/\(total))...") + + try await resizeDiskImage(image, to: image.size) + + await report("\(deviceName) expanded successfully.") + } else if image.size < actualSize { + let actualDescription = formatter.string(fromByteCount: Int64(actualSize)) + await report("\(deviceName) exceeds the configured size (\(actualDescription)); no changes made.") + } else { + let currentDescription = formatter.string(fromByteCount: Int64(actualSize)) + await report("\(deviceName) already uses \(currentDescription).") + } + } + + await report("Disk image checks complete.") + } + + /// Resizes a managed disk image to the specified size + private func resizeDiskImage(_ image: VBManagedDiskImage, to newSize: UInt64) async throws { + let imageURL = diskImageURL(for: image) + NSLog("Resizing disk image at \(imageURL.path) from current size to \(newSize) bytes") + + try await VBDiskResizer.resizeDiskImage( + at: imageURL, + format: image.format, + newSize: newSize, + guestType: configuration.systemType + ) + + NSLog("Successfully resized disk image at \(imageURL.path) to \(newSize) bytes") + } + + /// Validates that all disk images can be resized if needed + func validateDiskResizeCapability() -> [(device: VBStorageDevice, canResize: Bool)] { + let config = configuration + + return config.hardware.storageDevices.compactMap { device in + guard case .managedImage(let image) = device.backing else { return nil } + + let imageURL = diskImageURL(for: image) + let exists = FileManager.default.fileExists(atPath: imageURL.path) + + if !exists { + // New image, no resize needed + return nil + } + + return (device: device, canResize: image.canBeResized) + } + } + + /// Checks if a managed disk image has FileVault (locked volumes) enabled. + /// - Parameter image: The managed disk image to check. + /// - Returns: `true` if the disk image has FileVault-protected (locked) volumes, `false` otherwise. + func checkFileVaultForDiskImage(_ image: VBManagedDiskImage) async -> Bool { + let imageURL = diskImageURL(for: image) + return await VBDiskResizer.checkFileVaultStatus(at: imageURL, format: image.format) + } +} diff --git a/VirtualCore/Source/Utilities/VBDiskResizer.swift b/VirtualCore/Source/Utilities/VBDiskResizer.swift new file mode 100644 index 00000000..6c2d0ea7 --- /dev/null +++ b/VirtualCore/Source/Utilities/VBDiskResizer.swift @@ -0,0 +1,1718 @@ +// +// VBDiskResizer.swift +// VirtualCore +// +// Created by VirtualBuddy on 22/08/25. +// + +import Foundation +import zlib + +public enum VBDiskResizeError: LocalizedError { + case diskImageNotFound(URL) + case unsupportedImageFormat(VBManagedDiskImage.Format) + case insufficientSpace(required: UInt64, available: UInt64) + case cannotShrinkDisk + case systemCommandFailed(String, Int32) + case invalidSize(UInt64) + case apfsVolumesLocked(container: String) + + public var errorDescription: String? { + switch self { + case .diskImageNotFound(let url): + return "Disk image not found at path: \(url.path)" + case .unsupportedImageFormat(let format): + return "Resizing is not supported for \(format.displayName) format" + case .insufficientSpace(let required, let available): + let formatter = ByteCountFormatter() + formatter.countStyle = .file + let requiredStr = formatter.string(fromByteCount: Int64(required)) + let availableStr = formatter.string(fromByteCount: Int64(available)) + return "Insufficient disk space. Required: \(requiredStr), Available: \(availableStr)" + case .cannotShrinkDisk: + return "Cannot shrink disk image. Only expansion is supported for safety reasons." + case .systemCommandFailed(let command, let exitCode): + return "System command '\(command)' failed with exit code \(exitCode)" + case .invalidSize(let size): + return "Invalid size: \(size) bytes. Size must be larger than current disk size." + case .apfsVolumesLocked(let container): + return "The APFS container \(container) contains locked volumes. Unlock the disk (for example by signing into the FileVault-protected guest) and run 'diskutil apfs resizeContainer disk0s2 0' inside the guest to complete the resize." + } + } +} + +private extension FileHandle { + func vbWriteAll(_ data: Data) throws { + if #available(macOS 10.15.4, *) { + try self.write(contentsOf: data) + } else { + self.write(data) + } + } + + func vbRead(upToCount count: Int) throws -> Data? { + if #available(macOS 10.15.4, *) { + return try self.read(upToCount: count) + } else { + return self.readData(ofLength: count) + } + } + + func vbSeek(to offset: UInt64) throws { + if #available(macOS 10.15.4, *) { + _ = try self.seek(toOffset: offset) + } else { + self.seek(toFileOffset: offset) + } + } + + func vbSynchronize() throws { + if #available(macOS 10.15.4, *) { + try self.synchronize() + } else { + self.synchronizeFile() + } + } +} + +public struct VBDiskResizer { + + public enum ResizeStrategy { + case createLargerImage + case expandInPlace + } + + private struct APFSContainerInfo { + let container: String + let physicalStore: String? + let hasLockedVolumes: Bool + } + + private struct APFSContainerDetails { + let capacityCeiling: UInt64 + let physicalStoreSize: UInt64 + } + + private static func sanitizeDeviceIdentifier(_ identifier: String) -> String { + if identifier.hasPrefix("/dev/") { + return String(identifier.dropFirst(5)) + } + return identifier + } + + public static func canResizeFormat(_ format: VBManagedDiskImage.Format) -> Bool { + switch format { + case .raw, .dmg, .sparse: + return true + case .asif: + return false + } + } + + /// Checks if a disk image has FileVault (locked volumes) enabled. + /// This attaches the disk image temporarily to inspect its APFS containers. + /// - Parameters: + /// - url: The URL of the disk image to check. + /// - format: The format of the disk image. + /// - Returns: `true` if the disk image has FileVault-protected (locked) volumes, `false` otherwise. + public static func checkFileVaultStatus(at url: URL, format: VBManagedDiskImage.Format) async -> Bool { + guard canResizeFormat(format) else { return false } + guard FileManager.default.fileExists(atPath: url.path) else { return false } + + // Attach the disk image without mounting + let attachProcess = Process() + attachProcess.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil") + + switch format { + case .raw: + attachProcess.arguments = ["attach", "-imagekey", "diskimage-class=CRawDiskImage", "-nomount", url.path] + case .dmg, .sparse: + attachProcess.arguments = ["attach", "-nomount", url.path] + case .asif: + return false + } + + let attachPipe = Pipe() + attachProcess.standardOutput = attachPipe + attachProcess.standardError = Pipe() + + do { + try attachProcess.run() + attachProcess.waitUntilExit() + } catch { + NSLog("Failed to attach disk image for FileVault check: \(error)") + return false + } + + guard attachProcess.terminationStatus == 0 else { + NSLog("hdiutil attach failed for FileVault check with exit code \(attachProcess.terminationStatus)") + return false + } + + let attachOutput = String(data: attachPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + + guard let deviceNode = extractDeviceNode(from: attachOutput) else { + NSLog("Could not extract device node for FileVault check") + return false + } + + defer { + // Detach the disk image + let detachProcess = Process() + detachProcess.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil") + detachProcess.arguments = ["detach", deviceNode] + try? detachProcess.run() + detachProcess.waitUntilExit() + } + + // Check for locked volumes using the APFS list + if let containerInfo = await findAPFSContainerUsingAPFSList(deviceNode: deviceNode) { + return containerInfo.hasLockedVolumes + } + + return false + } + + public static func recommendedStrategy(for format: VBManagedDiskImage.Format) -> ResizeStrategy { + switch format { + case .raw: + return .expandInPlace // Use in-place expansion to save disk space + case .dmg, .sparse: + return .expandInPlace + case .asif: + return .createLargerImage + } + } + + public static func resizeDiskImage( + at url: URL, + format: VBManagedDiskImage.Format, + newSize: UInt64, + strategy: ResizeStrategy? = nil, + guestType: VBGuestType = .mac + ) async throws { + guard canResizeFormat(format) else { + throw VBDiskResizeError.unsupportedImageFormat(format) + } + + guard FileManager.default.fileExists(atPath: url.path) else { + throw VBDiskResizeError.diskImageNotFound(url) + } + + let currentSize = try await getCurrentImageSize(at: url, format: format) + guard newSize > currentSize else { + throw VBDiskResizeError.cannotShrinkDisk + } + + let finalStrategy = strategy ?? recommendedStrategy(for: format) + + switch finalStrategy { + case .createLargerImage: + try await createLargerImage(at: url, format: format, newSize: newSize, currentSize: currentSize) + case .expandInPlace: + try await expandImageInPlace(at: url, format: format, newSize: newSize, guestType: guestType) + } + + // After resizing the disk image, attempt to expand the partition + // Skip for Linux VMs - Linux does not use APFS and should handle partition expansion at boot + if guestType == .mac { + try await expandPartitionsInDiskImage(at: url, format: format) + } else { + NSLog("Skipping partition expansion for non-macOS guest (type: \(guestType)) - guest OS will handle partition resize") + } + } + + private static func getCurrentImageSize(at url: URL, format: VBManagedDiskImage.Format) async throws -> UInt64 { + switch format { + case .raw: + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + return attributes[.size] as? UInt64 ?? 0 + + case .dmg, .sparse: + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil") + process.arguments = ["imageinfo", "-plist", url.path] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + throw VBDiskResizeError.systemCommandFailed("hdiutil imageinfo", process.terminationStatus) + } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard let plist = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any], + let size = plist["Total Bytes"] as? UInt64 else { + throw VBDiskResizeError.systemCommandFailed("hdiutil imageinfo", -1) + } + + return size + + case .asif: + throw VBDiskResizeError.unsupportedImageFormat(format) + } + } + + private static func createLargerImage( + at url: URL, + format: VBManagedDiskImage.Format, + newSize: UInt64, + currentSize: UInt64 + ) async throws { + let backupURL = url.appendingPathExtension("backup") + let tempURL = url.appendingPathExtension("resizing") + + let parentDir = url.deletingLastPathComponent() + let availableSpace = try await getAvailableSpace(at: parentDir) + + let requiredSpace = newSize + currentSize + guard availableSpace >= requiredSpace else { + throw VBDiskResizeError.insufficientSpace(required: requiredSpace, available: availableSpace) + } + + do { + try FileManager.default.moveItem(at: url, to: backupURL) + + switch format { + case .raw: + // Create empty file of new size + FileManager.default.createFile(atPath: tempURL.path, contents: nil, attributes: nil) + let fileHandle = try FileHandle(forWritingTo: tempURL) + defer { fileHandle.closeFile() } + + let result = ftruncate(fileHandle.fileDescriptor, Int64(newSize)) + guard result == 0 else { + throw VBDiskResizeError.systemCommandFailed("ftruncate", result) + } + + // Copy original data to the beginning of the new larger file + let sourceFile = try FileHandle(forReadingFrom: backupURL) + fileHandle.seek(toFileOffset: 0) + defer { sourceFile.closeFile() } + + let bufferSize = 1024 * 1024 + while true { + let data = sourceFile.readData(ofLength: bufferSize) + if data.isEmpty { break } + fileHandle.write(data) + } + + case .dmg, .sparse: + try await createExpandedDMGImage(from: backupURL, to: tempURL, newSize: newSize, format: format) + + case .asif: + throw VBDiskResizeError.unsupportedImageFormat(format) + } + + try FileManager.default.moveItem(at: tempURL, to: url) + try FileManager.default.removeItem(at: backupURL) + + } catch { + if FileManager.default.fileExists(atPath: tempURL.path) { + try? FileManager.default.removeItem(at: tempURL) + } + + if FileManager.default.fileExists(atPath: backupURL.path) { + try? FileManager.default.moveItem(at: backupURL, to: url) + } + + throw error + } + } + + private static func expandImageInPlace(at url: URL, format: VBManagedDiskImage.Format, newSize: UInt64, guestType: VBGuestType = .mac) async throws { + let parentDir = url.deletingLastPathComponent() + let availableSpace = try await getAvailableSpace(at: parentDir) + + // Get current file size + let currentSize = try await getCurrentImageSize(at: url, format: format) + let additionalSpaceNeeded = newSize > currentSize ? newSize - currentSize : 0 + + guard availableSpace >= additionalSpaceNeeded else { + throw VBDiskResizeError.insufficientSpace(required: additionalSpaceNeeded, available: availableSpace) + } + + switch format { + case .dmg, .sparse: + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil") + + let sizeInSectors = newSize / 512 + process.arguments = ["resize", "-size", "\(sizeInSectors)s", url.path] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + let errorData = pipe.fileHandleForReading.readDataToEndOfFile() + let errorString = String(data: errorData, encoding: .utf8) ?? "Unknown error" + throw VBDiskResizeError.systemCommandFailed("hdiutil resize: \(errorString)", process.terminationStatus) + } + + case .raw: + try await expandRawImageInPlace(at: url, newSize: newSize) + // Adjust GPT layout based on guest type + try adjustGPTLayoutForRawImage(at: url, newSize: newSize, guestType: guestType) + + case .asif: + throw VBDiskResizeError.unsupportedImageFormat(format) + } + } + + private static func createRawImage(at url: URL, size: UInt64) async throws { + let tempURL = url.appendingPathExtension("tmp") + + // Create the temporary file first + FileManager.default.createFile(atPath: tempURL.path, contents: nil, attributes: nil) + + let fileHandle = try FileHandle(forWritingTo: tempURL) + defer { fileHandle.closeFile() } + + let result = ftruncate(fileHandle.fileDescriptor, Int64(size)) + guard result == 0 else { + throw VBDiskResizeError.systemCommandFailed("ftruncate", result) + } + + try FileManager.default.moveItem(at: tempURL, to: url) + } + + private static func createExpandedDMGImage(from sourceURL: URL, to destURL: URL, newSize: UInt64, format: VBManagedDiskImage.Format) async throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil") + + let formatArg: String + switch format { + case .dmg: + formatArg = "UDRW" + case .sparse: + formatArg = "SPARSE" + default: + formatArg = "UDRW" + } + + let sizeInSectors = newSize / 512 + process.arguments = [ + "convert", sourceURL.path, + "-format", formatArg, + "-o", destURL.path, + "-size", "\(sizeInSectors)s" + ] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + let errorData = pipe.fileHandleForReading.readDataToEndOfFile() + let errorString = String(data: errorData, encoding: .utf8) ?? "Unknown error" + throw VBDiskResizeError.systemCommandFailed("hdiutil convert: \(errorString)", process.terminationStatus) + } + } + + private static func expandRawImageInPlace(at url: URL, newSize: UInt64) async throws { + let fileHandle = try FileHandle(forWritingTo: url) + defer { fileHandle.closeFile() } + + let result = ftruncate(fileHandle.fileDescriptor, Int64(newSize)) + guard result == 0 else { + throw VBDiskResizeError.systemCommandFailed("ftruncate", result) + } + } + + private static func getAvailableSpace(at url: URL) async throws -> UInt64 { + let resourceValues = try url.resourceValues(forKeys: [.volumeAvailableCapacityKey]) + return UInt64(resourceValues.volumeAvailableCapacity ?? 0) + } + + /// Expands partitions within a disk image to use the newly available space + private static func expandPartitionsInDiskImage(at url: URL, format: VBManagedDiskImage.Format) async throws { + NSLog("Attempting to expand partitions in disk image at \(url.path)") + + switch format { + case .raw: + // For RAW images, we need to mount and resize using diskutil + try await expandPartitionsInRawImage(at: url) + + case .dmg, .sparse: + // For DMG/Sparse images, we can work with them directly + try await expandPartitionsInDMGImage(at: url) + + case .asif: + // ASIF format doesn't support resizing + NSLog("Skipping partition expansion for ASIF format") + } + } + + private static func expandPartitionsInRawImage(at url: URL) async throws { + // Mount the disk image as a device + let attachProcess = Process() + attachProcess.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil") + attachProcess.arguments = ["attach", "-imagekey", "diskimage-class=CRawDiskImage", "-nomount", url.path] + + let attachPipe = Pipe() + attachProcess.standardOutput = attachPipe + attachProcess.standardError = Pipe() + + try attachProcess.run() + attachProcess.waitUntilExit() + + guard attachProcess.terminationStatus == 0 else { + throw VBDiskResizeError.systemCommandFailed("hdiutil attach", attachProcess.terminationStatus) + } + + let attachOutput = String(data: attachPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + + // Extract device node (e.g., /dev/disk4) + guard let deviceNode = extractDeviceNode(from: attachOutput) else { + throw VBDiskResizeError.systemCommandFailed("Could not extract device node", -1) + } + + defer { + // Detach the disk image when done + let detachProcess = Process() + detachProcess.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil") + detachProcess.arguments = ["detach", deviceNode] + try? detachProcess.run() + detachProcess.waitUntilExit() + } + + // Resize the partition using diskutil + try await resizePartitionOnDevice(deviceNode: deviceNode) + } + + private static func expandPartitionsInDMGImage(at url: URL) async throws { + // Mount the DMG and resize its partitions + let attachProcess = Process() + attachProcess.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil") + attachProcess.arguments = ["attach", "-nomount", url.path] + + let attachPipe = Pipe() + attachProcess.standardOutput = attachPipe + attachProcess.standardError = Pipe() + + try attachProcess.run() + attachProcess.waitUntilExit() + + guard attachProcess.terminationStatus == 0 else { + throw VBDiskResizeError.systemCommandFailed("hdiutil attach", attachProcess.terminationStatus) + } + + let attachOutput = String(data: attachPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + + guard let deviceNode = extractDeviceNode(from: attachOutput) else { + throw VBDiskResizeError.systemCommandFailed("Could not extract device node", -1) + } + + defer { + let detachProcess = Process() + detachProcess.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil") + detachProcess.arguments = ["detach", deviceNode] + try? detachProcess.run() + detachProcess.waitUntilExit() + } + + try await resizePartitionOnDevice(deviceNode: deviceNode) + } + + private static func extractDeviceNode(from hdiutilOutput: String) -> String? { + // hdiutil output format: "/dev/disk4 Apple_partition_scheme" + let lines = hdiutilOutput.components(separatedBy: .newlines) + for line in lines { + if line.contains("/dev/disk") { + let components = line.components(separatedBy: .whitespaces) + if let deviceNode = components.first, deviceNode.hasPrefix("/dev/disk") { + return deviceNode + } + } + } + return nil + } + + private static func resizePartitionOnDevice(deviceNode: String) async throws { + NSLog("Attempting to resize partition on device \(deviceNode)") + + // First, get partition information + let listProcess = Process() + listProcess.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") + listProcess.arguments = ["list", deviceNode] + + let listPipe = Pipe() + listProcess.standardOutput = listPipe + listProcess.standardError = Pipe() + + try listProcess.run() + listProcess.waitUntilExit() + + guard listProcess.terminationStatus == 0 else { + NSLog("Warning: Could not list partitions on \(deviceNode)") + return + } + + let listOutput = String(data: listPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + NSLog("Partition layout for \(deviceNode):\n\(listOutput)") + + // First, check if we need to use diskutil apfs list to find the APFS container + // This is needed when the partition is an APFS volume rather than a container + // Also check if the device itself is an APFS container (common for VM disk images) + if let apfsContainerFromList = await findAPFSContainerUsingAPFSList(deviceNode: deviceNode) { + if apfsContainerFromList.hasLockedVolumes { + throw VBDiskResizeError.apfsVolumesLocked(container: apfsContainerFromList.container) + } + let targetDescription = apfsContainerFromList.physicalStore ?? apfsContainerFromList.container + NSLog("Found APFS container using 'diskutil apfs list': \(apfsContainerFromList.container) (store: \(targetDescription))") + try await resizeAPFSContainer(apfsContainerFromList) + } else if listOutput.contains("Apple_APFS_Recovery") { + // Check if there's an Apple_APFS_Recovery partition blocking expansion + NSLog("Detected Apple_APFS_Recovery partition - attempting recovery partition resize strategy") + try await resizeWithRecoveryPartition(deviceNode: deviceNode, listOutput: listOutput) + } else if let apfsContainer = findAPFSContainer(in: listOutput, deviceNode: deviceNode) { + let targetDescription = apfsContainer.physicalStore ?? apfsContainer.container + NSLog("Found APFS container: \(apfsContainer.container) (store: \(targetDescription))") + try await resizeAPFSContainer(apfsContainer) + } else if listOutput.contains("Apple_APFS") { + // The disk might be an APFS container itself (common for VM images) + // Try to resize it directly + NSLog("Disk appears to have APFS partitions, attempting to resize \(deviceNode) as container") + let cleanDevice = sanitizeDeviceIdentifier(deviceNode) + let containerInfo = APFSContainerInfo(container: cleanDevice, physicalStore: nil, hasLockedVolumes: false) + try await resizeAPFSContainer(containerInfo) + } else if let hfsPartition = findHFSPartition(in: listOutput, deviceNode: deviceNode) { + NSLog("Found HFS+ partition: \(hfsPartition)") + try await resizeHFSPartition(hfsPartition) + } else { + // Fallback: try the original method + if let partitionIdentifier = findResizablePartition(in: listOutput, deviceNode: deviceNode) { + NSLog("Using fallback resize for partition: \(partitionIdentifier)") + try await resizeGenericPartition(partitionIdentifier) + } else { + NSLog("Warning: Could not find any resizable partition on \(deviceNode)") + } + } + } + + private static func resizeAPFSContainer(_ info: APFSContainerInfo) async throws { + if info.hasLockedVolumes { + throw VBDiskResizeError.apfsVolumesLocked(container: info.container) + } + + let resizeTarget = info.physicalStore ?? info.container + + let primaryResult = runDiskutilCommand(arguments: ["apfs", "resizeContainer", resizeTarget, "0"]) + + if primaryResult.status == 0 { + NSLog("Successfully expanded APFS container target \(resizeTarget)") + } else { + NSLog("Warning: Failed to resize APFS container target \(resizeTarget): \(primaryResult.output)") + if primaryResult.output.localizedCaseInsensitiveContains("locked") { + throw VBDiskResizeError.apfsVolumesLocked(container: info.container) + } + } + + // When resizing using the physical store, issue a follow-up pass on the logical container to + // encourage APFS to grow the volumes to the new ceiling. Ignore failures in this follow-up. + if info.physicalStore != nil && info.container != resizeTarget { + let containerTarget = info.container + let containerResult = runDiskutilCommand(arguments: ["apfs", "resizeContainer", containerTarget, "0"]) + + if containerResult.status == 0 { + NSLog("Performed follow-up resize on APFS container \(containerTarget)") + } else { + NSLog("Follow-up resize on container \(containerTarget) failed (ignored): \(containerResult.output)") + if containerResult.output.localizedCaseInsensitiveContains("locked") { + throw VBDiskResizeError.apfsVolumesLocked(container: info.container) + } + } + } + + try await ensureAPFSContainerMaximized(info: info) + } + + private static func resizeHFSPartition(_ partitionIdentifier: String) async throws { + try await resizeGenericPartition(partitionIdentifier) + } + + private static func resizeGenericPartition(_ partitionIdentifier: String) async throws { + let resizeProcess = Process() + resizeProcess.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") + resizeProcess.arguments = ["resizeVolume", partitionIdentifier, "R"] + + let resizePipe = Pipe() + resizeProcess.standardOutput = resizePipe + resizeProcess.standardError = resizePipe + + try resizeProcess.run() + resizeProcess.waitUntilExit() + + let resizeOutput = String(data: resizePipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + + if resizeProcess.terminationStatus == 0 { + NSLog("Successfully expanded partition \(partitionIdentifier)") + } else { + // Check if this is an APFS volume that needs container resizing + if resizeOutput.contains("is an APFS Volume") && resizeOutput.contains("diskutil apfs resizeContainer") { + NSLog("Partition \(partitionIdentifier) is an APFS Volume, attempting to find and resize its container") + + // Extract the base device (e.g., /dev/disk10 from /dev/disk10s2) + // We need to find the last 's' followed by a number to properly extract the base device + let baseDevice: String + if let lastSIndex = partitionIdentifier.lastIndex(of: "s"), + partitionIdentifier.index(after: lastSIndex) < partitionIdentifier.endIndex, + partitionIdentifier[partitionIdentifier.index(after: lastSIndex)].isNumber { + baseDevice = String(partitionIdentifier[.. String? { + let lines = diskutilOutput.components(separatedBy: .newlines) + + // Look for APFS or HFS+ partitions (typically the main data partition) + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + // Skip header and empty lines + guard !trimmed.isEmpty && !trimmed.contains("TYPE NAME") else { continue } + + // Look for APFS Container or HFS+ partition + if (trimmed.contains("APFS") || trimmed.contains("Apple_HFS")) && + (trimmed.contains("Container") || trimmed.contains("Macintosh HD") || trimmed.contains("disk")) { + + // Extract partition number (e.g., "1:" -> "disk4s1") + let components = trimmed.components(separatedBy: .whitespaces) + for component in components { + if component.hasSuffix(":") { + let partitionNum = component.dropLast() // Remove ":" + return "\(deviceNode)s\(partitionNum)" + } + } + } + } + + // Fallback: try s2 which is commonly the main partition + return "\(deviceNode)s2" + } + + private static func findAPFSContainerUsingAPFSList(deviceNode: String) async -> APFSContainerInfo? { + let apfsListProcess = Process() + apfsListProcess.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") + apfsListProcess.arguments = ["apfs", "list", "-plist"] + + let apfsListPipe = Pipe() + apfsListProcess.standardOutput = apfsListPipe + apfsListProcess.standardError = Pipe() + + do { + try apfsListProcess.run() + apfsListProcess.waitUntilExit() + } catch { + NSLog("Failed to run 'diskutil apfs list -plist': \(error)") + return nil + } + + guard apfsListProcess.terminationStatus == 0 else { + NSLog("'diskutil apfs list -plist' failed with exit code \(apfsListProcess.terminationStatus)") + return nil + } + + let data = apfsListPipe.fileHandleForReading.readDataToEndOfFile() + guard + let plist = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any], + let containers = plist["Containers"] as? [[String: Any]] + else { + NSLog("Failed to parse 'diskutil apfs list -plist' output") + return nil + } + + let cleanDeviceNode = sanitizeDeviceIdentifier(deviceNode) + var candidates: [(info: APFSContainerInfo, size: UInt64, isMainContainer: Bool)] = [] + + for container in containers { + guard let containerRef = container["ContainerReference"] as? String else { continue } + let volumes = container["Volumes"] as? [[String: Any]] ?? [] + let roles = volumes.compactMap { $0["Roles"] as? [String] }.flatMap { $0 } + let hasLockedVolumes = volumes.contains { ($0["Locked"] as? Bool) == true } + + // Detect MAIN container: has "System" or "Data" role (the boot/data container) + let hasSystemOrData = roles.contains(where: { $0 == "System" }) || roles.contains(where: { $0 == "Data" }) + + // Detect ISC container: has "xART" or "Hardware" roles (unique to Internal Shared Cache) + let hasISCRoles = roles.contains(where: { $0 == "xART" }) || roles.contains(where: { $0 == "Hardware" }) + + // The main container is the one with System/Data and NOT ISC + let isMainContainer = hasSystemOrData && !hasISCRoles + + let physicalStores = container["PhysicalStores"] as? [[String: Any]] ?? [] + for store in physicalStores { + guard let storeIdentifier = store["DeviceIdentifier"] as? String else { continue } + guard storeIdentifier.hasPrefix(cleanDeviceNode) || containerRef == cleanDeviceNode else { continue } + let size = store["Size"] as? UInt64 ?? 0 + let info = APFSContainerInfo(container: containerRef, physicalStore: storeIdentifier, hasLockedVolumes: hasLockedVolumes) + candidates.append((info: info, size: size, isMainContainer: isMainContainer)) + NSLog("APFS candidate: container=\(containerRef), store=\(storeIdentifier), size=\(size), isMain=\(isMainContainer), hasSystemOrData=\(hasSystemOrData), hasISCRoles=\(hasISCRoles), roles=\(roles)") + } + + if containerRef == cleanDeviceNode { + let size = (physicalStores.first?["Size"] as? UInt64) ?? 0 + let info = APFSContainerInfo(container: containerRef, physicalStore: nil, hasLockedVolumes: hasLockedVolumes) + candidates.append((info: info, size: size, isMainContainer: isMainContainer)) + } + } + + guard !candidates.isEmpty else { + NSLog("No APFS container found in 'diskutil apfs list' for device \(cleanDeviceNode)") + return nil + } + + // Selection priority: + // 1. Find the MAIN container (has System/Data, not ISC) that is unlocked + // 2. Fall back to largest unlocked container + // 3. Fall back to any container + + let selected: (info: APFSContainerInfo, size: UInt64, isMainContainer: Bool)? + + // First priority: unlocked main container + if let mainUnlocked = candidates.first(where: { $0.isMainContainer && !$0.info.hasLockedVolumes }) { + selected = mainUnlocked + NSLog("Selected unlocked main APFS container: \(mainUnlocked.info.container)") + } + // Second priority: any main container (even if locked) + else if let mainAny = candidates.first(where: { $0.isMainContainer }) { + selected = mainAny + NSLog("Selected main APFS container (locked): \(mainAny.info.container)") + } + // Third priority: largest unlocked non-main container + else if let largestUnlocked = candidates.filter({ !$0.info.hasLockedVolumes }).max(by: { $0.size < $1.size }) { + selected = largestUnlocked + NSLog("Selected largest unlocked APFS container: \(largestUnlocked.info.container)") + } + // Last resort: any container + else { + selected = candidates.first + NSLog("Selected fallback APFS container: \(selected?.info.container ?? "none")") + } + + if let selected = selected { + NSLog("Final APFS container selection: \(selected.info.container) (store: \(selected.info.physicalStore ?? "none"), size: \(selected.size), isMain: \(selected.isMainContainer))") + } + + return selected?.info + } + + private static func findAPFSContainer(in diskutilOutput: String, deviceNode: String) -> APFSContainerInfo? { + let lines = diskutilOutput.components(separatedBy: .newlines) + var foundContainers: [(info: APFSContainerInfo, isMain: Bool)] = [] // (partition, containerRef, isMainContainer) + + // Look for APFS Container entries with their container references + // Format: "2: Apple_APFS Container disk11 47.8 GB disk8s2" + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + // Skip header and empty lines + guard !trimmed.isEmpty && !trimmed.contains("TYPE NAME") else { continue } + + // Look for Apple_APFS entries (but not ISC or Recovery) + if trimmed.contains("Apple_APFS") && !trimmed.contains("Apple_APFS_Recovery") { + let components = trimmed.components(separatedBy: .whitespaces).filter { !$0.isEmpty } + + // Find partition number + var partitionNum: String? + var containerRef: String? + + for (index, component) in components.enumerated() { + // Get partition number (e.g., "2:" -> "2") + if component.hasSuffix(":") { + partitionNum = String(component.dropLast()) + } + + // Look for "Container disk" pattern + if component == "Container" && index + 1 < components.count { + let nextComponent = components[index + 1] + if nextComponent.hasPrefix("disk") { + containerRef = nextComponent + } + } + } + + if let partition = partitionNum { + let partitionDevice = sanitizeDeviceIdentifier("\(deviceNode)s\(partition)") + let isMainContainer = !trimmed.contains("Apple_APFS_ISC") + + let containerIdentifier = sanitizeDeviceIdentifier(containerRef ?? partitionDevice) + let info = APFSContainerInfo(container: containerIdentifier, physicalStore: partitionDevice, hasLockedVolumes: false) + foundContainers.append((info: info, isMain: isMainContainer)) + + NSLog("Found APFS partition: \(partitionDevice) -> Container: \(containerIdentifier) (main: \(isMainContainer))") + } + } + } + + // Prefer main containers over ISC containers + if let mainContainer = foundContainers.first(where: { $0.isMain }) { + NSLog("Using main APFS container: \(mainContainer.info.container)") + return APFSContainerInfo(container: mainContainer.info.container, physicalStore: mainContainer.info.physicalStore, hasLockedVolumes: false) + } else if let anyContainer = foundContainers.first { + NSLog("Using fallback APFS container: \(anyContainer.info.container)") + return APFSContainerInfo(container: anyContainer.info.container, physicalStore: anyContainer.info.physicalStore, hasLockedVolumes: false) + } + + NSLog("No APFS container found in diskutil output") + return nil + } + + private static func findHFSPartition(in diskutilOutput: String, deviceNode: String) -> String? { + let lines = diskutilOutput.components(separatedBy: .newlines) + + // Look for HFS+ partitions + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + // Skip header and empty lines + guard !trimmed.isEmpty && !trimmed.contains("TYPE NAME") else { continue } + + // Look for Apple_HFS partition (Mac OS Extended) + if trimmed.contains("Apple_HFS") && !trimmed.contains("Container") { + // Extract partition number (e.g., "2:" -> "disk4s2") + let components = trimmed.components(separatedBy: .whitespaces) + for component in components { + if component.hasSuffix(":") { + let partitionNum = component.dropLast() // Remove ":" + let hfsDevice = "\(deviceNode)s\(partitionNum)" + NSLog("Found HFS+ partition: \(hfsDevice)") + return hfsDevice + } + } + } + } + + NSLog("No HFS+ partition found in diskutil output") + return nil + } + + private static func resizeWithRecoveryPartition(deviceNode: String, listOutput: String) async throws { + NSLog("Handling partition layout with Apple_APFS_Recovery partition") + + guard let mainContainer = findAPFSContainer(in: listOutput, deviceNode: deviceNode) else { + NSLog("Could not find main APFS container for recovery partition resize") + return + } + + let mainContainerTarget = mainContainer.physicalStore ?? mainContainer.container + NSLog("Primary APFS container for recovery handling: \(mainContainer.container) (store: \(mainContainerTarget))") + + // Check if recovery partition is blocking expansion + let recoveryPartition = findRecoveryPartition(in: listOutput, deviceNode: deviceNode) + + if let recovery = recoveryPartition { + NSLog("Found recovery partition: \(recovery)") + NSLog("Recovery partition detected - attempting advanced resize strategies") + + // Strategy 1: Try to delete the recovery partition to allow expansion + NSLog("Attempting to temporarily remove recovery partition for expansion") + + // First, we need to find the actual container reference for the recovery partition + // The recovery partition is typically a synthesized disk, so we need to find its container + let recoveryContainer = findRecoveryContainer(in: listOutput) + + if let containerToDelete = recoveryContainer { + NSLog("Found recovery container reference: \(containerToDelete)") + + let deleteProcess = Process() + deleteProcess.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") + deleteProcess.arguments = ["apfs", "deleteContainer", containerToDelete, "-force"] + + let deletePipe = Pipe() + deleteProcess.standardOutput = deletePipe + deleteProcess.standardError = deletePipe + + try deleteProcess.run() + deleteProcess.waitUntilExit() + + let deleteOutput = String(data: deletePipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + + if deleteProcess.terminationStatus == 0 { + NSLog("Successfully removed recovery partition, attempting main container resize") + + // Now try to resize the main container + try await resizeAPFSContainer(mainContainer) + + NSLog("Main container resized successfully") + // Note: The recovery partition will be recreated by macOS when needed + + return // Exit early on success + } else { + NSLog("Could not remove recovery container: \(deleteOutput)") + + // Check if it's protected by SIP + if deleteOutput.contains("csrutil disable") || deleteOutput.contains("Recovery Container") { + NSLog("Recovery partition is protected by System Integrity Protection (SIP)") + NSLog("The disk image has been successfully resized to provide more total space") + NSLog("To fully utilize the space, you can:") + NSLog("1. Boot the VM into Recovery Mode (Command+R during startup)") + NSLog("2. Use Disk Utility to manually adjust partitions") + NSLog("3. Or disable SIP temporarily if needed (not recommended)") + return // This is actually successful, just with limitations + } + } + } else { + NSLog("Could not identify recovery container reference") + + // Strategy 2: Try using the limit parameter to resize up to the recovery partition + NSLog("Attempting to resize main container up to recovery partition boundary") + + // Get total disk size (might be useful for debugging) + let diskInfoProcess = Process() + diskInfoProcess.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") + diskInfoProcess.arguments = ["info", deviceNode] + + let diskInfoPipe = Pipe() + diskInfoProcess.standardOutput = diskInfoPipe + diskInfoProcess.standardError = Pipe() + + try diskInfoProcess.run() + diskInfoProcess.waitUntilExit() + + _ = diskInfoPipe.fileHandleForReading.readDataToEndOfFile() + + // Try to resize leaving space for recovery + let resizeProcess = Process() + resizeProcess.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") + let recoveryResizeTarget = mainContainer.physicalStore ?? mainContainer.container + resizeProcess.arguments = ["apfs", "resizeContainer", recoveryResizeTarget, "0"] + + let resizePipe = Pipe() + resizeProcess.standardOutput = resizePipe + resizeProcess.standardError = resizePipe + + try resizeProcess.run() + resizeProcess.waitUntilExit() + + let resizeOutput = String(data: resizePipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + + if resizeProcess.terminationStatus == 0 { + NSLog("Successfully resized APFS container") + } else { + NSLog("Container resize failed: \(resizeOutput)") + NSLog("The disk image has been enlarged successfully") + NSLog("Note: The available space may be used by macOS dynamically") + } + } + } else { + NSLog("No recovery partition found, proceeding with standard resize") + try await resizeAPFSContainer(mainContainer) + } + } + + private static func parsePartitionLayout(_ listOutput: String, deviceNode: String) -> [(number: Int, type: String, name: String, size: String)] { + let lines = listOutput.components(separatedBy: .newlines) + var partitions: [(number: Int, type: String, name: String, size: String)] = [] + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty && !trimmed.contains("TYPE NAME") && trimmed.contains(":") else { continue } + + let components = trimmed.components(separatedBy: .whitespaces) + if let first = components.first, first.hasSuffix(":") { + let partitionNum = String(first.dropLast()) + if let num = Int(partitionNum), components.count >= 4 { + let type = components[1] + let name = components.count > 2 ? components[2] : "" + let size = components.count > 3 ? components[3] : "" + partitions.append((number: num, type: type, name: name, size: size)) + } + } + } + + return partitions + } + + private static func findRecoveryPartition(in diskutilOutput: String, deviceNode: String) -> String? { + let lines = diskutilOutput.components(separatedBy: .newlines) + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty && !trimmed.contains("TYPE NAME") else { continue } + + if trimmed.contains("Apple_APFS_Recovery") || (trimmed.contains("Recovery") && trimmed.contains("Container")) { + let components = trimmed.components(separatedBy: .whitespaces) + for component in components { + if component.hasSuffix(":") { + let partitionNum = component.dropLast() + let recoveryDevice = "\(deviceNode)s\(partitionNum)" + NSLog("Found recovery partition: \(recoveryDevice)") + return recoveryDevice + } + } + } + } + + return nil + } + + private static func findRecoveryContainer(in diskutilOutput: String) -> String? { + let lines = diskutilOutput.components(separatedBy: .newlines) + + // Look for the recovery container - it's typically shown as "Container disk6" in the output + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty && !trimmed.contains("TYPE NAME") else { continue } + + if trimmed.contains("Apple_APFS_Recovery") && trimmed.contains("Container") { + // Extract the container disk reference (e.g., "disk6" from "Container disk6") + let components = trimmed.components(separatedBy: .whitespaces) + + // Look for "Container" followed by "diskX" + for (index, component) in components.enumerated() { + if component == "Container" && index + 1 < components.count { + let nextComponent = components[index + 1] + if nextComponent.hasPrefix("disk") { + NSLog("Found recovery container: \(nextComponent)") + return nextComponent + } + } + } + } + } + + NSLog("Could not find recovery container in diskutil output") + return nil + } + + private static func ensureAPFSContainerMaximized(info: APFSContainerInfo) async throws { + if info.hasLockedVolumes { + throw VBDiskResizeError.apfsVolumesLocked(container: info.container) + } + + guard let details = try fetchAPFSContainerDetails(container: info.container) else { + return + } + + let physicalSize = details.physicalStoreSize + let capacity = details.capacityCeiling + let tolerance: UInt64 = 1 * 1024 * 1024 // 1 MB tolerance to account for rounding + + if physicalSize > capacity + tolerance { + NSLog("APFS container \(info.container) ceiling (\(capacity)) is below physical store size (\(physicalSize)); nudging container") + try await nudgeAPFSContainer(info: info, physicalSize: physicalSize) + + if let postDetails = try fetchAPFSContainerDetails(container: info.container) { + NSLog("Post-nudge container ceiling: \(postDetails.capacityCeiling) (store: \(postDetails.physicalStoreSize))") + } + } + } + + private static func fetchAPFSContainerDetails(container: String) throws -> APFSContainerDetails? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") + process.arguments = ["apfs", "list", "-plist", container] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + NSLog("Failed to query APFS container \(container): \(output)") + return nil + } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard + let plist = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any], + let containers = plist["Containers"] as? [[String: Any]], + let first = containers.first, + let capacity = first["CapacityCeiling"] as? UInt64, + let stores = first["PhysicalStores"] as? [[String: Any]], + let store = stores.first, + let storeSize = store["Size"] as? UInt64 + else { + NSLog("Could not parse APFS container details for \(container)") + return nil + } + + return APFSContainerDetails(capacityCeiling: capacity, physicalStoreSize: storeSize) + } + + private static func nudgeAPFSContainer(info: APFSContainerInfo, physicalSize: UInt64) async throws { + let alignment: UInt64 = 4096 + let shrinkDelta: UInt64 = 32 * 1024 * 1024 // 32 MB nudge to ensure actual size change + let resizeTarget = info.physicalStore ?? info.container + + guard physicalSize > alignment else { return } + + let tentativeShrink = physicalSize > shrinkDelta ? physicalSize - shrinkDelta : physicalSize - alignment + let alignedShrink = max((tentativeShrink / alignment) * alignment, alignment) + + let shrinkArg = "\(alignedShrink)B" + let shrinkResult = runDiskutilCommand(arguments: ["apfs", "resizeContainer", resizeTarget, shrinkArg]) + + if shrinkResult.status != 0 { + NSLog("APFS shrink nudge for \(resizeTarget) failed: \(shrinkResult.output)") + if shrinkResult.output.localizedCaseInsensitiveContains("locked") { + throw VBDiskResizeError.apfsVolumesLocked(container: info.container) + } + } + + let growResult = runDiskutilCommand(arguments: ["apfs", "resizeContainer", resizeTarget, "0"]) + if growResult.status != 0 { + NSLog("APFS grow after nudge for \(resizeTarget) failed: \(growResult.output)") + if growResult.output.localizedCaseInsensitiveContains("locked") { + throw VBDiskResizeError.apfsVolumesLocked(container: info.container) + } + } + } + + private static func runDiskutilCommand(arguments: [String]) -> (status: Int32, output: String) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") + process.arguments = arguments + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + do { + try process.run() + process.waitUntilExit() + } catch { + NSLog("Failed to run diskutil \(arguments.joined(separator: " ")): \(error)") + return (-1, "\(error)") + } + + let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + return (process.terminationStatus, output) + } + + private static func adjustGPTLayoutForRawImage(at url: URL, newSize: UInt64, guestType: VBGuestType) throws { + switch guestType { + case .mac: + try GPTLayoutAdjuster(imageURL: url, newSize: newSize).perform() + case .linux: + try LinuxGPTLayoutAdjuster(imageURL: url, newSize: newSize).perform() + } + } + + // MARK: - Linux GPT Layout Adjuster + + /// Adjusts GPT layout for Linux disk images. + /// Linux typically has a simpler partition layout (EFI + root, sometimes swap). + /// We extend the largest Linux partition to fill available space. + /// For LUKS-encrypted partitions, this extends the partition - the guest must then + /// run 'cryptsetup resize' followed by filesystem resize (resize2fs, xfs_growfs, etc.) + private struct LinuxGPTLayoutAdjuster { + let imageURL: URL + let newSize: UInt64 + + private let sectorSize: UInt64 = 512 + + // Linux partition type GUIDs + private let linuxFilesystemGUID = UUID(uuidString: "0FC63DAF-8483-4772-8E79-3D69D8477DE4")! // Generic Linux filesystem + private let linuxRootARM64GUID = UUID(uuidString: "B921B045-1DF0-41C3-AF44-4C6F280D3FAE")! // Linux root (ARM64) + private let linuxRootX64GUID = UUID(uuidString: "4F68BCE3-E8CD-4DB1-96E7-FBCAF984B709")! // Linux root (x86-64) + private let efiSystemGUID = UUID(uuidString: "C12A7328-F81F-11D2-BA4B-00A0C93EC93B")! // EFI System Partition + + func perform() throws { + guard newSize % sectorSize == 0 else { + throw VBDiskResizeError.systemCommandFailed("New disk size must be 512-byte aligned", -1) + } + + let fileHandle = try FileHandle(forUpdating: imageURL) + defer { try? fileHandle.close() } + + // Read primary GPT header (LBA 1) + let headerOffset = sectorSize + try fileHandle.vbSeek(to: headerOffset) + let headerData = try readExactly(fileHandle: fileHandle, length: Int(sectorSize)) + + var header = GPTHeader(data: headerData) + + // Validate GPT signature + guard header.signature == 0x5452415020494645 else { // "EFI PART" + NSLog("Invalid GPT signature, skipping Linux GPT adjustment") + return + } + + let entriesOffset = UInt64(header.partitionEntriesLBA) * sectorSize + let entriesLength = Int(header.numberOfEntries) * Int(header.entrySize) + + try fileHandle.vbSeek(to: entriesOffset) + var entries = try readExactly(fileHandle: fileHandle, length: entriesLength) + + // Find the largest Linux partition (this is typically the root partition) + guard let linuxPartitionIndex = findLargestLinuxPartition(in: entries, entrySize: Int(header.entrySize)) else { + NSLog("No Linux partition found in GPT, skipping partition resize") + // Still need to update GPT headers for the new disk size + try updateGPTHeadersOnly(fileHandle: fileHandle, header: &header, entries: entries) + return + } + + let entrySize = Int(header.entrySize) + let partitionBase = linuxPartitionIndex * entrySize + let currentLastLBA = readUInt64LittleEndian(from: entries, offset: partitionBase + 40) + + // Calculate new disk geometry + let totalSectors = newSize / sectorSize + let newBackupLBA = totalSectors - 1 + let backupEntriesLBA = newBackupLBA - 32 // 32 sectors for backup partition entries + let newLastUsable = backupEntriesLBA - 1 + + // Extend the partition to the new last usable LBA + let newLastLBA = newLastUsable + + guard newLastLBA > currentLastLBA else { + NSLog("Linux partition already at maximum size, only updating GPT headers") + try updateGPTHeadersOnly(fileHandle: fileHandle, header: &header, entries: entries) + return + } + + NSLog("Extending Linux partition from LBA \(currentLastLBA) to \(newLastLBA) (partition index: \(linuxPartitionIndex))") + + // Update partition end LBA + writeUInt64LittleEndian(&entries, offset: partitionBase + 40, value: newLastLBA) + + // Update GPT header fields + header.backupLBA = newBackupLBA + header.lastUsableLBA = newLastUsable + header.partitionEntriesCRC32 = crc32(of: entries) + + // Write updated partition entries (primary) + try fileHandle.vbSeek(to: entriesOffset) + try fileHandle.vbWriteAll(entries) + + // Write updated primary GPT header + let primaryHeaderData = header.serialized(sectorSize: sectorSize, isBackup: false) + try fileHandle.vbSeek(to: headerOffset) + try fileHandle.vbWriteAll(primaryHeaderData) + + // Write backup partition entries + let backupEntriesOffset = backupEntriesLBA * sectorSize + try fileHandle.vbSeek(to: backupEntriesOffset) + try fileHandle.vbWriteAll(entries) + + // Write backup GPT header + let backupHeaderData = header.serialized(sectorSize: sectorSize, isBackup: true) + try fileHandle.vbSeek(to: newBackupLBA * sectorSize) + try fileHandle.vbWriteAll(backupHeaderData) + + try fileHandle.vbSynchronize() + + NSLog("Linux GPT layout adjusted successfully. Partition extended by \((newLastLBA - currentLastLBA) * sectorSize / (1024*1024)) MB") + NSLog("Note: For LUKS-encrypted partitions, the guest must run 'cryptsetup resize' to utilize the new space") + } + + private func updateGPTHeadersOnly(fileHandle: FileHandle, header: inout GPTHeader, entries: Data) throws { + let totalSectors = newSize / sectorSize + let newBackupLBA = totalSectors - 1 + let backupEntriesLBA = newBackupLBA - 32 + let newLastUsable = backupEntriesLBA - 1 + + header.backupLBA = newBackupLBA + header.lastUsableLBA = newLastUsable + // Entries CRC doesn't change if we don't modify entries + + let headerOffset = sectorSize + + // Write updated primary GPT header + let primaryHeaderData = header.serialized(sectorSize: sectorSize, isBackup: false) + try fileHandle.vbSeek(to: headerOffset) + try fileHandle.vbWriteAll(primaryHeaderData) + + // Write partition entries to backup location + let entriesOffset = UInt64(header.partitionEntriesLBA) * sectorSize + let entriesLength = Int(header.numberOfEntries) * Int(header.entrySize) + try fileHandle.vbSeek(to: entriesOffset) + let entriesData = try readExactly(fileHandle: fileHandle, length: entriesLength) + + let backupEntriesOffset = backupEntriesLBA * sectorSize + try fileHandle.vbSeek(to: backupEntriesOffset) + try fileHandle.vbWriteAll(entriesData) + + // Write backup GPT header + let backupHeaderData = header.serialized(sectorSize: sectorSize, isBackup: true) + try fileHandle.vbSeek(to: newBackupLBA * sectorSize) + try fileHandle.vbWriteAll(backupHeaderData) + + try fileHandle.vbSynchronize() + NSLog("GPT headers updated for new disk size (no partition resize needed)") + } + + private func findLargestLinuxPartition(in entries: Data, entrySize: Int) -> Int? { + var bestIndex: Int? + var bestLength: UInt64 = 0 + + let linuxGUIDs: Set = [linuxFilesystemGUID, linuxRootARM64GUID, linuxRootX64GUID] + + for index in 0..<(entries.count / entrySize) { + let base = index * entrySize + let typeData = entries.subdata(in: base..<(base + 16)) + guard let entryGUID = uuidFromGPTBytes(typeData) else { continue } + + // Check if this is a Linux partition + guard linuxGUIDs.contains(entryGUID) else { continue } + + let firstLBA = readUInt64LittleEndian(from: entries, offset: base + 32) + let lastLBA = readUInt64LittleEndian(from: entries, offset: base + 40) + + // Skip empty partitions + guard firstLBA > 0 && lastLBA >= firstLBA else { continue } + + let length = lastLBA - firstLBA + 1 + if length > bestLength { + bestLength = length + bestIndex = index + NSLog("Found Linux partition at index \(index): LBA \(firstLBA)-\(lastLBA) (\(length * sectorSize / (1024*1024)) MB)") + } + } + + return bestIndex + } + + private func readExactly(fileHandle: FileHandle, length: Int) throws -> Data { + let data = try fileHandle.vbRead(upToCount: length) ?? Data() + guard data.count == length else { + throw NSError(domain: "VBDiskResizer", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to read expected GPT data"]) + } + return data + } + } + + // MARK: - macOS GPT Layout Adjuster + + private struct GPTLayoutAdjuster { + let imageURL: URL + let newSize: UInt64 + + private let sectorSize: UInt64 = 512 + private let mainContainerGUID = UUID(uuidString: "7C3457EF-0000-11AA-AA11-00306543ECAC")! + private let recoveryGUID = UUID(uuidString: "52637672-7900-11AA-AA11-00306543ECAC")! + + func perform() throws { + guard newSize % sectorSize == 0 else { + throw VBDiskResizeError.systemCommandFailed("New disk size must be 512-byte aligned", -1) + } + + let fileHandle = try FileHandle(forUpdating: imageURL) + defer { try? fileHandle.close() } + + let headerOffset = sectorSize + try fileHandle.vbSeek(to: headerOffset) + let headerData = try readExactly(fileHandle: fileHandle, length: Int(sectorSize)) + + var header = GPTHeader(data: headerData) + let entriesOffset = UInt64(header.partitionEntriesLBA) * sectorSize + let entriesLength = Int(header.numberOfEntries) * Int(header.entrySize) + + try fileHandle.vbSeek(to: entriesOffset) + var entries = try readExactly(fileHandle: fileHandle, length: entriesLength) + + guard + let mainIndex = findPartitionIndex(in: entries, guid: mainContainerGUID, entrySize: Int(header.entrySize), preferLargest: true), + let recoveryIndex = findPartitionIndex(in: entries, guid: recoveryGUID, entrySize: Int(header.entrySize), preferLargest: false) + else { + throw NSError(domain: "VBDiskResizer", code: 1, userInfo: [NSLocalizedDescriptionKey: "Could not locate APFS partitions in GPT"]) + } + + let mainLast = readUInt64LittleEndian(from: entries, offset: mainIndex * Int(header.entrySize) + 40) + let recoveryFirst = readUInt64LittleEndian(from: entries, offset: recoveryIndex * Int(header.entrySize) + 32) + let recoveryLast = readUInt64LittleEndian(from: entries, offset: recoveryIndex * Int(header.entrySize) + 40) + + let recoveryLength = recoveryLast - recoveryFirst + 1 + + let totalSectors = newSize / sectorSize + let newBackupLBA = totalSectors - 1 + let backupEntriesLBA = newBackupLBA - 32 + var newLastUsable = backupEntriesLBA - 8 + var newRecoveryFirst = newLastUsable - (recoveryLength - 1) + + let alignment: UInt64 = 8 + let remainder = newRecoveryFirst % alignment + if remainder != 0 { + newRecoveryFirst -= remainder + newLastUsable = newRecoveryFirst + recoveryLength - 1 + } + + let newMainLast = newRecoveryFirst - 1 + + guard newMainLast > mainLast else { + // Nothing to do if the main container already occupies the space + return + } + + try copySectors( + fileHandle: fileHandle, + from: recoveryFirst, + to: newRecoveryFirst, + count: recoveryLength, + sectorSize: sectorSize + ) + + try zeroSectors( + fileHandle: fileHandle, + start: recoveryFirst, + count: recoveryLength, + sectorSize: sectorSize + ) + + writeUInt64LittleEndian( + &entries, + offset: mainIndex * Int(header.entrySize) + 40, + value: newMainLast + ) + + writeUInt64LittleEndian( + &entries, + offset: recoveryIndex * Int(header.entrySize) + 32, + value: newRecoveryFirst + ) + + writeUInt64LittleEndian( + &entries, + offset: recoveryIndex * Int(header.entrySize) + 40, + value: newLastUsable + ) + + header.backupLBA = newBackupLBA + header.lastUsableLBA = newLastUsable + header.partitionEntriesCRC32 = crc32(of: entries) + + try fileHandle.vbSeek(to: entriesOffset) + try fileHandle.vbWriteAll(entries) + + let primaryHeaderData = header.serialized(sectorSize: sectorSize, isBackup: false) + try fileHandle.vbSeek(to: headerOffset) + try fileHandle.vbWriteAll(primaryHeaderData) + + let backupEntriesOffset = backupEntriesLBA * sectorSize + try fileHandle.vbSeek(to: backupEntriesOffset) + try fileHandle.vbWriteAll(entries) + + let backupHeaderData = header.serialized(sectorSize: sectorSize, isBackup: true) + try fileHandle.vbSeek(to: newBackupLBA * sectorSize) + try fileHandle.vbWriteAll(backupHeaderData) + + try fileHandle.vbSynchronize() + } + + private func readExactly(fileHandle: FileHandle, length: Int) throws -> Data { + let data = try fileHandle.vbRead(upToCount: length) ?? Data() + guard data.count == length else { + throw NSError(domain: "VBDiskResizer", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to read expected GPT data"]) + } + return data + } + + private func findPartitionIndex(in entries: Data, guid: UUID, entrySize: Int, preferLargest: Bool) -> Int? { + var bestIndex: Int? + var bestLength: UInt64 = 0 + + for index in 0..<(entries.count / entrySize) { + let base = index * entrySize + let typeData = entries.subdata(in: base..<(base + 16)) + guard let entryGUID = uuidFromGPTBytes(typeData), entryGUID == guid else { + continue + } + + if !preferLargest { + return index + } + + let first = readUInt64LittleEndian(from: entries, offset: base + 32) + let last = readUInt64LittleEndian(from: entries, offset: base + 40) + let length = last >= first ? last - first : 0 + if length > bestLength { + bestLength = length + bestIndex = index + } + } + + return preferLargest ? bestIndex : nil + } + + private func copySectors(fileHandle: FileHandle, from: UInt64, to: UInt64, count: UInt64, sectorSize: UInt64) throws { + let bufferSize: UInt64 = 4 * 1024 * 1024 + var remaining = count * sectorSize + var readOffset = from * sectorSize + var writeOffset = to * sectorSize + + while remaining > 0 { + let chunk = Int(min(bufferSize, remaining)) + try fileHandle.vbSeek(to: readOffset) + let data = try readExactly(fileHandle: fileHandle, length: chunk) + + try fileHandle.vbSeek(to: writeOffset) + try fileHandle.vbWriteAll(data) + + remaining -= UInt64(chunk) + readOffset += UInt64(chunk) + writeOffset += UInt64(chunk) + } + } + + private func zeroSectors(fileHandle: FileHandle, start: UInt64, count: UInt64, sectorSize: UInt64) throws { + let bufferSize: UInt64 = 4 * 1024 * 1024 + var remaining = count * sectorSize + var offset = start * sectorSize + let zeroChunk = Data(count: Int(min(bufferSize, remaining))) + + while remaining > 0 { + let chunk = Int(min(UInt64(zeroChunk.count), remaining)) + try fileHandle.vbSeek(to: offset) + try fileHandle.vbWriteAll(zeroChunk.prefix(chunk)) + + remaining -= UInt64(chunk) + offset += UInt64(chunk) + } + } + } + + private struct GPTHeader { + var signature: UInt64 + var revision: UInt32 + var headerSize: UInt32 + var headerCRC32: UInt32 + var reserved: UInt32 + var currentLBA: UInt64 + var backupLBA: UInt64 + var firstUsableLBA: UInt64 + var lastUsableLBA: UInt64 + var diskGUID: Data + var partitionEntriesLBA: UInt64 + var numberOfEntries: UInt32 + var entrySize: UInt32 + var partitionEntriesCRC32: UInt32 + + init(data: Data) { + signature = readUInt64LittleEndian(from: data, offset: 0) + revision = readUInt32LittleEndian(from: data, offset: 8) + headerSize = readUInt32LittleEndian(from: data, offset: 12) + headerCRC32 = readUInt32LittleEndian(from: data, offset: 16) + reserved = readUInt32LittleEndian(from: data, offset: 20) + currentLBA = readUInt64LittleEndian(from: data, offset: 24) + backupLBA = readUInt64LittleEndian(from: data, offset: 32) + firstUsableLBA = readUInt64LittleEndian(from: data, offset: 40) + lastUsableLBA = readUInt64LittleEndian(from: data, offset: 48) + diskGUID = data.subdata(in: 56..<72) + partitionEntriesLBA = readUInt64LittleEndian(from: data, offset: 72) + numberOfEntries = readUInt32LittleEndian(from: data, offset: 80) + entrySize = readUInt32LittleEndian(from: data, offset: 84) + partitionEntriesCRC32 = readUInt32LittleEndian(from: data, offset: 88) + } + + func serialized(sectorSize: UInt64, isBackup: Bool) -> Data { + var data = Data(count: Int(sectorSize)) + writeUInt64LittleEndian(&data, offset: 0, value: signature) + writeUInt32LittleEndian(&data, offset: 8, value: revision) + writeUInt32LittleEndian(&data, offset: 12, value: headerSize) + writeUInt32LittleEndian(&data, offset: 16, value: 0) // placeholder for CRC + writeUInt32LittleEndian(&data, offset: 20, value: reserved) + let current = isBackup ? backupLBA : currentLBA + let backup = isBackup ? currentLBA : backupLBA + writeUInt64LittleEndian(&data, offset: 24, value: current) + writeUInt64LittleEndian(&data, offset: 32, value: backup) + writeUInt64LittleEndian(&data, offset: 40, value: firstUsableLBA) + writeUInt64LittleEndian(&data, offset: 48, value: lastUsableLBA) + data.replaceSubrange(56..<72, with: diskGUID) + let entriesLBA = isBackup ? (backupLBA - 32) : partitionEntriesLBA + writeUInt64LittleEndian(&data, offset: 72, value: entriesLBA) + writeUInt32LittleEndian(&data, offset: 80, value: numberOfEntries) + writeUInt32LittleEndian(&data, offset: 84, value: entrySize) + writeUInt32LittleEndian(&data, offset: 88, value: partitionEntriesCRC32) + + let crc = crc32(of: data.prefix(Int(headerSize))) + writeUInt32LittleEndian(&data, offset: 16, value: crc) + return data + } + } + + private static func crc32(of data: Data) -> UInt32 { + data.withUnsafeBytes { buffer -> UInt32 in + guard let base = buffer.bindMemory(to: UInt8.self).baseAddress else { return 0 } + return UInt32(zlib.crc32(0, base, uInt(buffer.count))) + } + } + + private static func uuidFromGPTBytes(_ data: Data) -> UUID? { + guard data.count == 16 else { return nil } + let a = readUInt32LittleEndian(from: data, offset: 0) + let b = readUInt16LittleEndian(from: data, offset: 4) + let c = readUInt16LittleEndian(from: data, offset: 6) + let tail = Array(data[8..<16]) + let uuidString = String( + format: "%08x-%04x-%04x-%02x%02x-%02x%02x%02x%02x%02x%02x", + a, b, c, + tail[0], tail[1], + tail[2], tail[3], + tail[4], tail[5], tail[6], tail[7] + ) + return UUID(uuidString: uuidString) + } + + private static func readUInt64LittleEndian(from data: Data, offset: Int) -> UInt64 { + let range = offset..<(offset + 8) + return data.subdata(in: range).withUnsafeBytes { $0.load(as: UInt64.self) }.littleEndian + } + + private static func readUInt32LittleEndian(from data: Data, offset: Int) -> UInt32 { + let range = offset..<(offset + 4) + return data.subdata(in: range).withUnsafeBytes { $0.load(as: UInt32.self) }.littleEndian + } + + private static func readUInt16LittleEndian(from data: Data, offset: Int) -> UInt16 { + let range = offset..<(offset + 2) + return data.subdata(in: range).withUnsafeBytes { $0.load(as: UInt16.self) }.littleEndian + } + + private static func writeUInt64LittleEndian(_ data: inout Data, offset: Int, value: UInt64) { + var little = value.littleEndian + withUnsafeBytes(of: &little) { bytes in + data.replaceSubrange(offset..<(offset + 8), with: bytes) + } + } + + private static func writeUInt32LittleEndian(_ data: inout Data, offset: Int, value: UInt32) { + var little = value.littleEndian + withUnsafeBytes(of: &little) { bytes in + data.replaceSubrange(offset..<(offset + 4), with: bytes) + } + } + + private static func writeUInt16LittleEndian(_ data: inout Data, offset: Int, value: UInt16) { + var little = value.littleEndian + withUnsafeBytes(of: &little) { bytes in + data.replaceSubrange(offset..<(offset + 2), with: bytes) + } + } + +} diff --git a/VirtualCore/Source/Virtualization/Helpers/LinuxVirtualMachineConfigurationHelper.swift b/VirtualCore/Source/Virtualization/Helpers/LinuxVirtualMachineConfigurationHelper.swift index 345ddbec..e2b47353 100644 --- a/VirtualCore/Source/Virtualization/Helpers/LinuxVirtualMachineConfigurationHelper.swift +++ b/VirtualCore/Source/Virtualization/Helpers/LinuxVirtualMachineConfigurationHelper.swift @@ -54,6 +54,18 @@ struct LinuxVirtualMachineConfigurationHelper: VirtualMachineConfigurationHelper return consoleDevice } + + func createAdditionalBlockDevices() async throws -> [VZVirtioBlockDeviceConfiguration] { + var devices = try storageDeviceContainer.additionalBlockDevices(guestType: vm.configuration.systemType) + + // Attach Linux guest tools ISO if enabled + if vm.configuration.guestAdditionsEnabled, + let disk = try? VZVirtioBlockDeviceConfiguration.linuxGuestToolsDisk { + devices.append(disk) + } + + return devices + } } // MARK: - Configuration Models -> Virtualization diff --git a/VirtualCore/Source/Virtualization/Helpers/MacOSVirtualMachineConfigurationHelper.swift b/VirtualCore/Source/Virtualization/Helpers/MacOSVirtualMachineConfigurationHelper.swift index 0aaed447..86b19748 100644 --- a/VirtualCore/Source/Virtualization/Helpers/MacOSVirtualMachineConfigurationHelper.swift +++ b/VirtualCore/Source/Virtualization/Helpers/MacOSVirtualMachineConfigurationHelper.swift @@ -31,7 +31,9 @@ struct MacOSVirtualMachineConfigurationHelper: VirtualMachineConfigurationHelper func createAdditionalBlockDevices() async throws -> [VZVirtioBlockDeviceConfiguration] { var devices = try storageDeviceContainer.additionalBlockDevices(guestType: vm.configuration.systemType) - if vm.configuration.guestAdditionsEnabled, let disk = try? VZVirtioBlockDeviceConfiguration.guestAdditionsDisk { + if vm.configuration.guestAdditionsEnabled, + vm.configuration.systemType.supportsGuestApp, + let disk = try? VZVirtioBlockDeviceConfiguration.guestAdditionsDisk { devices.append(disk) } diff --git a/VirtualCore/Source/Virtualization/VMController.swift b/VirtualCore/Source/Virtualization/VMController.swift index 607da105..1e86f1ab 100644 --- a/VirtualCore/Source/Virtualization/VMController.swift +++ b/VirtualCore/Source/Virtualization/VMController.swift @@ -62,6 +62,7 @@ public struct VMSessionOptions: Hashable, Codable { public enum VMState: Equatable { case idle case starting(_ message: String?) + case resizingDisk(_ message: String?) case running(VZVirtualMachine) case paused(VZVirtualMachine) case savingState(VZVirtualMachine) @@ -158,6 +159,27 @@ public final class VMController: ObservableObject { state = .starting(nil) await waitForGuestDiskImageReadyIfNeeded() + + // Check and resize disk images if needed + do { + state = .resizingDisk("Preparing disk resize...") + try await virtualMachineModel.checkAndResizeDiskImages { message in + self.state = .resizingDisk(message) + } + state = .starting("Starting virtual machine...") + } catch { + if case let VBDiskResizeError.apfsVolumesLocked(container) = error { + let alert = NSAlert() + alert.messageText = "Unlock FileVault to Finish Resizing" + alert.informativeText = "VirtualBuddy enlarged the disk image, but the APFS container \(container) is still locked. Start the guest, sign in to unlock FileVault, then use Disk Utility (or run 'diskutil apfs resizeContainer disk0s2 0') inside the guest to claim the newly added space." + alert.addButton(withTitle: "OK") + alert.alertStyle = .informational + alert.runModal() + } + // Log resize errors but don't fail VM start + NSLog("Warning: Failed to resize disk images: \(error)") + state = .starting("Starting virtual machine...") + } try await updatingState { let newInstance = try createInstance() @@ -402,6 +424,7 @@ public extension VMState { switch lhs { case .idle: return rhs.isIdle case .starting: return rhs.isStarting + case .resizingDisk: return rhs.isResizingDisk case .running: return rhs.isRunning case .paused: return rhs.isPaused case .stopped: return rhs.isStopped @@ -420,6 +443,10 @@ public extension VMState { guard case .starting = self else { return false } return true } + var isResizingDisk: Bool { + guard case .resizingDisk = self else { return false } + return true + } var isRunning: Bool { guard case .running = self else { return false } @@ -512,3 +539,4 @@ public extension VBMacConfiguration { #endif } } + diff --git a/VirtualUI/Source/Session/Components/VirtualMachineControls.swift b/VirtualUI/Source/Session/Components/VirtualMachineControls.swift index 898a2c2b..dd912bbf 100644 --- a/VirtualUI/Source/Session/Components/VirtualMachineControls.swift +++ b/VirtualUI/Source/Session/Components/VirtualMachineControls.swift @@ -36,7 +36,7 @@ struct VirtualMachineControls: View { var body: some View { Group { switch controller.state { - case .idle, .paused, .stopped, .savingState, .restoringState, .stateSaveCompleted: + case .idle, .paused, .stopped, .savingState, .restoringState, .stateSaveCompleted, .resizingDisk: Button { runToolbarAction { if controller.state.canResume { diff --git a/VirtualUI/Source/Session/VirtualMachineSessionView.swift b/VirtualUI/Source/Session/VirtualMachineSessionView.swift index 8a092368..c7ed5bce 100644 --- a/VirtualUI/Source/Session/VirtualMachineSessionView.swift +++ b/VirtualUI/Source/Session/VirtualMachineSessionView.swift @@ -97,6 +97,18 @@ public struct VirtualMachineSessionView: View { .frame(maxWidth: 400) } } + case .resizingDisk(let message): + VStack(spacing: 12) { + ProgressView() + + if let message { + Text(message) + .foregroundStyle(.secondary) + .font(.subheadline) + .multilineTextAlignment(.center) + .frame(maxWidth: 400) + } + } case .running(let vm): vmView(with: vm) case .paused(let vm), .savingState(let vm), .restoringState(let vm, _), .stateSaveCompleted(let vm, _): @@ -127,6 +139,11 @@ public struct VirtualMachineSessionView: View { switch controller.state { case .paused: circularStartButton + case .resizingDisk(let message): + VMProgressOverlay( + message: message ?? "Resizing Disk Image", + duration: 30 + ) case .savingState, .stateSaveCompleted: VMProgressOverlay( message: controller.state.isStateSaveCompleted ? "State Saved!" : "Saving Virtual Machine State", diff --git a/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift b/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift index f013d449..5b0de62f 100644 --- a/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift +++ b/VirtualUI/Source/VM Configuration/Sections/Storage/ManagedDiskImageEditor.swift @@ -9,11 +9,13 @@ import SwiftUI import VirtualCore struct ManagedDiskImageEditor: View { + @EnvironmentObject var viewModel: VMConfigurationViewModel @State private var image: VBManagedDiskImage var minimumSize: UInt64 var isExistingDiskImage: Bool var onSave: (VBManagedDiskImage) -> Void var isBootVolume: Bool + var canResize: Bool init(image: VBManagedDiskImage, isExistingDiskImage: Bool, isForBootVolume: Bool, onSave: @escaping (VBManagedDiskImage) -> Void) { self._image = .init(wrappedValue: image) @@ -22,17 +24,23 @@ struct ManagedDiskImageEditor: View { let fallbackMinimumSize = isForBootVolume ? VBManagedDiskImage.minimumBootDiskImageSize : VBManagedDiskImage.minimumExtraDiskImageSize self.minimumSize = isExistingDiskImage ? image.size : fallbackMinimumSize self.isBootVolume = isForBootVolume + self.canResize = isExistingDiskImage && image.canBeResized } private let formatter: ByteCountFormatter = { let f = ByteCountFormatter() f.allowedUnits = [.useGB, .useMB, .useTB] f.formattingContext = .standalone - f.countStyle = .file + f.countStyle = .binary return f }() @State private var nameError: String? + @State private var isResizing = false + @State private var showResizeConfirmation = false + @State private var showFileVaultError = false + @State private var newSize: UInt64 = 0 + @State private var sliderTimer: Timer? @Environment(\.dismiss) private var dismiss @@ -50,21 +58,34 @@ struct ManagedDiskImageEditor: View { } } - NumericPropertyControl( - value: $image.size.gbStorageValue, - range: minimumSize.gbStorageValue...VBManagedDiskImage.maximumExtraDiskImageSize.gbStorageValue, - hideSlider: isExistingDiskImage, - label: isBootVolume ? "Boot Disk Size (GB)" : "Disk Image Size (GB)", - formatter: NumberFormatter.numericPropertyControlDefault - ) - .disabled(isExistingDiskImage) - .foregroundColor(sizeWarning != nil ? .yellow : .primary) + HStack { + NumericPropertyControl( + value: $image.size.gbStorageValue, + range: minimumSize.gbStorageValue...VBManagedDiskImage.maximumExtraDiskImageSize.gbStorageValue, + hideSlider: isExistingDiskImage && !canResize, + label: isBootVolume ? "Boot Disk Size (GB)" : "Disk Image Size (GB)", + formatter: NumberFormatter.numericPropertyControlDefault + ) + .disabled((isExistingDiskImage && !canResize) || isResizing) + .foregroundColor(sizeWarning != nil ? .yellow : .primary) + + if isResizing { + ProgressView() + .scaleEffect(0.5) + .frame(width: 16, height: 16) + } + } VStack(alignment: .leading, spacing: 8) { if !isExistingDiskImage, !isBootVolume { Text("You'll have to use Disk Utility in the guest operating system to initialize the disk image. If you see an error after it boots up, choose the \"Initialize\" option.") .foregroundColor(.yellow) } + + if isExistingDiskImage && canResize { + Text("This \(image.format.displayName) can be expanded. After resizing, you may need to expand the partition using Disk Utility in the guest operating system.") + .foregroundColor(.blue) + } if let sizeWarning { Text(sizeWarning) @@ -88,7 +109,33 @@ struct ManagedDiskImageEditor: View { .lineLimit(nil) } .onChange(of: image) { newValue in - onSave(newValue) + if isExistingDiskImage && canResize && newValue.size != minimumSize { + // Cancel any existing timer + sliderTimer?.invalidate() + + // Set a timer to show confirmation after user stops sliding + sliderTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in + newSize = newValue.size + showResizeConfirmation = true + } + } else { + onSave(newValue) + } + } + .alert("Resize Disk Image", isPresented: $showResizeConfirmation) { + Button("Cancel", role: .cancel) { + image.size = minimumSize + } + Button("Resize") { + performResize() + } + } message: { + Text("This will resize the disk image from \(formatter.string(fromByteCount: Int64(minimumSize))) to \(formatter.string(fromByteCount: Int64(newSize))). The resize will run automatically the next time the virtual machine starts and may take some time. This operation cannot be undone.") + } + .alert("FileVault Enabled", isPresented: $showFileVaultError) { + Button("OK", role: .cancel) { } + } message: { + Text("This disk has FileVault encryption enabled. To resize the disk, you must first disable FileVault in the guest operating system's System Settings, then restart the virtual machine before attempting to resize again.") } } @@ -98,9 +145,17 @@ struct ManagedDiskImageEditor: View { private var sizeChangeInfo: String { if isBootVolume { - return "Be sure to reserve enough space, since it won't be possible to change the size of the disk later." + if canResize { + return "Boot disk can be expanded, but not shrunk. Choose your size carefully." + } else { + return "Be sure to reserve enough space, since it won't be possible to change the size of the disk later." + } } else { - return "It's not possible to change the size of an existing storage device." + if canResize { + return "This disk can be expanded to a larger size, but cannot be shrunk." + } else { + return "It's not possible to change the size of an existing storage device." + } } } @@ -123,6 +178,32 @@ struct ManagedDiskImageEditor: View { return "The volume \(volumeDescription) doesn't have enough free space to fit the full size of the disk image." } + + private func performResize() { + isResizing = true + + Task { + // Check for FileVault before proceeding with resize + let hasFileVault = await viewModel.vm.checkFileVaultForDiskImage(image) + + await MainActor.run { + if hasFileVault { + // Reset size and show FileVault error + image.size = minimumSize + isResizing = false + showFileVaultError = true + } else { + // Proceed with resize + image.size = newSize + onSave(image) + isResizing = false + } + } + + // The actual resize will happen automatically when VM starts or restarts + // due to the size mismatch detection in checkAndResizeDiskImages() + } + } } #if DEBUG diff --git a/VirtualUI/Source/VM Configuration/Sections/Storage/StorageConfigurationView.swift b/VirtualUI/Source/VM Configuration/Sections/Storage/StorageConfigurationView.swift index 188c2de7..4c545527 100644 --- a/VirtualUI/Source/VM Configuration/Sections/Storage/StorageConfigurationView.swift +++ b/VirtualUI/Source/VM Configuration/Sections/Storage/StorageConfigurationView.swift @@ -36,6 +36,21 @@ struct StorageConfigurationView: View { configure(device) } .tag(device.id) + .contextMenu { + if device.canBeResized { + Button("Resize Disk…") { + configure(device) + } + } + + if !device.isBootVolume { + Button("Remove Device", role: .destructive) { + if let idx = hardware.storageDevices.firstIndex(where: { $0.id == device.id }) { + hardware.storageDevices.remove(at: idx) + } + } + } + } } } } emptyOverlay: { @@ -119,6 +134,13 @@ struct StorageDeviceListItem: View { Text(device.displayName) Spacer() + + if device.canBeResized { + Image(systemName: "arrow.up.right.and.arrow.down.left") + .font(.caption) + .foregroundColor(.blue) + .help("This disk can be resized") + } Button { configureDevice()