diff --git a/README.md b/README.md index 876a02c..c331e0b 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/engine/Sources/ODHubViewerApp/ViewerMain.swift b/engine/Sources/ODHubViewerApp/ViewerMain.swift index c976b77..d77781e 100644 --- a/engine/Sources/ODHubViewerApp/ViewerMain.swift +++ b/engine/Sources/ODHubViewerApp/ViewerMain.swift @@ -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 @@ -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") @@ -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. @@ -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 @@ -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 @@ -631,6 +653,7 @@ struct ODHubViewer: ParsableCommand { udid: device.udid, manager: manager, adapter: adapter, + saveScreenshotsToClipboard: { manager.savesScreenshotsToClipboard }, rotate: rotate, present: present ) @@ -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 ) { @@ -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())) @@ -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 } diff --git a/engine/Sources/OpenDeviceHubViewer/DeviceChromeView.swift b/engine/Sources/OpenDeviceHubViewer/DeviceChromeView.swift index 7c0bbae..4c5fa6e 100644 --- a/engine/Sources/OpenDeviceHubViewer/DeviceChromeView.swift +++ b/engine/Sources/OpenDeviceHubViewer/DeviceChromeView.swift @@ -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 @@ -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. diff --git a/engine/Sources/OpenDeviceHubViewer/DeviceWindowManager.swift b/engine/Sources/OpenDeviceHubViewer/DeviceWindowManager.swift index 9683eb3..c396871 100644 --- a/engine/Sources/OpenDeviceHubViewer/DeviceWindowManager.swift +++ b/engine/Sources/OpenDeviceHubViewer/DeviceWindowManager.swift @@ -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 } @@ -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() diff --git a/engine/Sources/OpenDeviceHubViewer/SettingsWindow.swift b/engine/Sources/OpenDeviceHubViewer/SettingsWindow.swift index dee5355..afb7480 100644 --- a/engine/Sources/OpenDeviceHubViewer/SettingsWindow.swift +++ b/engine/Sources/OpenDeviceHubViewer/SettingsWindow.swift @@ -9,6 +9,7 @@ public struct SettingsActions { public var automaticUpdates: (() -> Bool)? public var setAutomaticUpdates: ((Bool) -> Void)? public var checkForUpdates: (() -> Void)? + public var setPasteboardSync: ((Bool) -> Void)? public var forgetWindowPositions: () -> Void public var rememberedWindowCount: () -> Int public var openLinks: DefaultDeviceApplication? @@ -17,6 +18,7 @@ public struct SettingsActions { automaticUpdates: (() -> Bool)? = nil, setAutomaticUpdates: ((Bool) -> Void)? = nil, checkForUpdates: (() -> Void)? = nil, + setPasteboardSync: ((Bool) -> Void)? = nil, forgetWindowPositions: @escaping () -> Void, rememberedWindowCount: @escaping () -> Int, openLinks: DefaultDeviceApplication? = nil @@ -24,6 +26,7 @@ public struct SettingsActions { self.automaticUpdates = automaticUpdates self.setAutomaticUpdates = setAutomaticUpdates self.checkForUpdates = checkForUpdates + self.setPasteboardSync = setPasteboardSync self.forgetWindowPositions = forgetWindowPositions self.rememberedWindowCount = rememberedWindowCount self.openLinks = openLinks @@ -49,7 +52,9 @@ final class SettingsWindowController: NSWindowController { let view = SettingsView(settings: settings, actions: actions) let window = NSWindow(contentViewController: NSHostingController(rootView: view)) window.title = "Settings" - window.styleMask = [.titled, .closable] + window.styleMask = [.titled, .closable, .resizable] + window.minSize = NSSize(width: 420, height: 360) + window.setContentSize(NSSize(width: 520, height: 620)) window.isReleasedWhenClosed = false super.init(window: window) window.center() @@ -68,9 +73,13 @@ private struct SettingsView: View { @State private var shutsDownOnWindowClose: Bool @State private var bootsMostRecentOnStart: Bool + @State private var syncsPasteboard: Bool + @State private var savesScreenshotsToClipboard: Bool @State private var automaticUpdates: Bool - @State private var captureDirectory: URL? + @State private var screenshotDirectory: URL? + @State private var recordingDirectory: URL? @State private var rememberedWindows: Int + @State private var favorites: [LocationFavorite] @ObservedObject private var links: DefaultDeviceApplication init(settings: ViewerSettings, actions: SettingsActions) { @@ -78,9 +87,13 @@ private struct SettingsView: View { self.actions = actions _shutsDownOnWindowClose = State(initialValue: settings.shutsDownOnWindowClose) _bootsMostRecentOnStart = State(initialValue: settings.bootsMostRecentOnStart) + _syncsPasteboard = State(initialValue: settings.syncsPasteboard) + _savesScreenshotsToClipboard = State(initialValue: settings.savesScreenshotsToClipboard) _automaticUpdates = State(initialValue: actions.automaticUpdates?() ?? false) - _captureDirectory = State(initialValue: settings.captureDirectory) + _screenshotDirectory = State(initialValue: settings.screenshotDirectory) + _recordingDirectory = State(initialValue: settings.recordingDirectory) _rememberedWindows = State(initialValue: actions.rememberedWindowCount()) + _favorites = State(initialValue: settings.locationFavorites) links = actions.openLinks ?? DefaultDeviceApplication() } @@ -106,21 +119,31 @@ private struct SettingsView: View { .help("Simulators that are already running are always shown, either way.") } - Section("Screenshots and recordings") { + Section("Screenshots") { + Toggle("Save screenshots to the clipboard instead of files", isOn: Binding( + get: { savesScreenshotsToClipboard }, + set: { value in + settings.savesScreenshotsToClipboard = value + savesScreenshotsToClipboard = value + } + )) + .help("Save Screen places the captured image on the Mac clipboard and does not create a file.") + LabeledContent { HStack(spacing: 8) { - if captureDirectory != nil { + if screenshotDirectory != nil { Button("Use Desktop") { - settings.captureDirectory = nil - captureDirectory = nil + settings.screenshotDirectory = nil + screenshotDirectory = nil } } - Button("Choose\u{2026}", action: chooseCaptureDirectory) + Button("Choose\u{2026}", action: chooseScreenshotDirectory) } + .disabled(savesScreenshotsToClipboard) } label: { VStack(alignment: .leading, spacing: 4) { Text("Save to") - Text(captureDirectory?.path(percentEncoded: false) ?? "Desktop") + Text(screenshotDirectory?.path(percentEncoded: false) ?? "Desktop") .font(.footnote) .foregroundStyle(.secondary) .lineLimit(1) @@ -129,6 +152,45 @@ private struct SettingsView: View { } } + Section("Recordings") { + LabeledContent { + HStack(spacing: 8) { + if recordingDirectory != nil { + Button("Use Desktop") { + settings.recordingDirectory = nil + recordingDirectory = nil + } + } + Button("Choose\u{2026}", action: chooseRecordingDirectory) + } + } label: { + VStack(alignment: .leading, spacing: 4) { + Text("Save to") + Text(recordingDirectory?.path(percentEncoded: false) ?? "Desktop") + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + } + + Section("Pasteboard") { + Toggle("Automatically sync the Mac and device pasteboards", isOn: Binding( + get: { syncsPasteboard }, + set: { value in + settings.syncsPasteboard = value + actions.setPasteboardSync?(value) + syncsPasteboard = value + } + )) + .help("Copies made on either side are sent to the other side automatically.") + // Edit, Automatically Sync Pasteboard changes the same setting while this is open. + .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in + syncsPasteboard = settings.syncsPasteboard + } + } + Section("Windows") { LabeledContent { Button("Forget") { @@ -148,6 +210,21 @@ private struct SettingsView: View { } } + Section("Location favorites") { + ForEach(favorites) { favorite in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(favorite.name) + Text(String(format: "%.5f, %.5f", favorite.latitude, favorite.longitude)) + .font(.footnote).foregroundStyle(.secondary) + } + Spacer() + Button("Remove", role: .destructive) { removeFavorite(favorite) } + } + } + Button("Add Favorite\u{2026}", action: addFavorite) + } + if actions.openLinks != nil { Section("Links") { LabeledContent { @@ -209,9 +286,10 @@ private struct SettingsView: View { } .formStyle(.grouped) .toggleStyle(.switch) - .scrollDisabled(true) - .frame(width: 520, alignment: .topLeading) - .fixedSize(horizontal: false, vertical: true) + // The form scrolls inside a resizable window rather than growing it with every setting. + .scrollDisabled(false) + .frame(minWidth: 420, idealWidth: 520, minHeight: 360, idealHeight: 620, + alignment: .topLeading) } private var rememberedDescription: String { @@ -228,15 +306,83 @@ private struct SettingsView: View { : "Version \(Brand.version) (\(Brand.buildNumber))" } - private func chooseCaptureDirectory() { + private func addFavorite() { + guard let favorite = LocationFavoritePrompt.ask() else { return } + favorites.append(favorite) + settings.locationFavorites = favorites + } + + private func removeFavorite(_ favorite: LocationFavorite) { + favorites.removeAll { $0.id == favorite.id } + settings.locationFavorites = favorites + } + + private func chooseScreenshotDirectory() { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.canCreateDirectories = true + panel.prompt = "Choose" + panel.directoryURL = screenshotDirectory + guard panel.runModal() == .OK, let chosen = panel.url else { return } + settings.screenshotDirectory = chosen + screenshotDirectory = chosen + } + + private func chooseRecordingDirectory() { let panel = NSOpenPanel() panel.canChooseDirectories = true panel.canChooseFiles = false panel.canCreateDirectories = true panel.prompt = "Choose" - panel.directoryURL = captureDirectory + panel.directoryURL = recordingDirectory guard panel.runModal() == .OK, let chosen = panel.url else { return } - settings.captureDirectory = chosen - captureDirectory = chosen + settings.recordingDirectory = chosen + recordingDirectory = chosen + } +} + +@MainActor +private enum LocationFavoritePrompt { + static func ask() -> LocationFavorite? { + let alert = NSAlert() + alert.messageText = "Add Location Favorite" + alert.informativeText = "A name, then latitude and longitude separated by a comma." + // An alert's accessory keeps the frame it is given, so the fields are sized up front. + let name = NSTextField(frame: NSRect(x: 0, y: 0, width: 320, height: 24)) + name.placeholderString = "Name" + let coordinate = NSTextField(frame: NSRect(x: 0, y: 0, width: 320, height: 24)) + coordinate.placeholderString = "37.3349, -122.0090" + let stack = NSStackView(frame: NSRect(x: 0, y: 0, width: 320, height: 56)) + stack.orientation = .vertical + stack.spacing = 8 + stack.alignment = .leading + stack.addArrangedSubview(name) + stack.addArrangedSubview(coordinate) + alert.accessoryView = stack + alert.addButton(withTitle: "Add") + alert.addButton(withTitle: "Cancel") + guard alert.runModal() == .alertFirstButtonReturn else { return nil } + + let title = name.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !title.isEmpty else { + complain("The favorite needs a name.", "It is what the Location menu shows.") + return nil + } + guard let point = Coordinate(parsing: coordinate.stringValue) else { + complain( + "That is not a coordinate.", + "Latitude is between -90 and 90, longitude between -180 and 180." + ) + return nil + } + return LocationFavorite(name: title, latitude: point.latitude, longitude: point.longitude) + } + + private static func complain(_ message: String, _ detail: String) { + let complaint = NSAlert() + complaint.messageText = message + complaint.informativeText = detail + complaint.runModal() } } diff --git a/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift b/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift index c13a230..9cc4c31 100644 --- a/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift +++ b/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift @@ -42,12 +42,15 @@ public enum ViewerMenu { public var hasCameraControl: () -> Bool public var rotate: (Bool) -> Void public var restart: () -> Void + public var shutdown: () -> Void public var erase: () -> Void public var stepTextSize: (SimctlService.ContentSizeStep) -> Void public var toggleIncreaseContrast: () -> Void public var triggerICloudSync: () -> Void public var setLocation: (SimctlService.LocationScenario?) -> Void public var setCustomLocation: () -> Void + public var locationFavorites: () -> [LocationFavorite] + public var setFavoriteLocation: (LocationFavorite) -> Void public var toggleKeyboardInput: (Bool) -> Void public var toggleHardwareKeyboard: (Bool) -> Void public var matchKeyboardLanguage: (Bool) -> Void @@ -87,12 +90,15 @@ public enum ViewerMenu { hasCameraControl: @escaping () -> Bool = { false }, rotate: @escaping (Bool) -> Void, restart: @escaping () -> Void, + shutdown: @escaping () -> Void = {}, erase: @escaping () -> Void, stepTextSize: @escaping (SimctlService.ContentSizeStep) -> Void, toggleIncreaseContrast: @escaping () -> Void, triggerICloudSync: @escaping () -> Void, setLocation: @escaping (SimctlService.LocationScenario?) -> Void, setCustomLocation: @escaping () -> Void, + locationFavorites: @escaping () -> [LocationFavorite] = { [] }, + setFavoriteLocation: @escaping (LocationFavorite) -> Void = { _ in }, toggleKeyboardInput: @escaping (Bool) -> Void, toggleHardwareKeyboard: @escaping (Bool) -> Void, matchKeyboardLanguage: @escaping (Bool) -> Void, @@ -129,12 +135,15 @@ public enum ViewerMenu { self.hasCameraControl = hasCameraControl self.rotate = rotate self.restart = restart + self.shutdown = shutdown self.erase = erase self.stepTextSize = stepTextSize self.toggleIncreaseContrast = toggleIncreaseContrast self.triggerICloudSync = triggerICloudSync self.setLocation = setLocation self.setCustomLocation = setCustomLocation + self.locationFavorites = locationFavorites + self.setFavoriteLocation = setFavoriteLocation self.toggleKeyboardInput = toggleKeyboardInput self.toggleHardwareKeyboard = toggleHardwareKeyboard self.matchKeyboardLanguage = matchKeyboardLanguage @@ -274,7 +283,7 @@ public enum ViewerMenu { } // Both are the manual halves of the sync, so they are redundant while it is on. Simulator.app // greys them for the same reason. - target.trackPasteboardItems(get: getItem, send: sendItem) + target.trackPasteboardItems(sync: syncItem, get: getItem, send: sendItem) editMenu.addItem(.separator()) editMenu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a") @@ -284,6 +293,7 @@ public enum ViewerMenu { let deviceItem = NSMenuItem() let deviceMenu = NSMenu(title: "Device") deviceMenu.addItem(target.item("Restart", #selector(MenuTarget.restart), "", [])) + deviceMenu.addItem(target.item("Shut Down", #selector(MenuTarget.shutdown), "", [])) deviceMenu.addItem(target.item("Erase All Content and Settings\u{2026}", #selector(MenuTarget.erase), "", [])) deviceMenu.addItem(.separator()) let rotateLeft = target.item("Rotate Left", #selector(MenuTarget.rotateLeft), String(UnicodeScalar(NSLeftArrowFunctionKey)!), [.command]) @@ -392,15 +402,9 @@ public enum ViewerMenu { let locationItem = NSMenuItem(title: "Location", action: nil, keyEquivalent: "") let locationMenu = NSMenu(title: "Location") - locationMenu.addItem(target.item("None", #selector(MenuTarget.clearLocation), "", [])) - locationMenu.addItem(target.item("Custom Location\u{2026}", #selector(MenuTarget.customLocation), "", [])) - locationMenu.addItem(.separator()) - for scenario in SimctlService.LocationScenario.allCases { - let item = target.item(scenario.rawValue, #selector(MenuTarget.locationScenario(_:)), "", []) - item.representedObject = scenario.rawValue - locationMenu.addItem(item) - } locationItem.submenu = locationMenu + target.trackLocationMenu(locationItem, in: locationMenu) + target.rebuildLocationMenu() featuresMenu.addItem(locationItem) featuresItem.submenu = featuresMenu bar.addItem(featuresItem) @@ -520,10 +524,12 @@ public final class MenuTarget: NSObject, NSMenuDelegate, NSMenuItemValidation { private var matchesKeyboardLanguage = true private weak var getPasteboardItem: NSMenuItem? private weak var sendPasteboardItem: NSMenuItem? - private lazy var syncsPasteboardNow = actions.syncsPasteboard() + private weak var syncPasteboardItem: NSMenuItem? private weak var screenItem: NSMenuItem? private weak var cameraControlItem: NSMenuItem? private weak var deviceMenu: NSMenu? + private weak var locationItem: NSMenuItem? + private weak var locationMenu: NSMenu? init(actions: ViewerMenu.Actions, commandLineTool: CommandLineToolMenu? = nil) { self.actions = actions @@ -544,6 +550,12 @@ public final class MenuTarget: NSObject, NSMenuDelegate, NSMenuItemValidation { menu.delegate = self } + func trackLocationMenu(_ item: NSMenuItem, in menu: NSMenu) { + locationItem = item + locationMenu = menu + menu.delegate = self + } + func trackCameraControlItem(_ item: NSMenuItem) { cameraControlItem = item item.isHidden = !actions.hasCameraControl() @@ -555,9 +567,36 @@ public final class MenuTarget: NSObject, NSMenuDelegate, NSMenuItemValidation { rebuildScreenMenu() return } + if menu === locationMenu { + rebuildLocationMenu() + return + } retitleCommandLineToolItem() } + func rebuildLocationMenu() { + guard let menu = locationMenu else { return } + menu.removeAllItems() + menu.addItem(item("None", #selector(MenuTarget.clearLocation), "", [])) + menu.addItem(item("Custom Location\u{2026}", #selector(MenuTarget.customLocation), "", [])) + let favorites = actions.locationFavorites() + if !favorites.isEmpty { + menu.addItem(.separator()) + for favorite in favorites { + let entry = item(favorite.name, #selector(MenuTarget.favoriteLocation(_:)), "", []) + entry.representedObject = favorite + entry.toolTip = String(format: "%.5f, %.5f", favorite.latitude, favorite.longitude) + menu.addItem(entry) + } + } + menu.addItem(.separator()) + for scenario in SimctlService.LocationScenario.allCases { + let entry = item(scenario.rawValue, #selector(MenuTarget.locationScenario(_:)), "", []) + entry.representedObject = scenario.rawValue + menu.addItem(entry) + } + } + private func rebuildScreenMenu() { guard let screenItem, let submenu = screenItem.submenu else { return } let panels = actions.panels() @@ -657,12 +696,17 @@ public final class MenuTarget: NSObject, NSMenuDelegate, NSMenuItemValidation { @objc func cameraControl() { actions.pressButton(.cameraControl) } @objc func appSwitcher() { actions.appSwitcher() } @objc func restart() { actions.restart() } + @objc func shutdown() { actions.shutdown() } @objc func erase() { actions.erase() } @objc func textSizeUp() { actions.stepTextSize(.increment) } @objc func textSizeDown() { actions.stepTextSize(.decrement) } @objc func iCloudSync() { actions.triggerICloudSync() } @objc func clearLocation() { actions.setLocation(nil) } @objc func customLocation() { actions.setCustomLocation() } + @objc func favoriteLocation(_ sender: NSMenuItem) { + guard let favorite = sender.representedObject as? LocationFavorite else { return } + actions.setFavoriteLocation(favorite) + } @objc func newSimulator() { actions.newSimulator?() } /// Both start on, because that is what the app does before anyone touches the menu. @@ -672,17 +716,19 @@ public final class MenuTarget: NSObject, NSMenuDelegate, NSMenuItemValidation { actions.toggleKeyboardInput(sendsKeyboardInput) } - var syncsPasteboard: Bool { syncsPasteboardNow } + /// Read from the setting each time, because Settings changes it as well as this menu. + var syncsPasteboard: Bool { actions.syncsPasteboard() } - func trackPasteboardItems(get: NSMenuItem, send: NSMenuItem) { + func trackPasteboardItems(sync: NSMenuItem, get: NSMenuItem, send: NSMenuItem) { + syncPasteboardItem = sync getPasteboardItem = get sendPasteboardItem = send } @objc func automaticPasteboardSync(_ sender: NSMenuItem) { - syncsPasteboardNow.toggle() - sender.state = syncsPasteboardNow ? .on : .off - actions.toggleAutomaticPasteboardSync(syncsPasteboardNow) + let syncs = !syncsPasteboard + sender.state = syncs ? .on : .off + actions.toggleAutomaticPasteboardSync(syncs) } @objc func getPasteboard() { actions.getPasteboard() } @@ -719,8 +765,12 @@ public final class MenuTarget: NSObject, NSMenuDelegate, NSMenuItemValidation { public func validateMenuItem(_ item: NSMenuItem) -> Bool { if item === stopRecordingItem { return actions.isRecording() } + if item === syncPasteboardItem { + item.state = syncsPasteboard ? .on : .off + return item.action != nil + } if item === getPasteboardItem || item === sendPasteboardItem { - return item.action != nil && !syncsPasteboardNow + return item.action != nil && !syncsPasteboard } return item.action != nil } diff --git a/engine/Sources/OpenDeviceHubViewer/ViewerSettings.swift b/engine/Sources/OpenDeviceHubViewer/ViewerSettings.swift index 69ba1fb..09bf3ed 100644 --- a/engine/Sources/OpenDeviceHubViewer/ViewerSettings.swift +++ b/engine/Sources/OpenDeviceHubViewer/ViewerSettings.swift @@ -32,6 +32,13 @@ public struct ViewerSettings: Sendable { nonmutating set { setFlag("syncsPasteboard", newValue) } } + /// Saves a still capture to the Mac clipboard instead of creating a screenshot file. The + /// explicit Copy Screen menu item is always available; this preference controls Save Screen. + public var savesScreenshotsToClipboard: Bool { + get { flag("savesScreenshotsToClipboard", default: false) } + nonmutating set { setFlag("savesScreenshotsToClipboard", newValue) } + } + /// With nothing booted, opening the app starts the simulator you had last. Turn it off and a /// launch with nothing running opens no window. public var bootsMostRecentOnStart: Bool { @@ -39,8 +46,8 @@ public struct ViewerSettings: Sendable { nonmutating set { setFlag("bootMostRecentOnStart", newValue) } } - /// Where screenshots and recordings are written. Empty means the Desktop, which is where they - /// went before this was a choice. + /// Legacy shared capture location retained for backwards-compatible migration. New settings + /// should use `screenshotDirectory` and `recordingDirectory` independently. public var captureDirectory: URL? { get { guard let path = storage.text(forKey: prefix + "captureDirectory"), !path.isEmpty else { @@ -61,6 +68,20 @@ public struct ViewerSettings: Sendable { } } + /// Where screenshots are written. Existing installs using `captureDirectory` are migrated + /// lazily by reading that value until a dedicated choice is made. + public var screenshotDirectory: URL? { + get { directory("screenshotDirectory", fallingBackTo: captureDirectory) } + nonmutating set { setDirectory(newValue, key: "screenshotDirectory") } + } + + /// Where recordings are written. Existing installs using `captureDirectory` are migrated + /// lazily by reading that value until a dedicated choice is made. + public var recordingDirectory: URL? { + get { directory("recordingDirectory", fallingBackTo: captureDirectory) } + nonmutating set { setDirectory(newValue, key: "recordingDirectory") } + } + /// Stored as the port index, since a port's UUID is minted fresh on every boot. public func panelIndex(for udid: String) -> Int? { storage.text(forKey: prefix + "panel." + udid).flatMap(Int.init) @@ -89,6 +110,22 @@ public struct ViewerSettings: Sendable { } } + /// Named coordinates shown in Features > Location. + public var locationFavorites: [LocationFavorite] { + get { + guard let text = storage.text(forKey: prefix + "locationFavorites"), + let data = text.data(using: .utf8), + let values = try? JSONDecoder().decode([LocationFavorite].self, from: data) + else { return [] } + return values + } + nonmutating set { + guard let data = try? JSONEncoder().encode(newValue), + let text = String(data: data, encoding: .utf8) else { return } + storage.setText(text, forKey: prefix + "locationFavorites") + } + } + private func flag(_ name: String, default fallback: Bool) -> Bool { switch storage.text(forKey: prefix + name) { case "true": true @@ -100,6 +137,37 @@ public struct ViewerSettings: Sendable { private func setFlag(_ name: String, _ value: Bool) { storage.setText(value ? "true" : "false", forKey: prefix + name) } + + /// Nothing stored falls back to the shared folder older versions used; an empty value is a + /// deliberate choice of the Desktop. + private func directory(_ key: String, fallingBackTo legacy: URL?) -> URL? { + guard let stored = storage.text(forKey: prefix + key) else { return legacy } + return stored.isEmpty ? nil : URL(fileURLWithPath: stored, isDirectory: true) + } + + private func setDirectory(_ value: URL?, key: String) { + guard let value else { + storage.setText("", forKey: prefix + key) + return + } + var path = value.standardizedFileURL.path(percentEncoded: false) + while path.count > 1, path.hasSuffix("/") { path.removeLast() } + storage.setText(path, forKey: prefix + key) + } +} + +public struct LocationFavorite: Codable, Equatable, Identifiable, Sendable { + public let id: UUID + public var name: String + public var latitude: Double + public var longitude: Double + + public init(id: UUID = UUID(), name: String, latitude: Double, longitude: Double) { + self.id = id + self.name = name + self.latitude = latitude + self.longitude = longitude + } } diff --git a/engine/Tests/OpenDeviceHubViewerTests/CameraControlMenuTests.swift b/engine/Tests/OpenDeviceHubViewerTests/CameraControlMenuTests.swift index 948d2b4..5e61b83 100644 --- a/engine/Tests/OpenDeviceHubViewerTests/CameraControlMenuTests.swift +++ b/engine/Tests/OpenDeviceHubViewerTests/CameraControlMenuTests.swift @@ -18,8 +18,11 @@ final class CameraControlMenuTests: XCTestCase { func testTheItemFollowsTheDeviceInFront() throws { var frontHasOne = false var pressed: [HardwareButton] = [] - let target = install(hasCameraControl: { frontHasOne }, pressButton: { pressed.append($0) }) - let (item, menu) = try cameraControlItem() + var harness = MenuHarness() + harness.hasCameraControl = { frontHasOne } + harness.pressButton = { pressed.append($0) } + let target = harness.install() + let (item, menu) = try XCTUnwrap(MenuHarness.item(titled: "Camera Control")) XCTAssertTrue(item.isHidden, "offered for a device without the button") frontHasOne = true @@ -36,71 +39,12 @@ final class CameraControlMenuTests: XCTestCase { } func testItSitsWithTheOtherHardwareButtons() throws { - install(hasCameraControl: { true }, pressButton: { _ in }) - let (item, menu) = try cameraControlItem() + var harness = MenuHarness() + harness.hasCameraControl = { true } + harness.install() + let (item, menu) = try XCTUnwrap(MenuHarness.item(titled: "Camera Control")) let index = menu.index(of: item) XCTAssertEqual(menu.item(at: index - 1)?.title, "Action Button") XCTAssertEqual(menu.item(at: index + 1)?.title, "Siri") } - - private func cameraControlItem() throws -> (NSMenuItem, NSMenu) { - let menus = NSApplication.shared.mainMenu?.items.compactMap(\.submenu) ?? [] - for menu in menus { - if let item = menu.items.first(where: { $0.title == "Camera Control" }) { - return (item, menu) - } - } - throw XCTSkip("no Camera Control item") - } - - @discardableResult - private func install( - hasCameraControl: @escaping () -> Bool, - pressButton: @escaping (HardwareButton) -> Void - ) -> MenuTarget { - ViewerMenu.install( - into: NSApplication.shared, - actions: ViewerMenu.Actions( - setScaleMode: { _ in }, - toggleBezel: {}, - toggleKeepOnTop: {}, - pasteToDevice: {}, - setAppearance: { _ in }, - saveScreenshot: {}, - copyScreenshot: {}, - toggleRecording: {}, - simulateMemoryWarning: {}, - openSystemLog: {}, - openAppData: {}, - shake: {}, - toggleSlowAnimations: {}, - toggleLatencyOverlay: {}, - pressButton: pressButton, - hasCameraControl: hasCameraControl, - rotate: { _ in }, - restart: {}, - erase: {}, - stepTextSize: { _ in }, - toggleIncreaseContrast: {}, - triggerICloudSync: {}, - setLocation: { _ in }, - setCustomLocation: {}, - toggleKeyboardInput: { _ in }, - toggleHardwareKeyboard: { _ in }, - matchKeyboardLanguage: { _ in }, - toggleAutomaticPasteboardSync: { _ in }, - getPasteboard: {}, - sendPasteboard: {}, - syncsPasteboard: { false }, - panels: { [] }, - currentPanel: { nil }, - showPanel: { _ in }, - setOrientation: { _ in }, - appSwitcher: {}, - stopRecording: {}, - isRecording: { false } - ), - capabilities: [.hardwareButtons, .touch, .rotation, .shake] - ) - } } diff --git a/engine/Tests/OpenDeviceHubViewerTests/MenuHarness.swift b/engine/Tests/OpenDeviceHubViewerTests/MenuHarness.swift new file mode 100644 index 0000000..e3fd1b7 --- /dev/null +++ b/engine/Tests/OpenDeviceHubViewerTests/MenuHarness.swift @@ -0,0 +1,78 @@ +import AppKit +import OpenDeviceHubEngine +@testable import OpenDeviceHubViewer + +/// Installs the app menu with every action doing nothing except the ones a test sets. +@MainActor +struct MenuHarness { + var pressButton: (HardwareButton) -> Void = { _ in } + var hasCameraControl: () -> Bool = { false } + var shutdown: () -> Void = {} + var syncsPasteboard: () -> Bool = { false } + var toggleAutomaticPasteboardSync: (Bool) -> Void = { _ in } + var locationFavorites: () -> [LocationFavorite] = { [] } + var setFavoriteLocation: (LocationFavorite) -> Void = { _ in } + + @discardableResult + func install() -> MenuTarget { + ViewerMenu.install( + into: NSApplication.shared, + actions: ViewerMenu.Actions( + setScaleMode: { _ in }, + toggleBezel: {}, + toggleKeepOnTop: {}, + pasteToDevice: {}, + setAppearance: { _ in }, + saveScreenshot: {}, + copyScreenshot: {}, + toggleRecording: {}, + simulateMemoryWarning: {}, + openSystemLog: {}, + openAppData: {}, + shake: {}, + toggleSlowAnimations: {}, + toggleLatencyOverlay: {}, + pressButton: pressButton, + hasCameraControl: hasCameraControl, + rotate: { _ in }, + restart: {}, + shutdown: shutdown, + erase: {}, + stepTextSize: { _ in }, + toggleIncreaseContrast: {}, + triggerICloudSync: {}, + setLocation: { _ in }, + setCustomLocation: {}, + locationFavorites: locationFavorites, + setFavoriteLocation: setFavoriteLocation, + toggleKeyboardInput: { _ in }, + toggleHardwareKeyboard: { _ in }, + matchKeyboardLanguage: { _ in }, + toggleAutomaticPasteboardSync: toggleAutomaticPasteboardSync, + getPasteboard: {}, + sendPasteboard: {}, + syncsPasteboard: syncsPasteboard, + panels: { [] }, + currentPanel: { nil }, + showPanel: { _ in }, + setOrientation: { _ in }, + appSwitcher: {}, + stopRecording: {}, + isRecording: { false } + ), + capabilities: [.hardwareButtons, .touch, .rotation, .shake, .pasteboardSync] + ) + } + + /// The item with this title and the menu holding it, looked for through every submenu. + static func item(titled title: String) -> (NSMenuItem, NSMenu)? { + func search(_ menu: NSMenu) -> (NSMenuItem, NSMenu)? { + for item in menu.items { + if item.title == title { return (item, menu) } + if let submenu = item.submenu, let found = search(submenu) { return found } + } + return nil + } + return NSApplication.shared.mainMenu.flatMap(search) + } +} diff --git a/engine/Tests/OpenDeviceHubViewerTests/MenuSettingsTests.swift b/engine/Tests/OpenDeviceHubViewerTests/MenuSettingsTests.swift new file mode 100644 index 0000000..119a819 --- /dev/null +++ b/engine/Tests/OpenDeviceHubViewerTests/MenuSettingsTests.swift @@ -0,0 +1,85 @@ +import AppKit +import XCTest +import OpenDeviceHubEngine +@testable import OpenDeviceHubViewer + +@MainActor +final class MenuSettingsTests: XCTestCase { + private var previousMenu: NSMenu? + + override func setUp() async throws { + previousMenu = NSApplication.shared.mainMenu + } + + override func tearDown() async throws { + NSApplication.shared.mainMenu = previousMenu + } + + func testTheSyncItemFollowsTheSettingWhoeverChangesIt() throws { + var syncs = true + var harness = MenuHarness() + harness.syncsPasteboard = { syncs } + let target = harness.install() + let (sync, _) = try XCTUnwrap(MenuHarness.item(titled: "Automatically Sync Pasteboard")) + let (get, _) = try XCTUnwrap(MenuHarness.item(titled: "Get Pasteboard")) + XCTAssertTrue(target.validateMenuItem(sync)) + XCTAssertEqual(sync.state, .on) + XCTAssertFalse(target.validateMenuItem(get), "the manual half is offered while syncing") + + syncs = false + XCTAssertTrue(target.validateMenuItem(sync)) + XCTAssertEqual(sync.state, .off, "the tick missed sync being turned off in Settings") + XCTAssertTrue(target.validateMenuItem(get), "the manual half stayed greyed after sync went off") + } + + func testChoosingTheSyncItemTogglesTheCurrentSetting() throws { + var syncs = true + var sent: [Bool] = [] + var harness = MenuHarness() + harness.syncsPasteboard = { syncs } + harness.toggleAutomaticPasteboardSync = { value in + sent.append(value) + syncs = value + } + let target = harness.install() + let (sync, _) = try XCTUnwrap(MenuHarness.item(titled: "Automatically Sync Pasteboard")) + + syncs = false + target.automaticPasteboardSync(sync) + XCTAssertEqual(sent, [true], "the menu flipped a value Settings had already changed") + XCTAssertEqual(sync.state, .on) + } + + func testAFavoriteIsListedAndChosenFromTheLocationMenu() throws { + let lagos = LocationFavorite(name: "Lagos", latitude: 6.5244, longitude: 3.3792) + var favorites: [LocationFavorite] = [] + var chosen: [LocationFavorite] = [] + var harness = MenuHarness() + harness.locationFavorites = { favorites } + harness.setFavoriteLocation = { chosen.append($0) } + let target = harness.install() + XCTAssertNil(MenuHarness.item(titled: "Lagos")) + + favorites = [lagos] + let (_, locationMenu) = try XCTUnwrap(MenuHarness.item(titled: "Custom Location\u{2026}")) + target.menuNeedsUpdate(locationMenu) + let (item, _) = try XCTUnwrap(MenuHarness.item(titled: "Lagos"), "a favorite added in Settings is missing") + NSApplication.shared.sendAction(try XCTUnwrap(item.action), to: item.target, from: item) + XCTAssertEqual(chosen, [lagos]) + } + + func testShutDownSitsAfterRestart() throws { + var shutDowns = 0 + var harness = MenuHarness() + harness.shutdown = { shutDowns += 1 } + // A menu item holds its target weakly, so the test keeps it the way the app does. + let target = harness.install() + let (item, menu) = try XCTUnwrap(MenuHarness.item(titled: "Shut Down")) + XCTAssertEqual(menu.item(at: menu.index(of: item) - 1)?.title, "Restart") + let action = try XCTUnwrap(item.action) + withExtendedLifetime(target) { + NSApplication.shared.sendAction(action, to: item.target, from: item) + } + XCTAssertEqual(shutDowns, 1) + } +} diff --git a/engine/Tests/OpenDeviceHubViewerTests/ViewerSettingsTests.swift b/engine/Tests/OpenDeviceHubViewerTests/ViewerSettingsTests.swift index 33a9742..c60950d 100644 --- a/engine/Tests/OpenDeviceHubViewerTests/ViewerSettingsTests.swift +++ b/engine/Tests/OpenDeviceHubViewerTests/ViewerSettingsTests.swift @@ -11,6 +11,7 @@ final class ViewerSettingsTests: XCTestCase { let settings = settings() XCTAssertTrue(settings.shutsDownOnWindowClose) XCTAssertTrue(settings.bootsMostRecentOnStart) + XCTAssertFalse(settings.savesScreenshotsToClipboard) XCTAssertNil(settings.captureDirectory) } @@ -38,6 +39,53 @@ final class ViewerSettingsTests: XCTestCase { XCTAssertTrue(settings.bootsMostRecentOnStart) } + func testSaveScreenshotsToClipboardPreferenceSurvivesBeingTurnedOn() { + let settings = settings() + settings.savesScreenshotsToClipboard = true + XCTAssertTrue(settings.savesScreenshotsToClipboard) + } + + func testBothFoldersStartAtTheSharedFolderOlderVersionsSaved() { + let storage = InMemoryPreferences() + let shared = URL(fileURLWithPath: "/Users/someone/Captures", isDirectory: true) + ViewerSettings(storage: storage, prefix: "test.").captureDirectory = shared + + let settings = ViewerSettings(storage: storage, prefix: "test.") + XCTAssertEqual(settings.screenshotDirectory, shared) + XCTAssertEqual(settings.recordingDirectory, shared) + } + + func testChoosingOneFolderLeavesTheOtherAlone() { + let storage = InMemoryPreferences() + let shared = URL(fileURLWithPath: "/Users/someone/Captures", isDirectory: true) + let stills = URL(fileURLWithPath: "/Users/someone/Stills", isDirectory: true) + let settings = ViewerSettings(storage: storage, prefix: "test.") + settings.captureDirectory = shared + + settings.screenshotDirectory = stills + + XCTAssertEqual(settings.screenshotDirectory, stills) + XCTAssertEqual(settings.recordingDirectory, shared) + } + + func testUsingTheDesktopForOneFolderOverridesTheSharedOne() { + let storage = InMemoryPreferences() + let settings = ViewerSettings(storage: storage, prefix: "test.") + settings.captureDirectory = URL(fileURLWithPath: "/Users/someone/Captures", isDirectory: true) + + settings.recordingDirectory = nil + + XCTAssertNil(ViewerSettings(storage: storage, prefix: "test.").recordingDirectory) + XCTAssertNotNil(ViewerSettings(storage: storage, prefix: "test.").screenshotDirectory) + } + + func testAFolderIsStoredWithoutATrailingSlash() { + let storage = InMemoryPreferences() + ViewerSettings(storage: storage, prefix: "test.").screenshotDirectory = + URL(fileURLWithPath: "/Users/someone/Stills/", isDirectory: true) + XCTAssertEqual(storage.text(forKey: "test.screenshotDirectory"), "/Users/someone/Stills") + } + func testTheCaptureDirectoryRoundTrips() { let settings = settings() let chosen = URL(fileURLWithPath: "/Users/someone/My Captures", isDirectory: true) @@ -61,6 +109,28 @@ final class ViewerSettingsTests: XCTestCase { storage.setText("", forKey: "test.captureDirectory") XCTAssertNil(ViewerSettings(storage: storage, prefix: "test.").captureDirectory) } + + func testLocationFavoritesRoundTripInOrder() { + let storage = InMemoryPreferences() + let settings = ViewerSettings(storage: storage, prefix: "test.") + let favorites = [ + LocationFavorite(name: "Cupertino", latitude: 37.3349, longitude: -122.0090), + LocationFavorite(name: "London", latitude: 51.5072, longitude: -0.1276), + ] + + settings.locationFavorites = favorites + + XCTAssertEqual(ViewerSettings(storage: storage, prefix: "test.").locationFavorites, favorites) + } + + func testMissingOrMalformedLocationFavoritesReadAsEmpty() { + let storage = InMemoryPreferences() + let settings = ViewerSettings(storage: storage, prefix: "test.") + XCTAssertTrue(settings.locationFavorites.isEmpty) + + storage.setText("not json", forKey: "test.locationFavorites") + XCTAssertTrue(settings.locationFavorites.isEmpty) + } } final class InMemoryPreferences: PreferenceStorage, @unchecked Sendable {