diff --git a/VirtualCore/Source/Models/VBVirtualMachine.swift b/VirtualCore/Source/Models/VBVirtualMachine.swift index 1bf5c23b..1f3898a2 100644 --- a/VirtualCore/Source/Models/VBVirtualMachine.swift +++ b/VirtualCore/Source/Models/VBVirtualMachine.swift @@ -19,6 +19,9 @@ public struct VBVirtualMachine: Identifiable, VBStorageDeviceContainer { public var backgroundHash: BlurHashToken = .virtualBuddyBackground /// If this VM was imported from some other app, contains the name of the ``VMImporter`` that was used. public var importedFromAppName: String? = nil + /// Controls whether screenshots and thumbnails are automatically generated for this VM + @DecodableDefault.True + public var screenshotGenerationEnabled: Bool = true /// The original remote URL that was specified for downloading the restore image (if downloaded from a remote source). public private(set) var remoteInstallImageURL: URL? = nil diff --git a/VirtualCore/Source/Virtualization/VMController.swift b/VirtualCore/Source/Virtualization/VMController.swift index 607da105..d7ea8c88 100644 --- a/VirtualCore/Source/Virtualization/VMController.swift +++ b/VirtualCore/Source/Virtualization/VMController.swift @@ -373,6 +373,12 @@ public final class VMController: ObservableObject { } public func storeScreenshot(with data: Data) { + // Skip storing if screenshot generation is disabled for this VM + guard virtualMachineModel.metadata.screenshotGenerationEnabled else { + logger.debug("Screenshot generation disabled for VM \(virtualMachineModel.name), skipping screenshot storage") + return + } + do { try virtualMachineModel.write(data, forMetadataFileNamed: VBVirtualMachine.screenshotFileName) try virtualMachineModel.invalidateThumbnail() diff --git a/VirtualCore/Source/Virtualization/VMInstance.swift b/VirtualCore/Source/Virtualization/VMInstance.swift index f3c834dc..743bee88 100644 --- a/VirtualCore/Source/Virtualization/VMInstance.swift +++ b/VirtualCore/Source/Virtualization/VMInstance.swift @@ -226,6 +226,12 @@ public final class VMInstance: NSObject, ObservableObject { let task = Task { do { for await message in try await wormhole.desktopPictureMessages(from: virtualMachineModel.wormholeID) { + // Skip processing if screenshot generation is disabled for this VM + guard virtualMachineModel.metadata.screenshotGenerationEnabled else { + logger.debug("Screenshot generation disabled for VM \(virtualMachineModel.name), skipping desktop picture message") + continue + } + do { let fileURL = virtualMachineModel.metadataFileURL(VBVirtualMachine.thumbnailFileName) diff --git a/VirtualUI/Source/Session/Components/VMSnapshotHoverOverlay.swift b/VirtualUI/Source/Session/Components/VMSnapshotHoverOverlay.swift new file mode 100644 index 00000000..34c9dce9 --- /dev/null +++ b/VirtualUI/Source/Session/Components/VMSnapshotHoverOverlay.swift @@ -0,0 +1,216 @@ +// +// VMSnapshotHoverOverlay.swift +// VirtualUI +// +// Created for VirtualBuddy VM snapshot hover functionality. +// + +import SwiftUI +import VirtualCore +import AppKit +import UniformTypeIdentifiers + +struct VMSnapshotHoverOverlay: View { + @EnvironmentObject private var controller: VMController + @State private var isHovered = false + + var body: some View { + GeometryReader { geometry in + ZStack { + // Invisible full coverage area for hover detection + Rectangle() + .fill(Color.clear) + .contentShape(Rectangle()) + + if isHovered { + // Semi-transparent overlay + Rectangle() + .fill(Color.black.opacity(0.4)) + .transition(.opacity) + + // Button container + VStack(spacing: 16) { + Spacer() + + HStack(spacing: 16) { + // Save Image button + Button { + saveScreenshotToHost() + } label: { + HStack(spacing: 8) { + Image(systemName: "square.and.arrow.down") + Text("Save Image") + } + .font(.system(size: 14, weight: .medium)) + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + .buttonStyle(OverlayButtonStyle()) + + // Disable Preview button + Button { + toggleScreenshotGeneration() + } label: { + HStack(spacing: 8) { + Image(systemName: controller.virtualMachineModel.metadata.screenshotGenerationEnabled ? "eye.slash" : "eye") + Text(controller.virtualMachineModel.metadata.screenshotGenerationEnabled ? "Disable Previews" : "Enable Previews") + } + .font(.system(size: 14, weight: .medium)) + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + .buttonStyle(OverlayButtonStyle()) + } + + Spacer() + } + } + } + } + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.2)) { + isHovered = hovering + } + } + } + + private func saveScreenshotToHost() { + guard let screenshot = controller.virtualMachineModel.screenshot else { + NSAlert.runInformationAlert( + title: "No Screenshot Available", + message: "There is no screenshot available for this virtual machine." + ) + return + } + + let savePanel = NSSavePanel() + savePanel.title = "Save VM Screenshot" + savePanel.nameFieldStringValue = "\(controller.virtualMachineModel.name) Screenshot" + savePanel.allowedContentTypes = [.png, .jpeg, .heic] + savePanel.canCreateDirectories = true + + savePanel.begin { response in + guard response == .OK, let url = savePanel.url else { return } + + // Convert to the appropriate format based on file extension + let fileExtension = url.pathExtension.lowercased() + let success: Bool + + switch fileExtension { + case "png": + success = screenshot.pngWrite(to: url) + case "jpg", "jpeg": + success = screenshot.jpegWrite(to: url, compressionFactor: 0.9) + case "heic": + success = (try? screenshot.vb_encodeHEIC(to: url)) != nil + default: + // Default to PNG + success = screenshot.pngWrite(to: url.appendingPathExtension("png")) + } + + if !success { + NSAlert.runErrorAlert( + title: "Save Failed", + message: "Failed to save the screenshot to \(url.path)." + ) + } + } + } + + private func toggleScreenshotGeneration() { + let currentlyEnabled = controller.virtualMachineModel.metadata.screenshotGenerationEnabled + controller.virtualMachineModel.metadata.screenshotGenerationEnabled = !currentlyEnabled + + // Force save the metadata + do { + try controller.virtualMachineModel.saveMetadata() + } catch { + NSAlert.runErrorAlert( + title: "Settings Save Failed", + message: "Failed to save the screenshot generation setting: \(error.localizedDescription)" + ) + // Revert the change + controller.virtualMachineModel.metadata.screenshotGenerationEnabled = currentlyEnabled + } + } +} + +// Custom button style for overlay buttons +private struct OverlayButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Material.thick) + .shadow(color: .black.opacity(0.3), radius: 4, x: 0, y: 2) + ) + .foregroundColor(.primary) + .scaleEffect(configuration.isPressed ? 0.95 : 1.0) + .animation(.easeInOut(duration: 0.1), value: configuration.isPressed) + } +} + +// Extensions for NSImage saving +private extension NSImage { + func pngWrite(to url: URL) -> Bool { + guard let tiffRepresentation = tiffRepresentation, + let bitmapImage = NSBitmapImageRep(data: tiffRepresentation), + let pngData = bitmapImage.representation(using: .png, properties: [:]) else { + return false + } + + do { + try pngData.write(to: url) + return true + } catch { + return false + } + } + + func jpegWrite(to url: URL, compressionFactor: CGFloat = 0.9) -> Bool { + guard let tiffRepresentation = tiffRepresentation, + let bitmapImage = NSBitmapImageRep(data: tiffRepresentation), + let jpegData = bitmapImage.representation(using: .jpeg, properties: [.compressionFactor: compressionFactor]) else { + return false + } + + do { + try jpegData.write(to: url) + return true + } catch { + return false + } + } +} + +// Extensions for NSAlert convenience +private extension NSAlert { + static func runInformationAlert(title: String, message: String) { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.alertStyle = .informational + alert.addButton(withTitle: "OK") + alert.runModal() + } + + static func runErrorAlert(title: String, message: String) { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.alertStyle = .critical + alert.addButton(withTitle: "OK") + alert.runModal() + } +} + +#if DEBUG +struct VMSnapshotHoverOverlay_Previews: PreviewProvider { + static var previews: some View { + VMSnapshotHoverOverlay() + .environmentObject(VMController.preview) + .frame(width: 400, height: 300) + .background(Color.blue.opacity(0.3)) + } +} +#endif \ No newline at end of file diff --git a/VirtualUI/Source/Session/VirtualMachineSessionView.swift b/VirtualUI/Source/Session/VirtualMachineSessionView.swift index 8a092368..4daadce8 100644 --- a/VirtualUI/Source/Session/VirtualMachineSessionView.swift +++ b/VirtualUI/Source/Session/VirtualMachineSessionView.swift @@ -139,25 +139,35 @@ public struct VirtualMachineSessionView: View { } } .animation(.bouncy, value: controller.state) + + // Add hover overlay for screenshot functionality + VMSnapshotHoverOverlay() + .environmentObject(controller) } } private func startableStateView(with error: Error?) -> some View { - VStack(spacing: 28) { - if let error = error { - Text("The machine has stopped due to an error: \(String(describing: error))") - .multilineTextAlignment(.center) - .foregroundStyle(.secondary) - .lineLimit(nil) - .font(.caption) + ZStack { + VStack(spacing: 28) { + if let error = error { + Text("The machine has stopped due to an error: \(String(describing: error))") + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .lineLimit(nil) + .font(.caption) + } + + circularStartButton + + VMSessionConfigurationView() + .environment(\.backgroundMaterial, Material.thin) + .environmentObject(controller) + .frame(maxWidth: 400) } - circularStartButton - - VMSessionConfigurationView() - .environment(\.backgroundMaterial, Material.thin) + // Add hover overlay for screenshot functionality when VM is stopped/idle + VMSnapshotHoverOverlay() .environmentObject(controller) - .frame(maxWidth: 400) } }