From 2c1fd1f8019e90de6bab47f4fb07ea1b43582f65 Mon Sep 17 00:00:00 2001 From: sugakoji Date: Sun, 22 Feb 2026 22:54:52 +0900 Subject: [PATCH 1/9] Add open wrapper to route URLs to embedded browser When running `open https://...` inside a cmux terminal, the URL now opens in the built-in browser panel instead of the system default browser. Non-URL arguments and explicit flags pass through to /usr/bin/open unchanged. Closes #306 Co-Authored-By: Claude Opus 4.6 (cherry picked from commit 174623776eb0baef04f5a9ab49b926427c149acd) --- GhosttyTabs.xcodeproj/project.pbxproj | 3 ++ Resources/bin/open | 66 +++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100755 Resources/bin/open diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 65cc12e6..58641e08 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -59,6 +59,7 @@ B9000002A1B2C3D4E5F60719 /* cmux.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000001A1B2C3D4E5F60719 /* cmux.swift */; }; B900000BA1B2C3D4E5F60719 /* cmux in Copy CLI */ = {isa = PBXBuildFile; fileRef = B9000004A1B2C3D4E5F60719 /* cmux */; }; C1ADE00002A1B2C3D4E5F719 /* claude in Copy CLI */ = {isa = PBXBuildFile; fileRef = C1ADE00001A1B2C3D4E5F719 /* claude */; }; + D1BEF00002A1B2C3D4E5F719 /* open in Copy CLI */ = {isa = PBXBuildFile; fileRef = D1BEF00001A1B2C3D4E5F719 /* open */; }; 84E00D47E4584162AE53BC8D /* xterm-ghostty in Resources */ = {isa = PBXBuildFile; fileRef = B2E7294509CC42FE9191870E /* xterm-ghostty */; }; A5002000 /* THIRD_PARTY_LICENSES.md in Resources */ = {isa = PBXBuildFile; fileRef = A5002001 /* THIRD_PARTY_LICENSES.md */; }; B9000012A1B2C3D4E5F60719 /* AutomationSocketUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */; }; @@ -96,6 +97,7 @@ files = ( B900000BA1B2C3D4E5F60719 /* cmux in Copy CLI */, C1ADE00002A1B2C3D4E5F719 /* claude in Copy CLI */, + D1BEF00002A1B2C3D4E5F719 /* open in Copy CLI */, ); name = "Copy CLI"; runOnlyForDeploymentPostprocessing = 0; @@ -182,6 +184,7 @@ A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; B2E7294509CC42FE9191870E /* xterm-ghostty */ = {isa = PBXFileReference; lastKnownFileType = file; path = "ghostty/terminfo/78/xterm-ghostty"; sourceTree = ""; }; C1ADE00001A1B2C3D4E5F719 /* claude */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "Resources/bin/claude"; sourceTree = SOURCE_ROOT; }; + D1BEF00001A1B2C3D4E5F719 /* open */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "Resources/bin/open"; sourceTree = SOURCE_ROOT; }; A5002001 /* THIRD_PARTY_LICENSES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = THIRD_PARTY_LICENSES.md; sourceTree = SOURCE_ROOT; }; B9000001A1B2C3D4E5F60719 /* cmux.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmux.swift; sourceTree = ""; }; B9000004A1B2C3D4E5F60719 /* cmux */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = cmux; sourceTree = BUILT_PRODUCTS_DIR; }; diff --git a/Resources/bin/open b/Resources/bin/open new file mode 100755 index 00000000..4000fed6 --- /dev/null +++ b/Resources/bin/open @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# cmux open wrapper - routes HTTP(S) URLs to cmux's in-app browser +# +# When running inside a cmux terminal (CMUX_SOCKET_PATH is set), this wrapper +# intercepts `open https://...` invocations and opens them in cmux's built-in +# browser within the same workspace. All other arguments pass through to +# /usr/bin/open unchanged. + +# Pass through immediately if not in a cmux terminal. +if [[ -z "$CMUX_SOCKET_PATH" ]]; then + exec /usr/bin/open "$@" +fi + +# No arguments → pass through. +if [[ $# -eq 0 ]]; then + exec /usr/bin/open "$@" +fi + +# Scan for flags that indicate explicit user intent → pass through. +# Also collect non-flag arguments (potential URLs/files). +passthrough=false +urls=() +for arg in "$@"; do + case "$arg" in + -a|-b|-R|-e|-t|-f|-W|-g|-n|-h|-s|-j|-u|--env|--stdin|--stdout|--stderr) + passthrough=true + break + ;; + -*) + # Unknown flag → be conservative, pass through + passthrough=true + break + ;; + http://*|https://*) + urls+=("$arg") + ;; + *) + # Non-URL, non-flag argument (file path, etc.) → pass through all + passthrough=true + break + ;; + esac +done + +if [[ "$passthrough" == true ]] || [[ ${#urls[@]} -eq 0 ]]; then + exec /usr/bin/open "$@" +fi + +# Find cmux CLI (same directory as this script). +SELF_DIR="$(cd "$(dirname "$0")" && pwd)" +CMUX_CLI="$SELF_DIR/cmux" + +if [[ ! -x "$CMUX_CLI" ]]; then + exec /usr/bin/open "$@" +fi + +# Open each URL in cmux's in-app browser. +failed=false +for url in "${urls[@]}"; do + "$CMUX_CLI" browser open "$url" 2>/dev/null || failed=true +done + +# If any failed, fall back to system open for all URLs. +if [[ "$failed" == true ]]; then + exec /usr/bin/open "$@" +fi From f104dbc37f8caf7ec2fffe5be19a4b16e4f4067a Mon Sep 17 00:00:00 2001 From: sugakoji Date: Sun, 22 Feb 2026 23:21:58 +0900 Subject: [PATCH 2/9] Fix double-open on partial failure with multiple URLs When multiple URLs were passed and some succeeded but others failed, the fallback re-opened all URLs via /usr/bin/open, causing duplicates. Now only failed URLs are passed to the system open fallback. Co-Authored-By: Claude Opus 4.6 (cherry picked from commit 3790b0c0f0b98286b78f6f5aa8dbc9756cf756e8) --- Resources/bin/open | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Resources/bin/open b/Resources/bin/open index 4000fed6..b161e8b7 100755 --- a/Resources/bin/open +++ b/Resources/bin/open @@ -54,13 +54,13 @@ if [[ ! -x "$CMUX_CLI" ]]; then exec /usr/bin/open "$@" fi -# Open each URL in cmux's in-app browser. -failed=false +# Open each URL in cmux's in-app browser; track failures individually. +failed_urls=() for url in "${urls[@]}"; do - "$CMUX_CLI" browser open "$url" 2>/dev/null || failed=true + "$CMUX_CLI" browser open "$url" 2>/dev/null || failed_urls+=("$url") done -# If any failed, fall back to system open for all URLs. -if [[ "$failed" == true ]]; then - exec /usr/bin/open "$@" +# Fall back to system open only for URLs that failed. +if [[ ${#failed_urls[@]} -gt 0 ]]; then + exec /usr/bin/open "${failed_urls[@]}" fi From 2428ae5dbd5c6e3804f06b7d95e721e770adf14b Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 22 Feb 2026 18:13:14 -0800 Subject: [PATCH 3/9] Respect browser link settings in open wrapper --- Resources/bin/open | 150 +++++++++++++++++++- Sources/GhosttyTerminalView.swift | 3 + tests/test_open_wrapper.py | 221 ++++++++++++++++++++++++++++++ 3 files changed, 369 insertions(+), 5 deletions(-) create mode 100755 tests/test_open_wrapper.py diff --git a/Resources/bin/open b/Resources/bin/open index b161e8b7..e08a87c1 100755 --- a/Resources/bin/open +++ b/Resources/bin/open @@ -6,14 +6,135 @@ # browser within the same workspace. All other arguments pass through to # /usr/bin/open unchanged. +SYSTEM_OPEN_BIN="${CMUX_OPEN_WRAPPER_SYSTEM_OPEN:-/usr/bin/open}" +DEFAULTS_BIN="${CMUX_OPEN_WRAPPER_DEFAULTS:-/usr/bin/defaults}" + +if [[ ! -x "$SYSTEM_OPEN_BIN" ]]; then + SYSTEM_OPEN_BIN="/usr/bin/open" +fi + +if [[ ! -x "$DEFAULTS_BIN" ]]; then + DEFAULTS_BIN="/usr/bin/defaults" +fi + +settings_domain="${CMUX_BUNDLE_ID:-}" +whitelist_raw="" +whitelist_patterns=() + +system_open() { + exec "$SYSTEM_OPEN_BIN" "$@" +} + +trim() { + local value="$1" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + printf '%s' "$value" +} + +normalize_host() { + local value + value="$(trim "$1")" + value="${value,,}" + [[ -z "$value" ]] && return 1 + + if [[ "$value" == *"://"* ]]; then + value="${value#*://}" + fi + + value="${value%%/*}" + value="${value%%\?*}" + value="${value%%\#*}" + + if [[ "$value" == *"@"* ]]; then + value="${value##*@}" + fi + + if [[ "$value" == \[* ]]; then + value="${value#\[}" + value="${value%%\]*}" + elif [[ "$value" == *:* ]]; then + local colons="${value//[^:]}" + if [[ ${#colons} -eq 1 ]] && [[ "$value" =~ :[0-9]+$ ]]; then + value="${value%:*}" + fi + fi + + while [[ "$value" == .* ]]; do + value="${value#.}" + done + while [[ "$value" == *. ]]; do + value="${value%.}" + done + + [[ -z "$value" ]] && return 1 + printf '%s' "$value" +} + +normalize_whitelist_pattern() { + local value + value="$(trim "$1")" + value="${value,,}" + [[ -z "$value" ]] && return 1 + + if [[ "$value" == \*.* ]]; then + local suffix + suffix="$(normalize_host "${value#*.}")" || return 1 + printf '*.%s' "$suffix" + return 0 + fi + + normalize_host "$value" +} + +host_matches_pattern() { + local host="$1" + local pattern="$2" + + if [[ "$pattern" == \*.* ]]; then + local suffix="${pattern#*.}" + [[ "$host" == "$suffix" ]] && return 0 + [[ "$host" == *".$suffix" ]] && return 0 + return 1 + fi + + [[ "$host" == "$pattern" ]] +} + +host_matches_whitelist() { + local url="$1" + if [[ ${#whitelist_patterns[@]} -eq 0 ]]; then + return 0 + fi + + local host + host="$(normalize_host "$url")" || return 1 + for pattern in "${whitelist_patterns[@]}"; do + if host_matches_pattern "$host" "$pattern"; then + return 0 + fi + done + return 1 +} + +load_whitelist_patterns() { + local raw="$1" + local line + while IFS= read -r line || [[ -n "$line" ]]; do + local normalized + normalized="$(normalize_whitelist_pattern "$line")" || continue + whitelist_patterns+=("$normalized") + done <<< "$raw" +} + # Pass through immediately if not in a cmux terminal. if [[ -z "$CMUX_SOCKET_PATH" ]]; then - exec /usr/bin/open "$@" + system_open "$@" fi # No arguments → pass through. if [[ $# -eq 0 ]]; then - exec /usr/bin/open "$@" + system_open "$@" fi # Scan for flags that indicate explicit user intent → pass through. @@ -43,7 +164,22 @@ for arg in "$@"; do done if [[ "$passthrough" == true ]] || [[ ${#urls[@]} -eq 0 ]]; then - exec /usr/bin/open "$@" + system_open "$@" +fi + +# Respect the same settings used for terminal link clicks. +if [[ -n "$settings_domain" ]]; then + open_in_cmux="$("$DEFAULTS_BIN" read "$settings_domain" browserOpenTerminalLinksInCmuxBrowser 2>/dev/null || true)" + case "${open_in_cmux,,}" in + 0|false|no) + system_open "$@" + ;; + esac + + whitelist_raw="$("$DEFAULTS_BIN" read "$settings_domain" browserHostWhitelist 2>/dev/null || true)" + if [[ -n "$whitelist_raw" ]]; then + load_whitelist_patterns "$whitelist_raw" + fi fi # Find cmux CLI (same directory as this script). @@ -51,16 +187,20 @@ SELF_DIR="$(cd "$(dirname "$0")" && pwd)" CMUX_CLI="$SELF_DIR/cmux" if [[ ! -x "$CMUX_CLI" ]]; then - exec /usr/bin/open "$@" + system_open "$@" fi # Open each URL in cmux's in-app browser; track failures individually. failed_urls=() for url in "${urls[@]}"; do + if ! host_matches_whitelist "$url"; then + failed_urls+=("$url") + continue + fi "$CMUX_CLI" browser open "$url" 2>/dev/null || failed_urls+=("$url") done # Fall back to system open only for URLs that failed. if [[ ${#failed_urls[@]} -gt 0 ]]; then - exec /usr/bin/open "${failed_urls[@]}" + system_open "${failed_urls[@]}" fi diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 1bd23f8d..3c3fa1a0 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1365,6 +1365,9 @@ final class TerminalSurface: Identifiable, ObservableObject { env["CMUX_PANEL_ID"] = id.uuidString env["CMUX_TAB_ID"] = tabId.uuidString env["CMUX_SOCKET_PATH"] = SocketControlSettings.socketPath() + if let bundleId = Bundle.main.bundleIdentifier, !bundleId.isEmpty { + env["CMUX_BUNDLE_ID"] = bundleId + } // Port range for this workspace (base/range snapshotted once per app session) do { diff --git a/tests/test_open_wrapper.py b/tests/test_open_wrapper.py new file mode 100755 index 00000000..c4d90c27 --- /dev/null +++ b/tests/test_open_wrapper.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +""" +Regression tests for Resources/bin/open. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import tempfile +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SOURCE_WRAPPER = ROOT / "Resources" / "bin" / "open" + + +def make_executable(path: Path, content: str) -> None: + path.write_text(content, encoding="utf-8") + path.chmod(0o755) + + +def read_log(path: Path) -> list[str]: + if not path.exists(): + return [] + return [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] + + +def run_wrapper( + *, + args: list[str], + open_setting: str | None, + whitelist: str | None, + fail_urls: list[str] | None = None, +) -> tuple[list[str], list[str], int, str]: + with tempfile.TemporaryDirectory(prefix="cmux-open-wrapper-test-") as td: + tmp = Path(td) + wrapper = tmp / "open" + shutil.copy2(SOURCE_WRAPPER, wrapper) + wrapper.chmod(0o755) + + open_log = tmp / "open.log" + cmux_log = tmp / "cmux.log" + system_open = tmp / "system-open" + defaults = tmp / "defaults" + cmux = tmp / "cmux" + + make_executable( + system_open, + """#!/usr/bin/env bash +set -euo pipefail +printf '%s\\n' "$*" >> "$FAKE_OPEN_LOG" +""", + ) + + make_executable( + defaults, + """#!/usr/bin/env bash +set -euo pipefail +if [[ "${1:-}" != "read" ]]; then + exit 1 +fi +key="${3:-}" +case "$key" in + browserOpenTerminalLinksInCmuxBrowser) + if [[ -v FAKE_DEFAULTS_OPEN ]]; then + printf '%s\\n' "$FAKE_DEFAULTS_OPEN" + exit 0 + fi + exit 1 + ;; + browserHostWhitelist) + if [[ -v FAKE_DEFAULTS_WHITELIST ]]; then + printf '%s' "$FAKE_DEFAULTS_WHITELIST" + exit 0 + fi + exit 1 + ;; + *) + exit 1 + ;; +esac +""", + ) + + make_executable( + cmux, + """#!/usr/bin/env bash +set -euo pipefail +printf '%s\\n' "$*" >> "$FAKE_CMUX_LOG" +url="${*: -1}" +if [[ -n "${FAKE_CMUX_FAIL_URLS:-}" ]]; then + IFS=',' read -r -a failures <<< "$FAKE_CMUX_FAIL_URLS" + for fail_url in "${failures[@]}"; do + if [[ "$url" == "$fail_url" ]]; then + exit 1 + fi + done +fi +exit 0 +""", + ) + + env = os.environ.copy() + env["CMUX_SOCKET_PATH"] = "/tmp/cmux-open-wrapper-test.sock" + env["CMUX_BUNDLE_ID"] = "com.cmuxterm.app.debug.test" + env["CMUX_OPEN_WRAPPER_SYSTEM_OPEN"] = str(system_open) + env["CMUX_OPEN_WRAPPER_DEFAULTS"] = str(defaults) + env["FAKE_OPEN_LOG"] = str(open_log) + env["FAKE_CMUX_LOG"] = str(cmux_log) + + if open_setting is None: + env.pop("FAKE_DEFAULTS_OPEN", None) + else: + env["FAKE_DEFAULTS_OPEN"] = open_setting + + if whitelist is None: + env.pop("FAKE_DEFAULTS_WHITELIST", None) + else: + env["FAKE_DEFAULTS_WHITELIST"] = whitelist + + if fail_urls: + env["FAKE_CMUX_FAIL_URLS"] = ",".join(fail_urls) + else: + env.pop("FAKE_CMUX_FAIL_URLS", None) + + result = subprocess.run( + [str(wrapper), *args], + env=env, + capture_output=True, + text=True, + check=False, + ) + + return read_log(open_log), read_log(cmux_log), result.returncode, result.stderr.strip() + + +def expect(condition: bool, message: str, failures: list[str]) -> None: + if not condition: + failures.append(message) + + +def test_toggle_disabled_passthrough(failures: list[str]) -> None: + url = "https://example.com" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + open_setting="0", + whitelist="", + ) + expect(code == 0, f"toggle off: wrapper exited {code}: {stderr}", failures) + expect(cmux_log == [], f"toggle off: cmux should not be called, got {cmux_log}", failures) + expect(open_log == [url], f"toggle off: expected system open [{url}], got {open_log}", failures) + + +def test_whitelist_miss_passthrough(failures: list[str]) -> None: + url = "https://example.com" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + open_setting="1", + whitelist="localhost\n127.0.0.1", + ) + expect(code == 0, f"whitelist miss: wrapper exited {code}: {stderr}", failures) + expect(cmux_log == [], f"whitelist miss: cmux should not be called, got {cmux_log}", failures) + expect(open_log == [url], f"whitelist miss: expected system open [{url}], got {open_log}", failures) + + +def test_whitelist_match_routes_to_cmux(failures: list[str]) -> None: + url = "https://api.example.com/path?q=1" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + open_setting="1", + whitelist="*.example.com", + ) + expect(code == 0, f"whitelist match: wrapper exited {code}: {stderr}", failures) + expect(open_log == [], f"whitelist match: system open should not be called, got {open_log}", failures) + expect(cmux_log == [f"browser open {url}"], f"whitelist match: unexpected cmux log {cmux_log}", failures) + + +def test_partial_failures_only_fallback_failed_urls(failures: list[str]) -> None: + good = "https://api.example.com" + failed = "https://fail.example.com" + external = "https://outside.test" + open_log, cmux_log, code, stderr = run_wrapper( + args=[good, failed, external], + open_setting="1", + whitelist="*.example.com", + fail_urls=[failed], + ) + expect(code == 0, f"partial failure: wrapper exited {code}: {stderr}", failures) + expect( + cmux_log == [f"browser open {good}", f"browser open {failed}"], + f"partial failure: cmux log mismatch {cmux_log}", + failures, + ) + expect( + open_log == [f"{failed} {external}"], + f"partial failure: expected fallback for failed/external only, got {open_log}", + failures, + ) + + +def main() -> int: + failures: list[str] = [] + test_toggle_disabled_passthrough(failures) + test_whitelist_miss_passthrough(failures) + test_whitelist_match_routes_to_cmux(failures) + test_partial_failures_only_fallback_failed_urls(failures) + + if failures: + print("open wrapper regression tests failed:") + for failure in failures: + print(f" - {failure}") + return 1 + + print("open wrapper regression tests passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 0046b674aa4928a08f830d49db75892d173442ff Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 22 Feb 2026 18:26:07 -0800 Subject: [PATCH 4/9] Split open-wrapper interception into its own setting --- Resources/bin/open | 6 ++- Sources/Panels/BrowserPanel.swift | 16 +++++++ Sources/cmuxApp.swift | 18 ++++++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 20 ++++++++ tests/test_open_wrapper.py | 47 +++++++++++++++---- 5 files changed, 94 insertions(+), 13 deletions(-) diff --git a/Resources/bin/open b/Resources/bin/open index e08a87c1..f4104d90 100755 --- a/Resources/bin/open +++ b/Resources/bin/open @@ -169,7 +169,11 @@ fi # Respect the same settings used for terminal link clicks. if [[ -n "$settings_domain" ]]; then - open_in_cmux="$("$DEFAULTS_BIN" read "$settings_domain" browserOpenTerminalLinksInCmuxBrowser 2>/dev/null || true)" + open_in_cmux="$("$DEFAULTS_BIN" read "$settings_domain" browserInterceptTerminalOpenCommandInCmuxBrowser 2>/dev/null || true)" + if [[ -z "$open_in_cmux" ]]; then + # Backward compatibility for installs that predate the dedicated open-wrapper toggle. + open_in_cmux="$("$DEFAULTS_BIN" read "$settings_domain" browserOpenTerminalLinksInCmuxBrowser 2>/dev/null || true)" + fi case "${open_in_cmux,,}" in 0|false|no) system_open "$@" diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index ee3452d2..534b6edb 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -127,6 +127,9 @@ enum BrowserLinkOpenSettings { static let openTerminalLinksInCmuxBrowserKey = "browserOpenTerminalLinksInCmuxBrowser" static let defaultOpenTerminalLinksInCmuxBrowser: Bool = true + static let interceptTerminalOpenCommandInCmuxBrowserKey = "browserInterceptTerminalOpenCommandInCmuxBrowser" + static let defaultInterceptTerminalOpenCommandInCmuxBrowser: Bool = true + static let browserHostWhitelistKey = "browserHostWhitelist" static let defaultBrowserHostWhitelist: String = "" @@ -137,6 +140,19 @@ enum BrowserLinkOpenSettings { return defaults.bool(forKey: openTerminalLinksInCmuxBrowserKey) } + static func interceptTerminalOpenCommandInCmuxBrowser(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: interceptTerminalOpenCommandInCmuxBrowserKey) != nil { + return defaults.bool(forKey: interceptTerminalOpenCommandInCmuxBrowserKey) + } + + // Migrate existing behavior for users who only had the link-click toggle. + if defaults.object(forKey: openTerminalLinksInCmuxBrowserKey) != nil { + return defaults.bool(forKey: openTerminalLinksInCmuxBrowserKey) + } + + return defaultInterceptTerminalOpenCommandInCmuxBrowser + } + static func hostWhitelist(defaults: UserDefaults = .standard) -> [String] { let raw = defaults.string(forKey: browserHostWhitelistKey) ?? defaultBrowserHostWhitelist return raw diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 1d4f57fb..47c22d8d 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2559,6 +2559,8 @@ struct SettingsView: View { @AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled @AppStorage(BrowserThemeSettings.modeKey) private var browserThemeMode = BrowserThemeSettings.defaultMode.rawValue @AppStorage(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) private var openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser + @AppStorage(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey) + private var interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.defaultInterceptTerminalOpenCommandInCmuxBrowser @AppStorage(BrowserLinkOpenSettings.browserHostWhitelistKey) private var browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist @AppStorage(BrowserInsecureHTTPSettings.allowlistKey) private var browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText @AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled @@ -3023,13 +3025,24 @@ struct SettingsView: View { .controlSize(.small) } - if openTerminalLinksInCmuxBrowser { + SettingsCardDivider() + + SettingsCardRow( + "Intercept open http(s) in Terminal", + subtitle: "When off, `open https://...` and `open http://...` always use your default browser." + ) { + Toggle("", isOn: $interceptTerminalOpenCommandInCmuxBrowser) + .labelsHidden() + .controlSize(.small) + } + + if openTerminalLinksInCmuxBrowser || interceptTerminalOpenCommandInCmuxBrowser { SettingsCardDivider() VStack(alignment: .leading, spacing: 6) { SettingsCardRow( "Hosts to Open in Embedded Browser", - subtitle: "When you click links in terminal output, only these hosts open in cmux. Other hosts open in your default browser. One host or wildcard per line (for example: example.com, *.internal.example). Leave empty to open all links in cmux." + subtitle: "Applies to terminal link clicks and intercepted `open https://...` calls. Only these hosts open in cmux. Others open in your default browser. One host or wildcard per line (for example: example.com, *.internal.example). Leave empty to open all hosts in cmux." ) { EmptyView() } @@ -3291,6 +3304,7 @@ struct SettingsView: View { browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled browserThemeMode = BrowserThemeSettings.defaultMode.rawValue openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser + interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.defaultInterceptTerminalOpenCommandInCmuxBrowser browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 8355569e..e3b604f5 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -4140,6 +4140,26 @@ final class BrowserLinkOpenSettingsTests: XCTestCase { defaults.set(true, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) XCTAssertTrue(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser(defaults: defaults)) } + + func testOpenCommandInterceptionDefaultsToCmuxBrowser() { + XCTAssertTrue(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)) + } + + func testOpenCommandInterceptionUsesStoredValue() { + defaults.set(false, forKey: BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey) + XCTAssertFalse(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)) + + defaults.set(true, forKey: BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey) + XCTAssertTrue(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)) + } + + func testOpenCommandInterceptionFallsBackToLegacyLinkToggleWhenUnset() { + defaults.set(false, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) + XCTAssertFalse(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)) + + defaults.set(true, forKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) + XCTAssertTrue(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)) + } } final class TerminalOpenURLTargetResolutionTests: XCTestCase { diff --git a/tests/test_open_wrapper.py b/tests/test_open_wrapper.py index c4d90c27..e602eca7 100755 --- a/tests/test_open_wrapper.py +++ b/tests/test_open_wrapper.py @@ -30,7 +30,8 @@ def read_log(path: Path) -> list[str]: def run_wrapper( *, args: list[str], - open_setting: str | None, + intercept_setting: str | None, + legacy_open_setting: str | None = None, whitelist: str | None, fail_urls: list[str] | None = None, ) -> tuple[list[str], list[str], int, str]: @@ -63,9 +64,16 @@ if [[ "${1:-}" != "read" ]]; then fi key="${3:-}" case "$key" in + browserInterceptTerminalOpenCommandInCmuxBrowser) + if [[ -v FAKE_DEFAULTS_INTERCEPT_OPEN ]]; then + printf '%s\\n' "$FAKE_DEFAULTS_INTERCEPT_OPEN" + exit 0 + fi + exit 1 + ;; browserOpenTerminalLinksInCmuxBrowser) - if [[ -v FAKE_DEFAULTS_OPEN ]]; then - printf '%s\\n' "$FAKE_DEFAULTS_OPEN" + if [[ -v FAKE_DEFAULTS_LEGACY_OPEN ]]; then + printf '%s\\n' "$FAKE_DEFAULTS_LEGACY_OPEN" exit 0 fi exit 1 @@ -110,10 +118,15 @@ exit 0 env["FAKE_OPEN_LOG"] = str(open_log) env["FAKE_CMUX_LOG"] = str(cmux_log) - if open_setting is None: - env.pop("FAKE_DEFAULTS_OPEN", None) + if intercept_setting is None: + env.pop("FAKE_DEFAULTS_INTERCEPT_OPEN", None) else: - env["FAKE_DEFAULTS_OPEN"] = open_setting + env["FAKE_DEFAULTS_INTERCEPT_OPEN"] = intercept_setting + + if legacy_open_setting is None: + env.pop("FAKE_DEFAULTS_LEGACY_OPEN", None) + else: + env["FAKE_DEFAULTS_LEGACY_OPEN"] = legacy_open_setting if whitelist is None: env.pop("FAKE_DEFAULTS_WHITELIST", None) @@ -145,7 +158,7 @@ def test_toggle_disabled_passthrough(failures: list[str]) -> None: url = "https://example.com" open_log, cmux_log, code, stderr = run_wrapper( args=[url], - open_setting="0", + intercept_setting="0", whitelist="", ) expect(code == 0, f"toggle off: wrapper exited {code}: {stderr}", failures) @@ -157,7 +170,7 @@ def test_whitelist_miss_passthrough(failures: list[str]) -> None: url = "https://example.com" open_log, cmux_log, code, stderr = run_wrapper( args=[url], - open_setting="1", + intercept_setting="1", whitelist="localhost\n127.0.0.1", ) expect(code == 0, f"whitelist miss: wrapper exited {code}: {stderr}", failures) @@ -169,7 +182,7 @@ def test_whitelist_match_routes_to_cmux(failures: list[str]) -> None: url = "https://api.example.com/path?q=1" open_log, cmux_log, code, stderr = run_wrapper( args=[url], - open_setting="1", + intercept_setting="1", whitelist="*.example.com", ) expect(code == 0, f"whitelist match: wrapper exited {code}: {stderr}", failures) @@ -183,7 +196,7 @@ def test_partial_failures_only_fallback_failed_urls(failures: list[str]) -> None external = "https://outside.test" open_log, cmux_log, code, stderr = run_wrapper( args=[good, failed, external], - open_setting="1", + intercept_setting="1", whitelist="*.example.com", fail_urls=[failed], ) @@ -200,12 +213,26 @@ def test_partial_failures_only_fallback_failed_urls(failures: list[str]) -> None ) +def test_legacy_toggle_fallback_passthrough(failures: list[str]) -> None: + url = "https://example.com" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting=None, + legacy_open_setting="0", + whitelist="", + ) + expect(code == 0, f"legacy fallback: wrapper exited {code}: {stderr}", failures) + expect(cmux_log == [], f"legacy fallback: cmux should not be called, got {cmux_log}", failures) + expect(open_log == [url], f"legacy fallback: expected system open [{url}], got {open_log}", failures) + + def main() -> int: failures: list[str] = [] test_toggle_disabled_passthrough(failures) test_whitelist_miss_passthrough(failures) test_whitelist_match_routes_to_cmux(failures) test_partial_failures_only_fallback_failed_urls(failures) + test_legacy_toggle_fallback_passthrough(failures) if failures: print("open wrapper regression tests failed:") From 3afa345f3a45bda781a22a8ff67c22ead74ccdf3 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 22 Feb 2026 18:38:37 -0800 Subject: [PATCH 5/9] Harden open wrapper for Bash 3 and IDN host parity --- Resources/bin/open | 99 +++++++++++++++++++++++++++++++++----- tests/test_open_wrapper.py | 95 ++++++++++++++++++++++++++++++++++-- 2 files changed, 176 insertions(+), 18 deletions(-) diff --git a/Resources/bin/open b/Resources/bin/open index f4104d90..9c81ea54 100755 --- a/Resources/bin/open +++ b/Resources/bin/open @@ -8,6 +8,7 @@ SYSTEM_OPEN_BIN="${CMUX_OPEN_WRAPPER_SYSTEM_OPEN:-/usr/bin/open}" DEFAULTS_BIN="${CMUX_OPEN_WRAPPER_DEFAULTS:-/usr/bin/defaults}" +PYTHON3_BIN="${CMUX_OPEN_WRAPPER_PYTHON3:-}" if [[ ! -x "$SYSTEM_OPEN_BIN" ]]; then SYSTEM_OPEN_BIN="/usr/bin/open" @@ -17,6 +18,14 @@ if [[ ! -x "$DEFAULTS_BIN" ]]; then DEFAULTS_BIN="/usr/bin/defaults" fi +if [[ -n "$PYTHON3_BIN" ]]; then + if [[ ! -x "$PYTHON3_BIN" ]]; then + PYTHON3_BIN="" + fi +elif command -v python3 >/dev/null 2>&1; then + PYTHON3_BIN="$(command -v python3)" +fi + settings_domain="${CMUX_BUNDLE_ID:-}" whitelist_raw="" whitelist_patterns=() @@ -32,10 +41,74 @@ trim() { printf '%s' "$value" } +to_lower_ascii() { + # Bash 3.2-compatible lowercase conversion. + LC_ALL=C printf '%s' "$1" | tr '[:upper:]' '[:lower:]' +} + +normalize_boolean() { + to_lower_ascii "$(trim "$1")" +} + +is_false_setting() { + local normalized + normalized="$(normalize_boolean "$1")" + case "$normalized" in + 0|false|no|off) + return 0 + ;; + esac + return 1 +} + +canonicalize_idn_host() { + local value="$1" + [[ -z "$PYTHON3_BIN" ]] && { + printf '%s' "$value" + return 0 + } + + local canonicalized + canonicalized="$("$PYTHON3_BIN" - "$value" <<'PY' 2>/dev/null || true +import sys + +host = sys.argv[1].strip().rstrip(".") +if not host: + raise SystemExit(1) + +labels = host.split(".") +if any(not label for label in labels): + raise SystemExit(1) + +try: + canonical = ".".join(label.encode("idna").decode("ascii") for label in labels) +except Exception: + raise SystemExit(1) + +sys.stdout.write(canonical.lower()) +PY +)" + if [[ -n "$canonicalized" ]]; then + printf '%s' "$canonicalized" + return 0 + fi + printf '%s' "$value" +} + +is_http_url() { + local value="$1" + case "$value" in + [Hh][Tt][Tt][Pp]://*|[Hh][Tt][Tt][Pp][Ss]://*) + return 0 + ;; + esac + return 1 +} + normalize_host() { local value value="$(trim "$1")" - value="${value,,}" + value="$(to_lower_ascii "$value")" [[ -z "$value" ]] && return 1 if [[ "$value" == *"://"* ]]; then @@ -68,13 +141,14 @@ normalize_host() { done [[ -z "$value" ]] && return 1 + value="$(canonicalize_idn_host "$value")" printf '%s' "$value" } normalize_whitelist_pattern() { local value value="$(trim "$1")" - value="${value,,}" + value="$(to_lower_ascii "$value")" [[ -z "$value" ]] && return 1 if [[ "$value" == \*.* ]]; then @@ -152,13 +226,14 @@ for arg in "$@"; do passthrough=true break ;; - http://*|https://*) - urls+=("$arg") - ;; *) - # Non-URL, non-flag argument (file path, etc.) → pass through all - passthrough=true - break + if is_http_url "$arg"; then + urls+=("$arg") + else + # Non-URL, non-flag argument (file path, etc.) → pass through all + passthrough=true + break + fi ;; esac done @@ -174,11 +249,9 @@ if [[ -n "$settings_domain" ]]; then # Backward compatibility for installs that predate the dedicated open-wrapper toggle. open_in_cmux="$("$DEFAULTS_BIN" read "$settings_domain" browserOpenTerminalLinksInCmuxBrowser 2>/dev/null || true)" fi - case "${open_in_cmux,,}" in - 0|false|no) - system_open "$@" - ;; - esac + if is_false_setting "$open_in_cmux"; then + system_open "$@" + fi whitelist_raw="$("$DEFAULTS_BIN" read "$settings_domain" browserHostWhitelist 2>/dev/null || true)" if [[ -n "$whitelist_raw" ]]; then diff --git a/tests/test_open_wrapper.py b/tests/test_open_wrapper.py index e602eca7..6119033a 100755 --- a/tests/test_open_wrapper.py +++ b/tests/test_open_wrapper.py @@ -65,21 +65,21 @@ fi key="${3:-}" case "$key" in browserInterceptTerminalOpenCommandInCmuxBrowser) - if [[ -v FAKE_DEFAULTS_INTERCEPT_OPEN ]]; then + if [[ "${FAKE_DEFAULTS_INTERCEPT_OPEN+x}" == "x" ]]; then printf '%s\\n' "$FAKE_DEFAULTS_INTERCEPT_OPEN" exit 0 fi exit 1 ;; browserOpenTerminalLinksInCmuxBrowser) - if [[ -v FAKE_DEFAULTS_LEGACY_OPEN ]]; then + if [[ "${FAKE_DEFAULTS_LEGACY_OPEN+x}" == "x" ]]; then printf '%s\\n' "$FAKE_DEFAULTS_LEGACY_OPEN" exit 0 fi exit 1 ;; browserHostWhitelist) - if [[ -v FAKE_DEFAULTS_WHITELIST ]]; then + if [[ "${FAKE_DEFAULTS_WHITELIST+x}" == "x" ]]; then printf '%s' "$FAKE_DEFAULTS_WHITELIST" exit 0 fi @@ -97,7 +97,10 @@ esac """#!/usr/bin/env bash set -euo pipefail printf '%s\\n' "$*" >> "$FAKE_CMUX_LOG" -url="${*: -1}" +url="" +for arg in "$@"; do + url="$arg" +done if [[ -n "${FAKE_CMUX_FAIL_URLS:-}" ]]; then IFS=',' read -r -a failures <<< "$FAKE_CMUX_FAIL_URLS" for fail_url in "${failures[@]}"; do @@ -139,7 +142,7 @@ exit 0 env.pop("FAKE_CMUX_FAIL_URLS", None) result = subprocess.run( - [str(wrapper), *args], + ["/bin/bash", str(wrapper), *args], env=env, capture_output=True, text=True, @@ -166,6 +169,26 @@ def test_toggle_disabled_passthrough(failures: list[str]) -> None: expect(open_log == [url], f"toggle off: expected system open [{url}], got {open_log}", failures) +def test_toggle_disabled_case_insensitive_passthrough(failures: list[str]) -> None: + url = "https://example.com" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting=" FaLsE ", + whitelist="", + ) + expect(code == 0, f"toggle off (case-insensitive): wrapper exited {code}: {stderr}", failures) + expect( + cmux_log == [], + f"toggle off (case-insensitive): cmux should not be called, got {cmux_log}", + failures, + ) + expect( + open_log == [url], + f"toggle off (case-insensitive): expected system open [{url}], got {open_log}", + failures, + ) + + def test_whitelist_miss_passthrough(failures: list[str]) -> None: url = "https://example.com" open_log, cmux_log, code, stderr = run_wrapper( @@ -226,13 +249,75 @@ def test_legacy_toggle_fallback_passthrough(failures: list[str]) -> None: expect(open_log == [url], f"legacy fallback: expected system open [{url}], got {open_log}", failures) +def test_legacy_toggle_fallback_case_insensitive_passthrough(failures: list[str]) -> None: + url = "https://example.com" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting=None, + legacy_open_setting=" Off ", + whitelist="", + ) + expect(code == 0, f"legacy fallback (case-insensitive): wrapper exited {code}: {stderr}", failures) + expect( + cmux_log == [], + f"legacy fallback (case-insensitive): cmux should not be called, got {cmux_log}", + failures, + ) + expect( + open_log == [url], + f"legacy fallback (case-insensitive): expected system open [{url}], got {open_log}", + failures, + ) + + +def test_uppercase_scheme_routes_to_cmux(failures: list[str]) -> None: + url = "HTTPS://api.example.com/path?q=1" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting="1", + whitelist="*.example.com", + ) + expect(code == 0, f"uppercase scheme: wrapper exited {code}: {stderr}", failures) + expect(open_log == [], f"uppercase scheme: system open should not be called, got {open_log}", failures) + expect(cmux_log == [f"browser open {url}"], f"uppercase scheme: unexpected cmux log {cmux_log}", failures) + + +def test_unicode_whitelist_matches_punycode_url(failures: list[str]) -> None: + url = "https://xn--bcher-kva.example/path" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting="1", + whitelist="bücher.example", + ) + expect(code == 0, f"unicode whitelist: wrapper exited {code}: {stderr}", failures) + expect(open_log == [], f"unicode whitelist: system open should not be called, got {open_log}", failures) + expect(cmux_log == [f"browser open {url}"], f"unicode whitelist: unexpected cmux log {cmux_log}", failures) + + +def test_punycode_whitelist_matches_unicode_url(failures: list[str]) -> None: + url = "https://bücher.example/path" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting="1", + whitelist="xn--bcher-kva.example", + ) + expect(code == 0, f"punycode whitelist: wrapper exited {code}: {stderr}", failures) + expect(open_log == [], f"punycode whitelist: system open should not be called, got {open_log}", failures) + expect(cmux_log == [f"browser open {url}"], f"punycode whitelist: unexpected cmux log {cmux_log}", failures) + + def main() -> int: failures: list[str] = [] test_toggle_disabled_passthrough(failures) + test_toggle_disabled_case_insensitive_passthrough(failures) test_whitelist_miss_passthrough(failures) test_whitelist_match_routes_to_cmux(failures) test_partial_failures_only_fallback_failed_urls(failures) test_legacy_toggle_fallback_passthrough(failures) + test_legacy_toggle_fallback_case_insensitive_passthrough(failures) + test_uppercase_scheme_routes_to_cmux(failures) + test_unicode_whitelist_matches_punycode_url(failures) + test_punycode_whitelist_matches_unicode_url(failures) if failures: print("open wrapper regression tests failed:") From 4ee6640e35b1dce0678f79ca2ba6b5286e5564f1 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 22 Feb 2026 18:50:01 -0800 Subject: [PATCH 6/9] Preserve terminal focus for non-focus split opens --- Sources/Workspace.swift | 26 +++++++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 60 +++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index bb0345d4..ab06df65 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -960,6 +960,7 @@ final class Workspace: Identifiable, ObservableObject { isPinned: false ) surfaceIdToPanelId[newTab.id] = newPanel.id + let previousFocusedPanelId = focusedPanelId // Capture the source terminal's hosted view before bonsplit mutates focusedPaneId, // so we can hand it to focusPanel as the "move focus FROM" view. @@ -989,7 +990,17 @@ final class Workspace: Identifiable, ObservableObject { previousHostedView?.clearSuppressReparentFocus() } } else { - scheduleFocusReconcile() + // Bonsplit focuses the newly-created pane by default; restore the caller's + // pre-split focus context when this split is explicitly non-focus-intent. + if let previousFocusedPanelId, panels[previousFocusedPanelId] != nil { + previousHostedView?.suppressReparentFocus() + focusPanel(previousFocusedPanelId, previousHostedView: previousHostedView) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + previousHostedView?.clearSuppressReparentFocus() + } + } else { + scheduleFocusReconcile() + } } return newPanel @@ -1089,6 +1100,7 @@ final class Workspace: Identifiable, ObservableObject { isPinned: false ) surfaceIdToPanelId[newTab.id] = browserPanel.id + let previousFocusedPanelId = focusedPanelId // Create the split with the browser tab already present. // Mark this split as programmatic so didSplitPane doesn't auto-create a terminal. @@ -1110,7 +1122,17 @@ final class Workspace: Identifiable, ObservableObject { previousHostedView?.clearSuppressReparentFocus() } } else { - scheduleFocusReconcile() + // Bonsplit focuses the newly-created pane by default; restore the caller's + // pre-split focus context when this split is explicitly non-focus-intent. + if let previousFocusedPanelId, panels[previousFocusedPanelId] != nil { + previousHostedView?.suppressReparentFocus() + focusPanel(previousFocusedPanelId, previousHostedView: previousHostedView) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + previousHostedView?.clearSuppressReparentFocus() + } + } else { + scheduleFocusReconcile() + } } installBrowserPanelSubscription(browserPanel) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index e3b604f5..b24947bf 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1800,6 +1800,66 @@ final class TabManagerReopenClosedBrowserFocusTests: XCTestCase { @MainActor final class WorkspacePanelGitBranchTests: XCTestCase { + private func drainMainQueue() { + let expectation = expectation(description: "drain main queue") + DispatchQueue.main.async { + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + } + + func testBrowserSplitWithFocusFalsePreservesOriginalFocusedPanel() { + let workspace = Workspace() + guard let originalFocusedPanelId = workspace.focusedPanelId else { + XCTFail("Expected initial focused panel") + return + } + + guard let browserSplitPanel = workspace.newBrowserSplit( + from: originalFocusedPanelId, + orientation: .horizontal, + focus: false + ) else { + XCTFail("Expected browser split panel to be created") + return + } + + drainMainQueue() + + XCTAssertNotEqual(browserSplitPanel.id, originalFocusedPanelId) + XCTAssertEqual( + workspace.focusedPanelId, + originalFocusedPanelId, + "Expected non-focus browser split to preserve pre-split focus" + ) + } + + func testTerminalSplitWithFocusFalsePreservesOriginalFocusedPanel() { + let workspace = Workspace() + guard let originalFocusedPanelId = workspace.focusedPanelId else { + XCTFail("Expected initial focused panel") + return + } + + guard let terminalSplitPanel = workspace.newTerminalSplit( + from: originalFocusedPanelId, + orientation: .horizontal, + focus: false + ) else { + XCTFail("Expected terminal split panel to be created") + return + } + + drainMainQueue() + + XCTAssertNotEqual(terminalSplitPanel.id, originalFocusedPanelId) + XCTAssertEqual( + workspace.focusedPanelId, + originalFocusedPanelId, + "Expected non-focus terminal split to preserve pre-split focus" + ) + } + func testClosingFocusedSplitRestoresBranchForRemainingFocusedPanel() { let workspace = Workspace() guard let firstPanelId = workspace.focusedPanelId else { From 0dbe95b797d9d7d6fe3a66145e4a1b45459bca78 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 22 Feb 2026 19:04:57 -0800 Subject: [PATCH 7/9] Reassert non-focus split focus after delayed callbacks --- Sources/Workspace.swift | 189 +++++++++++------- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 54 +++++ 2 files changed, 175 insertions(+), 68 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index ab06df65..99bdd74e 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -962,49 +962,43 @@ final class Workspace: Identifiable, ObservableObject { surfaceIdToPanelId[newTab.id] = newPanel.id let previousFocusedPanelId = focusedPanelId - // Capture the source terminal's hosted view before bonsplit mutates focusedPaneId, - // so we can hand it to focusPanel as the "move focus FROM" view. - let previousHostedView = focusedTerminalPanel?.hostedView + // Capture the source terminal's hosted view before bonsplit mutates focusedPaneId, + // so we can hand it to focusPanel as the "move focus FROM" view. + let previousHostedView = focusedTerminalPanel?.hostedView - // Create the split with the new tab already present in the new pane. - isProgrammaticSplit = true - defer { isProgrammaticSplit = false } - guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else { - panels.removeValue(forKey: newPanel.id) - panelTitles.removeValue(forKey: newPanel.id) - surfaceIdToPanelId.removeValue(forKey: newTab.id) - return nil - } + // Create the split with the new tab already present in the new pane. + isProgrammaticSplit = true + defer { isProgrammaticSplit = false } + guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else { + panels.removeValue(forKey: newPanel.id) + panelTitles.removeValue(forKey: newPanel.id) + surfaceIdToPanelId.removeValue(forKey: newTab.id) + return nil + } #if DEBUG - dlog("split.created pane=\(paneId.id.uuidString.prefix(5)) orientation=\(orientation)") + dlog("split.created pane=\(paneId.id.uuidString.prefix(5)) orientation=\(orientation)") #endif - // Suppress the old view's becomeFirstResponder side-effects during SwiftUI reparenting. - // Without this, reparenting triggers onFocus + ghostty_surface_set_focus on the old view, - // stealing focus from the new panel and creating model/surface divergence. - if focus { - previousHostedView?.suppressReparentFocus() - focusPanel(newPanel.id, previousHostedView: previousHostedView) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - previousHostedView?.clearSuppressReparentFocus() - } - } else { - // Bonsplit focuses the newly-created pane by default; restore the caller's - // pre-split focus context when this split is explicitly non-focus-intent. - if let previousFocusedPanelId, panels[previousFocusedPanelId] != nil { - previousHostedView?.suppressReparentFocus() - focusPanel(previousFocusedPanelId, previousHostedView: previousHostedView) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - previousHostedView?.clearSuppressReparentFocus() - } - } else { - scheduleFocusReconcile() - } - } + // Suppress the old view's becomeFirstResponder side-effects during SwiftUI reparenting. + // Without this, reparenting triggers onFocus + ghostty_surface_set_focus on the old view, + // stealing focus from the new panel and creating model/surface divergence. + if focus { + previousHostedView?.suppressReparentFocus() + focusPanel(newPanel.id, previousHostedView: previousHostedView) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + previousHostedView?.clearSuppressReparentFocus() + } + } else { + preserveFocusAfterNonFocusSplit( + preferredPanelId: previousFocusedPanelId, + splitPanelId: newPanel.id, + previousHostedView: previousHostedView + ) + } - return newPanel - } + return newPanel + } /// Create a new surface (nested tab) in the specified pane with a terminal panel. /// - Parameter focus: nil = focus only if the target pane is already focused (default UI behavior), @@ -1102,38 +1096,32 @@ final class Workspace: Identifiable, ObservableObject { surfaceIdToPanelId[newTab.id] = browserPanel.id let previousFocusedPanelId = focusedPanelId - // Create the split with the browser tab already present. - // Mark this split as programmatic so didSplitPane doesn't auto-create a terminal. - isProgrammaticSplit = true - defer { isProgrammaticSplit = false } - guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else { - surfaceIdToPanelId.removeValue(forKey: newTab.id) - panels.removeValue(forKey: browserPanel.id) - panelTitles.removeValue(forKey: browserPanel.id) - return nil - } + // Create the split with the browser tab already present. + // Mark this split as programmatic so didSplitPane doesn't auto-create a terminal. + isProgrammaticSplit = true + defer { isProgrammaticSplit = false } + guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else { + surfaceIdToPanelId.removeValue(forKey: newTab.id) + panels.removeValue(forKey: browserPanel.id) + panelTitles.removeValue(forKey: browserPanel.id) + return nil + } - // See newTerminalSplit: suppress old view's becomeFirstResponder during reparenting. - let previousHostedView = focusedTerminalPanel?.hostedView - if focus { - previousHostedView?.suppressReparentFocus() - focusPanel(browserPanel.id) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - previousHostedView?.clearSuppressReparentFocus() - } - } else { - // Bonsplit focuses the newly-created pane by default; restore the caller's - // pre-split focus context when this split is explicitly non-focus-intent. - if let previousFocusedPanelId, panels[previousFocusedPanelId] != nil { - previousHostedView?.suppressReparentFocus() - focusPanel(previousFocusedPanelId, previousHostedView: previousHostedView) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - previousHostedView?.clearSuppressReparentFocus() - } - } else { - scheduleFocusReconcile() - } - } + // See newTerminalSplit: suppress old view's becomeFirstResponder during reparenting. + let previousHostedView = focusedTerminalPanel?.hostedView + if focus { + previousHostedView?.suppressReparentFocus() + focusPanel(browserPanel.id) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + previousHostedView?.clearSuppressReparentFocus() + } + } else { + preserveFocusAfterNonFocusSplit( + preferredPanelId: previousFocusedPanelId, + splitPanelId: browserPanel.id, + previousHostedView: previousHostedView + ) + } installBrowserPanelSubscription(browserPanel) @@ -1559,6 +1547,71 @@ final class Workspace: Identifiable, ObservableObject { } // MARK: - Focus Management + private func preserveFocusAfterNonFocusSplit( + preferredPanelId: UUID?, + splitPanelId: UUID, + previousHostedView: GhosttySurfaceScrollView? + ) { + guard let preferredPanelId, panels[preferredPanelId] != nil else { + scheduleFocusReconcile() + return + } + + // Bonsplit splitPane focuses the newly created pane and may emit one delayed + // didSelect/didFocus callback. Re-assert focus over multiple turns so model + // focus and AppKit first responder stay aligned with non-focus-intent splits. + reassertFocusAfterNonFocusSplit( + preferredPanelId: preferredPanelId, + splitPanelId: splitPanelId, + previousHostedView: previousHostedView, + allowPreviousHostedView: true + ) + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.reassertFocusAfterNonFocusSplit( + preferredPanelId: preferredPanelId, + splitPanelId: splitPanelId, + previousHostedView: previousHostedView, + allowPreviousHostedView: false + ) + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.reassertFocusAfterNonFocusSplit( + preferredPanelId: preferredPanelId, + splitPanelId: splitPanelId, + previousHostedView: previousHostedView, + allowPreviousHostedView: false + ) + self.scheduleFocusReconcile() + } + } + } + + private func reassertFocusAfterNonFocusSplit( + preferredPanelId: UUID, + splitPanelId: UUID, + previousHostedView: GhosttySurfaceScrollView?, + allowPreviousHostedView: Bool + ) { + guard panels[preferredPanelId] != nil else { return } + + if focusedPanelId == splitPanelId { + focusPanel( + preferredPanelId, + previousHostedView: allowPreviousHostedView ? previousHostedView : nil + ) + return + } + + guard focusedPanelId == preferredPanelId, + let terminalPanel = terminalPanel(for: preferredPanelId) else { + return + } + terminalPanel.hostedView.ensureFocus(for: id, surfaceId: preferredPanelId) + } + func focusPanel(_ panelId: UUID, previousHostedView: GhosttySurfaceScrollView? = nil) { #if DEBUG let pane = bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil" diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index b24947bf..137ede9e 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1860,6 +1860,60 @@ final class WorkspacePanelGitBranchTests: XCTestCase { ) } + func testBrowserSplitWithFocusFalseRecoversFromDelayedStaleSelection() { + let workspace = Workspace() + guard let originalFocusedPanelId = workspace.focusedPanelId else { + XCTFail("Expected initial focused panel") + return + } + guard let originalPaneId = workspace.paneId(forPanelId: originalFocusedPanelId) else { + XCTFail("Expected focused pane for initial panel") + return + } + + guard let browserSplitPanel = workspace.newBrowserSplit( + from: originalFocusedPanelId, + orientation: .horizontal, + focus: false + ) else { + XCTFail("Expected browser split panel to be created") + return + } + guard let splitPaneId = workspace.paneId(forPanelId: browserSplitPanel.id), + let splitTabId = workspace.surfaceIdFromPanelId(browserSplitPanel.id), + let splitTab = workspace.bonsplitController + .tabs(inPane: splitPaneId) + .first(where: { $0.id == splitTabId }) else { + XCTFail("Expected split pane/tab mapping") + return + } + + // Simulate one delayed stale split-selection callback from bonsplit. + DispatchQueue.main.async { + workspace.splitTabBar(workspace.bonsplitController, didSelectTab: splitTab, inPane: splitPaneId) + } + + drainMainQueue() + drainMainQueue() + drainMainQueue() + + XCTAssertEqual( + workspace.focusedPanelId, + originalFocusedPanelId, + "Expected non-focus split to reassert the pre-split focused panel" + ) + XCTAssertEqual( + workspace.bonsplitController.focusedPaneId, + originalPaneId, + "Expected focused pane to converge back to the pre-split pane" + ) + XCTAssertEqual( + workspace.bonsplitController.selectedTab(inPane: originalPaneId)?.id, + workspace.surfaceIdFromPanelId(originalFocusedPanelId), + "Expected selected tab to converge back to the pre-split focused panel" + ) + } + func testClosingFocusedSplitRestoresBranchForRemainingFocusedPanel() { let workspace = Workspace() guard let firstPanelId = workspace.focusedPanelId else { From a369cf44195f5022e177fcf524e258c22d6132cd Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 22 Feb 2026 19:13:15 -0800 Subject: [PATCH 8/9] Prevent background webview autofocus from stealing focus --- Sources/Panels/BrowserPanelView.swift | 14 ++++++++ Sources/Panels/CmuxWebView.swift | 8 +++++ cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 33 +++++++++++++++++++ 3 files changed, 55 insertions(+) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 37f0af44..98e311e3 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -3112,6 +3112,11 @@ struct WebViewRepresentable: NSViewRepresentable { let webView = panel.webView context.coordinator.panel = panel context.coordinator.webView = webView + Self.applyWebViewFirstResponderPolicy( + panel: panel, + webView: webView, + isPanelFocused: isPanelFocused + ) let shouldUseWindowPortal = panel.shouldPreserveWebViewAttachmentDuringTransientHide() if shouldUseWindowPortal { @@ -3359,6 +3364,15 @@ struct WebViewRepresentable: NSViewRepresentable { } } + private static func applyWebViewFirstResponderPolicy( + panel: BrowserPanel, + webView: WKWebView, + isPanelFocused: Bool + ) { + guard let cmuxWebView = webView as? CmuxWebView else { return } + cmuxWebView.allowsFirstResponderAcquisition = isPanelFocused && !panel.shouldSuppressWebViewFocus() + } + static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { coordinator.attachRetryWorkItem?.cancel() coordinator.attachRetryWorkItem = nil diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index 7ee2d00a..93f5a321 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -22,6 +22,14 @@ final class CmuxWebView: WKWebView { var onContextMenuDownloadStateChanged: ((Bool) -> Void)? var contextMenuLinkURLProvider: ((CmuxWebView, NSPoint, @escaping (URL?) -> Void) -> Void)? var contextMenuDefaultBrowserOpener: ((URL) -> Bool)? + /// Guard against background panes stealing first responder (e.g. page autofocus). + /// BrowserPanelView updates this as pane focus state changes. + var allowsFirstResponderAcquisition: Bool = true + + override func becomeFirstResponder() -> Bool { + guard allowsFirstResponderAcquisition else { return false } + return super.becomeFirstResponder() + } override func performKeyEquivalent(with event: NSEvent) -> Bool { // Preserve Cmd+Return/Enter for web content (e.g. editors/forms). Do not diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 137ede9e..d536fed5 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -99,6 +99,39 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { XCTAssertTrue(spy.invoked) } + @MainActor + func testCanBlockFirstResponderAcquisitionWhenPaneIsUnfocused() { + _ = NSApplication.shared + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = container + + let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) + webView.autoresizingMask = [.width, .height] + container.addSubview(webView) + + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + + webView.allowsFirstResponderAcquisition = true + XCTAssertTrue(window.makeFirstResponder(webView)) + + _ = window.makeFirstResponder(nil) + webView.allowsFirstResponderAcquisition = false + XCTAssertFalse(webView.becomeFirstResponder()) + + _ = window.makeFirstResponder(webView) + if let firstResponderView = window.firstResponder as? NSView { + XCTAssertFalse(firstResponderView === webView || firstResponderView.isDescendant(of: webView)) + } + } + private func installMenu(spy: ActionSpy, key: String, modifiers: NSEvent.ModifierFlags) { let mainMenu = NSMenu() From 4fd669d76e6b73c61e06118689f30f3d1339fd46 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 22 Feb 2026 19:34:07 -0800 Subject: [PATCH 9/9] Guard NSWindow responder against unfocused webview descendants --- Sources/AppDelegate.swift | 82 ++++++++++++++++++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 40 +++++++++ 2 files changed, 120 insertions(+), 2 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index a270f941..f3ab91f3 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -278,6 +278,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } method_exchangeImplementations(originalMethod, swizzledMethod) }() + private static let didInstallWindowFirstResponderSwizzle: Void = { + let targetClass: AnyClass = NSWindow.self + let originalSelector = #selector(NSWindow.makeFirstResponder(_:)) + let swizzledSelector = #selector(NSWindow.cmux_makeFirstResponder(_:)) + guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector), + let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else { + return + } + method_exchangeImplementations(originalMethod, swizzledMethod) + }() #if DEBUG private var didSetupJumpUnreadUITest = false @@ -397,7 +407,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent installMainWindowKeyObserver() refreshGhosttyGotoSplitShortcuts() installGhosttyConfigObserver() - installWindowKeyEquivalentSwizzle() + installWindowResponderSwizzles() installBrowserAddressBarFocusObservers() installShortcutMonitor() installShortcutDefaultsObserver() @@ -1533,8 +1543,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } - private func installWindowKeyEquivalentSwizzle() { + static func installWindowResponderSwizzlesForTesting() { + _ = didInstallWindowKeyEquivalentSwizzle + _ = didInstallWindowFirstResponderSwizzle + } + + private func installWindowResponderSwizzles() { _ = Self.didInstallWindowKeyEquivalentSwizzle + _ = Self.didInstallWindowFirstResponderSwizzle } private func installShortcutMonitor() { @@ -3833,6 +3849,21 @@ enum MenuBarIconRenderer { private extension NSWindow { + @objc func cmux_makeFirstResponder(_ responder: NSResponder?) -> Bool { + if let responder, + let webView = Self.cmuxOwningWebView(for: responder), + !webView.allowsFirstResponderAcquisition { +#if DEBUG + dlog( + "focus.guard blockedFirstResponder responder=\(String(describing: type(of: responder))) " + + "window=\(ObjectIdentifier(self))" + ) +#endif + return false + } + return cmux_makeFirstResponder(responder) + } + @objc func cmux_performKeyEquivalent(with event: NSEvent) -> Bool { #if DEBUG let frType = self.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" @@ -3941,4 +3972,51 @@ private extension NSWindow { parts.append("'\(chars)'(\(event.keyCode))") return parts.joined(separator: "+") } + + private static func cmuxOwningWebView(for responder: NSResponder) -> CmuxWebView? { + if let webView = responder as? CmuxWebView { + return webView + } + + if let view = responder as? NSView, + let webView = cmuxOwningWebView(for: view) { + return webView + } + + if let textView = responder as? NSTextView, + let delegateView = textView.delegate as? NSView, + let webView = cmuxOwningWebView(for: delegateView) { + return webView + } + + var current = responder.nextResponder + while let next = current { + if let webView = next as? CmuxWebView { + return webView + } + if let view = next as? NSView, + let webView = cmuxOwningWebView(for: view) { + return webView + } + current = next.nextResponder + } + + return nil + } + + private static func cmuxOwningWebView(for view: NSView) -> CmuxWebView? { + if let webView = view as? CmuxWebView { + return webView + } + + var current: NSView? = view.superview + while let candidate = current { + if let webView = candidate as? CmuxWebView { + return webView + } + current = candidate.superview + } + + return nil + } } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index d536fed5..9b0980fa 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -63,6 +63,10 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { } } + private final class FirstResponderView: NSView { + override var acceptsFirstResponder: Bool { true } + } + func testCmdNRoutesToMainMenuWhenWebViewIsFirstResponder() { let spy = ActionSpy() installMenu(spy: spy, key: "n", modifiers: [.command]) @@ -132,6 +136,42 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { } } + @MainActor + func testWindowFirstResponderGuardBlocksDescendantWhenPaneIsUnfocused() { + _ = NSApplication.shared + AppDelegate.installWindowResponderSwizzlesForTesting() + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) + window.contentView = container + + let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) + webView.autoresizingMask = [.width, .height] + container.addSubview(webView) + + let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10)) + webView.addSubview(descendant) + + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + + webView.allowsFirstResponderAcquisition = true + XCTAssertTrue(window.makeFirstResponder(descendant)) + + _ = window.makeFirstResponder(nil) + webView.allowsFirstResponderAcquisition = false + XCTAssertFalse(window.makeFirstResponder(descendant)) + + if let firstResponderView = window.firstResponder as? NSView { + XCTAssertFalse(firstResponderView === descendant || firstResponderView.isDescendant(of: webView)) + } + } + private func installMenu(spy: ActionSpy, key: String, modifiers: NSEvent.ModifierFlags) { let mainMenu = NSMenu()