Skip to content
Merged
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
74 changes: 49 additions & 25 deletions engine/Sources/ODHubViewerApp/ViewerMain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -93,7 +105,8 @@ struct ODHubViewer: ParsableCommand {
from: current,
adapter: adapter,
manager: manager,
allowBoot: allowBoot
allowBoot: allowBoot,
present: present
)
recent.remember(udid)
}
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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() } },
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.")
}
Expand Down Expand Up @@ -431,21 +437,24 @@ 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,
dockMenu: @escaping () -> NSMenu,
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
self.reopen = reopen
self.openLink = openLink
self.deviceToReopen = deviceToReopen
self.onTerminate = onTerminate
self.settlePreviews = settlePreviews
}

func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
Expand All @@ -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.
Expand Down Expand Up @@ -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(
Expand All @@ -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 }
Expand Down Expand Up @@ -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)) {
Expand Down
Loading
Loading