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:
commit
1d71303d18
11 changed files with 1086 additions and 52 deletions
|
|
@ -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
283
Resources/bin/open
Executable 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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
333
tests/test_open_wrapper.py
Executable 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue