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
10 changes: 5 additions & 5 deletions .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
20530 CLI/cmux.swift
17317 Sources/TerminalController.swift
15956 Sources/ContentView.swift
14636 Sources/AppDelegate.swift
16782 Sources/AppDelegate.swift
13975 Sources/Workspace.swift
13458 Sources/GhosttyTerminalView.swift
10607 Sources/Panels/BrowserPanel.swift
12676 Sources/Panels/BrowserPanel.swift
8285 Sources/cmuxApp.swift
7488 Sources/TabManager.swift
6794 Sources/Panels/BrowserPanelView.swift
Expand All @@ -19,12 +19,12 @@
4122 cmuxTests/WorkspaceRemoteConnectionTests.swift
3933 Sources/Panels/FilePreviewPanel.swift
3818 Sources/Feed/FeedPanelView.swift
3588 cmuxTests/BrowserConfigTests.swift
5509 cmuxTests/BrowserConfigTests.swift
3145 cmuxTests/BrowserPanelTests.swift
2917 Sources/CmuxConfig.swift
2863 cmuxTests/WindowAndDragTests.swift
2609 Sources/SessionIndexView.swift
2472 Sources/Panels/CmuxWebView.swift
2521 Sources/Panels/CmuxWebView.swift
2317 cmuxTests/TabManagerUnitTests.swift
2179 Sources/KeyboardShortcutSettings.swift
2172 Sources/TerminalWindowPortal.swift
Expand Down Expand Up @@ -65,7 +65,7 @@
705 cmuxUITests/BrowserOmnibarSuggestionsUITests.swift
701 cmuxUITests/BonsplitTabDragUITests.swift
683 Sources/SocketControlSettings.swift
645 Sources/App/ShortcutRoutingSupport.swift
867 Sources/App/ShortcutRoutingSupport.swift
643 Sources/Panels/BrowserPopupWindowController.swift
631 Sources/Feed/FeedCoordinator.swift
613 Sources/PortScanner.swift
Expand Down
36 changes: 36 additions & 0 deletions Sources/App/BrowserShortcutPassthroughPolicy.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import AppKit

/// Per-URL Cmd-modifier passthrough for the embedded browser pane.
///
/// When the focused browser pane's URL matches the user-configured
/// `browser.shortcutPassthroughHosts` allowlist, Cmd-modifier key equivalents
/// should be handed off to the web content instead of being claimed by cmux's
/// main menu. This is the seam that lets VS Code (running in code-server
/// inside the embedded browser) receive Cmd+P, Cmd+Shift+P, Cmd+F, Cmd+B,
/// Cmd+D, etc.
///
/// Default behavior (empty allowlist) is unchanged. When a chord is forwarded
/// but the page does NOT consume it (e.g. Cmd+Q has no JS handler), callers
/// fall back to AppKit's standard menu dispatch so system shortcuts still work.
///
/// `@MainActor` on the function (not just the body) gives compile-time
/// guarantees that callers are on the main actor, instead of only the runtime
/// trap from `MainActor.assumeIsolated`. The function takes a plain `URL?`
/// rather than a `WKWebView` reference so tests can exercise the policy
/// without instantiating a web view.
@MainActor
func shouldPassthroughCommandEquivalentToWebContent(
_ event: NSEvent,
responder: NSResponder? = nil,
url: URL?,
defaults: UserDefaults = .standard
) -> Bool {
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
guard flags.contains(.command) else { return false }

if cmuxIsLikelyWebInspectorResponder(responder) {
return false
}

return BrowserLinkOpenSettings.urlMatchesShortcutPassthrough(url, defaults: defaults)
}
41 changes: 37 additions & 4 deletions Sources/App/ShortcutRoutingSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,43 @@ func shouldDispatchBrowserArrowViaFirstResponderKeyDown(
return true
}

// Keep modified arrow routing narrow to avoid stealing cmux shortcuts such
// as Cmd+Option+Arrow pane focus. Browser document editors own Cmd+Up/Down
// as trusted keyDown navigation to the start/end of the document.
return normalizedFlags == [.command] && (keyCode == 125 || keyCode == 126)
// cmux owns Cmd+Option+Arrow for pane focus navigation
// (focusLeft/focusRight/focusUp/focusDown). Never route those; let cmux's
// shortcut chain claim them.
if normalizedFlags.contains(.command), normalizedFlags.contains(.option) {
return false
}

// Standard macOS text-navigation / selection chords. AppKit dispatches
// these via interpretKeyEvents -> moveLeftAndModifySelection: /
// moveWordRight: / moveToBeginningOfDocumentAndModifySelection: etc.,
// which means WebKit must see them through keyDown (not NSWindow.
// performKeyEquivalent) for selection extension and word jumps to work.
//
// - Shift+arrow: extend selection by character
// - Option+arrow: jump by word (←/→) or paragraph (↑/↓)
// - Shift+Option+arrow: extend selection by word/paragraph
if normalizedFlags == [.shift] {
return true
}
if normalizedFlags == [.option] {
return true
}
if normalizedFlags == [.shift, .option] {
return true
}

// Browser document editors own Cmd+Up/Down as trusted keyDown navigation
// to the start/end of the document. Cmd+Shift+Up/Down extends selection
// to that same boundary.
if normalizedFlags == [.command] && (keyCode == 125 || keyCode == 126) {
return true
}
if normalizedFlags == [.command, .shift] && (keyCode == 125 || keyCode == 126) {
return true
}

return false
}

func shouldDispatchBrowserOmnibarArrowViaFirstResponderKeyDown(
Expand Down
63 changes: 62 additions & 1 deletion Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11846,6 +11846,29 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
clearConfiguredShortcutChordState()
return false
}
// Per-URL passthrough — local event monitor entry point.
//
// Why this check exists despite identical checks at the NSWindow and
// CmuxWebView layers: the shortcut monitor (installShortcutMonitor)
// fires BEFORE AppKit sends the event to any window. If we let
// handleCustomShortcut's configured-shortcut routing run on a
// passthrough chord (e.g. Cmd+P with a configured `goToWorkspace`
// binding), the monitor would consume the event by returning nil
// and the chord would never reach the web view. We bail out here
// so the event flows through the normal dispatch chain and the
// downstream NSWindow/CmuxWebView passthrough fast paths take over.
if let focusedWindow = event.window ?? NSApp.keyWindow,
let firstResponder = focusedWindow.firstResponder,
browserOmnibarPanelId(for: firstResponder) == nil,
let webView = NSWindow.cmuxOwningWebView(for: firstResponder, in: focusedWindow, event: event),
shouldPassthroughCommandEquivalentToWebContent(
event,
responder: firstResponder,
url: webView.url
) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
clearConfiguredShortcutChordState()
return false
}

// `charactersIgnoringModifiers` can be nil for some synthetic NSEvents and certain special keys.
// Treat nil as "" and rely on keyCode/layout-aware fallback logic where needed.
Expand Down Expand Up @@ -16093,6 +16116,40 @@ private extension NSWindow {
)
return true
}
// Per-URL Cmd-modifier passthrough — NSWindow swizzle entry point.
//
// Why this check exists despite identical checks downstream in
// CmuxWebView.performKeyEquivalent: by the time AppKit's standard
// dispatch reaches the web view, several cmux-internal handlers
// further down in this function (stale-menu-shortcut, ghostty
// routing, browser-find/document-editing preflight) can have already
// claimed the event for cmux. We need to short-circuit at the
// window-swizzle layer so passthrough URLs bypass that entire
// ladder. Returning WebKit's actual consumption result here means
// chords the page does NOT handle (e.g. Cmd+Q with no JS handler)
// fall through to AppKit's standard main-menu dispatch and behave
// as users expect. The omnibar (browser address bar) is excluded
// so its own shortcuts keep working.
if let firstResponderWebView,
firstResponderOmnibarPanelId == nil,
shouldPassthroughCommandEquivalentToWebContent(
event,
responder: self.firstResponder,
url: firstResponderWebView.url
) {
let result = firstResponderWebView.performKeyEquivalent(with: event)
#if DEBUG
cmuxDebugLog(
" → passthrough host match: webView.performKeyEquivalent returned \(result)"
)
#endif
if result {
return true
}
// Web content did not consume the chord; let AppKit's standard
// menu dispatch run so system shortcuts (Cmd+Q, Cmd+W, Cmd+H, ...)
// still work on passthrough hosts.
}
Comment thread
jcrsilva marked this conversation as resolved.
if AppDelegate.shared?.shouldSuppressStaleCmuxMenuShortcut(event: event) == true {
if AppDelegate.shared?.handleConfiguredShortcutKeyEquivalent(event) == true {
#if DEBUG
Expand Down Expand Up @@ -16465,7 +16522,11 @@ private extension NSWindow {
return nil
}

private static func cmuxOwningWebView(
// `fileprivate` (not `private`) so AppDelegate.handleCustomShortcut can
// resolve the focused web view when applying the
// `browser.shortcutPassthroughHosts` policy in the local event monitor
// (which runs on the AppDelegate, not on the NSWindow extension).
fileprivate static func cmuxOwningWebView(
for responder: NSResponder,
in window: NSWindow,
event: NSEvent?
Expand Down
1 change: 1 addition & 0 deletions Sources/CmuxSettingsJSONPathSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ extension CmuxSettingsFileStore {
"browser.hostsToOpenInEmbeddedBrowser",
"browser.urlsToAlwaysOpenExternally",
"browser.insecureHttpHostsAllowedInEmbeddedBrowser",
"browser.shortcutPassthroughHosts",
"browser.showImportHintOnBlankTabs",
"browser.reactGrabVersion",
"shortcuts.bindings",
Expand Down
10 changes: 10 additions & 0 deletions Sources/KeyboardShortcutSettingsFileStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -903,6 +903,16 @@ final class CmuxSettingsFileStore {
} else if section.keys.contains("insecureHttpHostsAllowedInEmbeddedBrowser") {
logInvalid("browser.insecureHttpHostsAllowedInEmbeddedBrowser", sourcePath: sourcePath)
}
if let values = jsonStringArray(section["shortcutPassthroughHosts"]) {
let normalized = values
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
snapshot.managedUserDefaults[BrowserLinkOpenSettings.shortcutPassthroughHostsKey] = .string(
normalized.joined(separator: "\n")
)
} else if section.keys.contains("shortcutPassthroughHosts") {
logInvalid("browser.shortcutPassthroughHosts", sourcePath: sourcePath)
}
if let value = jsonBool(section["showImportHintOnBlankTabs"]) {
snapshot.managedUserDefaults[BrowserImportHintSettings.showOnBlankTabsKey] = .bool(value)
}
Expand Down
73 changes: 73 additions & 0 deletions Sources/Panels/BrowserPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -772,6 +772,44 @@ final class BrowserProfileStore: ObservableObject {
}
}

/// Thread-safe cache for `BrowserLinkOpenSettings.shortcutPassthroughHosts`.
/// The raw newline-delimited string is read from UserDefaults on every call
/// (UserDefaults itself is fast — backed by an in-memory dictionary), but the
/// expensive `components(separatedBy:)` + `map(trimmingCharacters)` + `filter`
/// chain is cached and reused while the raw value is unchanged. This matters
/// because the function fires three times per Cmd-modifier keystroke on the
/// passthrough dispatch path (handleCustomShortcut → cmux_performKeyEquivalent
/// → CmuxWebView.performKeyEquivalent). Cache is keyed on the raw string so
/// it self-invalidates the first call after any UserDefaults change, without
/// needing an explicit observer.
private final class ShortcutPassthroughHostsCache: @unchecked Sendable {
static let shared = ShortcutPassthroughHostsCache()
private let lock = NSLock()
private var cachedHosts: [String]?
private var cachedRaw: String?

func hosts(rawValue: String) -> [String] {
lock.lock()
if let cachedHosts, cachedRaw == rawValue {
let result = cachedHosts
lock.unlock()
return result
}
lock.unlock()

let parsed = rawValue
.components(separatedBy: .newlines)
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }

lock.lock()
cachedHosts = parsed
cachedRaw = rawValue
lock.unlock()
return parsed
}
}

enum BrowserLinkOpenSettings {
static let openTerminalLinksInCmuxBrowserKey = "browserOpenTerminalLinksInCmuxBrowser"
static let defaultOpenTerminalLinksInCmuxBrowser: Bool = true
Expand All @@ -790,6 +828,9 @@ enum BrowserLinkOpenSettings {
static let browserExternalOpenPatternsKey = "browserExternalOpenPatterns"
static let defaultBrowserExternalOpenPatterns: String = ""

static let shortcutPassthroughHostsKey = "browserShortcutPassthroughHosts"
static let defaultShortcutPassthroughHosts: String = ""

static func openTerminalLinksInCmuxBrowser(defaults: UserDefaults = .standard) -> Bool {
guard BrowserAvailabilitySettings.isEnabled(defaults: defaults) else { return false }
if defaults.object(forKey: openTerminalLinksInCmuxBrowserKey) == nil {
Expand Down Expand Up @@ -889,6 +930,38 @@ enum BrowserLinkOpenSettings {
return false
}

static func shortcutPassthroughHosts(defaults: UserDefaults = .standard) -> [String] {
let raw = defaults.string(forKey: shortcutPassthroughHostsKey) ?? defaultShortcutPassthroughHosts
return ShortcutPassthroughHostsCache.shared.hosts(rawValue: raw)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Check whether a hostname is on the user's Cmd-shortcut passthrough allowlist.
/// Empty list means "no passthrough" — every Cmd-modifier chord stays with
/// cmux's menus. This is the inverse of `hostMatchesWhitelist`'s "empty
/// allows all" semantics; the default for passthrough is opt-in.
/// Supports exact match and wildcard prefix (`*.example.com`, which also
/// matches the `example.com` apex).
static func hostMatchesShortcutPassthrough(_ host: String, defaults: UserDefaults = .standard) -> Bool {
let rawPatterns = shortcutPassthroughHosts(defaults: defaults)
if rawPatterns.isEmpty { return false }
guard let normalizedHost = BrowserInsecureHTTPSettings.normalizeHost(host) else { return false }
for rawPattern in rawPatterns {
guard let pattern = normalizeWhitelistPattern(rawPattern) else { continue }
if hostMatchesPattern(normalizedHost, pattern: pattern) {
return true
}
}
return false
}

/// Convenience wrapper around `hostMatchesShortcutPassthrough` that takes a
/// `URL?` and extracts the host. Returns `false` for nil URLs and URLs with
/// no host component (e.g. `about:blank`).
static func urlMatchesShortcutPassthrough(_ url: URL?, defaults: UserDefaults = .standard) -> Bool {
guard let host = url?.host, !host.isEmpty else { return false }
return hostMatchesShortcutPassthrough(host, defaults: defaults)
}

private static func normalizeWhitelistPattern(_ rawPattern: String) -> String? {
let trimmed = rawPattern
.trimmingCharacters(in: .whitespacesAndNewlines)
Expand Down
31 changes: 31 additions & 0 deletions Sources/Panels/CmuxWebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,37 @@ final class CmuxWebView: WKWebView {
return finish(super.performKeyEquivalent(with: event))
}

// Per-URL passthrough — CmuxWebView entry point.
//
// This check is NOT dead code despite an identical check existing in
// NSWindow.cmux_performKeyEquivalent. AppKit can reach
// CmuxWebView.performKeyEquivalent via at least two paths:
// (a) NSWindow.performKeyEquivalent walking the responder chain
// (the swizzled cmux_performKeyEquivalent's passthrough block
// calls super → reaches us here), and
// (b) direct programmatic dispatch — including the existing
// browser-find / browser-document-editing preflight at
// AppDelegate.swift:~15985, which calls
// firstResponderWebView.performKeyEquivalent(with: event)
// directly without re-entering cmux_performKeyEquivalent.
// Removing this check would silently make path (b) bypass the
// passthrough rule. Returning super's result lets WebKit consume the
// chord (e.g. VS Code's Cmd+P preventDefault); if super returns false,
// AppKit's standard responder/menu lookup continues. This branch
// deliberately SKIPS the NSApp.mainMenu.performKeyEquivalent
// forwarding below — the cmux-menu forward is what was claiming
// Cmd+P/Cmd+F before this fix.
if shouldPassthroughCommandEquivalentToWebContent(
event,
responder: window?.firstResponder,
url: self.url
) {
#if DEBUG
cmuxDebugLog("browser.web.performKeyEquivalent: passthrough host match → super")
#endif
return finish(super.performKeyEquivalent(with: event))
}

if Self.isPasteAsPlainTextCommandEquivalent(event) {
if event.timestamp > 0 {
lastPasteAsPlainTextPerformKeyEventTimestamp = event.timestamp
Expand Down
Loading