From 6dc9e7615c7ad733c6aaaa4e3c7b0418bf5f810b Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Thu, 24 Sep 2026 07:28:34 +0100 Subject: [PATCH 1/3] feat(viewer): add the standard macOS menus and take Apple's shortcuts The menu bar was built item by item as features landed, so the conventions every Mac app has were missing. The zoom shortcuts change to match the simulator this replaces. --- CHANGELOG.md | 4 ++ .../OpenDeviceHubViewer/ScaleMode.swift | 2 +- .../OpenDeviceHubViewer/ViewerMenu.swift | 72 +++++++++++++++---- 3 files changed, 64 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbe5730..abde5cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ First release. with the Mac. - A window follows its device: it shows when the device has shut down, offers a reboot, and reattaches on its own when the device comes back. +- The menu bar follows the simulator it replaces: the same menus in the same order, the same + shortcuts, and the standard macOS ones that were missing. The zoom shortcuts are Apple's now, + ⌘1 Physical Size, ⌘2 Point Accurate, ⌘3 Pixel Accurate, ⌘4 Fit Screen, which changes three of the + four this app used before. - The `odhub` command line tool beside the app, for listing devices and driving input from a script. One menu item puts it on your PATH, without a password on most machines. - If Xcode is missing or its simulator frameworks will not load, the app says what is missing and diff --git a/engine/Sources/OpenDeviceHubViewer/ScaleMode.swift b/engine/Sources/OpenDeviceHubViewer/ScaleMode.swift index 0114a43..0fc451d 100644 --- a/engine/Sources/OpenDeviceHubViewer/ScaleMode.swift +++ b/engine/Sources/OpenDeviceHubViewer/ScaleMode.swift @@ -31,7 +31,7 @@ public enum ScaleMode: String, Sendable, Hashable, Codable, CaseIterable, Expres public var displayName: String { switch self { - case .fit: return "Fit" + case .fit: return "Fit Screen" case .pointAccurate: return "Point Accurate" case .pixelAccurate: return "Pixel Accurate" case .physicalSize: return "Physical Size" diff --git a/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift b/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift index 94fe022..0913eae 100644 --- a/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift +++ b/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift @@ -92,6 +92,8 @@ public enum ViewerMenu { let appItem = NSMenuItem() let appMenu = NSMenu() + appMenu.addItem(target.item("About \(Brand.productName)", #selector(MenuTarget.about), "", [])) + appMenu.addItem(.separator()) if actions.checkForUpdates != nil { appMenu.addItem(target.item("Check for Updates\u{2026}", #selector(MenuTarget.checkForUpdates), "", [])) appMenu.addItem(.separator()) @@ -102,6 +104,19 @@ public enum ViewerMenu { appMenu.addItem(item) appMenu.addItem(.separator()) } + let servicesItem = NSMenuItem(title: "Services", action: nil, keyEquivalent: "") + let servicesMenu = NSMenu(title: "Services") + servicesItem.submenu = servicesMenu + application.servicesMenu = servicesMenu + appMenu.addItem(servicesItem) + appMenu.addItem(.separator()) + + appMenu.addItem(withTitle: "Hide \(Brand.productName)", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h") + appMenu.addItem(withTitle: "Hide Others", action: #selector(NSApplication.hideOtherApplications(_:)), keyEquivalent: "h") + .keyEquivalentModifierMask = [.command, .option] + appMenu.addItem(withTitle: "Show All", action: #selector(NSApplication.unhideAllApplications(_:)), keyEquivalent: "") + appMenu.addItem(.separator()) + appMenu.addItem(withTitle: "Quit \(Brand.productName)", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") appItem.submenu = appMenu bar.addItem(appItem) @@ -120,22 +135,20 @@ public enum ViewerMenu { let editItem = NSMenuItem() let editMenu = NSMenu(title: "Edit") - editMenu.addItem(target.item("Copy Screenshot", #selector(MenuTarget.copyScreenshot), "c", [])) - editMenu.addItem(target.item("Paste to Device", #selector(MenuTarget.paste), "v", [])) + editMenu.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z") + editMenu.addItem(withTitle: "Redo", action: Selector(("redo:")), keyEquivalent: "Z") + editMenu.addItem(.separator()) + editMenu.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x") + editMenu.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c") + editMenu.addItem(target.item("Copy Screen", #selector(MenuTarget.copyScreenshot), "c", [.command, .control])) + editMenu.addItem(target.item("Paste", #selector(MenuTarget.paste), "v", [])) + editMenu.addItem(.separator()) + editMenu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a") editItem.submenu = editMenu bar.addItem(editItem) let viewItem = NSMenuItem() let viewMenu = NSMenu(title: "View") - let scaleShortcuts: [(ScaleMode, String)] = [ - (.pointAccurate, "1"), (.pixelAccurate, "2"), (.physicalSize, "3"), (.fit, "4"), - ] - for (mode, key) in scaleShortcuts { - let item = target.item(mode.displayName, #selector(MenuTarget.scale(_:)), key, []) - item.representedObject = mode.rawValue - viewMenu.addItem(item) - } - viewMenu.addItem(.separator()) 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()) @@ -172,7 +185,7 @@ public enum ViewerMenu { let debugItem = NSMenuItem() let debugMenu = NSMenu(title: "Debug") - let memoryWarning = target.item("Simulate Memory Warning", #selector(MenuTarget.memoryWarning), "", []) + let memoryWarning = target.item("Simulate Memory Warning", #selector(MenuTarget.memoryWarning), "m", [.command, .shift]) // Unavailable items stay visible but disabled, with the reason in a tooltip, rather than // disappearing and leaving the menu looking arbitrary. disable(memoryWarning, unless: capabilities.contains(.memoryWarning), reason: "not available on this Xcode") @@ -187,13 +200,38 @@ public enum ViewerMenu { debugMenu.addItem(shake) debugMenu.addItem(.separator()) - debugMenu.addItem(target.item("Open System Log", #selector(MenuTarget.systemLog), "", [])) + debugMenu.addItem(target.item("Open System Log\u{2026}", #selector(MenuTarget.systemLog), "/", [])) debugMenu.addItem(target.item("Open App Data in Finder", #selector(MenuTarget.appData), "", [])) debugMenu.addItem(.separator()) debugMenu.addItem(target.item("Show Click to Frame Latency", #selector(MenuTarget.latency(_:)), "l", [.command, .shift])) debugItem.submenu = debugMenu bar.addItem(debugItem) + let windowItem = NSMenuItem() + let windowMenu = NSMenu(title: "Window") + windowMenu.addItem(withTitle: "Minimize", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m") + windowMenu.addItem(withTitle: "Zoom", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "") + windowMenu.addItem(.separator()) + let scaleShortcuts: [(ScaleMode, String)] = [ + (.physicalSize, "1"), (.pointAccurate, "2"), (.pixelAccurate, "3"), (.fit, "4"), + ] + for (mode, key) in scaleShortcuts { + let item = target.item(mode.displayName, #selector(MenuTarget.scale(_:)), key, []) + item.representedObject = mode.rawValue + windowMenu.addItem(item) + } + windowMenu.addItem(.separator()) + windowMenu.addItem(withTitle: "Bring All to Front", action: #selector(NSApplication.arrangeInFront(_:)), keyEquivalent: "") + windowItem.submenu = windowMenu + bar.addItem(windowItem) + application.windowsMenu = windowMenu + + let helpItem = NSMenuItem() + let helpMenu = NSMenu(title: "Help") + helpItem.submenu = helpMenu + bar.addItem(helpItem) + application.helpMenu = helpMenu + application.mainMenu = bar return target } @@ -272,6 +310,14 @@ public final class MenuTarget: NSObject, NSMenuDelegate { actions.setScaleMode(mode) } + @objc func about() { + NSApplication.shared.orderFrontStandardAboutPanel(options: [ + .applicationName: Brand.productName, + .applicationVersion: Brand.version, + .version: Brand.buildNumber, + ]) + } + @objc func bezel() { actions.toggleBezel() } @objc func keepOnTop() { actions.toggleKeepOnTop() } @objc func saveScreenshot() { actions.saveScreenshot() } From b12542b8cbc72bdeb140f10ddc60ba56d712a8b9 Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Thu, 24 Sep 2026 07:46:59 +0100 Subject: [PATCH 2/3] feat(viewer): give the standard menu commands their icons AppKit decorates the rows it inserts itself, not the ones a menu built in code hands it, so the symbols are named from the inventory. --- .../OpenDeviceHubViewer/ViewerMenu.swift | 48 +++++++++++++++---- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift b/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift index 0913eae..8ad4ae3 100644 --- a/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift +++ b/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift @@ -92,7 +92,9 @@ public enum ViewerMenu { let appItem = NSMenuItem() let appMenu = NSMenu() - appMenu.addItem(target.item("About \(Brand.productName)", #selector(MenuTarget.about), "", [])) + let aboutItem = target.item("About \(Brand.productName)", #selector(MenuTarget.about), "", []) + aboutItem.icon("info.circle") + appMenu.addItem(aboutItem) appMenu.addItem(.separator()) if actions.checkForUpdates != nil { appMenu.addItem(target.item("Check for Updates\u{2026}", #selector(MenuTarget.checkForUpdates), "", [])) @@ -109,12 +111,18 @@ public enum ViewerMenu { servicesItem.submenu = servicesMenu application.servicesMenu = servicesMenu appMenu.addItem(servicesItem) + servicesItem.icon("gearshape.2") appMenu.addItem(.separator()) appMenu.addItem(withTitle: "Hide \(Brand.productName)", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h") - appMenu.addItem(withTitle: "Hide Others", action: #selector(NSApplication.hideOtherApplications(_:)), keyEquivalent: "h") - .keyEquivalentModifierMask = [.command, .option] + .icon("rectangle.dashed") + let hideOthers = appMenu.addItem(withTitle: "Hide Others", action: #selector(NSApplication.hideOtherApplications(_:)), keyEquivalent: "h") + hideOthers.keyEquivalentModifierMask = [.command, .option] + // The closest public symbol. The one macOS draws here has no public equivalent, so this is + // deliberately an approximation rather than a match. + hideOthers.icon("rectangle.on.rectangle.dashed") appMenu.addItem(withTitle: "Show All", action: #selector(NSApplication.unhideAllApplications(_:)), keyEquivalent: "") + .icon("macwindow.on.rectangle") appMenu.addItem(.separator()) appMenu.addItem(withTitle: "Quit \(Brand.productName)", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") @@ -129,19 +137,22 @@ public enum ViewerMenu { fileMenu.addItem(open) fileMenu.addItem(.separator()) fileMenu.addItem(withTitle: "Close Window", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w") + .icon("xmark") fileItem.submenu = fileMenu bar.addItem(fileItem) } let editItem = NSMenuItem() let editMenu = NSMenu(title: "Edit") - editMenu.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z") - editMenu.addItem(withTitle: "Redo", action: Selector(("redo:")), keyEquivalent: "Z") + editMenu.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z").icon("arrow.uturn.backward") + editMenu.addItem(withTitle: "Redo", action: Selector(("redo:")), keyEquivalent: "Z").icon("arrow.uturn.forward") editMenu.addItem(.separator()) - editMenu.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x") - editMenu.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c") + editMenu.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x").icon("scissors") + editMenu.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c").icon("doc.on.doc") editMenu.addItem(target.item("Copy Screen", #selector(MenuTarget.copyScreenshot), "c", [.command, .control])) - editMenu.addItem(target.item("Paste", #selector(MenuTarget.paste), "v", [])) + let pasteItem = target.item("Paste", #selector(MenuTarget.paste), "v", []) + pasteItem.icon("doc.on.clipboard") + editMenu.addItem(pasteItem) editMenu.addItem(.separator()) editMenu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a") editItem.submenu = editMenu @@ -210,6 +221,7 @@ public enum ViewerMenu { let windowItem = NSMenuItem() let windowMenu = NSMenu(title: "Window") windowMenu.addItem(withTitle: "Minimize", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m") + .icon("minus.rectangle") windowMenu.addItem(withTitle: "Zoom", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "") windowMenu.addItem(.separator()) let scaleShortcuts: [(ScaleMode, String)] = [ @@ -222,6 +234,7 @@ public enum ViewerMenu { } windowMenu.addItem(.separator()) windowMenu.addItem(withTitle: "Bring All to Front", action: #selector(NSApplication.arrangeInFront(_:)), keyEquivalent: "") + .icon("square.3.layers.3d") windowItem.submenu = windowMenu bar.addItem(windowItem) application.windowsMenu = windowMenu @@ -237,6 +250,25 @@ public enum ViewerMenu { } } +/// The icons macOS draws on standard commands, set by hand. +/// +/// Simulator.app's `MainMenu.nib` carries no images at all, yet its Undo, Cut, Copy and application +/// menu rows have them, so on macOS 26 AppKit decorates nib loaded items itself. It does not do that +/// for a menu built in code: measured here, our Undo and Cut came out bare while the three rows +/// AppKit inserted on its own, AutoFill, Start Dictation and Emoji & Symbols, arrived with icons. So +/// matching the app we replace means naming the symbols. +/// +/// Only the standard commands get one. Our own commands stay bare, which is Simulator.app's own +/// seam: `Copy Screen` sits between two icon bearing rows with nothing, and the whole of Device, +/// I/O, Features and Debug has none. +private extension NSMenuItem { + @discardableResult + func icon(_ symbol: String) -> NSMenuItem { + image = NSImage(systemSymbolName: symbol, accessibilityDescription: title) + return self + } +} + private func disable(_ item: NSMenuItem, unless available: Bool, reason: String) { guard !available else { return } item.action = nil From 6db81c6f19743c704f52d19f9ad53812dd85a763 Mon Sep 17 00:00:00 2001 From: Codefarmer Date: Thu, 24 Sep 2026 08:16:05 +0100 Subject: [PATCH 3/3] fix(viewer): make the menu icons visible on macos 27 preferredImageVisibility is new there and defaults to automatic, which hides the image for a menu built in code. --- engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift b/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift index 8ad4ae3..20a429b 100644 --- a/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift +++ b/engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift @@ -261,10 +261,16 @@ public enum ViewerMenu { /// Only the standard commands get one. Our own commands stay bare, which is Simulator.app's own /// seam: `Copy Screen` sits between two icon bearing rows with nothing, and the whole of Device, /// I/O, Features and Debug has none. +/// Setting `image` is not enough on macOS 27. `preferredImageVisibility` is new there and defaults +/// 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 { @discardableResult func icon(_ symbol: String) -> NSMenuItem { image = NSImage(systemSymbolName: symbol, accessibilityDescription: title) + if #available(macOS 27.0, *) { + preferredImageVisibility = .visible + } return self } }