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

/// Portable catalog of macOS app menu shortcuts. The Cocoa host builds real
/// `NSMenuItem`s from these specs so chords, selectors, and targets stay one
/// source. The protocol suite rejects duplicate physical chords.
public enum MenuShortcutTarget: String, Equatable, Sendable {
case application
case appDelegate
case firstResponder
}

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 let target: MenuShortcutTarget
public let separatorBefore: Bool

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

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:",
target: .application
),
.init(
menu: "Headless", title: "Hide Others", key: "h", option: true,
selector: "hideOtherApplications:", target: .application
),
.init(
menu: "Headless", title: "Quit Headless", key: "q", selector: "terminate:",
target: .application
),
.init(
menu: "File", title: "New Window", key: "n", selector: "newWindow:",
target: .appDelegate
),
.init(menu: "File", title: "Open Location…", key: "l", selector: "openLocation:"),
.init(
menu: "File", title: "Save Snapshot to Desktop", key: "s", shift: true,
selector: "saveSnapshot:", separatorBefore: true
),
.init(
menu: "File", title: "Close Window", key: "w", selector: "performClose:",
separatorBefore: true
),
.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:", separatorBefore: true),
.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:", separatorBefore: true
),
.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:",
separatorBefore: true
),
.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:", separatorBefore: true
),
.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:", separatorBefore: true
),
.init(
menu: "Help", title: "Headless Help", key: "/", shift: true,
selector: "showHelpPage:"
),
]
86 changes: 86 additions & 0 deletions apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3095,6 +3095,91 @@ struct ProtocolTests {
try expect(checked >= 30, "expected to check every command line, checked \(checked)")
}

static func menuShortcutsHaveUniqueChords() throws {
var seen: [String: String] = [:]
var titles: [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")
try expect(spec.selector.hasSuffix(":"), "\(spec.title) selector must be an ObjC action")
if let previous = seen[spec.chordIdentity] {
throw TestFailure(
description: "\(spec.title) collides with \(previous) on \(spec.chordIdentity)"
)
}
seen[spec.chordIdentity] = spec.title
if let previous = titles[spec.title] {
throw TestFailure(description: "duplicate menu title \(spec.title) also used by \(previous)")
}
titles[spec.title] = spec.selector
}
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 help = headlessMenuShortcuts.first { $0.title == "Headless Help" }
try expect(help?.key == "/" && help?.shift == true,
"Headless Help should be Cmd-Shift-/")
try expect(
pin?.selector == "togglePin:" && pin?.target == .firstResponder,
"Pin on Top must keep togglePin: on the first responder"
)
try expect(
help?.selector == "showHelpPage:" && help?.target == .firstResponder,
"Headless Help must keep showHelpPage: on the first responder"
)
let textEditingActions = [
"Undo": "undo:", "Redo": "redo:", "Cut": "cut:", "Copy": "copy:",
"Paste": "paste:", "Select All": "selectAll:",
]
for (title, selector) in textEditingActions {
let shortcut = headlessMenuShortcuts.first { $0.title == title }
try expect(
shortcut?.selector == selector && shortcut?.target == .firstResponder,
"\(title) must target the active text responder"
)
}
let fullScreen = headlessMenuShortcuts.first { $0.title == "Enter Full Screen" }
try expect(
fullScreen?.selector == "toggleFullScreen:" && fullScreen?.target == .firstResponder,
"full screen must retain the standard responder-chain action"
)
let newWindow = headlessMenuShortcuts.first { $0.title == "New Window" }
try expect(
newWindow?.selector == "newWindow:" && newWindow?.target == .appDelegate,
"New Window must target the app delegate"
)
let quit = headlessMenuShortcuts.first { $0.title == "Quit Headless" }
try expect(
quit?.selector == "terminate:" && quit?.target == .application,
"Quit Headless must target NSApp"
)
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"
)
let host = try String(contentsOfFile: "main.swift", encoding: .utf8)
try expect(
host.contains("⌥⌘ P"),
"start page should advertise Option-Command-P for pin"
)
try expect(
!host.contains("<kbd>&#8984; P</kbd>"),
"start page must not advertise Command-P for pin"
)
try expect(
host.contains("NSSelectorFromString(spec.selector)"),
"menu items must take their actions from the catalog"
)
}

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

Expand Down
21 changes: 20 additions & 1 deletion apps/headless/Tests/fixture-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,28 @@ const routes = new Map([
['/allowlist-redirect', 'allowlist-redirect.html'],
['/allowlist-redirect/', 'allowlist-redirect.html'],
]);
const requestCounts = new Map();

const server = createServer(async (request, response) => {
const pathname = new URL(request.url ?? '/', 'http://127.0.0.1').pathname;
const requestURL = new URL(request.url ?? '/', 'http://127.0.0.1');
const pathname = requestURL.pathname;
if (pathname === '/request-count') {
const target = requestURL.searchParams.get('path');
if (!target?.startsWith('/')) {
response.writeHead(400, {'content-type': 'text/plain; charset=utf-8'});
response.end('A rooted path is required');
return;
}
const body = Buffer.from(String(requestCounts.get(target) ?? 0));
response.writeHead(200, {
'content-type': 'text/plain; charset=utf-8',
'content-length': body.length,
'cache-control': 'no-store',
});
response.end(body);
return;
}
requestCounts.set(pathname, (requestCounts.get(pathname) ?? 0) + 1);
if (pathname === '/api/diagnostic') {
const body = Buffer.from(JSON.stringify({ok: true}));
response.writeHead(200, {
Expand Down
Loading