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] 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:")