VMSettingsView.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. //
  2. // Copyright © 2020 osy. All rights reserved.
  3. //
  4. // Licensed under the Apache License, Version 2.0 (the "License");
  5. // you may not use this file except in compliance with the License.
  6. // You may obtain a copy of the License at
  7. //
  8. // http://www.apache.org/licenses/LICENSE-2.0
  9. //
  10. // Unless required by applicable law or agreed to in writing, software
  11. // distributed under the License is distributed on an "AS IS" BASIS,
  12. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. // See the License for the specific language governing permissions and
  14. // limitations under the License.
  15. //
  16. import SwiftUI
  17. struct VMSettingsView: View {
  18. let vm: VMData
  19. @ObservedObject var config: UTMQemuConfiguration
  20. @State private var isResetConfig: Bool = false
  21. @StateObject private var devicesState = DevicesState()
  22. @StateObject private var globalFileImporterShim = GlobalFileImporterShim()
  23. @EnvironmentObject private var data: UTMData
  24. @Environment(\.presentationMode) private var presentationMode: Binding<PresentationMode>
  25. var body: some View {
  26. NavigationView {
  27. Form {
  28. List {
  29. NavigationLink(
  30. destination: VMConfigInfoView(config: $config.information).navigationTitle("Information"),
  31. label: {
  32. Label("Information", systemImage: "info.circle")
  33. .labelStyle(.roundRectIcon)
  34. })
  35. NavigationLink(
  36. destination: VMConfigSystemView(config: $config.system, isResetConfig: $isResetConfig).navigationTitle("System"),
  37. label: {
  38. Label("System", systemImage: "cpu")
  39. .labelStyle(.roundRectIcon)
  40. })
  41. .onChange(of: isResetConfig) { newValue in
  42. if newValue {
  43. config.reset(forArchitecture: config.system.architecture, target: config.system.target)
  44. isResetConfig = false
  45. }
  46. }
  47. NavigationLink(
  48. destination: VMConfigQEMUView(config: $config.qemu, system: $config.system, fetchFixedArguments: {
  49. config.generatedArguments
  50. }).navigationTitle("QEMU"),
  51. label: {
  52. Label("QEMU", systemImage: "shippingbox")
  53. .labelStyle(.roundRectIcon)
  54. })
  55. NavigationLink(
  56. destination: VMConfigInputView(config: $config.input, hasUsbSupport: config.system.architecture.hasUsbSupport).navigationTitle("Input"),
  57. label: {
  58. Label("Input", systemImage: "keyboard")
  59. .labelStyle(.roundRectIcon)
  60. })
  61. NavigationLink(
  62. destination: VMConfigSharingView(config: $config.sharing).navigationTitle("Sharing"),
  63. label: {
  64. Label("Sharing", systemImage: "person.crop.circle")
  65. .labelStyle(.roundRectIcon)
  66. })
  67. if #available(iOS 15, *) {
  68. Devices(config: config, state: devicesState)
  69. } else {
  70. Section {
  71. NavigationLink {
  72. Form {
  73. List {
  74. Devices(config: config, state: devicesState)
  75. }
  76. }
  77. } label: {
  78. Label("Show all devices…", systemImage: "ellipsis")
  79. .labelStyle(RoundRectIconLabelStyle(color: .green))
  80. }
  81. }
  82. }
  83. }
  84. }
  85. #if !os(visionOS)
  86. .navigationTitle("Settings")
  87. #endif
  88. .navigationViewStyle(.stack)
  89. .settingsNavigation(addDeviceContent: {
  90. if #available(iOS 15, *) {
  91. VMSettingsAddDeviceMenuView(config: config, isCreateDriveShown: $devicesState.isCreateDriveShown, isImportDriveShown: $devicesState.isImportDriveShown)
  92. }
  93. }, editContent: {
  94. if #available(iOS 15, *) {
  95. EditButton()
  96. }
  97. }, cancelContent: {
  98. Button(action: cancel) {
  99. Text("Cancel")
  100. }
  101. }, saveContent: {
  102. Button(action: save) {
  103. Text("Save")
  104. }
  105. })
  106. .fileImporter(isPresented: $globalFileImporterShim.isPresented, allowedContentTypes: globalFileImporterShim.allowedContentTypes, onCompletion: globalFileImporterShim.onCompletion)
  107. }.environmentObject(globalFileImporterShim)
  108. .disabled(data.busy)
  109. .overlay(BusyOverlay())
  110. }
  111. func save() {
  112. data.busyWorkAsync {
  113. try await data.save(vm: vm)
  114. await MainActor.run {
  115. presentationMode.wrappedValue.dismiss()
  116. }
  117. }
  118. }
  119. func cancel() {
  120. presentationMode.wrappedValue.dismiss()
  121. data.busyWork {
  122. try data.discardChanges(for: self.vm)
  123. }
  124. }
  125. }
  126. /// Private state shared between VMSettingsView and Devices
  127. ///
  128. /// 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
  129. /// 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
  130. /// 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.
  131. private class DevicesState: ObservableObject {
  132. @Published var isCreateDriveShown: Bool = false
  133. @Published var isImportDriveShown: Bool = false
  134. }
  135. private struct Devices: View {
  136. @ObservedObject var config: UTMQemuConfiguration
  137. @ObservedObject var state: DevicesState
  138. var body: some View {
  139. Section(header: Text("Devices")) {
  140. ForEach($config.displays) { $display in
  141. NavigationLink(destination: VMConfigDisplayView(config: $display, system: $config.system).navigationTitle("Display")) {
  142. Label("Display", systemImage: "rectangle.on.rectangle")
  143. .labelStyle(RoundRectIconLabelStyle(color: .green))
  144. }
  145. }.onDelete { offsets in
  146. config.displays.remove(atOffsets: offsets)
  147. }
  148. ForEach($config.serials) { $serial in
  149. NavigationLink(destination: VMConfigSerialView(config: $serial, system: $config.system).navigationTitle("Serial")) {
  150. Label("Serial", systemImage: "rectangle.connected.to.line.below")
  151. .labelStyle(RoundRectIconLabelStyle(color: .green))
  152. }
  153. }.onDelete { offsets in
  154. config.serials.remove(atOffsets: offsets)
  155. }
  156. ForEach($config.networks) { $network in
  157. NavigationLink(destination: VMConfigNetworkView(config: $network, system: $config.system).navigationTitle("Network")) {
  158. Label("Network", systemImage: "network")
  159. .labelStyle(RoundRectIconLabelStyle(color: .green))
  160. }
  161. }.onDelete { offsets in
  162. config.networks.remove(atOffsets: offsets)
  163. }
  164. ForEach($config.sound) { $sound in
  165. NavigationLink(destination: VMConfigSoundView(config: $sound, system: $config.system).navigationTitle("Sound")) {
  166. Label("Sound", systemImage: "speaker.wave.2")
  167. .labelStyle(RoundRectIconLabelStyle(color: .green))
  168. }
  169. }.onDelete { offsets in
  170. config.sound.remove(atOffsets: offsets)
  171. }
  172. }
  173. Section(header: Text("Drives")) {
  174. VMDrivesSettingsView(config: config, isCreateDriveShown: $state.isCreateDriveShown, isImportDriveShown: $state.isImportDriveShown)
  175. .labelStyle(RoundRectIconLabelStyle(color: .yellow))
  176. }
  177. if #unavailable(iOS 15) {
  178. // SwiftUI: !! WARNING DO NOT REMOVE !! The follow is LOAD BEARING code disguised as an innocent version display.
  179. // On iOS 14, if you attach any attribute like .navigationBarItems() to something inside a List, it will mess up the layout.
  180. // As a result, we cannot put it on any of the items above and instead we put in the sacrificial Section below.
  181. Section {
  182. HStack {
  183. Text("Version")
  184. Spacer()
  185. Text(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "")
  186. }
  187. HStack {
  188. Text("Build")
  189. Spacer()
  190. Text(Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "")
  191. }
  192. }.navigationBarItems(trailing: VMSettingsAddDeviceMenuView(config: config, isCreateDriveShown: $state.isCreateDriveShown, isImportDriveShown: $state.isImportDriveShown))
  193. }
  194. }
  195. }
  196. struct RoundRectIconLabelStyle: LabelStyle {
  197. var color: Color = .blue
  198. func makeBody(configuration: Configuration) -> some View {
  199. Label(
  200. title: { configuration.title },
  201. icon: {
  202. ZStack(alignment: .center) {
  203. RoundedRectangle(cornerRadius: 10.0, style: .circular)
  204. .frame(width: 32, height: 32)
  205. .foregroundColor(color)
  206. configuration.icon.foregroundColor(.white)
  207. .imageScale(.medium)
  208. }
  209. })
  210. }
  211. }
  212. extension LabelStyle where Self == RoundRectIconLabelStyle {
  213. static var roundRectIcon: RoundRectIconLabelStyle {
  214. RoundRectIconLabelStyle()
  215. }
  216. }
  217. private extension View {
  218. /// Force an view to be unique in each update.
  219. ///
  220. /// On iOS 14 and under and macOS 11 and under, there is a SwiftUI bug
  221. /// which causes a crash when a table is updated with multiple sections.
  222. /// This workaround will (inefficently) force a redraw every refresh.
  223. /// - Returns: some View
  224. @ViewBuilder func uniqued() -> some View {
  225. if #available(iOS 15, macOS 12, *) {
  226. self
  227. } else {
  228. self.id(UUID())
  229. }
  230. }
  231. @ViewBuilder func settingsNavigation(@ViewBuilder addDeviceContent: () -> some View, @ViewBuilder editContent: () -> some View, @ViewBuilder cancelContent: () -> some View, @ViewBuilder saveContent: () -> some View) -> some View {
  232. if #available(iOS 26, visionOS 26, *) {
  233. self.toolbar {
  234. ToolbarItem(placement: .topBarLeading) {
  235. addDeviceContent()
  236. }
  237. #if os(iOS)
  238. ToolbarSpacer(placement: .topBarLeading)
  239. #endif
  240. ToolbarItem(placement: .topBarLeading) {
  241. editContent()
  242. }
  243. ToolbarItem(placement: .topBarTrailing) {
  244. cancelContent()
  245. }
  246. #if os(iOS)
  247. ToolbarSpacer(placement: .topBarTrailing)
  248. #endif
  249. ToolbarItem(placement: .topBarTrailing) {
  250. saveContent()
  251. }
  252. }
  253. } else {
  254. self.navigationBarItems(leading: HStack {
  255. addDeviceContent()
  256. editContent()
  257. }, trailing: HStack {
  258. cancelContent()
  259. saveContent()
  260. })
  261. }
  262. }
  263. }
  264. struct VMSettingsView_Previews: PreviewProvider {
  265. @State static private var config = UTMQemuConfiguration()
  266. static var previews: some View {
  267. VMSettingsView(vm: VMData(from: .empty), config: config)
  268. }
  269. }