diff --git a/engine/Sources/ODHubViewerApp/ViewerMain.swift b/engine/Sources/ODHubViewerApp/ViewerMain.swift index 7bd8182..764ccb0 100644 --- a/engine/Sources/ODHubViewerApp/ViewerMain.swift +++ b/engine/Sources/ODHubViewerApp/ViewerMain.swift @@ -248,6 +248,36 @@ struct ODHubViewer: ParsableCommand { } } }, + setOrientation: { orientation in + for udid in manager.openUDIDs { + guard let controller = manager.controller(for: udid) else { continue } + do { + try adapter.setOrientation(orientation, udid: udid) + controller.setOrientation(orientation) + } catch { + print("rotate failed: \(error.localizedDescription)") + } + } + }, + appSwitcher: { + for udid in manager.openUDIDs { + Task { + do { + let session = try adapter.openInput(udid) + defer { session.close() } + try await openAppSwitcher(session) + } catch { + print("app switcher failed: \(error.localizedDescription)") + } + } + } + }, + stopRecording: { + for url in manager.toggleRecording(into: recordingDirectory()) { + print("recorded \(url.path(percentEncoded: false))") + } + }, + isRecording: { manager.isRecording }, checkForUpdates: updates.map { updater in { updater.checkForUpdates() } } ), capabilities: adapter.capabilities, openSimulatorMenu: chooser.menu, commandLineTool: CommandLineToolInstaller.bundledTool == nil ? nil : CommandLineToolMenu( @@ -469,6 +499,23 @@ private func installToolbar( )) } +private func openAppSwitcher(_ session: any InputSession) async throws { + let path = HomeGesture.appSwitcherPath() + try await session.touch(TouchEvent(phase: .began, points: [path[0]], edge: .bottom)) + for point in path.dropFirst() { + try await Task.sleep(for: .milliseconds(10)) + try await session.touch(TouchEvent(phase: .moved, points: [point], edge: .bottom)) + } + let settle = HomeGesture.settlePath(around: path[path.count - 1]) + for point in settle { + try await Task.sleep(for: .milliseconds(40)) + try await session.touch(TouchEvent(phase: .moved, points: [point], edge: .bottom)) + } + try await session.touch( + TouchEvent(phase: .ended, points: [settle[settle.count - 1]], edge: .bottom) + ) +} + private func swipeHome(_ session: any InputSession) async throws { let path = HomeGesture.swipePath() try await session.touch(TouchEvent(phase: .began, points: [path[0]], edge: .bottom)) diff --git a/engine/Sources/OpenDeviceHubEngine/Input/DeviceOrientation.swift b/engine/Sources/OpenDeviceHubEngine/Input/DeviceOrientation.swift index cbb3fd8..eb8eac7 100644 --- a/engine/Sources/OpenDeviceHubEngine/Input/DeviceOrientation.swift +++ b/engine/Sources/OpenDeviceHubEngine/Input/DeviceOrientation.swift @@ -21,6 +21,15 @@ public enum DeviceOrientation: String, Sendable, Hashable, CaseIterable, Codable self == .landscapeLeft || self == .landscapeRight } + public var displayName: String { + switch self { + case .portrait: "Portrait" + case .landscapeRight: "Landscape Right" + case .portraitUpsideDown: "Portrait Upside Down" + case .landscapeLeft: "Landscape Left" + } + } + /// The size the viewer draws, which swaps the axes in landscape. public func displayedSize(portraitNative: CGSize) -> CGSize { isLandscape diff --git a/engine/Sources/OpenDeviceHubViewer/DeviceToolbar.swift b/engine/Sources/OpenDeviceHubViewer/DeviceToolbar.swift index 973d5a5..95261a4 100644 --- a/engine/Sources/OpenDeviceHubViewer/DeviceToolbar.swift +++ b/engine/Sources/OpenDeviceHubViewer/DeviceToolbar.swift @@ -124,6 +124,9 @@ final class DeviceToolbar: NSObject, NSToolbarDelegate, NSToolbarItemValidation private func describeCapture(_ item: NSToolbarItem) { if isRecording { describe(item, symbol: "stop.circle", title: "Stop Recording", tip: "Stop Recording (\u{2318}R)") + item.image = item.image?.withSymbolConfiguration( + NSImage.SymbolConfiguration(paletteColors: [.systemRed]) + ) } else { describe( item, diff --git a/engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift b/engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift index 7e0e77a..cdb44bd 100644 --- a/engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift +++ b/engine/Sources/OpenDeviceHubViewer/DeviceWindowController.swift @@ -16,7 +16,6 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate private let presentationView: DevicePresentationView private var chrome: DeviceChrome? private var toolbar: DeviceToolbar? - private let recordingIndicator = RecordingIndicator() private var frameTask: Task? private var overlay: ShutdownOverlayView? /// Called when the window's Reboot button is pressed. The owner boots the device; the window @@ -143,7 +142,6 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate public func stop() { guard !isStopped else { return } isStopped = true - recordingIndicator.detach() closeSessions() } @@ -381,7 +379,6 @@ public final class DeviceWindowController: NSWindowController, NSWindowDelegate } public func setRecordingIndicatorVisible(_ visible: Bool) { - recordingIndicator.setVisible(visible, on: window) updateTitle() toolbar?.setRecording(visible) } diff --git a/engine/Sources/OpenDeviceHubViewer/RecordingIndicator.swift b/engine/Sources/OpenDeviceHubViewer/RecordingIndicator.swift deleted file mode 100644 index ad25452..0000000 --- a/engine/Sources/OpenDeviceHubViewer/RecordingIndicator.swift +++ /dev/null @@ -1,75 +0,0 @@ -import AppKit -import QuartzCore - -/// A red dot in the title bar while a recording runs. A window title is plain text, so the dot is a -/// real view rather than a glyph: it takes a colour, sits at the title's own size, and fades in and -/// out instead of flicking between two characters. Keeping it out of the title also leaves the name -/// steady in Mission Control and the Window menu. -@MainActor -final class RecordingIndicator { - private let accessory = NSTitlebarAccessoryViewController() - private let dot = DotView() - private var attachedTo: NSWindow? - - init() { - dot.frame = CGRect(x: 0, y: 0, width: 22, height: 16) - accessory.view = dot - accessory.layoutAttribute = .left - } - - func setVisible(_ visible: Bool, on window: NSWindow?) { - guard let window else { return } - if visible { - guard attachedTo !== window else { return } - detach() - window.addTitlebarAccessoryViewController(accessory) - attachedTo = window - dot.startPulsing() - } else { - detach() - } - } - - func detach() { - dot.stopPulsing() - guard let attachedTo else { return } - if let index = attachedTo.titlebarAccessoryViewControllers.firstIndex(where: { $0 === accessory }) { - attachedTo.removeTitlebarAccessoryViewController(at: index) - } - self.attachedTo = nil - } -} - -private final class DotView: NSView { - private static let pulse = "odh.recording.pulse" - - override var intrinsicContentSize: NSSize { NSSize(width: 22, height: 16) } - - override func draw(_ dirtyRect: NSRect) { - NSColor.systemRed.setFill() - let size: CGFloat = 9 - NSBezierPath(ovalIn: CGRect( - x: (bounds.width - size) / 2, - y: (bounds.height - size) / 2, - width: size, - height: size - )).fill() - } - - func startPulsing() { - wantsLayer = true - guard !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion else { return } - let fade = CABasicAnimation(keyPath: "opacity") - fade.fromValue = 1.0 - fade.toValue = 0.25 - fade.duration = 0.7 - fade.autoreverses = true - fade.repeatCount = .infinity - fade.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) - layer?.add(fade, forKey: Self.pulse) - } - - func stopPulsing() { - layer?.removeAnimation(forKey: Self.pulse) - } -} diff --git a/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift b/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift index 20a429b..201b5ed 100644 --- a/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift +++ b/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift @@ -39,6 +39,10 @@ public enum ViewerMenu { public var toggleLatencyOverlay: () -> Void public var pressButton: (HardwareButton) -> Void public var rotate: (Bool) -> Void + public var setOrientation: (DeviceOrientation) -> Void + public var appSwitcher: () -> Void + public var stopRecording: () -> Void + public var isRecording: () -> Bool public var checkForUpdates: (() -> Void)? public init( @@ -58,6 +62,10 @@ public enum ViewerMenu { toggleLatencyOverlay: @escaping () -> Void, pressButton: @escaping (HardwareButton) -> Void, rotate: @escaping (Bool) -> Void, + setOrientation: @escaping (DeviceOrientation) -> Void, + appSwitcher: @escaping () -> Void, + stopRecording: @escaping () -> Void, + isRecording: @escaping () -> Bool, checkForUpdates: (() -> Void)? = nil ) { self.setScaleMode = setScaleMode @@ -76,6 +84,10 @@ public enum ViewerMenu { self.toggleLatencyOverlay = toggleLatencyOverlay self.pressButton = pressButton self.rotate = rotate + self.setOrientation = setOrientation + self.appSwitcher = appSwitcher + self.stopRecording = stopRecording + self.isRecording = isRecording self.checkForUpdates = checkForUpdates } } @@ -87,6 +99,15 @@ public enum ViewerMenu { openSimulatorMenu: NSMenu? = nil, commandLineTool: CommandLineToolMenu? = nil ) -> MenuTarget { + // AppKit adds Start Dictation and Emoji & Symbols to any Edit menu. Both work, so neither is + // a dead item, but there is no text field anywhere in this app to dictate into or to put a + // character in, and Simulator.app carries neither. Registered rather than set, so anyone who + // has chosen otherwise keeps their choice. + UserDefaults.standard.register(defaults: [ + "NSDisabledDictationMenuItem": true, + "NSDisabledCharacterPaletteMenuItem": true, + ]) + let target = MenuTarget(actions: actions, commandLineTool: commandLineTool) let bar = NSMenu() @@ -136,6 +157,12 @@ public enum ViewerMenu { open.submenu = openSimulatorMenu fileMenu.addItem(open) fileMenu.addItem(.separator()) + fileMenu.addItem(target.item("Save Screen", #selector(MenuTarget.saveScreenshot), "s", [])) + fileMenu.addItem(target.item("Record Screen", #selector(MenuTarget.record), "r", [])) + let stop = target.item("Stop Recording", #selector(MenuTarget.stopRecording), "", []) + target.trackStopRecordingItem(stop) + fileMenu.addItem(stop) + fileMenu.addItem(.separator()) fileMenu.addItem(withTitle: "Close Window", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w") .icon("xmark") fileItem.submenu = fileMenu @@ -158,42 +185,59 @@ public enum ViewerMenu { editItem.submenu = editMenu bar.addItem(editItem) - let viewItem = NSMenuItem() - let viewMenu = NSMenu(title: "View") - viewMenu.addItem(target.item("Show Device Bezel", #selector(MenuTarget.bezel), "b", [])) - viewMenu.addItem(target.item("Keep on Top", #selector(MenuTarget.keepOnTop), "t", [])) - viewMenu.addItem(.separator()) - viewMenu.addItem(withTitle: "Enter Full Screen", action: #selector(NSWindow.toggleFullScreen(_:)), keyEquivalent: "f") - .keyEquivalentModifierMask = [.control, .command] - viewItem.submenu = viewMenu - bar.addItem(viewItem) - let deviceItem = NSMenuItem() let deviceMenu = NSMenu(title: "Device") - let home = target.item("Home", #selector(MenuTarget.home), "h", [.command, .shift]) - disable(home, unless: capabilities.contains(.hardwareButtons), reason: "not available on this Xcode") - deviceMenu.addItem(home) - let lockItem = target.item("Lock", #selector(MenuTarget.lock), "l", [.command]) - disable(lockItem, unless: capabilities.contains(.hardwareButtons), reason: "not available on this Xcode") - deviceMenu.addItem(lockItem) - deviceMenu.addItem(target.item("Volume Up", #selector(MenuTarget.volumeUp), String(UnicodeScalar(NSUpArrowFunctionKey)!), [.command])) - deviceMenu.addItem(target.item("Volume Down", #selector(MenuTarget.volumeDown), String(UnicodeScalar(NSDownArrowFunctionKey)!), [.command])) - deviceMenu.addItem(.separator()) let rotateLeft = target.item("Rotate Left", #selector(MenuTarget.rotateLeft), String(UnicodeScalar(NSLeftArrowFunctionKey)!), [.command]) let rotateRight = target.item("Rotate Right", #selector(MenuTarget.rotateRight), String(UnicodeScalar(NSRightArrowFunctionKey)!), [.command]) for item in [rotateLeft, rotateRight] { disable(item, unless: capabilities.contains(.rotation), reason: "not available on this Xcode") deviceMenu.addItem(item) } + + let orientationItem = NSMenuItem(title: "Orientation", action: nil, keyEquivalent: "") + let orientationMenu = NSMenu(title: "Orientation") + // Four, where Simulator.app offers six: Face Up and Face Down have no equivalent in the + // engine, and an item that cannot do anything is worse than one that is not there. + for orientation in DeviceOrientation.allCases { + let item = target.item(orientation.displayName, #selector(MenuTarget.orientation(_:)), "", []) + item.representedObject = orientation.rawValue + disable(item, unless: capabilities.contains(.rotation), reason: "not available on this Xcode") + orientationMenu.addItem(item) + } + orientationItem.submenu = orientationMenu + deviceMenu.addItem(orientationItem) deviceMenu.addItem(.separator()) - deviceMenu.addItem(target.item("Save Screenshot", #selector(MenuTarget.saveScreenshot), "s", [])) - deviceMenu.addItem(target.item("Record Screen", #selector(MenuTarget.record), "r", [])) - deviceMenu.addItem(.separator()) - let appearance = target.item("Toggle Appearance", #selector(MenuTarget.appearance), "a", [.command, .shift]) - deviceMenu.addItem(appearance) + + let home = target.item("Home", #selector(MenuTarget.home), "h", [.command, .shift]) + let lockItem = target.item("Lock", #selector(MenuTarget.lock), "l", [.command]) + let actionButton = target.item("Action Button", #selector(MenuTarget.actionButton), "", []) + let siri = target.item("Siri", #selector(MenuTarget.siri), "h", [.command, .shift, .option]) + for item in [home, lockItem, actionButton, siri] { + disable(item, unless: capabilities.contains(.hardwareButtons), reason: "not available on this Xcode") + deviceMenu.addItem(item) + } + let deviceShake = target.item("Shake", #selector(MenuTarget.shake), "z", [.control, .command]) + disable(deviceShake, unless: capabilities.contains(.shake), reason: "not available on this Xcode") + deviceMenu.addItem(deviceShake) + let appSwitcher = target.item("App Switcher", #selector(MenuTarget.appSwitcher), "h", [.control, .command, .shift]) + disable(appSwitcher, unless: capabilities.contains(.touch), reason: "not available on this Xcode") + deviceMenu.addItem(appSwitcher) deviceItem.submenu = deviceMenu bar.addItem(deviceItem) + let ioItem = NSMenuItem() + let ioMenu = NSMenu(title: "I/O") + ioMenu.addItem(target.item("Increase Volume", #selector(MenuTarget.volumeUp), String(UnicodeScalar(NSUpArrowFunctionKey)!), [.command])) + ioMenu.addItem(target.item("Decrease Volume", #selector(MenuTarget.volumeDown), String(UnicodeScalar(NSDownArrowFunctionKey)!), [.command])) + ioItem.submenu = ioMenu + bar.addItem(ioItem) + + let featuresItem = NSMenuItem() + let featuresMenu = NSMenu(title: "Features") + featuresMenu.addItem(target.item("Toggle Appearance", #selector(MenuTarget.appearance), "a", [.command, .shift])) + featuresItem.submenu = featuresMenu + bar.addItem(featuresItem) + let debugItem = NSMenuItem() let debugMenu = NSMenu(title: "Debug") let memoryWarning = target.item("Simulate Memory Warning", #selector(MenuTarget.memoryWarning), "m", [.command, .shift]) @@ -206,10 +250,6 @@ public enum ViewerMenu { disable(slowAnimations, unless: capabilities.contains(.slowAnimations), reason: "not available on this Xcode") debugMenu.addItem(slowAnimations) - let shake = target.item("Shake", #selector(MenuTarget.shake), "z", [.control, .command]) - disable(shake, unless: capabilities.contains(.shake), reason: "not available on this Xcode") - debugMenu.addItem(shake) - debugMenu.addItem(.separator()) debugMenu.addItem(target.item("Open System Log\u{2026}", #selector(MenuTarget.systemLog), "/", [])) debugMenu.addItem(target.item("Open App Data in Finder", #selector(MenuTarget.appData), "", [])) @@ -224,6 +264,11 @@ public enum ViewerMenu { .icon("minus.rectangle") windowMenu.addItem(withTitle: "Zoom", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "") windowMenu.addItem(.separator()) + windowMenu.addItem(withTitle: "Enter Full Screen", action: #selector(NSWindow.toggleFullScreen(_:)), keyEquivalent: "f") + .keyEquivalentModifierMask = [.control, .command] + windowMenu.addItem(target.item("Show Device Bezels", #selector(MenuTarget.bezel), "b", [])) + windowMenu.addItem(target.item("Stay On Top", #selector(MenuTarget.keepOnTop), "t", [])) + windowMenu.addItem(.separator()) let scaleShortcuts: [(ScaleMode, String)] = [ (.physicalSize, "1"), (.pointAccurate, "2"), (.pixelAccurate, "3"), (.fit, "4"), ] @@ -265,11 +310,18 @@ public enum ViewerMenu { /// to `.automatic`, which for a menu built in code resolves to hidden: the image is present, /// template and correctly sized, and simply is not drawn. private extension NSMenuItem { + static let setPreferredImageVisibility = NSSelectorFromString("setPreferredImageVisibility:") + /// `NSMenuItem.ImageVisibility.visible`, read off the enum on macOS 27 rather than assumed. + static let imageVisibilityVisible = 1 + @discardableResult func icon(_ symbol: String) -> NSMenuItem { image = NSImage(systemSymbolName: symbol, accessibilityDescription: title) - if #available(macOS 27.0, *) { - preferredImageVisibility = .visible + // Through the runtime rather than as a property, because `#available` is a runtime test and + // the symbol still has to exist when this compiles. It does not on the macOS 26 SDK, which + // is what an Xcode 26 build has, and this project supports both. + if responds(to: Self.setPreferredImageVisibility) { + setValue(Self.imageVisibilityVisible, forKey: "preferredImageVisibility") } return self } @@ -284,10 +336,11 @@ private func disable(_ item: NSMenuItem, unless available: Bool, reason: String) /// Holds the menu actions. AppKit keeps menu targets weakly, so the caller retains this. @MainActor -public final class MenuTarget: NSObject, NSMenuDelegate { +public final class MenuTarget: NSObject, NSMenuDelegate, NSMenuItemValidation { private let actions: ViewerMenu.Actions private let commandLineTool: CommandLineToolMenu? private weak var commandLineToolItem: NSMenuItem? + private weak var stopRecordingItem: NSMenuItem? private var isDark = false private var isSlowAnimations = false private var isLatencyVisible = false @@ -369,6 +422,26 @@ public final class MenuTarget: NSObject, NSMenuDelegate { @objc func volumeUp() { actions.pressButton(.volumeUp) } @objc func volumeDown() { actions.pressButton(.volumeDown) } @objc func rotateLeft() { actions.rotate(true) } + + @objc func orientation(_ sender: NSMenuItem) { + guard let raw = sender.representedObject as? String, + let orientation = DeviceOrientation(rawValue: raw) else { return } + actions.setOrientation(orientation) + } + + @objc func siri() { actions.pressButton(.siri) } + @objc func actionButton() { actions.pressButton(.actionButton) } + @objc func appSwitcher() { actions.appSwitcher() } + @objc func stopRecording() { actions.stopRecording() } + + func trackStopRecordingItem(_ item: NSMenuItem) { + stopRecordingItem = item + } + + public func validateMenuItem(_ item: NSMenuItem) -> Bool { + guard item === stopRecordingItem else { return item.action != nil } + return actions.isRecording() + } @objc func rotateRight() { actions.rotate(false) } @objc func checkForUpdates() { actions.checkForUpdates?() }