Files
luma/Sources/LumaCore/VirtualMachines/VirtualMachineManager.swift
Ole André Vadla Ravnås 4aa17098b2 core: Speed up and simplify the VM picker
templates and availability spawned `emulator -list-avds` on every
render, so opening the boot sheet -- and clicking a template, which
re-queries them all -- stuttered for seconds. Cache the listing and
warm it off the main thread when the picker or sheet appears.

Collapse the two per-architecture Android entries into one; the
chosen AVD now decides the architecture, retiring the message that
wrongly claimed Frida could only instrument the other one.
2026-09-27 17:55:08 +02:00

284 lines
10 KiB
Swift

import Foundation
import Frida
import Observation
@Observable
@MainActor
public final class VirtualMachineManager {
public private(set) var backends: [any VirtualMachineBackend] = []
public private(set) var records: [VirtualMachineRecord] = []
public let agents: BareboneAgentLibrary
public let starterImages: StarterImageLibrary
private var running: [UUID: any VirtualMachine] = [:]
private var devices: [UUID: Device] = [:]
private var stopping: Set<UUID> = []
private let deviceManager: DeviceManager
private let store: ProjectStore
private let storageDirectory: URL
public init(deviceManager: DeviceManager, store: ProjectStore, dataDirectory: URL) {
self.deviceManager = deviceManager
self.store = store
self.storageDirectory = dataDirectory.appendingPathComponent("VirtualMachines", isDirectory: true)
self.agents = BareboneAgentLibrary(directory: dataDirectory.appendingPathComponent("BareboneAgents", isDirectory: true))
self.starterImages = StarterImageLibrary(
directory: dataDirectory.appendingPathComponent("StarterImages", isDirectory: true)
)
}
public func register(_ backend: any VirtualMachineBackend) {
backends.append(backend)
}
public func load() {
records = (try? store.fetchVirtualMachines()) ?? []
}
private var warmthToken = 0
public var templates: [VirtualMachineTemplate] {
_ = warmthToken
return backends.flatMap(\.templates).sorted { left, right in
(left.operatingSystem, left.architecture, left.name)
< (right.operatingSystem, right.architecture, right.name)
}
}
public func availability(for template: VirtualMachineTemplate) -> VirtualMachineAvailability {
_ = warmthToken
guard let backend = backend(for: template) else {
return .unavailable(reason: "No backend named \(template.backendID) is registered")
}
return backend.availability(for: template)
}
public func prewarm() async {
for backend in backends {
await backend.prewarm()
}
warmthToken &+= 1
}
public func resolvedParameters(
for template: VirtualMachineTemplate,
parameters: [String: VirtualMachineParameterValue]
) -> [String: VirtualMachineParameterValue] {
backend(for: template)?.resolvedParameters(for: template, parameters: parameters) ?? parameters
}
public func machine(for record: VirtualMachineRecord) -> (any VirtualMachine)? {
running[record.id]
}
public func template(for record: VirtualMachineRecord) -> VirtualMachineTemplate? {
templates.first { $0.id == record.templateID }
}
public func create(
template: VirtualMachineTemplate,
name: String,
parameters: [String: VirtualMachineParameterValue],
agentPath: URL?
) async throws -> any VirtualMachine {
let record = VirtualMachineRecord(
name: name,
templateID: template.id,
parameters: resolvedParameters(for: template, parameters: parameters),
agentPath: agentPath?.path
)
try store.save(record)
records.append(record)
return try await boot(record)
}
public func device(for record: VirtualMachineRecord) -> Device? {
devices[record.id]
}
public func device(for machine: any VirtualMachine) -> Device? {
devices[machine.id]
}
public func boot(_ record: VirtualMachineRecord, resumingFromReadySnapshot: Bool = true) async throws -> any VirtualMachine {
guard let template = template(for: record), let backend = backend(for: template) else {
throw VirtualMachineError.launchFailed(reason: "No template named \(record.templateID) is available")
}
let machineDirectory = storageDirectory.appendingPathComponent(record.id.uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: machineDirectory, withIntermediateDirectories: true)
let machine = try await backend.launch(
VirtualMachineLaunchRequest(
id: record.id,
template: template,
name: record.name,
parameters: record.parameters,
agentPath: agentPath(for: record, template: template),
storageDirectory: machineDirectory,
resumesFromReadySnapshot: record.hasReadySnapshot && resumingFromReadySnapshot
)
)
running[record.id] = machine
do {
try await addBareboneDevice(for: machine)
} catch {
await stop(record)
throw error
}
return machine
}
public func markReady(_ machine: any VirtualMachine) async throws {
if let device = devices.removeValue(forKey: machine.id) {
try? await deviceManager.removeBareboneDevice(device: device)
}
try await machine.captureReadySnapshot()
if let index = records.firstIndex(where: { $0.id == machine.id }) {
records[index].hasReadySnapshot = true
try store.save(records[index])
}
try await addBareboneDevice(for: machine)
}
public func discardReadySnapshot(_ record: VirtualMachineRecord) async throws {
try await running[record.id]?.discardReadySnapshot()
guard let index = records.firstIndex(where: { $0.id == record.id }) else { return }
records[index].hasReadySnapshot = false
try store.save(records[index])
}
private func addBareboneDevice(for machine: any VirtualMachine) async throws {
guard let stub = machine.debugStub else {
throw VirtualMachineError.launchFailed(reason: "The machine has no debugger to connect to")
}
let record = records.first { $0.id == machine.id }
var agent: BareboneAgentConfig?
if let record, let path = agentPath(for: record, template: machine.template), let transport = machine.agentTransport?.config {
let image = try Data(contentsOf: path)
agent = BareboneInjectedAgentConfig(image: [UInt8](image), transport: transport)
}
let device = try await deviceManager.addBareboneDevice(
config: BareboneConfig(
connection: stub.connectionConfig,
agent: agent,
image: try machine.kernelSymbols?.imageConfig,
kernel: machine.template.variant(for: record?.parameters ?? [:]).agentFlavor?.kernel.kind
),
id: Self.deviceID(for: machine.id),
name: machine.name,
icon: machine.template.icon
)
devices[machine.id] = device
}
static func deviceID(for machineID: UUID) -> String {
"barebone-vm-\(machineID.uuidString)"
}
public func isStopping(_ record: VirtualMachineRecord) -> Bool {
stopping.contains(record.id)
}
public func stop(_ record: VirtualMachineRecord) async {
guard let machine = running[record.id], !stopping.contains(record.id) else { return }
stopping.insert(record.id)
defer { stopping.remove(record.id) }
if let device = devices.removeValue(forKey: record.id) {
try? await deviceManager.removeBareboneDevice(device: device)
}
await machine.shutDown()
running.removeValue(forKey: record.id)
}
public func stopAll() async {
for record in records where running[record.id] != nil {
await stop(record)
}
}
public func forget(_ record: VirtualMachineRecord) async {
await stop(record)
try? store.deleteVirtualMachine(id: record.id)
records.removeAll { $0.id == record.id }
try? FileManager.default.removeItem(at: storageDirectory.appendingPathComponent(record.id.uuidString, isDirectory: true))
}
private func agentPath(for record: VirtualMachineRecord, template: VirtualMachineTemplate) -> URL? {
if let chosen = record.agentPath {
return URL(fileURLWithPath: chosen)
}
return template.variant(for: record.parameters).agentFlavor.flatMap { agents.cachedPath(for: $0) }
}
private func backend(for template: VirtualMachineTemplate) -> (any VirtualMachineBackend)? {
backends.first { $0.id == template.backendID }
}
}
extension BareboneAgentTransport {
var config: BareboneInjectingTransportConfig {
switch self {
case .hostlink(let qmp, let bus, let fabric):
return BareboneHostlinkTransportConfig(qmp: qmp, bus: bus, fabric: fabric.config)
case .vsock(let socketPath, let port):
return BareboneVsockTransportConfig(socketPath: socketPath.path, port: port)
case .pipeVsock(let socketPath):
return BareboneVsockPipeTransportConfig(socketPath: socketPath.path)
}
}
}
extension BareboneHostlinkFabric {
var config: Frida.BareboneHostlinkFabric {
switch self {
case .ports:
return BareboneHostlinkPortsFabric()
case .ecam(let base):
return BareboneHostlinkEcamFabric(ecam: base)
case .mmio:
return BareboneHostlinkMmioFabric()
}
}
}
extension BareboneDebugStub {
var connectionConfig: BareboneConnectionConfig {
switch self {
case .gdbRemote(let host, let port):
return BareboneConnectionConfig(host: host, port: UInt(port), flavor: .gdbRemote)
case .virtualization(let pid):
return BareboneConnectionConfig(pid: pid, flavor: .vz)
case .androidEmulator(let host, let port, let pid):
return BareboneConnectionConfig(host: host, port: UInt(port), pid: pid, flavor: .androidEmulator)
}
}
}
extension BareboneKernelSymbols {
var imageConfig: BareboneImageConfig {
get throws {
switch self {
case .linuxImage(let url):
return BareboneLinuxKernelConfig(kernel: try Frida.LinuxKernelImage.open(path: url.path))
case .linuxSystemMap(let url):
return BareboneLinuxKernelConfig(kernel: try LinuxSystemMap.open(path: url.path))
case .xnuKernelcache(let url):
return BareboneXnuKernelcacheConfig(kernelcache: try XnuKernelcache.open(path: url.path))
}
}
}
}