From 694ea1c4414792bf5e4d4605dda4dc14ee50a84c Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Thu, 24 Sep 2026 08:43:33 +0100 Subject: [PATCH 1/3] feat(viewer): give the menu bar the shape of the simulator it replaces Nine menus, no View. I/O and Features are new, and the orientation, Siri, Action Button and app switcher actions existed in the engine without a way to reach them. --- .../Sources/ODHubViewerApp/ViewerMain.swift | 47 +++++++ .../Input/DeviceOrientation.swift | 9 ++ .../OpenDeviceHubViewer/ViewerMenu.swift | 124 ++++++++++++++---- 3 files changed, 151 insertions(+), 29 deletions(-) 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/ViewerMenu.swift b/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift index 20a429b..b7e749d 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"), ] @@ -284,10 +329,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 +415,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?() } From 2e327fbc0edc0ea58c11d807933ddb8325ac998f Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Thu, 24 Sep 2026 09:10:18 +0100 Subject: [PATCH 2/3] fix(viewer): move the recording indicator out of the title bar The dot was a titlebar accessory from before the floating toolbar existed, so it landed on top of the runtime the toolbar now draws there. --- .../OpenDeviceHubViewer/DeviceToolbar.swift | 3 + .../DeviceWindowController.swift | 3 - .../RecordingIndicator.swift | 75 ------------------- 3 files changed, 3 insertions(+), 78 deletions(-) delete mode 100644 engine/Sources/OpenDeviceHubViewer/RecordingIndicator.swift 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) - } -} From a41668d2af8403f863d1aa50408c419659cc04c8 Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Thu, 24 Sep 2026 09:22:17 +0100 Subject: [PATCH 3/3] fix(viewer): keep the menu icons compiling on the macos 26 sdk An availability check gates the call at runtime, not the symbol at compile time, so the property is set through the runtime instead. --- engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift b/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift index b7e749d..201b5ed 100644 --- a/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift +++ b/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift @@ -310,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 }