2
0

SettingsView.swift 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519
  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. @available(macOS 11, *)
  18. struct SettingsView: View {
  19. private enum Selection: CaseIterable, Identifiable {
  20. case application
  21. case display
  22. case sound
  23. case input
  24. case network
  25. case file
  26. case server
  27. var id: Self {
  28. return self
  29. }
  30. var isAvailable: Bool {
  31. if self == .network {
  32. if #unavailable(macOS 12) {
  33. return false
  34. }
  35. }
  36. return true
  37. }
  38. var title: LocalizedStringKey {
  39. switch self {
  40. case .application:
  41. return "Application"
  42. case .display:
  43. return "Display"
  44. case .sound:
  45. return "Sound"
  46. case .input:
  47. return "Input"
  48. case .network:
  49. return "Network"
  50. case .file:
  51. return "File"
  52. case .server:
  53. return "Server"
  54. }
  55. }
  56. var systemImage: String {
  57. switch self {
  58. case .application:
  59. return "app.badge"
  60. case .display:
  61. return "rectangle.on.rectangle"
  62. case .sound:
  63. return "speaker.wave.2"
  64. case .input:
  65. return "keyboard"
  66. case .network:
  67. return "network"
  68. case .file:
  69. return "folder"
  70. case .server:
  71. return "server.rack"
  72. }
  73. }
  74. @ViewBuilder
  75. var view: some View {
  76. switch self {
  77. case .application:
  78. ApplicationSettingsView()
  79. case .display:
  80. DisplaySettingsView()
  81. case .sound:
  82. SoundSettingsView()
  83. case .input:
  84. InputSettingsView()
  85. case .network:
  86. if #available(macOS 12, *) {
  87. NetworkSettingsView()
  88. } else {
  89. EmptyView()
  90. }
  91. case .file:
  92. FileSettingsView()
  93. case .server:
  94. ServerSettingsView()
  95. }
  96. }
  97. }
  98. @State private var selection: Selection = .application
  99. var body: some View {
  100. if #available(macOS 26, *) {
  101. newBody
  102. } else {
  103. oldBody
  104. }
  105. }
  106. @available(macOS 15, *)
  107. @ViewBuilder
  108. var newBody: some View {
  109. NavigationSplitView {
  110. List(Selection.allCases, selection: $selection) { category in
  111. if category.isAvailable {
  112. Label(category.title, systemImage: category.systemImage)
  113. }
  114. }.toolbar(removing: .sidebarToggle)
  115. } detail: {
  116. VStack(alignment: .leading) {
  117. HStack(alignment: .top) {
  118. selection.view.padding()
  119. Spacer()
  120. }
  121. Spacer()
  122. }
  123. }
  124. }
  125. @ViewBuilder
  126. var oldBody: some View {
  127. TabView {
  128. ForEach(Selection.allCases) { category in
  129. if category.isAvailable {
  130. VStack(alignment: .leading) {
  131. HStack(alignment: .top) {
  132. category.view.padding()
  133. Spacer()
  134. }
  135. Spacer()
  136. }
  137. .tabItem {
  138. Label(category.title, systemImage: category.systemImage)
  139. }
  140. }
  141. }
  142. }
  143. }
  144. }
  145. struct ApplicationSettingsView: View {
  146. @AppStorage("KeepRunningAfterLastWindowClosed") var isKeepRunningAfterLastWindowClosed = false
  147. @AppStorage("HideDockIcon") var isDockIconHidden = false
  148. @AppStorage("ShowMenuIcon") var isMenuIconShown = false
  149. @AppStorage("PreventIdleSleep") var isPreventIdleSleep = false
  150. @AppStorage("NoQuitConfirmation") var isNoQuitConfirmation = false
  151. @AppStorage("NoUsbPrompt") var isNoUsbPrompt = false
  152. @State private var isConfirmResetAutoConnect = false
  153. var body: some View {
  154. Form {
  155. Toggle(isOn: $isKeepRunningAfterLastWindowClosed, label: {
  156. Text("Keep UTM running after last window is closed and all VMs are shut down")
  157. })
  158. if #available(macOS 13, *) {
  159. Toggle(isOn: $isDockIconHidden.inverted, label: {
  160. Text("Show dock icon")
  161. }).onChange(of: isDockIconHidden) { newValue in
  162. if newValue {
  163. isMenuIconShown = true
  164. isKeepRunningAfterLastWindowClosed = true
  165. }
  166. }
  167. Toggle(isOn: $isMenuIconShown, label: {
  168. Text("Show menu bar icon")
  169. }).disabled(isDockIconHidden)
  170. }
  171. Toggle(isOn: $isPreventIdleSleep, label: {
  172. Text("Prevent system from sleeping when any VM is running")
  173. })
  174. Toggle(isOn: $isNoQuitConfirmation, label: {
  175. Text("Do not show confirmation when closing a running VM")
  176. }).help("Closing a VM without properly shutting it down could result in data loss.")
  177. Section(header: Text("QEMU USB")) {
  178. Toggle(isOn: $isNoUsbPrompt, label: {
  179. Text("Do not show prompt when USB device is plugged in")
  180. })
  181. Button("Reset auto connect devices…") {
  182. isConfirmResetAutoConnect.toggle()
  183. }.help("Clears all saved USB devices.")
  184. .alert(isPresented: $isConfirmResetAutoConnect) {
  185. Alert(title: Text("Do you wish to reset all saved USB devices?"), primaryButton: .cancel(), secondaryButton: .destructive(Text("Reset")) {
  186. UTMUSBManager.shared.usbDevices.removeAll()
  187. })
  188. }
  189. }
  190. }
  191. }
  192. }
  193. struct DisplaySettingsView: View {
  194. @AppStorage("NoScreenshot") var isNoScreenshot = false
  195. @AppStorage("NoSaveScreenshot") var isNoSaveScreenshot = false
  196. @AppStorage("QEMURendererBackend") var qemuRendererBackend: UTMQEMURendererBackend = .qemuRendererBackendDefault
  197. @AppStorage("QEMURendererFPSLimit") var qemuRendererFpsLimit: Int = 0
  198. var body: some View {
  199. Form {
  200. Section(header: Text("Display")) {
  201. Toggle(isOn: $isNoScreenshot) {
  202. Text("Disable VM screenshot")
  203. }.help("No VM screenshots will be taken.")
  204. .onChange(of: isNoScreenshot) { newValue in
  205. isNoSaveScreenshot = newValue
  206. }
  207. Toggle(isOn: $isNoSaveScreenshot) {
  208. Text("Do not save VM screenshot to disk")
  209. }.help("If enabled, any existing screenshot will be deleted the next time the VM is started.")
  210. .disabled(isNoScreenshot)
  211. }
  212. Section(header: Text("QEMU Graphics Acceleration")) {
  213. Picker("Renderer Backend", selection: $qemuRendererBackend) {
  214. Text("Default").tag(UTMQEMURendererBackend.qemuRendererBackendDefault)
  215. Text("ANGLE (OpenGL)").tag(UTMQEMURendererBackend.qemuRendererBackendAngleGL)
  216. Text("ANGLE (Metal)").tag(UTMQEMURendererBackend.qemuRendererBackendAngleMetal)
  217. }.help("By default, the best renderer for this device will be used. You can override this with to always use a specific renderer. This only applies to QEMU VMs with GPU accelerated graphics.")
  218. HStack {
  219. Stepper("FPS Limit", value: $qemuRendererFpsLimit, in: 0...240, step: 15)
  220. NumberTextField("", number: $qemuRendererFpsLimit, prompt: "None")
  221. .frame(width: 80)
  222. .multilineTextAlignment(.trailing)
  223. .help("If set, a frame limit can improve smoothness in rendering by preventing stutters when set to the lowest value your device can handle.")
  224. }
  225. }
  226. }
  227. }
  228. }
  229. struct SoundSettingsView: View {
  230. @AppStorage("QEMUSoundBackend") var qemuSoundBackend: UTMQEMUSoundBackend = .qemuSoundBackendDefault
  231. var body: some View {
  232. Form {
  233. Section(header: Text("QEMU Sound")) {
  234. Picker("Sound Backend", selection: $qemuSoundBackend) {
  235. Text("Default").tag(UTMQEMUSoundBackend.qemuSoundBackendDefault)
  236. Text("SPICE with GStreamer (Input & Output)").tag(UTMQEMUSoundBackend.qemuSoundBackendSPICE)
  237. Text("CoreAudio (Output Only)").tag(UTMQEMUSoundBackend.qemuSoundBackendCoreAudio)
  238. }.help("By default, the best backend for the target will be used. If the selected backend is not available for any reason, an alternative will automatically be selected.")
  239. }
  240. }
  241. }
  242. }
  243. struct InputSettingsView: View {
  244. @AppStorage("FullScreenAutoCapture") var isFullScreenAutoCapture = false
  245. @AppStorage("WindowFocusAutoCapture") var isWindowFocusAutoCapture = false
  246. @AppStorage("OptionAsMetaKey") var isOptionAsMetaKey = false
  247. @AppStorage("CtrlRightClick") var isCtrlRightClick = false
  248. @AppStorage("AlternativeCaptureKey") var isAlternativeCaptureKey = false
  249. @AppStorage("IsCapsLockKey") var isCapsLockKey = false
  250. @AppStorage("IsNumLockForced") var isNumLockForced = false
  251. @AppStorage("IsCtrlCmdSwapped") var isCtrlCmdSwapped = false
  252. @AppStorage("InvertScroll") var isInvertScroll = false
  253. @AppStorage("HandleInitialClick") var isHandleInitialClick = false
  254. @AppStorage("IsISOKeySwapped") var isISOKeySwapped = false
  255. @State private var isKeyboardShortcutsShown = false
  256. var body: some View {
  257. Form {
  258. Section(header: Text("Mouse/Keyboard")) {
  259. Toggle(isOn: $isFullScreenAutoCapture) {
  260. Text("Capture input automatically when entering full screen")
  261. }.help("If enabled, input capture will toggle automatically when entering and exiting full screen mode.")
  262. Toggle(isOn: $isWindowFocusAutoCapture) {
  263. Text("Capture input automatically when window is focused")
  264. }.help("If enabled, input capture will toggle automatically when the VM's window is focused.")
  265. }
  266. Section(header: Text("Console")) {
  267. Toggle(isOn: $isOptionAsMetaKey, label: {
  268. Text("Option (⌥) is Meta key")
  269. }).help("If enabled, Option will be mapped to the Meta key which can be useful for emacs. Otherwise, option will work as the system intended (such as for entering international text).")
  270. }
  271. Section(header: Text("QEMU Pointer")) {
  272. Toggle(isOn: $isCtrlRightClick, label: {
  273. Text("Hold Control (⌃) for right click")
  274. })
  275. Toggle(isOn: $isInvertScroll, label: {
  276. Text("Invert scrolling")
  277. }).help("If enabled, scroll wheel input will be inverted.")
  278. Toggle(isOn: $isHandleInitialClick) {
  279. Text("Handle input on initial click")
  280. }.help("If enabled, when the VM is out of focus, the first click will be handled by the VM. Otherwise, the first click will only bring the window into focus.")
  281. }
  282. Section(header: Text("QEMU Keyboard")) {
  283. Button("Keyboard Shortcuts…") {
  284. isKeyboardShortcutsShown.toggle()
  285. }.help("Set up custom keyboard shortcuts that can be triggered from the keyboard menu.")
  286. Toggle(isOn: $isAlternativeCaptureKey, label: {
  287. Text("Use Command+Option (⌘+⌥) for input capture/release")
  288. }).help("If disabled, the default combination Control+Option (⌃+⌥) will be used.")
  289. Toggle(isOn: $isCapsLockKey, label: {
  290. Text("Caps Lock (⇪) is treated as a key")
  291. }).help("If enabled, caps lock will be handled like other keys. If disabled, it is treated as a toggle that is synchronized with the host.")
  292. Toggle(isOn: $isNumLockForced, label: {
  293. Text("Num Lock is forced on")
  294. }).help("If enabled, num lock will always be on to the guest. Note this may make your keyboard's num lock indicator out of sync.")
  295. Toggle(isOn: $isCtrlCmdSwapped, label: {
  296. Text("Swap Control (⌃) and Command (⌘) keys")
  297. }).help("This does not apply to key binding outside the guest.")
  298. Toggle(isOn: $isISOKeySwapped) {
  299. Text("Swap the leftmost key on the number row and the key next to left shift on ISO keyboards")
  300. }.help("This only applies to ISO layout keyboards.")
  301. }
  302. .sheet(isPresented: $isKeyboardShortcutsShown) {
  303. VMKeyboardShortcutsView().padding()
  304. .frame(idealWidth: 400)
  305. }
  306. }
  307. }
  308. }
  309. @available(macOS 12, *)
  310. struct NetworkSettingsView: View {
  311. @AppStorage("IsRegenerateMACOnClone") var isRegenerateMACOnClone = false
  312. @AppStorage("HostNetworks") var hostNetworksData: Data = Data()
  313. @State private var hostNetworks: [UTMConfigurationHostNetwork] = []
  314. @State private var selectedID: UUID?
  315. @State private var isImporterPresented: Bool = false
  316. private func loadData() {
  317. hostNetworks = (try? PropertyListDecoder().decode([UTMConfigurationHostNetwork].self, from: hostNetworksData)) ?? []
  318. }
  319. private func saveData() {
  320. hostNetworksData = (try? PropertyListEncoder().encode(hostNetworks)) ?? Data()
  321. }
  322. var body: some View {
  323. Form {
  324. Section(header: Text("Cloning")) {
  325. Toggle("Regenerate MAC addresses on clone", isOn: $isRegenerateMACOnClone)
  326. .help("When cloning a VM, regenerate MAC addresses on every network interface to prevent conflicts.")
  327. }
  328. Section(header: Text("Host Networks")) {
  329. Table($hostNetworks, selection: $selectedID) {
  330. TableColumn("Name") { $network in
  331. TextField(
  332. "Name",
  333. text: $network.name
  334. )
  335. .labelsHidden()
  336. }
  337. TableColumn("UUID") { $network in
  338. TextField(
  339. "UUID",
  340. text: $network.uuid,
  341. onEditingChanged: { (editingChanged) in
  342. if !editingChanged && UUID(uuidString: network.uuid) != nil {
  343. saveData()
  344. }
  345. }
  346. )
  347. .labelsHidden()
  348. .autocorrectionDisabled()
  349. .foregroundStyle(UUID(uuidString: network.uuid) == nil ? .red : .primary)
  350. }
  351. .width(min: 160)
  352. }.help("QEMU machines in 'Host' network mode can be placed in the same network to communicate with each other.")
  353. HStack {
  354. Button("Import from VMware Fusion") {
  355. isImporterPresented.toggle()
  356. }.fileImporter(isPresented: $isImporterPresented, allowedContentTypes: [.data]) { result in
  357. if let url = try? result.get() {
  358. for network in UTMConfigurationHostNetwork.parseVMware(from: url) {
  359. if !hostNetworks.contains(where: {$0.uuid == network.uuid}) {
  360. hostNetworks.append(network)
  361. }
  362. }
  363. saveData()
  364. }
  365. }.help("Navigate to '/Library/Preferences/VMware Fusion' (⌘+Shift+G) and select the 'networking' file")
  366. Spacer()
  367. Button("Delete") {
  368. hostNetworks.removeAll { network in
  369. network.id == selectedID
  370. }
  371. selectedID = nil
  372. saveData()
  373. }.disabled(selectedID == nil)
  374. Button("Add") {
  375. let network = UTMConfigurationHostNetwork(name: "Network \(hostNetworks.count)")
  376. hostNetworks.append(network)
  377. saveData()
  378. }
  379. }
  380. }
  381. }.onAppear(perform: loadData)
  382. }
  383. }
  384. struct FileSettingsView: View {
  385. @AppStorage("UseFileLock") var isUseFileLock = true
  386. var body: some View {
  387. Form {
  388. Section(header: Text("QEMU Backend")) {
  389. Toggle(isOn: $isUseFileLock) {
  390. Text("Lock drive images when in use")
  391. }.help("If enabled, all writable drive images will be locked when the VM is running. Read-only drive images will not be locked.")
  392. }
  393. }
  394. }
  395. }
  396. struct ServerSettingsView: View {
  397. private let defaultPort = 21589
  398. @AppStorage("ServerAutostart") var isServerAutostart: Bool = false
  399. @AppStorage("ServerExternal") var isServerExternal: Bool = false
  400. @AppStorage("ServerAutoblock") var isServerAutoblock: Bool = false
  401. @AppStorage("ServerPort") var serverPort: Int = 0
  402. @AppStorage("ServerPasswordRequired") var isServerPasswordRequired: Bool = false
  403. @AppStorage("ServerPassword") var serverPassword: String = ""
  404. // note it is okay to store the server password in plaintext in the settings plist because if the attacker is able to see the password,
  405. // they can gain execution in UTM application context... which is the context needed to read the password.
  406. var body: some View {
  407. Form {
  408. Section(header: Text("Startup")) {
  409. Toggle("Automatically start UTM server", isOn: $isServerAutostart)
  410. }
  411. Section(header: Text("Network")) {
  412. Toggle("Reject unknown connections by default", isOn: $isServerAutoblock)
  413. .help("If checked, you will not be prompted about any unknown connection and they will be rejected.")
  414. Toggle("Allow access from external clients", isOn: $isServerExternal)
  415. .help("By default, the server is only available on LAN but setting this will use UPnP/NAT-PMP to port forward to WAN.")
  416. .onChange(of: isServerExternal) { newValue in
  417. if newValue {
  418. if serverPort == 0 {
  419. serverPort = defaultPort
  420. }
  421. if !isServerPasswordRequired {
  422. isServerPasswordRequired = true
  423. }
  424. }
  425. }
  426. NumberTextField("", number: $serverPort, prompt: "Any")
  427. .frame(width: 80)
  428. .multilineTextAlignment(.trailing)
  429. .help("Specify a port number to listen on. This is required if external clients are permitted.")
  430. .onChange(of: serverPort) { newValue in
  431. if newValue == 0 {
  432. isServerExternal = false
  433. }
  434. if newValue < 0 || newValue >= UInt16.max {
  435. serverPort = defaultPort
  436. }
  437. }
  438. }
  439. Section(header: Text("Authentication")) {
  440. Toggle("Require Password", isOn: $isServerPasswordRequired)
  441. .disabled(isServerExternal)
  442. .help("If enabled, clients must enter a password. This is required if you want to access the server externally.")
  443. .onChange(of: isServerPasswordRequired) { newValue in
  444. if newValue && serverPassword.count == 0 {
  445. serverPassword = .random(length: 32)
  446. }
  447. }
  448. TextField("Password", text: $serverPassword)
  449. .disabled(!isServerPasswordRequired)
  450. }
  451. }
  452. }
  453. }
  454. extension UserDefaults {
  455. @objc dynamic var KeepRunningAfterLastWindowClosed: Bool { false }
  456. @objc dynamic var ShowMenuIcon: Bool { false }
  457. @objc dynamic var HideDockIcon: Bool { false }
  458. @objc dynamic var PreventIdleSleep: Bool { false }
  459. @objc dynamic var NoQuitConfirmation: Bool { false }
  460. @objc dynamic var NoCursorCaptureAlert: Bool { false }
  461. @objc dynamic var FullScreenAutoCapture: Bool { false }
  462. @objc dynamic var OptionAsMetaKey: Bool { false }
  463. @objc dynamic var CtrlRightClick: Bool { false }
  464. @objc dynamic var NoUsbPrompt: Bool { false }
  465. @objc dynamic var AlternativeCaptureKey: Bool { false }
  466. @objc dynamic var IsCapsLockKey: Bool { false }
  467. @objc dynamic var IsNumLockForced: Bool { false }
  468. @objc dynamic var NoSaveScreenshot: Bool { false }
  469. @objc dynamic var InvertScroll: Bool { false }
  470. @objc dynamic var QEMURendererBackend: Int { 0 }
  471. @objc dynamic var QEMURendererFPSLimit: Int { 0 }
  472. }
  473. @available(macOS 11, *)
  474. struct SettingsView_Previews: PreviewProvider {
  475. static var previews: some View {
  476. SettingsView()
  477. }
  478. }