diff --git a/VPhoneExecutable/VPhoneVirtualization/UI/GuestCommunication/VPhoneClipboardSync.swift b/VPhoneExecutable/VPhoneVirtualization/UI/GuestCommunication/VPhoneClipboardSync.swift new file mode 100644 index 0000000..ce3dee8 --- /dev/null +++ b/VPhoneExecutable/VPhoneVirtualization/UI/GuestCommunication/VPhoneClipboardSync.swift @@ -0,0 +1,138 @@ +import AppKit +import Foundation +import VPhoneCoreKit + +// MARK: - Clipboard Sync + +/// ⌘C, ⌘X and ⌘V in the VM window carry the clipboard through vphoned. +/// Paste sends a changed Mac clipboard to the guest before the guest pastes; +/// copy and cut bring the guest clipboard back once the guest has written it. +/// Only small text and images are synced (`VPhoneClipboardTransfer`). +@MainActor +final class VPhoneClipboardSync { + private weak var control: VPhoneGuestControl? + /// The Mac clipboard as last sent to or written from the guest. An + /// unchanged Mac clipboard is not sent again, so a guest copy made by + /// touch is not overwritten by an older Mac one. + private var syncedMacChangeCount: Int? + private var copyTask: Task? + + /// How long copy waits for the guest to write its clipboard. + private static let copyPolls = 20 + private static let copyPollInterval: Duration = .milliseconds(100) + + init(control: VPhoneGuestControl) { + self.control = control + } + + var isAvailable: Bool { + guard let control else { return false } + return control.isConnected && control.guestCapabilities.contains("clipboard") + } + + // MARK: - Actions + + func paste() { + guard let control else { return } + let pasteboard = NSPasteboard.general + let changeCount = pasteboard.changeCount + let payload = changeCount == syncedMacChangeCount ? nil : Self.macPayload(from: pasteboard) + Task { + if let payload { + do { + switch payload { + case let .text(text): try await control.clipboardSet(text: text) + case let .image(data): try await control.clipboardSet(imageData: data) + } + syncedMacChangeCount = changeCount + } catch { + print("[clipboard] Mac to guest: \(error)") + } + } + Self.sendChord(usage: Self.usageV, to: control) + } + } + + func copy(cut: Bool) { + guard let control else { return } + copyTask?.cancel() + copyTask = Task { + // The count read before the key, so any later change is this copy. + let before = try? await control.clipboardInfoAfterQueuedInput().changeCount + Self.sendChord(usage: cut ? Self.usageX : Self.usageC, to: control) + guard let before else { return } + for _ in 0 ..< Self.copyPolls { + try? await Task.sleep(for: Self.copyPollInterval) + guard !Task.isCancelled else { return } + guard let info = try? await control.clipboardInfoAfterQueuedInput(), + info.changeCount != before + else { continue } + await writeToMac(info, control: control) + return + } + } + } + + // MARK: - Guest to Mac + + private func writeToMac(_ info: VPhoneGuestControl.ClipboardContent, control: VPhoneGuestControl) async { + let pasteboard = NSPasteboard.general + switch VPhoneClipboardTransfer.guestKind(text: info.text, hasImage: info.hasImage) { + case .text: + guard let text = info.text, VPhoneClipboardTransfer.fits(text: text) else { return } + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + case .image: + guard let png = try? await control.clipboardImagePNG(), + VPhoneClipboardTransfer.fits(image: png) + else { return } + pasteboard.clearContents() + pasteboard.setData(png, forType: .png) + case nil: + return + } + syncedMacChangeCount = pasteboard.changeCount + } + + // MARK: - Mac to Guest + + private enum Payload { + case text(String) + case image(Data) + } + + private static func macPayload(from pasteboard: NSPasteboard) -> Payload? { + let types = pasteboard.types?.map(\.rawValue) ?? [] + switch VPhoneClipboardTransfer.macKind(types: types) { + case .text: + guard let text = pasteboard.string(forType: .string), VPhoneClipboardTransfer.fits(text: text) + else { return nil } + return .text(text) + case let .image(type): + guard var data = pasteboard.data(forType: NSPasteboard.PasteboardType(type)) else { return nil } + if !VPhoneClipboardTransfer.guestImageTypes.contains(type) { + guard let png = NSBitmapImageRep(data: data)?.representation(using: .png, properties: [:]) + else { return nil } + data = png + } + return VPhoneClipboardTransfer.fits(image: data) ? .image(data) : nil + case nil: + return nil + } + } + + // MARK: - Keys + + /// Keyboard page (0x07) usages. + private static let usageC: UInt32 = 0x06 + private static let usageV: UInt32 = 0x19 + private static let usageX: UInt32 = 0x1B + private static let usageLeftCommand: UInt32 = 0xE3 + + /// ⌘ plus one key, queued behind any input already sent. + private static func sendChord(usage: UInt32, to control: VPhoneGuestControl) { + control.sendHIDDown(page: 0x07, usage: usageLeftCommand) + control.sendHIDPress(page: 0x07, usage: usage) + control.sendHIDUp(page: 0x07, usage: usageLeftCommand) + } +} diff --git a/VPhoneExecutable/VPhoneVirtualization/UI/GuestCommunication/VPhoneGuestControl.swift b/VPhoneExecutable/VPhoneVirtualization/UI/GuestCommunication/VPhoneGuestControl.swift index b416aa2..f08224f 100644 --- a/VPhoneExecutable/VPhoneVirtualization/UI/GuestCommunication/VPhoneGuestControl.swift +++ b/VPhoneExecutable/VPhoneVirtualization/UI/GuestCommunication/VPhoneGuestControl.swift @@ -406,6 +406,26 @@ final class VPhoneGuestControl { ) } + /// The guest clipboard without its image, read after the input already + /// queued so it sees the effect of a key sent before it. + func clipboardInfoAfterQueuedInput() async throws -> ClipboardContent { + let info = try await callAfterQueuedInput("clipboard.get") + return ClipboardContent( + text: info["text"] as? String, + types: info["types"] as? [String] ?? [], + hasImage: info["has_image"] as? Bool ?? false, + changeCount: info["change_count"] as? Int ?? 0, + imageData: nil, + ) + } + + /// The guest clipboard image as PNG. + func clipboardImagePNG() async throws -> Data { + let response = try await http(method: "GET", path: "/v1/clipboard/image") + guard response.status == 200 else { throw try httpError(response) } + return response.body + } + func clipboardSet(text: String) async throws { _ = try await call("clipboard.set", params: ["text": text]) } diff --git a/VPhoneExecutable/VPhoneVirtualization/UI/UserInterface/VPhoneApplication.swift b/VPhoneExecutable/VPhoneVirtualization/UI/UserInterface/VPhoneApplication.swift new file mode 100644 index 0000000..3c7f5e9 --- /dev/null +++ b/VPhoneExecutable/VPhoneVirtualization/UI/UserInterface/VPhoneApplication.swift @@ -0,0 +1,38 @@ +import AppKit + +// MARK: - Application + +/// Offers each key press in the VM window to the main menu before the VM view +/// can take it. +/// +/// `capturesSystemKeys` makes `VZVirtualMachineView` install a local event +/// monitor that sends every key to the guest and swallows it. AppKit runs +/// local monitors in the order they were added, and the view adds its own +/// again each time it becomes first responder or its window becomes key, so +/// a monitor of ours always runs after it. `sendEvent(_:)` runs before any +/// local monitor, so the menu is asked here instead. +final class VPhoneApplication: NSApplication { + /// Keys whose key-down a menu item took. Their key-up is dropped too, so + /// the guest never sees a release without a press. + private var menuKeyCodes = Set() + + override func sendEvent(_ event: NSEvent) { + guard event.type == .keyDown || event.type == .keyUp, + event.window?.firstResponder is VPhoneVirtualMachineView + else { + super.sendEvent(event) + return + } + if event.type == .keyUp { + if menuKeyCodes.remove(event.keyCode) == nil { + super.sendEvent(event) + } + return + } + if mainMenu?.performKeyEquivalent(with: event) == true { + menuKeyCodes.insert(event.keyCode) + return + } + super.sendEvent(event) + } +} diff --git a/VPhoneExecutable/VPhoneVirtualization/UI/UserInterface/VPhoneVirtualMachineWindowController.swift b/VPhoneExecutable/VPhoneVirtualization/UI/UserInterface/VPhoneVirtualMachineWindowController.swift index 9737424..c9ed959 100644 --- a/VPhoneExecutable/VPhoneVirtualization/UI/UserInterface/VPhoneVirtualMachineWindowController.swift +++ b/VPhoneExecutable/VPhoneVirtualization/UI/UserInterface/VPhoneVirtualMachineWindowController.swift @@ -8,7 +8,6 @@ class VPhoneVirtualMachineWindowController: NSObject { private weak var control: VPhoneGuestControl? private weak var virtualMachineView: VPhoneVirtualMachineView? private(set) var touchIDMonitor: VPhoneTouchIDMonitor? - private var menuKeyMonitor: Any? private var homeButton: NSButton? private var subtitleLabel: NSTextField? @@ -33,6 +32,7 @@ class VPhoneVirtualMachineWindowController: NSObject { view.capturesSystemKeys = true view.keySender = keySender view.control = control + view.clipboardSync = VPhoneClipboardSync(control: control) virtualMachineView = view let vmView: NSView = view @@ -83,17 +83,6 @@ class VPhoneVirtualMachineWindowController: NSObject { controller.showWindow(nil) windowController = controller - // capturesSystemKeys lets the VM view take every shortcut before the menu - // bar sees it. Offer each key press to the menu first; the guest gets - // only what no enabled menu item handles. - menuKeyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak window] event in - let handledByMenu = MainActor.assumeIsolated { - guard let window, event.window === window else { return false } - return NSApp.mainMenu?.performKeyEquivalent(with: event) == true - } - return handledByMenu ? nil : event - } - keySender.window = window NSApp.activate(ignoringOtherApps: true) window.makeKeyAndOrderFront(nil) @@ -209,8 +198,12 @@ class VPhoneVirtualMachineWindowController: NSObject { guard let window = windowController?.window else { return } var parts: [String] = [] if control.isConnected { - if let version = control.guestIOSVersion, !version.isEmpty { parts.append("iOS \(version)") } - if let address = control.guestIPAddress, !address.isEmpty { parts.append(address) } + if let version = control.guestIOSVersion, !version.isEmpty { + parts.append("iOS \(version)") + } + if let address = control.guestIPAddress, !address.isEmpty { + parts.append(address) + } } let subtitle = parts.joined(separator: " - ") if window.subtitle != subtitle { diff --git a/VPhoneExecutable/VPhoneVirtualization/UI/VPhoneGuestApp.swift b/VPhoneExecutable/VPhoneVirtualization/UI/VPhoneGuestApp.swift index 9694c28..b8a85d3 100644 --- a/VPhoneExecutable/VPhoneVirtualization/UI/VPhoneGuestApp.swift +++ b/VPhoneExecutable/VPhoneVirtualization/UI/VPhoneGuestApp.swift @@ -16,7 +16,8 @@ public enum VPhoneGuestApp { /// isolation was implicit, and moving the code into a function loses it. @MainActor public static func run(_ boot: VPhoneBootCommand) -> Never { - let app = NSApplication.shared + // The first `shared` call picks the class, so this must come first. + let app = VPhoneApplication.shared let delegate = VPhoneVirtualMachineAppDelegate(command: boot) app.delegate = delegate app.run() diff --git a/VPhoneExecutable/VPhoneVirtualization/UI/VirtualMachine/VPhoneVirtualMachineView.swift b/VPhoneExecutable/VPhoneVirtualization/UI/VirtualMachine/VPhoneVirtualMachineView.swift index 402e2ba..2ea0040 100644 --- a/VPhoneExecutable/VPhoneVirtualization/UI/VirtualMachine/VPhoneVirtualMachineView.swift +++ b/VPhoneExecutable/VPhoneVirtualization/UI/VirtualMachine/VPhoneVirtualMachineView.swift @@ -80,6 +80,25 @@ class VPhoneVirtualMachineView: VZVirtualMachineView { keySender.sendHome() } + // MARK: - Clipboard + + /// The Edit menu's Copy, Cut and Paste reach the guest through here, so + /// ⌘C, ⌘X and ⌘V sync the clipboard. Without a connected agent the items + /// are disabled and the keys go to the guest unchanged. + var clipboardSync: VPhoneClipboardSync? + + @objc func copy(_: Any?) { + clipboardSync?.copy(cut: false) + } + + @objc func cut(_: Any?) { + clipboardSync?.copy(cut: true) + } + + @objc func paste(_: Any?) { + clipboardSync?.paste() + } + // MARK: - Drag and Drop /// Every dropped file goes through vphoned: an .ipa or .tipa is installed, @@ -173,12 +192,10 @@ class VPhoneVirtualMachineView: VZVirtualMachineView { /// Convert screenshot pixel coordinates to NSView local coordinates. private func pixelToLocal(pixelX: Double, pixelY: Double, screenWidth: Int, screenHeight: Int) -> NSPoint { - let w = bounds.width - let h = bounds.height - let localX = pixelX / Double(screenWidth) * w - // Screenshot y=0 is top, NSView y=0 is bottom (non-flipped) - let localY = (1.0 - pixelY / Double(screenHeight)) * h - return NSPoint(x: localX, y: localY) + displayGeometry.viewPoint(normalized: CGPoint( + x: pixelX / Double(screenWidth), + y: pixelY / Double(screenHeight), + )) } /// Synthesize an NSEvent at a given window point. @@ -310,55 +327,34 @@ class VPhoneVirtualMachineView: VZVirtualMachineView { // MARK: - Coordinate Helpers + /// The guest display as drawn in this view. Full screen letterboxes it, + /// so touches are measured against it rather than `bounds`. + private var displayGeometry: VPhoneDisplayGeometry { + VPhoneDisplayGeometry( + viewBounds: bounds, + displaySize: recordingGraphicsDisplay?.sizeInPixels ?? .zero, + isFlipped: isFlipped, + ) + } + private func normalizeCoordinate(_ localPoint: NSPoint) -> CGPoint { - let w = bounds.width - let h = bounds.height - - guard w > 0, h > 0 else { return .zero } - - var nx = Double(localPoint.x / w) - var ny = Double(localPoint.y / h) - - // Clamp - nx = max(0.0, min(1.0, nx)) - ny = max(0.0, min(1.0, ny)) - - if !isFlipped { - ny = 1.0 - ny - } - - return CGPoint(x: nx, y: ny) + displayGeometry.normalizedPoint(localPoint) } private func hitTestEdge(at point: CGPoint) -> Int { - let w = bounds.width - let h = bounds.height - - let edgeThreshold: CGFloat = 32.0 - - let distLeft = point.x - let distRight = w - point.x - let distTop = isFlipped ? point.y : (h - point.y) - let distBottom = isFlipped ? (h - point.y) : point.y - - var minDist = distLeft - var edgeCode = 8 // Left - - if distRight < minDist { - minDist = distRight - edgeCode = 4 // Right + displayGeometry.edge(at: point).rawValue + } +} + +// MARK: - Menu Validation + +extension VPhoneVirtualMachineView: NSMenuItemValidation { + func validateMenuItem(_ item: NSMenuItem) -> Bool { + switch item.action { + case #selector(copy(_:)), #selector(cut(_:)), #selector(paste(_:)): + clipboardSync?.isAvailable == true + default: + true } - - if distBottom < minDist { - minDist = distBottom - edgeCode = 2 // Bottom (Home bar swipe up) - } - - if distTop < minDist { - minDist = distTop - edgeCode = 1 // Top (Notification Center) - } - - return minDist < edgeThreshold ? edgeCode : 0 } } diff --git a/VPhoneKit/VPhoneCoreKit/Support/VPhoneClipboardTransfer.swift b/VPhoneKit/VPhoneCoreKit/Support/VPhoneClipboardTransfer.swift new file mode 100644 index 0000000..4ad52c2 --- /dev/null +++ b/VPhoneKit/VPhoneCoreKit/Support/VPhoneClipboardTransfer.swift @@ -0,0 +1,60 @@ +import Foundation + +// MARK: - Clipboard Transfer + +/// What ⌘C and ⌘V carry between the Mac clipboard and the guest clipboard. +/// Only text and images are synced, and only when they are small: a file, +/// rich text or a large image stays on its own side. +public enum VPhoneClipboardTransfer { + /// Largest text synced, in UTF-8 bytes. + public static let maxTextBytes = 1024 * 1024 + /// Largest image synced, in encoded bytes. + public static let maxImageBytes = 16 * 1024 * 1024 + + /// Image types the guest decodes directly. Any other image is converted + /// to PNG before it is sent. + public static let guestImageTypes: Set = ["public.png", "public.jpeg", "public.heic"] + /// Image types taken from the Mac clipboard. + public static let macImageTypes: Set = guestImageTypes.union(["public.tiff"]) + /// Plain text on the Mac clipboard. + public static let macTextType = "public.utf8-plain-text" + + public enum Kind: Equatable, Sendable { + case text + /// An image, read from the Mac clipboard as this type. + case image(type: String) + } + + /// The representation to send for a Mac clipboard whose types are listed + /// in the owner's order of preference. The first text or image type wins, + /// so a copied screenshot sends its image and copied text sends its text + /// even when an image rendering is offered after it. + public static func macKind(types: [String]) -> Kind? { + for type in types { + if type == macTextType { + return .text + } + if macImageTypes.contains(type) { + return .image(type: type) + } + } + return nil + } + + /// The representation to take from the guest clipboard. Text wins over an + /// image, since an app that offers both usually copied text. + public static func guestKind(text: String?, hasImage: Bool) -> Kind? { + if let text, !text.isEmpty { + return .text + } + return hasImage ? .image(type: "public.png") : nil + } + + public static func fits(text: String) -> Bool { + text.utf8.count <= maxTextBytes + } + + public static func fits(image: Data) -> Bool { + !image.isEmpty && image.count <= maxImageBytes + } +} diff --git a/VPhoneKit/VPhoneCoreKit/VirtualMachine/VPhoneDisplayGeometry.swift b/VPhoneKit/VPhoneCoreKit/VirtualMachine/VPhoneDisplayGeometry.swift new file mode 100644 index 0000000..0a26037 --- /dev/null +++ b/VPhoneKit/VPhoneCoreKit/VirtualMachine/VPhoneDisplayGeometry.swift @@ -0,0 +1,92 @@ +import CoreGraphics + +// MARK: - Display Geometry + +/// Where the guest display sits inside the view that shows it. +/// +/// The view draws the display aspect-fit and centered. A windowed VM keeps +/// the display's aspect ratio, so the two coincide; a full-screen VM fills +/// the Mac screen and letterboxes the display. Touches must be measured +/// against the drawn display, not the view, or a letterboxed display maps +/// the bars onto the guest screen and the real edges land inside it. +public struct VPhoneDisplayGeometry: Equatable, Sendable { + /// Edge a touch starts near, as `_VZTouch`'s `swipeAim` takes it. + public enum Edge: Int, Sendable { + case none = 0 + case top = 1 + case bottom = 2 + case right = 4 + case left = 8 + } + + /// Distance, in view points, within which a touch starts an edge swipe. + public static let edgeThreshold: CGFloat = 32 + + /// The drawn display, in the view's coordinates. + public let displayRect: CGRect + /// Whether the view's y axis points down. + public let isFlipped: Bool + + /// A display with no size, or a view with no area, maps onto the whole + /// view: there is nothing better to measure against. + public init(viewBounds: CGRect, displaySize: CGSize, isFlipped: Bool) { + self.isFlipped = isFlipped + guard displaySize.width > 0, displaySize.height > 0, + viewBounds.width > 0, viewBounds.height > 0 + else { + displayRect = viewBounds + return + } + let scale = min(viewBounds.width / displaySize.width, viewBounds.height / displaySize.height) + let size = CGSize(width: displaySize.width * scale, height: displaySize.height * scale) + displayRect = CGRect( + x: viewBounds.midX - size.width / 2, + y: viewBounds.midY - size.height / 2, + width: size.width, + height: size.height, + ) + } + + /// A view point as a 0...1 display position with y down. Points on the + /// letterbox bars clamp to the nearest display edge. + public func normalizedPoint(_ point: CGPoint) -> CGPoint { + guard displayRect.width > 0, displayRect.height > 0 else { return .zero } + let x = (point.x - displayRect.minX) / displayRect.width + let y = (point.y - displayRect.minY) / displayRect.height + return CGPoint( + x: min(1, max(0, x)), + y: min(1, max(0, isFlipped ? y : 1 - y)), + ) + } + + /// The view point for a 0...1 display position with y down. + public func viewPoint(normalized point: CGPoint) -> CGPoint { + let y = isFlipped ? point.y : 1 - point.y + return CGPoint( + x: displayRect.minX + point.x * displayRect.width, + y: displayRect.minY + y * displayRect.height, + ) + } + + /// The display edge nearest to a view point, when that point is within + /// `edgeThreshold` of it. A point on a letterbox bar is past the edge + /// beside it and counts as near it. + public func edge(at point: CGPoint) -> Edge { + let left = point.x - displayRect.minX + let right = displayRect.maxX - point.x + let lowY = point.y - displayRect.minY + let highY = displayRect.maxY - point.y + let candidates: [(Edge, CGFloat)] = [ + (.left, left), + (.right, right), + (.bottom, isFlipped ? highY : lowY), + (.top, isFlipped ? lowY : highY), + ] + // Ties keep the earlier entry, so a corner resolves to left or right. + var nearest = candidates[0] + for candidate in candidates.dropFirst() where candidate.1 < nearest.1 { + nearest = candidate + } + return nearest.1 < Self.edgeThreshold ? nearest.0 : .none + } +} diff --git a/VPhoneKit/VPhoneCoreKitTests/Support/ClipboardTransferTests.swift b/VPhoneKit/VPhoneCoreKitTests/Support/ClipboardTransferTests.swift new file mode 100644 index 0000000..515de25 --- /dev/null +++ b/VPhoneKit/VPhoneCoreKitTests/Support/ClipboardTransferTests.swift @@ -0,0 +1,70 @@ +import Foundation +import Testing +@testable import VPhoneCoreKit + +struct ClipboardTransferTests { + // MARK: - Mac to Guest + + @Test func `copied text sends its text`() { + let types = ["public.utf8-plain-text", "public.rtf", "public.html"] + #expect(VPhoneClipboardTransfer.macKind(types: types) == .text) + } + + @Test func `a screenshot sends its image`() { + #expect(VPhoneClipboardTransfer.macKind(types: ["public.png"]) == .image(type: "public.png")) + #expect(VPhoneClipboardTransfer.macKind(types: ["public.tiff"]) == .image(type: "public.tiff")) + } + + @Test func `the owner's first type wins`() { + #expect( + VPhoneClipboardTransfer.macKind(types: ["public.tiff", "public.utf8-plain-text"]) + == .image(type: "public.tiff"), + ) + #expect(VPhoneClipboardTransfer.macKind(types: ["public.utf8-plain-text", "public.png"]) == .text) + } + + @Test func `a copied file sends its name as text`() { + let types = ["public.file-url", "com.apple.finder.node", "public.utf8-plain-text"] + #expect(VPhoneClipboardTransfer.macKind(types: types) == .text) + } + + @Test func `nothing syncable sends nothing`() { + #expect(VPhoneClipboardTransfer.macKind(types: []) == nil) + #expect(VPhoneClipboardTransfer.macKind(types: ["public.file-url", "public.rtf", "com.adobe.pdf"]) == nil) + } + + @Test func `TIFF is converted before it reaches the guest`() { + #expect(!VPhoneClipboardTransfer.guestImageTypes.contains("public.tiff")) + #expect(VPhoneClipboardTransfer.macImageTypes.isSuperset(of: VPhoneClipboardTransfer.guestImageTypes)) + } + + // MARK: - Guest to Mac + + @Test func `guest text wins over a guest image`() { + #expect(VPhoneClipboardTransfer.guestKind(text: "hello", hasImage: true) == .text) + #expect(VPhoneClipboardTransfer.guestKind(text: "", hasImage: true) == .image(type: "public.png")) + #expect(VPhoneClipboardTransfer.guestKind(text: nil, hasImage: true) == .image(type: "public.png")) + } + + @Test func `an empty guest clipboard brings nothing back`() { + #expect(VPhoneClipboardTransfer.guestKind(text: "", hasImage: false) == nil) + #expect(VPhoneClipboardTransfer.guestKind(text: nil, hasImage: false) == nil) + } + + // MARK: - Size + + @Test func `text is limited by its UTF-8 size`() { + let limit = VPhoneClipboardTransfer.maxTextBytes + #expect(VPhoneClipboardTransfer.fits(text: String(repeating: "a", count: limit))) + #expect(!VPhoneClipboardTransfer.fits(text: String(repeating: "a", count: limit + 1))) + // Three bytes each, so fewer characters than the limit still exceed it. + #expect(!VPhoneClipboardTransfer.fits(text: String(repeating: "界", count: limit / 3 + 1))) + } + + @Test func `images are limited and must not be empty`() { + let limit = VPhoneClipboardTransfer.maxImageBytes + #expect(VPhoneClipboardTransfer.fits(image: Data(count: limit))) + #expect(!VPhoneClipboardTransfer.fits(image: Data(count: limit + 1))) + #expect(!VPhoneClipboardTransfer.fits(image: Data())) + } +} diff --git a/VPhoneKit/VPhoneCoreKitTests/VirtualMachine/DisplayGeometryTests.swift b/VPhoneKit/VPhoneCoreKitTests/VirtualMachine/DisplayGeometryTests.swift new file mode 100644 index 0000000..25b18c0 --- /dev/null +++ b/VPhoneKit/VPhoneCoreKitTests/VirtualMachine/DisplayGeometryTests.swift @@ -0,0 +1,106 @@ +import CoreGraphics +import Testing +@testable import VPhoneCoreKit + +/// Issue #525: in full screen the display is letterboxed, and touches were +/// normalized against the whole view, so the bars mapped onto the screen. +struct DisplayGeometryTests { + /// A 1179x2556 guest at 3x in its own window. + static let display = CGSize(width: 1179, height: 2556) + static let window = CGRect(x: 0, y: 0, width: 393, height: 852) + /// The same guest full screen on a 1512x982 Mac screen. + static let fullScreen = CGRect(x: 0, y: 0, width: 1512, height: 982) + + static func geometry(_ bounds: CGRect, flipped: Bool = false) -> VPhoneDisplayGeometry { + VPhoneDisplayGeometry(viewBounds: bounds, displaySize: display, isFlipped: flipped) + } + + static func close(_ a: CGPoint, _ b: CGPoint) -> Bool { + abs(a.x - b.x) < 1e-6 && abs(a.y - b.y) < 1e-6 + } + + // MARK: - Display Rect + + @Test func `a window of the display's aspect ratio is all display`() { + let rect = Self.geometry(Self.window).displayRect + #expect(abs(rect.minX) < 0.5 && abs(rect.minY) < 0.5) + #expect(abs(rect.width - 393) < 0.5 && abs(rect.height - 852) < 0.5) + } + + @Test func `full screen centers the display between bars`() { + let rect = Self.geometry(Self.fullScreen).displayRect + #expect(rect.minY == 0 && rect.height == 982) + let expectedWidth = 982 * 1179.0 / 2556.0 + #expect(abs(rect.width - expectedWidth) < 1e-9) + #expect(abs(rect.minX - (1512 - expectedWidth) / 2) < 1e-9) + } + + @Test func `a wide display in a tall view gets bars above and below`() { + let geometry = VPhoneDisplayGeometry( + viewBounds: CGRect(x: 0, y: 0, width: 100, height: 400), + displaySize: CGSize(width: 200, height: 100), + isFlipped: false, + ) + #expect(geometry.displayRect == CGRect(x: 0, y: 175, width: 100, height: 50)) + } + + @Test func `an unknown display size falls back to the view`() { + let geometry = VPhoneDisplayGeometry(viewBounds: Self.fullScreen, displaySize: .zero, isFlipped: false) + #expect(geometry.displayRect == Self.fullScreen) + } + + // MARK: - Normalizing + + @Test func `full screen display edges normalize to 0 and 1`() { + let geometry = Self.geometry(Self.fullScreen) + let rect = geometry.displayRect + #expect(Self.close(geometry.normalizedPoint(CGPoint(x: rect.minX, y: rect.maxY)), CGPoint(x: 0, y: 0))) + #expect(Self.close(geometry.normalizedPoint(CGPoint(x: rect.maxX, y: rect.minY)), CGPoint(x: 1, y: 1))) + #expect(Self.close(geometry.normalizedPoint(CGPoint(x: rect.midX, y: rect.midY)), CGPoint(x: 0.5, y: 0.5))) + } + + @Test func `points on the bars clamp to the nearest display edge`() { + let geometry = Self.geometry(Self.fullScreen) + #expect(Self.close(geometry.normalizedPoint(CGPoint(x: 10, y: 491)), CGPoint(x: 0, y: 0.5))) + #expect(Self.close(geometry.normalizedPoint(CGPoint(x: 1500, y: 491)), CGPoint(x: 1, y: 0.5))) + } + + @Test func `a flipped view keeps y down`() { + let geometry = Self.geometry(Self.fullScreen, flipped: true) + let rect = geometry.displayRect + #expect(Self.close(geometry.normalizedPoint(CGPoint(x: rect.minX, y: rect.minY)), CGPoint(x: 0, y: 0))) + } + + @Test(arguments: [false, true]) + func `viewPoint inverts normalizedPoint`(flipped: Bool) { + let geometry = Self.geometry(Self.fullScreen, flipped: flipped) + for point in [CGPoint(x: 0, y: 0), CGPoint(x: 0.25, y: 0.8), CGPoint(x: 1, y: 1)] { + #expect(Self.close(geometry.normalizedPoint(geometry.viewPoint(normalized: point)), point)) + } + } + + // MARK: - Edges + + @Test func `edges are measured from the display, not the view`() { + let geometry = Self.geometry(Self.fullScreen) + let rect = geometry.displayRect + #expect(geometry.edge(at: CGPoint(x: rect.minX + 5, y: rect.midY)) == .left) + #expect(geometry.edge(at: CGPoint(x: rect.maxX - 5, y: rect.midY)) == .right) + #expect(geometry.edge(at: CGPoint(x: rect.midX, y: rect.minY + 5)) == .bottom) + #expect(geometry.edge(at: CGPoint(x: rect.midX, y: rect.maxY - 5)) == .top) + #expect(geometry.edge(at: CGPoint(x: rect.midX, y: rect.midY)) == .none) + } + + @Test func `a point on a bar is at the edge beside it`() { + let geometry = Self.geometry(Self.fullScreen) + #expect(geometry.edge(at: CGPoint(x: 10, y: 491)) == .left) + #expect(geometry.edge(at: CGPoint(x: 1500, y: 491)) == .right) + } + + @Test func `a flipped view swaps top and bottom`() { + let geometry = Self.geometry(Self.fullScreen, flipped: true) + let rect = geometry.displayRect + #expect(geometry.edge(at: CGPoint(x: rect.midX, y: rect.minY + 5)) == .top) + #expect(geometry.edge(at: CGPoint(x: rect.midX, y: rect.maxY - 5)) == .bottom) + } +}