UTM/Platform/iOS/VMSettingsView.swift

248 lines
11 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 VMSettingsView: View {
let vm: VMData
@ObservedObject var config: UTMQemuConfiguration
@State private var isResetConfig: Bool = false
@StateObject private var devicesState = DevicesState()
@StateObject private var globalFileImporterShim = GlobalFileImporterShim()
@EnvironmentObject private var data: UTMData
@Environment(\.presentationMode) private var presentationMode: Binding<PresentationMode>
var body: some View {
NavigationView {
Form {
List {
NavigationLink(
destination: VMConfigInfoView(config: $config.information).navigationTitle("Information"),
label: {
Label("Information", systemImage: "info.circle")
.labelStyle(.roundRectIcon)
})
NavigationLink(
destination: VMConfigSystemView(config: $config.system, isResetConfig: $isResetConfig).navigationTitle("System"),
label: {
Label("System", systemImage: "cpu")
.labelStyle(.roundRectIcon)
})
.onChange(of: isResetConfig) { newValue in
if newValue {
config.reset(forArchitecture: config.system.architecture, target: config.system.target)
isResetConfig = false
}
}
NavigationLink(
destination: VMConfigQEMUView(config: $config.qemu, system: $config.system, fetchFixedArguments: {
config.generatedArguments
}).navigationTitle("QEMU"),
label: {
Label("QEMU", systemImage: "shippingbox")
.labelStyle(.roundRectIcon)
})
NavigationLink(
destination: VMConfigInputView(config: $config.input).navigationTitle("Input"),
label: {
Label("Input", systemImage: "keyboard")
.labelStyle(.roundRectIcon)
})
NavigationLink(
destination: VMConfigSharingView(config: $config.sharing).navigationTitle("Sharing"),
label: {
Label("Sharing", systemImage: "person.crop.circle")
.labelStyle(.roundRectIcon)
})
if #available(iOS 15, *) {
Devices(config: config, state: devicesState)
} else {
Section {
NavigationLink {
Form {
List {
Devices(config: config, state: devicesState)
}
}
} label: {
Label("Show all devices…", systemImage: "ellipsis")
.labelStyle(RoundRectIconLabelStyle(color: .green))
}
}
}
}
}
.navigationTitle("Settings")
.navigationViewStyle(.stack)
.navigationBarItems(leading: HStack {
if #available(iOS 15, *) {
VMSettingsAddDeviceMenuView(config: config, isCreateDriveShown: $devicesState.isCreateDriveShown, isImportDriveShown: $devicesState.isImportDriveShown)
EditButton()
}
}, trailing: HStack {
Button(action: cancel) {
Text("Cancel")
}
Button(action: save) {
Text("Save")
}
})
.fileImporter(isPresented: $globalFileImporterShim.isPresented, allowedContentTypes: globalFileImporterShim.allowedContentTypes, onCompletion: globalFileImporterShim.onCompletion)
}.environmentObject(globalFileImporterShim)
.disabled(data.busy)
.overlay(BusyOverlay())
}
func save() {
data.busyWorkAsync {
try await data.save(vm: vm)
await MainActor.run {
presentationMode.wrappedValue.dismiss()
}
}
}
func cancel() {
presentationMode.wrappedValue.dismiss()
data.busyWork {
try data.discardChanges(for: self.vm)
}
}
}
/// Private state shared between VMSettingsView and Devices
///
/// You may be reading this and wonder "why not use @State and @Binding instead?" Well, after a long session of debugging, it was revealed that lots of odd behaviours happen before
/// trial and error led us to the following code. For example, settings would not get updated, or updating settings would cause random crashes, or deleting a device crashes. (Seems to be
/// limited to iOS 14). Anyways, this code is less than optimal but it's what resulted from a natural evolution of code that would not cause any (known) problems in iOS 14.
private class DevicesState: ObservableObject {
@Published var isCreateDriveShown: Bool = false
@Published var isImportDriveShown: Bool = false
}
private struct Devices: View {
@ObservedObject var config: UTMQemuConfiguration
@ObservedObject var state: DevicesState
var body: some View {
Section(header: Text("Devices")) {
ForEach($config.displays) { $display in
NavigationLink(destination: VMConfigDisplayView(config: $display, system: $config.system).navigationTitle("Display")) {
Label("Display", systemImage: "rectangle.on.rectangle")
.labelStyle(RoundRectIconLabelStyle(color: .green))
}
}.onDelete { offsets in
config.displays.remove(atOffsets: offsets)
}
ForEach($config.serials) { $serial in
NavigationLink(destination: VMConfigSerialView(config: $serial, system: $config.system).navigationTitle("Serial")) {
Label("Serial", systemImage: "rectangle.connected.to.line.below")
.labelStyle(RoundRectIconLabelStyle(color: .green))
}
}.onDelete { offsets in
config.serials.remove(atOffsets: offsets)
}
ForEach($config.networks) { $network in
NavigationLink(destination: VMConfigNetworkView(config: $network, system: $config.system).navigationTitle("Network")) {
Label("Network", systemImage: "network")
.labelStyle(RoundRectIconLabelStyle(color: .green))
}
}.onDelete { offsets in
config.networks.remove(atOffsets: offsets)
}
ForEach($config.sound) { $sound in
NavigationLink(destination: VMConfigSoundView(config: $sound, system: $config.system).navigationTitle("Sound")) {
Label("Sound", systemImage: "speaker.wave.2")
.labelStyle(RoundRectIconLabelStyle(color: .green))
}
}.onDelete { offsets in
config.sound.remove(atOffsets: offsets)
}
}
Section(header: Text("Drives")) {
VMDrivesSettingsView(config: config, isCreateDriveShown: $state.isCreateDriveShown, isImportDriveShown: $state.isImportDriveShown)
.labelStyle(RoundRectIconLabelStyle(color: .yellow))
}
if #unavailable(iOS 15) {
// SwiftUI: !! WARNING DO NOT REMOVE !! The follow is LOAD BEARING code disguised as an innocent version display.
// On iOS 14, if you attach any attribute like .navigationBarItems() to something inside a List, it will mess up the layout.
// As a result, we cannot put it on any of the items above and instead we put in the sacrificial Section below.
Section {
HStack {
Text("Version")
Spacer()
Text(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "")
}
HStack {
Text("Build")
Spacer()
Text(Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "")
}
}.navigationBarItems(trailing: VMSettingsAddDeviceMenuView(config: config, isCreateDriveShown: $state.isCreateDriveShown, isImportDriveShown: $state.isImportDriveShown))
}
}
}
struct RoundRectIconLabelStyle: LabelStyle {
var color: Color = .blue
func makeBody(configuration: Configuration) -> some View {
Label(
title: { configuration.title },
icon: {
ZStack(alignment: .center) {
RoundedRectangle(cornerRadius: 10.0, style: .circular)
.frame(width: 32, height: 32)
.foregroundColor(color)
configuration.icon.foregroundColor(.white)
}
})
}
}
extension LabelStyle where Self == RoundRectIconLabelStyle {
static var roundRectIcon: RoundRectIconLabelStyle {
RoundRectIconLabelStyle()
}
}
private extension View {
/// Force an view to be unique in each update.
///
/// On iOS 14 and under and macOS 11 and under, there is a SwiftUI bug
/// which causes a crash when a table is updated with multiple sections.
/// This workaround will (inefficently) force a redraw every refresh.
/// - Returns: some View
@ViewBuilder func uniqued() -> some View {
if #available(iOS 15, macOS 12, *) {
self
} else {
self.id(UUID())
}
}
}
struct VMSettingsView_Previews: PreviewProvider {
@State static private var config = UTMQemuConfiguration()
static var previews: some View {
VMSettingsView(vm: VMData(from: .empty), config: config)
}
}