Skip to content
Open
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
89 changes: 89 additions & 0 deletions apps/headless/Sources/HeadlessProtocol/MenuShortcuts.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import Foundation

/// Portable catalog of macOS app menu shortcuts. The Cocoa host applies these
/// chords; the protocol suite rejects duplicates so Cmd+P cannot silently mean
/// both Pin and Print.
public struct MenuShortcutSpec: Equatable, Sendable {
public let menu: String
public let title: String
public let key: String
public let command: Bool
public let shift: Bool
public let option: Bool
public let control: Bool
public let selector: String

public init(
menu: String,
title: String,
key: String,
command: Bool = true,
shift: Bool = false,
option: Bool = false,
control: Bool = false,
selector: String
) {
self.menu = menu
self.title = title
self.key = key
self.command = command
self.shift = shift
self.option = option
self.control = control
self.selector = selector
}

public var chordIdentity: String {
[
command ? "cmd" : nil,
control ? "ctrl" : nil,
option ? "opt" : nil,
shift ? "shift" : nil,
key.isEmpty ? nil : key.lowercased(),
].compactMap { $0 }.joined(separator: "+")
}
}

public let headlessMenuShortcuts: [MenuShortcutSpec] = [
.init(menu: "Headless", title: "Hide Headless", key: "h", selector: "hide:"),
.init(
menu: "Headless", title: "Hide Others", key: "h", option: true,
selector: "hideOtherApplications:"
),
.init(menu: "Headless", title: "Quit Headless", key: "q", selector: "terminate:"),
.init(menu: "File", title: "New Window", key: "n", selector: "newWindow:"),
.init(menu: "File", title: "Open Location…", key: "l", selector: "openLocation:"),
.init(
menu: "File", title: "Save Snapshot to Desktop", key: "s", shift: true,
selector: "saveSnapshot:"
),
.init(menu: "File", title: "Close Window", key: "w", selector: "performClose:"),
.init(menu: "Edit", title: "Undo", key: "z", selector: "undo:"),
.init(menu: "Edit", title: "Redo", key: "z", shift: true, selector: "redo:"),
.init(menu: "Edit", title: "Cut", key: "x", selector: "cut:"),
.init(menu: "Edit", title: "Copy", key: "c", selector: "copy:"),
.init(menu: "Edit", title: "Paste", key: "v", selector: "paste:"),
.init(menu: "Edit", title: "Select All", key: "a", selector: "selectAll:"),
.init(
menu: "Edit", title: "Copy Current URL", key: "c", shift: true, selector: "copyPageURL:"
),
.init(menu: "View", title: "Reload Page", key: "r", selector: "reloadPage:"),
.init(
menu: "View", title: "Reload Ignoring Cache", key: "r", shift: true,
selector: "hardReloadPage:"
),
.init(menu: "View", title: "Zoom In", key: "=", selector: "zoomInPage:"),
.init(menu: "View", title: "Zoom Out", key: "-", selector: "zoomOutPage:"),
.init(menu: "View", title: "Actual Size", key: "0", selector: "resetZoom:"),
.init(
menu: "View", title: "Enter Full Screen", key: "f", control: true,
selector: "toggleFullScreen:"
),
.init(menu: "History", title: "Back", key: "[", selector: "goBackAction:"),
.init(menu: "History", title: "Forward", key: "]", selector: "goForwardAction:"),
.init(menu: "Window", title: "Minimize", key: "m", selector: "performMiniaturize:"),
.init(
menu: "Window", title: "Pin on Top", key: "p", option: true, selector: "togglePin:"
),
.init(menu: "Help", title: "Headless Help", key: "?", selector: "showHelpPage:"),
]
30 changes: 30 additions & 0 deletions apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2808,6 +2808,35 @@ struct ProtocolTests {
try expect(checked >= 30, "expected to check every command line, checked \(checked)")
}

static func menuShortcutsHaveUniqueChords() throws {
var seen: [String: String] = [:]
for spec in headlessMenuShortcuts {
try expect(!spec.selector.isEmpty, "\(spec.title) is missing a selector")
try expect(!spec.key.isEmpty, "\(spec.title) should not be in the keyed catalog without a chord")
if let previous = seen[spec.chordIdentity] {
throw TestFailure(
description: "\(spec.title) collides with \(previous) on \(spec.chordIdentity)"
)
}
seen[spec.chordIdentity] = spec.title
}
let pin = headlessMenuShortcuts.first { $0.title == "Pin on Top" }
try expect(pin?.key == "p" && pin?.command == true && pin?.option == true && pin?.shift == false,
"Pin on Top should be Cmd-Option-P, not Cmd-P")
try expect(
!headlessMenuShortcuts.contains { $0.key == "," },
"Cmd-, is reserved for a future Settings window"
)
let snapshot = headlessMenuShortcuts.first { $0.title == "Save Snapshot to Desktop" }
try expect(snapshot?.key == "s" && snapshot?.shift == true,
"snapshot capture should stay Cmd-Shift-S")
let p0 = try String(contentsOfFile: "docs/P0.md", encoding: .utf8)
try expect(
p0.contains("Cmd-Option-P") && p0.contains("Cmd-Shift-S"),
"P0 should document the Pin and snapshot chords"
)
}

static func authenticationProtocolAndChallengeLifecycle() throws {
let login = try CLIParser().parse([
"--session", "work", "auth", "login", "--challenge",
Expand Down Expand Up @@ -3343,6 +3372,7 @@ struct ProtocolTests {
("ephemeral authentication broker lifecycle", ephemeralAuthenticationBrokerLifecycle),
("host authentication orchestration", hostAuthenticationOrchestration),
("docs command reference matches help", docsCommandReferenceMatchesHelp),
("menu shortcuts have unique chords", menuShortcutsHaveUniqueChords),
]

var failures = 0
Expand Down
6 changes: 6 additions & 0 deletions apps/headless/docs/P0.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ private permissions, no-follow file operations, locking, and atomic durable
writes. Security boundaries below are fixed policy and cannot be changed with
configuration.

## macOS app shortcuts

Menu chords live in `MenuShortcuts.swift` and are tested for uniqueness.
Pin on Top is Cmd-Option-P so it does not take Cmd-P (Print). Snapshot is
Cmd-Shift-S. Cmd-, is reserved for a future Settings window and is not wired.

## Security boundaries

- Socket access is limited to the current operating-system user.
Expand Down
96 changes: 51 additions & 45 deletions apps/headless/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1048,87 +1048,93 @@ final class AppDelegate: NSObject, NSApplicationDelegate {

// MARK: Menu

private func menuItem(
_ title: String, action: Selector?, target: AnyObject? = nil
) -> NSMenuItem {
let item = NSMenuItem(title: title, action: action, keyEquivalent: "")
item.target = target
if let spec = headlessMenuShortcuts.first(where: { $0.title == title }) {
item.keyEquivalent = spec.key
var mask: NSEvent.ModifierFlags = []
if spec.command { mask.insert(.command) }
if spec.shift { mask.insert(.shift) }
if spec.option { mask.insert(.option) }
if spec.control { mask.insert(.control) }
item.keyEquivalentModifierMask = mask
}
return item
}

private func buildMenu() {
let main = NSMenu()

let appMenu = NSMenu()
appMenu.addItem(withTitle: "About Headless",
action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "")
appMenu.addItem(.separator())
appMenu.addItem(withTitle: "Hide Headless", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h")
let hideOthers = appMenu.addItem(withTitle: "Hide Others",
action: #selector(NSApplication.hideOtherApplications(_:)), keyEquivalent: "h")
hideOthers.keyEquivalentModifierMask = [.command, .option]
appMenu.addItem(menuItem("Hide Headless", action: #selector(NSApplication.hide(_:))))
appMenu.addItem(menuItem("Hide Others", action: #selector(NSApplication.hideOtherApplications(_:))))
appMenu.addItem(withTitle: "Show All", action: #selector(NSApplication.unhideAllApplications(_:)), keyEquivalent: "")
appMenu.addItem(.separator())
appMenu.addItem(withTitle: "Quit Headless", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
appMenu.addItem(menuItem("Quit Headless", action: #selector(NSApplication.terminate(_:))))
main.addItem(withTitle: "Headless", action: nil, keyEquivalent: "").submenu = appMenu

let fileMenu = NSMenu(title: "File")
let newWin = fileMenu.addItem(withTitle: "New Window", action: #selector(newWindow(_:)), keyEquivalent: "n")
newWin.target = self
fileMenu.addItem(withTitle: "Open Location…",
action: #selector(BrowserWindowController.openLocation(_:)), keyEquivalent: "l")
fileMenu.addItem(menuItem("New Window", action: #selector(newWindow(_:)), target: self))
fileMenu.addItem(menuItem("Open Location…", action: #selector(BrowserWindowController.openLocation(_:))))
fileMenu.addItem(.separator())
let snap = fileMenu.addItem(withTitle: "Save Snapshot to Desktop",
action: #selector(BrowserWindowController.saveSnapshot(_:)), keyEquivalent: "s")
snap.keyEquivalentModifierMask = [.command, .shift]
fileMenu.addItem(menuItem(
"Save Snapshot to Desktop",
action: #selector(BrowserWindowController.saveSnapshot(_:))
))
fileMenu.addItem(.separator())
fileMenu.addItem(withTitle: "Close Window", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w")
fileMenu.addItem(menuItem("Close Window", action: #selector(NSWindow.performClose(_:))))
main.addItem(withTitle: "File", action: nil, keyEquivalent: "").submenu = fileMenu

let editMenu = NSMenu(title: "Edit")
editMenu.addItem(withTitle: "Undo", action: NSSelectorFromString("undo:"), keyEquivalent: "z")
editMenu.addItem(withTitle: "Redo", action: NSSelectorFromString("redo:"), keyEquivalent: "Z")
editMenu.addItem(menuItem("Undo", action: NSSelectorFromString("undo:")))
editMenu.addItem(menuItem("Redo", action: NSSelectorFromString("redo:")))
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: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
editMenu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
editMenu.addItem(menuItem("Cut", action: #selector(NSText.cut(_:))))
editMenu.addItem(menuItem("Copy", action: #selector(NSText.copy(_:))))
editMenu.addItem(menuItem("Paste", action: #selector(NSText.paste(_:))))
editMenu.addItem(menuItem("Select All", action: #selector(NSText.selectAll(_:))))
editMenu.addItem(.separator())
let copyURL = editMenu.addItem(withTitle: "Copy Current URL",
action: #selector(BrowserWindowController.copyPageURL(_:)), keyEquivalent: "c")
copyURL.keyEquivalentModifierMask = [.command, .shift]
editMenu.addItem(menuItem(
"Copy Current URL",
action: #selector(BrowserWindowController.copyPageURL(_:))
))
main.addItem(withTitle: "Edit", action: nil, keyEquivalent: "").submenu = editMenu

let viewMenu = NSMenu(title: "View")
viewMenu.addItem(withTitle: "Reload Page",
action: #selector(BrowserWindowController.reloadPage(_:)), keyEquivalent: "r")
let hardReload = viewMenu.addItem(withTitle: "Reload Ignoring Cache",
action: #selector(BrowserWindowController.hardReloadPage(_:)), keyEquivalent: "r")
hardReload.keyEquivalentModifierMask = [.command, .shift]
viewMenu.addItem(menuItem("Reload Page", action: #selector(BrowserWindowController.reloadPage(_:))))
viewMenu.addItem(menuItem(
"Reload Ignoring Cache",
action: #selector(BrowserWindowController.hardReloadPage(_:))
))
viewMenu.addItem(.separator())
viewMenu.addItem(withTitle: "Zoom In",
action: #selector(BrowserWindowController.zoomInPage(_:)), keyEquivalent: "=")
viewMenu.addItem(withTitle: "Zoom Out",
action: #selector(BrowserWindowController.zoomOutPage(_:)), keyEquivalent: "-")
viewMenu.addItem(withTitle: "Actual Size",
action: #selector(BrowserWindowController.resetZoom(_:)), keyEquivalent: "0")
viewMenu.addItem(menuItem("Zoom In", action: #selector(BrowserWindowController.zoomInPage(_:))))
viewMenu.addItem(menuItem("Zoom Out", action: #selector(BrowserWindowController.zoomOutPage(_:))))
viewMenu.addItem(menuItem("Actual Size", action: #selector(BrowserWindowController.resetZoom(_:))))
viewMenu.addItem(.separator())
let fullScreen = viewMenu.addItem(withTitle: "Enter Full Screen",
action: #selector(NSWindow.toggleFullScreen(_:)), keyEquivalent: "f")
fullScreen.keyEquivalentModifierMask = [.command, .control]
viewMenu.addItem(menuItem("Enter Full Screen", action: #selector(NSWindow.toggleFullScreen(_:))))
main.addItem(withTitle: "View", action: nil, keyEquivalent: "").submenu = viewMenu

let historyMenu = NSMenu(title: "History")
historyMenu.addItem(withTitle: "Back",
action: #selector(BrowserWindowController.goBackAction(_:)), keyEquivalent: "[")
historyMenu.addItem(withTitle: "Forward",
action: #selector(BrowserWindowController.goForwardAction(_:)), keyEquivalent: "]")
historyMenu.addItem(menuItem("Back", action: #selector(BrowserWindowController.goBackAction(_:))))
historyMenu.addItem(menuItem("Forward", action: #selector(BrowserWindowController.goForwardAction(_:))))
main.addItem(withTitle: "History", action: nil, keyEquivalent: "").submenu = historyMenu

let windowMenu = NSMenu(title: "Window")
windowMenu.addItem(withTitle: "Minimize", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m")
windowMenu.addItem(menuItem("Minimize", action: #selector(NSWindow.performMiniaturize(_:))))
windowMenu.addItem(withTitle: "Zoom", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "")
windowMenu.addItem(.separator())
windowMenu.addItem(withTitle: "Pin on Top",
action: #selector(BrowserWindowController.togglePin(_:)), keyEquivalent: "p")
windowMenu.addItem(menuItem("Pin on Top", action: #selector(BrowserWindowController.togglePin(_:))))
main.addItem(withTitle: "Window", action: nil, keyEquivalent: "").submenu = windowMenu
NSApp.windowsMenu = windowMenu

let helpMenu = NSMenu(title: "Help")
helpMenu.addItem(withTitle: "Headless Help",
action: #selector(BrowserWindowController.showHelpPage(_:)), keyEquivalent: "?")
helpMenu.addItem(menuItem("Headless Help", action: #selector(BrowserWindowController.showHelpPage(_:))))
main.addItem(withTitle: "Help", action: nil, keyEquivalent: "").submenu = helpMenu
NSApp.helpMenu = helpMenu

Expand Down