Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions VirtualCore/Source/Models/VBVirtualMachine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added for backwards compatibility

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
Expand Down
6 changes: 6 additions & 0 deletions VirtualCore/Source/Virtualization/VMController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
6 changes: 6 additions & 0 deletions VirtualCore/Source/Virtualization/VMInstance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
216 changes: 216 additions & 0 deletions VirtualUI/Source/Session/Components/VMSnapshotHoverOverlay.swift
Original file line number Diff line number Diff line change
@@ -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
34 changes: 22 additions & 12 deletions VirtualUI/Source/Session/VirtualMachineSessionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Zstack is used to add layers ontop of the views (z-axis layers). In this case I am adding a hover over menu that needs to cover the entire VM preview area without changing it.

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)
}
}

Expand Down