Skip to content
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,13 @@ simulators.
two lists follow each other, so a device an installed runtime cannot run is never offered.
- **Screenshots and recordings**: a capture appears beside the window first, where you can open,
copy, save it elsewhere, reveal it in the Finder or throw it away. Left alone it files itself. The
folder is yours to choose.
folder is yours to choose. Settings independently choose screenshot and recording folders, or can
make Save Screen write the image to the Mac clipboard without creating a screenshot file.
- **The clipboard stays in step**: copy on the Mac and it is on the device; copy on the device and it
is on the Mac when you switch to another app. Or do it by hand from Edit.
- **Settings**: whether closing a window shuts the device down, whether launching opens the
simulator you had last, where captures go, and which app opens device links.
simulator you had last, where captures go, whether screenshots are saved to the clipboard instead,
whether pasteboards stay in sync, and which app opens device links.
- **Drag and drop**: drop a file or a link onto a device to open it there.
- **Follows the device**: shut a simulator down from anywhere and its window says so and offers to
start it again; boot it and the window reattaches on its own.
Expand Down
41 changes: 36 additions & 5 deletions engine/Sources/ODHubViewerApp/ViewerMain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,8 @@ struct ODHubViewer: ParsableCommand {

let previews = CapturePreviewPresenter(report: { print($0) })
let present: @MainActor ([URL]) -> Void = { urls in
let destination = recordingDirectory(settings)
for url in urls {
let destination = captureDirectory(for: url, settings: settings)
previews.show(
PendingCapture(temporary: url, destination: destination),
beside: NSApp.keyWindow ?? manager.openUDIDs.first.flatMap(manager.controller(for:))?.window
Expand Down Expand Up @@ -240,7 +240,11 @@ struct ODHubViewer: ParsableCommand {
}
},
saveScreenshot: {
present(manager.saveScreenshots(into: CaptureStaging.directory()))
if settings.savesScreenshotsToClipboard {
print(manager.copyScreenshotToClipboard() ? "screenshot saved to clipboard" : "nothing to save")
} else {
present(manager.saveScreenshots(into: CaptureStaging.directory()))
}
},
copyScreenshot: {
print(manager.copyScreenshotToClipboard() ? "screenshot copied" : "nothing to copy")
Expand Down Expand Up @@ -322,6 +326,11 @@ struct ODHubViewer: ParsableCommand {
runOnEveryDevice("restart", udid) { try SimctlService().restart(udid: $0) }
}
},
shutdown: {
for udid in manager.openUDIDs {
runOnEveryDevice("shutdown", udid) { try SimctlService().shutdown(udid: $0) }
}
},
erase: {
// Destructive and not undoable, so it asks, names the device, and Erase is not
// the default button.
Expand Down Expand Up @@ -383,6 +392,18 @@ struct ODHubViewer: ParsableCommand {
}
}
},
locationFavorites: { settings.locationFavorites },
setFavoriteLocation: { favorite in
for udid in manager.openUDIDs {
runOnEveryDevice("location", udid) {
try SimctlService().setLocation(
latitude: favorite.latitude,
longitude: favorite.longitude,
udid: $0
)
}
}
},
toggleKeyboardInput: { enabled in
for udid in manager.openUDIDs {
manager.controller(for: udid)?.sendsKeyboardInput = enabled
Expand Down Expand Up @@ -481,6 +502,7 @@ struct ODHubViewer: ParsableCommand {
automaticUpdates: updates.map { updater in { updater.checksAutomatically } },
setAutomaticUpdates: updates.map { updater in { updater.checksAutomatically = $0 } },
checkForUpdates: updates.map { updater in { updater.checkForUpdates() } },
setPasteboardSync: { pasteboard.setAutomatic($0) },
forgetWindowPositions: { store.forgetAll() },
rememberedWindowCount: { store.rememberedCount },
openLinks: deviceLinks
Expand Down Expand Up @@ -631,6 +653,7 @@ struct ODHubViewer: ParsableCommand {
udid: device.udid,
manager: manager,
adapter: adapter,
saveScreenshotsToClipboard: { manager.savesScreenshotsToClipboard },
rotate: rotate,
present: present
)
Expand Down Expand Up @@ -752,6 +775,7 @@ private func installToolbar(
udid: String,
manager: DeviceWindowManager,
adapter: any SimulatorAdapter,
saveScreenshotsToClipboard: @escaping @MainActor () -> Bool,
rotate: @escaping @MainActor (DeviceOrientation, String) -> Void,
present: @escaping @MainActor ([URL]) -> Void
) {
Expand All @@ -777,7 +801,11 @@ private func installToolbar(
}
},
saveScreenshot: {
present(manager.saveScreenshots(into: CaptureStaging.directory(), only: udid))
if saveScreenshotsToClipboard() {
print(manager.copyScreenshotToClipboard(only: udid) ? "screenshot saved to clipboard" : "nothing to save")
} else {
present(manager.saveScreenshots(into: CaptureStaging.directory(), only: udid))
}
},
stopRecording: {
present(manager.toggleRecording(into: CaptureStaging.directory()))
Expand Down Expand Up @@ -852,8 +880,11 @@ enum CaptureStaging {
}
}

private func recordingDirectory(_ settings: ViewerSettings = ViewerSettings()) -> URL {
if let chosen = settings.captureDirectory,
private func captureDirectory(for capture: URL, settings: ViewerSettings = ViewerSettings()) -> URL {
let chosen = capture.pathExtension.lowercased() == "mov"
? settings.recordingDirectory
: settings.screenshotDirectory
if let chosen,
FileManager.default.fileExists(atPath: chosen.path(percentEncoded: false)) {
return chosen
}
Expand Down
27 changes: 26 additions & 1 deletion engine/Sources/OpenDeviceHubViewer/DeviceChromeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,15 @@ public final class DeviceChromeView: NSView {
public override func layout() {
super.layout()
screenView.frame = screenRect
overlay?.frame = screenRect
if let overlay {
// The screen has rounded corners and the overlay is a plain view, so unmasked it
// paints into the bezel.
overlay.frame = screenRect
overlay.wantsLayer = true
overlay.layer?.cornerCurve = .continuous
overlay.layer?.cornerRadius = screenCornerRadius
overlay.layer?.masksToBounds = screenCornerRadius > 0
}
}

/// Covers the device's screen, and only the screen, so the body still frames whatever the
Expand All @@ -137,6 +145,23 @@ public final class DeviceChromeView: NSView {
}
}

/// The screen's corner radius in view points. The chrome's radius is the body's outside edge,
/// so the bezel comes off first: an iPhone 17 Pro Max's 80 less its 18 is the 62 its device
/// type gives for the screen.
private var screenCornerRadius: CGFloat {
guard let chrome, hasChrome, screenSize.width > 0 else { return 0 }
let upright = ChromeGeometry.contentSize(screen: screenSize, chrome: chrome)
guard upright.width > 0 else { return 0 }
let scale = orientation.isLandscape
? occupiedRect.height / upright.width
: occupiedRect.width / upright.width
let bezel = max(
max(chrome.insets.left, chrome.insets.right),
max(chrome.insets.top, chrome.insets.bottom)
)
return max(0, chrome.cornerRadius - bezel) * scale
}

public override func draw(_ dirtyRect: NSRect) {
// A window the size of the device has no margin, so this paints nothing until full screen
// or a resize leaves room around it.
Expand Down
14 changes: 11 additions & 3 deletions engine/Sources/OpenDeviceHubViewer/DeviceWindowManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ public final class DeviceWindowManager {

public var openCount: Int { controllers.count }

/// Current screenshot preference, read by per-window toolbar actions at invocation time.
public var savesScreenshotsToClipboard: Bool { settings.savesScreenshotsToClipboard }

public func isOpen(_ udid: String) -> Bool {
controllers[udid] != nil
}
Expand Down Expand Up @@ -296,9 +299,14 @@ public final class DeviceWindowManager {

/// Copies the frontmost device's screen to the Mac clipboard.
@discardableResult
public func copyScreenshotToClipboard() -> Bool {
let controller = controllers.values.first { $0.window?.isKeyWindow == true }
?? controllers.values.first
public func copyScreenshotToClipboard(only udid: String? = nil) -> Bool {
let controller: DeviceWindowController?
if let udid {
controller = controllers[udid]
} else {
controller = controllers.values.first { $0.window?.isKeyWindow == true }
?? controllers.values.first
}
guard let data = controller?.screenshotPNG(),
let image = NSImage(data: data) else { return false }
NSPasteboard.general.clearContents()
Expand Down
Loading
Loading