Merge pull request #331 from manaflow-ai/task-307-configurable-open-wrapper

Respect open-wrapper browser settings and preserve PR 307 history
This commit is contained in:
Lawrence Chen 2026-02-22 19:38:17 -08:00 committed by GitHub
commit 1d71303d18
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1086 additions and 52 deletions

View file

@ -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 = "<group>"; };
B2E7294509CC42FE9191870E /* xterm-ghostty */ = {isa = PBXFileReference; lastKnownFileType = file; path = "ghostty/terminfo/78/xterm-ghostty"; sourceTree = "<group>"; };
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 = "<group>"; };
B9000004A1B2C3D4E5F60719 /* cmux */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = cmux; sourceTree = BUILT_PRODUCTS_DIR; };

283
Resources/bin/open Executable file
View file

@ -0,0 +1,283 @@
#!/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.
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"
fi
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=()
system_open() {
exec "$SYSTEM_OPEN_BIN" "$@"
}
trim() {
local value="$1"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
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="$(to_lower_ascii "$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
value="$(canonicalize_idn_host "$value")"
printf '%s' "$value"
}
normalize_whitelist_pattern() {
local value
value="$(trim "$1")"
value="$(to_lower_ascii "$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
system_open "$@"
fi
# No arguments → pass through.
if [[ $# -eq 0 ]]; then
system_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
;;
*)
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
if [[ "$passthrough" == true ]] || [[ ${#urls[@]} -eq 0 ]]; then
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" 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
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
load_whitelist_patterns "$whitelist_raw"
fi
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
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
system_open "${failed_urls[@]}"
fi

View file

@ -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
}
}

View file

@ -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 {

View file

@ -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

View file

@ -3114,6 +3114,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 {
@ -3361,6 +3366,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

View file

@ -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

View file

@ -960,40 +960,45 @@ 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.
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 {
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),
@ -1089,29 +1094,34 @@ 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.
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 {
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)
@ -1537,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"

View file

@ -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

View file

@ -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])
@ -99,6 +103,75 @@ 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))
}
}
@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()
@ -1800,6 +1873,120 @@ 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 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 {
@ -4140,6 +4327,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 {

333
tests/test_open_wrapper.py Executable file
View file

@ -0,0 +1,333 @@
#!/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],
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]:
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
browserInterceptTerminalOpenCommandInCmuxBrowser)
if [[ "${FAKE_DEFAULTS_INTERCEPT_OPEN+x}" == "x" ]]; then
printf '%s\\n' "$FAKE_DEFAULTS_INTERCEPT_OPEN"
exit 0
fi
exit 1
;;
browserOpenTerminalLinksInCmuxBrowser)
if [[ "${FAKE_DEFAULTS_LEGACY_OPEN+x}" == "x" ]]; then
printf '%s\\n' "$FAKE_DEFAULTS_LEGACY_OPEN"
exit 0
fi
exit 1
;;
browserHostWhitelist)
if [[ "${FAKE_DEFAULTS_WHITELIST+x}" == "x" ]]; 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=""
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
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 intercept_setting is None:
env.pop("FAKE_DEFAULTS_INTERCEPT_OPEN", None)
else:
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)
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(
["/bin/bash", 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],
intercept_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_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(
args=[url],
intercept_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],
intercept_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],
intercept_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 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 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:")
for failure in failures:
print(f" - {failure}")
return 1
print("open wrapper regression tests passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())