mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-21 09:42:13 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b83bce4544 | ||
|
|
7d16516b6a | ||
|
|
56ddd8df75 | ||
|
|
b1534a05d5 | ||
|
|
f54e7c2cab | ||
|
|
85dfa961c9 | ||
|
|
3a74fc35e6 | ||
|
|
7bf7f890c4 | ||
|
|
48cd4b47e4 | ||
|
|
c1dee4f9b2 | ||
|
|
116dc01f55 | ||
|
|
52abb7589c |
@@ -55,6 +55,31 @@ config from above will just work in Cirrus CI and your tasks will be executed in
|
|||||||
|
|
||||||
**Note:** Cirrus CI only allows [images managed and regularly updated by us](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos).
|
**Note:** Cirrus CI only allows [images managed and regularly updated by us](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos).
|
||||||
|
|
||||||
|
### Retrieving artifacts from within Tart VMs
|
||||||
|
|
||||||
|
In many cases there is a need to retrieve particular files or a folder from within a Tart virtual machine.
|
||||||
|
For example, the below `.cirrus.yml` configuration defines a single task that builds a `tart` binary and
|
||||||
|
exposes it via [`artifacts` instruction](https://cirrus-ci.org/guide/writing-tasks/#artifacts-instruction):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
task:
|
||||||
|
name: Build
|
||||||
|
macos_instance:
|
||||||
|
image: ghcr.io/cirruslabs/macos-monterey-xcode:latest
|
||||||
|
build_script: swift build --product tart
|
||||||
|
binary_artifacts:
|
||||||
|
path: .build/debug/tart
|
||||||
|
```
|
||||||
|
|
||||||
|
Running Cirrus CLI with `--artifacts-dir` will write defined `artifacts` to the provided local directory on the host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cirrus run --artifacts-dir artifacts
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that all retrieved artifacts will be prefixed with the associated task name and `artifacts` instruction name.
|
||||||
|
For the example above, `tart` binary will be saved to `$PWD/artifacts/Build/binary/.build/debug/tart`.
|
||||||
|
|
||||||
## Virtual Machine Management
|
## Virtual Machine Management
|
||||||
|
|
||||||
### Creating from scratch
|
### Creating from scratch
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ struct Login: AsyncParsableCommand {
|
|||||||
var host: String
|
var host: String
|
||||||
|
|
||||||
@Option(help: "username")
|
@Option(help: "username")
|
||||||
var username: String
|
var username: String?
|
||||||
|
|
||||||
@Flag(help: "password-stdin")
|
@Flag(help: "password-stdin")
|
||||||
var passwordStdin: Bool = false
|
var passwordStdin: Bool = false
|
||||||
|
|
||||||
func validate() throws {
|
func validate() throws {
|
||||||
let usernameProvided = !username.isEmpty
|
let usernameProvided = username != nil
|
||||||
let passwordProvided = passwordStdin
|
let passwordProvided = passwordStdin
|
||||||
|
|
||||||
if usernameProvided != passwordProvided {
|
if usernameProvided != passwordProvided {
|
||||||
@@ -28,9 +28,11 @@ struct Login: AsyncParsableCommand {
|
|||||||
var user: String
|
var user: String
|
||||||
var password: String
|
var password: String
|
||||||
|
|
||||||
if !username.isEmpty {
|
if let username = username {
|
||||||
user = username
|
user = username
|
||||||
password = readLine()!
|
|
||||||
|
let passwordData = FileHandle.standardInput.readDataToEndOfFile()
|
||||||
|
password = String(decoding: passwordData, as: UTF8.self)
|
||||||
} else {
|
} else {
|
||||||
(user, password) = try StdinCredentials.retrieve()
|
(user, password) = try StdinCredentials.retrieve()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,51 +24,79 @@ struct Run: AsyncParsableCommand {
|
|||||||
|
|
||||||
@Flag(help: ArgumentHelp(
|
@Flag(help: ArgumentHelp(
|
||||||
"Use screen sharing instead of the built-in UI.",
|
"Use screen sharing instead of the built-in UI.",
|
||||||
discussion: "Useful since VNC supports copy/paste, drag and drop, etc.\nNote that Remote Login option should be enabled inside the VM."))
|
discussion: "Useful since Screen Sharing supports copy/paste, drag and drop, etc.\n"
|
||||||
|
+ "Note that Remote Login option should be enabled inside the VM."))
|
||||||
var vnc: Bool = false
|
var vnc: Bool = false
|
||||||
|
|
||||||
|
@Flag(help: ArgumentHelp(
|
||||||
|
"Use Virtualization.Framework's VNC server instead of the build-in UI.",
|
||||||
|
discussion: "Useful since this type of VNC is available in recovery mode and in macOS installation.\n"
|
||||||
|
+ "Note that this feature is experimental and there may be bugs present when using VNC."))
|
||||||
|
var vncExperimental: Bool = false
|
||||||
|
|
||||||
@Flag var withSoftnet: Bool = false
|
@Flag var withSoftnet: Bool = false
|
||||||
|
|
||||||
@MainActor
|
@Option(help: ArgumentHelp("""
|
||||||
func run() async throws {
|
Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"disk.bin:ro\")
|
||||||
let vmDir = try VMStorageLocal().open(name)
|
""", discussion: """
|
||||||
vm = try VM(vmDir: vmDir, withSoftnet: withSoftnet)
|
Learn how to create a disk image using Disk Utility here:
|
||||||
|
https://support.apple.com/en-gb/guide/disk-utility/dskutl11888/mac
|
||||||
|
"""))
|
||||||
|
var disk: [String] = []
|
||||||
|
|
||||||
var vncWrapper: VNCWrapper?
|
func validate() throws {
|
||||||
|
if vnc && vncExperimental {
|
||||||
if vnc {
|
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
|
||||||
vncWrapper = VNCWrapper(virtualMachine: vm!.virtualMachine)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func run() async throws {
|
||||||
|
let vmDir = try VMStorageLocal().open(name)
|
||||||
|
vm = try VM(
|
||||||
|
vmDir: vmDir,
|
||||||
|
withSoftnet: withSoftnet,
|
||||||
|
additionalDiskAttachments: additionalDiskAttachments()
|
||||||
|
)
|
||||||
|
|
||||||
|
let vncImpl: VNC? = try {
|
||||||
|
if vnc {
|
||||||
|
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
|
||||||
|
return ScreenSharingVNC(vmConfig: vmConfig)
|
||||||
|
} else if vncExperimental {
|
||||||
|
return FullFledgedVNC(virtualMachine: vm!.virtualMachine)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
let task = Task {
|
let task = Task {
|
||||||
do {
|
do {
|
||||||
if let vncWrapper = vncWrapper {
|
if let vncImpl = vncImpl {
|
||||||
let port = try await vncWrapper.waitForPort()
|
let vncURL = try await vncImpl.waitForURL()
|
||||||
|
|
||||||
let url = URL(string: "vnc://:\(vncWrapper.password)@127.0.0.1:\(port)")!
|
|
||||||
|
|
||||||
if noGraphics || ProcessInfo.processInfo.environment["CI"] != nil {
|
if noGraphics || ProcessInfo.processInfo.environment["CI"] != nil {
|
||||||
print("VNC server is running at \(url)")
|
print("VNC server is running at \(vncURL)")
|
||||||
} else {
|
} else {
|
||||||
print("Opening \(url)...")
|
print("Opening \(vncURL)...")
|
||||||
NSWorkspace.shared.open(url)
|
NSWorkspace.shared.open(vncURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try await vm!.run(recovery)
|
try await vm!.run(recovery)
|
||||||
|
|
||||||
if let vncWrapper = vncWrapper {
|
if let vncImpl = vncImpl {
|
||||||
try vncWrapper.stop()
|
try vncImpl.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
Foundation.exit(0)
|
Foundation.exit(0)
|
||||||
} catch {
|
} catch {
|
||||||
if error.localizedDescription.contains("Failed to lock auxiliary storage.") {
|
if error.localizedDescription.contains("Failed to lock auxiliary storage.") {
|
||||||
print("Virtual machine \"\(name)\" is already running!")
|
print("Virtual machine \"\(name)\" is already running!")
|
||||||
} else {
|
Foundation.exit(2)
|
||||||
print(error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
print(error)
|
||||||
Foundation.exit(1)
|
Foundation.exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,13 +107,34 @@ struct Run: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
sigintSrc.activate()
|
sigintSrc.activate()
|
||||||
|
|
||||||
if noGraphics || vnc {
|
if noGraphics || vnc || vncExperimental {
|
||||||
dispatchMain()
|
dispatchMain()
|
||||||
} else {
|
} else {
|
||||||
runUI()
|
runUI()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func additionalDiskAttachments() throws -> [VZDiskImageStorageDeviceAttachment] {
|
||||||
|
var result: [VZDiskImageStorageDeviceAttachment] = []
|
||||||
|
let readOnlySuffix = ":ro"
|
||||||
|
|
||||||
|
for rawDisk in disk {
|
||||||
|
if rawDisk.hasSuffix(readOnlySuffix) {
|
||||||
|
result.append(try VZDiskImageStorageDeviceAttachment(
|
||||||
|
url: URL(fileURLWithPath: String(rawDisk.prefix(rawDisk.count - readOnlySuffix.count))),
|
||||||
|
readOnly: true
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
result.append(try VZDiskImageStorageDeviceAttachment(
|
||||||
|
url: URL(fileURLWithPath: rawDisk),
|
||||||
|
readOnly: false
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
private func runUI() {
|
private func runUI() {
|
||||||
let nsApp = NSApplication.shared
|
let nsApp = NSApplication.shared
|
||||||
nsApp.setActivationPolicy(.regular)
|
nsApp.setActivationPolicy(.regular)
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct Config {
|
struct Config {
|
||||||
public static let tartHomeDir: URL = FileManager.default
|
let tartHomeDir: URL
|
||||||
.homeDirectoryForCurrentUser
|
let tartCacheDir: URL
|
||||||
.appendingPathComponent(".tart", isDirectory: true)
|
|
||||||
|
|
||||||
public static let tartCacheDir: URL = tartHomeDir.appendingPathComponent("cache", isDirectory: true)
|
init() {
|
||||||
|
var tartHomeDir: URL
|
||||||
|
|
||||||
|
if let customTartHome = ProcessInfo.processInfo.environment["TART_HOME"] {
|
||||||
|
tartHomeDir = URL(fileURLWithPath: customTartHome)
|
||||||
|
} else {
|
||||||
|
tartHomeDir = FileManager.default
|
||||||
|
.homeDirectoryForCurrentUser
|
||||||
|
.appendingPathComponent(".tart", isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.tartHomeDir = tartHomeDir
|
||||||
|
tartCacheDir = tartHomeDir.appendingPathComponent("cache", isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
static func jsonEncoder() -> JSONEncoder {
|
static func jsonEncoder() -> JSONEncoder {
|
||||||
let encoder = JSONEncoder()
|
let encoder = JSONEncoder()
|
||||||
|
|||||||
@@ -34,21 +34,37 @@ class KeychainCredentialsProvider: CredentialsProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func store(host: String, user: String, password: String) throws {
|
func store(host: String, user: String, password: String) throws {
|
||||||
let attributes: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
|
let passwordData = password.data(using: .utf8)
|
||||||
kSecAttrAccount as String: user,
|
let key: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
|
||||||
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
|
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
|
||||||
kSecAttrServer as String: host,
|
kSecAttrServer as String: host,
|
||||||
kSecValueData as String: password,
|
kSecAttrLabel as String: "Tart Credentials",
|
||||||
kSecAttrLabel as String: "Tart Credentials",
|
]
|
||||||
|
let value: [String: Any] = [kSecAttrAccount as String: user,
|
||||||
|
kSecValueData as String: passwordData,
|
||||||
]
|
]
|
||||||
|
|
||||||
let status = SecItemAdd(attributes as CFDictionary, nil)
|
let status = SecItemCopyMatching(key as CFDictionary, nil)
|
||||||
|
|
||||||
switch status {
|
switch status {
|
||||||
case errSecSuccess, errSecDuplicateItem:
|
case errSecItemNotFound:
|
||||||
return
|
let status = SecItemAdd(key.merging(value) { (current, _) in current } as CFDictionary, nil)
|
||||||
|
if status != errSecSuccess {
|
||||||
|
throw CredentialsProviderError.Failed(message: "Keychain failed to add item: \(status.explanation())")
|
||||||
|
}
|
||||||
|
case errSecSuccess:
|
||||||
|
let status = SecItemUpdate(key as CFDictionary, value as CFDictionary)
|
||||||
|
if status != errSecSuccess {
|
||||||
|
throw CredentialsProviderError.Failed(message: "Keychain failed to update item: \(status.explanation())")
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
throw CredentialsProviderError.Failed(message: "Keychain returned unsuccessful status \(status)")
|
throw CredentialsProviderError.Failed(message: "Keychain failed to find item: \(status.explanation())")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension OSStatus {
|
||||||
|
func explanation() -> CFString {
|
||||||
|
SecCopyErrorMessageString(self, nil) ?? "Unknown status code \(self)." as CFString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol Authentication {
|
||||||
|
func header() -> (String, String)
|
||||||
|
func isValid() -> Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BasicAuthentication: Authentication {
|
||||||
|
let user: String
|
||||||
|
let password: String
|
||||||
|
|
||||||
|
func header() -> (String, String) {
|
||||||
|
let creds = Data("\(user):\(password)".utf8).base64EncodedString()
|
||||||
|
|
||||||
|
return ("Authorization", "Basic \(creds)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isValid() -> Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import Foundation
|
|||||||
import NIOCore
|
import NIOCore
|
||||||
import NIOHTTP1
|
import NIOHTTP1
|
||||||
import AsyncHTTPClient
|
import AsyncHTTPClient
|
||||||
|
import NIOPosix
|
||||||
|
|
||||||
enum RegistryError: Error {
|
enum RegistryError: Error {
|
||||||
case UnexpectedHTTPStatusCode(when: String, code: UInt, details: String = "")
|
case UnexpectedHTTPStatusCode(when: String, code: UInt, details: String = "")
|
||||||
@@ -25,7 +26,7 @@ extension HTTPClientResponse.Body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TokenResponse: Decodable {
|
struct TokenResponse: Decodable, Authentication {
|
||||||
let defaultIssuedAt = Date()
|
let defaultIssuedAt = Date()
|
||||||
let defaultExpiresIn = 60
|
let defaultExpiresIn = 60
|
||||||
|
|
||||||
@@ -38,15 +39,16 @@ struct TokenResponse: Decodable {
|
|||||||
|
|
||||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||||
|
|
||||||
// RFC3339 date formatter from Apple's documentation[1]
|
let dateFormatter = ISO8601DateFormatter()
|
||||||
//
|
dateFormatter.formatOptions = [.withInternetDateTime]
|
||||||
// [1]: https://developer.apple.com/documentation/foundation/dateformatter
|
|
||||||
let dateFormatter = DateFormatter()
|
|
||||||
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
|
|
||||||
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ"
|
|
||||||
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
|
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||||
|
|
||||||
decoder.dateDecodingStrategy = .formatted(dateFormatter)
|
decoder.dateDecodingStrategy = .custom { decoder in
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
let dateString = try container.decode(String.self)
|
||||||
|
|
||||||
|
return dateFormatter.date(from: dateString) ?? Date()
|
||||||
|
}
|
||||||
|
|
||||||
return try decoder.decode(TokenResponse.self, from: fromData)
|
return try decoder.decode(TokenResponse.self, from: fromData)
|
||||||
}
|
}
|
||||||
@@ -65,16 +67,20 @@ struct TokenResponse: Decodable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var isValid: Bool {
|
func header() -> (String, String) {
|
||||||
get {
|
("Authorization", "Bearer \(token)")
|
||||||
Date() < tokenExpiresAt
|
}
|
||||||
}
|
|
||||||
|
func isValid() -> Bool {
|
||||||
|
Date() < tokenExpiresAt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class Registry {
|
class Registry {
|
||||||
private let httpClient = HTTPClient(eventLoopGroupProvider: .createNew)
|
private let httpClient = HTTPClient(
|
||||||
|
eventLoopGroupProvider: .shared(MultiThreadedEventLoopGroup(numberOfThreads: 1))
|
||||||
|
)
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
try! httpClient.syncShutdown()
|
try! httpClient.syncShutdown()
|
||||||
}
|
}
|
||||||
@@ -83,7 +89,7 @@ class Registry {
|
|||||||
let namespace: String
|
let namespace: String
|
||||||
let credentialsProvider: CredentialsProvider
|
let credentialsProvider: CredentialsProvider
|
||||||
|
|
||||||
var currentAuthToken: TokenResponse? = nil
|
var currentAuthToken: Authentication? = nil
|
||||||
|
|
||||||
init(urlComponents: URLComponents,
|
init(urlComponents: URLComponents,
|
||||||
namespace: String,
|
namespace: String,
|
||||||
@@ -224,11 +230,12 @@ class Registry {
|
|||||||
_ urlComponents: URLComponents,
|
_ urlComponents: URLComponents,
|
||||||
headers: Dictionary<String, String> = Dictionary(),
|
headers: Dictionary<String, String> = Dictionary(),
|
||||||
parameters: Dictionary<String, String> = Dictionary(),
|
parameters: Dictionary<String, String> = Dictionary(),
|
||||||
body: Data? = nil
|
body: Data? = nil,
|
||||||
|
doAuth: Bool = true
|
||||||
) async throws -> HTTPClientResponse {
|
) async throws -> HTTPClientResponse {
|
||||||
var urlComponents = urlComponents
|
var urlComponents = urlComponents
|
||||||
|
|
||||||
if urlComponents.queryItems == nil {
|
if urlComponents.queryItems == nil && !parameters.isEmpty {
|
||||||
urlComponents.queryItems = []
|
urlComponents.queryItems = []
|
||||||
}
|
}
|
||||||
urlComponents.queryItems?.append(contentsOf: parameters.map { key, value -> URLQueryItem in
|
urlComponents.queryItems?.append(contentsOf: parameters.map { key, value -> URLQueryItem in
|
||||||
@@ -245,7 +252,7 @@ class Registry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate token if it has expired
|
// Invalidate token if it has expired
|
||||||
if currentAuthToken?.isValid == false {
|
if currentAuthToken?.isValid() == false {
|
||||||
currentAuthToken = nil
|
currentAuthToken = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,6 +273,15 @@ class Registry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let wwwAuthenticate = try WWWAuthenticate(rawHeaderValue: wwwAuthenticateRaw)
|
let wwwAuthenticate = try WWWAuthenticate(rawHeaderValue: wwwAuthenticateRaw)
|
||||||
|
|
||||||
|
if wwwAuthenticate.scheme == "Basic" {
|
||||||
|
if let (user, password) = try credentialsProvider.retrieve(host: baseURL.host!) {
|
||||||
|
currentAuthToken = BasicAuthentication(user: user, password: password)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if wwwAuthenticate.scheme != "Bearer" {
|
if wwwAuthenticate.scheme != "Bearer" {
|
||||||
throw RegistryError.AuthFailed(why: "WWW-Authenticate header's authentication scheme "
|
throw RegistryError.AuthFailed(why: "WWW-Authenticate header's authentication scheme "
|
||||||
+ "\"\(wwwAuthenticate.scheme)\" is unsupported, expected \"Bearer\" scheme")
|
+ "\"\(wwwAuthenticate.scheme)\" is unsupported, expected \"Bearer\" scheme")
|
||||||
@@ -301,7 +317,7 @@ class Registry {
|
|||||||
headers["Authorization"] = "Basic \(encodedCredentials!)"
|
headers["Authorization"] = "Basic \(encodedCredentials!)"
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = try await rawRequest(.GET, authenticateURL, headers: headers)
|
let response = try await rawRequest(.GET, authenticateURL, headers: headers, doAuth: false)
|
||||||
if response.status != .ok {
|
if response.status != .ok {
|
||||||
let body = try await response.body.readTextResponse() ?? ""
|
let body = try await response.body.readTextResponse() ?? ""
|
||||||
throw RegistryError.AuthFailed(why: "received unexpected HTTP status code \(response.status.code) "
|
throw RegistryError.AuthFailed(why: "received unexpected HTTP status code \(response.status.code) "
|
||||||
@@ -316,7 +332,8 @@ class Registry {
|
|||||||
var request = request
|
var request = request
|
||||||
|
|
||||||
if let token = currentAuthToken {
|
if let token = currentAuthToken {
|
||||||
request.headers.add(name: "Authorization", value: "Bearer \(token.token)")
|
let (name, value) = token.header()
|
||||||
|
request.headers.add(name: name, value: value)
|
||||||
}
|
}
|
||||||
|
|
||||||
return try await httpClient.execute(request, deadline: .distantFuture)
|
return try await httpClient.execute(request, deadline: .distantFuture)
|
||||||
|
|||||||
+24
-10
@@ -25,7 +25,10 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
|
|
||||||
var softnet: Softnet? = nil
|
var softnet: Softnet? = nil
|
||||||
|
|
||||||
init(vmDir: VMDirectory, withSoftnet: Bool = false) throws {
|
init(vmDir: VMDirectory,
|
||||||
|
withSoftnet: Bool = false,
|
||||||
|
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = []
|
||||||
|
) throws {
|
||||||
let auxStorage = VZMacAuxiliaryStorage(contentsOf: vmDir.nvramURL)
|
let auxStorage = VZMacAuxiliaryStorage(contentsOf: vmDir.nvramURL)
|
||||||
|
|
||||||
name = vmDir.name
|
name = vmDir.name
|
||||||
@@ -37,7 +40,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config,
|
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config,
|
||||||
softnet: softnet)
|
softnet: softnet, additionalDiskAttachments: additionalDiskAttachments)
|
||||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||||
|
|
||||||
super.init()
|
super.init()
|
||||||
@@ -53,7 +56,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
let ipswCacheFolder = Config.tartCacheDir.appendingPathComponent("IPSWs", isDirectory: true)
|
let ipswCacheFolder = Config().tartCacheDir.appendingPathComponent("IPSWs", isDirectory: true)
|
||||||
try FileManager.default.createDirectory(at: ipswCacheFolder, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: ipswCacheFolder, withIntermediateDirectories: true)
|
||||||
|
|
||||||
let expectedIPSWLocation = ipswCacheFolder.appendingPathComponent("\(image.buildVersion).ipsw", isDirectory: false)
|
let expectedIPSWLocation = ipswCacheFolder.appendingPathComponent("\(image.buildVersion).ipsw", isDirectory: false)
|
||||||
@@ -94,7 +97,13 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init(vmDir: VMDirectory, ipswURL: URL?, diskSizeGB: UInt16, withSoftnet: Bool = false) async throws {
|
init(
|
||||||
|
vmDir: VMDirectory,
|
||||||
|
ipswURL: URL?,
|
||||||
|
diskSizeGB: UInt16,
|
||||||
|
withSoftnet: Bool = false,
|
||||||
|
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = []
|
||||||
|
) async throws {
|
||||||
let ipswURL = ipswURL != nil ? ipswURL! : try await VM.retrieveLatestIPSW();
|
let ipswURL = ipswURL != nil ? ipswURL! : try await VM.retrieveLatestIPSW();
|
||||||
|
|
||||||
// Load the restore image and try to get the requirements
|
// Load the restore image and try to get the requirements
|
||||||
@@ -132,7 +141,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config,
|
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config,
|
||||||
softnet: softnet)
|
softnet: softnet, additionalDiskAttachments: additionalDiskAttachments)
|
||||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||||
|
|
||||||
super.init()
|
super.init()
|
||||||
@@ -183,7 +192,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
diskURL: URL,
|
diskURL: URL,
|
||||||
auxStorage: VZMacAuxiliaryStorage,
|
auxStorage: VZMacAuxiliaryStorage,
|
||||||
vmConfig: VMConfig,
|
vmConfig: VMConfig,
|
||||||
softnet: Softnet? = nil
|
softnet: Softnet? = nil,
|
||||||
|
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment]
|
||||||
) throws -> VZVirtualMachineConfiguration {
|
) throws -> VZVirtualMachineConfiguration {
|
||||||
let configuration = VZVirtualMachineConfiguration()
|
let configuration = VZVirtualMachineConfiguration()
|
||||||
|
|
||||||
@@ -227,7 +237,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
|
|
||||||
// Audio
|
// Audio
|
||||||
let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration()
|
let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration()
|
||||||
soundDeviceConfiguration.streams = [VZVirtioSoundDeviceInputStreamConfiguration(), VZVirtioSoundDeviceOutputStreamConfiguration()]
|
let inputAudioStreamConfiguration = VZVirtioSoundDeviceInputStreamConfiguration()
|
||||||
|
inputAudioStreamConfiguration.source = VZHostAudioInputStreamSource()
|
||||||
|
let outputAudioStreamConfiguration = VZVirtioSoundDeviceOutputStreamConfiguration()
|
||||||
|
outputAudioStreamConfiguration.sink = VZHostAudioOutputStreamSink()
|
||||||
|
soundDeviceConfiguration.streams = [inputAudioStreamConfiguration, outputAudioStreamConfiguration]
|
||||||
configuration.audioDevices = [soundDeviceConfiguration]
|
configuration.audioDevices = [soundDeviceConfiguration]
|
||||||
|
|
||||||
// Keyboard and mouse
|
// Keyboard and mouse
|
||||||
@@ -247,9 +261,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
configuration.networkDevices = [vio]
|
configuration.networkDevices = [vio]
|
||||||
|
|
||||||
// Storage
|
// Storage
|
||||||
let attachment = try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false)
|
var attachments = [try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false)]
|
||||||
let storage = VZVirtioBlockDeviceConfiguration(attachment: attachment)
|
attachments.append(contentsOf: additionalDiskAttachments)
|
||||||
configuration.storageDevices = [storage]
|
configuration.storageDevices = attachments.map { VZVirtioBlockDeviceConfiguration(attachment: $0) }
|
||||||
|
|
||||||
// Entropy
|
// Entropy
|
||||||
configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
|
configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
class VMStorageLocal {
|
class VMStorageLocal {
|
||||||
let baseURL: URL = Config.tartHomeDir.appendingPathComponent("vms", isDirectory: true)
|
let baseURL: URL = Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true)
|
||||||
|
|
||||||
private func vmURL(_ name: String) -> URL {
|
private func vmURL(_ name: String) -> URL {
|
||||||
baseURL.appendingPathComponent(name, isDirectory: true)
|
baseURL.appendingPathComponent(name, isDirectory: true)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
class VMStorageOCI {
|
class VMStorageOCI {
|
||||||
let baseURL = Config.tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
|
let baseURL = Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
|
||||||
|
|
||||||
private func vmURL(_ name: RemoteName) -> URL {
|
private func vmURL(_ name: RemoteName) -> URL {
|
||||||
baseURL.appendingRemoteName(name)
|
baseURL.appendingRemoteName(name)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Foundation
|
|||||||
import Dynamic
|
import Dynamic
|
||||||
import Virtualization
|
import Virtualization
|
||||||
|
|
||||||
class VNCWrapper {
|
class FullFledgedVNC: VNC {
|
||||||
let password: String
|
let password: String
|
||||||
private let vnc: Dynamic
|
private let vnc: Dynamic
|
||||||
|
|
||||||
@@ -15,6 +15,19 @@ class VNCWrapper {
|
|||||||
vnc.start()
|
vnc.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func waitForURL() async throws -> URL {
|
||||||
|
while true {
|
||||||
|
// Port is 0 shortly after start(),
|
||||||
|
// but will be initialized later
|
||||||
|
if let port = vnc.port.asUInt16, port != 0 {
|
||||||
|
return URL(string: "vnc://:\(password)@127.0.0.1:\(port)")!
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait 50 ms.
|
||||||
|
try await Task.sleep(nanoseconds: 50_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func stop() throws {
|
func stop() throws {
|
||||||
vnc.stop()
|
vnc.stop()
|
||||||
}
|
}
|
||||||
@@ -22,17 +35,4 @@ class VNCWrapper {
|
|||||||
deinit {
|
deinit {
|
||||||
try? stop()
|
try? stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitForPort() async throws -> UInt16 {
|
|
||||||
while true {
|
|
||||||
// Port is 0 shortly after start(),
|
|
||||||
// but will be initialized later
|
|
||||||
if let port = vnc.port.asUInt16, port != 0 {
|
|
||||||
return port
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait 50 ms.
|
|
||||||
try await Task.sleep(nanoseconds: 50_000_000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import Foundation
|
||||||
|
import Dynamic
|
||||||
|
import Virtualization
|
||||||
|
|
||||||
|
class ScreenSharingVNC: VNC {
|
||||||
|
let vmConfig: VMConfig
|
||||||
|
|
||||||
|
init(vmConfig: VMConfig) {
|
||||||
|
self.vmConfig = vmConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForURL() async throws -> URL {
|
||||||
|
let ip = try await IP.resolveIP(vmConfig, secondsToWait: 60)
|
||||||
|
|
||||||
|
if let ip = ip {
|
||||||
|
return URL(string: "vnc://\(ip)")!
|
||||||
|
}
|
||||||
|
|
||||||
|
throw IPNotFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() throws {
|
||||||
|
// nothing to do
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol VNC {
|
||||||
|
func waitForURL() async throws -> URL
|
||||||
|
func stop() throws
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ final class TokenResponseTests: XCTestCase {
|
|||||||
let expectedTokenExpiresAtRange = Date()...Date().addingTimeInterval(60)
|
let expectedTokenExpiresAtRange = Date()...Date().addingTimeInterval(60)
|
||||||
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
|
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
|
||||||
|
|
||||||
XCTAssertTrue(tokenResponse.isValid)
|
XCTAssertTrue(tokenResponse.isValid())
|
||||||
}
|
}
|
||||||
|
|
||||||
func testExpirationBasic() throws {
|
func testExpirationBasic() throws {
|
||||||
@@ -23,9 +23,9 @@ final class TokenResponseTests: XCTestCase {
|
|||||||
let expectedTokenExpiresAtRange = Date()...Date().addingTimeInterval(2)
|
let expectedTokenExpiresAtRange = Date()...Date().addingTimeInterval(2)
|
||||||
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
|
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
|
||||||
|
|
||||||
XCTAssertTrue(tokenResponse.isValid)
|
XCTAssertTrue(tokenResponse.isValid())
|
||||||
_ = XCTWaiter.wait(for: [expectation(description: "Wait 3 seconds for the token to become invalid")], timeout: 2)
|
_ = XCTWaiter.wait(for: [expectation(description: "Wait 3 seconds for the token to become invalid")], timeout: 2)
|
||||||
XCTAssertFalse(tokenResponse.isValid)
|
XCTAssertFalse(tokenResponse.isValid())
|
||||||
}
|
}
|
||||||
|
|
||||||
func testExpirationWithIssuedAt() throws {
|
func testExpirationWithIssuedAt() throws {
|
||||||
@@ -33,6 +33,6 @@ final class TokenResponseTests: XCTestCase {
|
|||||||
let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw)
|
let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw)
|
||||||
|
|
||||||
XCTAssertEqual(Date(timeIntervalSince1970: 3600), tokenResponse.tokenExpiresAt)
|
XCTAssertEqual(Date(timeIntervalSince1970: 3600), tokenResponse.tokenExpiresAt)
|
||||||
XCTAssertFalse(tokenResponse.isValid)
|
XCTAssertFalse(tokenResponse.isValid())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user