Skip to content

Commit 7a2efb5

Browse files
authored
Merge pull request #174 from LockInTime/feat/macos-shortcuts
fix(macos): move Pin off Cmd-P and test menu chords
2 parents ab4e3e8 + 02a6ff4 commit 7a2efb5

6 files changed

Lines changed: 932 additions & 62 deletions

File tree

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import Foundation
2+
3+
/// Portable catalog of macOS app menu shortcuts. The Cocoa host builds real
4+
/// `NSMenuItem`s from these specs so chords, selectors, and targets stay one
5+
/// source. The protocol suite rejects duplicate physical chords.
6+
public enum MenuShortcutTarget: String, Equatable, Sendable {
7+
case application
8+
case appDelegate
9+
case firstResponder
10+
}
11+
12+
public struct MenuShortcutSpec: Equatable, Sendable {
13+
public let menu: String
14+
public let title: String
15+
public let key: String
16+
public let command: Bool
17+
public let shift: Bool
18+
public let option: Bool
19+
public let control: Bool
20+
public let selector: String
21+
public let target: MenuShortcutTarget
22+
public let separatorBefore: Bool
23+
24+
public init(
25+
menu: String,
26+
title: String,
27+
key: String,
28+
command: Bool = true,
29+
shift: Bool = false,
30+
option: Bool = false,
31+
control: Bool = false,
32+
selector: String,
33+
target: MenuShortcutTarget = .firstResponder,
34+
separatorBefore: Bool = false
35+
) {
36+
self.menu = menu
37+
self.title = title
38+
self.key = key
39+
self.command = command
40+
self.shift = shift
41+
self.option = option
42+
self.control = control
43+
self.selector = selector
44+
self.target = target
45+
self.separatorBefore = separatorBefore
46+
}
47+
48+
public var chordIdentity: String {
49+
[
50+
command ? "cmd" : nil,
51+
control ? "ctrl" : nil,
52+
option ? "opt" : nil,
53+
shift ? "shift" : nil,
54+
key.isEmpty ? nil : key.lowercased(),
55+
].compactMap { $0 }.joined(separator: "+")
56+
}
57+
}
58+
59+
public let headlessMenuShortcuts: [MenuShortcutSpec] = [
60+
.init(
61+
menu: "Headless", title: "Hide Headless", key: "h", selector: "hide:",
62+
target: .application
63+
),
64+
.init(
65+
menu: "Headless", title: "Hide Others", key: "h", option: true,
66+
selector: "hideOtherApplications:", target: .application
67+
),
68+
.init(
69+
menu: "Headless", title: "Quit Headless", key: "q", selector: "terminate:",
70+
target: .application
71+
),
72+
.init(
73+
menu: "File", title: "New Window", key: "n", selector: "newWindow:",
74+
target: .appDelegate
75+
),
76+
.init(menu: "File", title: "Open Location…", key: "l", selector: "openLocation:"),
77+
.init(
78+
menu: "File", title: "Save Snapshot to Desktop", key: "s", shift: true,
79+
selector: "saveSnapshot:", separatorBefore: true
80+
),
81+
.init(
82+
menu: "File", title: "Close Window", key: "w", selector: "performClose:",
83+
separatorBefore: true
84+
),
85+
.init(menu: "Edit", title: "Undo", key: "z", selector: "undo:"),
86+
.init(menu: "Edit", title: "Redo", key: "z", shift: true, selector: "redo:"),
87+
.init(menu: "Edit", title: "Cut", key: "x", selector: "cut:", separatorBefore: true),
88+
.init(menu: "Edit", title: "Copy", key: "c", selector: "copy:"),
89+
.init(menu: "Edit", title: "Paste", key: "v", selector: "paste:"),
90+
.init(menu: "Edit", title: "Select All", key: "a", selector: "selectAll:"),
91+
.init(
92+
menu: "Edit", title: "Copy Current URL", key: "c", shift: true,
93+
selector: "copyPageURL:", separatorBefore: true
94+
),
95+
.init(menu: "View", title: "Reload Page", key: "r", selector: "reloadPage:"),
96+
.init(
97+
menu: "View", title: "Reload Ignoring Cache", key: "r", shift: true,
98+
selector: "hardReloadPage:"
99+
),
100+
.init(
101+
menu: "View", title: "Zoom In", key: "=", selector: "zoomInPage:",
102+
separatorBefore: true
103+
),
104+
.init(menu: "View", title: "Zoom Out", key: "-", selector: "zoomOutPage:"),
105+
.init(menu: "View", title: "Actual Size", key: "0", selector: "resetZoom:"),
106+
.init(
107+
menu: "View", title: "Enter Full Screen", key: "f", control: true,
108+
selector: "toggleFullScreen:", separatorBefore: true
109+
),
110+
.init(menu: "History", title: "Back", key: "[", selector: "goBackAction:"),
111+
.init(menu: "History", title: "Forward", key: "]", selector: "goForwardAction:"),
112+
.init(menu: "Window", title: "Minimize", key: "m", selector: "performMiniaturize:"),
113+
.init(
114+
menu: "Window", title: "Pin on Top", key: "p", option: true,
115+
selector: "togglePin:", separatorBefore: true
116+
),
117+
.init(
118+
menu: "Help", title: "Headless Help", key: "/", shift: true,
119+
selector: "showHelpPage:"
120+
),
121+
]

apps/headless/Tests/HeadlessProtocolTests/ProtocolTests.swift

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3095,6 +3095,91 @@ struct ProtocolTests {
30953095
try expect(checked >= 30, "expected to check every command line, checked \(checked)")
30963096
}
30973097

3098+
static func menuShortcutsHaveUniqueChords() throws {
3099+
var seen: [String: String] = [:]
3100+
var titles: [String: String] = [:]
3101+
for spec in headlessMenuShortcuts {
3102+
try expect(!spec.selector.isEmpty, "\(spec.title) is missing a selector")
3103+
try expect(!spec.key.isEmpty, "\(spec.title) should not be in the keyed catalog without a chord")
3104+
try expect(spec.selector.hasSuffix(":"), "\(spec.title) selector must be an ObjC action")
3105+
if let previous = seen[spec.chordIdentity] {
3106+
throw TestFailure(
3107+
description: "\(spec.title) collides with \(previous) on \(spec.chordIdentity)"
3108+
)
3109+
}
3110+
seen[spec.chordIdentity] = spec.title
3111+
if let previous = titles[spec.title] {
3112+
throw TestFailure(description: "duplicate menu title \(spec.title) also used by \(previous)")
3113+
}
3114+
titles[spec.title] = spec.selector
3115+
}
3116+
let pin = headlessMenuShortcuts.first { $0.title == "Pin on Top" }
3117+
try expect(pin?.key == "p" && pin?.command == true && pin?.option == true && pin?.shift == false,
3118+
"Pin on Top should be Cmd-Option-P, not Cmd-P")
3119+
try expect(
3120+
!headlessMenuShortcuts.contains { $0.key == "," },
3121+
"Cmd-, is reserved for a future Settings window"
3122+
)
3123+
let snapshot = headlessMenuShortcuts.first { $0.title == "Save Snapshot to Desktop" }
3124+
try expect(snapshot?.key == "s" && snapshot?.shift == true,
3125+
"snapshot capture should stay Cmd-Shift-S")
3126+
let help = headlessMenuShortcuts.first { $0.title == "Headless Help" }
3127+
try expect(help?.key == "/" && help?.shift == true,
3128+
"Headless Help should be Cmd-Shift-/")
3129+
try expect(
3130+
pin?.selector == "togglePin:" && pin?.target == .firstResponder,
3131+
"Pin on Top must keep togglePin: on the first responder"
3132+
)
3133+
try expect(
3134+
help?.selector == "showHelpPage:" && help?.target == .firstResponder,
3135+
"Headless Help must keep showHelpPage: on the first responder"
3136+
)
3137+
let textEditingActions = [
3138+
"Undo": "undo:", "Redo": "redo:", "Cut": "cut:", "Copy": "copy:",
3139+
"Paste": "paste:", "Select All": "selectAll:",
3140+
]
3141+
for (title, selector) in textEditingActions {
3142+
let shortcut = headlessMenuShortcuts.first { $0.title == title }
3143+
try expect(
3144+
shortcut?.selector == selector && shortcut?.target == .firstResponder,
3145+
"\(title) must target the active text responder"
3146+
)
3147+
}
3148+
let fullScreen = headlessMenuShortcuts.first { $0.title == "Enter Full Screen" }
3149+
try expect(
3150+
fullScreen?.selector == "toggleFullScreen:" && fullScreen?.target == .firstResponder,
3151+
"full screen must retain the standard responder-chain action"
3152+
)
3153+
let newWindow = headlessMenuShortcuts.first { $0.title == "New Window" }
3154+
try expect(
3155+
newWindow?.selector == "newWindow:" && newWindow?.target == .appDelegate,
3156+
"New Window must target the app delegate"
3157+
)
3158+
let quit = headlessMenuShortcuts.first { $0.title == "Quit Headless" }
3159+
try expect(
3160+
quit?.selector == "terminate:" && quit?.target == .application,
3161+
"Quit Headless must target NSApp"
3162+
)
3163+
let p0 = try String(contentsOfFile: "docs/P0.md", encoding: .utf8)
3164+
try expect(
3165+
p0.contains("Cmd-Option-P") && p0.contains("Cmd-Shift-S"),
3166+
"P0 should document the Pin and snapshot chords"
3167+
)
3168+
let host = try String(contentsOfFile: "main.swift", encoding: .utf8)
3169+
try expect(
3170+
host.contains("⌥⌘ P"),
3171+
"start page should advertise Option-Command-P for pin"
3172+
)
3173+
try expect(
3174+
!host.contains("<kbd>&#8984; P</kbd>"),
3175+
"start page must not advertise Command-P for pin"
3176+
)
3177+
try expect(
3178+
host.contains("NSSelectorFromString(spec.selector)"),
3179+
"menu items must take their actions from the catalog"
3180+
)
3181+
}
3182+
30983183
static func authenticationProtocolAndChallengeLifecycle() throws {
30993184
let login = try CLIParser().parse([
31003185
"--session", "work", "auth", "login", "--challenge",
@@ -3765,6 +3850,7 @@ struct ProtocolTests {
37653850
("ephemeral authentication broker lifecycle", ephemeralAuthenticationBrokerLifecycle),
37663851
("host authentication orchestration", hostAuthenticationOrchestration),
37673852
("docs command reference matches help", docsCommandReferenceMatchesHelp),
3853+
("menu shortcuts have unique chords", menuShortcutsHaveUniqueChords),
37683854
("artifact file upload boundaries", artifactUploadCommands),
37693855
]
37703856

apps/headless/Tests/fixture-server.mjs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,28 @@ const routes = new Map([
1717
['/allowlist-redirect', 'allowlist-redirect.html'],
1818
['/allowlist-redirect/', 'allowlist-redirect.html'],
1919
]);
20+
const requestCounts = new Map();
2021

2122
const server = createServer(async (request, response) => {
22-
const pathname = new URL(request.url ?? '/', 'http://127.0.0.1').pathname;
23+
const requestURL = new URL(request.url ?? '/', 'http://127.0.0.1');
24+
const pathname = requestURL.pathname;
25+
if (pathname === '/request-count') {
26+
const target = requestURL.searchParams.get('path');
27+
if (!target?.startsWith('/')) {
28+
response.writeHead(400, {'content-type': 'text/plain; charset=utf-8'});
29+
response.end('A rooted path is required');
30+
return;
31+
}
32+
const body = Buffer.from(String(requestCounts.get(target) ?? 0));
33+
response.writeHead(200, {
34+
'content-type': 'text/plain; charset=utf-8',
35+
'content-length': body.length,
36+
'cache-control': 'no-store',
37+
});
38+
response.end(body);
39+
return;
40+
}
41+
requestCounts.set(pathname, (requestCounts.get(pathname) ?? 0) + 1);
2342
if (pathname === '/api/diagnostic') {
2443
const body = Buffer.from(JSON.stringify({ok: true}));
2544
response.writeHead(200, {

0 commit comments

Comments
 (0)