Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion engine/Sources/OpenDeviceHubViewer/ScaleMode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
110 changes: 97 additions & 13 deletions engine/Sources/OpenDeviceHubViewer/ViewerMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ public enum ViewerMenu {

let appItem = NSMenuItem()
let appMenu = NSMenu()
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), "", []))
appMenu.addItem(.separator())
Expand All @@ -102,6 +106,25 @@ 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)
servicesItem.icon("gearshape.2")
appMenu.addItem(.separator())

appMenu.addItem(withTitle: "Hide \(Brand.productName)", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h")
.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")
appItem.submenu = appMenu
bar.addItem(appItem)
Expand All @@ -114,28 +137,29 @@ 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(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").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").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]))
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
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())
Expand Down Expand Up @@ -172,7 +196,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")
Expand All @@ -187,18 +211,70 @@ 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")
.icon("minus.rectangle")
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: "")
.icon("square.3.layers.3d")
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
}
}

/// 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.
/// 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
}
}

private func disable(_ item: NSMenuItem, unless available: Bool, reason: String) {
guard !available else { return }
item.action = nil
Expand Down Expand Up @@ -272,6 +348,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() }
Expand Down
Loading