2
0

VMWindowView.swift 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. //
  2. // Copyright © 2022 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. import SwiftUIVisualEffects
  18. #if os(visionOS)
  19. import VisionKeyboardKit
  20. #endif
  21. struct VMWindowView: View {
  22. let id: VMSessionState.WindowID
  23. @State var isInteractive = true
  24. @State private var state: VMWindowState
  25. @EnvironmentObject private var session: VMSessionState
  26. @Environment(\.scenePhase) private var scenePhase
  27. #if os(visionOS)
  28. @Environment(\.dismissWindow) private var dismissWindow
  29. #endif
  30. private let keyboardDidShowNotification = NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification)
  31. private let keyboardDidHideNotification = NotificationCenter.default.publisher(for: UIResponder.keyboardDidHideNotification)
  32. private let didReceiveMemoryWarningNotification = NotificationCenter.default.publisher(for: UIApplication.didReceiveMemoryWarningNotification)
  33. init(id: VMSessionState.WindowID, isInteractive: Bool = true) {
  34. self.id = id
  35. self._isInteractive = State<Bool>(initialValue: isInteractive)
  36. self._state = State<VMWindowState>(initialValue: VMWindowState(id: id))
  37. }
  38. private func withOptionalAnimation<Result>(_ animation: Animation? = .default, _ body: () throws -> Result) rethrows -> Result {
  39. if UIAccessibility.isReduceMotionEnabled {
  40. return try body()
  41. } else {
  42. return try withAnimation(animation, body)
  43. }
  44. }
  45. var body: some View {
  46. ZStack {
  47. ZStack {
  48. if let device = state.device {
  49. switch device {
  50. case .display(_, _):
  51. VMDisplayHostedView(vm: session.vm, device: device, state: $state)
  52. .prefersPersistentSystemOverlaysHidden()
  53. case .serial(_, _):
  54. VMDisplayHostedView(vm: session.vm, device: device, state: $state)
  55. .prefersPersistentSystemOverlaysHidden()
  56. }
  57. } else if !state.isBusy && state.isRunning {
  58. // headless
  59. HeadlessView()
  60. }
  61. if state.isBusy || !state.isRunning {
  62. BlurEffect().blurEffectStyle(.light)
  63. VStack {
  64. Spacer()
  65. HStack {
  66. Spacer()
  67. if state.isBusy {
  68. Spinner(size: .large)
  69. } else if session.vmState == .paused {
  70. Button {
  71. session.vm.requestVmResume()
  72. } label: {
  73. if #available(iOS 16, *) {
  74. Label("Resume", systemImage: "playpause.circle.fill")
  75. } else {
  76. Label("Resume", systemImage: "play.circle.fill")
  77. }
  78. }
  79. } else {
  80. Button {
  81. session.vm.requestVmStart()
  82. } label: {
  83. Label("Start", systemImage: "play.circle.fill")
  84. }
  85. }
  86. Spacer()
  87. }
  88. Spacer()
  89. }.labelStyle(.iconOnly)
  90. .font(.system(size: 128))
  91. .vibrancyEffect()
  92. .vibrancyEffectStyle(.label)
  93. }
  94. }.background(Color.black)
  95. .ignoresSafeArea()
  96. #if !os(visionOS)
  97. if isInteractive && state.isRunning {
  98. VMToolbarView(state: $state)
  99. }
  100. #endif
  101. }
  102. .modifier(VMToolbarOrnamentModifier(state: $state))
  103. .statusBarHidden(true)
  104. .alert(item: $state.alert, content: { type in
  105. switch type {
  106. case .powerDown:
  107. return Alert(title: Text("Are you sure you want to stop this VM and exit? Any unsaved changes will be lost."), primaryButton: .destructive(Text("Yes")) {
  108. session.powerDown()
  109. }, secondaryButton: .cancel(Text("No")))
  110. case .terminateApp:
  111. return Alert(title: Text("Are you sure you want to exit UTM?"), primaryButton: .destructive(Text("Yes")) {
  112. session.powerDown(isKill: true)
  113. }, secondaryButton: .cancel(Text("No")))
  114. case .restart:
  115. return Alert(title: Text("Are you sure you want to reset this VM? Any unsaved changes will be lost."), primaryButton: .destructive(Text("Yes")) {
  116. session.reset()
  117. }, secondaryButton: .cancel(Text("No")))
  118. #if WITH_USB
  119. case .deviceConnected(let device):
  120. return Alert(title: Text("Would you like to connect '\(device.name ?? device.description)' to this virtual machine?"), primaryButton: .default(Text("Yes")) {
  121. session.mostRecentConnectedDevice = nil
  122. session.connectDevice(device)
  123. }, secondaryButton: .cancel(Text("No")) {
  124. session.mostRecentConnectedDevice = nil
  125. })
  126. #endif
  127. case .nonfatalError(let message), .fatalError(let message):
  128. return Alert(title: Text(message), dismissButton: .cancel(Text("OK")) {
  129. if case .fatalError(_) = type {
  130. session.stop()
  131. } else if session.vmState == .stopped {
  132. session.stop()
  133. } else {
  134. session.nonfatalError = nil
  135. }
  136. })
  137. case .memoryWarning:
  138. return Alert(title: Text("Running low on memory! UTM might soon be killed by iOS. You can prevent this by decreasing the amount of memory and/or JIT cache assigned to this VM"), dismissButton: .cancel(Text("OK")) {
  139. session.didReceiveMemoryWarning()
  140. })
  141. }
  142. })
  143. .onChange(of: session.windowDeviceMap) { windowDeviceMap in
  144. if let device = windowDeviceMap[state.id] {
  145. state.device = device
  146. } else {
  147. state.device = nil
  148. }
  149. }
  150. .onChange(of: state.device) { [oldDevice = state.device] newDevice in
  151. if session.windowDeviceMap[state.id] != newDevice {
  152. session.windowDeviceMap[state.id] = newDevice
  153. }
  154. state.saveWindow(to: session.vm.registryEntry, device: oldDevice)
  155. state.restoreWindow(from: session.vm.registryEntry, device: newDevice)
  156. }
  157. #if WITH_USB
  158. .onChange(of: session.mostRecentConnectedDevice) { newValue in
  159. if session.activeWindow == state.id, let device = newValue {
  160. state.alert = .deviceConnected(device)
  161. }
  162. }
  163. #endif
  164. .onChange(of: session.nonfatalError) { newValue in
  165. if session.activeWindow == state.id, let message = newValue {
  166. state.alert = .nonfatalError(message)
  167. }
  168. }
  169. .onChange(of: session.fatalError) { newValue in
  170. if session.activeWindow == state.id, let message = newValue {
  171. state.alert = .fatalError(message)
  172. }
  173. }
  174. .onChange(of: session.vmState) { [oldValue = session.vmState] newValue in
  175. vmStateUpdated(from: oldValue, to: newValue)
  176. }
  177. .onChange(of: session.isDynamicResolutionSupported) { newValue in
  178. state.isDynamicResolutionSupported = newValue
  179. }
  180. .onReceive(keyboardDidShowNotification) { _ in
  181. state.isKeyboardShown = true
  182. state.isKeyboardRequested = true
  183. }
  184. .onReceive(keyboardDidHideNotification) { _ in
  185. state.isKeyboardShown = false
  186. state.isKeyboardRequested = false
  187. }
  188. .onReceive(didReceiveMemoryWarningNotification) { _ in
  189. if session.activeWindow == state.id && !session.hasShownMemoryWarning {
  190. session.hasShownMemoryWarning = true
  191. state.alert = .memoryWarning
  192. }
  193. }
  194. .onChange(of: scenePhase) { newValue in
  195. guard session.activeWindow == state.id else {
  196. return
  197. }
  198. if newValue == .background {
  199. saveWindow()
  200. session.didEnterBackground()
  201. } else if newValue == .active {
  202. session.didEnterForeground()
  203. }
  204. }
  205. .onAppear {
  206. vmStateUpdated(from: nil, to: session.vmState)
  207. session.registerWindow(state.id, isExternal: !isInteractive)
  208. if !isInteractive {
  209. session.externalWindowBinding = $state
  210. }
  211. state.isDynamicResolutionSupported = session.isDynamicResolutionSupported
  212. // in case an alert appeared before we created the view
  213. if session.activeWindow == state.id {
  214. #if WITH_USB
  215. if let device = session.mostRecentConnectedDevice {
  216. state.alert = .deviceConnected(device)
  217. }
  218. #endif
  219. if let nonfatalError = session.nonfatalError {
  220. state.alert = .nonfatalError(nonfatalError)
  221. }
  222. if let fatalError = session.fatalError {
  223. state.alert = .fatalError(fatalError)
  224. }
  225. }
  226. }
  227. .onDisappear {
  228. session.removeWindow(state.id)
  229. if !isInteractive {
  230. session.externalWindowBinding = nil
  231. }
  232. #if os(visionOS)
  233. dismissWindow(keyboardFor: state.id)
  234. #endif
  235. }
  236. }
  237. private func vmStateUpdated(from oldState: UTMVirtualMachineState?, to vmState: UTMVirtualMachineState) {
  238. if oldState == .started {
  239. saveWindow()
  240. }
  241. switch vmState {
  242. case .stopped, .paused:
  243. withOptionalAnimation {
  244. state.isBusy = false
  245. state.isRunning = false
  246. }
  247. // do not close if we have a popup open
  248. DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
  249. if session.nonfatalError == nil && session.fatalError == nil {
  250. if session.vmState == .stopped {
  251. session.stop()
  252. }
  253. }
  254. }
  255. case .pausing, .stopping, .starting, .resuming, .saving, .restoring:
  256. withOptionalAnimation {
  257. state.isBusy = true
  258. state.isRunning = false
  259. }
  260. case .started:
  261. withOptionalAnimation {
  262. state.isBusy = false
  263. state.isRunning = true
  264. }
  265. }
  266. }
  267. private func saveWindow() {
  268. state.saveWindow(to: session.vm.registryEntry, device: state.device)
  269. }
  270. }
  271. private struct HeadlessView: View {
  272. var body: some View {
  273. ZStack {
  274. BlurEffect().blurEffectStyle(.dark)
  275. VStack {
  276. Image(systemName: "rectangle.dashed")
  277. .font(.title)
  278. Text("No output device is selected for this window.")
  279. .foregroundColor(.white)
  280. .font(.headline)
  281. .multilineTextAlignment(.center)
  282. }.padding()
  283. .frame(width: 200, height: 200, alignment: .center)
  284. .foregroundColor(.white)
  285. .background(Color.gray.opacity(0.5))
  286. .clipShape(RoundedRectangle(cornerRadius: 25.0, style: .continuous))
  287. .vibrancyEffect()
  288. .vibrancyEffectStyle(.label)
  289. }
  290. }
  291. }
  292. #if !os(visionOS)
  293. /// Stub for non-Vision platforms
  294. fileprivate struct VMToolbarOrnamentModifier: ViewModifier {
  295. @Binding var state: VMWindowState
  296. func body(content: Content) -> some View {
  297. content
  298. }
  299. }
  300. #endif
  301. private extension View {
  302. func prefersPersistentSystemOverlaysHidden() -> some View {
  303. if #available(iOS 16, *) {
  304. return self.persistentSystemOverlays(.hidden)
  305. } else {
  306. return self
  307. }
  308. }
  309. }