#!/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 } is_file_url() { local value="$1" case "$value" in [Ff][Ii][Ll][Ee]://*) return 0 ;; esac return 1 } has_uri_scheme() { local value="$1" [[ "$value" =~ ^[A-Za-z][A-Za-z0-9+.-]*: ]] } is_html_extension() { local value value="$(to_lower_ascii "$(trim "$1")")" case "$value" in *.html|*.htm) return 0 ;; esac return 1 } is_explicit_local_path() { local value="$1" case "$value" in /*|./*|../*|~|~/*) return 0 ;; esac return 1 } file_url_points_to_html() { local value="$1" if [[ -n "$PYTHON3_BIN" ]]; then "$PYTHON3_BIN" - "$value" <<'PY' >/dev/null 2>&1 import sys from urllib.parse import unquote, urlsplit value = sys.argv[1].strip() if not value: raise SystemExit(1) parts = urlsplit(value) path = unquote(parts.path or "") lower = path.lower() if lower.endswith(".html") or lower.endswith(".htm"): raise SystemExit(0) raise SystemExit(1) PY return $? fi local without_fragment="${value%%\#*}" local without_query="${without_fragment%%\?*}" local remainder path_part case "$without_query" in [Ff][Ii][Ll][Ee]://*) remainder="${without_query#*://}" ;; *) return 1 ;; esac if [[ "$remainder" == /* ]]; then path_part="$remainder" elif [[ "$remainder" == */* ]]; then path_part="/${remainder#*/}" else return 1 fi is_html_extension "$path_part" } path_to_file_url_without_python() { local raw="$1" local expanded="$raw" case "$expanded" in "~") expanded="$HOME" ;; "~/"*) expanded="$HOME/${expanded#~/}" ;; esac local absolute if [[ "$expanded" == /* ]]; then absolute="$expanded" else absolute="$(pwd)/$expanded" fi local directory="$absolute" local basename="" if [[ "$absolute" == */* ]]; then directory="${absolute%/*}" basename="${absolute##*/}" fi local resolved_directory if resolved_directory="$(cd "$directory" 2>/dev/null && pwd -P)"; then absolute="$resolved_directory" if [[ -n "$basename" ]]; then absolute="$absolute/$basename" fi fi local encoded="" local length=${#absolute} local index char hex local LC_ALL=C for ((index = 0; index < length; index++)); do char="${absolute:index:1}" case "$char" in [a-zA-Z0-9.~_-]|/) encoded+="$char" ;; *) printf -v hex '%02X' "'$char" encoded+="%$hex" ;; esac done printf 'file://%s\n' "$encoded" } path_to_file_url() { local raw="$1" if [[ -n "$PYTHON3_BIN" ]]; then local converted if converted="$("$PYTHON3_BIN" - "$raw" <<'PY' 2>/dev/null import pathlib import sys raw = sys.argv[1] if not raw: raise SystemExit(1) path = pathlib.Path(raw).expanduser() if path.is_absolute(): resolved = path.resolve(strict=False) else: resolved = (pathlib.Path.cwd() / path).resolve(strict=False) sys.stdout.write(resolved.as_uri()) PY )"; then printf '%s\n' "$converted" return 0 fi fi path_to_file_url_without_python "$raw" } 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 and route eligible browser targets to cmux. passthrough=false cmux_targets=() passthrough_args=() 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 cmux_targets+=("$arg") elif is_file_url "$arg"; then if file_url_points_to_html "$arg"; then cmux_targets+=("$arg") else passthrough_args+=("$arg") fi elif has_uri_scheme "$arg"; then passthrough_args+=("$arg") elif is_html_extension "$arg"; then if is_explicit_local_path "$arg" || [[ -e "$arg" ]]; then if local_file_url="$(path_to_file_url "$arg")"; then cmux_targets+=("$local_file_url") else passthrough_args+=("$arg") fi else passthrough_args+=("$arg") fi else passthrough_args+=("$arg") fi ;; esac done if [[ "$passthrough" == true ]] || [[ ${#cmux_targets[@]} -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. # External-open pattern rules are evaluated in-app (NSRegularExpression) so # terminal link clicks and intercepted `open` commands share one regex dialect. failed_urls=() for url in "${cmux_targets[@]}"; do if is_http_url "$url" && ! host_matches_whitelist "$url"; then failed_urls+=("$url") continue fi CMUX_RESPECT_EXTERNAL_OPEN_RULES=1 "$CMUX_CLI" browser open "$url" 2>/dev/null || failed_urls+=("$url") done # Fall back to system open for unmatched args and URLs that failed. if [[ ${#passthrough_args[@]} -gt 0 ]] || [[ ${#failed_urls[@]} -gt 0 ]]; then system_open "${passthrough_args[@]}" "${failed_urls[@]}" fi