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:
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
BIN
Binary file not shown.
Reference in New Issue
Block a user