478 lines
16 KiB
Swift
478 lines
16 KiB
Swift
import Frida
|
|
import LumaCore
|
|
import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
|
|
#if canImport(AppKit)
|
|
|
|
struct BootVirtualMachineSheet: View {
|
|
let engine: Engine
|
|
let deviceAdded: (Device) -> Void
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
@State private var selectedTemplateID: String?
|
|
@State private var machineName: String = ""
|
|
@State private var autoName: String = ""
|
|
@State private var agentPath: URL?
|
|
@State private var parameters: [String: VirtualMachineParameterValue] = [:]
|
|
@State private var machine: (any VirtualMachine)?
|
|
@State private var failure: String?
|
|
@State private var isBooting = false
|
|
@State private var isMarkingReady = false
|
|
@State private var isImporting = false
|
|
@State private var awaitedImport: String?
|
|
@State private var importingParameter: VirtualMachineParameter?
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("Boot Virtual Machine")
|
|
.font(.title3.weight(.semibold))
|
|
|
|
if let machine {
|
|
bootedView(machine)
|
|
} else if isBooting {
|
|
bootingView
|
|
} else {
|
|
templateChooser
|
|
}
|
|
|
|
if let failure {
|
|
Text(failure)
|
|
.font(.footnote)
|
|
.foregroundStyle(.red)
|
|
}
|
|
|
|
actions
|
|
}
|
|
.padding(20)
|
|
.frame(minWidth: 680, minHeight: 460, maxHeight: 720)
|
|
.onAppear(perform: selectFirstAvailableTemplate)
|
|
.task { await engine.virtualMachines.prewarm() }
|
|
.task { await engine.virtualMachines.agents.refreshReleases() }
|
|
.fileImporter(isPresented: $isImporting, allowedContentTypes: allowedImportTypes) { result in
|
|
if let awaitedImport, case .success(let url) = result {
|
|
if awaitedImport == Self.agentImport {
|
|
agentPath = url
|
|
} else {
|
|
parameters[awaitedImport] = .text(url.path)
|
|
}
|
|
}
|
|
awaitedImport = nil
|
|
}
|
|
}
|
|
|
|
private var templateChooser: some View {
|
|
HStack(alignment: .top, spacing: 16) {
|
|
List(engine.virtualMachines.templates, id: \.id, selection: templateSelection) { template in
|
|
HStack(spacing: 8) {
|
|
if let icon = template.icon {
|
|
icon.swiftUIImage
|
|
.resizable()
|
|
.interpolation(.high)
|
|
.frame(width: 24, height: 24)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(template.name)
|
|
Text(availabilityText(for: template))
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.tag(template.id)
|
|
}
|
|
.contentMargins(.top, 0)
|
|
.frame(width: 220)
|
|
.onChange(of: parameters) { _, _ in refreshDefaultName() }
|
|
|
|
if let template = selectedTemplate {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Text(template.summary)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(10)
|
|
.background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 6))
|
|
.padding(.top, Self.listRowInset)
|
|
.padding(.horizontal, Self.formGroupInset)
|
|
|
|
Form {
|
|
TextField("Name", text: $machineName)
|
|
|
|
if let architecture = template.parameters.first(where: {
|
|
$0.id == VirtualMachineTemplate.architectureParameterID
|
|
}) {
|
|
parameterField(architecture)
|
|
}
|
|
|
|
if let starterImages = template.variant(for: effectiveParameters).starterImages {
|
|
starterSection(starterImages)
|
|
}
|
|
|
|
ForEach(template.parameters.filter { $0.id != VirtualMachineTemplate.architectureParameterID }) { parameter in
|
|
parameterField(parameter)
|
|
}
|
|
|
|
if let flavor = template.variant(for: effectiveParameters).agentFlavor {
|
|
agentSection(flavor)
|
|
}
|
|
}
|
|
.formStyle(.grouped)
|
|
.contentMargins(.top, 0)
|
|
}
|
|
.id(template.id)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var templateSelection: Binding<String?> {
|
|
Binding(
|
|
get: { selectedTemplateID },
|
|
set: { newValue in
|
|
selectedTemplateID = newValue
|
|
adoptTemplateDefaults()
|
|
}
|
|
)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func parameterField(_ parameter: VirtualMachineParameter) -> some View {
|
|
switch parameter.kind {
|
|
case .text:
|
|
TextField(parameter.name, text: textBinding(parameter))
|
|
|
|
case .number(_, let minimum, let maximum, let unit):
|
|
LabeledContent(parameter.name) {
|
|
HStack {
|
|
TextField("", value: numberBinding(parameter), format: .number)
|
|
.frame(width: 80)
|
|
if let unit {
|
|
Text(unit).foregroundStyle(.secondary)
|
|
}
|
|
Stepper("", value: numberBinding(parameter), in: minimum...maximum, step: 64)
|
|
.labelsHidden()
|
|
}
|
|
}
|
|
|
|
case .filePath:
|
|
LabeledContent(parameter.name) {
|
|
HStack {
|
|
Text(parameters[parameter.id]?.text ?? "")
|
|
.lineLimit(1)
|
|
.truncationMode(.head)
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
Button("Choose…") {
|
|
awaitedImport = parameter.id
|
|
importingParameter = parameter
|
|
isImporting = true
|
|
}
|
|
}
|
|
}
|
|
|
|
case .choice(let options, _):
|
|
Picker(parameter.name, selection: textBinding(parameter)) {
|
|
ForEach(options) { option in
|
|
Text(option.name).tag(option.id)
|
|
}
|
|
}
|
|
|
|
case .toggle:
|
|
Toggle(parameter.name, isOn: toggleBinding(parameter))
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func starterSection(_ images: StarterImages) -> some View {
|
|
LabeledContent("Starter files") {
|
|
HStack {
|
|
Text(starterDescription(images))
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
|
|
Spacer()
|
|
|
|
Button("Download") {
|
|
perform {
|
|
let paths = try await engine.virtualMachines.starterImages.download(images)
|
|
for (parameter, path) in paths {
|
|
parameters[parameter] = .text(path.path)
|
|
}
|
|
}
|
|
}
|
|
.help("Download \(images.name) and fill these in")
|
|
.disabled(engine.virtualMachines.starterImages.state(for: images).isDownloading)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func starterDescription(_ images: StarterImages) -> String {
|
|
switch engine.virtualMachines.starterImages.state(for: images) {
|
|
case .ready, .missing:
|
|
return images.name
|
|
case .downloading(let fraction):
|
|
return downloadingLabel(fraction)
|
|
case .failed(let reason):
|
|
return reason
|
|
}
|
|
}
|
|
|
|
private func downloadingLabel(_ fraction: Double?) -> String {
|
|
guard let fraction else { return "Downloading…" }
|
|
return "Downloading… \(Int(fraction * 100))%"
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func agentSection(_ flavor: BareboneAgentFlavor) -> some View {
|
|
Section("Barebone Agent") {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(agentDescription(flavor))
|
|
.font(.footnote)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
.foregroundStyle(.secondary)
|
|
|
|
HStack {
|
|
Button("Choose…") {
|
|
awaitedImport = Self.agentImport
|
|
importingParameter = nil
|
|
isImporting = true
|
|
}
|
|
|
|
agentDownloadButton(flavor)
|
|
}
|
|
}
|
|
.task(id: flavor) {
|
|
await engine.virtualMachines.agents.loadFetchedVersion(for: flavor)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func agentDownloadButton(_ flavor: BareboneAgentFlavor) -> some View {
|
|
let agents = engine.virtualMachines.agents
|
|
let state = agents.state(for: flavor)
|
|
|
|
if agentPath != nil {
|
|
EmptyView()
|
|
} else if state != .ready {
|
|
Button(agents.latestVersion(for: flavor).map { "Download \($0)" } ?? "Download") {
|
|
perform { try await agents.downloadLatest(flavor) }
|
|
}
|
|
.disabled(state.isDownloading)
|
|
} else if case .available(let version) = agents.update(for: flavor) {
|
|
Button("Update to \(version)") {
|
|
perform { try await agents.downloadLatest(flavor) }
|
|
}
|
|
}
|
|
}
|
|
|
|
private func agentDescription(_ flavor: BareboneAgentFlavor) -> String {
|
|
if let agentPath {
|
|
return agentPath.path
|
|
}
|
|
|
|
let agents = engine.virtualMachines.agents
|
|
switch agents.state(for: flavor) {
|
|
case .ready:
|
|
guard let version = agents.fetchedVersion(for: flavor) else {
|
|
return "Downloaded \(flavor.name)"
|
|
}
|
|
return "Downloaded \(flavor.name) · \(version)"
|
|
case .downloading(let fraction):
|
|
return downloadingLabel(fraction)
|
|
case .missing:
|
|
return "Not downloaded yet"
|
|
case .failed(let reason):
|
|
return reason
|
|
}
|
|
}
|
|
|
|
private static let agentImport = "barebone-agent"
|
|
private static let listRowInset: CGFloat = 10
|
|
private static let formGroupInset: CGFloat = 20
|
|
|
|
private func bootedView(_ machine: any VirtualMachine) -> some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
if let display = machine.display {
|
|
VirtualMachineDisplayView(display: display)
|
|
.frame(maxWidth: .infinity, minHeight: 320, maxHeight: .infinity)
|
|
}
|
|
|
|
Text("Drive the machine to the state you want to come back to, then mark it ready.")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
private var bootingView: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(Color.black.opacity(0.85))
|
|
|
|
VStack(spacing: 12) {
|
|
ProgressView()
|
|
.controlSize(.large)
|
|
Text("Booting \(machineName)…")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.environment(\.colorScheme, .dark)
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: 320, maxHeight: .infinity)
|
|
|
|
Text("Drive the machine to the state you want to come back to, then mark it ready.")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
private var actions: some View {
|
|
HStack {
|
|
Spacer()
|
|
|
|
Button(machine == nil && !isBooting ? "Cancel" : "Later") { finish() }
|
|
.disabled(isMarkingReady)
|
|
|
|
if machine != nil || isBooting {
|
|
Button {
|
|
markReady()
|
|
} label: {
|
|
if isMarkingReady {
|
|
HStack(spacing: 6) {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
Text("Marking Ready…")
|
|
}
|
|
} else {
|
|
Text("Mark Ready")
|
|
}
|
|
}
|
|
.keyboardShortcut(.defaultAction)
|
|
.disabled(machine == nil || isMarkingReady)
|
|
} else {
|
|
Button("Boot") { boot() }
|
|
.keyboardShortcut(.defaultAction)
|
|
.disabled(isBooting || !isReadyToBoot)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func markReady() {
|
|
guard let machine else { return }
|
|
isMarkingReady = true
|
|
perform {
|
|
defer { isMarkingReady = false }
|
|
try await engine.virtualMachines.markReady(machine)
|
|
finish()
|
|
}
|
|
}
|
|
|
|
private func finish() {
|
|
if let machine, let device = engine.virtualMachines.device(for: machine) {
|
|
deviceAdded(device)
|
|
}
|
|
dismiss()
|
|
}
|
|
|
|
private func boot() {
|
|
guard let template = selectedTemplate else { return }
|
|
|
|
isBooting = true
|
|
perform {
|
|
defer { isBooting = false }
|
|
machine = try await engine.virtualMachines.create(
|
|
template: template,
|
|
name: machineName,
|
|
parameters: parameters,
|
|
agentPath: agentPath
|
|
)
|
|
engine.setSidePanel(.virtualMachines)
|
|
}
|
|
}
|
|
|
|
private func perform(_ work: @escaping () async throws -> Void) {
|
|
failure = nil
|
|
Task {
|
|
do {
|
|
try await work()
|
|
} catch {
|
|
failure = error.localizedDescription
|
|
}
|
|
}
|
|
}
|
|
|
|
private func selectFirstAvailableTemplate() {
|
|
guard selectedTemplateID == nil else { return }
|
|
selectedTemplateID = engine.virtualMachines.templates.first {
|
|
engine.virtualMachines.availability(for: $0).isAvailable
|
|
}?.id
|
|
adoptTemplateDefaults()
|
|
}
|
|
|
|
private func adoptTemplateDefaults() {
|
|
agentPath = nil
|
|
parameters = selectedTemplate?.defaultParameterValues ?? [:]
|
|
autoName = defaultMachineName()
|
|
machineName = autoName
|
|
}
|
|
|
|
private func defaultMachineName() -> String {
|
|
selectedTemplate?.name ?? ""
|
|
}
|
|
|
|
private func refreshDefaultName() {
|
|
guard machineName == autoName else { return }
|
|
autoName = defaultMachineName()
|
|
machineName = autoName
|
|
}
|
|
|
|
private func availabilityText(for template: VirtualMachineTemplate) -> String {
|
|
if let reason = engine.virtualMachines.availability(for: template).reason { return reason }
|
|
return template.variants.map(\.architecture.displayName).joined(separator: " \u{00b7} ")
|
|
}
|
|
|
|
private var allowedImportTypes: [UTType] {
|
|
guard case .filePath(let extensions)? = importingParameter?.kind, !extensions.isEmpty else { return [.data] }
|
|
return extensions.compactMap { UTType(filenameExtension: $0) } + [.data]
|
|
}
|
|
|
|
private var selectedTemplate: VirtualMachineTemplate? {
|
|
engine.virtualMachines.templates.first { $0.id == selectedTemplateID }
|
|
}
|
|
|
|
private var effectiveParameters: [String: VirtualMachineParameterValue] {
|
|
guard let selectedTemplate else { return parameters }
|
|
return engine.virtualMachines.resolvedParameters(for: selectedTemplate, parameters: parameters)
|
|
}
|
|
|
|
private var isReadyToBoot: Bool {
|
|
guard let selectedTemplate, !machineName.trimmingCharacters(in: .whitespaces).isEmpty else { return false }
|
|
return engine.virtualMachines.availability(for: selectedTemplate).isAvailable
|
|
}
|
|
|
|
private func textBinding(_ parameter: VirtualMachineParameter) -> Binding<String> {
|
|
Binding(
|
|
get: { parameters[parameter.id]?.text ?? "" },
|
|
set: { parameters[parameter.id] = .text($0) }
|
|
)
|
|
}
|
|
|
|
private func numberBinding(_ parameter: VirtualMachineParameter) -> Binding<Int> {
|
|
Binding(
|
|
get: { parameters[parameter.id]?.number ?? 0 },
|
|
set: { parameters[parameter.id] = .number($0) }
|
|
)
|
|
}
|
|
|
|
private func toggleBinding(_ parameter: VirtualMachineParameter) -> Binding<Bool> {
|
|
Binding(
|
|
get: { parameters[parameter.id]?.toggle ?? false },
|
|
set: { parameters[parameter.id] = .toggle($0) }
|
|
)
|
|
}
|
|
}
|
|
|
|
#endif
|