From 7dcab9113b752635c7ef26ab6275a4d927957cda Mon Sep 17 00:00:00 2001 From: Sharun Date: Tue, 10 Mar 2026 16:38:06 -0700 Subject: [PATCH 1/8] Add blacklist mode for browser host filtering Add a Whitelist/Blacklist mode toggle to the browser host list setting, allowing users to specify domains that should be blocked from opening in the embedded browser instead of only being able to whitelist allowed domains. Data model (BrowserPanel.swift): - Add HostListMode enum (whitelist/blacklist) and browserHostListModeKey UserDefaults key (default: "whitelist" for backward compatibility) - Add shouldOpenInCmuxBrowser(host:) that respects the configured mode: whitelist mode matches existing behavior, blacklist mode inverts the check so listed hosts open externally and everything else opens in cmux - Keep hostMatchesWhitelist() intact for backward compatibility - Extract hostMatchesEntry() private helper to share pattern-matching logic between both code paths URL routing (GhosttyTerminalView.swift): - Replace hostMatchesWhitelist() call with shouldOpenInCmuxBrowser() at the terminal link click decision point Settings UI (cmuxApp.swift): - Add @AppStorage binding for browserHostListMode - Add segmented Picker control (Whitelist/Blacklist) in browser settings - Make host list label and subtitle text dynamic based on selected mode - Include new setting in the reset-to-defaults flow Shell open wrapper (Resources/bin/open): - Read browserHostListMode from UserDefaults - Add should_open_in_cmux() function that inverts the whitelist check when in blacklist mode - Update URL routing loop to use should_open_in_cmux() instead of host_matches_whitelist() directly Localization (Localizable.xcstrings): - Add English strings for hostListMode, hostBlacklist labels/subtitles and Whitelist/Blacklist picker options - Add Japanese translations (needs_review) Tests: - Add BrowserHostListModeTests (10 cases) covering default mode, mode reading, invalid fallback, whitelist-mode parity, and blacklist-mode behavior (empty list, exact match, miss, wildcard, case-insensitive, dot-boundary) - Add 5 Python open wrapper tests for blacklist mode (match, miss, wildcard, empty list, explicit whitelist mode) --- Resources/Localizable.xcstrings | 102 +++++ Resources/bin/open | 28 +- Sources/GhosttyTerminalView.swift | 7 +- Sources/Panels/BrowserPanel.swift | 51 ++- Sources/cmuxApp.swift | 27 +- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 97 +++++ tests/test_open_wrapper.py | 365 ++++++++++++++++-- 7 files changed, 627 insertions(+), 50 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 8a7296921a73..31f94a8f47c1 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -49595,6 +49595,108 @@ } } }, + "settings.browser.hostBlacklist": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hosts to Block from Embedded Browser" + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "内蔵ブラウザからブロックするホスト" + } + } + } + }, + "settings.browser.hostBlacklist.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Applies to terminal link clicks and intercepted `open https://...` calls. These hosts always open in your default browser. All other hosts open in cmux. One host or wildcard per line (for example: example.com, *.internal.example). Leave empty to open all hosts in cmux." + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "ターミナルのリンククリックおよびインターセプトされた`open https://...`呼び出しに適用されます。これらのホストは常にデフォルトブラウザで開きます。その他のホストはcmuxで開きます。1行に1つのホストまたはワイルドカード(例: example.com, *.internal.example)。空欄にするとすべてのホストをcmuxで開きます。" + } + } + } + }, + "settings.browser.hostListMode": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Host List Mode" + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "ホストリストモード" + } + } + } + }, + "settings.browser.hostListMode.blacklist": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Blacklist" + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "ブラックリスト" + } + } + } + }, + "settings.browser.hostListMode.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Whitelist: only listed hosts open in cmux. Blacklist: listed hosts always open in your default browser." + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "ホワイトリスト:リストのホストのみcmuxで開きます。ブラックリスト:リストのホストは常にデフォルトブラウザで開きます。" + } + } + } + }, + "settings.browser.hostListMode.whitelist": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Whitelist" + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "ホワイトリスト" + } + } + } + }, "settings.browser.hostWhitelist": { "extractionState": "manual", "localizations": { diff --git a/Resources/bin/open b/Resources/bin/open index 203ba1db3ee1..bd679aa638ae 100755 --- a/Resources/bin/open +++ b/Resources/bin/open @@ -29,6 +29,7 @@ fi settings_domain="${CMUX_BUNDLE_ID:-}" whitelist_raw="" whitelist_patterns=() +host_list_mode="whitelist" system_open() { exec "$SYSTEM_OPEN_BIN" "$@" @@ -354,6 +355,26 @@ host_matches_whitelist() { return 1 } +# Determine whether a URL's host should open in cmux, respecting the +# configured host-list mode (whitelist or blacklist). +should_open_in_cmux() { + local url="$1" + if [[ ${#whitelist_patterns[@]} -eq 0 ]]; then + return 0 + fi + + if [[ "$host_list_mode" == "blacklist" ]]; then + # Blacklist mode: if the host matches a pattern, it should NOT open in cmux. + if host_matches_whitelist "$url"; then + return 1 + fi + return 0 + fi + + # Whitelist mode (default): delegate directly. + host_matches_whitelist "$url" +} + load_whitelist_patterns() { local raw="$1" local line @@ -438,6 +459,11 @@ if [[ -n "$settings_domain" ]]; then load_whitelist_patterns "$whitelist_raw" fi + host_list_mode_raw="$("$DEFAULTS_BIN" read "$settings_domain" browserHostListMode 2>/dev/null || true)" + if [[ "$host_list_mode_raw" == "blacklist" ]]; then + host_list_mode="blacklist" + fi + fi # Find cmux CLI (same directory as this script). @@ -453,7 +479,7 @@ fi # terminal link clicks and intercepted `open` commands share one regex dialect. failed_urls=() for url in "${cmux_targets[@]}"; do - if is_http_url "$url" && ! host_matches_whitelist "$url"; then + if is_http_url "$url" && ! should_open_in_cmux "$url"; then failed_urls+=("$url") continue fi diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 49c059f09e40..6b6d6f924a36 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2149,10 +2149,11 @@ class GhosttyApp { } } - // If a host whitelist is configured and this host isn't in it, open externally. - if !BrowserLinkOpenSettings.hostMatchesWhitelist(host) { + // If the host-list rules (whitelist or blacklist mode) say this host should not + // open in cmux, open externally. + if !BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: host) { #if DEBUG - dlog("link.openURL target=embedded but hostWhitelist miss host=\(host) url=\(url)") + dlog("link.openURL target=embedded but hostList miss host=\(host) url=\(url)") #endif return performOnMain { NSWorkspace.shared.open(url) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index d9c06dc6b7b2..6fe31151c195 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -186,9 +186,26 @@ enum BrowserLinkOpenSettings { static let browserHostWhitelistKey = "browserHostWhitelist" static let defaultBrowserHostWhitelist: String = "" + static let browserHostListModeKey = "browserHostListMode" + static let defaultBrowserHostListMode: String = "whitelist" static let browserExternalOpenPatternsKey = "browserExternalOpenPatterns" static let defaultBrowserExternalOpenPatterns: String = "" + /// The two supported host-list modes. + /// - `whitelist`: only listed hosts open in cmux (empty list = allow all). + /// - `blacklist`: listed hosts always open externally (empty list = allow all in cmux). + enum HostListMode: String, CaseIterable, Identifiable { + case whitelist + case blacklist + + var id: String { rawValue } + } + + static func hostListMode(defaults: UserDefaults = .standard) -> HostListMode { + let raw = defaults.string(forKey: browserHostListModeKey) ?? defaultBrowserHostListMode + return HostListMode(rawValue: raw) ?? .whitelist + } + static func openTerminalLinksInCmuxBrowser(defaults: UserDefaults = .standard) -> Bool { if defaults.object(forKey: openTerminalLinksInCmuxBrowserKey) == nil { return defaultOpenTerminalLinksInCmuxBrowser @@ -260,8 +277,8 @@ enum BrowserLinkOpenSettings { return false } - /// Check whether a hostname matches the configured whitelist. - /// Empty whitelist means "allow all" (no filtering). + /// Check whether a hostname matches the configured host list. + /// Empty list means "allow all" (no filtering) regardless of mode. /// Supports exact match and wildcard prefix (`*.example.com`). static func hostMatchesWhitelist(_ host: String, defaults: UserDefaults = .standard) -> Bool { let rawPatterns = hostWhitelist(defaults: defaults) @@ -276,6 +293,36 @@ enum BrowserLinkOpenSettings { return false } + /// Determine whether a host should open in the cmux embedded browser, + /// respecting the configured host-list mode (whitelist or blacklist). + /// + /// - Whitelist mode (default): empty list = allow all; non-empty = only listed hosts open in cmux. + /// - Blacklist mode: empty list = allow all; non-empty = listed hosts open externally, everything else in cmux. + static func shouldOpenInCmuxBrowser(host: String, defaults: UserDefaults = .standard) -> Bool { + let rawPatterns = hostWhitelist(defaults: defaults) + if rawPatterns.isEmpty { return true } + + let matchesEntry = hostMatchesEntry(host, rawPatterns: rawPatterns) + switch hostListMode(defaults: defaults) { + case .whitelist: + return matchesEntry + case .blacklist: + return !matchesEntry + } + } + + /// Check whether a host matches any entry in the provided raw patterns list. + private static func hostMatchesEntry(_ host: String, rawPatterns: [String]) -> Bool { + 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 + } + private static func normalizeWhitelistPattern(_ rawPattern: String) -> String? { let trimmed = rawPattern .trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 99ea9f8fc22c..249099f18976 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2851,6 +2851,7 @@ struct SettingsView: View { @AppStorage(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey) private var interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue() @AppStorage(BrowserLinkOpenSettings.browserHostWhitelistKey) private var browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist + @AppStorage(BrowserLinkOpenSettings.browserHostListModeKey) private var browserHostListMode = BrowserLinkOpenSettings.defaultBrowserHostListMode @AppStorage(BrowserLinkOpenSettings.browserExternalOpenPatternsKey) private var browserExternalOpenPatterns = BrowserLinkOpenSettings.defaultBrowserExternalOpenPatterns @AppStorage(BrowserInsecureHTTPSettings.allowlistKey) private var browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText @@ -3777,10 +3778,31 @@ struct SettingsView: View { if openTerminalLinksInCmuxBrowser || interceptTerminalOpenCommandInCmuxBrowser { SettingsCardDivider() + SettingsCardRow( + String(localized: "settings.browser.hostListMode", defaultValue: "Host List Mode"), + subtitle: String(localized: "settings.browser.hostListMode.subtitle", defaultValue: "Whitelist: only listed hosts open in cmux. Blacklist: listed hosts always open in your default browser.") + ) { + Picker("", selection: $browserHostListMode) { + Text(String(localized: "settings.browser.hostListMode.whitelist", defaultValue: "Whitelist")) + .tag(BrowserLinkOpenSettings.HostListMode.whitelist.rawValue) + Text(String(localized: "settings.browser.hostListMode.blacklist", defaultValue: "Blacklist")) + .tag(BrowserLinkOpenSettings.HostListMode.blacklist.rawValue) + } + .labelsHidden() + .pickerStyle(.segmented) + .frame(width: 180) + } + + SettingsCardDivider() + VStack(alignment: .leading, spacing: 6) { SettingsCardRow( - String(localized: "settings.browser.hostWhitelist", defaultValue: "Hosts to Open in Embedded Browser"), - subtitle: String(localized: "settings.browser.hostWhitelist.subtitle", defaultValue: "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.") + browserHostListMode == BrowserLinkOpenSettings.HostListMode.blacklist.rawValue + ? String(localized: "settings.browser.hostBlacklist", defaultValue: "Hosts to Block from Embedded Browser") + : String(localized: "settings.browser.hostWhitelist", defaultValue: "Hosts to Open in Embedded Browser"), + subtitle: browserHostListMode == BrowserLinkOpenSettings.HostListMode.blacklist.rawValue + ? String(localized: "settings.browser.hostBlacklist.subtitle", defaultValue: "Applies to terminal link clicks and intercepted `open https://...` calls. These hosts always open in your default browser. All other hosts open in cmux. One host or wildcard per line (for example: example.com, *.internal.example). Leave empty to open all hosts in cmux.") + : String(localized: "settings.browser.hostWhitelist.subtitle", defaultValue: "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() } @@ -4146,6 +4168,7 @@ struct SettingsView: View { openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.defaultInterceptTerminalOpenCommandInCmuxBrowser browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist + browserHostListMode = BrowserLinkOpenSettings.defaultBrowserHostListMode browserExternalOpenPatterns = BrowserLinkOpenSettings.defaultBrowserExternalOpenPatterns browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 259eecf86902..e5f03cb2b4da 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -11925,6 +11925,103 @@ final class BrowserHostWhitelistTests: XCTestCase { } } +// MARK: - Blacklist mode tests + +final class BrowserHostListModeTests: XCTestCase { + private var suiteName: String! + private var defaults: UserDefaults! + + override func setUp() { + super.setUp() + suiteName = "BrowserHostListModeTests.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + defaults.removePersistentDomain(forName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + super.tearDown() + } + + func testDefaultModeIsWhitelist() { + XCTAssertEqual(BrowserLinkOpenSettings.hostListMode(defaults: defaults), .whitelist) + } + + func testBlacklistModeIsReadFromDefaults() { + defaults.set("blacklist", forKey: BrowserLinkOpenSettings.browserHostListModeKey) + XCTAssertEqual(BrowserLinkOpenSettings.hostListMode(defaults: defaults), .blacklist) + } + + func testInvalidModeStringFallsBackToWhitelist() { + defaults.set("invalid", forKey: BrowserLinkOpenSettings.browserHostListModeKey) + XCTAssertEqual(BrowserLinkOpenSettings.hostListMode(defaults: defaults), .whitelist) + } + + // MARK: - shouldOpenInCmuxBrowser: whitelist mode + + func testWhitelistModeEmptyListAllowsAll() { + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "example.com", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "localhost", defaults: defaults)) + } + + func testWhitelistModeMatchOpensInCmux() { + defaults.set("localhost\n127.0.0.1", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "localhost", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "127.0.0.1", defaults: defaults)) + } + + func testWhitelistModeMissOpensExternally() { + defaults.set("localhost", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) + XCTAssertFalse(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "example.com", defaults: defaults)) + } + + // MARK: - shouldOpenInCmuxBrowser: blacklist mode + + func testBlacklistModeEmptyListAllowsAll() { + defaults.set("blacklist", forKey: BrowserLinkOpenSettings.browserHostListModeKey) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "example.com", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "localhost", defaults: defaults)) + } + + func testBlacklistModeMatchOpensExternally() { + defaults.set("blacklist", forKey: BrowserLinkOpenSettings.browserHostListModeKey) + defaults.set("ads.example.com\ntracker.net", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) + XCTAssertFalse(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "ads.example.com", defaults: defaults)) + XCTAssertFalse(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "tracker.net", defaults: defaults)) + } + + func testBlacklistModeMissOpensInCmux() { + defaults.set("blacklist", forKey: BrowserLinkOpenSettings.browserHostListModeKey) + defaults.set("ads.example.com", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "example.com", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "github.com", defaults: defaults)) + } + + func testBlacklistModeWildcard() { + defaults.set("blacklist", forKey: BrowserLinkOpenSettings.browserHostListModeKey) + defaults.set("*.blocked.com", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) + XCTAssertFalse(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "blocked.com", defaults: defaults)) + XCTAssertFalse(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "sub.blocked.com", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "allowed.com", defaults: defaults)) + } + + func testBlacklistModeCaseInsensitive() { + defaults.set("blacklist", forKey: BrowserLinkOpenSettings.browserHostListModeKey) + defaults.set("Blocked.COM", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) + XCTAssertFalse(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "blocked.com", defaults: defaults)) + XCTAssertFalse(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "BLOCKED.COM", defaults: defaults)) + } + + func testBlacklistModeWildcardRequiresDotBoundary() { + defaults.set("blacklist", forKey: BrowserLinkOpenSettings.browserHostListModeKey) + defaults.set("*.example.com", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) + XCTAssertFalse(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "sub.example.com", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "badexample.com", defaults: defaults)) + } +} + final class TerminalControllerSidebarDedupeTests: XCTestCase { func testShouldReplaceStatusEntryReturnsFalseForUnchangedPayload() { let current = SidebarStatusEntry( diff --git a/tests/test_open_wrapper.py b/tests/test_open_wrapper.py index b2b98a51756c..2726c814a92c 100755 --- a/tests/test_open_wrapper.py +++ b/tests/test_open_wrapper.py @@ -24,7 +24,11 @@ def make_executable(path: Path, content: str) -> None: 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()] + return [ + line.strip() + for line in path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] def run_wrapper( @@ -33,6 +37,7 @@ def run_wrapper( intercept_setting: str | None, legacy_open_setting: str | None = None, whitelist: str | None, + host_list_mode: str | None = None, external_patterns: str | None = None, fail_urls: list[str] | None = None, local_files: list[str] | None = None, @@ -95,6 +100,13 @@ def run_wrapper( fi exit 1 ;; + browserHostListMode) + if [[ "${FAKE_DEFAULTS_HOST_LIST_MODE+x}" == "x" ]]; then + printf '%s' "$FAKE_DEFAULTS_HOST_LIST_MODE" + exit 0 + fi + exit 1 + ;; *) exit 1 ;; @@ -127,7 +139,9 @@ def run_wrapper( for relative_path in local_files: target = tmp / relative_path target.parent.mkdir(parents=True, exist_ok=True) - target.write_text("fixture", encoding="utf-8") + target.write_text( + "fixture", encoding="utf-8" + ) env = os.environ.copy() env["CMUX_SOCKET_PATH"] = "/tmp/cmux-open-wrapper-test.sock" @@ -161,6 +175,11 @@ def run_wrapper( else: env["FAKE_DEFAULTS_EXTERNAL_PATTERNS"] = external_patterns + if host_list_mode is None: + env.pop("FAKE_DEFAULTS_HOST_LIST_MODE", None) + else: + env["FAKE_DEFAULTS_HOST_LIST_MODE"] = host_list_mode + if fail_urls: env["FAKE_CMUX_FAIL_URLS"] = ",".join(fail_urls) else: @@ -175,7 +194,12 @@ def run_wrapper( check=False, ) - return read_log(open_log), read_log(cmux_log), result.returncode, result.stderr.strip() + return ( + read_log(open_log), + read_log(cmux_log), + result.returncode, + result.stderr.strip(), + ) def expect(condition: bool, message: str, failures: list[str]) -> None: @@ -191,8 +215,16 @@ def test_toggle_disabled_passthrough(failures: list[str]) -> None: 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) + 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_toggle_disabled_case_insensitive_passthrough(failures: list[str]) -> None: @@ -202,7 +234,11 @@ def test_toggle_disabled_case_insensitive_passthrough(failures: list[str]) -> No intercept_setting=" FaLsE ", whitelist="", ) - expect(code == 0, f"toggle off (case-insensitive): wrapper exited {code}: {stderr}", failures) + 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}", @@ -223,8 +259,16 @@ def test_whitelist_miss_passthrough(failures: list[str]) -> None: 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) + 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: @@ -235,8 +279,16 @@ def test_whitelist_match_routes_to_cmux(failures: list[str]) -> None: 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) + 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_external_literal_pattern_is_deferred_to_app(failures: list[str]) -> None: @@ -247,7 +299,11 @@ def test_external_literal_pattern_is_deferred_to_app(failures: list[str]) -> Non whitelist="", external_patterns="platform.openai.com/account/usage", ) - expect(code == 0, f"external literal deferred: wrapper exited {code}: {stderr}", failures) + expect( + code == 0, + f"external literal deferred: wrapper exited {code}: {stderr}", + failures, + ) expect( cmux_log == [f"browser open {url}"], f"external literal deferred: expected wrapper to pass URL to cmux, got {cmux_log}", @@ -268,7 +324,9 @@ def test_external_regex_pattern_is_deferred_to_app(failures: list[str]) -> None: whitelist="*.example.com", external_patterns=r"re:^https?://[^/]*\.example\.com/(billing|usage)", ) - expect(code == 0, f"external regex deferred: wrapper exited {code}: {stderr}", failures) + expect( + code == 0, f"external regex deferred: wrapper exited {code}: {stderr}", failures + ) expect( cmux_log == [f"browser open {url}"], f"external regex deferred: expected wrapper to pass URL to cmux, got {cmux_log}", @@ -281,7 +339,9 @@ def test_external_regex_pattern_is_deferred_to_app(failures: list[str]) -> None: ) -def test_external_regex_with_icu_features_is_deferred_to_app(failures: list[str]) -> None: +def test_external_regex_with_icu_features_is_deferred_to_app( + failures: list[str], +) -> None: url = "https://example.com/usage/42" open_log, cmux_log, code, stderr = run_wrapper( args=[url], @@ -289,7 +349,11 @@ def test_external_regex_with_icu_features_is_deferred_to_app(failures: list[str] whitelist="example.com", external_patterns=r"re:^https://example\.com/usage/\d+$", ) - expect(code == 0, f"external regex icu deferred: wrapper exited {code}: {stderr}", failures) + expect( + code == 0, + f"external regex icu deferred: wrapper exited {code}: {stderr}", + failures, + ) expect( cmux_log == [f"browser open {url}"], f"external regex icu deferred: expected wrapper to pass URL to cmux, got {cmux_log}", @@ -310,7 +374,9 @@ def test_external_invalid_regex_is_ignored_silently(failures: list[str]) -> None whitelist="", external_patterns=r"re:[unclosed", ) - expect(code == 0, f"external invalid regex: wrapper exited {code}: {stderr}", failures) + expect( + code == 0, f"external invalid regex: wrapper exited {code}: {stderr}", failures + ) expect( cmux_log == [f"browser open {url}"], f"external invalid regex: expected cmux open for {url}, got {cmux_log}", @@ -360,11 +426,21 @@ def test_legacy_toggle_fallback_passthrough(failures: list[str]) -> None: 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) + 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 test_legacy_toggle_fallback_case_insensitive_passthrough(failures: list[str]) -> None: +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], @@ -372,7 +448,11 @@ def test_legacy_toggle_fallback_case_insensitive_passthrough(failures: list[str] legacy_open_setting=" Off ", whitelist="", ) - expect(code == 0, f"legacy fallback (case-insensitive): wrapper exited {code}: {stderr}", failures) + 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}", @@ -393,8 +473,16 @@ def test_uppercase_scheme_routes_to_cmux(failures: list[str]) -> None: 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) + 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_local_html_file_routes_to_cmux(failures: list[str]) -> None: @@ -406,8 +494,16 @@ def test_local_html_file_routes_to_cmux(failures: list[str]) -> None: local_files=[filename], ) expect(code == 0, f"local html file: wrapper exited {code}: {stderr}", failures) - expect(open_log == [], f"local html file: system open should not be called, got {open_log}", failures) - expect(len(cmux_log) == 1, f"local html file: expected exactly one cmux call, got {cmux_log}", failures) + expect( + open_log == [], + f"local html file: system open should not be called, got {open_log}", + failures, + ) + expect( + len(cmux_log) == 1, + f"local html file: expected exactly one cmux call, got {cmux_log}", + failures, + ) if cmux_log: expect( cmux_log[0].startswith("browser open file://"), @@ -429,11 +525,21 @@ def test_file_url_html_routes_to_cmux(failures: list[str]) -> None: whitelist="", ) expect(code == 0, f"file url html: wrapper exited {code}: {stderr}", failures) - expect(open_log == [], f"file url html: system open should not be called, got {open_log}", failures) - expect(cmux_log == [f"browser open {url}"], f"file url html: unexpected cmux log {cmux_log}", failures) + expect( + open_log == [], + f"file url html: system open should not be called, got {open_log}", + failures, + ) + expect( + cmux_log == [f"browser open {url}"], + f"file url html: unexpected cmux log {cmux_log}", + failures, + ) -def test_file_url_html_routes_to_cmux_without_python_binary(failures: list[str]) -> None: +def test_file_url_html_routes_to_cmux_without_python_binary( + failures: list[str], +) -> None: url = "file:///tmp/cmux-open-wrapper-fixture.html" open_log, cmux_log, code, stderr = run_wrapper( args=[url], @@ -441,7 +547,11 @@ def test_file_url_html_routes_to_cmux_without_python_binary(failures: list[str]) whitelist="", python_bin="/definitely/missing/python3", ) - expect(code == 0, f"file url html no-python fallback: wrapper exited {code}: {stderr}", failures) + expect( + code == 0, + f"file url html no-python fallback: wrapper exited {code}: {stderr}", + failures, + ) expect( open_log == [], f"file url html no-python fallback: system open should not be called, got {open_log}", @@ -454,7 +564,9 @@ def test_file_url_html_routes_to_cmux_without_python_binary(failures: list[str]) ) -def test_local_html_file_routes_to_cmux_without_python_binary(failures: list[str]) -> None: +def test_local_html_file_routes_to_cmux_without_python_binary( + failures: list[str], +) -> None: filename = "fixtures/no python fallback.html" open_log, cmux_log, code, stderr = run_wrapper( args=[filename], @@ -463,8 +575,16 @@ def test_local_html_file_routes_to_cmux_without_python_binary(failures: list[str local_files=[filename], python_bin="/definitely/missing/python3", ) - expect(code == 0, f"local html no-python fallback: wrapper exited {code}: {stderr}", failures) - expect(open_log == [], f"local html no-python fallback: system open should not be called, got {open_log}", failures) + expect( + code == 0, + f"local html no-python fallback: wrapper exited {code}: {stderr}", + failures, + ) + expect( + open_log == [], + f"local html no-python fallback: system open should not be called, got {open_log}", + failures, + ) expect( len(cmux_log) == 1, f"local html no-python fallback: expected exactly one cmux call, got {cmux_log}", @@ -490,7 +610,11 @@ def test_domain_like_html_argument_passthrough(failures: list[str]) -> None: intercept_setting="1", whitelist="", ) - expect(code == 0, f"domain-like html argument: wrapper exited {code}: {stderr}", failures) + expect( + code == 0, + f"domain-like html argument: wrapper exited {code}: {stderr}", + failures, + ) expect( cmux_log == [], f"domain-like html argument: cmux should not be called, got {cmux_log}", @@ -510,9 +634,19 @@ def test_non_file_scheme_html_passthrough(failures: list[str]) -> None: intercept_setting="1", whitelist="", ) - expect(code == 0, f"non-file scheme html: wrapper exited {code}: {stderr}", failures) - expect(cmux_log == [], f"non-file scheme html: cmux should not be called, got {cmux_log}", failures) - expect(open_log == [url], f"non-file scheme html: expected system open [{url}], got {open_log}", failures) + expect( + code == 0, f"non-file scheme html: wrapper exited {code}: {stderr}", failures + ) + expect( + cmux_log == [], + f"non-file scheme html: cmux should not be called, got {cmux_log}", + failures, + ) + expect( + open_log == [url], + f"non-file scheme html: expected system open [{url}], got {open_log}", + failures, + ) def test_mailto_html_passthrough(failures: list[str]) -> None: @@ -523,8 +657,16 @@ def test_mailto_html_passthrough(failures: list[str]) -> None: whitelist="", ) expect(code == 0, f"mailto html: wrapper exited {code}: {stderr}", failures) - expect(cmux_log == [], f"mailto html: cmux should not be called, got {cmux_log}", failures) - expect(open_log == [url], f"mailto html: expected system open [{url}], got {open_log}", failures) + expect( + cmux_log == [], + f"mailto html: cmux should not be called, got {cmux_log}", + failures, + ) + expect( + open_log == [url], + f"mailto html: expected system open [{url}], got {open_log}", + failures, + ) def test_local_non_html_file_passthrough(failures: list[str]) -> None: @@ -536,8 +678,16 @@ def test_local_non_html_file_passthrough(failures: list[str]) -> None: local_files=[filename], ) expect(code == 0, f"local non-html file: wrapper exited {code}: {stderr}", failures) - expect(cmux_log == [], f"local non-html file: cmux should not be called, got {cmux_log}", failures) - expect(open_log == [filename], f"local non-html file: expected system open [{filename}], got {open_log}", failures) + expect( + cmux_log == [], + f"local non-html file: cmux should not be called, got {cmux_log}", + failures, + ) + expect( + open_log == [filename], + f"local non-html file: expected system open [{filename}], got {open_log}", + failures, + ) def test_unicode_whitelist_matches_punycode_url(failures: list[str]) -> None: @@ -548,8 +698,16 @@ def test_unicode_whitelist_matches_punycode_url(failures: list[str]) -> None: 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) + 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: @@ -560,8 +718,126 @@ def test_punycode_whitelist_matches_unicode_url(failures: list[str]) -> None: 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) + 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 test_blacklist_mode_match_opens_externally(failures: list[str]) -> None: + """In blacklist mode, hosts in the list should open externally (system browser).""" + url = "https://ads.example.com/banner" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting="1", + whitelist="ads.example.com\ntracker.net", + host_list_mode="blacklist", + ) + expect(code == 0, f"blacklist match: wrapper exited {code}: {stderr}", failures) + expect( + cmux_log == [], + f"blacklist match: cmux should not be called, got {cmux_log}", + failures, + ) + expect( + open_log == [url], + f"blacklist match: expected system open [{url}], got {open_log}", + failures, + ) + + +def test_blacklist_mode_miss_routes_to_cmux(failures: list[str]) -> None: + """In blacklist mode, hosts NOT in the list should open in cmux.""" + url = "https://github.com/repo" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting="1", + whitelist="ads.example.com", + host_list_mode="blacklist", + ) + expect(code == 0, f"blacklist miss: wrapper exited {code}: {stderr}", failures) + expect( + open_log == [], + f"blacklist miss: system open should not be called, got {open_log}", + failures, + ) + expect( + cmux_log == [f"browser open {url}"], + f"blacklist miss: unexpected cmux log {cmux_log}", + failures, + ) + + +def test_blacklist_mode_wildcard(failures: list[str]) -> None: + """In blacklist mode, wildcard patterns should block matching hosts.""" + url = "https://sub.blocked.com/page" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting="1", + whitelist="*.blocked.com", + host_list_mode="blacklist", + ) + expect(code == 0, f"blacklist wildcard: wrapper exited {code}: {stderr}", failures) + expect( + cmux_log == [], + f"blacklist wildcard: cmux should not be called, got {cmux_log}", + failures, + ) + expect( + open_log == [url], + f"blacklist wildcard: expected system open [{url}], got {open_log}", + failures, + ) + + +def test_blacklist_mode_empty_list_allows_all(failures: list[str]) -> None: + """In blacklist mode with an empty list, all hosts should open in cmux.""" + url = "https://example.com/page" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting="1", + whitelist="", + host_list_mode="blacklist", + ) + expect(code == 0, f"blacklist empty: wrapper exited {code}: {stderr}", failures) + expect( + open_log == [], + f"blacklist empty: system open should not be called, got {open_log}", + failures, + ) + expect( + cmux_log == [f"browser open {url}"], + f"blacklist empty: unexpected cmux log {cmux_log}", + failures, + ) + + +def test_whitelist_mode_explicit_setting(failures: list[str]) -> None: + """Explicit whitelist mode should behave the same as the default.""" + url = "https://example.com/page" + open_log, cmux_log, code, stderr = run_wrapper( + args=[url], + intercept_setting="1", + whitelist="localhost", + host_list_mode="whitelist", + ) + expect(code == 0, f"explicit whitelist: wrapper exited {code}: {stderr}", failures) + expect( + cmux_log == [], + f"explicit whitelist: cmux should not be called, got {cmux_log}", + failures, + ) + expect( + open_log == [url], + f"explicit whitelist: expected system open [{url}], got {open_log}", + failures, + ) def main() -> int: @@ -588,6 +864,11 @@ def main() -> int: test_local_non_html_file_passthrough(failures) test_unicode_whitelist_matches_punycode_url(failures) test_punycode_whitelist_matches_unicode_url(failures) + test_blacklist_mode_match_opens_externally(failures) + test_blacklist_mode_miss_routes_to_cmux(failures) + test_blacklist_mode_wildcard(failures) + test_blacklist_mode_empty_list_allows_all(failures) + test_whitelist_mode_explicit_setting(failures) if failures: print("open wrapper regression tests failed:") From 0e1b6e1eb1fdaa7488d65d349f4f2ce184caa289 Mon Sep 17 00:00:00 2001 From: Sharun Date: Wed, 11 Mar 2026 12:02:00 -0700 Subject: [PATCH 2/8] fix(BrowserPanel): make hostMatchesWhitelist private --- Sources/Panels/BrowserPanel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index c625a9e83026..3cd4636dea66 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -280,7 +280,7 @@ enum BrowserLinkOpenSettings { /// Check whether a hostname matches the configured host list. /// Empty list means "allow all" (no filtering) regardless of mode. /// Supports exact match and wildcard prefix (`*.example.com`). - static func hostMatchesWhitelist(_ host: String, defaults: UserDefaults = .standard) -> Bool { + private static func hostMatchesWhitelist(_ host: String, defaults: UserDefaults = .standard) -> Bool { let rawPatterns = hostWhitelist(defaults: defaults) if rawPatterns.isEmpty { return true } guard let normalizedHost = BrowserInsecureHTTPSettings.normalizeHost(host) else { return false } From 3e8924a33e24ad4a8a355d1ca7c7a2002eaf10c8 Mon Sep 17 00:00:00 2001 From: Sharun Date: Wed, 11 Mar 2026 12:02:04 -0700 Subject: [PATCH 3/8] fix(open): rename whitelist_ variables to host_list_ for mode-neutrality --- Resources/bin/open | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/Resources/bin/open b/Resources/bin/open index bd679aa638ae..28753f2e47fa 100755 --- a/Resources/bin/open +++ b/Resources/bin/open @@ -27,8 +27,8 @@ elif command -v python3 >/dev/null 2>&1; then fi settings_domain="${CMUX_BUNDLE_ID:-}" -whitelist_raw="" -whitelist_patterns=() +host_list_raw="" +host_list_patterns=() host_list_mode="whitelist" system_open() { @@ -339,15 +339,15 @@ host_matches_pattern() { [[ "$host" == "$pattern" ]] } -host_matches_whitelist() { +host_matches_list_pattern() { local url="$1" - if [[ ${#whitelist_patterns[@]} -eq 0 ]]; then + if [[ ${#host_list_patterns[@]} -eq 0 ]]; then return 0 fi local host host="$(normalize_host "$url")" || return 1 - for pattern in "${whitelist_patterns[@]}"; do + for pattern in "${host_list_patterns[@]}"; do if host_matches_pattern "$host" "$pattern"; then return 0 fi @@ -359,29 +359,29 @@ host_matches_whitelist() { # configured host-list mode (whitelist or blacklist). should_open_in_cmux() { local url="$1" - if [[ ${#whitelist_patterns[@]} -eq 0 ]]; then + if [[ ${#host_list_patterns[@]} -eq 0 ]]; then return 0 fi if [[ "$host_list_mode" == "blacklist" ]]; then # Blacklist mode: if the host matches a pattern, it should NOT open in cmux. - if host_matches_whitelist "$url"; then + if host_matches_list_pattern "$url"; then return 1 fi return 0 fi # Whitelist mode (default): delegate directly. - host_matches_whitelist "$url" + host_matches_list_pattern "$url" } -load_whitelist_patterns() { +load_host_list_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") + host_list_patterns+=("$normalized") done <<< "$raw" } @@ -454,9 +454,9 @@ if [[ -n "$settings_domain" ]]; then system_open "$@" fi - whitelist_raw="$("$DEFAULTS_BIN" read "$settings_domain" browserHostWhitelist 2>/dev/null || true)" - if [[ -n "$whitelist_raw" ]]; then - load_whitelist_patterns "$whitelist_raw" + host_list_raw="$("$DEFAULTS_BIN" read "$settings_domain" browserHostWhitelist 2>/dev/null || true)" + if [[ -n "$host_list_raw" ]]; then + load_host_list_patterns "$host_list_raw" fi host_list_mode_raw="$("$DEFAULTS_BIN" read "$settings_domain" browserHostListMode 2>/dev/null || true)" From dc177db4dc61c44d0b59354a61b7c3adf51074d4 Mon Sep 17 00:00:00 2001 From: Sharun Date: Wed, 11 Mar 2026 12:02:08 -0700 Subject: [PATCH 4/8] fix(settings): normalize browserHostListMode before driving UI --- Sources/cmuxApp.swift | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index d0935eb9775f..b349d8775b89 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2857,6 +2857,22 @@ struct SettingsView: View { private var interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue() @AppStorage(BrowserLinkOpenSettings.browserHostWhitelistKey) private var browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist @AppStorage(BrowserLinkOpenSettings.browserHostListModeKey) private var browserHostListMode = BrowserLinkOpenSettings.defaultBrowserHostListMode + + private var resolvedBrowserHostListMode: BrowserLinkOpenSettings.HostListMode { + BrowserLinkOpenSettings.HostListMode(rawValue: browserHostListMode) ?? .whitelist + } + + private var browserHostListModeSelection: Binding { + Binding( + get: { resolvedBrowserHostListMode.rawValue }, + set: { newValue in + browserHostListMode = + BrowserLinkOpenSettings.HostListMode(rawValue: newValue)?.rawValue + ?? BrowserLinkOpenSettings.defaultBrowserHostListMode + } + ) + } + @AppStorage(BrowserLinkOpenSettings.browserExternalOpenPatternsKey) private var browserExternalOpenPatterns = BrowserLinkOpenSettings.defaultBrowserExternalOpenPatterns @AppStorage(BrowserInsecureHTTPSettings.allowlistKey) private var browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText @@ -3789,7 +3805,7 @@ struct SettingsView: View { String(localized: "settings.browser.hostListMode", defaultValue: "Host List Mode"), subtitle: String(localized: "settings.browser.hostListMode.subtitle", defaultValue: "Whitelist: only listed hosts open in cmux. Blacklist: listed hosts always open in your default browser.") ) { - Picker("", selection: $browserHostListMode) { + Picker("", selection: browserHostListModeSelection) { Text(String(localized: "settings.browser.hostListMode.whitelist", defaultValue: "Whitelist")) .tag(BrowserLinkOpenSettings.HostListMode.whitelist.rawValue) Text(String(localized: "settings.browser.hostListMode.blacklist", defaultValue: "Blacklist")) @@ -3804,10 +3820,10 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: 6) { SettingsCardRow( - browserHostListMode == BrowserLinkOpenSettings.HostListMode.blacklist.rawValue + resolvedBrowserHostListMode == .blacklist ? String(localized: "settings.browser.hostBlacklist", defaultValue: "Hosts to Block from Embedded Browser") : String(localized: "settings.browser.hostWhitelist", defaultValue: "Hosts to Open in Embedded Browser"), - subtitle: browserHostListMode == BrowserLinkOpenSettings.HostListMode.blacklist.rawValue + subtitle: resolvedBrowserHostListMode == .blacklist ? String(localized: "settings.browser.hostBlacklist.subtitle", defaultValue: "Applies to terminal link clicks and intercepted `open https://...` calls. These hosts always open in your default browser. All other hosts open in cmux. One host or wildcard per line (for example: example.com, *.internal.example). Leave empty to open all hosts in cmux.") : String(localized: "settings.browser.hostWhitelist.subtitle", defaultValue: "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.") ) { From ccaace2234f1ef352e6388335c60f62b262be1b7 Mon Sep 17 00:00:00 2001 From: Sharun Date: Wed, 11 Mar 2026 12:16:36 -0700 Subject: [PATCH 5/8] refactor: remove dead hostMatchesWhitelist, consolidate to shouldOpenInCmuxBrowser --- Sources/Panels/BrowserPanel.swift | 16 ------ cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 50 +++++++++---------- 2 files changed, 25 insertions(+), 41 deletions(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 3cd4636dea66..bfd980ac51b4 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -277,22 +277,6 @@ enum BrowserLinkOpenSettings { return false } - /// Check whether a hostname matches the configured host list. - /// Empty list means "allow all" (no filtering) regardless of mode. - /// Supports exact match and wildcard prefix (`*.example.com`). - private static func hostMatchesWhitelist(_ host: String, defaults: UserDefaults = .standard) -> Bool { - let rawPatterns = hostWhitelist(defaults: defaults) - if rawPatterns.isEmpty { return true } - 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 - } - /// Determine whether a host should open in the cmux embedded browser, /// respecting the configured host-list mode (whitelist or blacklist). /// diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 68a501cc6695..81b7ef3191b7 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -12396,49 +12396,49 @@ final class BrowserHostWhitelistTests: XCTestCase { } func testEmptyWhitelistAllowsAll() { - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults)) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "example.com", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "localhost", defaults: defaults)) } func testExactMatch() { defaults.set("localhost\n127.0.0.1", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults)) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("127.0.0.1", defaults: defaults)) - XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "localhost", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "127.0.0.1", defaults: defaults)) + XCTAssertFalse(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "example.com", defaults: defaults)) } func testExactMatchIsCaseInsensitive() { defaults.set("LocalHost", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults)) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("LOCALHOST", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "localhost", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "LOCALHOST", defaults: defaults)) } func testWildcardSuffix() { defaults.set("*.localtest.me", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("app.localtest.me", defaults: defaults)) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("sub.app.localtest.me", defaults: defaults)) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localtest.me", defaults: defaults)) - XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "app.localtest.me", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "sub.app.localtest.me", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "localtest.me", defaults: defaults)) + XCTAssertFalse(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "example.com", defaults: defaults)) } func testWildcardIsCaseInsensitive() { defaults.set("*.Example.COM", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("sub.example.com", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "sub.example.com", defaults: defaults)) } func testBlankLinesAndWhitespaceIgnored() { defaults.set(" localhost \n\n 127.0.0.1 \n", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults)) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("127.0.0.1", defaults: defaults)) - XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "localhost", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "127.0.0.1", defaults: defaults)) + XCTAssertFalse(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "example.com", defaults: defaults)) } func testMixedExactAndWildcard() { defaults.set("localhost\n127.0.0.1\n*.local.dev", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults)) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("127.0.0.1", defaults: defaults)) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("app.local.dev", defaults: defaults)) - XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("github.com", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "localhost", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "127.0.0.1", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "app.local.dev", defaults: defaults)) + XCTAssertFalse(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "github.com", defaults: defaults)) } func testDefaultWhitelistIsEmpty() { @@ -12448,24 +12448,24 @@ final class BrowserHostWhitelistTests: XCTestCase { func testWildcardRequiresDotBoundary() { defaults.set("*.example.com", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("badexample.com", defaults: defaults)) - XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com.evil", defaults: defaults)) + XCTAssertFalse(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "badexample.com", defaults: defaults)) + XCTAssertFalse(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "example.com.evil", defaults: defaults)) } func testWhitelistNormalizesSchemesPortsAndTrailingDots() { defaults.set("https://LOCALHOST:3000/path\n*.Example.COM:443", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost.", defaults: defaults)) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("api.example.com", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "localhost.", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "api.example.com", defaults: defaults)) } func testInvalidWhitelistEntriesDoNotImplicitlyAllowAll() { defaults.set("http://\n*.\n", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults)) + XCTAssertFalse(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "example.com", defaults: defaults)) } func testUnicodeWhitelistEntryMatchesPunycodeHost() { defaults.set("b\u{00FC}cher.example", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("xn--bcher-kva.example", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "xn--bcher-kva.example", defaults: defaults)) } } From b8a8c6efb2716cf2c45924d768a4c07ab6c97767 Mon Sep 17 00:00:00 2001 From: Sharun Date: Wed, 11 Mar 2026 14:10:46 -0700 Subject: [PATCH 6/8] fix: respect host whitelist/blacklist when opening PR links from sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar PR link clicks (both individual and batch 'Open All Workspace PR Links') bypassed the host filtering entirely — they only checked the openSidebarPullRequestLinksInCmuxBrowser boolean toggle without consulting BrowserLinkOpenSettings.shouldOpenExternally() or BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host:). This meant blacklisted domains (e.g. github.tesla.com) would still open in the embedded browser when clicked from the sidebar, even though terminal link clicks correctly routed them externally. Both openPullRequestLink() and openWorkspacePullRequestsInConfiguredBrowser() now apply the same external-open-pattern and host whitelist/blacklist checks that GhosttyTerminalView uses before routing to the embedded browser. --- Sources/ContentView.swift | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 01adb67d4389..3d37557437f9 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -5882,9 +5882,18 @@ struct ContentView: View { var openedCount = 0 if openSidebarPullRequestLinksInCmuxBrowser { for pullRequest in pullRequests { - if tabManager.openBrowser(url: pullRequest.url, insertAtEnd: true) != nil { + let url = pullRequest.url + // Respect external-open patterns and host whitelist/blacklist settings. + let shouldOpenExternally = BrowserLinkOpenSettings.shouldOpenExternally(url) + || { + guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return false } + return !BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: host) + }() + if shouldOpenExternally { + if NSWorkspace.shared.open(url) { openedCount += 1 } + } else if tabManager.openBrowser(url: url, insertAtEnd: true) != nil { openedCount += 1 - } else if NSWorkspace.shared.open(pullRequest.url) { + } else if NSWorkspace.shared.open(url) { openedCount += 1 } } @@ -10247,6 +10256,16 @@ private struct TabItemView: View { private func openPullRequestLink(_ url: URL) { updateSelection() if openSidebarPullRequestLinksInCmuxBrowser { + // Respect external-open patterns and host whitelist/blacklist settings. + if BrowserLinkOpenSettings.shouldOpenExternally(url) { + NSWorkspace.shared.open(url) + return + } + if let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? ""), + !BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: host) { + NSWorkspace.shared.open(url) + return + } if tabManager.openBrowser( inWorkspace: tab.id, url: url, From cf819a465d6ecc8032251cf9177e748d759c903c Mon Sep 17 00:00:00 2001 From: Sharun Date: Wed, 8 Apr 2026 17:24:22 -0700 Subject: [PATCH 7/8] Update submodules to upstream/main --- ghostty | 2 +- homebrew-cmux | 2 +- vendor/bonsplit | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ghostty b/ghostty index a50579bd5dde..3b684a085d40 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit a50579bd5ddec81c6244b9b349d4bf781f667cec +Subproject commit 3b684a085d40ec79e8a0ae863a4f2b48ed4dba74 diff --git a/homebrew-cmux b/homebrew-cmux index dcfaa081e5b3..a5f372ecfa5e 160000 --- a/homebrew-cmux +++ b/homebrew-cmux @@ -1 +1 @@ -Subproject commit dcfaa081e5b3e0ad62c5c1a5a4d58f4562f6be71 +Subproject commit a5f372ecfa5ee3903af6e1faba0eda096b4f5746 diff --git a/vendor/bonsplit b/vendor/bonsplit index fa452db181f3..098d9fa00e2b 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit fa452db181f361514087558a29204bda7e38218f +Subproject commit 098d9fa00e2b1d4712f1a46b818ee7d53d4aa31f From ad98c33bfc110858b91a83cb7224f90cba965353 Mon Sep 17 00:00:00 2001 From: Sharun Date: Thu, 9 Apr 2026 16:25:41 -0700 Subject: [PATCH 8/8] fix: update tests --- .../KeyboardShortcutSettingsFileStore.swift | 1 + Sources/cmuxApp.swift | 152 +++++++++--------- cmuxTests/BrowserConfigTests.swift | 50 +++--- 3 files changed, 106 insertions(+), 97 deletions(-) diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index 276754dbd1b0..4a4504aaa9e4 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -80,6 +80,7 @@ final class CmuxSettingsFileStore { "browser.theme", "browser.openTerminalLinksInCmuxBrowser", "browser.interceptTerminalOpenCommandInCmuxBrowser", + "browser.hostListMode", "browser.hostsToOpenInEmbeddedBrowser", "browser.urlsToAlwaysOpenExternally", "browser.insecureHttpHostsAllowedInEmbeddedBrowser", diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 0e1364ac620b..463b3a92a9e1 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -5634,78 +5634,7 @@ struct SettingsView: View { } if openTerminalLinksInCmuxBrowser || interceptTerminalOpenCommandInCmuxBrowser { - SettingsCardDivider() - - SettingsCardRow( - String(localized: "settings.browser.hostListMode", defaultValue: "Host List Mode"), - subtitle: String(localized: "settings.browser.hostListMode.subtitle", defaultValue: "Whitelist: only listed hosts open in cmux. Blacklist: listed hosts always open in your default browser.") - ) { - Picker("", selection: browserHostListModeSelection) { - Text(String(localized: "settings.browser.hostListMode.whitelist", defaultValue: "Whitelist")) - .tag(BrowserLinkOpenSettings.HostListMode.whitelist.rawValue) - Text(String(localized: "settings.browser.hostListMode.blacklist", defaultValue: "Blacklist")) - .tag(BrowserLinkOpenSettings.HostListMode.blacklist.rawValue) - } - .labelsHidden() - .pickerStyle(.segmented) - .frame(width: 180) - } - - SettingsCardDivider() - - VStack(alignment: .leading, spacing: 6) { - SettingsCardRow( - configurationReview: .json("browser.hostsToOpenInEmbeddedBrowser"), - resolvedBrowserHostListMode == .blacklist - ? String(localized: "settings.browser.hostBlacklist", defaultValue: "Hosts to Block from Embedded Browser") - : String(localized: "settings.browser.hostWhitelist", defaultValue: "Hosts to Open in Embedded Browser"), - subtitle: resolvedBrowserHostListMode == .blacklist - ? String(localized: "settings.browser.hostBlacklist.subtitle", defaultValue: "Applies to terminal link clicks and intercepted `open https://...` calls. These hosts always open in your default browser. All other hosts open in cmux. One host or wildcard per line (for example: example.com, *.internal.example). Leave empty to open all hosts in cmux.") - : String(localized: "settings.browser.hostWhitelist.subtitle", defaultValue: "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() - } - - TextEditor(text: $browserHostWhitelist) - .font(.system(.body, design: .monospaced)) - .frame(minHeight: 60, maxHeight: 120) - .scrollContentBackground(.hidden) - .padding(6) - .background(Color(nsColor: .controlBackgroundColor)) - .cornerRadius(6) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) - ) - .padding(.horizontal, 16) - .padding(.bottom, 12) - } - - SettingsCardDivider() - - VStack(alignment: .leading, spacing: 6) { - SettingsCardRow( - configurationReview: .json("browser.urlsToAlwaysOpenExternally"), - String(localized: "settings.browser.externalPatterns", defaultValue: "URLs to Always Open Externally"), - subtitle: String(localized: "settings.browser.externalPatterns.subtitle", defaultValue: "Applies to terminal link clicks and intercepted `open https://...` calls. One rule per line. Plain text matches any URL substring, or prefix with `re:` for regex (for example: openai.com/usage, re:^https?://[^/]*\\.example\\.com/(billing|usage)).") - ) { - EmptyView() - } - - TextEditor(text: $browserExternalOpenPatterns) - .font(.system(.body, design: .monospaced)) - .frame(minHeight: 60, maxHeight: 120) - .scrollContentBackground(.hidden) - .padding(6) - .background(Color(nsColor: .controlBackgroundColor)) - .cornerRadius(6) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) - ) - .padding(.horizontal, 16) - .padding(.bottom, 12) - } + browserHostListSettingsSection() } SettingsCardDivider() @@ -6134,6 +6063,85 @@ struct SettingsView: View { NSApplication.shared.terminate(nil) } + @ViewBuilder + private func browserHostListSettingsSection() -> some View { + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .json("browser.hostListMode"), + String(localized: "settings.browser.hostListMode", defaultValue: "Host List Mode"), + subtitle: String(localized: "settings.browser.hostListMode.subtitle", defaultValue: "Whitelist: only listed hosts open in cmux. Blacklist: listed hosts always open in your default browser.") + ) { + Picker("", selection: browserHostListModeSelection) { + Text(String(localized: "settings.browser.hostListMode.whitelist", defaultValue: "Whitelist")) + .tag(BrowserLinkOpenSettings.HostListMode.whitelist.rawValue) + Text(String(localized: "settings.browser.hostListMode.blacklist", defaultValue: "Blacklist")) + .tag(BrowserLinkOpenSettings.HostListMode.blacklist.rawValue) + } + .labelsHidden() + .pickerStyle(.segmented) + .frame(width: 180) + } + + SettingsCardDivider() + + VStack(alignment: .leading, spacing: 6) { + let hostListTitle: String = resolvedBrowserHostListMode == .blacklist + ? String(localized: "settings.browser.hostBlacklist", defaultValue: "Hosts to Block from Embedded Browser") + : String(localized: "settings.browser.hostWhitelist", defaultValue: "Hosts to Open in Embedded Browser") + let hostListSubtitle: String = resolvedBrowserHostListMode == .blacklist + ? String(localized: "settings.browser.hostBlacklist.subtitle", defaultValue: "Applies to terminal link clicks and intercepted `open https://...` calls. These hosts always open in your default browser. All other hosts open in cmux. One host or wildcard per line (for example: example.com, *.internal.example). Leave empty to open all hosts in cmux.") + : String(localized: "settings.browser.hostWhitelist.subtitle", defaultValue: "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.") + SettingsCardRow( + configurationReview: .json("browser.hostsToOpenInEmbeddedBrowser"), + hostListTitle, + subtitle: hostListSubtitle + ) { + EmptyView() + } + + TextEditor(text: $browserHostWhitelist) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 60, maxHeight: 120) + .scrollContentBackground(.hidden) + .padding(6) + .background(Color(nsColor: .controlBackgroundColor)) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) + ) + .padding(.horizontal, 16) + .padding(.bottom, 12) + } + + SettingsCardDivider() + + VStack(alignment: .leading, spacing: 6) { + SettingsCardRow( + configurationReview: .json("browser.urlsToAlwaysOpenExternally"), + String(localized: "settings.browser.externalPatterns", defaultValue: "URLs to Always Open Externally"), + subtitle: String(localized: "settings.browser.externalPatterns.subtitle", defaultValue: "Applies to terminal link clicks and intercepted `open https://...` calls. One rule per line. Plain text matches any URL substring, or prefix with `re:` for regex (for example: openai.com/usage, re:^https?://[^/]*\\.example\\.com/(billing|usage)).") + ) { + EmptyView() + } + + TextEditor(text: $browserExternalOpenPatterns) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 60, maxHeight: 120) + .scrollContentBackground(.hidden) + .padding(6) + .background(Color(nsColor: .controlBackgroundColor)) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) + ) + .padding(.horizontal, 16) + .padding(.bottom, 12) + } + } + private func resetAllSettings() { isResettingSettings = true appLanguage = LanguageSettings.defaultLanguage.rawValue diff --git a/cmuxTests/BrowserConfigTests.swift b/cmuxTests/BrowserConfigTests.swift index 7ee49c5733a6..e22edbab0373 100644 --- a/cmuxTests/BrowserConfigTests.swift +++ b/cmuxTests/BrowserConfigTests.swift @@ -3332,49 +3332,49 @@ final class BrowserHostWhitelistTests: XCTestCase { } func testEmptyWhitelistAllowsAll() { - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults)) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "example.com", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "localhost", defaults: defaults)) } func testExactMatch() { defaults.set("localhost\n127.0.0.1", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults)) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("127.0.0.1", defaults: defaults)) - XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "localhost", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "127.0.0.1", defaults: defaults)) + XCTAssertFalse(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "example.com", defaults: defaults)) } func testExactMatchIsCaseInsensitive() { defaults.set("LocalHost", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults)) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("LOCALHOST", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "localhost", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "LOCALHOST", defaults: defaults)) } func testWildcardSuffix() { defaults.set("*.localtest.me", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("app.localtest.me", defaults: defaults)) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("sub.app.localtest.me", defaults: defaults)) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localtest.me", defaults: defaults)) - XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "app.localtest.me", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "sub.app.localtest.me", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "localtest.me", defaults: defaults)) + XCTAssertFalse(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "example.com", defaults: defaults)) } func testWildcardIsCaseInsensitive() { defaults.set("*.Example.COM", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("sub.example.com", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "sub.example.com", defaults: defaults)) } func testBlankLinesAndWhitespaceIgnored() { defaults.set(" localhost \n\n 127.0.0.1 \n", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults)) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("127.0.0.1", defaults: defaults)) - XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "localhost", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "127.0.0.1", defaults: defaults)) + XCTAssertFalse(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "example.com", defaults: defaults)) } func testMixedExactAndWildcard() { defaults.set("localhost\n127.0.0.1\n*.local.dev", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults)) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("127.0.0.1", defaults: defaults)) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("app.local.dev", defaults: defaults)) - XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("github.com", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "localhost", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "127.0.0.1", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "app.local.dev", defaults: defaults)) + XCTAssertFalse(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "github.com", defaults: defaults)) } func testDefaultWhitelistIsEmpty() { @@ -3384,24 +3384,24 @@ final class BrowserHostWhitelistTests: XCTestCase { func testWildcardRequiresDotBoundary() { defaults.set("*.example.com", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("badexample.com", defaults: defaults)) - XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com.evil", defaults: defaults)) + XCTAssertFalse(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "badexample.com", defaults: defaults)) + XCTAssertFalse(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "example.com.evil", defaults: defaults)) } func testWhitelistNormalizesSchemesPortsAndTrailingDots() { defaults.set("https://LOCALHOST:3000/path\n*.Example.COM:443", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost.", defaults: defaults)) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("api.example.com", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "localhost.", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "api.example.com", defaults: defaults)) } func testInvalidWhitelistEntriesDoNotImplicitlyAllowAll() { defaults.set("http://\n*.\n", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults)) + XCTAssertFalse(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "example.com", defaults: defaults)) } func testUnicodeWhitelistEntryMatchesPunycodeHost() { defaults.set("b\u{00FC}cher.example", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) - XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("xn--bcher-kva.example", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.shouldOpenInCmuxBrowser(host: "xn--bcher-kva.example", defaults: defaults)) } }