Initial public release

This commit is contained in:
f-trycua
2025-01-31 16:09:20 +01:00
commit 19a07d24d3
75 changed files with 6740 additions and 0 deletions

76
.gitignore vendored Normal file
View File

@@ -0,0 +1,76 @@
# Xcode
#
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
.DS_Store
## User settings
xcuserdata/
## Obj-C/Swift specific
*.hmap
## App packaging
*.ipa
*.dSYM.zip
*.dSYM
## Playgrounds
timeline.xctimeline
playground.xcworkspace
# Swift Package Manager
#
# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
# Packages/
# Package.pins
# Package.resolved
# *.xcodeproj
#
# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
# hence it is not needed unless you have added a package configuration file to your project
# .swiftpm
.build/
# CocoaPods
#
# We recommend against adding the Pods directory to your .gitignore. However
# you should judge for yourself, the pros and cons are mentioned at:
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
#
# Pods/
#
# Add this line if you want to avoid checking in source code from the Xcode workspace
# *.xcworkspace
# Carthage
#
# Add this line if you want to avoid checking in source code from Carthage dependencies.
# Carthage/Checkouts
Carthage/Build/
# fastlane
#
# It is recommended to not store the screenshots in the git repo.
# Instead, use fastlane to re-generate the screenshots whenever they are needed.
# For more information about the recommended setup visit:
# https://docs.fastlane.tools/best-practices/source-control/#source-control
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/**/*.png
fastlane/test_output
# Local environment variables
.env.local
# Ignore folder
ignore
# .release
.release/
# Swift Package Manager
.swiftpm/

227
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,227 @@
{
"configurations": [
{
"type": "bashdb",
"request": "launch",
"name": "Bash-Debug (select script from list of sh files)",
"cwd": "${workspaceFolder}",
"program": "${command:SelectScriptName}",
"pathBash": "/opt/homebrew/bin/bash",
"args": []
},
{
"type": "lldb",
"request": "launch",
"sourceLanguages": [
"swift"
],
"args": [
"serve"
],
"cwd": "${workspaceFolder:lume}",
"name": "Debug lume serve",
"program": "${workspaceFolder:lume}/.build/debug/lume",
"preLaunchTask": "build-debug"
},
{
"type": "lldb",
"request": "launch",
"sourceLanguages": [
"swift"
],
"args": [
"create",
"macos-vm",
"--cpu",
"4",
"--memory",
"4GB",
"--disk-size",
"40GB",
"--ipsw",
"/Users/<USER>/Downloads/UniversalMac_15.2_24C101_Restore.ipsw"
],
"cwd": "${workspaceFolder:lume}",
"name": "Debug lume create --os macos --ipsw 'path/to/ipsw' (macos)",
"program": "${workspaceFolder:lume}/.build/debug/lume",
"preLaunchTask": "build-debug"
},
{
"type": "lldb",
"request": "launch",
"sourceLanguages": [
"swift"
],
"args": [
"create",
"macos-vm",
"--cpu",
"4",
"--memory",
"4GB",
"--disk-size",
"20GB",
"--ipsw",
"latest"
],
"cwd": "${workspaceFolder:lume}",
"name": "Debug lume create --os macos --ipsw latest (macos)",
"program": "${workspaceFolder:lume}/.build/debug/lume",
"preLaunchTask": "build-debug"
},
{
"type": "lldb",
"request": "launch",
"sourceLanguages": [
"swift"
],
"args": [
"create",
"linux-vm",
"--os",
"linux",
"--cpu",
"4",
"--memory",
"4GB",
"--disk-size",
"20GB"
],
"cwd": "${workspaceFolder:lume}",
"name": "Debug lume create --os linux (linux)",
"program": "${workspaceFolder:lume}/.build/debug/lume",
"preLaunchTask": "build-debug"
},
{
"type": "lldb",
"request": "launch",
"sourceLanguages": [
"swift"
],
"args": [
"pull",
"macos-sequoia-vanilla:15.2",
"--name",
"macos-vm-cloned"
],
"cwd": "${workspaceFolder:lume}",
"name": "Debug lume pull (macos)",
"program": "${workspaceFolder:lume}/.build/debug/lume",
"preLaunchTask": "build-debug"
},
{
"type": "lldb",
"request": "launch",
"sourceLanguages": [
"swift"
],
"args": [
"run",
"macos-vm",
"--shared-dir",
"/Users/<USER>/repos/trycua/lume/shared_folder:rw",
"--start-vnc"
],
"cwd": "${workspaceFolder:lume}",
"name": "Debug lume run (macos)",
"program": "${workspaceFolder:lume}/.build/debug/lume",
"preLaunchTask": "build-debug"
},
{
"type": "lldb",
"request": "launch",
"sourceLanguages": [
"swift"
],
"args": [
"run",
"linux-vm",
"--start-vnc",
"--mount",
"/Users/<USER>/Downloads/ubuntu-24.04.1-live-server-arm64.iso"
],
"cwd": "${workspaceFolder:lume}",
"name": "Debug lume run with setup mount (linux)",
"program": "${workspaceFolder:lume}/.build/debug/lume",
"preLaunchTask": "build-debug"
},
{
"type": "lldb",
"request": "launch",
"sourceLanguages": [
"swift"
],
"args": [
"run",
"linux-vm",
"--start-vnc"
],
"cwd": "${workspaceFolder:lume}",
"name": "Debug lume run (linux)",
"program": "${workspaceFolder:lume}/.build/debug/lume",
"preLaunchTask": "build-debug"
},
{
"type": "lldb",
"request": "launch",
"sourceLanguages": [
"swift"
],
"args": [
"get",
"macos-vm"
],
"cwd": "${workspaceFolder:lume}",
"name": "Debug lume get (macos)",
"program": "${workspaceFolder:lume}/.build/debug/lume",
"preLaunchTask": "build-debug"
},
{
"type": "lldb",
"request": "launch",
"sourceLanguages": [
"swift"
],
"args": [
"ls"
],
"cwd": "${workspaceFolder:lume}",
"name": "Debug lume ls",
"program": "${workspaceFolder:lume}/.build/debug/lume",
"preLaunchTask": "build-debug"
},
{
"type": "lldb",
"request": "launch",
"sourceLanguages": [
"swift"
],
"args": [
"stop",
"macos-vm"
],
"cwd": "${workspaceFolder:lume}",
"name": "Debug lume stop (macos)",
"program": "${workspaceFolder:lume}/.build/debug/lume",
"preLaunchTask": "build-debug"
},
{
"type": "lldb",
"request": "launch",
"args": [],
"cwd": "${workspaceFolder:lume}",
"name": "Debug lume",
"program": "${workspaceFolder:lume}/.build/debug/lume",
"preLaunchTask": "swift: Build Debug lume"
},
{
"type": "lldb",
"request": "launch",
"args": [],
"cwd": "${workspaceFolder:lume}",
"name": "Release lume",
"program": "${workspaceFolder:lume}/.build/release/lume",
"preLaunchTask": "swift: Build Release lume"
}
]
}

18
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,18 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build-debug",
"type": "shell",
"command": "${workspaceFolder:lume}/scripts/build/build-debug.sh",
"group": {
"kind": "build",
"isDefault": true
},
"presentation": {
"reveal": "silent"
},
"problemMatcher": []
}
]
}

39
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,39 @@
# Contributing to lume
We deeply appreciate your interest in contributing to lume! Whether you're reporting bugs, suggesting enhancements, improving docs, or submitting pull requests, your contributions help improve the project for everyone.
## Reporting Bugs
If you've encountered a bug in the project, we encourage you to report it. Please follow these steps:
1. **Check the Issue Tracker**: Before submitting a new bug report, please check our issue tracker to see if the bug has already been reported.
2. **Create a New Issue**: If the bug hasn't been reported, create a new issue with:
- A clear title and detailed description
- Steps to reproduce the issue
- Expected vs actual behavior
- Your environment (macOS version, lume version)
- Any relevant logs or error messages
3. **Label Your Issue**: Label your issue as a `bug` to help maintainers identify it quickly.
## Suggesting Enhancements
We're always looking for suggestions to make lume better. If you have an idea:
1. **Check Existing Issues**: See if someone else has already suggested something similar.
2. **Create a New Issue**: If your enhancement is new, create an issue describing:
- The problem your enhancement solves
- How your enhancement would work
- Any potential implementation details
- Why this enhancement would benefit lume users
## Documentation
Documentation improvements are always welcome. You can:
- Fix typos or unclear explanations
- Add examples and use cases
- Improve API documentation
- Add tutorials or guides
For detailed instructions on setting up your development environment and submitting code contributions, please see our [Development.md](docs/Development.md) guide.
Feel free to join our [Discord community](https://discord.com/channels/1328377437301641247) to discuss ideas or get help with your contributions.

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 trycua
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

69
Package.resolved Normal file
View File

@@ -0,0 +1,69 @@
{
"originHash" : "81a9d169da3c391b981b894044911091d11285486aab463e32222490c931ba45",
"pins" : [
{
"identity" : "dynamic",
"kind" : "remoteSourceControl",
"location" : "https://github.com/mhdhejazi/Dynamic",
"state" : {
"branch" : "master",
"revision" : "772883073d044bc754d401cabb6574624eb3778f"
}
},
{
"identity" : "swift-argument-parser",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-argument-parser",
"state" : {
"revision" : "41982a3656a71c768319979febd796c6fd111d5c",
"version" : "1.5.0"
}
},
{
"identity" : "swift-atomics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-atomics.git",
"state" : {
"revision" : "cd142fd2f64be2100422d658e7411e39489da985",
"version" : "1.2.0"
}
},
{
"identity" : "swift-cmark",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-cmark.git",
"state" : {
"revision" : "3ccff77b2dc5b96b77db3da0d68d28068593fa53",
"version" : "0.5.0"
}
},
{
"identity" : "swift-format",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-format.git",
"state" : {
"branch" : "release/5.10",
"revision" : "3191b8f3109730af449c6332d0b1ca6653b857a0"
}
},
{
"identity" : "swift-markdown",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-markdown.git",
"state" : {
"revision" : "8f79cb175981458a0a27e76cb42fee8e17b1a993",
"version" : "0.5.0"
}
},
{
"identity" : "swift-syntax",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-syntax.git",
"state" : {
"branch" : "release/5.10",
"revision" : "cdd571f366a4298bb863a9dcfe1295bb595041d5"
}
}
],
"version" : 3
}

35
Package.swift Normal file
View File

@@ -0,0 +1,35 @@
// swift-tools-version: 6.0
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "lume",
platforms: [
.macOS(.v14)
],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.1"),
.package(url: "https://github.com/apple/swift-format.git", branch: ("release/5.10")),
.package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.2.0")),
.package(url: "https://github.com/mhdhejazi/Dynamic", branch: "master")
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
.executableTarget(
name: "lume",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "Atomics", package: "swift-atomics"),
.product(name: "Dynamic", package: "Dynamic")
],
path: "src"),
.testTarget(
name: "lumeTests",
dependencies: [
"lume"
],
path: "tests")
]
)

140
README.md Normal file
View File

@@ -0,0 +1,140 @@
<div align="center">
<h1>
<div class="image-wrapper" style="display: inline-block;">
<picture>
<source media="(prefers-color-scheme: dark)" alt="logo" height="150" srcset="img/logo_white.png" style="display: block; margin: auto;">
<source media="(prefers-color-scheme: light)" alt="logo" height="150" srcset="img/logo_black.png" style="display: block; margin: auto;">
<img alt="Shows my svg">
</picture>
</div>
[![Swift 6](https://img.shields.io/badge/Swift_6-F54A2A?logo=swift&logoColor=white&labelColor=F54A2A)](#)
[![macOS](https://img.shields.io/badge/macOS-000000?logo=apple&logoColor=F0F0F0)](#)
[![Homebrew](https://img.shields.io/badge/Homebrew-FBB040?logo=homebrew&logoColor=fff)](#install)
[![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?&logo=discord&logoColor=white)](https://discord.gg/8p56E2KJ)
</h1>
</div>
**lume** is a lightweight Command Line Interface and local API server to create, run and manage macOS and Linux virtual machines (VMs) with near-native performance on Apple Silicon, using Apple's `Virtualization.Framework`.
### Run prebuilt macOS images in just 1 step
<div align="center">
<img src="img/cli.png" alt="lume cli">
</div>
```bash
lume run macos-sequoia-vanilla:latest
```
## Usage
```bash
lume <command>
Commands:
lume create <name> Create a new macOS or Linux VM
lume run <name> Run a VM
lume ls List all VMs
lume get <name> Get detailed information about a VM
lume set <name> Modify VM configuration
lume stop <name> Stop a running VM
lume delete <name> Delete a VM
lume pull <image> Pull a macOS image from container registry
lume clone <name> <new-name> Clone an existing VM
lume images List available macOS images in local cache
lume ipsw Get the latest macOS restore image URL
lume prune Remove cached images
lume serve Start the API server
Options:
--help Show help [boolean]
--version Show version number [boolean]
Command Options:
create:
--os <os> Operating system to install (macOS or linux, default: macOS)
--cpu <cores> Number of CPU cores (default: 4)
--memory <size> Memory size, e.g., 8GB (default: 4GB)
--disk-size <size> Disk size, e.g., 50GB (default: 40GB)
--display <res> Display resolution (default: 1024x768)
--ipsw <path> Path to IPSW file or 'latest' for macOS VMs
run:
--no-display Do not start the VNC client app
--shared-dir <dir> Share directory with VM (format: path[:ro|rw])
--mount <path> For Linux VMs only, attach a read-only disk image
set:
--cpu <cores> New number of CPU cores
--memory <size> New memory size
--disk-size <size> New disk size
delete:
--force Force deletion without confirmation
pull:
--registry <url> Container registry URL (default: ghcr.io)
--organization <org> Organization to pull from (default: trycua)
serve:
--port <port> Port to listen on (default: 3000)
```
## Install
```bash
brew tap trycua/lume
brew install lume
```
You can also download the `lume.pkg.tar.gz` archive from the [latest release](https://github.com/trycua/lume/releases), extract it, and install the package manually.
## Prebuilt Images
Pre-built images are available on [ghcr.io/trycua](https://github.com/orgs/trycua/packages).
These images come with an SSH server pre-configured and auto-login enabled.
| Image | Tag | Description | Size |
|-------|------------|-------------|------|
| `macos-sequoia-vanilla` | `latest`, `15.2` | macOS Sonoma 15.2 | 40GB |
| `macos-sequoia-xcode` | `latest`, `15.2` | macOS Sonoma 15.2 with Xcode command line tools | 50GB |
| `ubuntu-vanilla` | `latest`, `24.04.1` | [Ubuntu Server for ARM 24.04.1 LTS](https://ubuntu.com/download/server/arm) with Ubuntu Desktop | 20GB |
For additional disk space, resize the VM disk after pulling the image using the `lume set <name> --disk-size <size>` command.
## Local API Server
`lume` exposes a local HTTP API server that listens on `http://localhost:3000/lume`, enabling automated management of VMs.
```bash
lume serve
```
For detailed API documentation, please refer to [API Reference](docs/API-Reference.md).
## Docs
- [API Reference](docs/API-Reference.md)
- [Development](docs/Development.md)
- [FAQ](docs/FAQ.md)
## Contributing
We welcome and greatly appreciate contributions to lume! Whether you're improving documentation, adding new features, fixing bugs, or adding new VM images, your efforts help make lume better for everyone. For detailed instructions on how to contribute, please refer to our [Contributing Guidelines](CONTRIBUTING.md).
Join our [Discord community](https://discord.com/channels/1328377437301641247) to discuss ideas or get assistance.
## License
lume is open-sourced under the MIT License - see the [LICENSE](LICENSE) file for details.
## Trademarks
Apple, macOS, and Apple Silicon are trademarks of Apple Inc. Ubuntu and Canonical are registered trademarks of Canonical Ltd. This project is not affiliated with, endorsed by, or sponsored by Apple Inc. or Canonical Ltd.
## Stargazers over time
[![Stargazers over time](https://starchart.cc/trycua/lume.svg?variant=adaptive)](https://starchart.cc/trycua/lume)

226
docs/API-Reference.md Normal file
View File

@@ -0,0 +1,226 @@
## API Reference
<details open>
<summary><strong>Create VM</strong> - POST /vms</summary>
```bash
curl --connect-timeout 6000 \
--max-time 5000 \
-X POST \
-H "Content-Type: application/json" \
-d '{
"name": "lume_vm",
"os": "macOS",
"cpu": 2,
"memory": "4GB",
"diskSize": "64GB",
"display": "1024x768",
"ipsw": "latest"
}' \
http://localhost:3000/lume/vms
```
</details>
<details open>
<summary><strong>Run VM</strong> - POST /vms/:name/run</summary>
```bash
# Basic run
curl --connect-timeout 6000 \
--max-time 5000 \
-X POST \
http://localhost:3000/lume/vms/my-vm-name/run
# Run with VNC client started and shared directory
curl --connect-timeout 6000 \
--max-time 5000 \
-X POST \
-H "Content-Type: application/json" \
-d '{
"noDisplay": false,
"sharedDirectory": [
{
"hostPath": "~/Projects",
"readOnly": false
}
]
}' \
http://localhost:3000/lume/vms/lume_vm/run
```
</details>
<details open>
<summary><strong>List VMs</strong> - GET /vms</summary>
```bash
curl --connect-timeout 6000 \
--max-time 5000 \
http://localhost:3000/lume/vms
```
```
[
{
"name": "my-vm",
"state": "stopped",
"os": "macOS",
"cpu": 2,
"memory": "4GB",
"diskSize": "64GB"
},
{
"name": "my-vm-2",
"state": "stopped",
"os": "linux",
"cpu": 2,
"memory": "4GB",
"diskSize": "64GB"
}
]
```
</details>
<details open>
<summary><strong>Get VM Details</strong> - GET /vms/:name</summary>
```bash
curl --connect-timeout 6000 \
--max-time 5000 \
http://localhost:3000/lume/vms/lume_vm\
```
```
{
"name": "lume_vm",
"state": "running",
"os": "macOS",
"cpu": 2,
"memory": "4GB",
"diskSize": "64GB"
}
```
</details>
<details open>
<summary><strong>Update VM Settings</strong> - PATCH /vms/:name</summary>
```bash
curl --connect-timeout 6000 \
--max-time 5000 \
-X PATCH \
-H "Content-Type: application/json" \
-d '{
"cpu": 4,
"memory": "8GB",
"diskSize": "128GB"
}' \
http://localhost:3000/lume/vms/my-vm-name
```
</details>
<details open>
<summary><strong>Stop VM</strong> - POST /vms/:name/stop</summary>
```bash
curl --connect-timeout 6000 \
--max-time 5000 \
-X POST \
http://localhost:3000/lume/vms/my-vm-name/stop
```
</details>
<details open>
<summary><strong>Delete VM</strong> - DELETE /vms/:name</summary>
```bash
curl --connect-timeout 6000 \
--max-time 5000 \
-X DELETE \
http://localhost:3000/lume/vms/my-vm-name
```
</details>
<details open>
<summary><strong>Pull Image</strong> - POST /pull</summary>
```bash
curl --connect-timeout 6000 \
--max-time 5000 \
-X POST \
-H "Content-Type: application/json" \
-d '{
"image": "monterey:latest",
"name": "my-vm-name", # Optional, defaults to image name
"registry": "ghcr.io", # Optional, defaults to ghcr.io
"organization": "trycua" # Optional, defaults to trycua
}' \
http://localhost:3000/lume/pull
```
```bash
curl --connect-timeout 6000 \
--max-time 5000 \
-X POST \
-H "Content-Type: application/json" \
-d '{
"image": "macos-sequoia-vanilla:15.2",
"name": "macos-sequoia-vanilla"
}' \
http://localhost:3000/lume/pull
```
</details>
<details open>
<summary><strong>Clone VM</strong> - POST /vms/:name/clone</summary>
```bash
curl --connect-timeout 6000 \
--max-time 5000 \
-X POST \
-H "Content-Type: application/json" \
-d '{
"name": "source-vm",
"newName": "cloned-vm"
}' \
http://localhost:3000/lume/vms/source-vm/clone
```
</details>
<details open>
<summary><strong>Get Latest IPSW URL</strong> - GET /ipsw</summary>
```bash
curl --connect-timeout 6000 \
--max-time 5000 \
http://localhost:3000/lume/ipsw
```
</details>
<details open>
<summary><strong>List Images</strong> - GET /images</summary>
```bash
# List images with default organization (trycua)
curl --connect-timeout 6000 \
--max-time 5000 \
http://localhost:3000/lume/images
```
```json
{
"local": [
"macos-sequoia-xcode:latest",
"macos-sequoia-vanilla:latest"
]
}
```
</details>
<details open>
<summary><strong>Prune Images</strong> - POST /lume/prune</summary>
```bash
curl --connect-timeout 6000 \
--max-time 5000 \
-X POST \
http://localhost:3000/lume/prune
```
</details>

45
docs/Development.md Normal file
View File

@@ -0,0 +1,45 @@
# Development Guide
This guide will help you set up your development environment and understand the process for contributing code to lume.
## Environment Setup
Lume development requires:
- Swift 6 or higher
- Xcode 15 or higher
- macOS Sequoia 15.2 or higher
- (Optional) VS Code with Swift extension
## Setting Up the Repository Locally
1. **Fork the Repository**: Create your own fork of lume
2. **Clone the Repository**:
```bash
git clone https://github.com/trycua/lume.git
cd lume
```
3. **Install Dependencies**:
```bash
swift package resolve
```
4. **Build the Project**:
```bash
swift build
```
## Development Workflow
1. Create a new branch for your changes
2. Make your changes
3. Run the tests: `swift test`
4. Build and test your changes locally
5. Commit your changes with clear commit messages
## Submitting Pull Requests
1. Push your changes to your fork
2. Open a Pull Request with:
- A clear title and description
- Reference to any related issues
- Screenshots or logs if relevant
3. Respond to any feedback from maintainers

37
docs/FAQ.md Normal file
View File

@@ -0,0 +1,37 @@
# FAQs
### Where are the VMs stored?
VMs are stored in `~/.lume`.
### How are images cached?
Images are cached in `~/.lume/cache`. When doing `lume pull <image>`, it will check if the image is already cached. If not, it will download the image and cache it, removing any older versions.
### Are VM disks taking up all the disk space?
No, macOS uses sparse files, which only allocate space as needed. For example, VM disks totaling 50 GB may only use 20 GB on disk.
### How do I get the latest macOS restore image URL?
```bash
lume ipsw
```
### How do I delete a VM?
```bash
lume delete <name>
```
### How do I install a custom linux image?
The process for creating a custom linux image differs than macOS, with IPSW restore files not being used. You need to create a linux VM first, then mount a setup image file to the VM for the first boot.
```bash
lume create <name> --os linux
lume run <name> --mount <path-to-setup-image> --start-vnc
lume run <name> --start-vnc
```

BIN
img/cli.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

BIN
img/logo_black.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

BIN
img/logo_white.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.virtualization</key>
<true/>
</dict>
</plist>

4
scripts/build/build-debug.sh Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/sh
swift build --product lume
codesign --force --entitlement resources/lume.entitlements --sign - .build/debug/lume

View File

@@ -0,0 +1,99 @@
#!/bin/bash
# Check required environment variables
required_vars=(
"CERT_APPLICATION_NAME"
"CERT_INSTALLER_NAME"
"APPLE_ID"
"TEAM_ID"
"APP_SPECIFIC_PASSWORD"
)
for var in "${required_vars[@]}"; do
if [ -z "${!var}" ]; then
echo "Error: $var is not set"
exit 1
fi
done
# Move to the project root directory
pushd ../../
# Build the release version
swift build -c release --product lume
# Sign the binary with hardened runtime entitlements
codesign --force --options runtime \
--entitlement ./resources/lume.entitlements \
--sign "$CERT_APPLICATION_NAME" \
.build/release/lume
# Create a temporary directory for packaging
TEMP_ROOT=$(mktemp -d)
mkdir -p "$TEMP_ROOT/usr/local/bin"
cp -f .build/release/lume "$TEMP_ROOT/usr/local/bin/"
# Build the installer package
pkgbuild --root "$TEMP_ROOT" \
--identifier "com.trycua.lume" \
--version "1.0" \
--install-location "/" \
--sign "$CERT_INSTALLER_NAME" \
./.release/lume.pkg
# Submit for notarization using stored credentials
xcrun notarytool submit ./.release/lume.pkg \
--apple-id "${APPLE_ID}" \
--team-id "${TEAM_ID}" \
--password "${APP_SPECIFIC_PASSWORD}" \
--wait
# Staple the notarization ticket
xcrun stapler staple ./.release/lume.pkg
# Create temporary directory for package extraction
EXTRACT_ROOT=$(mktemp -d)
PKG_PATH="$(pwd)/.release/lume.pkg"
# Extract the pkg using xar
cd "$EXTRACT_ROOT"
xar -xf "$PKG_PATH"
# Verify Payload exists before proceeding
if [ ! -f "Payload" ]; then
echo "Error: Payload file not found after xar extraction"
exit 1
fi
# Create a directory for the extracted contents
mkdir -p extracted
cd extracted
# Extract the Payload
cat ../Payload | gunzip -dc | cpio -i
# Verify the binary exists
if [ ! -f "usr/local/bin/lume" ]; then
echo "Error: lume binary not found in expected location"
exit 1
fi
# Copy extracted lume to ./.release/lume
cp -f usr/local/bin/lume "$(dirname "$PKG_PATH")/lume"
# Create symbolic link in /usr/local/bin
cd "$(dirname "$PKG_PATH")"
sudo ln -sf "$(pwd)/lume" /usr/local/bin/lume
# Create zip archive of the package
tar -czvf lume.tar.gz lume
tar -czvf lume.pkg.tar.gz lume.pkg
# Create sha256 checksum for the lume tarball
shasum -a 256 lume.tar.gz
popd
# Clean up
rm -rf "$TEMP_ROOT"
rm -rf "$EXTRACT_ROOT"

15
scripts/build/build-release.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/sh
pushd ../../
swift build -c release --product lume
codesign --force --entitlement ./resources/lume.entitlements --sign - .build/release/lume
mkdir -p ./.release
cp -f .build/release/lume ./.release/lume
# Create symbolic link in /usr/local/bin
sudo mkdir -p /usr/local/bin
sudo ln -sf "$(pwd)/.release/lume" /usr/local/bin/lume
popd

205
scripts/ghcr/pull-ghcr.sh Executable file
View File

@@ -0,0 +1,205 @@
#!/bin/bash
# Exit immediately if a command exits with a non-zero status
set -e
# Default parameters
organization=""
image_name=""
image_version=""
target_folder_path=""
# Parse the command line arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--organization)
organization="$2"
shift 2
;;
--image-name)
image_name="$2"
shift 2
;;
--image-version)
image_version="$2"
shift 2
;;
--target-folder-path)
target_folder_path="$2"
shift 2
;;
--help)
echo "Usage: $0 [options]"
echo "Options:"
echo " --organization <organization> : GitHub organization (required)"
echo " --image-name <name> : Name of the image to pull (required)"
echo " --image-version <version> : Version of the image to pull (required)"
echo " --target-folder-path <path> : Path where to extract the files (required)"
exit 0
;;
*)
echo "Unknown option: $1"
exit 1
;;
esac
done
# Ensure required arguments
if [[ -z "$organization" || -z "$image_name" || -z "$image_version" || -z "$target_folder_path" ]]; then
echo "Error: Missing required arguments. Use --help for usage."
exit 1
fi
# Check and install required tools
for tool in "jq" "pv" "parallel"; do
if ! command -v "$tool" &> /dev/null; then
echo "$tool is not installed. Installing using Homebrew..."
if ! command -v brew &> /dev/null; then
echo "Homebrew is not installed. Please install Homebrew first: https://brew.sh/"
exit 1
fi
brew install "$tool"
fi
done
# Create target folder if it doesn't exist
mkdir -p "$target_folder_path"
# Create a temporary directory for processing files
work_dir=$(mktemp -d)
echo "Working directory: $work_dir"
trap 'rm -rf "$work_dir"' EXIT
# Registry details
REGISTRY="ghcr.io"
REPOSITORY="$organization/$image_name"
TAG="$image_version"
# Get anonymous token
echo "Getting authentication token..."
curl -s "https://$REGISTRY/token?service=ghcr.io&scope=repository:$REPOSITORY:pull" -o "$work_dir/token.json"
TOKEN=$(cat "$work_dir/token.json" | jq -r ".token")
if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
echo "Failed to obtain token"
exit 1
fi
echo "Token obtained successfully"
# Fetch manifest
echo "Fetching manifest..."
MANIFEST_RESPONSE=$(curl -s \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/vnd.oci.image.manifest.v1+json" \
"https://$REGISTRY/v2/$REPOSITORY/manifests/$TAG")
echo "Processing manifest..."
# Create a directory for all files
cd "$work_dir"
# Create a download function for parallel execution
download_layer() {
local media_type="$1"
local digest="$2"
local output_file="$3"
echo "Downloading $output_file..."
curl -s -L \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: $media_type" \
"https://$REGISTRY/v2/$REPOSITORY/blobs/$digest" | \
pv > "$output_file"
}
export -f download_layer
export TOKEN REGISTRY REPOSITORY
# Process layers and create download jobs
echo "$MANIFEST_RESPONSE" | jq -c '.layers[]' | while read -r layer; do
media_type=$(echo "$layer" | jq -r '.mediaType')
digest=$(echo "$layer" | jq -r '.digest')
# Skip empty layers
if [[ "$media_type" == "application/vnd.oci.empty.v1+json" ]]; then
continue
fi
# Extract part information if present
if [[ $media_type =~ part\.number=([0-9]+)\;part\.total=([0-9]+) ]]; then
part_num="${BASH_REMATCH[1]}"
total_parts="${BASH_REMATCH[2]}"
echo "Found part $part_num of $total_parts"
output_file="disk.img.part.$part_num"
else
case "$media_type" in
"application/vnd.oci.image.layer.v1.tar")
output_file="disk.img"
;;
"application/vnd.oci.image.config.v1+json")
output_file="config.json"
;;
"application/octet-stream")
output_file="nvram.bin"
;;
*)
echo "Unknown media type: $media_type"
continue
;;
esac
fi
# Add to download queue
echo "$media_type"$'\t'"$digest"$'\t'"$output_file" >> download_queue.txt
done
# Download all files in parallel
echo "Downloading files in parallel..."
parallel --colsep $'\t' -a download_queue.txt download_layer {1} {2} {3}
# Check if we have disk parts to reassemble
if ls disk.img.part.* 1> /dev/null 2>&1; then
echo "Found disk parts, reassembling..."
# Get total parts from the first part's filename
first_part=$(ls disk.img.part.* | head -n 1)
total_parts=$(echo "$MANIFEST_RESPONSE" | jq -r '.layers[] | select(.mediaType | contains("part.total")) | .mediaType' | grep -o 'part\.total=[0-9]*' | cut -d= -f2 | head -n 1)
echo "Total parts to reassemble: $total_parts"
# Concatenate parts in order
echo "Reassembling disk image..."
{
for i in $(seq 1 "$total_parts"); do
part_file="disk.img.part.$i"
if [ -f "$part_file" ]; then
cat "$part_file"
else
echo "Error: Missing part $i"
exit 1
fi
done
} | pv > "$target_folder_path/disk.img"
echo "Disk image reassembled successfully"
else
# If no parts found, just copy disk.img if it exists
if [ -f disk.img ]; then
echo "Copying disk image..."
pv disk.img > "$target_folder_path/disk.img"
fi
fi
# Copy config.json if it exists
if [ -f config.json ]; then
echo "Copying config.json..."
cp config.json "$target_folder_path/"
fi
# Copy nvram.bin if it exists
if [ -f nvram.bin ]; then
echo "Copying nvram.bin..."
cp nvram.bin "$target_folder_path/"
fi
echo "Download complete: Files extracted to $target_folder_path"

208
scripts/ghcr/push-ghcr.sh Executable file
View File

@@ -0,0 +1,208 @@
#!/bin/bash
# Exit immediately if a command exits with a non-zero status
set -e
# Default parameters
organization=""
folder_path=""
image_name=""
image_versions=""
chunk_size="500M" # Default chunk size for splitting large files
# Parse the command line arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--organization)
organization="$2"
shift 2
;;
--folder-path)
folder_path="$2"
shift 2
;;
--image-name)
image_name="$2"
shift 2
;;
--image-versions)
image_versions="$2"
shift 2
;;
--chunk-size)
chunk_size="$2"
shift 2
;;
--help)
echo "Usage: $0 [options]"
echo "Options:"
echo " --organization <organization> : GitHub organization (required if not using token)"
echo " --folder-path <path> : Path to the folder to upload (required)"
echo " --image-name <name> : Name of the image to publish (required)"
echo " --image-versions <versions> : Comma separated list of versions of the image to publish (required)"
echo " --chunk-size <size> : Size of chunks for large files (e.g., 500M, default: 500M)"
exit 0
;;
*)
echo "Unknown option: $1"
exit 1
;;
esac
done
# Ensure required arguments
if [[ -z "$organization" || -z "$folder_path" || -z "$image_name" || -z "$image_versions" ]]; then
echo "Error: Missing required arguments. Use --help for usage."
exit 1
fi
# Check if the GITHUB_TOKEN variable is set
if [[ -z "$GITHUB_TOKEN" ]]; then
echo "Error: GITHUB_TOKEN is not set."
exit 1
fi
# Ensure the folder exists
if [[ ! -d "$folder_path" ]]; then
echo "Error: Folder $folder_path does not exist."
exit 1
fi
# Check and install required tools
for tool in "oras" "split" "pv" "gzip"; do
if ! command -v "$tool" &> /dev/null; then
echo "$tool is not installed. Installing using Homebrew..."
if ! command -v brew &> /dev/null; then
echo "Homebrew is not installed. Please install Homebrew first: https://brew.sh/"
exit 1
fi
brew install "$tool"
fi
done
# Authenticate with GitHub Container Registry
echo "$GITHUB_TOKEN" | oras login ghcr.io -u "$organization" --password-stdin
# Create a temporary directory for processing files
work_dir=$(mktemp -d)
echo "Working directory: $work_dir"
trap 'rm -rf "$work_dir"' EXIT
# Create a directory for all files
mkdir -p "$work_dir/files"
cd "$work_dir/files"
# Copy config.json if it exists
if [ -f "$folder_path/config.json" ]; then
echo "Copying config.json..."
cp "$folder_path/config.json" config.json
fi
# Copy nvram.bin if it exists
nvram_bin="$folder_path/nvram.bin"
if [ -f "$nvram_bin" ]; then
echo "Copying nvram.bin..."
cp "$nvram_bin" nvram.bin
fi
# Process disk.img if it exists and needs splitting
disk_img="$folder_path/disk.img"
if [ -f "$disk_img" ]; then
file_size=$(stat -f%z "$disk_img")
if [ $file_size -gt 524288000 ]; then # 500MB in bytes
echo "Splitting large file: disk.img"
echo "Original disk.img size: $(du -h "$disk_img" | cut -f1)"
# Copy and split the file with progress monitoring
echo "Copying disk image..."
pv "$disk_img" > disk.img
echo "Splitting file..."
split -b "$chunk_size" disk.img disk.img.part.
rm disk.img
# Get original file size for verification
original_size=$(stat -f%z "$disk_img")
echo "Original disk.img size: $(awk -v size=$original_size 'BEGIN {printf "%.2f GB", size/1024/1024/1024}')"
# Verify split parts total size
total_size=0
total_parts=$(ls disk.img.part.* | wc -l | tr -d ' ')
part_num=0
# Create array for files and their annotations
files=()
for part in disk.img.part.*; do
part_size=$(stat -f%z "$part")
total_size=$((total_size + part_size))
part_num=$((part_num + 1))
echo "Part $part: $(awk -v size=$part_size 'BEGIN {printf "%.2f GB", size/1024/1024/1024}')"
files+=("$part:application/vnd.oci.image.layer.v1.tar;part.number=$part_num;part.total=$total_parts")
done
echo "Total size of parts: $(awk -v size=$total_size 'BEGIN {printf "%.2f GB", size/1024/1024/1024}')"
# Verify total size matches original
if [ $total_size -ne $original_size ]; then
echo "ERROR: Size mismatch!"
echo "Original file size: $(awk -v size=$original_size 'BEGIN {printf "%.2f GB", size/1024/1024/1024}')"
echo "Sum of parts size: $(awk -v size=$total_size 'BEGIN {printf "%.2f GB", size/1024/1024/1024}')"
echo "Difference: $(awk -v orig=$original_size -v total=$total_size 'BEGIN {printf "%.2f GB", (orig-total)/1024/1024/1024}')"
exit 1
fi
# Add remaining files
if [ -f "config.json" ]; then
files+=("config.json:application/vnd.oci.image.config.v1+json")
fi
if [ -f "nvram.bin" ]; then
files+=("nvram.bin:application/octet-stream")
fi
# Push versions in parallel
push_pids=()
for version in $image_versions; do
(
echo "Pushing version $version..."
oras push --disable-path-validation \
"ghcr.io/$organization/$image_name:$version" \
"${files[@]}"
echo "Completed push for version $version"
) &
push_pids+=($!)
done
# Wait for all pushes to complete
for pid in "${push_pids[@]}"; do
wait "$pid"
done
else
# Push disk.img directly if it's small enough
echo "Copying disk image..."
pv "$disk_img" > disk.img
# Push all files together
echo "Pushing all files..."
files=("disk.img:application/vnd.oci.image.layer.v1.tar")
if [ -f "config.json" ]; then
files+=("config.json:application/vnd.oci.image.config.v1+json")
fi
if [ -f "nvram.bin" ]; then
files+=("nvram.bin:application/octet-stream")
fi
for version in $image_versions; do
# Push all files in one command
oras push --disable-path-validation \
"ghcr.io/$organization/$image_name:$version" \
"${files[@]}"
done
fi
fi
for version in $image_versions; do
echo "Upload complete: ghcr.io/$organization/$image_name:$version"
done

22
src/Commands/Clone.swift Normal file
View File

@@ -0,0 +1,22 @@
import ArgumentParser
import Foundation
struct Clone: AsyncParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Clone an existing virtual machine"
)
@Argument(help: "Name of the source virtual machine", completion: .custom(completeVMName))
var name: String
@Argument(help: "Name for the cloned virtual machine")
var newName: String
init() {}
@MainActor
func run() async throws {
let vmController = LumeController()
try vmController.clone(name: name, newName: newName)
}
}

52
src/Commands/Create.swift Normal file
View File

@@ -0,0 +1,52 @@
import ArgumentParser
import Foundation
import Virtualization
// MARK: - Create Command
struct Create: AsyncParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Create a new virtual machine"
)
@Argument(help: "Name for the virtual machine")
var name: String
@Option(help: "Operating system to install. Defaults to macOS.", completion: .list(["macOS", "linux"]))
var os: String = "macOS"
@Option(help: "Number of CPU cores", transform: { Int($0) ?? 4 })
var cpu: Int = 4
@Option(help: "Memory size, e.g., 8192MB or 8GB. Defaults to 8GB.", transform: { try parseSize($0) })
var memory: UInt64 = 8 * 1024 * 1024 * 1024
@Option(help: "Disk size, e.g., 20480MB or 20GB. Defaults to 50GB.", transform: { try parseSize($0) })
var diskSize: UInt64 = 50 * 1024 * 1024 * 1024
@Option(help: "Display resolution in format WIDTHxHEIGHT. Defaults to 1024x768.")
var display: VMDisplayResolution = VMDisplayResolution(string: "1024x768")!
@Option(
help: "Path to macOS restore image (IPSW), or 'latest' to download the latest supported version. Required for macOS VMs.",
completion: .file(extensions: ["ipsw"])
)
var ipsw: String?
init() {
}
@MainActor
func run() async throws {
let vmController = LumeController()
try await vmController.create(
name: name,
os: os,
diskSize: diskSize,
cpuCount: cpu,
memorySize: memory,
display: display.string,
ipsw: ipsw
)
}
}

31
src/Commands/Delete.swift Normal file
View File

@@ -0,0 +1,31 @@
import ArgumentParser
import Foundation
struct Delete: AsyncParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Delete a virtual machine"
)
@Argument(help: "Name of the virtual machine to delete", completion: .custom(completeVMName))
var name: String
@Flag(name: .long, help: "Force deletion without confirmation")
var force = false
init() {}
@MainActor
func run() async throws {
if !force {
print("Are you sure you want to delete the virtual machine '\(name)'? [y/N] ", terminator: "")
guard let response = readLine()?.lowercased(),
response == "y" || response == "yes" else {
print("Deletion cancelled")
return
}
}
let vmController = LumeController()
try await vmController.delete(name: name)
}
}

21
src/Commands/Get.swift Normal file
View File

@@ -0,0 +1,21 @@
import ArgumentParser
import Foundation
struct Get: AsyncParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Get detailed information about a virtual machine"
)
@Argument(help: "Name of the virtual machine", completion: .custom(completeVMName))
var name: String
init() {
}
@MainActor
func run() async throws {
let vmController = LumeController()
let vm = try vmController.get(name: name)
VMDetailsPrinter.printStatus([vm.details])
}
}

20
src/Commands/IPSW.swift Normal file
View File

@@ -0,0 +1,20 @@
import ArgumentParser
import Foundation
struct IPSW: AsyncParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Get macOS restore image IPSW URL",
discussion: "Download IPSW file manually, then use in create command with --ipsw"
)
init() {
}
@MainActor
func run() async throws {
let vmController = LumeController()
let url = try await vmController.getLatestIPSWURL()
print(url.absoluteString)
}
}

19
src/Commands/Images.swift Normal file
View File

@@ -0,0 +1,19 @@
import ArgumentParser
import Foundation
struct Images: AsyncParsableCommand {
static let configuration = CommandConfiguration(
abstract: "List available macOS images from local cache"
)
@Option(help: "Organization to list from. Defaults to trycua")
var organization: String = "trycua"
init() {}
@MainActor
func run() async throws {
let vmController = LumeController()
_ = try await vmController.getImages(organization: organization)
}
}

19
src/Commands/List.swift Normal file
View File

@@ -0,0 +1,19 @@
import ArgumentParser
import Foundation
struct List: AsyncParsableCommand {
static let configuration: CommandConfiguration = CommandConfiguration(
commandName: "ls",
abstract: "List virtual machines"
)
init() {
}
@MainActor
func run() async throws {
let manager = LumeController()
let vms = try manager.list()
VMDetailsPrinter.printStatus(vms)
}
}

19
src/Commands/Prune.swift Normal file
View File

@@ -0,0 +1,19 @@
import ArgumentParser
import Foundation
struct Prune: AsyncParsableCommand {
static let configuration: CommandConfiguration = CommandConfiguration(
commandName: "prune",
abstract: "Remove cached images"
)
init() {
}
@MainActor
func run() async throws {
let manager = LumeController()
try await manager.pruneImages()
print("Successfully removed cached images")
}
}

30
src/Commands/Pull.swift Normal file
View File

@@ -0,0 +1,30 @@
import ArgumentParser
import Foundation
struct Pull: AsyncParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Pull a macOS image from GitHub Container Registry"
)
@Argument(help: "Image to pull (format: name:tag)")
var image: String
@Argument(help: "Name for the VM (defaults to image name without tag)", transform: { Optional($0) })
var name: String?
@Option(help: "Github Container Registry to pull from. Defaults to ghcr.io")
var registry: String = "ghcr.io"
@Option(help: "Organization to pull from. Defaults to trycua")
var organization: String = "trycua"
init() {}
@MainActor
func run() async throws {
let vmController = LumeController()
let components = image.split(separator: ":")
let vmName = name ?? (components.count == 2 ? "\(components[0])_\(components[1])" : image)
try await vmController.pullImage(image: image, name: vmName, registry: registry, organization: organization)
}
}

97
src/Commands/Run.swift Normal file
View File

@@ -0,0 +1,97 @@
import ArgumentParser
import Foundation
import Virtualization
struct Run: AsyncParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Run a virtual machine"
)
@Argument(help: "Name of the virtual machine or image to pull and run (format: name or name:tag)", completion: .custom(completeVMName))
var name: String
@Flag(name: [.short, .long], help: "Do not start the VNC client")
var noDisplay: Bool = false
@Option(name: [.customLong("shared-dir")], help: "Directory to share with the VM. Can be just a path for read-write access (e.g. ~/src) or path:tag where tag is 'ro' for read-only or 'rw' for read-write (e.g. ~/src:ro)")
var sharedDirectories: [String] = []
@Option(help: "For Linux VMs only, a read-only disk image to attach to the VM (e.g. --mount=\"ubuntu.iso\")", completion: .file())
var mount: Path?
@Option(help: "Github Container Registry to pull the images from. Defaults to ghcr.io")
var registry: String = "ghcr.io"
@Option(help: "Organization to pull the images from. Defaults to trycua")
var organization: String = "trycua"
private var parsedSharedDirectories: [SharedDirectory] {
get throws {
try sharedDirectories.map { dirString -> SharedDirectory in
let components = dirString.split(separator: ":", maxSplits: 1)
let hostPath = String(components[0])
// If no tag is provided, default to read-write
if components.count == 1 {
return SharedDirectory(
hostPath: hostPath,
tag: VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag,
readOnly: false
)
}
// Parse the tag if provided
let tag = String(components[1])
let readOnly: Bool
switch tag.lowercased() {
case "ro":
readOnly = true
case "rw":
readOnly = false
default:
throw ValidationError("Invalid tag value. Must be either 'ro' for read-only or 'rw' for read-write")
}
return SharedDirectory(
hostPath: hostPath,
tag: VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag,
readOnly: readOnly
)
}
}
}
init() {
}
@MainActor
func run() async throws {
let vmController = LumeController()
let dirs = try parsedSharedDirectories
var vmName = name
// Shorthand for pulling an image directly during run
let components = name.split(separator: ":")
if components.count == 2 {
// This is an image reference, try to pull it first
let image = name
vmName = "\(components[0])_\(components[1])"
do {
try vmController.validateVMExists(vmName)
}
catch {
// If the VM doesn't exist, try to pull the image
try await vmController.pullImage(image: image, name: vmName, registry: registry, organization: organization)
}
}
try await vmController.runVM(
name: vmName,
noDisplay: noDisplay,
sharedDirectories: dirs,
mount: mount
)
}
}

17
src/Commands/Serve.swift Normal file
View File

@@ -0,0 +1,17 @@
import ArgumentParser
import Foundation
struct Serve: AsyncParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Start the VM management server"
)
@Option(help: "Port to listen on")
var port: UInt16 = 3000
func run() async throws {
let server = await Server(port: port)
Logger.info("Starting server", metadata: ["port": "\(port)"])
try await server.start()
}
}

34
src/Commands/Set.swift Normal file
View File

@@ -0,0 +1,34 @@
import ArgumentParser
import Foundation
struct Set: AsyncParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Set new values for CPU, memory, and disk size of a virtual machine"
)
@Argument(help: "Name of the virtual machine", completion: .custom(completeVMName))
var name: String
@Option(help: "New number of CPU cores")
var cpu: Int?
@Option(help: "New memory size, e.g., 8192MB or 8GB.", transform: { try parseSize($0) })
var memory: UInt64?
@Option(help: "New disk size, e.g., 20480MB or 20GB.", transform: { try parseSize($0) })
var diskSize: UInt64?
init() {
}
@MainActor
func run() async throws {
let vmController = LumeController()
try vmController.updateSettings(
name: name,
cpu: cpu,
memory: memory,
diskSize: diskSize
)
}
}

20
src/Commands/Stop.swift Normal file
View File

@@ -0,0 +1,20 @@
import ArgumentParser
import Foundation
struct Stop: AsyncParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Stop a virtual machine"
)
@Argument(help: "Name of the virtual machine", completion: .custom(completeVMName))
var name: String
init() {
}
@MainActor
func run() async throws {
let vmController = LumeController()
try await vmController.stopVM(name: name)
}
}

View File

@@ -0,0 +1,764 @@
import ArgumentParser
import Foundation
import Swift
struct Layer: Codable, Equatable {
let mediaType: String
let digest: String
let size: Int
}
struct Manifest: Codable {
let layers: [Layer]
let config: Layer?
let mediaType: String
let schemaVersion: Int
}
struct RepositoryTag: Codable {
let name: String
let tags: [String]
}
struct RepositoryList: Codable {
let repositories: [String]
}
struct RepositoryTags: Codable {
let name: String
let tags: [String]
}
struct CachedImage {
let repository: String
let tag: String
let manifestId: String
}
actor ProgressTracker {
private var totalBytes: Int64 = 0
private var downloadedBytes: Int64 = 0
private var progressLogger = ProgressLogger(threshold: 0.01)
private var totalFiles: Int = 0
private var completedFiles: Int = 0
func setTotal(_ total: Int64, files: Int) {
totalBytes = total
totalFiles = files
}
func addProgress(_ bytes: Int64) {
downloadedBytes += bytes
let progress = Double(downloadedBytes) / Double(totalBytes)
progressLogger.logProgress(current: progress, context: "Downloading Image")
}
}
actor TaskCounter {
private var count: Int = 0
func increment() { count += 1 }
func decrement() { count -= 1 }
func current() -> Int { count }
}
class ImageContainerRegistry: @unchecked Sendable {
private let registry: String
private let organization: String
private let progress = ProgressTracker()
private let cacheDirectory: URL
private let downloadLock = NSLock()
private var activeDownloads: [String] = []
init(registry: String, organization: String) {
self.registry = registry
self.organization = organization
// Setup cache directory in user's home
let home = FileManager.default.homeDirectoryForCurrentUser
self.cacheDirectory = home.appendingPathComponent(".lume/cache/ghcr")
try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
// Create organization directory
let orgDir = cacheDirectory.appendingPathComponent(organization)
try? FileManager.default.createDirectory(at: orgDir, withIntermediateDirectories: true)
}
private func getManifestIdentifier(_ manifest: Manifest) -> String {
// Use config digest if available, otherwise create a hash from layers
if let config = manifest.config {
return config.digest.replacingOccurrences(of: ":", with: "_")
}
// If no config layer, create a hash from all layer digests
let layerHash = manifest.layers.map { $0.digest }.joined(separator: "+")
return layerHash.replacingOccurrences(of: ":", with: "_")
}
private func getImageCacheDirectory(manifestId: String) -> URL {
return cacheDirectory
.appendingPathComponent(organization)
.appendingPathComponent(manifestId)
}
private func getCachedManifestPath(manifestId: String) -> URL {
return getImageCacheDirectory(manifestId: manifestId).appendingPathComponent("manifest.json")
}
private func getCachedLayerPath(manifestId: String, digest: String) -> URL {
return getImageCacheDirectory(manifestId: manifestId).appendingPathComponent(digest.replacingOccurrences(of: ":", with: "_"))
}
private func setupImageCache(manifestId: String) throws {
let cacheDir = getImageCacheDirectory(manifestId: manifestId)
// Remove existing cache if it exists
if FileManager.default.fileExists(atPath: cacheDir.path) {
try FileManager.default.removeItem(at: cacheDir)
// Ensure it's completely removed
while FileManager.default.fileExists(atPath: cacheDir.path) {
try? FileManager.default.removeItem(at: cacheDir)
}
}
try FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
}
private func loadCachedManifest(manifestId: String) -> Manifest? {
let manifestPath = getCachedManifestPath(manifestId: manifestId)
guard let data = try? Data(contentsOf: manifestPath) else { return nil }
return try? JSONDecoder().decode(Manifest.self, from: data)
}
private func validateCache(manifest: Manifest, manifestId: String) -> Bool {
// First check if manifest exists and matches
guard let cachedManifest = loadCachedManifest(manifestId: manifestId),
cachedManifest.layers == manifest.layers else {
return false
}
// Then verify all layer files exist
for layer in manifest.layers {
let cachedLayer = getCachedLayerPath(manifestId: manifestId, digest: layer.digest)
if !FileManager.default.fileExists(atPath: cachedLayer.path) {
return false
}
}
return true
}
private func saveManifest(_ manifest: Manifest, manifestId: String) throws {
let manifestPath = getCachedManifestPath(manifestId: manifestId)
try JSONEncoder().encode(manifest).write(to: manifestPath)
}
private func isDownloading(_ digest: String) -> Bool {
downloadLock.lock()
defer { downloadLock.unlock() }
return activeDownloads.contains(digest)
}
private func markDownloadStarted(_ digest: String) {
downloadLock.lock()
if !activeDownloads.contains(digest) {
activeDownloads.append(digest)
}
downloadLock.unlock()
}
private func markDownloadComplete(_ digest: String) {
downloadLock.lock()
activeDownloads.removeAll { $0 == digest }
downloadLock.unlock()
}
private func waitForExistingDownload(_ digest: String, cachedLayer: URL) async throws {
while isDownloading(digest) {
try await Task.sleep(nanoseconds: 1_000_000_000) // Sleep for 1 second
if FileManager.default.fileExists(atPath: cachedLayer.path) {
return // File is now available
}
}
}
func pull(image: String, name: String?) async throws {
// Validate home directory
let home = Home()
try home.validateHomeDirectory()
// Use provided name or derive from image
let vmName = name ?? image.split(separator: ":").first.map(String.init) ?? ""
let vmDir = home.getVMDirectory(vmName)
// Parse image name and tag
let components = image.split(separator: ":")
guard components.count == 2 else {
throw PullError.invalidImageFormat
}
let imageName = String(components[0])
let tag = String(components[1])
// Get anonymous token
Logger.info("Getting registry authentication token")
let token = try await getToken(repository: "\(self.organization)/\(imageName)")
// Fetch manifest
Logger.info("Fetching Image manifest")
let manifest: Manifest = try await fetchManifest(
repository: "\(self.organization)/\(imageName)",
tag: tag,
token: token
)
// Get manifest identifier
let manifestId = getManifestIdentifier(manifest)
// Create VM directory
try FileManager.default.createDirectory(at: URL(fileURLWithPath: vmDir.dir.path), withIntermediateDirectories: true)
// Check if we have a valid cached version
if validateCache(manifest: manifest, manifestId: manifestId) {
Logger.info("Using cached version of image")
try await copyFromCache(manifest: manifest, manifestId: manifestId, to: URL(fileURLWithPath: vmDir.dir.path))
return
}
// Setup new cache directory
try setupImageCache(manifestId: manifestId)
// Save new manifest
try saveManifest(manifest, manifestId: manifestId)
// Create temporary directory for new downloads
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer {
try? FileManager.default.removeItem(at: tempDir)
}
// Set total size and file count
let totalFiles = manifest.layers.filter { $0.mediaType != "application/vnd.oci.empty.v1+json" }.count
await progress.setTotal(
manifest.layers.reduce(0) { $0 + Int64($1.size) },
files: totalFiles
)
// Process layers with limited concurrency
Logger.info("Processing Image layers")
var diskParts: [(Int, URL)] = []
var totalParts = 0
let maxConcurrentTasks = 5
let counter = TaskCounter()
try await withThrowingTaskGroup(of: Int64.self) { group in
for layer in manifest.layers {
if layer.mediaType == "application/vnd.oci.empty.v1+json" {
continue
}
while await counter.current() >= maxConcurrentTasks {
_ = try await group.next()
await counter.decrement()
}
let outputURL: URL
if let partInfo = extractPartInfo(from: layer.mediaType) {
let (partNum, total) = partInfo
totalParts = total
outputURL = tempDir.appendingPathComponent("disk.img.part.\(partNum)")
diskParts.append((partNum, outputURL))
} else {
switch layer.mediaType {
case "application/vnd.oci.image.layer.v1.tar":
outputURL = tempDir.appendingPathComponent("disk.img")
case "application/vnd.oci.image.config.v1+json":
outputURL = tempDir.appendingPathComponent("config.json")
case "application/octet-stream":
outputURL = tempDir.appendingPathComponent("nvram.bin")
default:
continue
}
}
group.addTask { @Sendable [self] in
await counter.increment()
let cachedLayer = getCachedLayerPath(manifestId: manifestId, digest: layer.digest)
if FileManager.default.fileExists(atPath: cachedLayer.path) {
try FileManager.default.copyItem(at: cachedLayer, to: outputURL)
await progress.addProgress(Int64(layer.size))
} else {
// Check if this layer is already being downloaded
if isDownloading(layer.digest) {
try await waitForExistingDownload(layer.digest, cachedLayer: cachedLayer)
if FileManager.default.fileExists(atPath: cachedLayer.path) {
try FileManager.default.copyItem(at: cachedLayer, to: outputURL)
await progress.addProgress(Int64(layer.size))
return Int64(layer.size)
}
}
// Start new download
markDownloadStarted(layer.digest)
defer { markDownloadComplete(layer.digest) }
try await self.downloadLayer(
repository: "\(self.organization)/\(imageName)",
digest: layer.digest,
mediaType: layer.mediaType,
token: token,
to: outputURL,
maxRetries: 5,
progress: progress
)
// Cache the downloaded layer
if FileManager.default.fileExists(atPath: cachedLayer.path) {
try FileManager.default.removeItem(at: cachedLayer)
}
try FileManager.default.copyItem(at: outputURL, to: cachedLayer)
}
return Int64(layer.size)
}
}
// Wait for remaining tasks
for try await _ in group { }
}
Logger.info("") // New line after progress
// Handle disk parts if present
if !diskParts.isEmpty {
Logger.info("Reassembling disk image...")
let outputURL = URL(fileURLWithPath: vmDir.dir.path).appendingPathComponent("disk.img")
try FileManager.default.createDirectory(at: outputURL.deletingLastPathComponent(), withIntermediateDirectories: true)
// Create empty output file
FileManager.default.createFile(atPath: outputURL.path, contents: nil)
let outputHandle = try FileHandle(forWritingTo: outputURL)
defer { try? outputHandle.close() }
var totalWritten: UInt64 = 0
let expectedTotalSize = UInt64(manifest.layers.filter { extractPartInfo(from: $0.mediaType) != nil }.reduce(0) { $0 + $1.size })
// Process parts in order
for partNum in 1...totalParts {
guard let (_, partURL) = diskParts.first(where: { $0.0 == partNum }) else {
throw PullError.missingPart(partNum)
}
let inputHandle = try FileHandle(forReadingFrom: partURL)
defer {
try? inputHandle.close()
try? FileManager.default.removeItem(at: partURL)
}
// Read and write in chunks to minimize memory usage
let chunkSize = 10 * 1024 * 1024 // 10MB chunks
while let chunk = try inputHandle.read(upToCount: chunkSize) {
try outputHandle.write(contentsOf: chunk)
totalWritten += UInt64(chunk.count)
let progress: Double = Double(totalWritten) / Double(expectedTotalSize) * 100
Logger.info("Reassembling disk image: \(Int(progress))%")
}
}
// Verify final size
let finalSize = try FileManager.default.attributesOfItem(atPath: outputURL.path)[.size] as? UInt64 ?? 0
Logger.info("Final disk image size: \(ByteCountFormatter.string(fromByteCount: Int64(finalSize), countStyle: .file))")
Logger.info("Expected size: \(ByteCountFormatter.string(fromByteCount: Int64(expectedTotalSize), countStyle: .file))")
if finalSize != expectedTotalSize {
Logger.info("Warning: Final size (\(finalSize) bytes) differs from expected size (\(expectedTotalSize) bytes)")
}
Logger.info("Disk image reassembled successfully")
} else {
// Copy single disk image if it exists
let diskURL = tempDir.appendingPathComponent("disk.img")
if FileManager.default.fileExists(atPath: diskURL.path) {
try FileManager.default.copyItem(
at: diskURL,
to: URL(fileURLWithPath: vmDir.dir.path).appendingPathComponent("disk.img")
)
}
}
// Copy config and nvram files if they exist
for file in ["config.json", "nvram.bin"] {
let sourceURL = tempDir.appendingPathComponent(file)
if FileManager.default.fileExists(atPath: sourceURL.path) {
try FileManager.default.copyItem(
at: sourceURL,
to: URL(fileURLWithPath: vmDir.dir.path).appendingPathComponent(file)
)
}
}
Logger.info("Download complete: Files extracted to \(vmDir.dir.path)")
// If this was a "latest" tag pull and we successfully downloaded and cached the new version,
// clean up any old versions
if tag.lowercased() == "latest" {
let orgDir = cacheDirectory.appendingPathComponent(organization)
if FileManager.default.fileExists(atPath: orgDir.path) {
let contents = try FileManager.default.contentsOfDirectory(atPath: orgDir.path)
for item in contents {
// Skip if it's the current manifest
if item == manifestId { continue }
let itemPath = orgDir.appendingPathComponent(item)
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: itemPath.path, isDirectory: &isDirectory),
isDirectory.boolValue else { continue }
// Check for manifest.json
let manifestPath = itemPath.appendingPathComponent("manifest.json")
guard let manifestData = try? Data(contentsOf: manifestPath),
let oldManifest = try? JSONDecoder().decode(Manifest.self, from: manifestData),
let config = oldManifest.config else { continue }
let configPath = getCachedLayerPath(manifestId: item, digest: config.digest)
guard let configData = try? Data(contentsOf: configPath),
let configJson = try? JSONSerialization.jsonObject(with: configData) as? [String: Any],
let labels = configJson["config"] as? [String: Any],
let imageConfig = labels["Labels"] as? [String: String],
let oldRepository = imageConfig["org.opencontainers.image.source"]?.components(separatedBy: "/").last else { continue }
// Only delete if it's from the same repository
if oldRepository == imageName {
try FileManager.default.removeItem(at: itemPath)
Logger.info("Removed outdated cached version", metadata: [
"old_manifest_id": item,
"repository": imageName
])
}
}
}
}
}
private func copyFromCache(manifest: Manifest, manifestId: String, to destination: URL) async throws {
Logger.info("Copying from cache...")
var diskParts: [(Int, URL)] = []
var totalParts = 0
var expectedTotalSize: UInt64 = 0
for layer in manifest.layers {
let cachedLayer = getCachedLayerPath(manifestId: manifestId, digest: layer.digest)
if let partInfo = extractPartInfo(from: layer.mediaType) {
let (partNum, total) = partInfo
totalParts = total
let partURL = destination.appendingPathComponent("disk.img.part.\(partNum)")
try FileManager.default.copyItem(at: cachedLayer, to: partURL)
diskParts.append((partNum, partURL))
expectedTotalSize += UInt64(layer.size)
} else {
let fileName: String
switch layer.mediaType {
case "application/vnd.oci.image.layer.v1.tar":
fileName = "disk.img"
case "application/vnd.oci.image.config.v1+json":
fileName = "config.json"
case "application/octet-stream":
fileName = "nvram.bin"
default:
continue
}
try FileManager.default.copyItem(
at: cachedLayer,
to: destination.appendingPathComponent(fileName)
)
}
}
// Reassemble disk parts if needed
if !diskParts.isEmpty {
Logger.info("Reassembling disk image from cached parts...")
let outputURL = destination.appendingPathComponent("disk.img")
FileManager.default.createFile(atPath: outputURL.path, contents: nil)
let outputHandle = try FileHandle(forWritingTo: outputURL)
defer { try? outputHandle.close() }
var totalWritten: UInt64 = 0
for partNum in 1...totalParts {
guard let (_, partURL) = diskParts.first(where: { $0.0 == partNum }) else {
throw PullError.missingPart(partNum)
}
let inputHandle = try FileHandle(forReadingFrom: partURL)
while let data = try inputHandle.read(upToCount: 1024 * 1024 * 10) {
try outputHandle.write(contentsOf: data)
totalWritten += UInt64(data.count)
}
try inputHandle.close()
try FileManager.default.removeItem(at: partURL)
}
// Verify final size
let finalSize = try FileManager.default.attributesOfItem(atPath: outputURL.path)[.size] as? UInt64 ?? 0
Logger.info("Final disk image size: \(ByteCountFormatter.string(fromByteCount: Int64(finalSize), countStyle: .file))")
Logger.info("Expected size: \(ByteCountFormatter.string(fromByteCount: Int64(expectedTotalSize), countStyle: .file))")
if finalSize != expectedTotalSize {
Logger.info("Warning: Final size (\(finalSize) bytes) differs from expected size (\(expectedTotalSize) bytes)")
}
}
Logger.info("Cache copy complete")
}
private func getToken(repository: String) async throws -> String {
let url = URL(string: "https://\(self.registry)/token")!
.appending(queryItems: [
URLQueryItem(name: "service", value: self.registry),
URLQueryItem(name: "scope", value: "repository:\(repository):pull")
])
let (data, _) = try await URLSession.shared.data(from: url)
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
guard let token = json?["token"] as? String else {
throw PullError.tokenFetchFailed
}
return token
}
private func fetchManifest(repository: String, tag: String, token: String) async throws -> Manifest {
var request = URLRequest(url: URL(string: "https://\(self.registry)/v2/\(repository)/manifests/\(tag)")!)
request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.addValue("application/vnd.oci.image.manifest.v1+json", forHTTPHeaderField: "Accept")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw PullError.manifestFetchFailed
}
return try JSONDecoder().decode(Manifest.self, from: data)
}
private func downloadLayer(
repository: String,
digest: String,
mediaType: String,
token: String,
to url: URL,
maxRetries: Int = 5,
progress: isolated ProgressTracker
) async throws {
var lastError: Error?
for attempt in 1...maxRetries {
do {
var request = URLRequest(url: URL(string: "https://\(self.registry)/v2/\(repository)/blobs/\(digest)")!)
request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.addValue(mediaType, forHTTPHeaderField: "Accept")
request.timeoutInterval = 60
// Configure session for better reliability
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 60
config.timeoutIntervalForResource = 3600
config.waitsForConnectivity = true
config.httpMaximumConnectionsPerHost = 1
let session = URLSession(configuration: config)
let (tempURL, response) = try await session.download(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw PullError.layerDownloadFailed(digest)
}
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
try FileManager.default.moveItem(at: tempURL, to: url)
progress.addProgress(Int64(httpResponse.expectedContentLength))
return
} catch {
lastError = error
if attempt < maxRetries {
let delay = Double(attempt) * 5
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
}
}
}
throw lastError ?? PullError.layerDownloadFailed(digest)
}
private func decompressGzipFile(at source: URL, to destination: URL) throws {
Logger.info("Decompressing \(source.lastPathComponent)...")
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/gunzip")
process.arguments = ["-c"]
let inputPipe = Pipe()
let outputPipe = Pipe()
process.standardInput = inputPipe
process.standardOutput = outputPipe
try process.run()
// Read and pipe the gzipped file in chunks to avoid memory issues
let inputHandle = try FileHandle(forReadingFrom: source)
let outputHandle = try FileHandle(forWritingTo: destination)
defer {
try? inputHandle.close()
try? outputHandle.close()
}
// Create the output file
FileManager.default.createFile(atPath: destination.path, contents: nil)
// Process in 10MB chunks
let chunkSize = 10 * 1024 * 1024
while let chunk = try inputHandle.read(upToCount: chunkSize) {
try inputPipe.fileHandleForWriting.write(contentsOf: chunk)
// Read and write output in chunks as well
while let decompressedChunk = try outputPipe.fileHandleForReading.read(upToCount: chunkSize) {
try outputHandle.write(contentsOf: decompressedChunk)
}
}
try inputPipe.fileHandleForWriting.close()
// Read any remaining output
while let decompressedChunk = try outputPipe.fileHandleForReading.read(upToCount: chunkSize) {
try outputHandle.write(contentsOf: decompressedChunk)
}
process.waitUntilExit()
if process.terminationStatus != 0 {
throw PullError.decompressionFailed(source.lastPathComponent)
}
// Verify the decompressed size
let decompressedSize = try FileManager.default.attributesOfItem(atPath: destination.path)[.size] as? UInt64 ?? 0
Logger.info("Decompressed size: \(ByteCountFormatter.string(fromByteCount: Int64(decompressedSize), countStyle: .file))")
}
private func extractPartInfo(from mediaType: String) -> (partNum: Int, total: Int)? {
let pattern = #"part\.number=(\d+);part\.total=(\d+)"#
guard let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(
in: mediaType,
range: NSRange(mediaType.startIndex..., in: mediaType)
),
let partNumRange = Range(match.range(at: 1), in: mediaType),
let totalRange = Range(match.range(at: 2), in: mediaType),
let partNum = Int(mediaType[partNumRange]),
let total = Int(mediaType[totalRange]) else {
return nil
}
return (partNum, total)
}
private func listRepositories() async throws -> [String] {
var request = URLRequest(url: URL(string: "https://\(registry)/v2/\(organization)/repositories/list")!)
request.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw PullError.manifestFetchFailed
}
if httpResponse.statusCode == 404 {
return []
}
guard httpResponse.statusCode == 200 else {
throw PullError.manifestFetchFailed
}
let repoList = try JSONDecoder().decode(RepositoryList.self, from: data)
return repoList.repositories
}
func getImages() async throws -> [CachedImage] {
var images: [CachedImage] = []
let orgDir = cacheDirectory.appendingPathComponent(organization)
if FileManager.default.fileExists(atPath: orgDir.path) {
let contents = try FileManager.default.contentsOfDirectory(atPath: orgDir.path)
for item in contents {
let itemPath = orgDir.appendingPathComponent(item)
var isDirectory: ObjCBool = false
// Check if it's a directory
guard FileManager.default.fileExists(atPath: itemPath.path, isDirectory: &isDirectory),
isDirectory.boolValue else { continue }
// Check for manifest.json
let manifestPath = itemPath.appendingPathComponent("manifest.json")
guard FileManager.default.fileExists(atPath: manifestPath.path),
let manifestData = try? Data(contentsOf: manifestPath),
let manifest = try? JSONDecoder().decode(Manifest.self, from: manifestData) else { continue }
// The directory name is now just the manifest ID
let manifestId = item
// Verify the manifest ID matches
let currentManifestId = getManifestIdentifier(manifest)
if currentManifestId == manifestId {
// Add the image with just the manifest ID for now
images.append(CachedImage(
repository: "unknown",
tag: "unknown",
manifestId: manifestId
))
}
}
}
// For each cached image, try to find its repository and tag by checking the config
for i in 0..<images.count {
let manifestId = images[i].manifestId
let manifestPath = getCachedManifestPath(manifestId: manifestId)
if let manifestData = try? Data(contentsOf: manifestPath),
let manifest = try? JSONDecoder().decode(Manifest.self, from: manifestData),
let config = manifest.config,
let configData = try? Data(contentsOf: getCachedLayerPath(manifestId: manifestId, digest: config.digest)),
let configJson = try? JSONSerialization.jsonObject(with: configData) as? [String: Any],
let labels = configJson["config"] as? [String: Any],
let imageConfig = labels["Labels"] as? [String: String],
let repository = imageConfig["org.opencontainers.image.source"]?.components(separatedBy: "/").last,
let tag = imageConfig["org.opencontainers.image.version"] {
// Found repository and tag information in the config
images[i] = CachedImage(
repository: repository,
tag: tag,
manifestId: manifestId
)
}
}
return images.sorted { $0.repository == $1.repository ? $0.tag < $1.tag : $0.repository < $1.repository }
}
private func listRemoteImageTags(repository: String) async throws -> [String] {
var request = URLRequest(url: URL(string: "https://\(registry)/v2/\(organization)/\(repository)/tags/list")!)
request.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw PullError.manifestFetchFailed
}
if httpResponse.statusCode == 404 {
return []
}
guard httpResponse.statusCode == 200 else {
throw PullError.manifestFetchFailed
}
let repoTags = try JSONDecoder().decode(RepositoryTags.self, from: data)
return repoTags.tags
}
}

View File

@@ -0,0 +1,4 @@
public struct ImageList: Codable {
public let local: [String]
public let remote: [String]
}

View File

@@ -0,0 +1,42 @@
import Foundation
struct ImagesPrinter {
private struct Column: Sendable {
let header: String
let width: Int
let getValue: @Sendable (String) -> String
}
private static let columns: [Column] = [
Column(header: "name", width: 28) { $0.split(separator: ":").first.map(String.init) ?? $0 },
Column(header: "tag", width: 16) { $0.split(separator: ":").last.map(String.init) ?? "-" }
]
static func print(images: [String]) {
if images.isEmpty {
Swift.print("No images found")
return
}
printHeader()
images.sorted().forEach(printImage)
}
private static func printHeader() {
let paddedHeaders = columns.map { $0.header.paddedToWidth($0.width) }
Swift.print(paddedHeaders.joined())
}
private static func printImage(_ image: String) {
let paddedColumns = columns.map { column in
column.getValue(image).paddedToWidth(column.width)
}
Swift.print(paddedColumns.joined())
}
}
private extension String {
func paddedToWidth(_ width: Int) -> String {
padding(toLength: width, withPad: " ", startingAt: 0)
}
}

157
src/Errors/Errors.swift Normal file
View File

@@ -0,0 +1,157 @@
import Foundation
enum HomeError: Error, LocalizedError {
case directoryCreationFailed(path: String)
case directoryAccessDenied(path: String)
case invalidHomeDirectory
case directoryAlreadyExists(path: String)
var errorDescription: String? {
switch self {
case .directoryCreationFailed(let path):
return "Failed to create directory at path: \(path)"
case .directoryAccessDenied(let path):
return "Access denied to directory at path: \(path)"
case .invalidHomeDirectory:
return "Invalid home directory configuration"
case .directoryAlreadyExists(let path):
return "Directory already exists at path: \(path)"
}
}
}
enum PullError: Error, LocalizedError {
case invalidImageFormat
case tokenFetchFailed
case manifestFetchFailed
case layerDownloadFailed(String)
case missingPart(Int)
case decompressionFailed(String)
var errorDescription: String? {
switch self {
case .invalidImageFormat:
return "Invalid image format. Expected format: name:tag"
case .tokenFetchFailed:
return "Failed to obtain authentication token"
case .manifestFetchFailed:
return "Failed to fetch manifest"
case .layerDownloadFailed(let digest):
return "Failed to download layer: \(digest)"
case .missingPart(let number):
return "Missing disk image part \(number)"
case .decompressionFailed(let filename):
return "Failed to decompress file: \(filename)"
}
}
}
enum VMConfigError: CustomNSError, LocalizedError {
case invalidDisplayResolution(String)
case invalidMachineIdentifier
case emptyMachineIdentifier
case emptyHardwareModel
case invalidHardwareModel
case invalidDiskSize
case malformedSizeInput(String)
var errorDescription: String? {
switch self {
case .invalidDisplayResolution(let resolution):
return "Invalid display resolution: \(resolution)"
case .emptyMachineIdentifier:
return "Empty machine identifier"
case .invalidMachineIdentifier:
return "Invalid machine identifier"
case .emptyHardwareModel:
return "Empty hardware model"
case .invalidHardwareModel:
return "Invalid hardware model: the host does not support the hardware model"
case .invalidDiskSize:
return "Invalid disk size"
case .malformedSizeInput(let input):
return "Malformed size input: \(input)"
}
}
static var errorDomain: String { "VMConfigError" }
var errorCode: Int {
switch self {
case .invalidDisplayResolution: return 1
case .emptyMachineIdentifier: return 2
case .invalidMachineIdentifier: return 3
case .emptyHardwareModel: return 4
case .invalidHardwareModel: return 5
case .invalidDiskSize: return 6
case .malformedSizeInput: return 7
}
}
}
enum VMDirectoryError: Error, LocalizedError {
case configNotFound
case invalidConfigData
case diskOperationFailed(String)
case fileCreationFailed(String)
case sessionNotFound
case invalidSessionData
var errorDescription: String {
switch self {
case .configNotFound:
return "VM configuration file not found"
case .invalidConfigData:
return "Invalid VM configuration data"
case .diskOperationFailed(let reason):
return "Disk operation failed: \(reason)"
case .fileCreationFailed(let path):
return "Failed to create file at path: \(path)"
case .sessionNotFound:
return "VNC session file not found"
case .invalidSessionData:
return "Invalid VNC session data"
}
}
}
enum VMError: Error, LocalizedError {
case alreadyExists(String)
case notFound(String)
case notInitialized(String)
case notRunning(String)
case alreadyRunning(String)
case installNotStarted(String)
case stopTimeout(String)
case resizeTooSmall(current: UInt64, requested: UInt64)
case vncNotConfigured
case internalError(String)
case unsupportedOS(String)
var errorDescription: String? {
switch self {
case .alreadyExists(let name):
return "Virtual machine already exists with name: \(name)"
case .notFound(let name):
return "Virtual machine not found: \(name)"
case .notInitialized(let name):
return "Virtual machine not initialized: \(name)"
case .notRunning(let name):
return "Virtual machine not running: \(name)"
case .alreadyRunning(let name):
return "Virtual machine already running: \(name)"
case .installNotStarted(let name):
return "Virtual machine install not started: \(name)"
case .stopTimeout(let name):
return "Timeout while stopping virtual machine: \(name)"
case .resizeTooSmall(let current, let requested):
return "Cannot resize disk to \(requested) bytes, current size is \(current) bytes"
case .vncNotConfigured:
return "VNC is not configured for this virtual machine"
case .internalError(let message):
return "Internal error: \(message)"
case .unsupportedOS(let os):
return "Unsupported operating system: \(os)"
}
}
}

146
src/FileSystem/Home.swift Normal file
View File

@@ -0,0 +1,146 @@
import Foundation
/// Manages the application's home directory and virtual machine directories.
/// Responsible for creating, accessing, and validating the application's directory structure.
final class Home {
// MARK: - Constants
private enum Constants {
static let defaultDirectoryName = ".lume"
static let homeDirPath = "~/\(defaultDirectoryName)"
}
// MARK: - Properties
let homeDir: Path
private let fileManager: FileManager
// MARK: - Initialization
init(fileManager: FileManager = .default) {
self.fileManager = fileManager
self.homeDir = Path(Constants.homeDirPath)
}
// MARK: - VM Directory Management
/// Creates a temporary VM directory with a unique identifier
/// - Returns: A VMDirectory instance representing the created directory
/// - Throws: HomeError if directory creation fails
func createTempVMDirectory() throws -> VMDirectory {
let uuid = UUID().uuidString
let tempDir = homeDir.directory(uuid)
Logger.info("Creating temporary directory", metadata: ["path": tempDir.path])
do {
try createDirectory(at: tempDir.url)
return VMDirectory(tempDir)
} catch {
throw HomeError.directoryCreationFailed(path: tempDir.path)
}
}
/// Returns a VMDirectory instance for the given name
/// - Parameter name: Name of the VM directory
/// - Returns: A VMDirectory instance
func getVMDirectory(_ name: String) -> VMDirectory {
VMDirectory(homeDir.directory(name))
}
/// Returns all initialized VM directories
/// - Returns: An array of VMDirectory instances
/// - Throws: HomeError if directory access is denied
func getAllVMDirectories() throws -> [VMDirectory] {
guard homeDir.exists() else { return [] }
do {
let allFolders = try fileManager.contentsOfDirectory(
at: homeDir.url,
includingPropertiesForKeys: nil
)
let folders = allFolders
.compactMap { url in
let sanitizedName = sanitizeFileName(url.lastPathComponent)
let dir = getVMDirectory(sanitizedName)
let dir1 = dir.initialized() ? dir : nil
return dir1
}
return folders
} catch {
throw HomeError.directoryAccessDenied(path: homeDir.path)
}
}
/// Copies a VM directory to a new location with a new name
/// - Parameters:
/// - sourceName: Name of the source VM
/// - destName: Name for the destination VM
/// - Throws: HomeError if the copy operation fails
func copyVMDirectory(from sourceName: String, to destName: String) throws {
let sourceDir = getVMDirectory(sourceName)
let destDir = getVMDirectory(destName)
if destDir.initialized() {
throw HomeError.directoryAlreadyExists(path: destDir.dir.path)
}
do {
try fileManager.copyItem(atPath: sourceDir.dir.path, toPath: destDir.dir.path)
} catch {
throw HomeError.directoryCreationFailed(path: destDir.dir.path)
}
}
// MARK: - Directory Validation
/// Validates and ensures the existence of the home directory
/// - Throws: HomeError if validation fails or directory creation fails
func validateHomeDirectory() throws {
if !homeDir.exists() {
try createHomeDirectory()
return
}
guard isValidDirectory(at: homeDir.path) else {
throw HomeError.invalidHomeDirectory
}
}
// MARK: - Private Helpers
private func createHomeDirectory() throws {
do {
try createDirectory(at: homeDir.url)
} catch {
throw HomeError.directoryCreationFailed(path: homeDir.path)
}
}
private func createDirectory(at url: URL) throws {
try fileManager.createDirectory(
at: url,
withIntermediateDirectories: true
)
}
private func isValidDirectory(at path: String) -> Bool {
var isDirectory: ObjCBool = false
return fileManager.fileExists(atPath: path, isDirectory: &isDirectory)
&& isDirectory.boolValue
&& Path(path).writable()
}
private func sanitizeFileName(_ name: String) -> String {
// Only decode percent encoding (e.g., %20 for spaces)
return name.removingPercentEncoding ?? name
}
}
// MARK: - Home + CustomStringConvertible
extension Home: CustomStringConvertible {
var description: String {
"Home(path: \(homeDir.path))"
}
}

View File

@@ -0,0 +1,141 @@
import ArgumentParser
import Foundation
import Virtualization
/// Represents a shared directory configuration
struct SharedDirectory {
let hostPath: String
let tag: String
let readOnly: Bool
var string: String {
return "\(hostPath):\(tag):\(readOnly ? "ro" : "rw")"
}
}
// MARK: - VMConfig
struct VMConfig: Codable {
// MARK: - Properties
let os: String
private var _cpuCount: Int?
private var _memorySize: UInt64?
private var _diskSize: UInt64?
private var _macAddress: String?
let display: VMDisplayResolution
private var _hardwareModel: Data?
private var _machineIdentifier: Data?
// MARK: - Initialization
init(
os: String,
cpuCount: Int? = nil,
memorySize: UInt64? = nil,
diskSize: UInt64? = nil,
macAddress: String? = nil,
display: String,
hardwareModel: Data? = nil,
machineIdentifier: Data? = nil
) throws {
self.os = os
self._cpuCount = cpuCount
self._memorySize = memorySize
self._diskSize = diskSize
self._macAddress = macAddress
self.display = VMDisplayResolution(string: display) ?? VMDisplayResolution(string: "1024x768")!
self._hardwareModel = hardwareModel
self._machineIdentifier = machineIdentifier
}
var cpuCount: Int? {
get { _cpuCount }
set { _cpuCount = newValue }
}
var memorySize: UInt64? {
get { _memorySize }
set { _memorySize = newValue }
}
var diskSize: UInt64? {
get { _diskSize }
set { _diskSize = newValue }
}
var hardwareModel: Data? {
get { _hardwareModel }
set { _hardwareModel = newValue }
}
var machineIdentifier: Data? {
get { _machineIdentifier }
set { _machineIdentifier = newValue }
}
var macAddress: String? {
get { _macAddress }
set { _macAddress = newValue }
}
mutating func setCpuCount(_ count: Int) {
_cpuCount = count
}
mutating func setMemorySize(_ size: UInt64) {
_memorySize = size
}
mutating func setDiskSize(_ size: UInt64) {
_diskSize = size
}
mutating func setHardwareModel(_ hardwareModel: Data) {
_hardwareModel = hardwareModel
}
mutating func setMachineIdentifier(_ machineIdentifier: Data) {
_machineIdentifier = machineIdentifier
}
mutating func setMacAddress(_ macAddress: String) {
_macAddress = macAddress
}
// MARK: - Codable
enum CodingKeys: String, CodingKey {
case _cpuCount = "cpuCount"
case _memorySize = "memorySize"
case _diskSize = "diskSize"
case macAddress
case display
case _hardwareModel = "hardwareModel"
case _machineIdentifier = "machineIdentifier"
case os
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
os = try container.decode(String.self, forKey: .os)
_cpuCount = try container.decodeIfPresent(Int.self, forKey: ._cpuCount)
_memorySize = try container.decodeIfPresent(UInt64.self, forKey: ._memorySize)
_diskSize = try container.decodeIfPresent(UInt64.self, forKey: ._diskSize)
_macAddress = try container.decodeIfPresent(String.self, forKey: .macAddress)
display = VMDisplayResolution(string: try container.decode(String.self, forKey: .display))!
_hardwareModel = try container.decodeIfPresent(Data.self, forKey: ._hardwareModel)
_machineIdentifier = try container.decodeIfPresent(Data.self, forKey: ._machineIdentifier)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(os, forKey: .os)
try container.encodeIfPresent(_cpuCount, forKey: ._cpuCount)
try container.encodeIfPresent(_memorySize, forKey: ._memorySize)
try container.encodeIfPresent(_diskSize, forKey: ._diskSize)
try container.encodeIfPresent(_macAddress, forKey: .macAddress)
try container.encode(display.string, forKey: .display)
try container.encodeIfPresent(_hardwareModel, forKey: ._hardwareModel)
try container.encodeIfPresent(_machineIdentifier, forKey: ._machineIdentifier)
}
}

View File

@@ -0,0 +1,181 @@
import Foundation
// MARK: - VMDirectory
/// Manages a virtual machine's directory structure and files
/// Responsible for:
/// - Managing VM configuration files
/// - Handling disk operations
/// - Managing VM state and locking
/// - Providing access to VM-related paths
struct VMDirectory {
// MARK: - Constants
private enum FileNames {
static let nvram = "nvram.bin"
static let disk = "disk.img"
static let config = "config.json"
static let sessions = "sessions.json"
}
// MARK: - Properties
let dir: Path
let nvramPath: Path
let diskPath: Path
let configPath: Path
let sessionsPath: Path
private let fileManager: FileManager
/// The name of the VM directory
var name: String { dir.name }
// MARK: - Initialization
/// Creates a new VMDirectory instance
/// - Parameters:
/// - dir: The base directory path for the VM
/// - fileManager: FileManager instance to use for file operations
init(_ dir: Path, fileManager: FileManager = .default) {
self.dir = dir
self.fileManager = fileManager
self.nvramPath = dir.file(FileNames.nvram)
self.diskPath = dir.file(FileNames.disk)
self.configPath = dir.file(FileNames.config)
self.sessionsPath = dir.file(FileNames.sessions)
}
}
// MARK: - VM State Management
extension VMDirectory {
/// Checks if the VM directory is fully initialized with all required files
func initialized() -> Bool {
configPath.exists() && diskPath.exists() && nvramPath.exists()
}
/// Checks if the VM directory exists
func exists() -> Bool {
dir.exists()
}
}
// MARK: - Disk Management
extension VMDirectory {
/// Resizes the VM's disk to the specified size
/// - Parameter size: The new size in bytes
/// - Throws: VMDirectoryError if the disk operation fails
func setDisk(_ size: UInt64) throws {
do {
if !diskPath.exists() {
guard fileManager.createFile(atPath: diskPath.path, contents: nil) else {
throw VMDirectoryError.fileCreationFailed(diskPath.path)
}
}
let handle = try FileHandle(forWritingTo: diskPath.url)
defer { try? handle.close() }
try handle.truncate(atOffset: size)
} catch {
}
}
}
// MARK: - Configuration Management
extension VMDirectory {
/// Saves the VM configuration to disk
/// - Parameter config: The configuration to save
/// - Throws: VMDirectoryError if the save operation fails
func saveConfig(_ config: VMConfig) throws {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
do {
let data = try encoder.encode(config)
guard fileManager.createFile(atPath: configPath.path, contents: data) else {
throw VMDirectoryError.fileCreationFailed(configPath.path)
}
} catch {
throw VMDirectoryError.invalidConfigData
}
}
/// Loads the VM configuration from disk
/// - Returns: The loaded configuration
/// - Throws: VMDirectoryError if the load operation fails
func loadConfig() throws -> VMConfig {
guard let data = fileManager.contents(atPath: configPath.path) else {
throw VMDirectoryError.configNotFound
}
do {
let decoder = JSONDecoder()
return try decoder.decode(VMConfig.self, from: data)
} catch {
throw VMDirectoryError.invalidConfigData
}
}
}
// MARK: - VNC Session Management
struct VNCSession: Codable {
let url: String
}
extension VMDirectory {
/// Saves VNC session information to disk
/// - Parameter session: The VNC session to save
/// - Throws: VMDirectoryError if the save operation fails
func saveSession(_ session: VNCSession) throws {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
do {
let data = try encoder.encode(session)
guard fileManager.createFile(atPath: sessionsPath.path, contents: data) else {
throw VMDirectoryError.fileCreationFailed(sessionsPath.path)
}
} catch {
throw VMDirectoryError.invalidSessionData
}
}
/// Loads the VNC session information from disk
/// - Returns: The loaded VNC session
/// - Throws: VMDirectoryError if the load operation fails
func loadSession() throws -> VNCSession {
guard let data = fileManager.contents(atPath: sessionsPath.path) else {
throw VMDirectoryError.sessionNotFound
}
do {
let decoder = JSONDecoder()
return try decoder.decode(VNCSession.self, from: data)
} catch {
throw VMDirectoryError.invalidSessionData
}
}
/// Removes the VNC session information from disk
func clearSession() {
try? fileManager.removeItem(atPath: sessionsPath.path)
}
}
// MARK: - CustomStringConvertible
extension VMDirectory: CustomStringConvertible {
var description: String {
"VMDirectory(path: \(dir.path))"
}
}
extension VMDirectory {
func delete() throws {
try fileManager.removeItem(atPath: dir.path)
}
}

514
src/LumeController.swift Normal file
View File

@@ -0,0 +1,514 @@
import ArgumentParser
import Foundation
import Virtualization
// MARK: - Shared VM Manager
@MainActor
final class SharedVM {
static let shared: SharedVM = SharedVM()
private var runningVMs: [String: VM] = [:]
private init() {}
func getVM(name: String) -> VM? {
return runningVMs[name]
}
func setVM(name: String, vm: VM) {
runningVMs[name] = vm
}
func removeVM(name: String) {
runningVMs.removeValue(forKey: name)
}
}
/// Entrypoint for Commands and API server
final class LumeController {
// MARK: - Properties
let home: Home
private let imageLoaderFactory: ImageLoaderFactory
private let vmFactory: VMFactory
// MARK: - Initialization
init(
home: Home = Home(),
imageLoaderFactory: ImageLoaderFactory = DefaultImageLoaderFactory(),
vmFactory: VMFactory = DefaultVMFactory()
) {
self.home = home
self.imageLoaderFactory = imageLoaderFactory
self.vmFactory = vmFactory
}
// MARK: - Public VM Management Methods
/// Lists all virtual machines in the system
@MainActor
public func list() throws -> [VMDetails] {
do {
let statuses = try home.getAllVMDirectories().map { directory in
let vm = try self.get(name: directory.name)
return vm.details
}
return statuses
} catch {
Logger.error("Failed to list VMs", metadata: ["error": error.localizedDescription])
throw error
}
}
@MainActor
public func clone(name: String, newName: String) throws {
Logger.info("Cloning VM", metadata: ["source": name, "destination": newName])
do {
try self.validateVMExists(name)
// Copy the VM directory
try home.copyVMDirectory(from: name, to: newName)
// Update MAC address in the cloned VM to ensure uniqueness
let clonedVM = try get(name: newName)
try clonedVM.setMacAddress(VZMACAddress.randomLocallyAdministered().string)
Logger.info("VM cloned successfully", metadata: ["source": name, "destination": newName])
} catch {
Logger.error("Failed to clone VM", metadata: ["error": error.localizedDescription])
throw error
}
}
@MainActor
public func get(name: String) throws -> VM {
do {
try self.validateVMExists(name)
let vm = try self.loadVM(name: name)
return vm
} catch {
Logger.error("Failed to get VM", metadata: ["error": error.localizedDescription])
throw error
}
}
/// Factory for creating the appropriate VM type based on the OS
@MainActor
public func create(
name: String,
os: String,
diskSize: UInt64,
cpuCount: Int,
memorySize: UInt64,
display: String,
ipsw: String?
) async throws {
Logger.info(
"Creating VM",
metadata: [
"name": name,
"os": os,
"disk_size": "\(diskSize / 1024 / 1024)MB",
"cpu_count": "\(cpuCount)",
"memory_size": "\(memorySize / 1024 / 1024)MB",
"display": display,
"ipsw": ipsw ?? "none",
])
do {
try validateCreateParameters(name: name, os: os, ipsw: ipsw)
let vm = try await createTempVMConfig(
os: os,
cpuCount: cpuCount,
memorySize: memorySize,
diskSize: diskSize,
display: display
)
try await vm.setup(
ipswPath: ipsw ?? "none",
cpuCount: cpuCount,
memorySize: memorySize,
diskSize: diskSize,
display: display
)
try vm.finalize(to: name, home: home)
Logger.info("VM created successfully", metadata: ["name": name])
} catch {
Logger.error("Failed to create VM", metadata: ["error": error.localizedDescription])
throw error
}
}
@MainActor
public func delete(name: String) async throws {
Logger.info("Deleting VM", metadata: ["name": name])
do {
try self.validateVMExists(name)
// Stop VM if it's running
if SharedVM.shared.getVM(name: name) != nil {
try await stopVM(name: name)
}
let vmDir = home.getVMDirectory(name)
try vmDir.delete()
Logger.info("VM deleted successfully", metadata: ["name": name])
} catch {
Logger.error("Failed to delete VM", metadata: ["error": error.localizedDescription])
throw error
}
}
// MARK: - VM Operations
@MainActor
public func updateSettings(
name: String,
cpu: Int? = nil,
memory: UInt64? = nil,
diskSize: UInt64? = nil
) throws {
Logger.info(
"Updating VM settings",
metadata: [
"name": name,
"cpu": cpu.map { "\($0)" } ?? "unchanged",
"memory": memory.map { "\($0 / 1024 / 1024)MB" } ?? "unchanged",
"disk_size": diskSize.map { "\($0 / 1024 / 1024)MB" } ?? "unchanged",
])
do {
try self.validateVMExists(name)
let vm = try get(name: name)
// Apply settings in order
if let cpu = cpu {
try vm.setCpuCount(cpu)
}
if let memory = memory {
try vm.setMemorySize(memory)
}
if let diskSize = diskSize {
try vm.setDiskSize(diskSize)
}
Logger.info("VM settings updated successfully", metadata: ["name": name])
} catch {
Logger.error(
"Failed to update VM settings", metadata: ["error": error.localizedDescription])
throw error
}
}
@MainActor
public func stopVM(name: String) async throws {
Logger.info("Stopping VM", metadata: ["name": name])
do {
try self.validateVMExists(name)
// Try to get VM from cache first
let vm: VM
if let cachedVM = SharedVM.shared.getVM(name: name) {
vm = cachedVM
} else {
vm = try get(name: name)
}
try await vm.stop()
// Remove VM from cache after stopping
SharedVM.shared.removeVM(name: name)
Logger.info("VM stopped successfully", metadata: ["name": name])
} catch {
// Clean up cache even if stop fails
SharedVM.shared.removeVM(name: name)
Logger.error("Failed to stop VM", metadata: ["error": error.localizedDescription])
throw error
}
}
@MainActor
public func runVM(
name: String,
noDisplay: Bool = false,
sharedDirectories: [SharedDirectory] = [],
mount: Path? = nil
) async throws {
Logger.info(
"Running VM",
metadata: [
"name": name,
"no_display": "\(noDisplay)",
"shared_directories": "\(sharedDirectories.map( { $0.string } ).joined(separator: ", "))",
"mount": mount?.path ?? "none",
])
do {
try validateRunParameters(
name: name, sharedDirectories: sharedDirectories, mount: mount)
let vm = try get(name: name)
SharedVM.shared.setVM(name: name, vm: vm)
try await vm.run(noDisplay: noDisplay, sharedDirectories: sharedDirectories, mount: mount)
Logger.info("VM started successfully", metadata: ["name": name])
} catch {
SharedVM.shared.removeVM(name: name)
Logger.error("Failed to run VM", metadata: ["error": error.localizedDescription])
throw error
}
}
// MARK: - Image Management
@MainActor
public func getLatestIPSWURL() async throws -> URL {
Logger.info("Fetching latest supported IPSW URL")
do {
let imageLoader = DarwinImageLoader()
let url = try await imageLoader.fetchLatestSupportedURL()
Logger.info("Found latest IPSW URL", metadata: ["url": url.absoluteString])
return url
} catch {
Logger.error(
"Failed to fetch IPSW URL", metadata: ["error": error.localizedDescription])
throw error
}
}
@MainActor
public func pullImage(image: String, name: String?, registry: String, organization: String)
async throws
{
do {
let vmName: String = name ?? image.split(separator: ":").first.map(String.init) ?? image
Logger.info(
"Pulling image",
metadata: [
"image": image,
"name": name ?? "default",
"registry": registry,
"organization": organization,
])
try self.validatePullParameters(
image: image, name: vmName, registry: registry, organization: organization)
let imageContainerRegistry = ImageContainerRegistry(
registry: registry, organization: organization)
try await imageContainerRegistry.pull(image: image, name: vmName)
Logger.info("Setting new VM mac address")
// Update MAC address in the cloned VM to ensure uniqueness
let vm = try get(name: vmName)
try vm.setMacAddress(VZMACAddress.randomLocallyAdministered().string)
Logger.info(
"Image pulled successfully",
metadata: [
"image": image,
"name": vmName,
"registry": registry,
"organization": organization,
])
} catch {
Logger.error("Failed to pull image", metadata: ["error": error.localizedDescription])
throw error
}
}
@MainActor
public func pruneImages() async throws {
Logger.info("Pruning cached images")
do {
let home = FileManager.default.homeDirectoryForCurrentUser
let cacheDir = home.appendingPathComponent(".lume/cache/ghcr")
if FileManager.default.fileExists(atPath: cacheDir.path) {
try FileManager.default.removeItem(at: cacheDir)
try FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
Logger.info("Successfully removed cached images")
} else {
Logger.info("No cached images found")
}
} catch {
Logger.error("Failed to prune images", metadata: ["error": error.localizedDescription])
throw error
}
}
public struct ImageInfo: Codable {
public let repository: String
public let tag: String
public let manifestId: String
public var fullName: String {
return "\(repository):\(tag)"
}
}
public struct ImageList: Codable {
public let local: [ImageInfo]
public let remote: [String]
}
@MainActor
public func getImages(organization: String = "trycua") async throws -> ImageList {
Logger.info("Listing local images", metadata: ["organization": organization])
let imageContainerRegistry = ImageContainerRegistry(registry: "ghcr.io", organization: organization)
let cachedImages = try await imageContainerRegistry.getImages()
let imageInfos = cachedImages.map { image in
ImageInfo(repository: image.repository, tag: image.tag, manifestId: image.manifestId)
}
ImagesPrinter.print(images: imageInfos.map { $0.fullName })
return ImageList(local: imageInfos, remote: [])
}
// MARK: - Private Helper Methods
@MainActor
private func createTempVMConfig(
os: String,
cpuCount: Int,
memorySize: UInt64,
diskSize: UInt64,
display: String
) async throws -> VM {
let config = try VMConfig(
os: os,
cpuCount: cpuCount,
memorySize: memorySize,
diskSize: diskSize,
macAddress: VZMACAddress.randomLocallyAdministered().string,
display: display
)
let vmDirContext = VMDirContext(
dir: try home.createTempVMDirectory(),
config: config,
home: home
)
let imageLoader = os.lowercased() == "macos" ? imageLoaderFactory.createImageLoader() : nil
return try vmFactory.createVM(vmDirContext: vmDirContext, imageLoader: imageLoader)
}
@MainActor
private func loadVM(name: String) throws -> VM {
let vmDir = home.getVMDirectory(name)
guard vmDir.initialized() else {
throw VMError.notInitialized(name)
}
let config: VMConfig = try vmDir.loadConfig()
let vmDirContext = VMDirContext(dir: vmDir, config: config, home: home)
let imageLoader =
config.os.lowercased() == "macos" ? imageLoaderFactory.createImageLoader() : nil
return try vmFactory.createVM(vmDirContext: vmDirContext, imageLoader: imageLoader)
}
// MARK: - Validation Methods
private func validateCreateParameters(name: String, os: String, ipsw: String?) throws {
if os.lowercased() == "macos" {
guard let ipsw = ipsw else {
throw ValidationError("IPSW path required for macOS VM")
}
if ipsw != "latest" && !FileManager.default.fileExists(atPath: ipsw) {
throw ValidationError("IPSW file not found")
}
} else if os.lowercased() == "linux" {
if ipsw != nil {
throw ValidationError("IPSW path not supported for Linux VM")
}
} else {
throw ValidationError("Unsupported OS type: \(os)")
}
let vmDir = home.getVMDirectory(name)
if vmDir.exists() {
throw VMError.alreadyExists(name)
}
}
private func validateSharedDirectories(_ directories: [SharedDirectory]) throws {
for dir in directories {
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: dir.hostPath, isDirectory: &isDirectory),
isDirectory.boolValue
else {
throw ValidationError(
"Host path does not exist or is not a directory: \(dir.hostPath)")
}
}
}
public func validateVMExists(_ name: String) throws {
let vmDir = home.getVMDirectory(name)
guard vmDir.initialized() else {
throw VMError.notFound(name)
}
}
private func validatePullParameters(
image: String, name: String, registry: String, organization: String
) throws {
guard !image.isEmpty else {
throw ValidationError("Image name cannot be empty")
}
guard !name.isEmpty else {
throw ValidationError("VM name cannot be empty")
}
guard !registry.isEmpty else {
throw ValidationError("Registry cannot be empty")
}
guard !organization.isEmpty else {
throw ValidationError("Organization cannot be empty")
}
let vmDir = home.getVMDirectory(name)
if vmDir.exists() {
throw VMError.alreadyExists(name)
}
}
private func validateRunParameters(
name: String, sharedDirectories: [SharedDirectory]?, mount: Path?
) throws {
try self.validateVMExists(name)
if let dirs: [SharedDirectory] = sharedDirectories {
try self.validateSharedDirectories(dirs)
}
let vmConfig = try home.getVMDirectory(name).loadConfig()
switch vmConfig.os.lowercased() {
case "macos":
if mount != nil {
throw ValidationError(
"Mounting disk images is not supported for macOS VMs. If you are looking to mount a IPSW, please use the --ipsw option in the create command."
)
}
case "linux":
if let mount = mount, !FileManager.default.fileExists(atPath: mount.path) {
throw ValidationError("Mount file not found: \(mount.path)")
}
default:
break
}
}
}

43
src/Main.swift Normal file
View File

@@ -0,0 +1,43 @@
import ArgumentParser
import Foundation
@main
struct Lume: AsyncParsableCommand {
static var configuration: CommandConfiguration {
CommandConfiguration(
commandName: "lume",
abstract: "A lightweight CLI and local API server to build, run and manage macOS VMs.",
version: Version.current,
subcommands: CommandRegistry.allCommands,
helpNames: .long
)
}
}
// MARK: - Version Management
extension Lume {
enum Version {
static let current = "0.1.0"
}
}
// MARK: - Command Execution
extension Lume {
public static func main() async {
do {
try await executeCommand()
} catch {
exit(withError: error)
}
}
private static func executeCommand() async throws {
var command = try parseAsRoot()
if var asyncCommand = command as? AsyncParsableCommand {
try await asyncCommand.run()
} else {
try command.run()
}
}
}

113
src/Server/HTTP.swift Normal file
View File

@@ -0,0 +1,113 @@
import Foundation
import Network
enum HTTPError: Error {
case internalError
}
struct HTTPRequest {
let method: String
let path: String
let headers: [String: String]
let body: Data?
init?(data: Data) {
guard let requestString = String(data: data, encoding: .utf8) else { return nil }
let components = requestString.components(separatedBy: "\r\n\r\n")
guard components.count >= 1 else { return nil }
let headerLines = components[0].components(separatedBy: "\r\n")
guard !headerLines.isEmpty else { return nil }
// Parse request line
let requestLine = headerLines[0].components(separatedBy: " ")
guard requestLine.count >= 2 else { return nil }
self.method = requestLine[0]
self.path = requestLine[1]
// Parse headers
var headers: [String: String] = [:]
for line in headerLines.dropFirst() {
let headerComponents = line.split(separator: ":", maxSplits: 1).map(String.init)
if headerComponents.count == 2 {
headers[headerComponents[0].trimmingCharacters(in: .whitespaces)] =
headerComponents[1].trimmingCharacters(in: .whitespaces)
}
}
self.headers = headers
// Parse body if present
if components.count > 1 {
self.body = components[1].data(using: .utf8)
} else {
self.body = nil
}
}
}
struct HTTPResponse {
enum StatusCode: Int {
case ok = 200
case accepted = 202
case badRequest = 400
case notFound = 404
case internalServerError = 500
var description: String {
switch self {
case .ok: return "OK"
case .accepted: return "Accepted"
case .badRequest: return "Bad Request"
case .notFound: return "Not Found"
case .internalServerError: return "Internal Server Error"
}
}
}
let statusCode: StatusCode
let headers: [String: String]
let body: Data?
init(statusCode: StatusCode, headers: [String: String] = [:], body: Data? = nil) {
self.statusCode = statusCode
self.headers = headers
self.body = body
}
init(statusCode: StatusCode, body: String) {
self.statusCode = statusCode
self.headers = ["Content-Type": "text/plain"]
self.body = body.data(using: .utf8)
}
func serialize() -> Data {
var response = "HTTP/1.1 \(statusCode.rawValue) \(statusCode.description)\r\n"
var headers = self.headers
if let body = body {
headers["Content-Length"] = "\(body.count)"
}
for (key, value) in headers {
response += "\(key): \(value)\r\n"
}
response += "\r\n"
var responseData = response.data(using: .utf8) ?? Data()
if let body = body {
responseData.append(body)
}
return responseData
}
}
final class HTTPServer {
let port: UInt16
init(port: UInt16) {
self.port = port
}
}

316
src/Server/Handlers.swift Normal file
View File

@@ -0,0 +1,316 @@
import Foundation
import Virtualization
import ArgumentParser
@MainActor
extension Server {
// MARK: - VM Management Handlers
func handleListVMs() async throws -> HTTPResponse {
do {
let vmController = LumeController()
let vms = try vmController.list()
return try .json(vms)
} catch {
return .badRequest(message: error.localizedDescription)
}
}
func handleGetVM(name: String) async throws -> HTTPResponse {
do {
let vmController = LumeController()
let vm = try vmController.get(name: name)
return try .json(vm.details)
} catch {
return .badRequest(message: error.localizedDescription)
}
}
func handleCreateVM(_ body: Data?) async throws -> HTTPResponse {
guard let body = body,
let request = try? JSONDecoder().decode(CreateVMRequest.self, from: body) else {
return HTTPResponse(
statusCode: .badRequest,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(APIError(message: "Invalid request body"))
)
}
do {
let sizes = try request.parse()
let vmController = LumeController()
try await vmController.create(
name: request.name,
os: request.os,
diskSize: sizes.diskSize,
cpuCount: request.cpu,
memorySize: sizes.memory,
display: request.display,
ipsw: request.ipsw
)
return HTTPResponse(
statusCode: .ok,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(["message": "VM created successfully", "name": request.name])
)
} catch {
return HTTPResponse(
statusCode: .badRequest,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(APIError(message: error.localizedDescription))
)
}
}
func handleDeleteVM(name: String) async throws -> HTTPResponse {
do {
let vmController = LumeController()
try await vmController.delete(name: name)
return HTTPResponse(statusCode: .ok, headers: ["Content-Type": "application/json"], body: Data())
} catch {
return HTTPResponse(statusCode: .badRequest, headers: ["Content-Type": "application/json"], body: try JSONEncoder().encode(APIError(message: error.localizedDescription)))
}
}
func handleCloneVM(_ body: Data?) async throws -> HTTPResponse {
guard let body = body,
let request = try? JSONDecoder().decode(CloneRequest.self, from: body) else {
return HTTPResponse(
statusCode: .badRequest,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(APIError(message: "Invalid request body"))
)
}
do {
let vmController = LumeController()
try vmController.clone(name: request.name, newName: request.newName)
return HTTPResponse(
statusCode: .ok,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode([
"message": "VM cloned successfully",
"source": request.name,
"destination": request.newName
])
)
} catch {
return HTTPResponse(
statusCode: .badRequest,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(APIError(message: error.localizedDescription))
)
}
}
// MARK: - VM Operation Handlers
func handleSetVM(name: String, body: Data?) async throws -> HTTPResponse {
guard let body = body,
let request = try? JSONDecoder().decode(SetVMRequest.self, from: body) else {
return HTTPResponse(
statusCode: .badRequest,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(APIError(message: "Invalid request body"))
)
}
do {
let vmController = LumeController()
let sizes = try request.parse()
try vmController.updateSettings(
name: name,
cpu: request.cpu,
memory: sizes.memory,
diskSize: sizes.diskSize
)
return HTTPResponse(
statusCode: .ok,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(["message": "VM settings updated successfully"])
)
} catch {
return HTTPResponse(
statusCode: .badRequest,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(APIError(message: error.localizedDescription))
)
}
}
func handleStopVM(name: String) async throws -> HTTPResponse {
do {
let vmController = LumeController()
try await vmController.stopVM(name: name)
return HTTPResponse(
statusCode: .ok,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(["message": "VM stopped successfully"])
)
} catch {
return HTTPResponse(
statusCode: .badRequest,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(APIError(message: error.localizedDescription))
)
}
}
func handleRunVM(name: String, body: Data?) async throws -> HTTPResponse {
let request = body.flatMap { try? JSONDecoder().decode(RunVMRequest.self, from: $0) } ?? RunVMRequest(noDisplay: nil, sharedDirectories: nil)
do {
let dirs = try request.parse()
// Start VM in background
startVM(
name: name,
noDisplay: request.noDisplay ?? false,
sharedDirectories: dirs
)
// Return response immediately
return HTTPResponse(
statusCode: .accepted,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode([
"message": "VM start initiated",
"name": name,
"status": "pending"
])
)
} catch {
return HTTPResponse(
statusCode: .badRequest,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(APIError(message: error.localizedDescription))
)
}
}
// MARK: - Image Management Handlers
func handleIPSW() async throws -> HTTPResponse {
do {
let vmController = LumeController()
let url = try await vmController.getLatestIPSWURL()
return HTTPResponse(
statusCode: .ok,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(["url": url.absoluteString])
)
} catch {
return HTTPResponse(
statusCode: .badRequest,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(APIError(message: error.localizedDescription))
)
}
}
func handlePull(_ body: Data?) async throws -> HTTPResponse {
guard let body = body,
let request = try? JSONDecoder().decode(PullRequest.self, from: body) else {
return HTTPResponse(
statusCode: .badRequest,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(APIError(message: "Invalid request body"))
)
}
do {
let vmController = LumeController()
try await vmController.pullImage(image: request.image, name: request.name, registry: request.registry, organization: request.organization)
return HTTPResponse(
statusCode: .ok,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(["message": "Image pulled successfully"])
)
} catch {
return HTTPResponse(
statusCode: .badRequest,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(APIError(message: error.localizedDescription))
)
}
}
func handlePruneImages() async throws -> HTTPResponse {
do {
let vmController = LumeController()
try await vmController.pruneImages()
return HTTPResponse(
statusCode: .ok,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(["message": "Successfully removed cached images"])
)
} catch {
return HTTPResponse(
statusCode: .badRequest,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(APIError(message: error.localizedDescription))
)
}
}
func handleGetImages(_ request: HTTPRequest) async throws -> HTTPResponse {
// Parse query parameters from URL path and query string
let pathAndQuery = request.path.split(separator: "?", maxSplits: 1)
let queryParams = pathAndQuery.count > 1 ? pathAndQuery[1]
.split(separator: "&")
.reduce(into: [String: String]()) { dict, param in
let parts = param.split(separator: "=", maxSplits: 1)
if parts.count == 2 {
dict[String(parts[0])] = String(parts[1])
}
} : [:]
let organization = queryParams["organization"] ?? "trycua"
do {
let vmController = LumeController()
let images = try await vmController.getImages(organization: organization)
return HTTPResponse(
statusCode: .ok,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(images)
)
} catch {
return HTTPResponse(
statusCode: .badRequest,
headers: ["Content-Type": "application/json"],
body: try JSONEncoder().encode(APIError(message: error.localizedDescription))
)
}
}
// MARK: - Private Helper Methods
nonisolated private func startVM(
name: String,
noDisplay: Bool,
sharedDirectories: [SharedDirectory] = []
) {
Task.detached { @MainActor @Sendable in
Logger.info("Starting VM in background", metadata: ["name": name])
do {
let vmController = LumeController()
try await vmController.runVM(
name: name,
noDisplay: noDisplay,
sharedDirectories: sharedDirectories
)
Logger.info("VM started successfully in background", metadata: ["name": name])
} catch {
Logger.error("Failed to start VM in background", metadata: [
"name": name,
"error": error.localizedDescription
])
}
}
}
}

86
src/Server/Requests.swift Normal file
View File

@@ -0,0 +1,86 @@
import Foundation
import ArgumentParser
import Virtualization
struct RunVMRequest: Codable {
let noDisplay: Bool?
let sharedDirectories: [SharedDirectoryRequest]?
struct SharedDirectoryRequest: Codable {
let hostPath: String
let readOnly: Bool?
}
func parse() throws -> [SharedDirectory] {
guard let sharedDirectories = sharedDirectories else { return [] }
return try sharedDirectories.map { dir -> SharedDirectory in
// Validate that the host path exists and is a directory
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: dir.hostPath, isDirectory: &isDirectory),
isDirectory.boolValue else {
throw ValidationError("Host path does not exist or is not a directory: \(dir.hostPath)")
}
return SharedDirectory(
hostPath: dir.hostPath,
tag: VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag,
readOnly: dir.readOnly ?? false
)
}
}
}
struct PullRequest: Codable {
let image: String
let name: String?
var registry: String
var organization: String
enum CodingKeys: String, CodingKey {
case image, name, registry, organization
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
image = try container.decode(String.self, forKey: .image)
name = try container.decodeIfPresent(String.self, forKey: .name)
registry = try container.decodeIfPresent(String.self, forKey: .registry) ?? "ghcr.io"
organization = try container.decodeIfPresent(String.self, forKey: .organization) ?? "trycua"
}
}
struct CreateVMRequest: Codable {
let name: String
let os: String
let cpu: Int
let memory: String
let diskSize: String
let display: String
let ipsw: String?
func parse() throws -> (memory: UInt64, diskSize: UInt64) {
return (
memory: try parseSize(memory),
diskSize: try parseSize(diskSize)
)
}
}
struct SetVMRequest: Codable {
let cpu: Int?
let memory: String?
let diskSize: String?
func parse() throws -> (memory: UInt64?, diskSize: UInt64?) {
return (
memory: try memory.map { try parseSize($0) },
diskSize: try diskSize.map { try parseSize($0) }
)
}
}
struct CloneRequest: Codable {
let name: String
let newName: String
}

View File

@@ -0,0 +1,25 @@
import Foundation
struct APIError: Codable {
let message: String
}
extension HTTPResponse {
static func json<T: Encodable>(_ value: T) throws -> HTTPResponse {
let data = try JSONEncoder().encode(value)
return HTTPResponse(
statusCode: .ok,
headers: ["Content-Type": "application/json"],
body: data
)
}
static func badRequest(message: String) -> HTTPResponse {
let error = APIError(message: message)
return try! HTTPResponse(
statusCode: .badRequest,
headers: ["Content-Type": "application/json"],
body: JSONEncoder().encode(error)
)
}
}

281
src/Server/Server.swift Normal file
View File

@@ -0,0 +1,281 @@
import Foundation
import Network
// MARK: - Server Class
@MainActor
final class Server {
// MARK: - Route Type
private struct Route {
let method: String
let path: String
let handler: (HTTPRequest) async throws -> HTTPResponse
func matches(_ request: HTTPRequest) -> Bool {
if method != request.method { return false }
// Handle path parameters
let routeParts = path.split(separator: "/")
let requestParts = request.path.split(separator: "/")
if routeParts.count != requestParts.count { return false }
for (routePart, requestPart) in zip(routeParts, requestParts) {
if routePart.hasPrefix(":") { continue } // Path parameter
if routePart != requestPart { return false }
}
return true
}
func extractParams(_ request: HTTPRequest) -> [String: String] {
var params: [String: String] = [:]
let routeParts = path.split(separator: "/")
let requestParts = request.path.split(separator: "/")
for (routePart, requestPart) in zip(routeParts, requestParts) {
if routePart.hasPrefix(":") {
let paramName = String(routePart.dropFirst())
params[paramName] = String(requestPart)
}
}
return params
}
}
// MARK: - Properties
private let port: NWEndpoint.Port
private let controller: LumeController
private var isRunning = false
private var listener: NWListener?
private var routes: [Route]
// MARK: - Initialization
init(port: UInt16 = 3000) {
self.port = NWEndpoint.Port(rawValue: port)!
self.controller = LumeController()
self.routes = []
// Define API routes after self is fully initialized
self.setupRoutes()
}
// MARK: - Route Setup
private func setupRoutes() {
routes = [
Route(method: "GET", path: "/lume/vms", handler: { [weak self] _ in
guard let self else { throw HTTPError.internalError }
return try await self.handleListVMs()
}),
Route(method: "GET", path: "/lume/vms/:name", handler: { [weak self] request in
guard let self else { throw HTTPError.internalError }
let params = Route(method: "GET", path: "/lume/vms/:name", handler: { _ in
HTTPResponse(statusCode: .ok, body: "")
}).extractParams(request)
guard let name = params["name"] else {
return HTTPResponse(statusCode: .badRequest, body: "Missing VM name")
}
return try await self.handleGetVM(name: name)
}),
Route(method: "DELETE", path: "/lume/vms/:name", handler: { [weak self] request in
guard let self else { throw HTTPError.internalError }
let params = Route(method: "DELETE", path: "/lume/vms/:name", handler: { _ in
HTTPResponse(statusCode: .ok, body: "")
}).extractParams(request)
guard let name = params["name"] else {
return HTTPResponse(statusCode: .badRequest, body: "Missing VM name")
}
return try await self.handleDeleteVM(name: name)
}),
Route(method: "POST", path: "/lume/vms", handler: { [weak self] request in
guard let self else { throw HTTPError.internalError }
return try await self.handleCreateVM(request.body)
}),
Route(method: "POST", path: "/lume/vms/clone", handler: { [weak self] request in
guard let self else { throw HTTPError.internalError }
return try await self.handleCloneVM(request.body)
}),
Route(method: "PATCH", path: "/lume/vms/:name", handler: { [weak self] request in
guard let self else { throw HTTPError.internalError }
let params = Route(method: "PATCH", path: "/lume/vms/:name", handler: { _ in
HTTPResponse(statusCode: .ok, body: "")
}).extractParams(request)
guard let name = params["name"] else {
return HTTPResponse(statusCode: .badRequest, body: "Missing VM name")
}
return try await self.handleSetVM(name: name, body: request.body)
}),
Route(method: "POST", path: "/lume/vms/:name/run", handler: { [weak self] request in
guard let self else { throw HTTPError.internalError }
let params = Route(method: "POST", path: "/lume/vms/:name/run", handler: { _ in
HTTPResponse(statusCode: .ok, body: "")
}).extractParams(request)
guard let name = params["name"] else {
return HTTPResponse(statusCode: .badRequest, body: "Missing VM name")
}
return try await self.handleRunVM(name: name, body: request.body)
}),
Route(method: "POST", path: "/lume/vms/:name/stop", handler: { [weak self] request in
guard let self else { throw HTTPError.internalError }
let params = Route(method: "POST", path: "/lume/vms/:name/stop", handler: { _ in
HTTPResponse(statusCode: .ok, body: "")
}).extractParams(request)
guard let name = params["name"] else {
return HTTPResponse(statusCode: .badRequest, body: "Missing VM name")
}
return try await self.handleStopVM(name: name)
}),
Route(method: "GET", path: "/lume/ipsw", handler: { [weak self] _ in
guard let self else { throw HTTPError.internalError }
return try await self.handleIPSW()
}),
Route(method: "POST", path: "/lume/pull", handler: { [weak self] request in
guard let self else { throw HTTPError.internalError }
return try await self.handlePull(request.body)
}),
Route(method: "POST", path: "/lume/prune", handler: { [weak self] _ in
guard let self else { throw HTTPError.internalError }
return try await self.handlePruneImages()
}),
Route(method: "GET", path: "/lume/images", handler: { [weak self] request in
guard let self else { throw HTTPError.internalError }
return try await self.handleGetImages(request)
})
]
}
// MARK: - Server Lifecycle
func start() async throws {
let parameters = NWParameters.tcp
listener = try NWListener(using: parameters, on: port)
listener?.newConnectionHandler = { [weak self] connection in
Task { @MainActor [weak self] in
guard let self else { return }
self.handleConnection(connection)
}
}
listener?.start(queue: .main)
isRunning = true
Logger.info("Server started", metadata: ["port": "\(port.rawValue)"])
// Keep the server running
while isRunning {
try await Task.sleep(nanoseconds: 1_000_000_000)
}
}
func stop() {
isRunning = false
listener?.cancel()
}
// MARK: - Connection Handling
private func handleConnection(_ connection: NWConnection) {
connection.stateUpdateHandler = { [weak self] state in
switch state {
case .ready:
Task { @MainActor [weak self] in
guard let self else { return }
self.receiveData(connection)
}
case .failed(let error):
Logger.error("Connection failed", metadata: ["error": error.localizedDescription])
connection.cancel()
case .cancelled:
// Connection is already cancelled, no need to cancel again
break
default:
break
}
}
connection.start(queue: .main)
}
private func receiveData(_ connection: NWConnection) {
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] content, _, isComplete, error in
if let error = error {
Logger.error("Receive error", metadata: ["error": error.localizedDescription])
connection.cancel()
return
}
guard let data = content, !data.isEmpty else {
if isComplete {
connection.cancel()
}
return
}
Task { @MainActor [weak self] in
guard let self else { return }
do {
let response = try await self.handleRequest(data)
self.send(response, on: connection)
} catch {
let errorResponse = self.errorResponse(error)
self.send(errorResponse, on: connection)
}
}
}
}
private func send(_ response: HTTPResponse, on connection: NWConnection) {
let data = response.serialize()
Logger.info("Serialized response", metadata: ["data": String(data: data, encoding: .utf8) ?? ""])
connection.send(content: data, completion: .contentProcessed { [weak connection] error in
if let error = error {
Logger.error("Failed to send response", metadata: ["error": error.localizedDescription])
} else {
Logger.info("Response sent successfully")
}
if connection?.state != .cancelled {
connection?.cancel()
}
})
}
// MARK: - Request Handling
private func handleRequest(_ data: Data) async throws -> HTTPResponse {
Logger.info("Received request data", metadata: ["data": String(data: data, encoding: .utf8) ?? ""])
guard let request = HTTPRequest(data: data) else {
Logger.error("Failed to parse request")
return HTTPResponse(statusCode: .badRequest, body: "Invalid request")
}
Logger.info("Parsed request", metadata: [
"method": request.method,
"path": request.path,
"headers": "\(request.headers)",
"body": String(data: request.body ?? Data(), encoding: .utf8) ?? ""
])
// Find matching route
guard let route = routes.first(where: { $0.matches(request) }) else {
return HTTPResponse(statusCode: .notFound, body: "Not found")
}
// Handle the request
let response = try await route.handler(request)
Logger.info("Sending response", metadata: [
"statusCode": "\(response.statusCode.rawValue)",
"headers": "\(response.headers)",
"body": String(data: response.body ?? Data(), encoding: .utf8) ?? ""
])
return response
}
private func errorResponse(_ error: Error) -> HTTPResponse {
HTTPResponse(
statusCode: .internalServerError,
headers: ["Content-Type": "application/json"],
body: try! JSONEncoder().encode(APIError(message: error.localizedDescription))
)
}
}

View File

@@ -0,0 +1,21 @@
import ArgumentParser
enum CommandRegistry {
static var allCommands: [ParsableCommand.Type] {
[
Create.self,
Pull.self,
Images.self,
Clone.self,
Get.self,
Set.self,
List.self,
Run.self,
Stop.self,
IPSW.self,
Serve.self,
Delete.self,
Prune.self
]
}
}

View File

@@ -0,0 +1,6 @@
import ArgumentParser
import Foundation
func completeVMName(_ arguments: [String]) -> [String] {
(try? Home().getAllVMDirectories().map(\.name)) ?? []
}

29
src/Utils/Logger.swift Normal file
View File

@@ -0,0 +1,29 @@
import Foundation
struct Logger {
typealias Metadata = [String: String]
enum Level: String {
case info
case error
case debug
}
static func info(_ message: String, metadata: Metadata = [:]) {
log(.info, message, metadata)
}
static func error(_ message: String, metadata: Metadata = [:]) {
log(.error, message, metadata)
}
static func debug(_ message: String, metadata: Metadata = [:]) {
log(.debug, message, metadata)
}
private static func log(_ level: Level, _ message: String, _ metadata: Metadata) {
let timestamp = ISO8601DateFormatter().string(from: Date())
let metadataString = metadata.isEmpty ? "" : " " + metadata.map { "\($0.key)=\($0.value)" }.joined(separator: " ")
print("[\(timestamp)] \(level.rawValue.uppercased()): \(message)\(metadataString)")
}
}

View File

@@ -0,0 +1,24 @@
import Foundation
enum NetworkUtils {
/// Checks if an IP address is reachable by sending a ping
/// - Parameter ipAddress: The IP address to check
/// - Returns: true if the IP is reachable, false otherwise
static func isReachable(ipAddress: String) -> Bool {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/sbin/ping")
process.arguments = ["-c", "1", "-t", "1", ipAddress]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
process.waitUntilExit()
return process.terminationStatus == 0
} catch {
return false
}
}
}

46
src/Utils/Path.swift Normal file
View File

@@ -0,0 +1,46 @@
import ArgumentParser
import Foundation
struct Path: CustomStringConvertible, ExpressibleByArgument {
let url: URL
init(_ path: String) {
url = URL(filePath: NSString(string: path).expandingTildeInPath).standardizedFileURL
}
init(_ url: URL) {
self.url = url
}
init(argument: String) {
self.init(argument)
}
func file(_ path: String) -> Path {
return Path(url.appendingPathComponent(path, isDirectory: false))
}
func directory(_ path: String) -> Path {
return Path(url.appendingPathComponent(path, isDirectory: true))
}
func exists() -> Bool {
return FileManager.default.fileExists(atPath: url.standardizedFileURL.path(percentEncoded: false))
}
func writable() -> Bool {
return FileManager.default.isWritableFile(atPath: url.standardizedFileURL.path(percentEncoded: false))
}
var name: String {
return url.lastPathComponent
}
var path: String {
return url.standardizedFileURL.path(percentEncoded: false)
}
var description: String {
return url.path()
}
}

View File

@@ -0,0 +1,15 @@
import Foundation
/// Protocol for process execution
protocol ProcessRunner {
func run(executable: String, arguments: [String]) throws
}
class DefaultProcessRunner: ProcessRunner {
func run(executable: String, arguments: [String]) throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: executable)
process.arguments = arguments
try process.run()
}
}

View File

@@ -0,0 +1,18 @@
import Foundation
struct ProgressLogger {
private var lastLoggedProgress: Double = 0.0
private let threshold: Double
init(threshold: Double = 0.05) {
self.threshold = threshold
}
mutating func logProgress(current: Double, context: String) {
if current - lastLoggedProgress >= threshold {
lastLoggedProgress = current
let percentage = Int(current * 100)
Logger.info("\(context) Progress: \(percentage)%")
}
}
}

7
src/Utils/String.swift Normal file
View File

@@ -0,0 +1,7 @@
import Foundation
extension String {
func padding(_ toLength: Int) -> String {
return self.padding(toLength: toLength, withPad: " ", startingAt: 0)
}
}

57
src/Utils/Utils.swift Normal file
View File

@@ -0,0 +1,57 @@
import Foundation
import ArgumentParser
extension Collection {
subscript (safe index: Index) -> Element? {
indices.contains(index) ? self[index] : nil
}
}
func resolveBinaryPath(_ name: String) -> URL? {
guard let path = ProcessInfo.processInfo.environment["PATH"] else {
return nil
}
for pathComponent in path.split(separator: ":") {
let url = URL(fileURLWithPath: String(pathComponent))
.appendingPathComponent(name, isDirectory: false)
if FileManager.default.fileExists(atPath: url.path) {
return url
}
}
return nil
}
// Helper function to parse size strings
func parseSize(_ input: String) throws -> UInt64 {
let lowercased = input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let multiplier: UInt64
let valueString: String
if lowercased.hasSuffix("tb") {
multiplier = 1024 * 1024 * 1024 * 1024
valueString = String(lowercased.dropLast(2))
} else if lowercased.hasSuffix("gb") {
multiplier = 1024 * 1024 * 1024
valueString = String(lowercased.dropLast(2))
} else if lowercased.hasSuffix("mb") {
multiplier = 1024 * 1024
valueString = String(lowercased.dropLast(2))
} else if lowercased.hasSuffix("kb") {
multiplier = 1024
valueString = String(lowercased.dropLast(2))
} else {
multiplier = 1024 * 1024 // Default to MB
valueString = lowercased
}
guard let value = UInt64(valueString.trimmingCharacters(in: .whitespacesAndNewlines)) else {
throw ValidationError("Malformed size input: \(input)") // Throw ad-hoc error for invalid input
}
let val = value * multiplier
return val
}

84
src/VM/DarwinVM.swift Normal file
View File

@@ -0,0 +1,84 @@
import Foundation
/// macOS-specific virtual machine implementation
@MainActor
final class DarwinVM: VM {
private let imageLoader: ImageLoader
init(
vmDirContext: VMDirContext,
virtualizationServiceFactory: @escaping (VMVirtualizationServiceContext) throws -> VMVirtualizationService = { try DarwinVirtualizationService(configuration: $0) },
vncServiceFactory: @escaping (VMDirectory) -> VNCService = { DefaultVNCService(vmDirectory: $0) },
imageLoader: ImageLoader
) {
self.imageLoader = imageLoader
super.init(
vmDirContext: vmDirContext,
virtualizationServiceFactory: virtualizationServiceFactory,
vncServiceFactory: vncServiceFactory
)
}
override func getOSType() -> String {
return "macOS"
}
// MARK: - Installation and Configuration
override func setup(ipswPath: String, cpuCount: Int, memorySize: UInt64, diskSize: UInt64, display: String) async throws {
let imagePath: Path
if ipswPath == "latest" {
Logger.info("Downloading latest supported Image...")
let downloadedPath = try await self.imageLoader.downloadLatestImage()
imagePath = Path(downloadedPath.path)
} else {
imagePath = Path(ipswPath)
}
let requirements = try await imageLoader.loadImageRequirements(from: imagePath.url)
try setDiskSize(diskSize)
let finalCpuCount = max(cpuCount, requirements.minimumSupportedCPUCount)
try setCpuCount(finalCpuCount)
if finalCpuCount != cpuCount {
Logger.info("CPU count overridden due to minimum image requirements", metadata: ["original": "\(cpuCount)", "final": "\(finalCpuCount)"])
}
let finalMemorySize = max(memorySize, requirements.minimumSupportedMemorySize)
try setMemorySize(finalMemorySize)
if finalMemorySize != memorySize {
Logger.info("Memory size overridden due to minimum image requirements", metadata: ["original": "\(memorySize)", "final": "\(finalMemorySize)"])
}
try updateVMConfig(
vmConfig: try VMConfig(
os: getOSType(),
cpuCount: finalCpuCount,
memorySize: finalMemorySize,
diskSize: diskSize,
macAddress: DarwinVirtualizationService.generateMacAddress(),
display: display,
hardwareModel: requirements.hardwareModel,
machineIdentifier: DarwinVirtualizationService.generateMachineIdentifier()
)
)
let service: any VMVirtualizationService = try virtualizationServiceFactory(
try createVMVirtualizationServiceContext(
cpuCount: finalCpuCount,
memorySize: finalMemorySize,
display: display
)
)
guard let darwinService = service as? DarwinVirtualizationService else {
throw VMError.internalError("Installation requires DarwinVirtualizationService")
}
// Create auxiliary storage with hardware model
try darwinService.createAuxiliaryStorage(at: vmDirContext.nvramPath, hardwareModel: requirements.hardwareModel)
try await darwinService.installMacOS(imagePath: imagePath) { progress in
Logger.info("Installing macOS", metadata: ["progress": "\(Int(progress * 100))%"])
}
}
}

55
src/VM/LinuxVM.swift Normal file
View File

@@ -0,0 +1,55 @@
import Foundation
/// Linux-specific virtual machine implementation
@MainActor
final class LinuxVM: VM {
override init(
vmDirContext: VMDirContext,
virtualizationServiceFactory: @escaping (VMVirtualizationServiceContext) throws -> VMVirtualizationService = { try LinuxVirtualizationService(configuration: $0) },
vncServiceFactory: @escaping (VMDirectory) -> VNCService = { DefaultVNCService(vmDirectory: $0) }
) {
super.init(
vmDirContext: vmDirContext,
virtualizationServiceFactory: virtualizationServiceFactory,
vncServiceFactory: vncServiceFactory
)
}
override func getOSType() -> String {
return "linux"
}
override func setup(
ipswPath: String,
cpuCount: Int,
memorySize: UInt64,
diskSize: UInt64,
display: String
) async throws {
try setDiskSize(diskSize)
let service = try virtualizationServiceFactory(
try createVMVirtualizationServiceContext(
cpuCount: cpuCount,
memorySize: memorySize,
display: display
)
)
guard let linuxService = service as? LinuxVirtualizationService else {
throw VMError.internalError("Installation requires LinuxVirtualizationService")
}
try updateVMConfig(vmConfig: try VMConfig(
os: getOSType(),
cpuCount: cpuCount,
memorySize: memorySize,
diskSize: diskSize,
macAddress: linuxService.generateMacAddress(),
display: display
))
// Create NVRAM store for EFI
try linuxService.createNVRAM(at: vmDirContext.nvramPath)
}
}

390
src/VM/VM.swift Normal file
View File

@@ -0,0 +1,390 @@
import Foundation
// MARK: - Support Types
/// Base context for virtual machine directory and configuration
struct VMDirContext {
let dir: VMDirectory
var config: VMConfig
let home: Home
func saveConfig() throws {
try dir.saveConfig(config)
}
var name: String { dir.name }
var initialized: Bool { dir.initialized() }
var diskPath: Path { dir.diskPath }
var nvramPath: Path { dir.nvramPath }
func setDisk(_ size: UInt64) throws {
try dir.setDisk(size)
}
func finalize(to name: String) throws {
let vmDir = home.getVMDirectory(name)
try FileManager.default.moveItem(at: dir.dir.url, to: vmDir.dir.url)
}
}
// MARK: - Base VM Class
/// Base class for virtual machine implementations
@MainActor
class VM {
// MARK: - Properties
var vmDirContext: VMDirContext
@MainActor
private var virtualizationService: VMVirtualizationService?
private let vncService: VNCService
internal let virtualizationServiceFactory: (VMVirtualizationServiceContext) throws -> VMVirtualizationService
private let vncServiceFactory: (VMDirectory) -> VNCService
// MARK: - Initialization
init(
vmDirContext: VMDirContext,
virtualizationServiceFactory: @escaping (VMVirtualizationServiceContext) throws -> VMVirtualizationService = { try DarwinVirtualizationService(configuration: $0) },
vncServiceFactory: @escaping (VMDirectory) -> VNCService = { DefaultVNCService(vmDirectory: $0) }
) {
self.vmDirContext = vmDirContext
self.virtualizationServiceFactory = virtualizationServiceFactory
self.vncServiceFactory = vncServiceFactory
// Initialize VNC service
self.vncService = vncServiceFactory(vmDirContext.dir)
}
// MARK: - VM State Management
private var isRunning: Bool {
// First check if we have an IP address
guard let ipAddress = DHCPLeaseParser.getIPAddress(forMAC: vmDirContext.config.macAddress!) else {
return false
}
// Then check if it's reachable
return NetworkUtils.isReachable(ipAddress: ipAddress)
}
var details: VMDetails {
let isRunning: Bool = self.isRunning
let vncUrl = isRunning ? getVNCUrl() : nil
return VMDetails(
name: vmDirContext.name,
os: getOSType(),
cpuCount: vmDirContext.config.cpuCount ?? 0,
memorySize: vmDirContext.config.memorySize ?? 0,
diskSize: try! getDiskSize(),
status: isRunning ? "running" : "stopped",
vncUrl: vncUrl,
ipAddress: isRunning ? DHCPLeaseParser.getIPAddress(forMAC: vmDirContext.config.macAddress!) : nil
)
}
// MARK: - VM Lifecycle Management
func run(noDisplay: Bool, sharedDirectories: [SharedDirectory], mount: Path?) async throws {
guard vmDirContext.initialized else {
throw VMError.notInitialized(vmDirContext.name)
}
guard let cpuCount = vmDirContext.config.cpuCount,
let memorySize = vmDirContext.config.memorySize else {
throw VMError.notInitialized(vmDirContext.name)
}
// Try to acquire lock on config file
let fileHandle = try FileHandle(forWritingTo: vmDirContext.dir.configPath.url)
guard flock(fileHandle.fileDescriptor, LOCK_EX | LOCK_NB) == 0 else {
try? fileHandle.close()
throw VMError.alreadyRunning(vmDirContext.name)
}
Logger.info("Running VM with configuration", metadata: [
"cpuCount": "\(cpuCount)",
"memorySize": "\(memorySize)",
"diskSize": "\(vmDirContext.config.diskSize ?? 0)",
"sharedDirectories": sharedDirectories.map(
{ $0.string }
).joined(separator: ", ")
])
// Create and configure the VM
do {
let config = try createVMVirtualizationServiceContext(
cpuCount: cpuCount,
memorySize: memorySize,
display: vmDirContext.config.display.string,
sharedDirectories: sharedDirectories,
mount: mount
)
virtualizationService = try virtualizationServiceFactory(config)
let vncInfo = try await setupVNC(noDisplay: noDisplay)
Logger.info("VNC info", metadata: ["vncInfo": vncInfo])
// Start the VM
guard let service = virtualizationService else {
throw VMError.internalError("Virtualization service not initialized")
}
try await service.start()
while true {
try await Task.sleep(nanoseconds: UInt64(1e9))
}
} catch {
virtualizationService = nil
vncService.stop()
// Release lock
flock(fileHandle.fileDescriptor, LOCK_UN)
try? fileHandle.close()
throw error
}
}
@MainActor
func stop() async throws {
guard vmDirContext.initialized else {
throw VMError.notInitialized(vmDirContext.name)
}
Logger.info("Attempting to stop VM", metadata: ["name": vmDirContext.name])
// If we have a virtualization service, try to stop it cleanly first
if let service = virtualizationService {
do {
try await service.stop()
virtualizationService = nil
vncService.stop()
Logger.info("VM stopped successfully via virtualization service", metadata: ["name": vmDirContext.name])
return
} catch let error {
Logger.error("Failed to stop VM via virtualization service, falling back to process termination", metadata: [
"name": vmDirContext.name,
"error": "\(error)"
])
// Fall through to process termination
}
}
// Try to open config file to get file descriptor - note that this matches with the serve process - so this is only for the command line
let fileHandle = try? FileHandle(forReadingFrom: vmDirContext.dir.configPath.url)
guard let fileHandle = fileHandle else {
Logger.error("Failed to open config file - VM not running", metadata: ["name": vmDirContext.name])
throw VMError.notRunning(vmDirContext.name)
}
// Get the PID of the process holding the lock using lsof command
let task = Process()
task.executableURL = URL(fileURLWithPath: "/usr/sbin/lsof")
task.arguments = ["-F", "p", vmDirContext.dir.configPath.path]
let outputPipe = Pipe()
task.standardOutput = outputPipe
try task.run()
task.waitUntilExit()
let outputData = try outputPipe.fileHandleForReading.readToEnd() ?? Data()
guard let outputString = String(data: outputData, encoding: .utf8),
let pidString = outputString.split(separator: "\n").first?.dropFirst(), // Drop the 'p' prefix
let pid = pid_t(pidString) else {
try? fileHandle.close()
Logger.error("Failed to find VM process - VM not running", metadata: ["name": vmDirContext.name])
throw VMError.notRunning(vmDirContext.name)
}
// First try graceful shutdown with SIGINT
if kill(pid, SIGINT) == 0 {
Logger.info("Sent SIGINT to VM process", metadata: ["name": vmDirContext.name, "pid": "\(pid)"])
}
// Wait for process to stop with timeout
var attempts = 0
while attempts < 10 {
try await Task.sleep(nanoseconds: 1_000_000_000)
// Check if process still exists
if kill(pid, 0) != 0 {
// Process is gone, do final cleanup
virtualizationService = nil
vncService.stop()
try? fileHandle.close()
Logger.info("VM stopped successfully via process termination", metadata: ["name": vmDirContext.name])
return
}
attempts += 1
}
// If graceful shutdown failed, force kill the process
Logger.info("Graceful shutdown failed, forcing termination", metadata: ["name": vmDirContext.name])
if kill(pid, SIGKILL) == 0 {
// Wait a moment for the process to be fully killed
try await Task.sleep(nanoseconds: 2_000_000_000)
// Do final cleanup
virtualizationService = nil
vncService.stop()
try? fileHandle.close()
Logger.info("VM forcefully stopped", metadata: ["name": vmDirContext.name])
return
}
// If we get here, something went very wrong
try? fileHandle.close()
Logger.error("Failed to stop VM", metadata: ["name": vmDirContext.name, "pid": "\(pid)"])
throw VMError.internalError("Failed to stop VM process")
}
// MARK: - Resource Management
func updateVMConfig(vmConfig: VMConfig) throws {
vmDirContext.config = vmConfig
try vmDirContext.saveConfig()
}
private func getDiskSize() throws -> DiskSize {
let resourceValues = try vmDirContext.diskPath.url.resourceValues(forKeys: [
.totalFileAllocatedSizeKey,
.totalFileSizeKey
])
guard let allocated = resourceValues.totalFileAllocatedSize,
let total = resourceValues.totalFileSize else {
throw VMConfigError.invalidDiskSize
}
return DiskSize(allocated: UInt64(allocated), total: UInt64(total))
}
func resizeDisk(_ newSize: UInt64) throws {
let currentSize = try getDiskSize()
guard newSize >= currentSize.total else {
throw VMError.resizeTooSmall(current: currentSize.total, requested: newSize)
}
try setDiskSize(newSize)
}
func setCpuCount(_ newCpuCount: Int) throws {
guard !isRunning else {
throw VMError.alreadyRunning(vmDirContext.name)
}
vmDirContext.config.setCpuCount(newCpuCount)
try vmDirContext.saveConfig()
}
func setMemorySize(_ newMemorySize: UInt64) throws {
guard !isRunning else {
throw VMError.alreadyRunning(vmDirContext.name)
}
vmDirContext.config.setMemorySize(newMemorySize)
try vmDirContext.saveConfig()
}
func setDiskSize(_ newDiskSize: UInt64) throws {
try vmDirContext.setDisk(newDiskSize)
vmDirContext.config.setDiskSize(newDiskSize)
try vmDirContext.saveConfig()
}
func setHardwareModel(_ newHardwareModel: Data) throws {
guard !isRunning else {
throw VMError.alreadyRunning(vmDirContext.name)
}
vmDirContext.config.setHardwareModel(newHardwareModel)
try vmDirContext.saveConfig()
}
func setMachineIdentifier(_ newMachineIdentifier: Data) throws {
guard !isRunning else {
throw VMError.alreadyRunning(vmDirContext.name)
}
vmDirContext.config.setMachineIdentifier(newMachineIdentifier)
try vmDirContext.saveConfig()
}
func setMacAddress(_ newMacAddress: String) throws {
guard !isRunning else {
throw VMError.alreadyRunning(vmDirContext.name)
}
vmDirContext.config.setMacAddress(newMacAddress)
try vmDirContext.saveConfig()
}
// MARK: - VNC Management
func getVNCUrl() -> String? {
return vncService.url
}
private func setupVNC(noDisplay: Bool) async throws -> String {
guard let service = virtualizationService else {
throw VMError.internalError("Virtualization service not initialized")
}
try await vncService.start(port: 0, virtualMachine: service.getVirtualMachine())
guard let url = vncService.url else {
throw VMError.vncNotConfigured
}
if !noDisplay {
Logger.info("Starting VNC session")
try await vncService.openClient(url: url)
}
return url
}
// MARK: - Platform-specific Methods
func getOSType() -> String {
fatalError("Must be implemented by subclass")
}
func createVMVirtualizationServiceContext(
cpuCount: Int,
memorySize: UInt64,
display: String,
sharedDirectories: [SharedDirectory] = [],
mount: Path? = nil
) throws -> VMVirtualizationServiceContext {
return VMVirtualizationServiceContext(
cpuCount: cpuCount,
memorySize: memorySize,
display: display,
sharedDirectories: sharedDirectories,
mount: mount,
hardwareModel: vmDirContext.config.hardwareModel,
machineIdentifier: vmDirContext.config.machineIdentifier,
macAddress: vmDirContext.config.macAddress!,
diskPath: vmDirContext.diskPath,
nvramPath: vmDirContext.nvramPath
)
}
func setup(
ipswPath: String,
cpuCount: Int,
memorySize: UInt64,
diskSize: UInt64,
display: String
) async throws {
fatalError("Must be implemented by subclass")
}
// MARK: - Finalization
/// Post-installation step to move the VM directory to the home directory
func finalize(to name: String, home: Home) throws {
try vmDirContext.finalize(to: name)
}
}

41
src/VM/VMDetails.swift Normal file
View File

@@ -0,0 +1,41 @@
import Foundation
import Network
struct DiskSize: Codable {
let allocated: UInt64
let total: UInt64
}
extension DiskSize {
var formattedAllocated: String {
formatBytes(allocated)
}
var formattedTotal: String {
formatBytes(total)
}
private func formatBytes(_ bytes: UInt64) -> String {
let units = ["B", "KB", "MB", "GB", "TB"]
var size = Double(bytes)
var unitIndex = 0
while size >= 1024 && unitIndex < units.count - 1 {
size /= 1024
unitIndex += 1
}
return String(format: "%.1f%@", size, units[unitIndex])
}
}
struct VMDetails: Codable {
let name: String
let os: String
let cpuCount: Int
let memorySize: UInt64
let diskSize: DiskSize
let status: String
let vncUrl: String?
let ipAddress: String?
}

View File

@@ -0,0 +1,61 @@
import Foundation
/// Prints VM status information in a formatted table
enum VMDetailsPrinter {
/// Represents a column in the VM status table
private struct Column: Sendable {
let header: String
let width: Int
let getValue: @Sendable (VMDetails) -> String
}
/// Configuration for all columns in the status table
private static let columns: [Column] = [
Column(header: "name", width: 34, getValue: { $0.name }),
Column(header: "os", width: 8, getValue: { $0.os }),
Column(header: "cpu", width: 8, getValue: { String($0.cpuCount) }),
Column(header: "memory", width: 8, getValue: {
String(format: "%.2fG", Float($0.memorySize) / (1024 * 1024 * 1024))
}),
Column(header: "disk", width: 16, getValue: {
"\($0.diskSize.formattedAllocated)/\($0.diskSize.formattedTotal)"
}),
Column(header: "status", width: 16, getValue: {
$0.status
}),
Column(header: "ip", width: 16, getValue: {
$0.ipAddress ?? "-"
}),
Column(header: "vnc", width: 50, getValue: {
$0.vncUrl ?? "-"
})
]
/// Prints the status of all VMs in a formatted table
/// - Parameter vms: Array of VM status objects to display
static func printStatus(_ vms: [VMDetails]) {
printHeader()
vms.forEach(printVM)
}
private static func printHeader() {
let paddedHeaders = columns.map { $0.header.paddedToWidth($0.width) }
print(paddedHeaders.joined())
}
private static func printVM(_ vm: VMDetails) {
let paddedColumns = columns.map { column in
column.getValue(vm).paddedToWidth(column.width)
}
print(paddedColumns.joined())
}
}
private extension String {
/// Pads the string to the specified width with spaces
/// - Parameter width: Target width for padding
/// - Returns: Padded string
func paddedToWidth(_ width: Int) -> String {
padding(toLength: width, withPad: " ", startingAt: 0)
}
}

View File

@@ -0,0 +1,28 @@
import Foundation
import ArgumentParser
struct VMDisplayResolution: Codable, ExpressibleByArgument {
let width: Int
let height: Int
init?(string: String) {
let components = string.components(separatedBy: "x")
guard components.count == 2,
let width = Int(components[0]),
let height = Int(components[1]),
width > 0, height > 0 else {
return nil
}
self.width = width
self.height = height
}
var string: String {
"\(width)x\(height)"
}
init?(argument: String) {
guard let resolution = VMDisplayResolution(string: argument) else { return nil }
self = resolution
}
}

37
src/VM/VMFactory.swift Normal file
View File

@@ -0,0 +1,37 @@
import Foundation
import Virtualization
enum VMType: String {
case darwin = "macOS"
case linux = "linux"
}
protocol VMFactory {
@MainActor
func createVM(
vmDirContext: VMDirContext,
imageLoader: ImageLoader?
) throws -> VM
}
class DefaultVMFactory: VMFactory {
@MainActor
func createVM(
vmDirContext: VMDirContext,
imageLoader: ImageLoader?
) throws -> VM {
let osType = vmDirContext.config.os.lowercased()
switch osType {
case "macos", "darwin":
guard let imageLoader = imageLoader else {
throw VMError.internalError("ImageLoader required for macOS VM")
}
return DarwinVM(vmDirContext: vmDirContext, imageLoader: imageLoader)
case "linux":
return LinuxVM(vmDirContext: vmDirContext)
default:
throw VMError.unsupportedOS(osType)
}
}
}

View File

@@ -0,0 +1,19 @@
import Foundation
final class PassphraseGenerator {
private let words: [String]
init(words: [String] = PassphraseGenerator.defaultWords) {
self.words = words
}
func prefix(_ count: Int) -> [String] {
guard count > 0 else { return [] }
return (0..<count).map { _ in words.randomElement() ?? words[0] }
}
private static let defaultWords = [
"apple", "banana", "cherry", "date",
"elder", "fig", "grape", "honey"
]
}

70
src/VNC/VNCService.swift Normal file
View File

@@ -0,0 +1,70 @@
import Foundation
import Dynamic
import Virtualization
/// Protocol defining the interface for VNC server operations
@MainActor
protocol VNCService {
var url: String? { get }
func start(port: Int, virtualMachine: Any?) async throws
func stop()
func openClient(url: String) async throws
}
/// Default implementation of VNCService
@MainActor
final class DefaultVNCService: VNCService {
private var vncServer: Any?
private let vmDirectory: VMDirectory
init(vmDirectory: VMDirectory) {
self.vmDirectory = vmDirectory
}
var url: String? {
get {
return try? vmDirectory.loadSession().url
}
}
func start(port: Int, virtualMachine: Any?) async throws {
let password = Array(PassphraseGenerator().prefix(4)).joined(separator: "-")
let securityConfiguration = Dynamic._VZVNCAuthenticationSecurityConfiguration(password: password)
let server = Dynamic._VZVNCServer(port: port, queue: DispatchQueue.main,
securityConfiguration: securityConfiguration)
if let vm = virtualMachine as? VZVirtualMachine {
server.virtualMachine = vm
}
server.start()
vncServer = server
// Wait for port to be assigned
while true {
if let port: UInt16 = server.port.asUInt16, port != 0 {
let url = "vnc://:\(password)@127.0.0.1:\(port)"
// Save session information
let session = VNCSession(
url: url
)
try vmDirectory.saveSession(session)
break
}
try await Task.sleep(nanoseconds: 50_000_000)
}
}
func stop() {
if let server = vncServer as? Dynamic {
server.stop()
}
vncServer = nil
vmDirectory.clearSession()
}
func openClient(url: String) async throws {
let processRunner = DefaultProcessRunner()
try processRunner.run(executable: "/usr/bin/open", arguments: [url])
}
}

View File

@@ -0,0 +1,109 @@
import Foundation
/// Represents a DHCP lease entry from the system's DHCP lease file
private struct DHCPLease {
let macAddress: String
let ipAddress: String
let expirationDate: Date
/// Creates a lease entry from raw DHCP lease file key-value pairs
/// - Parameter dict: Dictionary containing the raw lease data
/// - Returns: A DHCPLease instance if the data is valid, nil otherwise
static func from(_ dict: [String: String]) -> DHCPLease? {
guard let hwAddress = dict["hw_address"],
let ipAddress = dict["ip_address"],
let lease = dict["lease"] else {
return nil
}
// Parse MAC address from hw_address field (format can be "1,xx:xx:xx:xx:xx:xx" or "ff,...")
let hwParts = hwAddress.split(separator: ",")
guard hwParts.count >= 2 else { return nil }
// Get the MAC part after the prefix and normalize it
let rawMacAddress = String(hwParts[1]).trimmingCharacters(in: .whitespaces)
// Normalize the MAC address by ensuring each component is two digits
let normalizedMacAddress = rawMacAddress.split(separator: ":")
.map { component in
let hex = String(component)
return hex.count == 1 ? "0\(hex)" : hex
}
.joined(separator: ":")
// Convert hex timestamp to Date
let timestampHex = lease.trimmingCharacters(in: CharacterSet(charactersIn: "0x"))
guard let timestamp = UInt64(timestampHex, radix: 16) else { return nil }
let expirationDate = Date(timeIntervalSince1970: TimeInterval(timestamp))
return DHCPLease(
macAddress: normalizedMacAddress,
ipAddress: ipAddress,
expirationDate: expirationDate
)
}
/// Checks if the lease is currently valid
var isValid: Bool {
expirationDate > Date()
}
}
/// Parses DHCP lease files to retrieve IP addresses for VMs based on their MAC addresses
enum DHCPLeaseParser {
private static let leasePath = "/var/db/dhcpd_leases"
/// Retrieves the IP address for a given MAC address from the DHCP lease file
/// - Parameter macAddress: The MAC address to look up
/// - Returns: The IP address if found, nil otherwise
static func getIPAddress(forMAC macAddress: String) -> String? {
guard let leaseContents = try? String(contentsOfFile: leasePath, encoding: .utf8) else {
return nil
}
// Normalize the input MAC address to ensure consistent format
let normalizedMacAddress = macAddress.split(separator: ":").map { component in
let hex = String(component)
return hex.count == 1 ? "0\(hex)" : hex
}.joined(separator: ":")
let leases = try? parseDHCPLeases(leaseContents)
return leases?.first { lease in
lease.macAddress == normalizedMacAddress
}?.ipAddress
}
/// Parses the contents of a DHCP lease file into lease entries
/// - Parameter contents: The raw contents of the lease file
/// - Returns: Array of parsed lease entries
private static func parseDHCPLeases(_ contents: String) throws -> [DHCPLease] {
var leases: [DHCPLease] = []
var currentLease: [String: String] = [:]
var inLeaseBlock = false
let lines = contents.components(separatedBy: .newlines)
for line in lines {
let trimmedLine = line.trimmingCharacters(in: .whitespaces)
if trimmedLine == "{" {
inLeaseBlock = true
currentLease = [:]
} else if trimmedLine == "}" {
if let lease = DHCPLease.from(currentLease) {
leases.append(lease)
}
inLeaseBlock = false
} else if inLeaseBlock {
let parts = trimmedLine.split(separator: "=", maxSplits: 1)
if parts.count == 2 {
let key = String(parts[0]).trimmingCharacters(in: .whitespaces)
let value = String(parts[1]).trimmingCharacters(in: .whitespaces)
currentLease[key] = value
}
}
}
return leases
}
}

View File

@@ -0,0 +1,113 @@
import Foundation
import Virtualization
/// Handles loading and validation of macOS restore images (IPSW files).
/// Provides functionality to:
/// - Fetch the latest supported macOS restore image URL
/// - Load and validate image requirements for VM creation
/// - Extract hardware model and auxiliary storage configuration
protocol ImageLoader: Sendable {
typealias ImageRequirements = DarwinImageLoader.ImageRequirements
func fetchLatestSupportedURL() async throws -> URL
func loadImageRequirements(from url: URL) async throws -> ImageRequirements
func downloadLatestImage() async throws -> Path
}
final class DarwinImageLoader: NSObject, ImageLoader, @unchecked Sendable, URLSessionDownloadDelegate {
struct ImageRequirements: Sendable {
let hardwareModel: Data
let minimumSupportedCPUCount: Int
let minimumSupportedMemorySize: UInt64
}
enum ImageError: Error {
case invalidImage
case unsupportedConfiguration
case downloadFailed
}
private var lastLoggedProgress: Double = 0.0
private var progressLogger = ProgressLogger()
private var completionHandler: ((URL?, Error?) -> Void)?
func fetchLatestSupportedURL() async throws -> URL {
try await withCheckedThrowingContinuation { continuation in
VZMacOSRestoreImage.fetchLatestSupported { result in
switch result {
case .success(let image):
continuation.resume(returning: image.url)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
func loadImageRequirements(from url: URL) async throws -> ImageRequirements {
let image = try await VZMacOSRestoreImage.image(from: url)
guard let requirements = image.mostFeaturefulSupportedConfiguration else {
throw ImageError.unsupportedConfiguration
}
return ImageRequirements(
hardwareModel: requirements.hardwareModel.dataRepresentation,
minimumSupportedCPUCount: requirements.minimumSupportedCPUCount,
minimumSupportedMemorySize: requirements.minimumSupportedMemorySize
)
}
func downloadLatestImage() async throws -> Path {
let url = try await fetchLatestSupportedURL()
let tempDir = FileManager.default.temporaryDirectory
let downloadPath = tempDir.appendingPathComponent("latest.ipsw")
// Reset progress logger state
progressLogger = ProgressLogger(threshold: 0.01)
// Create a continuation to wait for download completion
return try await withCheckedThrowingContinuation { continuation in
let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
let task = session.downloadTask(with: url)
// Use the delegate method to handle completion
self.completionHandler = { location, error in
if let error = error {
continuation.resume(throwing: error)
return
}
do {
// Remove existing file if it exists
if FileManager.default.fileExists(atPath: downloadPath.path) {
try FileManager.default.removeItem(at: downloadPath)
}
try FileManager.default.moveItem(at: location!, to: downloadPath)
Logger.info("Download completed and moved to: \(downloadPath.path)")
continuation.resume(returning: Path(downloadPath.path))
} catch {
continuation.resume(throwing: error)
}
}
task.resume()
}
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
progressLogger.logProgress(current: progress, context: "Downloading IPSW")
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
// Call the stored completion handler
completionHandler?(location, nil)
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
// Call the stored completion handler with an error if it occurred
if let error = error {
completionHandler?(nil, error)
}
}
}

View File

@@ -0,0 +1,17 @@
import Foundation
/// Protocol defining a factory for creating image loaders based on the image type
protocol ImageLoaderFactory {
/// Creates an appropriate ImageLoader based on the image path or type
func createImageLoader() -> ImageLoader
}
/// Default implementation of ImageLoaderFactory that creates appropriate loaders based on image type
final class DefaultImageLoaderFactory: ImageLoaderFactory {
func createImageLoader() -> ImageLoader {
// For now, we only support Darwin images
// In the future, this can be extended to support other OS types
// by analyzing the image path or having explicit OS type parameter
return DarwinImageLoader()
}
}

View File

@@ -0,0 +1,329 @@
import Foundation
import Virtualization
/// Framework-agnostic VM configuration
struct VMVirtualizationServiceContext {
let cpuCount: Int
let memorySize: UInt64
let display: String
let sharedDirectories: [SharedDirectory]?
let mount: Path?
let hardwareModel: Data?
let machineIdentifier: Data?
let macAddress: String
let diskPath: Path
let nvramPath: Path
}
/// Protocol defining the interface for virtualization operations
@MainActor
protocol VMVirtualizationService {
var state: VZVirtualMachine.State { get }
func start() async throws
func stop() async throws
func pause() async throws
func resume() async throws
func getVirtualMachine() -> Any
}
/// Base implementation of VMVirtualizationService using VZVirtualMachine
@MainActor
class BaseVirtualizationService: VMVirtualizationService {
let virtualMachine: VZVirtualMachine
var state: VZVirtualMachine.State {
virtualMachine.state
}
init(virtualMachine: VZVirtualMachine) {
self.virtualMachine = virtualMachine
}
func start() async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
Task { @MainActor in
virtualMachine.start { result in
switch result {
case .success:
continuation.resume()
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
}
func stop() async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
virtualMachine.stop { error in
if let error = error {
continuation.resume(throwing: error)
} else {
continuation.resume()
}
}
}
}
func pause() async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
virtualMachine.start { result in
switch result {
case .success:
continuation.resume()
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
func resume() async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
virtualMachine.start { result in
switch result {
case .success:
continuation.resume()
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
func getVirtualMachine() -> Any {
return virtualMachine
}
// Helper methods for creating common configurations
static func createStorageDeviceConfiguration(diskPath: Path, readOnly: Bool = false) throws -> VZStorageDeviceConfiguration {
return VZVirtioBlockDeviceConfiguration(
attachment: try VZDiskImageStorageDeviceAttachment(
url: diskPath.url,
readOnly: readOnly,
cachingMode: VZDiskImageCachingMode.automatic,
synchronizationMode: VZDiskImageSynchronizationMode.fsync
)
)
}
static func createNetworkDeviceConfiguration(macAddress: String) throws -> VZNetworkDeviceConfiguration {
let network = VZVirtioNetworkDeviceConfiguration()
guard let vzMacAddress = VZMACAddress(string: macAddress) else {
throw VMConfigError.invalidMachineIdentifier
}
network.attachment = VZNATNetworkDeviceAttachment()
network.macAddress = vzMacAddress
return network
}
static func createDirectorySharingDevices(sharedDirectories: [SharedDirectory]?) -> [VZDirectorySharingDeviceConfiguration] {
return sharedDirectories?.map { sharedDir in
let device = VZVirtioFileSystemDeviceConfiguration(tag: sharedDir.tag)
let url = URL(fileURLWithPath: sharedDir.hostPath)
device.share = VZSingleDirectoryShare(directory: VZSharedDirectory(url: url, readOnly: sharedDir.readOnly))
return device
} ?? []
}
}
/// macOS-specific virtualization service
@MainActor
final class DarwinVirtualizationService: BaseVirtualizationService {
static func createConfiguration(_ config: VMVirtualizationServiceContext) throws -> VZVirtualMachineConfiguration {
let vzConfig = VZVirtualMachineConfiguration()
vzConfig.cpuCount = config.cpuCount
vzConfig.memorySize = config.memorySize
// Platform configuration
guard let machineIdentifier = config.machineIdentifier else {
throw VMConfigError.emptyMachineIdentifier
}
guard let hardwareModel = config.hardwareModel else {
throw VMConfigError.emptyHardwareModel
}
let platform = VZMacPlatformConfiguration()
platform.auxiliaryStorage = VZMacAuxiliaryStorage(url: config.nvramPath.url)
Logger.info("Pre-VZMacHardwareModel: hardwareModel=\(hardwareModel)")
guard let vzHardwareModel = VZMacHardwareModel(dataRepresentation: hardwareModel) else {
throw VMConfigError.invalidHardwareModel
}
platform.hardwareModel = vzHardwareModel
guard let vzMachineIdentifier = VZMacMachineIdentifier(dataRepresentation: machineIdentifier) else {
throw VMConfigError.invalidMachineIdentifier
}
platform.machineIdentifier = vzMachineIdentifier
vzConfig.platform = platform
vzConfig.bootLoader = VZMacOSBootLoader()
// Graphics configuration
let display = VMDisplayResolution(string: config.display)!
let graphics = VZMacGraphicsDeviceConfiguration()
graphics.displays = [
VZMacGraphicsDisplayConfiguration(
widthInPixels: display.width,
heightInPixels: display.height,
pixelsPerInch: 220 // Retina display density
)
]
vzConfig.graphicsDevices = [graphics]
// Common configurations
vzConfig.keyboards = [VZUSBKeyboardConfiguration()]
vzConfig.pointingDevices = [VZUSBScreenCoordinatePointingDeviceConfiguration()]
var storageDevices = [try createStorageDeviceConfiguration(diskPath: config.diskPath)]
if let mount = config.mount {
storageDevices.append(try createStorageDeviceConfiguration(diskPath: mount, readOnly: true))
}
vzConfig.storageDevices = storageDevices
vzConfig.networkDevices = [try createNetworkDeviceConfiguration(macAddress: config.macAddress)]
vzConfig.memoryBalloonDevices = [VZVirtioTraditionalMemoryBalloonDeviceConfiguration()]
vzConfig.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
// Directory sharing
let directorySharingDevices = createDirectorySharingDevices(sharedDirectories: config.sharedDirectories)
if !directorySharingDevices.isEmpty {
vzConfig.directorySharingDevices = directorySharingDevices
}
try vzConfig.validate()
return vzConfig
}
static func generateMacAddress() -> String {
VZMACAddress.randomLocallyAdministered().string
}
static func generateMachineIdentifier() -> Data {
VZMacMachineIdentifier().dataRepresentation
}
func createAuxiliaryStorage(at path: Path, hardwareModel: Data) throws {
guard let vzHardwareModel = VZMacHardwareModel(dataRepresentation: hardwareModel) else {
throw VMConfigError.invalidHardwareModel
}
_ = try VZMacAuxiliaryStorage(creatingStorageAt: path.url, hardwareModel: vzHardwareModel)
}
init(configuration: VMVirtualizationServiceContext) throws {
let vzConfig = try Self.createConfiguration(configuration)
super.init(virtualMachine: VZVirtualMachine(configuration: vzConfig))
}
func installMacOS(imagePath: Path, progressHandler: (@Sendable (Double) -> Void)?) async throws {
var observers: [NSKeyValueObservation] = [] // must hold observer references during installation to print process
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
Task {
let installer = VZMacOSInstaller(virtualMachine: virtualMachine, restoringFromImageAt: imagePath.url)
Logger.info("Starting macOS installation")
if let progressHandler = progressHandler {
let observer = installer.progress.observe(\.fractionCompleted, options: [.initial, .new]) { (progress, change) in
if let newValue = change.newValue {
progressHandler(newValue)
}
}
observers.append(observer)
}
installer.install { result in
switch result {
case .success:
continuation.resume()
case .failure(let error):
Logger.error("Failed to install, error=\(error))")
continuation.resume(throwing: error)
}
}
}
}
Logger.info("macOS installation finished")
}
}
/// Linux-specific virtualization service
@MainActor
final class LinuxVirtualizationService: BaseVirtualizationService {
static func createConfiguration(_ config: VMVirtualizationServiceContext) throws -> VZVirtualMachineConfiguration {
let vzConfig = VZVirtualMachineConfiguration()
vzConfig.cpuCount = config.cpuCount
vzConfig.memorySize = config.memorySize
// Platform configuration
let platform = VZGenericPlatformConfiguration()
if #available(macOS 15, *) {
platform.isNestedVirtualizationEnabled = VZGenericPlatformConfiguration.isNestedVirtualizationSupported
}
vzConfig.platform = platform
let bootLoader = VZEFIBootLoader()
bootLoader.variableStore = VZEFIVariableStore(url: config.nvramPath.url)
vzConfig.bootLoader = bootLoader
// Graphics configuration
let display = VMDisplayResolution(string: config.display)!
let graphics = VZVirtioGraphicsDeviceConfiguration()
graphics.scanouts = [
VZVirtioGraphicsScanoutConfiguration(
widthInPixels: display.width,
heightInPixels: display.height
)
]
vzConfig.graphicsDevices = [graphics]
// Common configurations
vzConfig.keyboards = [VZUSBKeyboardConfiguration()]
vzConfig.pointingDevices = [VZUSBScreenCoordinatePointingDeviceConfiguration()]
var storageDevices = [try createStorageDeviceConfiguration(diskPath: config.diskPath)]
if let mount = config.mount {
storageDevices.append(try createStorageDeviceConfiguration(diskPath: mount, readOnly: true))
}
vzConfig.storageDevices = storageDevices
vzConfig.networkDevices = [try createNetworkDeviceConfiguration(macAddress: config.macAddress)]
vzConfig.memoryBalloonDevices = [VZVirtioTraditionalMemoryBalloonDeviceConfiguration()]
vzConfig.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
// Directory sharing
var directorySharingDevices = createDirectorySharingDevices(sharedDirectories: config.sharedDirectories)
// Add Rosetta support if available
if #available(macOS 13.0, *) {
if VZLinuxRosettaDirectoryShare.availability == .installed {
do {
let rosettaShare = try VZLinuxRosettaDirectoryShare()
let rosettaDevice = VZVirtioFileSystemDeviceConfiguration(tag: "rosetta")
rosettaDevice.share = rosettaShare
directorySharingDevices.append(rosettaDevice)
Logger.info("Added Rosetta support to Linux VM")
} catch {
Logger.info("Failed to add Rosetta support: \(error.localizedDescription)")
}
} else {
Logger.info("Rosetta not installed, skipping Rosetta support")
}
}
if !directorySharingDevices.isEmpty {
vzConfig.directorySharingDevices = directorySharingDevices
}
try vzConfig.validate()
return vzConfig
}
func generateMacAddress() -> String {
VZMACAddress.randomLocallyAdministered().string
}
func createNVRAM(at path: Path) throws {
_ = try VZEFIVariableStore(creatingVariableStoreAt: path.url)
}
init(configuration: VMVirtualizationServiceContext) throws {
let vzConfig = try Self.createConfiguration(configuration)
super.init(virtualMachine: VZVirtualMachine(configuration: vzConfig))
}
}

30
tests/Mocks/MockVM.swift Normal file
View File

@@ -0,0 +1,30 @@
import Foundation
@testable import lume
@MainActor
class MockVM: VM {
private var mockIsRunning = false
override func getOSType() -> String {
return "mock-os"
}
override func setup(ipswPath: String, cpuCount: Int, memorySize: UInt64, diskSize: UInt64, display: String) async throws {
// Mock setup implementation
vmDirContext.config.setCpuCount(cpuCount)
vmDirContext.config.setMemorySize(memorySize)
vmDirContext.config.setDiskSize(diskSize)
vmDirContext.config.setMacAddress("00:11:22:33:44:55")
try vmDirContext.saveConfig()
}
override func run(noDisplay: Bool, sharedDirectories: [SharedDirectory], mount: Path?) async throws {
mockIsRunning = true
try await super.run(noDisplay: noDisplay, sharedDirectories: sharedDirectories, mount: mount)
}
override func stop() async throws {
mockIsRunning = false
try await super.stop()
}
}

View File

@@ -0,0 +1,65 @@
import Foundation
import Virtualization
@testable import lume
@MainActor
final class MockVMVirtualizationService: VMVirtualizationService {
private(set) var currentState: VZVirtualMachine.State = .stopped
private(set) var startCallCount = 0
private(set) var stopCallCount = 0
private(set) var pauseCallCount = 0
private(set) var resumeCallCount = 0
var state: VZVirtualMachine.State {
currentState
}
private var _shouldFailNextOperation = false
private var _operationError: Error = VMError.internalError("Mock operation failed")
nonisolated func configure(shouldFail: Bool, error: Error = VMError.internalError("Mock operation failed")) async {
await setConfiguration(shouldFail: shouldFail, error: error)
}
@MainActor
private func setConfiguration(shouldFail: Bool, error: Error) {
_shouldFailNextOperation = shouldFail
_operationError = error
}
func start() async throws {
startCallCount += 1
if _shouldFailNextOperation {
throw _operationError
}
currentState = .running
}
func stop() async throws {
stopCallCount += 1
if _shouldFailNextOperation {
throw _operationError
}
currentState = .stopped
}
func pause() async throws {
pauseCallCount += 1
if _shouldFailNextOperation {
throw _operationError
}
currentState = .paused
}
func resume() async throws {
resumeCallCount += 1
if _shouldFailNextOperation {
throw _operationError
}
currentState = .running
}
func getVirtualMachine() -> Any {
return "mock_vm"
}
}

View File

@@ -0,0 +1,42 @@
import Foundation
@testable import lume
@MainActor
final class MockVNCService: VNCService {
private(set) var url: String?
private(set) var isRunning = false
private(set) var clientOpenCount = 0
private var _attachedVM: Any?
private let vmDirectory: VMDirectory
init(vmDirectory: VMDirectory) {
self.vmDirectory = vmDirectory
}
nonisolated var attachedVM: String? {
get async {
await Task { @MainActor in
_attachedVM as? String
}.value
}
}
func start(port: Int, virtualMachine: Any?) async throws {
isRunning = true
url = "vnc://localhost:\(port)"
_attachedVM = virtualMachine
}
func stop() {
isRunning = false
url = nil
_attachedVM = nil
}
func openClient(url: String) async throws {
guard isRunning else {
throw VMError.vncNotConfigured
}
clientOpenCount += 1
}
}

187
tests/VMTests.swift Normal file
View File

@@ -0,0 +1,187 @@
import Foundation
import Testing
@testable import lume
class MockProcessRunner: ProcessRunner {
var runCalls: [(executable: String, arguments: [String])] = []
func run(executable: String, arguments: [String]) throws {
runCalls.append((executable, arguments))
}
}
private func setupVMDirectory(_ tempDir: URL) throws -> VMDirectory {
let vmDir = VMDirectory(Path(tempDir.path))
// Create disk image file
let diskPath = vmDir.diskPath
let diskData = Data(repeating: 0, count: 1024 * 1024) // 1MB mock disk
try diskData.write(to: diskPath.url)
// Create nvram file
let nvramPath = vmDir.nvramPath
let nvramData = Data(repeating: 0, count: 1024) // 1KB mock nvram
try nvramData.write(to: nvramPath.url)
// Create initial config file
var config = try VMConfig(
os: "mock-os",
cpuCount: 1,
memorySize: 1024,
diskSize: 1024,
display: "1024x768"
)
config.setMacAddress("00:11:22:33:44:55")
try vmDir.saveConfig(config)
// Create .initialized file to mark VM as initialized
let initializedPath = vmDir.dir.file(".initialized")
try Data().write(to: initializedPath.url)
return vmDir
}
@MainActor
@Test("VM initialization and configuration")
func testVMInitialization() async throws {
let tempDir = try createTempDirectory()
let vmDir = try setupVMDirectory(tempDir)
var config = try VMConfig(
os: "mock-os",
cpuCount: 1,
memorySize: 1024,
diskSize: 1024,
display: "1024x768"
)
config.setMacAddress("00:11:22:33:44:55") // Set MAC address to avoid nil
let home = Home(fileManager: FileManager.default)
let context = VMDirContext(dir: vmDir, config: config, home: home)
let vm = MockVM(
vmDirContext: context,
virtualizationServiceFactory: { _ in MockVMVirtualizationService() },
vncServiceFactory: { MockVNCService(vmDirectory: $0) }
)
// Test initial state
let details = vm.details
#expect(details.name == vmDir.name)
#expect(details.os == "mock-os")
#expect(details.status == "stopped")
#expect(details.vncUrl == nil)
}
@MainActor
@Test("VM run and stop operations")
func testVMRunAndStop() async throws {
let tempDir = try createTempDirectory()
let vmDir = try setupVMDirectory(tempDir)
var config = try VMConfig(
os: "mock-os",
cpuCount: 2,
memorySize: 2048,
diskSize: 1024,
display: "1024x768"
)
config.setMacAddress("00:11:22:33:44:55")
let home = Home(fileManager: FileManager.default)
let context = VMDirContext(dir: vmDir, config: config, home: home)
let vm = MockVM(
vmDirContext: context,
virtualizationServiceFactory: { _ in MockVMVirtualizationService() },
vncServiceFactory: { MockVNCService(vmDirectory: $0) }
)
// Test running VM
let runTask = Task {
try await vm.run(noDisplay: false, sharedDirectories: [], mount: nil)
}
// Give the VM time to start
try await Task.sleep(nanoseconds: UInt64(1e9))
// Test stopping VM
try await vm.stop()
runTask.cancel()
}
@MainActor
@Test("VM configuration updates")
func testVMConfigurationUpdates() async throws {
let tempDir = try createTempDirectory()
let vmDir = try setupVMDirectory(tempDir)
var config = try VMConfig(
os: "mock-os",
cpuCount: 1,
memorySize: 1024,
diskSize: 1024,
display: "1024x768"
)
config.setMacAddress("00:11:22:33:44:55")
let home = Home(fileManager: FileManager.default)
let context = VMDirContext(dir: vmDir, config: config, home: home)
let vm = MockVM(
vmDirContext: context,
virtualizationServiceFactory: { _ in MockVMVirtualizationService() },
vncServiceFactory: { MockVNCService(vmDirectory: $0) }
)
// Test CPU count update
try vm.setCpuCount(4)
#expect(vm.vmDirContext.config.cpuCount == 4)
// Test memory size update
try vm.setMemorySize(4096)
#expect(vm.vmDirContext.config.memorySize == 4096)
// Test MAC address update
try vm.setMacAddress("00:11:22:33:44:66")
#expect(vm.vmDirContext.config.macAddress == "00:11:22:33:44:66")
}
@MainActor
@Test("VM setup process")
func testVMSetup() async throws {
let tempDir = try createTempDirectory()
let vmDir = try setupVMDirectory(tempDir)
var config = try VMConfig(
os: "mock-os",
cpuCount: 1,
memorySize: 1024,
diskSize: 1024,
display: "1024x768"
)
config.setMacAddress("00:11:22:33:44:55")
let home = Home(fileManager: FileManager.default)
let context = VMDirContext(dir: vmDir, config: config, home: home)
let vm = MockVM(
vmDirContext: context,
virtualizationServiceFactory: { _ in MockVMVirtualizationService() },
vncServiceFactory: { MockVNCService(vmDirectory: $0) }
)
let expectedDiskSize: UInt64 = 64 * 1024 * 1024 * 1024 // 64 GB
try await vm.setup(
ipswPath: "/path/to/mock.ipsw",
cpuCount: 2,
memorySize: 2048,
diskSize: expectedDiskSize,
display: "1024x768"
)
#expect(vm.vmDirContext.config.cpuCount == 2)
#expect(vm.vmDirContext.config.memorySize == 2048)
let actualDiskSize = vm.vmDirContext.config.diskSize ?? 0
#expect(actualDiskSize == expectedDiskSize, "Expected disk size \(expectedDiskSize), but got \(actualDiskSize)")
#expect(vm.vmDirContext.config.macAddress == "00:11:22:33:44:55")
}
private func createTempDirectory() throws -> URL {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
return tempDir
}

View File

@@ -0,0 +1,68 @@
import Foundation
import Testing
import Virtualization
@testable import lume
@Test("VMVirtualizationService starts correctly")
func testVMVirtualizationServiceStart() async throws {
let service = MockVMVirtualizationService()
// Initial state
#expect(await service.state == .stopped)
#expect(await service.startCallCount == 0)
// Start service
try await service.start()
#expect(await service.state == .running)
#expect(await service.startCallCount == 1)
}
@Test("VMVirtualizationService stops correctly")
func testVMVirtualizationServiceStop() async throws {
let service = MockVMVirtualizationService()
// Start then stop
try await service.start()
try await service.stop()
#expect(await service.state == .stopped)
#expect(await service.stopCallCount == 1)
}
@Test("VMVirtualizationService handles pause and resume")
func testVMVirtualizationServicePauseResume() async throws {
let service = MockVMVirtualizationService()
// Start and pause
try await service.start()
try await service.pause()
#expect(await service.state == .paused)
#expect(await service.pauseCallCount == 1)
// Resume
try await service.resume()
#expect(await service.state == .running)
#expect(await service.resumeCallCount == 1)
}
@Test("VMVirtualizationService handles operation failures")
func testVMVirtualizationServiceFailures() async throws {
let service = MockVMVirtualizationService()
await service.configure(shouldFail: true)
// Test start failure
do {
try await service.start()
#expect(Bool(false), "Expected start to throw")
} catch let error as VMError {
switch error {
case .internalError(let message):
#expect(message == "Mock operation failed")
default:
#expect(Bool(false), "Unexpected error type: \(error)")
}
}
#expect(await service.state == .stopped)
#expect(await service.startCallCount == 1)
}

View File

@@ -0,0 +1,86 @@
import Foundation
import Testing
@testable import lume
@Test("VNCService starts correctly")
func testVNCServiceStart() async throws {
let tempDir = try createTempDirectory()
let vmDir = VMDirectory(Path(tempDir.path))
let service = await MockVNCService(vmDirectory: vmDir)
// Initial state
let isRunning = await service.isRunning
let url = await service.url
#expect(!isRunning)
#expect(url == nil)
// Start service
try await service.start(port: 5900, virtualMachine: nil)
#expect(await service.isRunning)
#expect(await service.url?.contains("5900") ?? false)
}
@Test("VNCService stops correctly")
func testVNCServiceStop() async throws {
let tempDir = try createTempDirectory()
let vmDir = VMDirectory(Path(tempDir.path))
let service = await MockVNCService(vmDirectory: vmDir)
try await service.start(port: 5900, virtualMachine: nil)
await service.stop()
let isRunning = await service.isRunning
let url = await service.url
#expect(!isRunning)
#expect(url == nil)
}
@Test("VNCService handles client operations")
func testVNCServiceClient() async throws {
let tempDir = try createTempDirectory()
let vmDir = VMDirectory(Path(tempDir.path))
let service = await MockVNCService(vmDirectory: vmDir)
// Should fail when not started
do {
try await service.openClient(url: "vnc://localhost:5900")
#expect(Bool(false), "Expected openClient to throw when not started")
} catch VMError.vncNotConfigured {
// Expected error
} catch {
#expect(Bool(false), "Expected vncNotConfigured error but got \(error)")
}
// Start and try client operations
try await service.start(port: 5900, virtualMachine: nil)
try await service.openClient(url: "vnc://localhost:5900")
#expect(await service.clientOpenCount == 1)
// Stop and verify client operations fail
await service.stop()
do {
try await service.openClient(url: "vnc://localhost:5900")
#expect(Bool(false), "Expected openClient to throw after stopping")
} catch VMError.vncNotConfigured {
// Expected error
} catch {
#expect(Bool(false), "Expected vncNotConfigured error but got \(error)")
}
}
@Test("VNCService handles virtual machine attachment")
func testVNCServiceVMAttachment() async throws {
let tempDir = try createTempDirectory()
let vmDir = VMDirectory(Path(tempDir.path))
let service = await MockVNCService(vmDirectory: vmDir)
let mockVM = "mock_vm"
try await service.start(port: 5900, virtualMachine: mockVM)
let attachedVM = await service.attachedVM
#expect(attachedVM == mockVM)
}
private func createTempDirectory() throws -> URL {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
return tempDir
}