diff --git a/Platform/Shared/VMConfigQEMUCommandLineView.swift b/Platform/Shared/VMConfigQEMUCommandLineView.swift new file mode 100644 index 00000000..41ff1580 --- /dev/null +++ b/Platform/Shared/VMConfigQEMUCommandLineView.swift @@ -0,0 +1,65 @@ +// +// Copyright © 2026 Turing Software, LLC. 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 + +/// Read-only view of the complete command line UTM runs. +struct VMConfigQEMUCommandLineView: View { + @ObservedObject var config: UTMQemuConfiguration + + /// One option per line, in the form of a shell command with line continuations. + private var commandLine: String { + var lines = ["qemu-system-\(config.system.architecture.rawValue)"] + for argument in config.allArguments { + let string = argument.string.contains(" ") ? "\"\(argument.string)\"" : argument.string + if string.hasPrefix("-") { + lines.append(string) + } else { + lines[lines.count - 1] += " \(string)" + } + } + return lines.joined(separator: " \\\n ") + } + + var body: some View { + #if os(macOS) + VStack(alignment: .leading) { + Text("Command Line") + .font(.headline) + List { + Text(commandLine) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + }.listStyle(.bordered) + } + #else + Section(header: Text("Command Line")) { + Text(commandLine) + .font(.system(.footnote, design: .monospaced)) + .textSelection(.enabled) + } + #endif + } +} + +struct VMConfigQEMUCommandLineView_Previews: PreviewProvider { + @State static private var config = UTMQemuConfiguration() + + static var previews: some View { + VMConfigQEMUCommandLineView(config: config) + .padding() + } +} diff --git a/Platform/Shared/VMConfigQEMUView.swift b/Platform/Shared/VMConfigQEMUView.swift index 73b5456d..f0e350fd 100644 --- a/Platform/Shared/VMConfigQEMUView.swift +++ b/Platform/Shared/VMConfigQEMUView.swift @@ -17,30 +17,25 @@ import SwiftUI struct VMConfigQEMUView: View { - private struct Argument: Identifiable { - let id: Int - let string: String - } - - @Binding var config: UTMQemuConfigurationQEMU - @Binding var system: UTMQemuConfigurationSystem - let fetchFixedArguments: () -> [QEMUArgument] + @ObservedObject var config: UTMQemuConfiguration + #if os(macOS) + @Binding var isCustomArgumentsEnabled: Bool + #endif @State private var showExportLog: Bool = false - @State private var showExportArgs: Bool = false private var logExists: Bool { - guard let debugLogURL = config.debugLogURL else { + guard let debugLogURL = config.qemu.debugLogURL else { return false } return FileManager.default.fileExists(atPath: debugLogURL.path) } private var supportsUefi: Bool { - UTMQemuConfigurationQEMU.uefiImagePrefix(forArchitecture: system.architecture) != nil + UTMQemuConfigurationQEMU.uefiImagePrefix(forArchitecture: config.system.architecture) != nil } private var supportsPs2: Bool { - if system.target.rawValue.starts(with: "pc") || system.target.rawValue.starts(with: "q35") { + if config.system.target.rawValue.starts(with: "pc") || config.system.target.rawValue.starts(with: "q35") { return true } else { return false @@ -51,7 +46,7 @@ struct VMConfigQEMUView: View { VStack { Form { Section(header: Text("Logging")) { - Toggle(isOn: $config.hasDebugLog, label: { + Toggle(isOn: $config.qemu.hasDebugLog, label: { Text("Debug Logging") }) Button("Export Debug Log") { @@ -60,167 +55,93 @@ struct VMConfigQEMUView: View { .disabled(!logExists) } DetailedSection("Tweaks", description: "These are advanced settings affecting QEMU which should be kept default unless you are running into issues.") { - Toggle("UEFI Boot", isOn: $config.hasUefiBoot) + Toggle("UEFI Boot", isOn: $config.qemu.hasUefiBoot) .disabled(!supportsUefi) .help("Should be off for older operating systems such as Windows 7 or lower.") - Toggle("RNG Device", isOn: $config.hasRNGDevice) + Toggle("RNG Device", isOn: $config.qemu.hasRNGDevice) .help("Should be on always unless the guest cannot boot because of this.") - Toggle("Balloon Device", isOn: $config.hasBalloonDevice) + Toggle("Balloon Device", isOn: $config.qemu.hasBalloonDevice) .help("Should be on always unless the guest cannot boot because of this.") - Toggle("TPM 2.0 Device", isOn: $config.hasTPMDevice) + Toggle("TPM 2.0 Device", isOn: $config.qemu.hasTPMDevice) .help("TPM can be used to protect secrets in the guest operating system. Note that the host will always be able to read these secrets and therefore no expectation of physical security is provided.") - .onChange(of: config.hasTPMDevice) { newValue in + .onChange(of: config.qemu.hasTPMDevice) { newValue in if newValue { - config.isUefiVariableResetRequested = true - config.hasPreloadedSecureBootKeys = true + config.qemu.isUefiVariableResetRequested = true + config.qemu.hasPreloadedSecureBootKeys = true } else { - config.hasPreloadedSecureBootKeys = false + config.qemu.hasPreloadedSecureBootKeys = false } } - Toggle("Use Hypervisor", isOn: $config.hasHypervisor) + Toggle("Use Hypervisor", isOn: $config.qemu.hasHypervisor) .help("Only available if host architecture matches the target. Otherwise, TCG emulation is used.") - .disabled(!system.architecture.hasHypervisorSupport) - if config.hasHypervisor { - Toggle("Use TSO", isOn: $config.hasTSO) + .disabled(!config.system.architecture.hasHypervisorSupport) + if config.qemu.hasHypervisor { + Toggle("Use TSO", isOn: $config.qemu.hasTSO) .help("Only available when Hypervisor is used on supported hardware. TSO speeds up Intel emulation in the guest at the cost of decreased performance in general.") - .disabled(!system.architecture.hasTSOSupport) + .disabled(!config.system.architecture.hasTSOSupport) } - Toggle("Use local time for base clock", isOn: $config.hasRTCLocalTime) + Toggle("Use local time for base clock", isOn: $config.qemu.hasRTCLocalTime) .help("If checked, use local time for RTC which is required for Windows. Otherwise, use UTC clock.") - Toggle("Force PS/2 controller", isOn: $config.hasPS2Controller) + Toggle("Force PS/2 controller", isOn: $config.qemu.hasPS2Controller) .disabled(!supportsPs2) .help("Instantiate PS/2 controller even when USB input is supported. Required for older Windows.") } DetailedSection("Maintenance", description: "Options here only apply on next boot and are not saved.") { - Toggle("Reset UEFI Variables", isOn: $config.isUefiVariableResetRequested) + Toggle("Reset UEFI Variables", isOn: $config.qemu.isUefiVariableResetRequested) .help("You can use this if your boot options are corrupted or if you wish to re-enroll in the default keys for secure boot.") - .disabled(!config.hasUefiBoot) - Toggle("Preload Secure Boot Keys", isOn: $config.hasPreloadedSecureBootKeys) + .disabled(!config.qemu.hasUefiBoot) + Toggle("Preload Secure Boot Keys", isOn: $config.qemu.hasPreloadedSecureBootKeys) .help("Enable Secure Boot with Microsoft UEFI keys. This is required to Secure Boot Windows.") - .disabled(!config.isUefiVariableResetRequested || !config.hasTPMDevice) - .onChange(of: config.isUefiVariableResetRequested) { newValue in + .disabled(!config.qemu.isUefiVariableResetRequested || !config.qemu.hasTPMDevice) + .onChange(of: config.qemu.isUefiVariableResetRequested) { newValue in if !newValue { - config.hasPreloadedSecureBootKeys = false + config.qemu.hasPreloadedSecureBootKeys = false } } } DetailedSection("QEMU Machine Properties", description: "This is appended to the -machine argument.") { - DefaultTextField("", text: $config.machinePropertyOverride.bound, prompt: "Default") + DefaultTextField("", text: $config.qemu.machinePropertyOverride.bound, prompt: "Default") + } + #if os(macOS) + DetailedSection("Custom Arguments", description: "Custom arguments are for debugging only. Compatibility with future versions is not guaranteed.") { + Toggle("Use Custom Arguments", isOn: $isCustomArgumentsEnabled) + .onChange(of: isCustomArgumentsEnabled) { newValue in + if !newValue { + config.qemu.additionalArguments.removeAll() + } + } + } + #else + Section(footer: Text("Custom arguments are for debugging only. Compatibility with future versions is not guaranteed.")) { + NavigationLink("Arguments") { + VMConfigQEMUArgumentsView(config: config) + .navigationTitle("Arguments") + } } - // macOS uses the new VMConfigQEMUArgumentsView - #if !os(macOS) - additionalArguments #endif - }.navigationBarItems(trailing: EditButton()) + } .disableAutocorrection(true) } } - @ViewBuilder - var additionalArguments: some View { - Section(header: Text("QEMU Arguments")) { - let fixedArgs = fetchFixedArguments() - Button("Export QEMU Command…") { - showExportArgs.toggle() - }.modifier(VMShareItemModifier(isPresented: $showExportArgs, shareItem: exportArgs(fixedArgs))) - List { - ForEach(fixedArgs) { arg in - Text(arg.string) - }.foregroundColor(.secondary) - CustomArguments(config: $config) - NewArgumentTextField(config: $config) - } - } - } - private func exportDebugLog() -> VMShareItemModifier.ShareItem? { - guard let srcLogPath = config.debugLogURL else { + guard let srcLogPath = config.qemu.debugLogURL else { return nil } return .debugLog(srcLogPath) } - - private func exportArgs(_ args: [QEMUArgument]) -> VMShareItemModifier.ShareItem { - var argString = "qemu-system-\(system.architecture.rawValue)" - for arg in args { - if arg.string.contains(" ") { - argString += " \"\(arg.string)\"" - } else { - argString += " \(arg.string)" - } - } - for arg in config.additionalArguments { - argString += " \(arg.string)" - } - return .qemuCommand(argString) - } -} - -struct CustomArguments: View { - @Binding var config: UTMQemuConfigurationQEMU - - var body: some View { - ForEach($config.additionalArguments) { $arg in - let i = config.additionalArguments.firstIndex(of: arg) ?? 0 - HStack { - DefaultTextField("", text: $arg.string, prompt: "(Delete)", onEditingChanged: { editing in - if !editing && arg.string == "" { - DispatchQueue.main.async { // SwiftUI doesn't like removing in a ForEach binding - config.additionalArguments.remove(at: i) - } - } - }) - #if os(macOS) - Spacer() - if i != 0 { - Button(action: { - config.additionalArguments.move(fromOffsets: IndexSet(integer: i), toOffset: i-1) - }, label: { - Label("Move Up", systemImage: "arrow.up").labelStyle(.iconOnly) - }) - } - #endif - } - }.onDelete { offsets in - config.additionalArguments.remove(atOffsets: offsets) - } - .onMove { offsets, index in - config.additionalArguments.move(fromOffsets: offsets, toOffset: index) - } - } -} - -struct NewArgumentTextField: View { - @Binding var config: UTMQemuConfigurationQEMU - @State private var newArg: String = "" - - var body: some View { - Group { - DefaultTextField("", text: $newArg, prompt: "New…", onEditingChanged: addArg) - }.onDisappear { - if newArg != "" { - addArg(editing: false) - } - } - } - - private func addArg(editing: Bool) { - guard !editing else { - return - } - if newArg != "" { - config.additionalArguments.append(QEMUArgument(newArg)) - } - newArg = "" - } } struct VMConfigQEMUView_Previews: PreviewProvider { - @State static private var config = UTMQemuConfigurationQEMU() - @State static private var system = UTMQemuConfigurationSystem() + @State static private var config = UTMQemuConfiguration() static var previews: some View { - VMConfigQEMUView(config: $config, system: $system, fetchFixedArguments: { [] }) + #if os(macOS) + VMConfigQEMUView(config: config, isCustomArgumentsEnabled: .constant(false)) .frame(minHeight: 500) + #else + VMConfigQEMUView(config: config) + .frame(minHeight: 500) + #endif } } diff --git a/Platform/Shared/VMShareFileModifier.swift b/Platform/Shared/VMShareFileModifier.swift index da329f6b..c22d89f0 100644 --- a/Platform/Shared/VMShareFileModifier.swift +++ b/Platform/Shared/VMShareFileModifier.swift @@ -42,7 +42,6 @@ struct VMShareItemModifier: ViewModifier { case debugLog(URL) case utmCopy(VMData) case utmMove(VMData) - case qemuCommand(String) @MainActor func toActivityItem() -> Any { switch self { @@ -50,8 +49,6 @@ struct VMShareItemModifier: ViewModifier { return url case .utmCopy(let vm), .utmMove(let vm): return vm.pathUrl - case .qemuCommand(let command): - return command } } } diff --git a/Platform/iOS/VMConfigQEMUArgumentsView.swift b/Platform/iOS/VMConfigQEMUArgumentsView.swift new file mode 100644 index 00000000..51d5558d --- /dev/null +++ b/Platform/iOS/VMConfigQEMUArgumentsView.swift @@ -0,0 +1,102 @@ +// +// Copyright © 2026 Turing Software, LLC. 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 VMConfigQEMUArgumentsView: View { + @ObservedObject var config: UTMQemuConfiguration + + var body: some View { + Form { + Section(header: Text("Custom Arguments")) { + ForEach($config.qemu.additionalArguments) { $argument in + NavigationLink { + QEMUArgumentEdit(argument: argument, + onSave: { argument = $0 }, + onDelete: { config.qemu.additionalArguments.removeAll(where: { $0.id == argument.id }) }) + } label: { + Text(argument.string) + .font(.system(.body, design: .monospaced)) + } + }.onDelete { offsets in + config.qemu.additionalArguments.remove(atOffsets: offsets) + }.onMove { offsets, destination in + config.qemu.additionalArguments.move(fromOffsets: offsets, toOffset: destination) + } + NavigationLink("New") { + QEMUArgumentEdit(onSave: { config.qemu.additionalArguments.append($0) }) + } + } + VMConfigQEMUCommandLineView(config: config) + }.toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + EditButton() + } + } + } +} + +struct QEMUArgumentEdit: View { + @State var argument: QEMUArgument = QEMUArgument("") + let onSave: (QEMUArgument) -> Void + var onDelete: (() -> Void)? = nil + @Environment(\.presentationMode) private var presentationMode: Binding + + var body: some View { + Form { + Section(header: Text("Argument")) { + TextField("-option value", text: $argument.string) + .font(.system(.body, design: .monospaced)) + .disableAutocorrection(true) + .textInputAutocapitalization(.never) + } + } + .navigationTitle(onDelete == nil ? "New Argument" : "Edit Argument") + .toolbar { + ToolbarItemGroup(placement: .navigationBarTrailing) { + if let onDelete = onDelete { + Button("Delete", role: .destructive) { + closePopup(after: onDelete) + } + } + Button("Save") { + closePopup(after: { onSave(argument) }) + }.disabled(argument.string.isEmpty) + } + } + } + + private func closePopup(after action: () -> Void) { + action() + presentationMode.wrappedValue.dismiss() + } +} + +struct VMConfigQEMUArgumentsView_Previews: PreviewProvider { + @State static private var config = UTMQemuConfiguration() + + static var previews: some View { + NavigationView { + VMConfigQEMUArgumentsView(config: config) + .navigationTitle("Arguments") + }.onAppear { + if config.qemu.additionalArguments.isEmpty { + config.qemu.additionalArguments.append(QEMUArgument("-append")) + config.qemu.additionalArguments.append(QEMUArgument("\"root=/dev/vda1 console=ttyS0\"")) + } + } + } +} diff --git a/Platform/iOS/VMSettingsView.swift b/Platform/iOS/VMSettingsView.swift index 17ba5c91..fa4fc2ea 100644 --- a/Platform/iOS/VMSettingsView.swift +++ b/Platform/iOS/VMSettingsView.swift @@ -51,9 +51,7 @@ struct VMSettingsView: View { } } NavigationLink( - destination: VMConfigQEMUView(config: $config.qemu, system: $config.system, fetchFixedArguments: { - config.generatedArguments - }).navigationTitle("QEMU"), + destination: VMConfigQEMUView(config: config).navigationTitle("QEMU"), label: { Label("QEMU", systemImage: "shippingbox") .labelStyle(.roundRectIcon) diff --git a/Platform/macOS/SavePanel.swift b/Platform/macOS/SavePanel.swift index 4509fd1a..53591f0c 100644 --- a/Platform/macOS/SavePanel.swift +++ b/Platform/macOS/SavePanel.swift @@ -50,10 +50,6 @@ struct SavePanel: NSViewRepresentable { savePanel.title = NSLocalizedString("Select where to save UTM Virtual Machine:", comment: "SavePanel") savePanel.nameFieldStringValue = vm.pathUrl.lastPathComponent savePanel.allowedContentTypes = [.UTM] - case .qemuCommand: - savePanel.title = NSLocalizedString("Select where to export QEMU command:", comment: "SavePanel") - savePanel.nameFieldStringValue = "command" - savePanel.allowedContentTypes = [.plainText] } // Calling savePanel.begin with the appropriate completion handlers @@ -95,17 +91,6 @@ struct SavePanel: NSViewRepresentable { } isPresented = false } - case .qemuCommand(let command): - savePanel.beginSheetModal(for: window) { result in - if result == .OK { - if let destUrl = savePanel.url { - data.busyWork { - try command.write(to: destUrl, atomically: true, encoding: .utf8) - } - } - } - isPresented = false - } } } } diff --git a/Platform/macOS/VMConfigQEMUArgumentsView.swift b/Platform/macOS/VMConfigQEMUArgumentsView.swift index 9cfefbd6..91d08cbd 100644 --- a/Platform/macOS/VMConfigQEMUArgumentsView.swift +++ b/Platform/macOS/VMConfigQEMUArgumentsView.swift @@ -17,130 +17,128 @@ import SwiftUI struct VMConfigQEMUArgumentsView: View { + @ObservedObject var config: UTMQemuConfiguration + @State private var selectedId: UUID? + @State private var isEditingNewArgument = false + @State private var editArgument: QEMUArgument? + + private var selectedIndex: Int? { + config.qemu.additionalArguments.firstIndex(where: { $0.id == selectedId }) + } + + var body: some View { + // GeometryReader keeps the ideal size small so the sheet does not grow to fit the whole command line + GeometryReader { _ in + VStack(alignment: .leading) { + Table(config.qemu.additionalArguments, selection: $selectedId) { + TableColumn("Custom Arguments") { argument in + Text(argument.string) + .font(.system(.body, design: .monospaced)) + } + } + .frame(height: 150) + HStack { + Spacer() + Button("Move Up") { + move(by: -1) + }.disabled(selectedIndex == nil || selectedIndex == 0) + Button("Move Down") { + move(by: 1) + }.disabled(selectedIndex == nil || selectedIndex == config.qemu.additionalArguments.count - 1) + Button("Delete") { + if let index = selectedIndex { + config.qemu.additionalArguments.remove(at: index) + selectedId = nil + } + }.disabled(selectedIndex == nil) + Button("Edit…", action: editSelected) + .disabled(selectedIndex == nil) + .popover(item: $editArgument, arrowEdge: .top) { argument in + QEMUArgumentEdit(config: $config.qemu, argument: argument).padding() + .frame(width: 400) + } + Button("New…") { + isEditingNewArgument.toggle() + }.popover(isPresented: $isEditingNewArgument, arrowEdge: .top) { + QEMUArgumentEdit(config: $config.qemu, argument: QEMUArgument("")).padding() + .frame(width: 400) + } + } + VMConfigQEMUCommandLineView(config: config) + }.padding() + } + } + + private func editSelected() { + editArgument = config.qemu.additionalArguments.first(where: { $0.id == selectedId }) + } + + private func move(by offset: Int) { + guard let index = selectedIndex else { + return + } + let destination = offset < 0 ? index - 1 : index + 2 + config.qemu.additionalArguments.move(fromOffsets: IndexSet(integer: index), toOffset: destination) + } +} + +struct QEMUArgumentEdit: View { @Binding var config: UTMQemuConfigurationQEMU - let architecture: QEMUArchitecture - let fixedArguments: [QEMUArgument] + @State var argument: QEMUArgument + @Environment(\.presentationMode) var presentationMode: Binding - private let fixedUuids: Set - @State private var selected: Set - @State private var selectedArgument = QEMUArgument("") - @FocusState private var focused: UUID? - @State private var showExportArgs: Bool = false - - private var customUuids: Set { - Set(config.additionalArguments.map({ $0.id })) - } - - private var exportShareItem: VMShareItemModifier.ShareItem { - var argString = "qemu-system-\(architecture.rawValue)" - for arg in fixedArguments { - if arg.string.contains(" ") { - argString += " \"\(arg.string)\"" - } else { - argString += " \(arg.string)" - } - } - for arg in config.additionalArguments { - argString += " \(arg.string)" - } - return .qemuCommand(argString) - } - - init(config: Binding, architecture: QEMUArchitecture, fixedArguments: [QEMUArgument]) { - self._config = config - self.architecture = architecture - self.fixedArguments = fixedArguments - self.fixedUuids = Set(fixedArguments.map({ $0.id })) - self._selected = State>(initialValue: .init()) + private var index: Int? { + config.additionalArguments.firstIndex(where: { $0.id == argument.id }) } var body: some View { VStack { - Table(of: QEMUArgument.self, selection: $selected) { - TableColumn("Arguments") { arg in - let customSelected = selected.intersection(customUuids) - if fixedUuids.contains(arg.id) || customSelected.count > 1 || !customSelected.contains(arg.id) { - Text(arg.string) - .foregroundColor(fixedUuids.contains(arg.id) ? .secondary : .primary) - .textSelection(.enabled) - } else { - TextField("", text: $selectedArgument.string) - .focused($focused, equals: arg.id) - .onSubmit(of: .text) { - if let index = config.additionalArguments.firstIndex(of: arg) { - config.additionalArguments[index] = selectedArgument - } - } - } - } - } rows: { - ForEach(fixedArguments) { arg in - TableRow(arg) - } - ForEach(config.additionalArguments) { arg in - TableRow(arg) - } - }.onChange(of: selected) { newValue in - // save changes to last selected argument - if let index = config.additionalArguments.firstIndex(where: { $0.id == selectedArgument.id }) { - config.additionalArguments[index] = selectedArgument - selectedArgument = .init("") - } - // get new selected argument - if let selectedId = selected.intersection(customUuids).first { - if let arg = config.additionalArguments.first(where: { $0.id == selectedId }) { - selectedArgument = arg - } - } - } - Spacer() + TextField("Argument", text: $argument.string, prompt: Text("-option value")) + .font(.system(.body, design: .monospaced)) + .disableAutocorrection(true) + .onSubmit(save) HStack { - Button { - showExportArgs.toggle() - } label: { - Text("Export QEMU Command…") - }.help("Export all arguments as a text file. This is only for debugging purposes as UTM's built-in QEMU differs from upstream QEMU in supported arguments.") Spacer() - let customSelected = selected.intersection(customUuids) - if !customSelected.isEmpty { - if customSelected.count > 1 || customSelected.first != config.additionalArguments.first?.id { - Button { - for i in 1.. 1 || customSelected.first != config.additionalArguments.last?.id { - Button { - for i in (0..