From 22cb7ca0831e5f61d30a1dfc59772dc50fec7f64 Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Thu, 24 Sep 2026 15:27:52 +0100 Subject: [PATCH 1/3] feat(viewer): work out where a capture preview sits Beside the device rather than over it, so the thing just captured is not hidden by the proof that it was. --- .../CapturePreviewLayout.swift | 57 ++++++++++++++ .../CapturePreviewLayoutTests.swift | 76 +++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 engine/Sources/OpenDeviceHubViewer/CapturePreviewLayout.swift create mode 100644 engine/Tests/OpenDeviceHubViewerTests/CapturePreviewLayoutTests.swift diff --git a/engine/Sources/OpenDeviceHubViewer/CapturePreviewLayout.swift b/engine/Sources/OpenDeviceHubViewer/CapturePreviewLayout.swift new file mode 100644 index 0000000..2c0168c --- /dev/null +++ b/engine/Sources/OpenDeviceHubViewer/CapturePreviewLayout.swift @@ -0,0 +1,57 @@ +import CoreGraphics + +/// Where a capture preview sits relative to the window it came from. +/// +/// Beside the device rather than over it, so the thing you just captured is never hidden by the +/// proof that you captured it. To the right by default, to the left when the right would run off +/// the display, and clamped to the display when neither side fits. +public enum CapturePreviewLayout { + public static let gap: CGFloat = 12 + /// Previews stack upwards from the window's bottom edge, newest lowest. + public static let stackStep: CGFloat = 8 + + public static func frame( + size: CGSize, + beside window: CGRect, + visible: CGRect, + index: Int = 0 + ) -> CGRect { + let toTheRight = window.maxX + gap + let toTheLeft = window.minX - gap - size.width + + let x: CGFloat + if toTheRight + size.width <= visible.maxX { + x = toTheRight + } else if toTheLeft >= visible.minX { + x = toTheLeft + } else { + // Neither side fits, so it goes as far right as the display allows and overlaps. + x = visible.maxX - size.width + } + + let stacked = window.minY + CGFloat(index) * (size.height + stackStep) + return CGRect( + x: clamp(x, low: visible.minX, high: visible.maxX - size.width), + y: clamp(stacked, low: visible.minY, high: visible.maxY - size.height), + width: size.width, + height: size.height + ) + } + + /// A card the same shape as the device, so a tall phone does not become a square thumbnail. + public static func size(for capture: CGSize, longestEdge: CGFloat = 200) -> CGSize { + guard capture.width > 0, capture.height > 0 else { + return CGSize(width: longestEdge, height: longestEdge) + } + let scale = longestEdge / max(capture.width, capture.height) + return CGSize( + width: max(1, (capture.width * scale).rounded()), + height: max(1, (capture.height * scale).rounded()) + ) + } + + private static func clamp(_ value: CGFloat, low: CGFloat, high: CGFloat) -> CGFloat { + guard high > low else { return low } + return min(max(value, low), high) + } +} diff --git a/engine/Tests/OpenDeviceHubViewerTests/CapturePreviewLayoutTests.swift b/engine/Tests/OpenDeviceHubViewerTests/CapturePreviewLayoutTests.swift new file mode 100644 index 0000000..f53e3d2 --- /dev/null +++ b/engine/Tests/OpenDeviceHubViewerTests/CapturePreviewLayoutTests.swift @@ -0,0 +1,76 @@ +import XCTest +@testable import OpenDeviceHubViewer + +/// Placement is the part that goes wrong silently: a preview off the edge of the display is +/// invisible, and one on top of the device hides what was captured. +final class CapturePreviewLayoutTests: XCTestCase { + private let display = CGRect(x: 0, y: 0, width: 1512, height: 900) + private let card = CGSize(width: 120, height: 200) + + func testItSitsToTheRightWhenThereIsRoom() { + let frame = CapturePreviewLayout.frame( + size: card, + beside: CGRect(x: 400, y: 100, width: 430, height: 700), + visible: display + ) + XCTAssertEqual(frame.minX, 830 + CapturePreviewLayout.gap) + } + + func testItMovesToTheLeftWhenTheRightWouldRunOff() { + let frame = CapturePreviewLayout.frame( + size: card, + beside: CGRect(x: 1300, y: 100, width: 200, height: 700), + visible: display + ) + XCTAssertEqual(frame.maxX, 1300 - CapturePreviewLayout.gap) + } + + func testWithNeitherSideFittingItStaysOnTheDisplay() { + let narrow = CGRect(x: 0, y: 0, width: 200, height: 900) + let frame = CapturePreviewLayout.frame( + size: card, + beside: CGRect(x: 10, y: 100, width: 180, height: 700), + visible: narrow + ) + XCTAssertGreaterThanOrEqual(frame.minX, narrow.minX) + XCTAssertLessThanOrEqual(frame.maxX, narrow.maxX) + } + + func testPreviewsStackUpwardsWithoutOverlapping() { + let window = CGRect(x: 400, y: 100, width: 430, height: 700) + let first = CapturePreviewLayout.frame(size: card, beside: window, visible: display, index: 0) + let second = CapturePreviewLayout.frame(size: card, beside: window, visible: display, index: 1) + XCTAssertGreaterThanOrEqual(second.minY, first.maxY) + } + + func testItNeverLeavesTheVisibleArea() { + for y in [-500.0, 0.0, 400.0, 2000.0] { + let frame = CapturePreviewLayout.frame( + size: card, + beside: CGRect(x: 400, y: y, width: 430, height: 700), + visible: display, + index: 3 + ) + XCTAssertGreaterThanOrEqual(frame.minY, display.minY, "y=\(y)") + XCTAssertLessThanOrEqual(frame.maxY, display.maxY, "y=\(y)") + } + } + + func testTheCardKeepsTheDeviceShape() { + let size = CapturePreviewLayout.size(for: CGSize(width: 1206, height: 2622), longestEdge: 200) + XCTAssertEqual(size.height, 200) + XCTAssertEqual(size.width, 92) + } + + func testALandscapeCaptureIsWiderThanItIsTall() { + let size = CapturePreviewLayout.size(for: CGSize(width: 2622, height: 1206), longestEdge: 200) + XCTAssertEqual(size.width, 200) + XCTAssertGreaterThan(size.width, size.height) + } + + func testAnEmptyCaptureDoesNotProduceAnImpossibleCard() { + let size = CapturePreviewLayout.size(for: .zero) + XCTAssertGreaterThan(size.width, 0) + XCTAssertGreaterThan(size.height, 0) + } +} From cf250400183d23d7e9563bfb521201eb1c5331de Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Thu, 24 Sep 2026 15:56:06 +0100 Subject: [PATCH 2/3] feat(viewer): hold a capture until its preview goes away The simulator this replaces writes nothing to the capture folder while the preview is up, confirmed by watching the folder during a capture. --- .../OpenDeviceHubViewer/PendingCapture.swift | 74 +++++++++++++++++ .../PendingCaptureTests.swift | 81 +++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 engine/Sources/OpenDeviceHubViewer/PendingCapture.swift create mode 100644 engine/Tests/OpenDeviceHubViewerTests/PendingCaptureTests.swift diff --git a/engine/Sources/OpenDeviceHubViewer/PendingCapture.swift b/engine/Sources/OpenDeviceHubViewer/PendingCapture.swift new file mode 100644 index 0000000..affe866 --- /dev/null +++ b/engine/Sources/OpenDeviceHubViewer/PendingCapture.swift @@ -0,0 +1,74 @@ +import Foundation + +/// A capture that has been taken but not yet filed. +/// +/// The simulator this replaces writes nothing to the capture folder while its preview is on screen: +/// the file appears only once the preview goes away without being acted on. Confirmed by watching +/// the folder during a capture. So a capture lands in a temporary place first, and this is what +/// decides where it ends up. +public struct PendingCapture: Equatable, Sendable { + public let temporary: URL + public let destination: URL + + public init(temporary: URL, destination: URL) { + self.temporary = temporary + self.destination = destination + } + + public var name: String { temporary.lastPathComponent } + + /// Where the file will be when nothing is done to it. + public var settled: URL { destination.appending(path: name) } +} + +/// Moving, saving and discarding a pending capture. Separated from the preview window so the rules +/// can be tested without anything on screen. +/// `FileManager` is thread safe but not marked `Sendable`, hence the unchecked conformance, the +/// same reason `UserDefaultsPreferenceStorage` carries one. +public struct CaptureFiler: @unchecked Sendable { + private let manager: FileManager + + public init(manager: FileManager = .default) { + self.manager = manager + } + + /// Moves the capture where it was always going. Returns where it landed, which is not always + /// what was asked for: a name already taken gets a number rather than overwriting. + @discardableResult + public func settle(_ capture: PendingCapture) throws -> URL { + try manager.createDirectory(at: capture.destination, withIntermediateDirectories: true) + let target = Self.availableURL(capture.settled, manager: manager) + try manager.moveItem(at: capture.temporary, to: target) + return target + } + + @discardableResult + public func save(_ capture: PendingCapture, to chosen: URL) throws -> URL { + if manager.fileExists(atPath: chosen.path(percentEncoded: false)) { + try manager.removeItem(at: chosen) + } + try manager.moveItem(at: capture.temporary, to: chosen) + return chosen + } + + public func discard(_ capture: PendingCapture) { + try? manager.removeItem(at: capture.temporary) + } + + /// `name.png`, then `name 2.png`, and so on. Overwriting someone's earlier capture because the + /// clock produced the same second is not a trade worth making. + static func availableURL(_ wanted: URL, manager: FileManager) -> URL { + guard manager.fileExists(atPath: wanted.path(percentEncoded: false)) else { return wanted } + let directory = wanted.deletingLastPathComponent() + let stem = wanted.deletingPathExtension().lastPathComponent + let ext = wanted.pathExtension + for suffix in 2...999 { + let candidate = directory + .appending(path: ext.isEmpty ? "\(stem) \(suffix)" : "\(stem) \(suffix).\(ext)") + if !manager.fileExists(atPath: candidate.path(percentEncoded: false)) { + return candidate + } + } + return wanted + } +} diff --git a/engine/Tests/OpenDeviceHubViewerTests/PendingCaptureTests.swift b/engine/Tests/OpenDeviceHubViewerTests/PendingCaptureTests.swift new file mode 100644 index 0000000..2bab711 --- /dev/null +++ b/engine/Tests/OpenDeviceHubViewerTests/PendingCaptureTests.swift @@ -0,0 +1,81 @@ +import XCTest +@testable import OpenDeviceHubViewer + +/// Real files in a temporary directory, because the whole point of this type is what happens on +/// disk, and a mocked file system would test the mock. +final class PendingCaptureTests: XCTestCase { + private var root: URL! + private let filer = CaptureFiler() + + override func setUpWithError() throws { + root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appending(path: "odh-capture-tests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: root) + } + + private func pending(named name: String = "shot.png") throws -> PendingCapture { + let temporary = root.appending(path: "tmp-\(name)") + try Data("image".utf8).write(to: temporary) + return PendingCapture( + temporary: temporary, + destination: root.appending(path: "Captures") + ) + } + + func testSettlingMovesItIntoTheCaptureFolder() throws { + let capture = try pending() + let landed = try filer.settle(capture) + XCTAssertTrue(FileManager.default.fileExists(atPath: landed.path(percentEncoded: false))) + XCTAssertFalse(FileManager.default.fileExists(atPath: capture.temporary.path(percentEncoded: false))) + XCTAssertEqual(landed.deletingLastPathComponent().lastPathComponent, "Captures") + } + + func testSettlingCreatesTheFolderIfItIsNotThere() throws { + let capture = try pending() + XCTAssertFalse(FileManager.default.fileExists(atPath: capture.destination.path(percentEncoded: false))) + _ = try filer.settle(capture) + XCTAssertTrue(FileManager.default.fileExists(atPath: capture.destination.path(percentEncoded: false))) + } + + /// Two captures in the same second must not become one file. + func testASecondCaptureWithTheSameNameDoesNotOverwriteTheFirst() throws { + let first = try pending() + let firstURL = try filer.settle(first) + try Data("first".utf8).write(to: firstURL) + + let second = try pending() + let secondURL = try filer.settle(second) + + XCTAssertNotEqual(firstURL, secondURL) + XCTAssertEqual(try String(contentsOf: firstURL, encoding: .utf8), "first") + XCTAssertTrue(secondURL.lastPathComponent.contains("2")) + } + + func testDiscardingLeavesNothingBehind() throws { + let capture = try pending() + filer.discard(capture) + XCTAssertFalse(FileManager.default.fileExists(atPath: capture.temporary.path(percentEncoded: false))) + XCTAssertFalse(FileManager.default.fileExists(atPath: capture.settled.path(percentEncoded: false))) + } + + func testSavingSomewhereElseReplacesWhatIsThere() throws { + let capture = try pending() + let chosen = root.appending(path: "chosen.png") + try Data("old".utf8).write(to: chosen) + let landed = try filer.save(capture, to: chosen) + XCTAssertEqual(landed, chosen) + XCTAssertEqual(try String(contentsOf: chosen, encoding: .utf8), "image") + } + + func testNothingIsWrittenToTheCaptureFolderUntilItSettles() throws { + let capture = try pending() + XCTAssertFalse( + FileManager.default.fileExists(atPath: capture.settled.path(percentEncoded: false)), + "the capture folder must stay empty while the preview is up" + ) + } +} From deba4cd5ad48c3f4d282972502a3113e37566ccf Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Thu, 24 Sep 2026 16:28:31 +0100 Subject: [PATCH 3/3] feat(viewer): show a capture before filing it Beside the window, one at a time, and nothing reaches the capture folder until the preview resolves. --- .../Sources/ODHubViewerApp/ViewerMain.swift | 74 ++++-- .../OpenDeviceHubViewer/CapturePreview.swift | 239 ++++++++++++++++++ .../CapturePreviewLayout.swift | 12 +- .../DeviceWindowController.swift | 10 +- .../ScreenshotWriter.swift | 53 ++++ .../CapturePreviewLayoutTests.swift | 10 +- 6 files changed, 353 insertions(+), 45 deletions(-) create mode 100644 engine/Sources/OpenDeviceHubViewer/CapturePreview.swift diff --git a/engine/Sources/ODHubViewerApp/ViewerMain.swift b/engine/Sources/ODHubViewerApp/ViewerMain.swift index d530dc3..87e8ee9 100644 --- a/engine/Sources/ODHubViewerApp/ViewerMain.swift +++ b/engine/Sources/ODHubViewerApp/ViewerMain.swift @@ -84,6 +84,18 @@ struct ODHubViewer: ParsableCommand { plan.udids.forEach(store.forget) } let manager = DeviceWindowManager(frameStore: store) + + let previews = CapturePreviewPresenter(report: { print($0) }) + let present: @MainActor ([URL]) -> Void = { urls in + let destination = recordingDirectory(settings) + for url in urls { + previews.show( + PendingCapture(temporary: url, destination: destination), + beside: NSApp.keyWindow ?? manager.openUDIDs.first.flatMap(manager.controller(for:))?.window + ) + } + } + var failures: [String] = [] let show: @MainActor (String, Bool) throws -> Void = { udid, allowBoot in @@ -93,7 +105,8 @@ struct ODHubViewer: ParsableCommand { from: current, adapter: adapter, manager: manager, - allowBoot: allowBoot + allowBoot: allowBoot, + present: present ) recent.remember(udid) } @@ -166,24 +179,17 @@ struct ODHubViewer: ParsableCommand { } }, saveScreenshot: { - let directory = recordingDirectory(settings) - for url in manager.saveScreenshots(into: directory) { - print("saved \(url.path(percentEncoded: false))") - } + present(manager.saveScreenshots(into: CaptureStaging.directory())) }, copyScreenshot: { print(manager.copyScreenshotToClipboard() ? "screenshot copied" : "nothing to copy") }, toggleRecording: { - let directory = recordingDirectory(settings) - let finished = manager.toggleRecording(into: directory) + let finished = manager.toggleRecording(into: CaptureStaging.directory()) if finished.isEmpty { print("recording started") } else { - for url in finished { print("recorded \(url.path(percentEncoded: false))") } - // Showing the file is the closest thing to dragging it out of the window, - // which needs a drag source and is not built yet. - NSWorkspace.shared.activateFileViewerSelecting(finished) + present(finished) } }, simulateMemoryWarning: { @@ -277,9 +283,7 @@ struct ODHubViewer: ParsableCommand { } }, stopRecording: { - for url in manager.toggleRecording(into: recordingDirectory(settings)) { - print("recorded \(url.path(percentEncoded: false))") - } + present(manager.toggleRecording(into: CaptureStaging.directory())) }, isRecording: { manager.isRecording }, checkForUpdates: updates.map { updater in { updater.checkForUpdates() } }, @@ -348,7 +352,8 @@ struct ODHubViewer: ParsableCommand { devices: (try? adapter.devices()) ?? [], remembered: nil ).udids.first }, - onTerminate: { manager.closeAll() } + onTerminate: { manager.closeAll() }, + settlePreviews: { previews.settleEverything() } ) // NSApplication holds its delegate weakly, and nothing else refers to these objects // once the run loop starts, so without this ARC releases them and the display sessions @@ -366,7 +371,8 @@ struct ODHubViewer: ParsableCommand { from devices: [DeviceInfo], adapter: any SimulatorAdapter, manager: DeviceWindowManager, - allowBoot: Bool + allowBoot: Bool, + present: @escaping @MainActor ([URL]) -> Void ) throws { guard var device = devices.first(where: { $0.udid.caseInsensitiveCompare(udid) == .orderedSame @@ -402,7 +408,7 @@ struct ODHubViewer: ParsableCommand { keepOnTop: keepOnTop, showFPS: fps ) - installToolbar(udid: device.udid, manager: manager, adapter: adapter) + installToolbar(udid: device.udid, manager: manager, adapter: adapter, present: present) if case .largerThanScreen(let size) = controller.applyScaleMode(scale) { print("\(device.name): \(scale.displayName) needs \(Int(size.width))x\(Int(size.height)) points, which is larger than this display.") } @@ -431,6 +437,7 @@ private final class ViewerAppDelegate: NSObject, NSApplicationDelegate { private let openLink: (String) -> Void private let deviceToReopen: () -> String? private let onTerminate: () -> Void + private let settlePreviews: () -> Void init( quitsWithLastWindow: Bool, @@ -438,7 +445,8 @@ private final class ViewerAppDelegate: NSObject, NSApplicationDelegate { reopen: @escaping (String) -> Void, openLink: @escaping (String) -> Void, deviceToReopen: @escaping () -> String?, - onTerminate: @escaping () -> Void + onTerminate: @escaping () -> Void, + settlePreviews: @escaping () -> Void ) { self.quitsWithLastWindow = quitsWithLastWindow self.dockMenu = dockMenu @@ -446,6 +454,7 @@ private final class ViewerAppDelegate: NSObject, NSApplicationDelegate { self.openLink = openLink self.deviceToReopen = deviceToReopen self.onTerminate = onTerminate + self.settlePreviews = settlePreviews } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { @@ -468,6 +477,13 @@ private final class ViewerAppDelegate: NSObject, NSApplicationDelegate { onTerminate() } + /// A preview still on screen at quit is filed rather than lost, which is what leaving it alone + /// would have done anyway. + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + settlePreviews() + return .terminateNow + } + /// A devices:// link only reaches this app if someone chose it in Settings. One that names a /// simulator opens here; anything else goes back to Device Hub whole, rather than being dropped /// because this app did not understand it. @@ -501,7 +517,8 @@ private final class ViewerAppDelegate: NSObject, NSApplicationDelegate { private func installToolbar( udid: String, manager: DeviceWindowManager, - adapter: any SimulatorAdapter + adapter: any SimulatorAdapter, + present: @escaping @MainActor ([URL]) -> Void ) { guard let controller = manager.controller(for: udid) else { return } controller.setToolbarActions(DeviceToolbarActions( @@ -526,14 +543,10 @@ private func installToolbar( } }, saveScreenshot: { - for url in manager.saveScreenshots(into: recordingDirectory(), only: udid) { - print("saved \(url.path(percentEncoded: false))") - } + present(manager.saveScreenshots(into: CaptureStaging.directory(), only: udid)) }, stopRecording: { - for url in manager.toggleRecording(into: recordingDirectory()) { - print("recorded \(url.path(percentEncoded: false))") - } + present(manager.toggleRecording(into: CaptureStaging.directory())) }, rotate: { [weak controller] toLeft in guard let controller else { return } @@ -581,6 +594,17 @@ private func swipeHome(_ session: any InputSession) async throws { /// Recordings and screenshots land on the Desktop, falling back to a temporary folder on a machine /// that has none. +/// Captures are written here first and only move into the capture folder when their preview goes +/// away, so the folder stays empty while a preview is still on screen. +enum CaptureStaging { + static func directory() -> URL { + let staging = URL(fileURLWithPath: NSTemporaryDirectory()) + .appending(path: "\(Brand.identifierPrefix).captures") + try? FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true) + return staging + } +} + private func recordingDirectory(_ settings: ViewerSettings = ViewerSettings()) -> URL { if let chosen = settings.captureDirectory, FileManager.default.fileExists(atPath: chosen.path(percentEncoded: false)) { diff --git a/engine/Sources/OpenDeviceHubViewer/CapturePreview.swift b/engine/Sources/OpenDeviceHubViewer/CapturePreview.swift new file mode 100644 index 0000000..4be1e24 --- /dev/null +++ b/engine/Sources/OpenDeviceHubViewer/CapturePreview.swift @@ -0,0 +1,239 @@ +import AppKit +import AVFoundation +import OpenDeviceHubEngine + +/// The card that appears beside a device after a capture, and what happens to the file. +/// +/// Left alone it files the capture and disappears, which is what the simulator this replaces does. +/// Acted on, it does that instead. Nothing is written to the capture folder until one of those +/// happens. +@MainActor +public final class CapturePreviewPresenter { + public static let lifetime: TimeInterval = 6 + + private var showing: [CapturePreviewPanel] = [] + private let filer: CaptureFiler + private let report: (String) -> Void + + public init(filer: CaptureFiler = CaptureFiler(), report: @escaping (String) -> Void = { _ in }) { + self.filer = filer + self.report = report + } + + public func show(_ capture: PendingCapture, beside window: NSWindow?) { + // One at a time. Taking another capture files the one before it straight away, which is + // what the simulator this replaces does: previews never pile up on screen. + settleEverything() + + guard let image = Self.thumbnail(for: capture.temporary) else { + // Nothing to show is not a reason to lose the file. + settle(capture) + return + } + + let size = CapturePreviewLayout.size(for: image.size) + let panel = CapturePreviewPanel(capture: capture, image: image, size: size) + panel.onFinish = { [weak self] outcome in self?.finish(panel, outcome) } + + let anchor = window?.frame ?? NSScreen.main?.visibleFrame ?? .zero + let visible = (window?.screen ?? NSScreen.main)?.visibleFrame ?? .zero + panel.anchor = anchor + panel.setFrame( + CapturePreviewLayout.frame(size: size, beside: anchor, visible: visible), + display: false + ) + showing.append(panel) + panel.orderFrontRegardless() + panel.startCountdown(Self.lifetime) + } + + /// Everything still on screen is filed, so quitting does not quietly lose a capture. + public func settleEverything() { + for panel in showing { + panel.stopCountdown() + settle(panel.capture) + panel.close() + } + showing.removeAll() + } + + private func finish(_ panel: CapturePreviewPanel, _ outcome: CapturePreviewOutcome) { + showing.removeAll { $0 === panel } + panel.close() + switch outcome { + case .settle: + settle(panel.capture) + case .discard: + filer.discard(panel.capture) + case .saved(let url): + report("saved \(url.path(percentEncoded: false))") + } + } + + private func settle(_ capture: PendingCapture) { + do { + let landed = try filer.settle(capture) + report("saved \(landed.path(percentEncoded: false))") + } catch { + report("could not save the capture: \(error.localizedDescription)") + } + } + + /// A still for an image, and the first frame for a recording, so a video does not show as a + /// blank card. + static func thumbnail(for url: URL) -> NSImage? { + if let image = NSImage(contentsOf: url), image.size.width > 0 { + return image + } + let asset = AVURLAsset(url: url) + let generator = AVAssetImageGenerator(asset: asset) + generator.appliesPreferredTrackTransform = true + guard let frame = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return nil } + return NSImage(cgImage: frame, size: CGSize(width: frame.width, height: frame.height)) + } +} + +public enum CapturePreviewOutcome { + case settle + case discard + case saved(URL) +} + +@MainActor +final class CapturePreviewPanel: NSPanel { + let capture: PendingCapture + var anchor: CGRect = .zero + var onFinish: ((CapturePreviewOutcome) -> Void)? + + private var countdown: Task? + private let filer = CaptureFiler() + + init(capture: PendingCapture, image: NSImage, size: CGSize) { + self.capture = capture + super.init( + contentRect: CGRect(origin: .zero, size: size), + // Borderless and non activating: this is a notice, not something to switch apps for. + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + isFloatingPanel = true + level = .floating + backgroundColor = .clear + isOpaque = false + hasShadow = true + collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + + let view = CapturePreviewView(image: image) + view.frame = CGRect(origin: .zero, size: size) + view.onClick = { [weak self] in self?.open() } + view.menu = buildMenu() + contentView = view + } + + func startCountdown(_ seconds: TimeInterval) { + countdown = Task { [weak self] in + try? await Task.sleep(for: .seconds(seconds)) + guard !Task.isCancelled else { return } + self?.onFinish?(.settle) + } + } + + func stopCountdown() { + countdown?.cancel() + countdown = nil + } + + private func buildMenu() -> NSMenu { + let menu = NSMenu() + for (title, action) in [ + ("Open", #selector(open)), + ("Save As\u{2026}", #selector(saveAs)), + ("Copy", #selector(copyToPasteboard)), + ("Reveal in Finder", #selector(reveal)), + ("Delete", #selector(deleteCapture)), + ] { + let item = NSMenuItem(title: title, action: action, keyEquivalent: "") + item.target = self + menu.addItem(item) + } + return menu + } + + /// Opening, revealing and copying all need the file where it is going to live, so they settle it + /// first. Only Delete and the countdown decide otherwise. + @objc private func open() { + stopCountdown() + guard let landed = try? filer.settle(capture) else { return } + NSWorkspace.shared.open(landed) + onFinish?(.saved(landed)) + } + + @objc private func reveal() { + stopCountdown() + guard let landed = try? filer.settle(capture) else { return } + NSWorkspace.shared.activateFileViewerSelecting([landed]) + onFinish?(.saved(landed)) + } + + @objc private func copyToPasteboard() { + stopCountdown() + NSPasteboard.general.clearContents() + NSPasteboard.general.writeObjects([capture.temporary as NSURL]) + if let image = NSImage(contentsOf: capture.temporary) { + NSPasteboard.general.writeObjects([image]) + } + onFinish?(.settle) + } + + @objc private func saveAs() { + stopCountdown() + let panel = NSSavePanel() + panel.nameFieldStringValue = capture.name + panel.directoryURL = capture.destination + guard panel.runModal() == .OK, let chosen = panel.url, + let landed = try? filer.save(capture, to: chosen) else { + // Cancelled, so the capture is still pending and keeps its original fate. + onFinish?(.settle) + return + } + onFinish?(.saved(landed)) + } + + @objc private func deleteCapture() { + stopCountdown() + onFinish?(.discard) + } +} + +private final class CapturePreviewView: NSView { + var onClick: (() -> Void)? + private let image: NSImage + + init(image: NSImage) { + self.image = image + super.init(frame: .zero) + wantsLayer = true + layer?.cornerRadius = 10 + layer?.masksToBounds = true + layer?.borderWidth = 1 + layer?.borderColor = NSColor.separatorColor.cgColor + } + + required init?(coder: NSCoder) { fatalError("init(coder:) is unsupported") } + + override func draw(_ dirtyRect: NSRect) { + NSColor.windowBackgroundColor.setFill() + bounds.fill() + image.draw(in: bounds, from: .zero, operation: .sourceOver, fraction: 1) + } + + override func mouseDown(with event: NSEvent) { + // Control click is a right click, which is how the menu is reached on a one button mouse. + if event.modifierFlags.contains(.control) { + rightMouseDown(with: event) + return + } + onClick?() + } +} diff --git a/engine/Sources/OpenDeviceHubViewer/CapturePreviewLayout.swift b/engine/Sources/OpenDeviceHubViewer/CapturePreviewLayout.swift index 2c0168c..f8d2c71 100644 --- a/engine/Sources/OpenDeviceHubViewer/CapturePreviewLayout.swift +++ b/engine/Sources/OpenDeviceHubViewer/CapturePreviewLayout.swift @@ -7,15 +7,8 @@ import CoreGraphics /// the display, and clamped to the display when neither side fits. public enum CapturePreviewLayout { public static let gap: CGFloat = 12 - /// Previews stack upwards from the window's bottom edge, newest lowest. - public static let stackStep: CGFloat = 8 - public static func frame( - size: CGSize, - beside window: CGRect, - visible: CGRect, - index: Int = 0 - ) -> CGRect { + public static func frame(size: CGSize, beside window: CGRect, visible: CGRect) -> CGRect { let toTheRight = window.maxX + gap let toTheLeft = window.minX - gap - size.width @@ -29,10 +22,9 @@ public enum CapturePreviewLayout { x = visible.maxX - size.width } - let stacked = window.minY + CGFloat(index) * (size.height + stackStep) return CGRect( x: clamp(x, low: visible.minX, high: visible.maxX - size.width), - y: clamp(stacked, low: visible.minY, high: visible.maxY - size.height), + y: clamp(window.minY, low: visible.minY, high: visible.maxY - size.height), width: size.width, height: size.height ) diff --git a/engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift b/engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift index cdb44bd..8bed863 100644 --- a/engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift +++ b/engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift @@ -344,7 +344,15 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate /// The most recent frame as a PNG, matching whatever the window is showing including the bezel. public func screenshotPNG() -> Data? { - renderer.currentSurface.flatMap(ScreenshotWriter.pngData) + guard let surface = renderer.currentSurface else { return nil } + // With the body shown, a screenshot means the device, not just its screen. Without it, the + // screen is the whole picture. + guard chromeView.hasChrome else { return ScreenshotWriter.pngData(from: surface) } + return ScreenshotWriter.pngData( + from: surface, + inside: chromeView, + screenRect: chromeView.screenRect + ) ?? ScreenshotWriter.pngData(from: surface) } /// The plain device name, without the recording dot or the latency overlay, so screenshot and diff --git a/engine/Sources/OpenDeviceHubViewer/ScreenshotWriter.swift b/engine/Sources/OpenDeviceHubViewer/ScreenshotWriter.swift index 4465e96..b5f54c5 100644 --- a/engine/Sources/OpenDeviceHubViewer/ScreenshotWriter.swift +++ b/engine/Sources/OpenDeviceHubViewer/ScreenshotWriter.swift @@ -38,6 +38,59 @@ public enum ScreenshotWriter { ) } + /// The screen drawn inside the device's body, which is what the window shows and therefore what + /// a screenshot of "the device" means. The body is AppKit drawing and comes out of + /// `cacheDisplay`; the screen is Metal and does not, so it is composited in afterwards. + /// + /// Rendered at the device's own pixel scale rather than the window's, so a window scaled down to + /// fit still saves a full resolution screenshot. + @MainActor + public static func pngData( + from surface: IOSurfaceRef, + inside chrome: NSView, + screenRect: CGRect + ) -> Data? { + guard let screen = image(from: surface), + screenRect.width > 0, screenRect.height > 0, + chrome.bounds.width > 0, chrome.bounds.height > 0 else { + return nil + } + let scale = CGFloat(screen.width) / screenRect.width + let size = CGSize( + width: (chrome.bounds.width * scale).rounded(), + height: (chrome.bounds.height * scale).rounded() + ) + guard size.width >= 1, size.height >= 1, + let context = CGContext( + data: nil, + width: Int(size.width), + height: Int(size.height), + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue + | CGBitmapInfo.byteOrder32Little.rawValue + ) else { + return nil + } + + context.scaleBy(x: scale, y: scale) + let graphics = NSGraphicsContext(cgContext: context, flipped: false) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = graphics + chrome.displayIgnoringOpacity(chrome.bounds, in: graphics) + NSGraphicsContext.restoreGraphicsState() + + context.draw(screen, in: screenRect) + return context.makeImage().flatMap(pngData(from:)) + } + + static func pngData(from image: CGImage) -> Data? { + let representation = NSBitmapImageRep(cgImage: image) + representation.size = CGSize(width: image.width, height: image.height) + return representation.representation(using: .png, properties: [:]) + } + public static func pngData(from surface: IOSurfaceRef) -> Data? { guard let image = image(from: surface) else { return nil } return NSBitmapImageRep(cgImage: image).representation(using: .png, properties: [:]) diff --git a/engine/Tests/OpenDeviceHubViewerTests/CapturePreviewLayoutTests.swift b/engine/Tests/OpenDeviceHubViewerTests/CapturePreviewLayoutTests.swift index f53e3d2..7965c4f 100644 --- a/engine/Tests/OpenDeviceHubViewerTests/CapturePreviewLayoutTests.swift +++ b/engine/Tests/OpenDeviceHubViewerTests/CapturePreviewLayoutTests.swift @@ -36,20 +36,12 @@ final class CapturePreviewLayoutTests: XCTestCase { XCTAssertLessThanOrEqual(frame.maxX, narrow.maxX) } - func testPreviewsStackUpwardsWithoutOverlapping() { - let window = CGRect(x: 400, y: 100, width: 430, height: 700) - let first = CapturePreviewLayout.frame(size: card, beside: window, visible: display, index: 0) - let second = CapturePreviewLayout.frame(size: card, beside: window, visible: display, index: 1) - XCTAssertGreaterThanOrEqual(second.minY, first.maxY) - } - func testItNeverLeavesTheVisibleArea() { for y in [-500.0, 0.0, 400.0, 2000.0] { let frame = CapturePreviewLayout.frame( size: card, beside: CGRect(x: 400, y: y, width: 430, height: 700), - visible: display, - index: 3 + visible: display ) XCTAssertGreaterThanOrEqual(frame.minY, display.minY, "y=\(y)") XCTAssertLessThanOrEqual(frame.maxY, display.maxY, "y=\(y)")