tstest/tailmac: add customized macOS virtualization tooling (#13146)

updates tailcale/corp#22371

Adds custom macOS vm tooling.  See the README for
the general gist, but this will spin up VMs with unixgram
capable network interfaces listening to a named socket,
and with a virtio socket device for host-guest communication.

We can add other devices like consoles, serial, etc as needed.

The whole things is buildable with a single make command, and
everything is controllable via the command line using the TailMac
utility.

This should all be generally functional but takes a few shortcuts
with error handling and the like.  The virtio socket device support
has not been tested and may require some refinement.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
This commit is contained in:
Jonathan Nobels
2024-08-19 15:01:19 -04:00
committed by GitHub
parent 1e8f8ee5f1
commit 8fad8c4b9b
29 changed files with 2954 additions and 0 deletions
+125
View File
@@ -0,0 +1,125 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import Foundation
let kDefaultDiskSizeGb: Int64 = 72
let kDefaultMemSizeGb: UInt64 = 72
/// Represents a configuration for a virtual machine
class Config: Codable {
var serverSocket = "/tmp/qemu-dgram.sock"
var memorySize = (kDefaultMemSizeGb * 1024 * 1024 * 1024) as UInt64
var mac = "52:cc:cc:cc:cc:01"
var ethermac = "52:cc:cc:cc:ce:01"
var port: UInt32 = 51009
// The virtual machines ID. Also double as the directory name under which
// we will store configuration, block device, etc.
let vmID: String
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
if let ethermac = try container.decodeIfPresent(String.self, forKey: .ethermac) {
self.ethermac = ethermac
}
if let serverSocket = try container.decodeIfPresent(String.self, forKey: .serverSocket) {
self.serverSocket = serverSocket
}
if let memorySize = try container.decodeIfPresent(UInt64.self, forKey: .memorySize) {
self.memorySize = memorySize
}
if let port = try container.decodeIfPresent(UInt32.self, forKey: .port) {
self.port = port
}
if let mac = try container.decodeIfPresent(String.self, forKey: .mac) {
self.mac = mac
}
if let vmID = try container.decodeIfPresent(String.self, forKey: .vmID) {
self.vmID = vmID
} else {
self.vmID = "default"
}
}
init(_ vmID: String = "default") {
self.vmID = vmID
let configFile = vmDataURL.appendingPathComponent("config.json")
if FileManager.default.fileExists(atPath: configFile.path()) {
print("Using config file at path \(configFile)")
if let jsonData = try? Data(contentsOf: configFile) {
let config = try! JSONDecoder().decode(Config.self, from: jsonData)
self.serverSocket = config.serverSocket
self.memorySize = config.memorySize
self.mac = config.mac
self.port = config.port
self.ethermac = config.ethermac
}
}
}
func persist() {
let configFile = vmDataURL.appendingPathComponent("config.json")
let data = try! JSONEncoder().encode(self)
try! data.write(to: configFile)
}
lazy var restoreImageURL: URL = {
vmBundleURL.appendingPathComponent("RestoreImage.ipsw")
}()
// The VM Data URL holds the specific files composing a unique VM guest instance
// By default, VM's are persisted at ~/VM.bundle/<vmID>
lazy var vmDataURL = {
let dataURL = vmBundleURL.appendingPathComponent(vmID)
return dataURL
}()
lazy var auxiliaryStorageURL = {
vmDataURL.appendingPathComponent("AuxiliaryStorage")
}()
lazy var diskImageURL = {
vmDataURL.appendingPathComponent("Disk.img")
}()
lazy var diskSize: Int64 = {
kDefaultDiskSizeGb * 1024 * 1024 * 1024
}()
lazy var hardwareModelURL = {
vmDataURL.appendingPathComponent("HardwareModel")
}()
lazy var machineIdentifierURL = {
vmDataURL.appendingPathComponent("MachineIdentifier")
}()
lazy var saveFileURL = {
vmDataURL.appendingPathComponent("SaveFile.vzvmsave")
}()
}
// The VM Bundle URL holds the restore image and a set of VM images
// By default, VM's are persisted at ~/VM.bundle
var vmBundleURL: URL = {
let vmBundlePath = NSHomeDirectory() + "/VM.bundle/"
createDir(vmBundlePath)
let bundleURL = URL(fileURLWithPath: vmBundlePath)
return bundleURL
}()
func createDir(_ path: String) {
do {
try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true)
} catch {
fatalError("Unable to create dir at \(path) \(error)")
}
}
@@ -0,0 +1,12 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import Foundation
struct Notifications {
// Stops the virtual machine and saves its state
static var stop = Notification.Name("io.tailscale.macvmhost.stop")
// Pauses the virtual machine and exits without saving its state
static var halt = Notification.Name("io.tailscale.macvmhost.halt")
}
@@ -0,0 +1,145 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import Foundation
import Virtualization
struct TailMacConfigHelper {
let config: Config
func computeCPUCount() -> Int {
let totalAvailableCPUs = ProcessInfo.processInfo.processorCount
var virtualCPUCount = totalAvailableCPUs <= 1 ? 1 : totalAvailableCPUs - 1
virtualCPUCount = max(virtualCPUCount, VZVirtualMachineConfiguration.minimumAllowedCPUCount)
virtualCPUCount = min(virtualCPUCount, VZVirtualMachineConfiguration.maximumAllowedCPUCount)
return virtualCPUCount
}
func computeMemorySize() -> UInt64 {
// Set the amount of system memory to 4 GB; this is a baseline value
// that you can change depending on your use case.
var memorySize = config.memorySize
memorySize = max(memorySize, VZVirtualMachineConfiguration.minimumAllowedMemorySize)
memorySize = min(memorySize, VZVirtualMachineConfiguration.maximumAllowedMemorySize)
return memorySize
}
func createBootLoader() -> VZMacOSBootLoader {
return VZMacOSBootLoader()
}
func createGraphicsDeviceConfiguration() -> VZMacGraphicsDeviceConfiguration {
let graphicsConfiguration = VZMacGraphicsDeviceConfiguration()
graphicsConfiguration.displays = [
// The system arbitrarily chooses the resolution of the display to be 1920 x 1200.
VZMacGraphicsDisplayConfiguration(widthInPixels: 1920, heightInPixels: 1200, pixelsPerInch: 80)
]
return graphicsConfiguration
}
func createBlockDeviceConfiguration() -> VZVirtioBlockDeviceConfiguration {
do {
let diskImageAttachment = try VZDiskImageStorageDeviceAttachment(url: config.diskImageURL, readOnly: false)
let disk = VZVirtioBlockDeviceConfiguration(attachment: diskImageAttachment)
return disk
} catch {
fatalError("Failed to create Disk image. \(error)")
}
}
func createSocketDeviceConfiguration() -> VZVirtioSocketDeviceConfiguration {
return VZVirtioSocketDeviceConfiguration()
}
func createNetworkDeviceConfiguration() -> VZVirtioNetworkDeviceConfiguration {
let networkDevice = VZVirtioNetworkDeviceConfiguration()
networkDevice.macAddress = VZMACAddress(string: config.ethermac)!
/* Bridged networking requires special entitlements from Apple
if let interface = VZBridgedNetworkInterface.networkInterfaces.first(where: { $0.identifier == "en0" }) {
let networkAttachment = VZBridgedNetworkDeviceAttachment(interface: interface)
networkDevice.attachment = networkAttachment
} else {
print("Assuming en0 for bridged ethernet. Could not findd adapter")
}*/
/// But we can do NAT without Tim Apple's approval
let networkAttachment = VZNATNetworkDeviceAttachment()
networkDevice.attachment = networkAttachment
return networkDevice
}
func createSocketNetworkDeviceConfiguration() -> VZVirtioNetworkDeviceConfiguration {
let networkDevice = VZVirtioNetworkDeviceConfiguration()
networkDevice.macAddress = VZMACAddress(string: config.mac)!
let socket = Darwin.socket(AF_UNIX, SOCK_DGRAM, 0)
// Outbound network packets
let serverSocket = config.serverSocket
// Inbound network packets
let clientSockId = config.vmID
let clientSocket = "/tmp/qemu-dgram-\(clientSockId).sock"
unlink(clientSocket)
var clientAddr = sockaddr_un()
clientAddr.sun_family = sa_family_t(AF_UNIX)
clientSocket.withCString { ptr in
withUnsafeMutablePointer(to: &clientAddr.sun_path.0) { dest in
_ = strcpy(dest, ptr)
}
}
let bindRes = Darwin.bind(socket,
withUnsafePointer(to: &clientAddr, { $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { $0 } }),
socklen_t(MemoryLayout<sockaddr_un>.size))
if bindRes == -1 {
print("Error binding virtual network client socket - \(String(cString: strerror(errno)))")
return networkDevice
}
var serverAddr = sockaddr_un()
serverAddr.sun_family = sa_family_t(AF_UNIX)
serverSocket.withCString { ptr in
withUnsafeMutablePointer(to: &serverAddr.sun_path.0) { dest in
_ = strcpy(dest, ptr)
}
}
let connectRes = Darwin.connect(socket,
withUnsafePointer(to: &serverAddr, { $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { $0 } }),
socklen_t(MemoryLayout<sockaddr_un>.size))
if connectRes == -1 {
print("Error binding virtual network server socket - \(String(cString: strerror(errno)))")
return networkDevice
}
print("Virtual if mac address is \(config.mac)")
print("Client bound to \(clientSocket)")
print("Connected to server at \(serverSocket)")
print("Socket fd is \(socket)")
let handle = FileHandle(fileDescriptor: socket)
let device = VZFileHandleNetworkDeviceAttachment(fileHandle: handle)
networkDevice.attachment = device
return networkDevice
}
func createPointingDeviceConfiguration() -> VZPointingDeviceConfiguration {
return VZMacTrackpadConfiguration()
}
func createKeyboardConfiguration() -> VZKeyboardConfiguration {
return VZMacKeyboardConfiguration()
}
}
@@ -0,0 +1,52 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import Cocoa
import Foundation
import Virtualization
class AppDelegate: NSObject, NSApplicationDelegate {
@IBOutlet var window: NSWindow!
@IBOutlet weak var virtualMachineView: VZVirtualMachineView!
var runner: VMController!
func applicationDidFinishLaunching(_ aNotification: Notification) {
DispatchQueue.main.async { [self] in
runner = VMController()
runner.createVirtualMachine()
virtualMachineView.virtualMachine = runner.virtualMachine
virtualMachineView.capturesSystemKeys = true
// Configure the app to automatically respond to changes in the display size.
virtualMachineView.automaticallyReconfiguresDisplay = true
let fileManager = FileManager.default
if fileManager.fileExists(atPath: config.saveFileURL.path) {
print("Restoring virtual machine state from \(config.saveFileURL)")
runner.restoreVirtualMachine()
} else {
print("Restarting virtual machine")
runner.startVirtualMachine()
}
}
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
return true
}
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
if runner.virtualMachine.state == .running {
runner.pauseAndSaveVirtualMachine(completionHandler: {
sender.reply(toApplicationShouldTerminate: true)
})
return .terminateLater
}
return .terminateNow
}
}
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,58 @@
{
"images" : [
{
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,696 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="32700.99.1234" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="22690"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<customObject id="-2" userLabel="File's Owner" customClass="NSApplication">
<connections>
<outlet property="delegate" destination="Voe-Tx-rLC" id="GzC-gU-4Uq"/>
</connections>
</customObject>
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
<customObject id="Voe-Tx-rLC" customClass="AppDelegate" customModule="vnetMacHost" customModuleProvider="target">
<connections>
<outlet property="virtualMachineView" destination="EiT-Mj-1SZ" id="KBI-Ak-yeW"/>
<outlet property="window" destination="QvC-M9-y7g" id="gIp-Ho-8D9"/>
</connections>
</customObject>
<customObject id="YLy-65-1bz" customClass="NSFontManager"/>
<menu title="Main Menu" systemMenu="main" id="AYu-sK-qS6">
<items>
<menuItem title="TailMac" id="1Xt-HY-uBw">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="TailMac" systemMenu="apple" id="uQy-DD-JDr">
<items>
<menuItem title="About TailMac" id="5kV-Vb-QxS">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="orderFrontStandardAboutPanel:" target="-1" id="Exp-CZ-Vem"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="VOq-y0-SEH"/>
<menuItem title="Preferences…" keyEquivalent="," id="BOF-NM-1cW"/>
<menuItem isSeparatorItem="YES" id="wFC-TO-SCJ"/>
<menuItem title="Services" id="NMo-om-nkz">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Services" systemMenu="services" id="hz9-B4-Xy5"/>
</menuItem>
<menuItem isSeparatorItem="YES" id="4je-JR-u6R"/>
<menuItem title="Hide TailMac" keyEquivalent="h" id="Olw-nP-bQN">
<connections>
<action selector="hide:" target="-1" id="PnN-Uc-m68"/>
</connections>
</menuItem>
<menuItem title="Hide Others" keyEquivalent="h" id="Vdr-fp-XzO">
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
<connections>
<action selector="hideOtherApplications:" target="-1" id="VT4-aY-XCT"/>
</connections>
</menuItem>
<menuItem title="Show All" id="Kd2-mp-pUS">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="unhideAllApplications:" target="-1" id="Dhg-Le-xox"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="kCx-OE-vgT"/>
<menuItem title="Save and quit TailMac" keyEquivalent="q" id="4sb-4s-VLi">
<connections>
<action selector="terminate:" target="-1" id="Te7-pn-YzF"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="File" id="dMs-cI-mzQ">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="File" id="bib-Uj-vzu">
<items>
<menuItem title="New" keyEquivalent="n" id="Was-JA-tGl">
<connections>
<action selector="newDocument:" target="-1" id="4Si-XN-c54"/>
</connections>
</menuItem>
<menuItem title="Open…" keyEquivalent="o" id="IAo-SY-fd9">
<connections>
<action selector="openDocument:" target="-1" id="bVn-NM-KNZ"/>
</connections>
</menuItem>
<menuItem title="Open Recent" id="tXI-mr-wws">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Open Recent" systemMenu="recentDocuments" id="oas-Oc-fiZ">
<items>
<menuItem title="Clear Menu" id="vNY-rz-j42">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="clearRecentDocuments:" target="-1" id="Daa-9d-B3U"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem isSeparatorItem="YES" id="m54-Is-iLE"/>
<menuItem title="Close" keyEquivalent="w" id="DVo-aG-piG">
<connections>
<action selector="performClose:" target="-1" id="HmO-Ls-i7Q"/>
</connections>
</menuItem>
<menuItem title="Save…" keyEquivalent="s" id="pxx-59-PXV">
<connections>
<action selector="saveDocument:" target="-1" id="teZ-XB-qJY"/>
</connections>
</menuItem>
<menuItem title="Save As…" keyEquivalent="S" id="Bw7-FT-i3A">
<connections>
<action selector="saveDocumentAs:" target="-1" id="mDf-zr-I0C"/>
</connections>
</menuItem>
<menuItem title="Revert to Saved" keyEquivalent="r" id="KaW-ft-85H">
<connections>
<action selector="revertDocumentToSaved:" target="-1" id="iJ3-Pv-kwq"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="aJh-i4-bef"/>
<menuItem title="Page Setup…" keyEquivalent="P" id="qIS-W8-SiK">
<modifierMask key="keyEquivalentModifierMask" shift="YES" command="YES"/>
<connections>
<action selector="runPageLayout:" target="-1" id="Din-rz-gC5"/>
</connections>
</menuItem>
<menuItem title="Print…" keyEquivalent="p" id="aTl-1u-JFS">
<connections>
<action selector="print:" target="-1" id="qaZ-4w-aoO"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Edit" id="5QF-Oa-p0T">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Edit" id="W48-6f-4Dl">
<items>
<menuItem title="Undo" keyEquivalent="z" id="dRJ-4n-Yzg">
<connections>
<action selector="undo:" target="-1" id="M6e-cu-g7V"/>
</connections>
</menuItem>
<menuItem title="Redo" keyEquivalent="Z" id="6dh-zS-Vam">
<connections>
<action selector="redo:" target="-1" id="oIA-Rs-6OD"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="WRV-NI-Exz"/>
<menuItem title="Cut" keyEquivalent="x" id="uRl-iY-unG">
<connections>
<action selector="cut:" target="-1" id="YJe-68-I9s"/>
</connections>
</menuItem>
<menuItem title="Copy" keyEquivalent="c" id="x3v-GG-iWU">
<connections>
<action selector="copy:" target="-1" id="G1f-GL-Joy"/>
</connections>
</menuItem>
<menuItem title="Paste" keyEquivalent="v" id="gVA-U4-sdL">
<connections>
<action selector="paste:" target="-1" id="UvS-8e-Qdg"/>
</connections>
</menuItem>
<menuItem title="Paste and Match Style" keyEquivalent="V" id="WeT-3V-zwk">
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
<connections>
<action selector="pasteAsPlainText:" target="-1" id="cEh-KX-wJQ"/>
</connections>
</menuItem>
<menuItem title="Delete" id="pa3-QI-u2k">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="delete:" target="-1" id="0Mk-Ml-PaM"/>
</connections>
</menuItem>
<menuItem title="Select All" keyEquivalent="a" id="Ruw-6m-B2m">
<connections>
<action selector="selectAll:" target="-1" id="VNm-Mi-diN"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="uyl-h8-XO2"/>
<menuItem title="Find" id="4EN-yA-p0u">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Find" id="1b7-l0-nxx">
<items>
<menuItem title="Find…" tag="1" keyEquivalent="f" id="Xz5-n4-O0W">
<connections>
<action selector="performFindPanelAction:" target="-1" id="cD7-Qs-BN4"/>
</connections>
</menuItem>
<menuItem title="Find and Replace…" tag="12" keyEquivalent="f" id="YEy-JH-Tfz">
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
<connections>
<action selector="performFindPanelAction:" target="-1" id="WD3-Gg-5AJ"/>
</connections>
</menuItem>
<menuItem title="Find Next" tag="2" keyEquivalent="g" id="q09-fT-Sye">
<connections>
<action selector="performFindPanelAction:" target="-1" id="NDo-RZ-v9R"/>
</connections>
</menuItem>
<menuItem title="Find Previous" tag="3" keyEquivalent="G" id="OwM-mh-QMV">
<connections>
<action selector="performFindPanelAction:" target="-1" id="HOh-sY-3ay"/>
</connections>
</menuItem>
<menuItem title="Use Selection for Find" tag="7" keyEquivalent="e" id="buJ-ug-pKt">
<connections>
<action selector="performFindPanelAction:" target="-1" id="U76-nv-p5D"/>
</connections>
</menuItem>
<menuItem title="Jump to Selection" keyEquivalent="j" id="S0p-oC-mLd">
<connections>
<action selector="centerSelectionInVisibleArea:" target="-1" id="IOG-6D-g5B"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Spelling and Grammar" id="Dv1-io-Yv7">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Spelling" id="3IN-sU-3Bg">
<items>
<menuItem title="Show Spelling and Grammar" keyEquivalent=":" id="HFo-cy-zxI">
<connections>
<action selector="showGuessPanel:" target="-1" id="vFj-Ks-hy3"/>
</connections>
</menuItem>
<menuItem title="Check Document Now" keyEquivalent=";" id="hz2-CU-CR7">
<connections>
<action selector="checkSpelling:" target="-1" id="fz7-VC-reM"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="bNw-od-mp5"/>
<menuItem title="Check Spelling While Typing" id="rbD-Rh-wIN">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleContinuousSpellChecking:" target="-1" id="7w6-Qz-0kB"/>
</connections>
</menuItem>
<menuItem title="Check Grammar With Spelling" id="mK6-2p-4JG">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleGrammarChecking:" target="-1" id="muD-Qn-j4w"/>
</connections>
</menuItem>
<menuItem title="Correct Spelling Automatically" id="78Y-hA-62v">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleAutomaticSpellingCorrection:" target="-1" id="2lM-Qi-WAP"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Substitutions" id="9ic-FL-obx">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Substitutions" id="FeM-D8-WVr">
<items>
<menuItem title="Show Substitutions" id="z6F-FW-3nz">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="orderFrontSubstitutionsPanel:" target="-1" id="oku-mr-iSq"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="gPx-C9-uUO"/>
<menuItem title="Smart Copy/Paste" id="9yt-4B-nSM">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleSmartInsertDelete:" target="-1" id="3IJ-Se-DZD"/>
</connections>
</menuItem>
<menuItem title="Smart Quotes" id="hQb-2v-fYv">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleAutomaticQuoteSubstitution:" target="-1" id="ptq-xd-QOA"/>
</connections>
</menuItem>
<menuItem title="Smart Dashes" id="rgM-f4-ycn">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleAutomaticDashSubstitution:" target="-1" id="oCt-pO-9gS"/>
</connections>
</menuItem>
<menuItem title="Smart Links" id="cwL-P1-jid">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleAutomaticLinkDetection:" target="-1" id="Gip-E3-Fov"/>
</connections>
</menuItem>
<menuItem title="Data Detectors" id="tRr-pd-1PS">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleAutomaticDataDetection:" target="-1" id="R1I-Nq-Kbl"/>
</connections>
</menuItem>
<menuItem title="Text Replacement" id="HFQ-gK-NFA">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleAutomaticTextReplacement:" target="-1" id="DvP-Fe-Py6"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Transformations" id="2oI-Rn-ZJC">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Transformations" id="c8a-y6-VQd">
<items>
<menuItem title="Make Upper Case" id="vmV-6d-7jI">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="uppercaseWord:" target="-1" id="sPh-Tk-edu"/>
</connections>
</menuItem>
<menuItem title="Make Lower Case" id="d9M-CD-aMd">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="lowercaseWord:" target="-1" id="iUZ-b5-hil"/>
</connections>
</menuItem>
<menuItem title="Capitalize" id="UEZ-Bs-lqG">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="capitalizeWord:" target="-1" id="26H-TL-nsh"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Speech" id="xrE-MZ-jX0">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Speech" id="3rS-ZA-NoH">
<items>
<menuItem title="Start Speaking" id="Ynk-f8-cLZ">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="startSpeaking:" target="-1" id="654-Ng-kyl"/>
</connections>
</menuItem>
<menuItem title="Stop Speaking" id="Oyz-dy-DGm">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="stopSpeaking:" target="-1" id="dX8-6p-jy9"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Format" id="jxT-CU-nIS">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Format" id="GEO-Iw-cKr">
<items>
<menuItem title="Font" id="Gi5-1S-RQB">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Font" systemMenu="font" id="aXa-aM-Jaq">
<items>
<menuItem title="Show Fonts" keyEquivalent="t" id="Q5e-8K-NDq">
<connections>
<action selector="orderFrontFontPanel:" target="YLy-65-1bz" id="WHr-nq-2xA"/>
</connections>
</menuItem>
<menuItem title="Bold" tag="2" keyEquivalent="b" id="GB9-OM-e27">
<connections>
<action selector="addFontTrait:" target="YLy-65-1bz" id="hqk-hr-sYV"/>
</connections>
</menuItem>
<menuItem title="Italic" tag="1" keyEquivalent="i" id="Vjx-xi-njq">
<connections>
<action selector="addFontTrait:" target="YLy-65-1bz" id="IHV-OB-c03"/>
</connections>
</menuItem>
<menuItem title="Underline" keyEquivalent="u" id="WRG-CD-K1S">
<connections>
<action selector="underline:" target="-1" id="FYS-2b-JAY"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="5gT-KC-WSO"/>
<menuItem title="Bigger" tag="3" keyEquivalent="+" id="Ptp-SP-VEL">
<connections>
<action selector="modifyFont:" target="YLy-65-1bz" id="Uc7-di-UnL"/>
</connections>
</menuItem>
<menuItem title="Smaller" tag="4" keyEquivalent="-" id="i1d-Er-qST">
<connections>
<action selector="modifyFont:" target="YLy-65-1bz" id="HcX-Lf-eNd"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="kx3-Dk-x3B"/>
<menuItem title="Kern" id="jBQ-r6-VK2">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Kern" id="tlD-Oa-oAM">
<items>
<menuItem title="Use Default" id="GUa-eO-cwY">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="useStandardKerning:" target="-1" id="6dk-9l-Ckg"/>
</connections>
</menuItem>
<menuItem title="Use None" id="cDB-IK-hbR">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="turnOffKerning:" target="-1" id="U8a-gz-Maa"/>
</connections>
</menuItem>
<menuItem title="Tighten" id="46P-cB-AYj">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="tightenKerning:" target="-1" id="hr7-Nz-8ro"/>
</connections>
</menuItem>
<menuItem title="Loosen" id="ogc-rX-tC1">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="loosenKerning:" target="-1" id="8i4-f9-FKE"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Ligatures" id="o6e-r0-MWq">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Ligatures" id="w0m-vy-SC9">
<items>
<menuItem title="Use Default" id="agt-UL-0e3">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="useStandardLigatures:" target="-1" id="7uR-wd-Dx6"/>
</connections>
</menuItem>
<menuItem title="Use None" id="J7y-lM-qPV">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="turnOffLigatures:" target="-1" id="iX2-gA-Ilz"/>
</connections>
</menuItem>
<menuItem title="Use All" id="xQD-1f-W4t">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="useAllLigatures:" target="-1" id="KcB-kA-TuK"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Baseline" id="OaQ-X3-Vso">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Baseline" id="ijk-EB-dga">
<items>
<menuItem title="Use Default" id="3Om-Ey-2VK">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="unscript:" target="-1" id="0vZ-95-Ywn"/>
</connections>
</menuItem>
<menuItem title="Superscript" id="Rqc-34-cIF">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="superscript:" target="-1" id="3qV-fo-wpU"/>
</connections>
</menuItem>
<menuItem title="Subscript" id="I0S-gh-46l">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="subscript:" target="-1" id="Q6W-4W-IGz"/>
</connections>
</menuItem>
<menuItem title="Raise" id="2h7-ER-AoG">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="raiseBaseline:" target="-1" id="4sk-31-7Q9"/>
</connections>
</menuItem>
<menuItem title="Lower" id="1tx-W0-xDw">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="lowerBaseline:" target="-1" id="OF1-bc-KW4"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem isSeparatorItem="YES" id="Ndw-q3-faq"/>
<menuItem title="Show Colors" keyEquivalent="C" id="bgn-CT-cEk">
<connections>
<action selector="orderFrontColorPanel:" target="-1" id="mSX-Xz-DV3"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="iMs-zA-UFJ"/>
<menuItem title="Copy Style" keyEquivalent="c" id="5Vv-lz-BsD">
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
<connections>
<action selector="copyFont:" target="-1" id="GJO-xA-L4q"/>
</connections>
</menuItem>
<menuItem title="Paste Style" keyEquivalent="v" id="vKC-jM-MkH">
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
<connections>
<action selector="pasteFont:" target="-1" id="JfD-CL-leO"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Text" id="Fal-I4-PZk">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Text" id="d9c-me-L2H">
<items>
<menuItem title="Align Left" keyEquivalent="{" id="ZM1-6Q-yy1">
<connections>
<action selector="alignLeft:" target="-1" id="zUv-R1-uAa"/>
</connections>
</menuItem>
<menuItem title="Center" keyEquivalent="|" id="VIY-Ag-zcb">
<connections>
<action selector="alignCenter:" target="-1" id="spX-mk-kcS"/>
</connections>
</menuItem>
<menuItem title="Justify" id="J5U-5w-g23">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="alignJustified:" target="-1" id="ljL-7U-jND"/>
</connections>
</menuItem>
<menuItem title="Align Right" keyEquivalent="}" id="wb2-vD-lq4">
<connections>
<action selector="alignRight:" target="-1" id="r48-bG-YeY"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="4s2-GY-VfK"/>
<menuItem title="Writing Direction" id="H1b-Si-o9J">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Writing Direction" id="8mr-sm-Yjd">
<items>
<menuItem title="Paragraph" enabled="NO" id="ZvO-Gk-QUH">
<modifierMask key="keyEquivalentModifierMask"/>
</menuItem>
<menuItem id="YGs-j5-SAR">
<string key="title"> Default</string>
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="makeBaseWritingDirectionNatural:" target="-1" id="qtV-5e-UBP"/>
</connections>
</menuItem>
<menuItem id="Lbh-J2-qVU">
<string key="title"> Left to Right</string>
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="makeBaseWritingDirectionLeftToRight:" target="-1" id="S0X-9S-QSf"/>
</connections>
</menuItem>
<menuItem id="jFq-tB-4Kx">
<string key="title"> Right to Left</string>
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="makeBaseWritingDirectionRightToLeft:" target="-1" id="5fk-qB-AqJ"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="swp-gr-a21"/>
<menuItem title="Selection" enabled="NO" id="cqv-fj-IhA">
<modifierMask key="keyEquivalentModifierMask"/>
</menuItem>
<menuItem id="Nop-cj-93Q">
<string key="title"> Default</string>
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="makeTextWritingDirectionNatural:" target="-1" id="lPI-Se-ZHp"/>
</connections>
</menuItem>
<menuItem id="BgM-ve-c93">
<string key="title"> Left to Right</string>
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="makeTextWritingDirectionLeftToRight:" target="-1" id="caW-Bv-w94"/>
</connections>
</menuItem>
<menuItem id="RB4-Sm-HuC">
<string key="title"> Right to Left</string>
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="makeTextWritingDirectionRightToLeft:" target="-1" id="EXD-6r-ZUu"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem isSeparatorItem="YES" id="fKy-g9-1gm"/>
<menuItem title="Show Ruler" id="vLm-3I-IUL">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleRuler:" target="-1" id="FOx-HJ-KwY"/>
</connections>
</menuItem>
<menuItem title="Copy Ruler" keyEquivalent="c" id="MkV-Pr-PK5">
<modifierMask key="keyEquivalentModifierMask" control="YES" command="YES"/>
<connections>
<action selector="copyRuler:" target="-1" id="71i-fW-3W2"/>
</connections>
</menuItem>
<menuItem title="Paste Ruler" keyEquivalent="v" id="LVM-kO-fVI">
<modifierMask key="keyEquivalentModifierMask" control="YES" command="YES"/>
<connections>
<action selector="pasteRuler:" target="-1" id="cSh-wd-qM2"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="View" id="H8h-7b-M4v">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="View" id="HyV-fh-RgO">
<items>
<menuItem title="Show Toolbar" keyEquivalent="t" id="snW-S8-Cw5">
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
<connections>
<action selector="toggleToolbarShown:" target="-1" id="BXY-wc-z0C"/>
</connections>
</menuItem>
<menuItem title="Customize Toolbar…" id="1UK-8n-QPP">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="runToolbarCustomizationPalette:" target="-1" id="pQI-g3-MTW"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="hB3-LF-h0Y"/>
<menuItem title="Show Sidebar" keyEquivalent="s" id="kIP-vf-haE">
<modifierMask key="keyEquivalentModifierMask" control="YES" command="YES"/>
<connections>
<action selector="toggleSidebar:" target="-1" id="iwa-gc-5KM"/>
</connections>
</menuItem>
<menuItem title="Enter Full Screen" keyEquivalent="f" id="4J7-dP-txa">
<modifierMask key="keyEquivalentModifierMask" control="YES" command="YES"/>
<connections>
<action selector="toggleFullScreen:" target="-1" id="dU3-MA-1Rq"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Window" id="aUF-d1-5bR">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Window" systemMenu="window" id="Td7-aD-5lo">
<items>
<menuItem title="Minimize" keyEquivalent="m" id="OY7-WF-poV">
<connections>
<action selector="performMiniaturize:" target="-1" id="VwT-WD-YPe"/>
</connections>
</menuItem>
<menuItem title="Zoom" id="R4o-n2-Eq4">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="performZoom:" target="-1" id="DIl-cC-cCs"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="eu3-7i-yIM"/>
<menuItem title="Bring All to Front" id="LE2-aR-0XJ">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="arrangeInFront:" target="-1" id="DRN-fu-gQh"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Help" id="wpr-3q-Mcd">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Help" systemMenu="help" id="F2S-fz-NVQ">
<items>
<menuItem title="TailMac Help" keyEquivalent="?" id="FKE-Sm-Kum">
<connections>
<action selector="showHelp:" target="-1" id="y7X-2Q-9no"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
</items>
<point key="canvasLocation" x="200" y="121"/>
</menu>
<window title="TailMac" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" animationBehavior="default" id="QvC-M9-y7g">
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
<windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
<rect key="contentRect" x="335" y="390" width="960" height="600"/>
<rect key="screenRect" x="0.0" y="0.0" width="3840" height="2135"/>
<view key="contentView" id="EiT-Mj-1SZ" customClass="VZVirtualMachineView">
<rect key="frame" x="0.0" y="0.0" width="960" height="600"/>
<autoresizingMask key="autoresizingMask"/>
</view>
<point key="canvasLocation" x="200" y="400"/>
</window>
</objects>
</document>
+30
View File
@@ -0,0 +1,30 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import Cocoa
import Foundation
import Virtualization
import ArgumentParser
@main
struct HostCli: ParsableCommand {
static var configuration = CommandConfiguration(
abstract: "A utility for running virtual machines",
subcommands: [Run.self],
defaultSubcommand: Run.self)
}
var config: Config = Config()
extension HostCli {
struct Run: ParsableCommand {
@Option var id: String
mutating func run() {
print("Running vm with identifier \(id)")
config = Config(id)
_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv)
}
}
}
+5
View File
@@ -0,0 +1,5 @@
<?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/>
</plist>
@@ -0,0 +1,179 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import Cocoa
import Foundation
import Virtualization
import Foundation
class VMController: NSObject, VZVirtualMachineDelegate {
var virtualMachine: VZVirtualMachine!
lazy var helper = TailMacConfigHelper(config: config)
override init() {
super.init()
listenForNotifications()
}
func listenForNotifications() {
let nc = DistributedNotificationCenter()
nc.addObserver(forName: Notifications.stop, object: nil, queue: nil) { notification in
if let vmID = notification.userInfo?["id"] as? String {
if config.vmID == vmID {
print("We've been asked to stop... Saving state and exiting")
self.pauseAndSaveVirtualMachine {
exit(0)
}
}
}
}
nc.addObserver(forName: Notifications.halt, object: nil, queue: nil) { notification in
if let vmID = notification.userInfo?["id"] as? String {
if config.vmID == vmID {
print("We've been asked to stop... Saving state and exiting")
self.virtualMachine.pause { (result) in
if case let .failure(error) = result {
fatalError("Virtual machine failed to pause with \(error)")
}
exit(0)
}
}
}
}
}
func createMacPlaform() -> VZMacPlatformConfiguration {
let macPlatform = VZMacPlatformConfiguration()
let auxiliaryStorage = VZMacAuxiliaryStorage(contentsOf: config.auxiliaryStorageURL)
macPlatform.auxiliaryStorage = auxiliaryStorage
if !FileManager.default.fileExists(atPath: config.vmDataURL.path()) {
fatalError("Missing Virtual Machine Bundle at \(config.vmDataURL). Run InstallationTool first to create it.")
}
// Retrieve the hardware model and save this value to disk during installation.
guard let hardwareModelData = try? Data(contentsOf: config.hardwareModelURL) else {
fatalError("Failed to retrieve hardware model data.")
}
guard let hardwareModel = VZMacHardwareModel(dataRepresentation: hardwareModelData) else {
fatalError("Failed to create hardware model.")
}
if !hardwareModel.isSupported {
fatalError("The hardware model isn't supported on the current host")
}
macPlatform.hardwareModel = hardwareModel
// Retrieve the machine identifier and save this value to disk during installation.
guard let machineIdentifierData = try? Data(contentsOf: config.machineIdentifierURL) else {
fatalError("Failed to retrieve machine identifier data.")
}
guard let machineIdentifier = VZMacMachineIdentifier(dataRepresentation: machineIdentifierData) else {
fatalError("Failed to create machine identifier.")
}
macPlatform.machineIdentifier = machineIdentifier
return macPlatform
}
func createVirtualMachine() {
let virtualMachineConfiguration = VZVirtualMachineConfiguration()
virtualMachineConfiguration.platform = createMacPlaform()
virtualMachineConfiguration.bootLoader = helper.createBootLoader()
virtualMachineConfiguration.cpuCount = helper.computeCPUCount()
virtualMachineConfiguration.memorySize = helper.computeMemorySize()
virtualMachineConfiguration.graphicsDevices = [helper.createGraphicsDeviceConfiguration()]
virtualMachineConfiguration.storageDevices = [helper.createBlockDeviceConfiguration()]
virtualMachineConfiguration.networkDevices = [helper.createNetworkDeviceConfiguration(), helper.createSocketNetworkDeviceConfiguration()]
virtualMachineConfiguration.pointingDevices = [helper.createPointingDeviceConfiguration()]
virtualMachineConfiguration.keyboards = [helper.createKeyboardConfiguration()]
virtualMachineConfiguration.socketDevices = [helper.createSocketDeviceConfiguration()]
try! virtualMachineConfiguration.validate()
try! virtualMachineConfiguration.validateSaveRestoreSupport()
virtualMachine = VZVirtualMachine(configuration: virtualMachineConfiguration)
virtualMachine.delegate = self
}
func startVirtualMachine() {
virtualMachine.start(completionHandler: { (result) in
if case let .failure(error) = result {
fatalError("Virtual machine failed to start with \(error)")
}
self.startSocketDevice()
})
}
func startSocketDevice() {
if let device = virtualMachine.socketDevices.first as? VZVirtioSocketDevice {
print("Configuring socket device at port \(config.port)")
device.connect(toPort: config.port) { connection in
//TODO: Anything? Or is this enough to bootstrap it on both ends?
}
} else {
print("Virtual machine could not start it's socket device")
}
}
func resumeVirtualMachine() {
virtualMachine.resume(completionHandler: { (result) in
if case let .failure(error) = result {
fatalError("Virtual machine failed to resume with \(error)")
}
})
}
func restoreVirtualMachine() {
virtualMachine.restoreMachineStateFrom(url: config.saveFileURL, completionHandler: { [self] (error) in
// Remove the saved file. Whether success or failure, the state no longer matches the VM's disk.
let fileManager = FileManager.default
try! fileManager.removeItem(at: config.saveFileURL)
if error == nil {
self.resumeVirtualMachine()
} else {
self.startVirtualMachine()
}
})
}
func saveVirtualMachine(completionHandler: @escaping () -> Void) {
virtualMachine.saveMachineStateTo(url: config.saveFileURL, completionHandler: { (error) in
guard error == nil else {
fatalError("Virtual machine failed to save with \(error!)")
}
completionHandler()
})
}
func pauseAndSaveVirtualMachine(completionHandler: @escaping () -> Void) {
virtualMachine.pause { result in
if case let .failure(error) = result {
fatalError("Virtual machine failed to pause with \(error)")
}
self.saveVirtualMachine(completionHandler: completionHandler)
}
}
// MARK: - VZVirtualMachineDeleate
func virtualMachine(_ virtualMachine: VZVirtualMachine, didStopWithError error: Error) {
print("Virtual machine did stop with error: \(error.localizedDescription)")
exit(-1)
}
func guestDidStop(_ virtualMachine: VZVirtualMachine) {
print("Guest did stop virtual machine.")
exit(0)
}
}
@@ -0,0 +1,58 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import Foundation
import Virtualization
class RestoreImage: NSObject {
private var downloadObserver: NSKeyValueObservation?
// MARK: Observe the download progress.
var restoreImageURL: URL
init(_ dest: URL) {
restoreImageURL = dest
}
public func download(completionHandler: @escaping () -> Void) {
print("Attempting to download latest available restore image.")
VZMacOSRestoreImage.fetchLatestSupported { [self](result: Result<VZMacOSRestoreImage, Error>) in
switch result {
case let .failure(error):
fatalError(error.localizedDescription)
case let .success(restoreImage):
downloadRestoreImage(restoreImage: restoreImage, completionHandler: completionHandler)
}
}
}
private func downloadRestoreImage(restoreImage: VZMacOSRestoreImage, completionHandler: @escaping () -> Void) {
let downloadTask = URLSession.shared.downloadTask(with: restoreImage.url) { localURL, response, error in
if let error = error {
fatalError("Download failed. \(error.localizedDescription).")
}
do {
try FileManager.default.moveItem(at: localURL!, to: self.restoreImageURL)
} catch {
fatalError("Failed to move downloaded restore image to \(self.restoreImageURL) \(error).")
}
completionHandler()
}
var lastPct = 0
downloadObserver = downloadTask.progress.observe(\.fractionCompleted, options: [.initial, .new]) { (progress, change) in
let pct = Int(change.newValue! * 100)
if pct != lastPct {
print("Restore image download progress: \(pct)%")
lastPct = pct
}
}
downloadTask.resume()
}
}
+334
View File
@@ -0,0 +1,334 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import Foundation
import Virtualization
import ArgumentParser
var usage =
"""
Installs and configures VMs suitable for use with natlab
To create a new VM (this will grab a restore image if needed)
tailmac create --id <vm_id>
To refresh an existing restore image:
tailmac refresh
To clone a vm (this will clone the mac and port as well)
tailmac clone --identfier <old_vm_id> --target-id <new_vm_id>
To reconfigure a vm:
tailmac configure --id <vm_id> --mac 11:22:33:44:55:66 --port 12345 --mem 8000000000000 -sock "/tmp/mySock.sock"
To run a vm:
tailmac run --id <vm_id>
To stop a vm: (this may take a minute - the vm needs to persist it's state)
tailmac stop --id <vm_id>
To halt a vm without persisting its state
tailmac halt --id <vm_id>
To delete a vm:
tailmac delete --id <vm_id>
To list the available VM images:
tailmac ls
"""
@main
struct Tailmac: ParsableCommand {
static var configuration = CommandConfiguration(
abstract: "A utility for setting up VM images",
usage: usage,
subcommands: [Create.self, Clone.self, Delete.self, Configure.self, Stop.self, Run.self, Ls.self, Halt.self],
defaultSubcommand: Ls.self)
}
extension Tailmac {
struct Ls: ParsableCommand {
mutating func run() {
do {
let dirs = try FileManager.default.contentsOfDirectory(atPath: vmBundleURL.path())
var images = [String]()
// This assumes we don't put anything else interesting in our VM.bundle dir
// You may need to add some other exclusions or checks here if that's the case.
for dir in dirs {
if !dir.contains("ipsw") {
images.append(URL(fileURLWithPath: dir).lastPathComponent)
}
}
print("Available images:\n\(images)")
} catch {
fatalError("Failed to query available images \(error)")
}
}
}
}
extension Tailmac {
struct Stop: ParsableCommand {
@Option(help: "The vm identifier") var id: String
mutating func run() {
print("Stopping vm with id \(id). This may take some time!")
let nc = DistributedNotificationCenter()
nc.post(name: Notifications.stop, object: nil, userInfo: ["id": id])
}
}
}
extension Tailmac {
struct Halt: ParsableCommand {
@Option(help: "The vm identifier") var id: String
mutating func run() {
print("Halting vm with id \(id)")
let nc = DistributedNotificationCenter()
nc.post(name: Notifications.halt, object: nil, userInfo: ["id": id])
}
}
}
extension Tailmac {
struct Run: ParsableCommand {
@Option(help: "The vm identifier") var id: String
@Flag(help: "Tail the TailMac log output instead of returning immediatly") var tail
mutating func run() {
let process = Process()
let stdOutPipe = Pipe()
let appPath = "./Host.app/Contents/MacOS/Host"
process.executableURL = URL(
fileURLWithPath: appPath,
isDirectory: false,
relativeTo: NSRunningApplication.current.bundleURL
)
if !FileManager.default.fileExists(atPath: appPath) {
fatalError("Could not find Host.app. This must be co-located with the tailmac utility")
}
process.arguments = ["run", "--id", id]
do {
process.standardOutput = stdOutPipe
try process.run()
} catch {
fatalError("Unable to launch the vm process")
}
// This doesn't print until we exit which is not ideal, but at least we
// get the output
if tail != 0 {
let outHandle = stdOutPipe.fileHandleForReading
let queue = OperationQueue()
NotificationCenter.default.addObserver(
forName: NSNotification.Name.NSFileHandleDataAvailable,
object: outHandle, queue: queue)
{
notification -> Void in
let data = outHandle.availableData
if data.count > 0 {
if let str = String(data: data, encoding: String.Encoding.utf8) {
print(str)
}
}
outHandle.waitForDataInBackgroundAndNotify()
}
outHandle.waitForDataInBackgroundAndNotify()
process.waitUntilExit()
}
}
}
}
extension Tailmac {
struct Configure: ParsableCommand {
@Option(help: "The vm identifier") var id: String
@Option(help: "The mac address of the socket network interface") var mac: String?
@Option(help: "The port for the virtio socket device") var port: String?
@Option(help: "The named socket for the socket network interface") var sock: String?
@Option(help: "The desired RAM in bytes") var mem: String?
@Option(help: "The ethernet address for a standard NAT adapter") var ethermac: String?
mutating func run() {
let config = Config(id)
let vmExists = FileManager.default.fileExists(atPath: config.vmDataURL.path())
if !vmExists {
print("VM with id \(id) doesn't exist. Cannot configure.")
return
}
if let mac {
config.mac = mac
}
if let port, let portInt = UInt32(port) {
config.port = portInt
}
if let ethermac {
config.ethermac = ethermac
}
if let mem, let membytes = UInt64(mem) {
config.memorySize = membytes
}
if let sock {
config.serverSocket = sock
}
config.persist()
let str = String(data:try! JSONEncoder().encode(config), encoding: .utf8)!
print("New Config: \(str)")
}
}
}
extension Tailmac {
struct Delete: ParsableCommand {
@Option(help: "The vm identifer") var id: String?
mutating func run() {
guard let id else {
print("Usage: Installer delete --id=<id>")
return
}
let config = Config(id)
let vmExists = FileManager.default.fileExists(atPath: config.vmDataURL.path())
if !vmExists {
print("VM with id \(id) doesn't exist. Cannot delete.")
return
}
do {
try FileManager.default.removeItem(at: config.vmDataURL)
} catch {
print("Whoops... Deletion failed \(error)")
}
}
}
}
extension Tailmac {
struct Clone: ParsableCommand {
@Option(help: "The vm identifier") var id: String
@Option(help: "The vm identifier for the cloned vm") var targetId: String
mutating func run() {
let config = Config(id)
let targetConfig = Config(targetId)
if id == targetId {
fatalError("The ids match. Clone failed.")
}
let vmExists = FileManager.default.fileExists(atPath: config.vmDataURL.path())
if !vmExists {
print("VM with id \(id) doesn't exist. Cannot clone.")
return
}
print("Cloning \(config.vmDataURL) to \(targetConfig.vmDataURL)")
do {
try FileManager.default.copyItem(at: config.vmDataURL, to: targetConfig.vmDataURL)
} catch {
print("Whoops... Cloning failed \(error)")
}
}
}
}
extension Tailmac {
struct RefreshImage: ParsableCommand {
mutating func run() {
let config = Config()
let exists = FileManager.default.fileExists(atPath: config.restoreImageURL.path())
if exists {
try? FileManager.default.removeItem(at: config.restoreImageURL)
}
let restoreImage = RestoreImage(config.restoreImageURL)
restoreImage.download {
print("Restore image refreshed")
}
}
}
}
extension Tailmac {
struct Create: ParsableCommand {
@Option(help: "The vm identifier. Each VM instance needs a unique ID.") var id: String
@Option(help: "The mac address of the socket network interface") var mac: String?
@Option(help: "The port for the virtio socket device") var port: String?
@Option(help: "The named socket for the socket network interface") var sock: String?
@Option(help: "The desired RAM in bytes") var mem: String?
@Option(help: "The ethernet address for a standard NAT adapter") var ethermac: String?
@Option(help: "The image name to build from. If omitted we will use RestoreImage.ipsw in ~/VM.bundle and download it if needed") var image: String?
mutating func run() {
buildVM(id)
}
func buildVM(_ id: String) {
print("Configuring vm with id \(id)")
let config = Config(id)
let installer = VMInstaller(config)
let vmExists = FileManager.default.fileExists(atPath: config.vmDataURL.path())
if vmExists {
print("VM with id \(id) already exists. No action taken.")
return
}
createDir(config.vmDataURL.path())
if let mac {
config.mac = mac
}
if let port, let portInt = UInt32(port) {
config.port = portInt
}
if let ethermac {
config.ethermac = ethermac
}
if let mem, let membytes = UInt64(mem) {
config.memorySize = membytes
}
if let sock {
config.serverSocket = sock
}
config.persist()
let restoreImagePath = image ?? config.restoreImageURL.path()
let exists = FileManager.default.fileExists(atPath: restoreImagePath)
if exists {
print("Using existing restore image at \(restoreImagePath)")
installer.installMacOS(ipswURL: URL(fileURLWithPath: restoreImagePath))
} else {
if image != nil {
fatalError("Unable to find custom restore image")
}
print("Downloading default restore image to \(config.restoreImageURL)")
let restoreImage = RestoreImage(URL(fileURLWithPath: restoreImagePath))
restoreImage.download {
// Install from the restore image that you downloaded.
installer.installMacOS(ipswURL: URL(fileURLWithPath: restoreImagePath))
}
}
dispatchMain()
}
}
}
@@ -0,0 +1,140 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import Foundation
import Virtualization
class VMInstaller: NSObject {
private var installationObserver: NSKeyValueObservation?
private var virtualMachine: VZVirtualMachine!
private var config: Config
private var helper: TailMacConfigHelper
init(_ config: Config) {
self.config = config
helper = TailMacConfigHelper(config: config)
}
public func installMacOS(ipswURL: URL) {
print("Attempting to install from IPSW at \(ipswURL).")
VZMacOSRestoreImage.load(from: ipswURL, completionHandler: { [self](result: Result<VZMacOSRestoreImage, Error>) in
switch result {
case let .failure(error):
fatalError(error.localizedDescription)
case let .success(restoreImage):
installMacOS(restoreImage: restoreImage)
}
})
}
// MARK: - Internal helper functions.
private func installMacOS(restoreImage: VZMacOSRestoreImage) {
guard let macOSConfiguration = restoreImage.mostFeaturefulSupportedConfiguration else {
fatalError("No supported configuration available.")
}
if !macOSConfiguration.hardwareModel.isSupported {
fatalError("macOSConfiguration configuration isn't supported on the current host.")
}
DispatchQueue.main.async { [self] in
setupVirtualMachine(macOSConfiguration: macOSConfiguration)
startInstallation(restoreImageURL: restoreImage.url)
}
}
// MARK: Create the Mac platform configuration.
private func createMacPlatformConfiguration(macOSConfiguration: VZMacOSConfigurationRequirements) -> VZMacPlatformConfiguration {
let macPlatformConfiguration = VZMacPlatformConfiguration()
let auxiliaryStorage: VZMacAuxiliaryStorage
do {
auxiliaryStorage = try VZMacAuxiliaryStorage(creatingStorageAt: config.auxiliaryStorageURL,
hardwareModel: macOSConfiguration.hardwareModel,
options: [])
} catch {
fatalError("Unable to create aux storage at \(config.auxiliaryStorageURL) \(error)")
}
macPlatformConfiguration.auxiliaryStorage = auxiliaryStorage
macPlatformConfiguration.hardwareModel = macOSConfiguration.hardwareModel
macPlatformConfiguration.machineIdentifier = VZMacMachineIdentifier()
// Store the hardware model and machine identifier to disk so that you
// can retrieve them for subsequent boots.
try! macPlatformConfiguration.hardwareModel.dataRepresentation.write(to: config.hardwareModelURL)
try! macPlatformConfiguration.machineIdentifier.dataRepresentation.write(to: config.machineIdentifierURL)
return macPlatformConfiguration
}
private func setupVirtualMachine(macOSConfiguration: VZMacOSConfigurationRequirements) {
let virtualMachineConfiguration = VZVirtualMachineConfiguration()
virtualMachineConfiguration.platform = createMacPlatformConfiguration(macOSConfiguration: macOSConfiguration)
virtualMachineConfiguration.cpuCount = helper.computeCPUCount()
if virtualMachineConfiguration.cpuCount < macOSConfiguration.minimumSupportedCPUCount {
fatalError("CPUCount isn't supported by the macOS configuration.")
}
virtualMachineConfiguration.memorySize = helper.computeMemorySize()
if virtualMachineConfiguration.memorySize < macOSConfiguration.minimumSupportedMemorySize {
fatalError("memorySize isn't supported by the macOS configuration.")
}
createDiskImage()
virtualMachineConfiguration.bootLoader = helper.createBootLoader()
virtualMachineConfiguration.graphicsDevices = [helper.createGraphicsDeviceConfiguration()]
virtualMachineConfiguration.storageDevices = [helper.createBlockDeviceConfiguration()]
virtualMachineConfiguration.networkDevices = [helper.createNetworkDeviceConfiguration(), helper.createSocketNetworkDeviceConfiguration()]
virtualMachineConfiguration.pointingDevices = [helper.createPointingDeviceConfiguration()]
virtualMachineConfiguration.keyboards = [helper.createKeyboardConfiguration()]
try! virtualMachineConfiguration.validate()
try! virtualMachineConfiguration.validateSaveRestoreSupport()
virtualMachine = VZVirtualMachine(configuration: virtualMachineConfiguration)
}
private func startInstallation(restoreImageURL: URL) {
let installer = VZMacOSInstaller(virtualMachine: virtualMachine, restoringFromImageAt: restoreImageURL)
print("Starting installation.")
installer.install(completionHandler: { (result: Result<Void, Error>) in
if case let .failure(error) = result {
fatalError(error.localizedDescription)
} else {
print("Installation succeeded.")
}
})
// Observe installation progress.
installationObserver = installer.progress.observe(\.fractionCompleted, options: [.initial, .new]) { (progress, change) in
print("Installation progress: \(change.newValue! * 100).")
}
}
// Create an empty disk image for the virtual machine.
private func createDiskImage() {
let diskFd = open(config.diskImageURL.path, O_RDWR | O_CREAT, S_IRUSR | S_IWUSR)
if diskFd == -1 {
fatalError("Cannot create disk image.")
}
// 72 GB disk space.
var result = ftruncate(diskFd, config.diskSize)
if result != 0 {
fatalError("ftruncate() failed.")
}
result = close(diskFd)
if result != 0 {
fatalError("Failed to close the disk image.")
}
}
}
BIN
View File
Binary file not shown.