UTM/Platform/Shared/VMDetailsView.swift

337 lines
12 KiB
Swift

//
// Copyright © 2020 osy. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
struct VMDetailsView: View {
@ObservedObject var vm: VMData
@EnvironmentObject private var data: UTMData
@Environment(\.presentationMode) private var presentationMode: Binding<PresentationMode>
#if !os(macOS)
@Environment(\.horizontalSizeClass) private var horizontalSizeClass: UserInterfaceSizeClass?
private var regularScreenSizeClass: Bool {
horizontalSizeClass == .regular
}
#else
private let regularScreenSizeClass: Bool = true
#endif
private var sizeLabel: String {
let size = data.computeSize(for: vm)
return ByteCountFormatter.string(fromByteCount: size, countStyle: .binary)
}
var body: some View {
if vm.isDeleted {
VStack {
Spacer()
HStack {
Spacer()
Text("This virtual machine has been removed.")
.font(.headline)
Spacer()
}
Spacer()
}
} else {
ScrollView {
Screenshot(vm: vm, large: regularScreenSizeClass)
let notes = vm.detailsNotes ?? ""
if regularScreenSizeClass && !notes.isEmpty {
HStack(alignment: .top) {
Details(vm: vm, sizeLabel: sizeLabel)
.frame(maxWidth: .infinity)
Text(notes)
.font(.body)
.frame(maxWidth: .infinity, alignment: .topLeading)
.fixedSize(horizontal: false, vertical: true)
.padding([.leading, .trailing])
}.padding([.leading, .trailing])
#if os(macOS)
if let appleVM = vm.wrapped as? UTMAppleVirtualMachine {
VMAppleRemovableDrivesView(vm: vm, config: appleVM.config, registryEntry: appleVM.registryEntry)
.padding([.leading, .trailing, .bottom])
} else if let qemuVM = vm.wrapped as? UTMQemuVirtualMachine {
VMRemovableDrivesView(vm: vm, config: qemuVM.config)
.padding([.leading, .trailing, .bottom])
}
#else
let qemuVM = vm.wrapped as! UTMQemuVirtualMachine
VMRemovableDrivesView(vm: vm, config: qemuVM.config)
.padding([.leading, .trailing, .bottom])
#endif
} else {
VStack {
Details(vm: vm, sizeLabel: sizeLabel)
if !notes.isEmpty {
Text(notes)
.font(.body)
.fixedSize(horizontal: false, vertical: true)
}
#if os(macOS)
if let appleVM = vm.wrapped as? UTMAppleVirtualMachine {
VMAppleRemovableDrivesView(vm: vm, config: appleVM.config, registryEntry: appleVM.registryEntry)
} else if let qemuVM = vm.wrapped as? UTMQemuVirtualMachine {
VMRemovableDrivesView(vm: vm, config: qemuVM.config)
}
#else
let qemuVM = vm.wrapped as! UTMQemuVirtualMachine
VMRemovableDrivesView(vm: vm, config: qemuVM.config)
#endif
}.padding([.leading, .trailing, .bottom])
}
}.labelStyle(DetailsLabelStyle())
.modifier(VMOptionalNavigationTitleModifier(vm: vm))
.modifier(VMToolbarModifier(vm: vm, bottom: !regularScreenSizeClass))
.sheet(isPresented: $data.showSettingsModal) {
if let qemuConfig = vm.config as? UTMQemuConfiguration {
VMSettingsView(vm: vm, config: qemuConfig)
.environmentObject(data)
}
#if os(macOS)
if let appleConfig = vm.config as? UTMAppleConfiguration {
VMSettingsView(vm: vm, config: appleConfig)
.environmentObject(data)
}
#endif
}
}
}
}
/// Returns just the content under macOS but adds the title on iOS. #3099
private struct VMOptionalNavigationTitleModifier: ViewModifier {
@ObservedObject var vm: VMData
func body(content: Content) -> some View {
#if os(macOS)
return content.navigationSubtitle(vm.detailsTitleLabel)
#else
return content.navigationTitle(vm.detailsTitleLabel)
#endif
}
}
struct Screenshot: View {
@ObservedObject var vm: VMData
let large: Bool
@EnvironmentObject private var data: UTMData
var body: some View {
ZStack {
Rectangle()
.fill(Color.black)
if let screenshotImage = vm.screenshotImage {
#if os(macOS)
Image(nsImage: screenshotImage)
.resizable()
.aspectRatio(contentMode: .fit)
#else
Image(uiImage: screenshotImage)
.resizable()
.aspectRatio(contentMode: .fit)
#endif
}
Rectangle()
.fill(Color(red: 230/255, green: 229/255, blue: 235/255))
.blendMode(.hardLight)
#if os(visionOS)
.overlay {
if vm.isStopped {
Image(systemName: "play.circle.fill")
.resizable()
.frame(width: 100, height: 100)
}
}
.hoverEffect()
.onTapGesture {
data.run(vm: vm)
}
#endif
if vm.isBusy {
Spinner(size: .large)
} else if vm.isStopped {
#if !os(visionOS)
Button(action: { data.run(vm: vm) }, label: {
Label("Run", systemImage: "play.circle.fill")
.labelStyle(.iconOnly)
.font(Font.system(size: 96))
.foregroundColor(Color.black)
}).buttonStyle(.plain)
#endif
}
}.aspectRatio(CGSize(width: 16, height: 9), contentMode: large ? .fill : .fit)
#if os(visionOS)
.frame(maxWidth: 500)
#endif
}
}
struct Details: View {
@ObservedObject var vm: VMData
let sizeLabel: String
@EnvironmentObject private var data: UTMData
var body: some View {
VStack {
if vm.isShortcut {
HStack {
plainLabel("Path", systemImage: "folder")
Spacer()
Text(vm.pathUrl.path)
.foregroundColor(.secondary)
}
}
HStack {
plainLabel("Status", systemImage: "info.circle")
Spacer()
Text(vm.stateLabel)
.foregroundColor(.secondary)
}
HStack {
plainLabel("Architecture", systemImage: "cpu")
Spacer()
Text(vm.detailsSystemArchitectureLabel)
.foregroundColor(.secondary)
}
HStack {
plainLabel("Machine", systemImage: "desktopcomputer")
Spacer()
Text(vm.detailsSystemTargetLabel)
.foregroundColor(.secondary)
}
HStack {
plainLabel("Memory", systemImage: "memorychip")
Spacer()
Text(vm.detailsSystemMemoryLabel)
.foregroundColor(.secondary)
}
HStack {
plainLabel("Size", systemImage: "internaldrive")
Spacer()
Text(sizeLabel)
.foregroundColor(.secondary)
}
#if os(macOS)
if let appleConfig = vm.config as? UTMAppleConfiguration {
ForEach(appleConfig.serials) { serial in
if serial.mode == .ptty {
HStack {
plainLabel("Serial (TTY)", systemImage: "phone.connection")
Spacer()
OptionalSelectableText(serial.interface?.name)
}
}
}
}
#endif
if let qemuConfig = vm.config as? UTMQemuConfiguration {
ForEach(qemuConfig.serials) { serial in
if serial.mode == .tcpClient {
HStack {
plainLabel("Serial (Client)", systemImage: "network")
Spacer()
let address = "\(serial.tcpHostAddress ?? "example.com"):\(serial.tcpPort ?? 1234)"
OptionalSelectableText(vm.state == .started ? address : nil)
}
} else if serial.mode == .tcpServer {
HStack {
plainLabel("Serial (Server)", systemImage: "network")
Spacer()
let address = "\(serial.tcpPort ?? 1234)"
OptionalSelectableText(vm.state == .started ? address : nil)
}
}
#if os(macOS)
if serial.mode == .ptty {
HStack {
plainLabel("Serial (TTY)", systemImage: "phone.connection")
Spacer()
OptionalSelectableText(serial.pttyDevice?.path)
}
}
#endif
}
}
}.lineLimit(1)
.truncationMode(.tail)
}
private func plainLabel(_ text: String, systemImage: String) -> some View {
return Label {
Text(LocalizedStringKey(text))
} icon: {
Image(systemName: systemImage).foregroundColor(.primary)
}
}
}
struct DetailsLabelStyle: LabelStyle {
var color: Color = .accentColor
func makeBody(configuration: Configuration) -> some View {
Label(
title: { configuration.title.font(.headline) },
icon: {
ZStack(alignment: .center) {
Rectangle()
.frame(width: 32, height: 32)
.foregroundColor(.clear)
configuration.icon.foregroundColor(color)
}
})
}
}
private struct OptionalSelectableText: View {
var content: String?
init(_ content: String?) {
self.content = content
}
var body: some View {
if #available(iOS 15, macOS 12, *) {
(content.map { Text($0) } ?? Text("Inactive", comment: "VMDetailsView"))
.foregroundColor(.secondary)
.textSelection(.enabled)
} else {
(content.map { Text($0) } ?? Text("Inactive", comment: "VMDetailsView"))
.foregroundColor(.secondary)
}
}
}
struct VMDetailsView_Previews: PreviewProvider {
@State static private var config = UTMQemuConfiguration()
static var previews: some View {
VMDetailsView(vm: VMData(from: .empty))
.onAppear {
config.sharing.directoryShareMode = .webdav
var drive = UTMQemuConfigurationDrive()
drive.imageType = .disk
drive.interface = .ide
config.drives.append(drive)
drive.interface = .scsi
config.drives.append(drive)
drive.imageType = .cd
config.drives.append(drive)
}
}
}