diff --git a/bin/code-notify b/bin/code-notify index a882563..30031df 100755 --- a/bin/code-notify +++ b/bin/code-notify @@ -52,6 +52,11 @@ case "$COMMAND_NAME" in source "$LIB_DIR/commands/global.sh" handle_global_command "$@" ;; + "click-through") + [[ "$(uname -s)" != "Darwin" ]] && { error "Unknown command: $1"; echo "Try 'cn help' for usage"; exit 1; } + source "$LIB_DIR/commands/global.sh" + handle_global_command "$@" + ;; "repair-hooks") shift repair_legacy_hooks_command "${1:-}" @@ -77,6 +82,11 @@ case "$COMMAND_NAME" in source "$LIB_DIR/commands/global.sh" handle_global_command "$@" ;; + "click-through") + [[ "$(uname -s)" != "Darwin" ]] && { error "Unknown command: $1"; echo "Try 'code-notify help' for usage"; exit 1; } + source "$LIB_DIR/commands/global.sh" + handle_global_command "$@" + ;; "repair-hooks") shift repair_legacy_hooks_command "${1:-}" diff --git a/lib/code-notify/commands/global.sh b/lib/code-notify/commands/global.sh index 73aa414..1556938 100755 --- a/lib/code-notify/commands/global.sh +++ b/lib/code-notify/commands/global.sh @@ -7,6 +7,7 @@ GLOBAL_CMD_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$GLOBAL_CMD_DIR/../utils/voice.sh" source "$GLOBAL_CMD_DIR/../utils/sound.sh" source "$GLOBAL_CMD_DIR/../utils/help.sh" +source "$GLOBAL_CMD_DIR/../utils/click-through.sh" CODE_NOTIFY_RELEASES_API="https://api.github.com/repos/mylee04/code-notify/releases/latest" @@ -43,6 +44,9 @@ handle_global_command() { "alerts") handle_alerts_command "$@" ;; + "click-through") + handle_click_through_command "$@" + ;; "help") show_help ;; diff --git a/lib/code-notify/core/notifier.sh b/lib/code-notify/core/notifier.sh index 59b958d..c902090 100755 --- a/lib/code-notify/core/notifier.sh +++ b/lib/code-notify/core/notifier.sh @@ -15,6 +15,9 @@ NOTIFIER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$NOTIFIER_DIR/../utils/detect.sh" source "$NOTIFIER_DIR/../utils/voice.sh" source "$NOTIFIER_DIR/../utils/sound.sh" +source "$NOTIFIER_DIR/../utils/click-through-store.sh" +source "$NOTIFIER_DIR/../utils/click-through-runtime.sh" +source "$NOTIFIER_DIR/../utils/click-through-resolver.sh" has_jq() { command -v jq >/dev/null 2>&1 @@ -328,24 +331,7 @@ fi # Get terminal bundle ID for macOS activation get_terminal_bundle_id() { - case "${TERM_PROGRAM:-}" in - "iTerm.app") echo "com.googlecode.iterm2" ;; - "Apple_Terminal") echo "com.apple.Terminal" ;; - "vscode") echo "com.microsoft.VSCode" ;; - "WezTerm") echo "com.github.wez.wezterm" ;; - "Alacritty") echo "org.alacritty" ;; - "Hyper") echo "co.zeit.hyper" ;; - *) - # Fallback: try to detect from parent process - if [[ -n "${ITERM_SESSION_ID:-}" ]]; then - echo "com.googlecode.iterm2" - elif [[ -n "${WEZTERM_PANE:-}" ]]; then - echo "com.github.wez.wezterm" - else - echo "com.apple.Terminal" - fi - ;; - esac + click_through_resolve_activation_bundle_id } # Function to send notification on macOS diff --git a/lib/code-notify/utils/click-through-resolver.sh b/lib/code-notify/utils/click-through-resolver.sh new file mode 100644 index 0000000..1b4ed6f --- /dev/null +++ b/lib/code-notify/utils/click-through-resolver.sh @@ -0,0 +1,96 @@ +#!/bin/bash + +# Requires click-through-store.sh and click-through-runtime.sh to be sourced first. + +click_through_resolve_configured_bundle_id() { + local term_prog bundle_id + + term_prog=$(click_through_get_runtime_term_program || true) + if [[ -n "$term_prog" ]]; then + bundle_id=$(click_through_lookup_config_bundle_id "$term_prog" || true) + if [[ -n "$bundle_id" ]]; then + printf '%s\n' "$bundle_id" + return 0 + fi + fi + + bundle_id=$(click_through_get_context_bundle_id || true) + if [[ -n "$bundle_id" ]] && click_through_lookup_config_term_program "$bundle_id" >/dev/null 2>&1; then + printf '%s\n' "$bundle_id" + return 0 + fi + + return 1 +} + +click_through_resolve_activation_bundle_id() { + local term_prog bundle_id + + bundle_id=$(click_through_resolve_configured_bundle_id || true) + if [[ -n "$bundle_id" ]]; then + printf '%s\n' "$bundle_id" + return 0 + fi + + term_prog=$(click_through_get_runtime_term_program || true) + if [[ -n "$term_prog" ]]; then + bundle_id=$(click_through_lookup_builtin_bundle_id "$term_prog" || true) + if [[ -n "$bundle_id" ]]; then + printf '%s\n' "$bundle_id" + return 0 + fi + fi + + click_through_get_fallback_bundle_id +} + +click_through_resolve_default_term_program() { + local bundle_id="$1" + local app_name="$2" + local term_prog + + term_prog=$(click_through_get_runtime_term_program || true) + if [[ -n "$term_prog" ]]; then + printf '%s\n' "$term_prog" + return 0 + fi + + if [[ -n "$bundle_id" ]]; then + term_prog=$(click_through_lookup_config_term_program "$bundle_id" || true) + if [[ -n "$term_prog" ]]; then + printf '%s\n' "$term_prog" + return 0 + fi + + term_prog=$(click_through_lookup_builtin_term_program "$bundle_id" || true) + if [[ -n "$term_prog" ]]; then + printf '%s\n' "$term_prog" + return 0 + fi + fi + + click_through_normalize_term_program "$app_name" +} + +click_through_find_existing_mapping_term_program() { + local bundle_id="$1" + local term_prog existing_bundle + + term_prog=$(click_through_get_runtime_term_program || true) + if [[ -n "$term_prog" ]]; then + existing_bundle=$(click_through_lookup_config_bundle_id "$term_prog" || true) + if [[ "$existing_bundle" == "$bundle_id" ]]; then + printf '%s\n' "$term_prog" + return 0 + fi + return 1 + fi + + term_prog=$(click_through_lookup_config_term_program "$bundle_id" || true) + if [[ -n "$term_prog" ]]; then + printf '%s\n' "$term_prog" + return 0 + fi + + return 1 +} diff --git a/lib/code-notify/utils/click-through-runtime.sh b/lib/code-notify/utils/click-through-runtime.sh new file mode 100644 index 0000000..d5c47cb --- /dev/null +++ b/lib/code-notify/utils/click-through-runtime.sh @@ -0,0 +1,83 @@ +#!/bin/bash + +click_through_normalize_term_program() { + local fallback="${1:-app}" + printf '%s\n' "$fallback" | tr '[:upper:]' '[:lower:]' | tr ' ' '_' | tr -cd '[:alnum:]_.-' +} + +click_through_get_fallback_bundle_id() { + if [[ -n "${GHOSTTY_RESOURCES_DIR:-}" ]]; then + echo "com.mitchellh.ghostty" + elif [[ -n "${ITERM_SESSION_ID:-}" ]]; then + echo "com.googlecode.iterm2" + elif [[ -n "${WEZTERM_PANE:-}" ]]; then + echo "com.github.wez.wezterm" + else + echo "com.apple.Terminal" + fi +} + +click_through_get_bundle_id() { + local app_path="$1" + /usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$app_path/Contents/Info.plist" 2>/dev/null +} + +click_through_detect_parent_app_path() { + local pid=$$ + local parent command app_path + + if [[ -n "${CODE_NOTIFY_CLICK_THROUGH_APP_PATH:-}" ]] && [[ -d "${CODE_NOTIFY_CLICK_THROUGH_APP_PATH}" ]]; then + printf '%s\n' "${CODE_NOTIFY_CLICK_THROUGH_APP_PATH}" + return 0 + fi + + while [[ "$pid" -gt 1 ]]; do + parent=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ') + [[ -n "$parent" ]] || return 1 + pid="$parent" + command=$(ps -o command= -p "$pid" 2>/dev/null || true) + + if [[ "$command" == *".app/Contents/MacOS/"* ]]; then + app_path="${command%%.app/Contents/MacOS/*}.app" + if [[ "$app_path" != *"/Contents/Frameworks/"* ]] && [[ -d "$app_path" ]]; then + printf '%s\n' "$app_path" + return 0 + fi + fi + done + + return 1 +} + +click_through_get_context_bundle_id() { + local app_path bundle_id + + app_path=$(click_through_detect_parent_app_path 2>/dev/null || true) + if [[ -n "$app_path" ]]; then + bundle_id=$(click_through_get_bundle_id "$app_path") + if [[ -n "$bundle_id" ]]; then + printf '%s\n' "$bundle_id" + return 0 + fi + fi + + if [[ -n "${__CFBundleIdentifier:-}" ]]; then + printf '%s\n' "${__CFBundleIdentifier}" + return 0 + fi + + return 1 +} + +click_through_get_runtime_term_program() { + local term_prog + + for term_prog in "${TERM_PROGRAM:-}" "${TERMINAL_EMULATOR:-}" "${LC_TERMINAL:-}"; do + if [[ -n "$term_prog" ]]; then + printf '%s\n' "$term_prog" + return 0 + fi + done + + return 1 +} diff --git a/lib/code-notify/utils/click-through-store.sh b/lib/code-notify/utils/click-through-store.sh new file mode 100644 index 0000000..019ae2b --- /dev/null +++ b/lib/code-notify/utils/click-through-store.sh @@ -0,0 +1,152 @@ +#!/bin/bash + +CLICK_THROUGH_CONFIG="${CODE_NOTIFY_HOME:-$HOME/.code-notify}/click-through.conf" + +click_through_each_builtin_entry() { + cat <<'EOF' +ghostty=com.mitchellh.ghostty +iTerm.app=com.googlecode.iterm2 +Apple_Terminal=com.apple.Terminal +vscode=com.microsoft.VSCode +cursor=com.todesktop.230313mzl4w4u92 +zed=dev.zed.Zed +WezTerm=com.github.wez.wezterm +Alacritty=org.alacritty +Hyper=co.zeit.hyper +WarpTerminal=dev.warp.Warp-Stable +kitty=net.kovidgoyal.kitty +Xcode=com.apple.dt.Xcode +EOF +} + +click_through_each_config_entry() { + [[ -f "$CLICK_THROUGH_CONFIG" ]] || return 0 + + local line key value + while IFS= read -r line; do + [[ -z "$line" ]] && continue + [[ "$line" == \#* ]] && continue + key="${line%%=*}" + value="${line#*=}" + [[ -z "$key" ]] && continue + printf '%s=%s\n' "$key" "$value" + done < "$CLICK_THROUGH_CONFIG" +} + +click_through_lookup_value() { + local key="$1" + local source_fn="$2" + local line entry_key entry_value + + while IFS= read -r line; do + entry_key="${line%%=*}" + entry_value="${line#*=}" + if [[ "$entry_key" == "$key" ]]; then + printf '%s\n' "$entry_value" + return 0 + fi + done < <("$source_fn") + + return 1 +} + +click_through_lookup_key() { + local value="$1" + local source_fn="$2" + local line entry_key entry_value + + while IFS= read -r line; do + entry_key="${line%%=*}" + entry_value="${line#*=}" + if [[ "$entry_value" == "$value" ]]; then + printf '%s\n' "$entry_key" + return 0 + fi + done < <("$source_fn") + + return 1 +} + +click_through_lookup_config_bundle_id() { + click_through_lookup_value "$1" click_through_each_config_entry +} + +click_through_lookup_config_term_program() { + click_through_lookup_key "$1" click_through_each_config_entry +} + +click_through_lookup_builtin_bundle_id() { + click_through_lookup_value "$1" click_through_each_builtin_entry +} + +click_through_lookup_builtin_term_program() { + click_through_lookup_key "$1" click_through_each_builtin_entry +} + +click_through_has_entries() { + local line + while IFS= read -r line; do + [[ -n "$line" ]] && return 0 + done < <(click_through_each_config_entry) + return 1 +} + +click_through_write_entries() { + local entries="$1" + mkdir -p "$(dirname "$CLICK_THROUGH_CONFIG")" + + { + echo "# Code-Notify click-through configuration" + echo "# Maps TERM_PROGRAM values to macOS bundle IDs" + echo "" + if [[ -n "$entries" ]]; then + printf '%s\n' "$entries" + fi + } > "$CLICK_THROUGH_CONFIG" +} + +click_through_upsert_entry() { + local term_prog="$1" + local bundle_id="$2" + local entries="" + local line key value + + while IFS= read -r line; do + key="${line%%=*}" + value="${line#*=}" + if [[ "$key" == "$term_prog" ]] || [[ "$value" == "$bundle_id" ]]; then + continue + fi + [[ -n "$entries" ]] && entries+=$'\n' + entries+="$line" + done < <(click_through_each_config_entry) + + [[ -n "$entries" ]] && entries+=$'\n' + entries+="${term_prog}=${bundle_id}" + click_through_write_entries "$entries" +} + +click_through_remove_entry() { + local target="$1" + local entries="" + local removed=1 + local line key value + + while IFS= read -r line; do + key="${line%%=*}" + value="${line#*=}" + if [[ "$key" == "$target" ]] || [[ "$value" == "$target" ]]; then + removed=0 + continue + fi + [[ -n "$entries" ]] && entries+=$'\n' + entries+="$line" + done < <(click_through_each_config_entry) + + if [[ $removed -ne 0 ]]; then + return 1 + fi + + click_through_write_entries "$entries" + return 0 +} diff --git a/lib/code-notify/utils/click-through.sh b/lib/code-notify/utils/click-through.sh new file mode 100644 index 0000000..3d24beb --- /dev/null +++ b/lib/code-notify/utils/click-through.sh @@ -0,0 +1,358 @@ +#!/bin/bash + +# Click-through configuration for macOS notifications. +# Maps TERM_PROGRAM values to bundle IDs used by terminal-notifier -activate. + +CLICK_THROUGH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$CLICK_THROUGH_DIR/click-through-store.sh" +source "$CLICK_THROUGH_DIR/click-through-runtime.sh" +source "$CLICK_THROUGH_DIR/click-through-resolver.sh" + +collect_click_through_search_results() { + local query="$1" + local line candidate query_lower seen="" + + while IFS= read -r line; do + [[ -d "$line" ]] || continue + case "$seen" in + *"|$line|"*) continue ;; + esac + seen="${seen}|${line}|" + printf '%s\n' "$line" + done < <(mdfind "kMDItemContentTypeTree == 'com.apple.application-bundle' && kMDItemFSName == '*${query}*'c" 2>/dev/null | head -20) + + query_lower=$(printf '%s' "$query" | tr '[:upper:]' '[:lower:]') + for candidate in /Applications/*.app /Applications/**/*.app "$HOME/Applications"/*.app; do + [[ -d "$candidate" ]] || continue + if [[ "$(basename "$candidate" .app | tr '[:upper:]' '[:lower:]')" == *"$query_lower"* ]]; then + case "$seen" in + *"|$candidate|"*) continue ;; + esac + seen="${seen}|${candidate}|" + printf '%s\n' "$candidate" + fi + done +} + +select_click_through_result() { + local -a results=("$@") + local idx choice bundle_id + + if [[ ${#results[@]} -eq 1 ]]; then + printf '%s\n' "${results[0]}" + return 0 + fi + + echo "" + echo " Found ${#results[@]} apps:" + echo "" + for idx in "${!results[@]}"; do + bundle_id=$(click_through_get_bundle_id "${results[$idx]}") + printf ' %s%2d)%s %-24s %s%s%s\n' \ + "$BOLD" "$((idx + 1))" "$RESET" \ + "$(basename "${results[$idx]}" .app)" \ + "$DIM" "$bundle_id" "$RESET" + done + + echo "" + printf ' Select [1-%d]: ' "${#results[@]}" + read -r choice + + if [[ -z "$choice" ]] || [[ "$choice" -lt 1 ]] || [[ "$choice" -gt ${#results[@]} ]] 2>/dev/null; then + error "Invalid selection." + return 1 + fi + + printf '%s\n' "${results[$((choice - 1))]}" +} + +resolve_click_through_app_path() { + local query="$1" + local -a results=() + local line + + if [[ -z "$query" ]]; then + return 1 + fi + + if [[ -d "$query" ]] && [[ "$query" == *.app ]]; then + printf '%s\n' "$query" + return 0 + fi + + while IFS= read -r line; do + [[ -n "$line" ]] && results+=("$line") + done < <(collect_click_through_search_results "$query") + + [[ ${#results[@]} -gt 0 ]] || return 1 + select_click_through_result "${results[@]}" +} + +show_click_through_status() { + local line key value + + if ! click_through_has_entries; then + info "No click-through mappings found. Run ${BOLD}cn click-through add${RESET} to set up." + return 0 + fi + + echo "" + header " Click-Through Mappings" + echo "" + + while IFS= read -r line; do + key="${line%%=*}" + value="${line#*=}" + printf ' %s%-20s%s -> %s%s%s\n' "$BOLD" "$key" "$RESET" "$DIM" "$value" "$RESET" + done < <(click_through_each_config_entry) + + echo "" + dim " Config: ${CLICK_THROUGH_CONFIG}" +} + +run_click_through_add() { + local query="${1:-}" + local app_path="" + local bundle_id app_name term_prog default_term input + local auto_detected=0 existing_term + + echo "" + header " Add Click-Through App" + + if [[ -z "$query" ]]; then + app_path=$(click_through_detect_parent_app_path 2>/dev/null || true) + if [[ -n "$app_path" ]]; then + query="$app_path" + auto_detected=1 + else + printf ' Enter app name or path to .app: ' + read -r query + fi + fi + + [[ -n "$query" ]] || { error "No app provided."; return 1; } + + app_path=$(resolve_click_through_app_path "$query") || { + error "No apps found matching: $query" + return 1 + } + + bundle_id=$(click_through_get_bundle_id "$app_path") + [[ -n "$bundle_id" ]] || { error "Could not read bundle ID from: $app_path"; return 1; } + + app_name=$(basename "$app_path" .app) + default_term=$(click_through_resolve_default_term_program "$bundle_id" "$app_name") + + if [[ "$auto_detected" -eq 1 ]]; then + existing_term=$(click_through_find_existing_mapping_term_program "$bundle_id" || true) + if [[ -n "$existing_term" ]]; then + echo "" + info "Mapping already exists: ${BOLD}${existing_term}${RESET} -> ${DIM}${bundle_id}${RESET}" + dim " Run ${BOLD}cn click-through remove${RESET} to delete it." + return 0 + fi + fi + + echo "" + echo " App: ${BOLD}${app_name}${RESET} ${DIM}(${bundle_id})${RESET}" + echo " TERM_PROGRAM: ${BOLD}${default_term}${RESET}" + echo "" + dim " Tip: run 'echo \$TERM_PROGRAM' in the app's terminal to verify" + echo "" + printf ' Save? Enter to confirm, or type a different TERM_PROGRAM: ' + read -r input + + term_prog="${input:-$default_term}" + [[ -n "$term_prog" ]] || { error "TERM_PROGRAM cannot be empty."; return 1; } + + click_through_upsert_entry "$term_prog" "$bundle_id" + echo "" + success "Saved: TERM_PROGRAM=${term_prog} -> ${app_name} (${bundle_id})" +} + +draw_click_through_remove_item() { + local idx="$1" + local current="$2" + local term_prog="$3" + local bundle_id="$4" + local is_selected="$5" + local pointer=" " + local checkbox="${DIM}[ ]${RESET}" + local padded + + if [[ "$idx" -eq "$current" ]]; then + pointer=" ${CYAN}>${RESET} " + fi + + if [[ "$is_selected" -eq 1 ]]; then + checkbox="${GREEN}[x]${RESET}" + fi + + padded=$(printf '%-20s' "$term_prog") + printf '\e[2K\r%s%s %s %s%s%s\n' \ + "$pointer" "$checkbox" "$padded" \ + "$DIM" "$bundle_id" "$RESET" +} + +draw_click_through_remove_footer() { + local total="$1" + shift + local selected_count=0 + local value + + for value in "$@"; do + [[ "$value" -eq 1 ]] && selected_count=$((selected_count + 1)) + done + + printf '\e[2K\r %s%d / %d selected%s' "$DIM" "$selected_count" "$total" "$RESET" +} + +run_click_through_remove() { + local -a terms=() + local -a bundles=() + local -a selected=() + local line + + if ! click_through_has_entries; then + info "No click-through mappings to remove." + return 0 + fi + + while IFS= read -r line; do + terms+=("${line%%=*}") + bundles+=("${line#*=}") + selected+=(0) + done < <(click_through_each_config_entry) + + echo "" + header " Remove Click-Through Entries" + echo "" + dim " Up/Down move Space toggle Enter remove q cancel" + echo "" + + local idx current=0 total="${#terms[@]}" + for idx in "${!terms[@]}"; do + draw_click_through_remove_item "$idx" "$current" "${terms[$idx]}" "${bundles[$idx]}" "${selected[$idx]}" + done + draw_click_through_remove_footer "$total" "${selected[@]}" + + local key="" selected_count=0 entries="" removed_terms="" + while true; do + IFS= read -rsn1 key || true + + case "$key" in + $'\x1b') + read -rsn2 key || true + case "$key" in + "[A") + [[ "$current" -gt 0 ]] && current=$((current - 1)) + ;; + "[B") + [[ "$current" -lt $((total - 1)) ]] && current=$((current + 1)) + ;; + esac + ;; + " ") + if [[ "${selected[$current]}" -eq 1 ]]; then + selected[$current]=0 + else + selected[$current]=1 + fi + ;; + ""|$'\n') + break + ;; + "q"|"Q") + echo "" + echo "" + dim " Cancelled." + return 0 + ;; + esac + + printf '\e[%dA' "$total" + for idx in "${!terms[@]}"; do + draw_click_through_remove_item "$idx" "$current" "${terms[$idx]}" "${bundles[$idx]}" "${selected[$idx]}" + done + draw_click_through_remove_footer "$total" "${selected[@]}" + done + + for idx in "${!terms[@]}"; do + if [[ "${selected[$idx]}" -eq 1 ]]; then + selected_count=$((selected_count + 1)) + [[ -n "$removed_terms" ]] && removed_terms+=", " + removed_terms+="${terms[$idx]}" + continue + fi + [[ -n "$entries" ]] && entries+=$'\n' + entries+="${terms[$idx]}=${bundles[$idx]}" + done + + if [[ "$selected_count" -eq 0 ]]; then + echo "" + echo "" + info "No mappings selected." + return 0 + fi + + click_through_write_entries "$entries" + echo "" + echo "" + success "Removed ${selected_count} mapping(s): ${removed_terms}" +} + +show_click_through_help() { + cat << EOF + +${BOLD}cn click-through${RESET} - Configure which app opens when a notification is clicked + +${BOLD}USAGE:${RESET} + cn click-through [command] [args] + +${BOLD}COMMANDS:${RESET} + ${GREEN}status${RESET} Show current mappings (default) + ${GREEN}add${RESET} [name] Add an app mapping (auto-detect or search) + ${GREEN}remove${RESET} Interactively remove one or more mappings + ${GREEN}reset${RESET} Remove all custom mappings + ${GREEN}help${RESET} Show this help text + + Note: controls which app Code-Notify activates when you click a macOS notification. + +${BOLD}EXAMPLES:${RESET} + cn click-through + cn click-through add + cn click-through add Ghostty + cn click-through remove + cn click-through reset + +EOF +} + +handle_click_through_command() { + local action="${1:-status}" + shift 2>/dev/null || true + + case "$action" in + "status") + show_click_through_status + ;; + "add") + run_click_through_add "${1:-}" + ;; + "remove"|"rm") + run_click_through_remove + ;; + "reset") + rm -f "$CLICK_THROUGH_CONFIG" + success "Click-through mappings reset" + ;; + "help"|"-h"|"--help") + show_click_through_help + ;; + *) + error "Unknown click-through action: $action" + show_click_through_help + return 1 + ;; + esac +} diff --git a/lib/code-notify/utils/help.sh b/lib/code-notify/utils/help.sh index 4562ffa..7ac1bf1 100644 --- a/lib/code-notify/utils/help.sh +++ b/lib/code-notify/utils/help.sh @@ -2,6 +2,10 @@ # Shared help text for Code-Notify +is_macos_help_context() { + [[ "$(uname -s)" == "Darwin" ]] +} + # Show help message # Usage: show_help [command_name] show_help() { @@ -28,6 +32,15 @@ ${BOLD}COMMANDS:${RESET} ${GREEN}update${RESET} [check] Update code-notify or check the latest release ${GREEN}alerts${RESET} Configure which events trigger alerts ${GREEN}voice${RESET} Voice notification commands +EOF + + if is_macos_help_context; then + cat << EOF + ${GREEN}click-through${RESET} Configure which app opens on notification click +EOF + fi + + cat << EOF ${GREEN}setup${RESET} Run initial setup wizard ${GREEN}help${RESET} Show this help message ${GREEN}version${RESET} Show version information @@ -67,6 +80,22 @@ ${BOLD}SOUND COMMANDS:${RESET} ${GREEN}sound test${RESET} Play current sound ${GREEN}sound list${RESET} Show available system sounds ${GREEN}sound status${RESET} Show sound configuration +EOF + + if is_macos_help_context; then + cat << EOF + +${BOLD}CLICK-THROUGH COMMANDS:${RESET} + ${GREEN}click-through${RESET} Show current mappings + ${GREEN}click-through add${RESET} [name] Add an app mapping + ${GREEN}click-through remove${RESET} Interactively remove mappings + ${GREEN}click-through reset${RESET} Reset to built-in defaults + + Note: controls which app Code-Notify activates when you click a macOS notification. +EOF + fi + + cat << EOF ${BOLD}ALIASES:${RESET} ${CYAN}cn${RESET} Main command @@ -87,6 +116,17 @@ ${BOLD}EXAMPLES:${RESET} cn alerts reset # Back to idle_prompt only (less noisy) cn sound on # Enable notification sounds cn sound set ~/ding.wav # Use custom sound +EOF + + if is_macos_help_context; then + cat << EOF + cn click-through # Show current click-through mappings + cn click-through add # Add an app mapping + cn click-through remove # Interactively remove mappings +EOF + fi + + cat << EOF cnp on # Enable for current project ${BOLD}MORE INFO:${RESET} diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh index e52450f..e88bd6b 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -182,6 +182,22 @@ else test_fail "project settings consistency failed" fi +# Test 18: click-through resolver keeps lookup order and defaults stable +test_start "click-through resolver" +if bash tests/test-click-through-resolver.sh >/dev/null 2>&1; then + test_pass +else + test_fail "click-through resolver failed" +fi + +# Test 19: click-through commands persist mappings and drive notifier activation +test_start "click-through commands" +if bash tests/test-click-through.sh >/dev/null 2>&1; then + test_pass +else + test_fail "click-through commands failed" +fi + # Summary echo "" echo "Test Summary:" diff --git a/tests/test-click-through-resolver.sh b/tests/test-click-through-resolver.sh new file mode 100644 index 0000000..b4e9586 --- /dev/null +++ b/tests/test-click-through-resolver.sh @@ -0,0 +1,74 @@ +#!/bin/bash + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$SCRIPT_DIR/.." + +pass() { echo "PASS: $1"; } +fail() { echo "FAIL: $1"; exit 1; } + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "SKIP: click-through resolver is macOS-only" + exit 0 +fi + +test_dir="$(mktemp -d)" +trap 'rm -rf "$test_dir"' EXIT + +export HOME="$test_dir/home" +config_file="$HOME/.code-notify/click-through.conf" +apps_dir="$HOME/Applications" +pycharm_app="$apps_dir/PyCharm.app" + +mkdir -p "$HOME/.code-notify" "$pycharm_app/Contents" + +cat > "$pycharm_app/Contents/Info.plist" <<'EOF' + + + + + CFBundleIdentifier + com.jetbrains.pycharm + + +EOF + +source "$ROOT_DIR/lib/code-notify/utils/click-through-store.sh" +source "$ROOT_DIR/lib/code-notify/utils/click-through-runtime.sh" +source "$ROOT_DIR/lib/code-notify/utils/click-through-resolver.sh" + +[[ "$(click_through_lookup_builtin_bundle_id "ghostty")" == "com.mitchellh.ghostty" ]] || fail "builtin key lookup should use the canonical mapping table" +[[ "$(click_through_lookup_builtin_term_program "com.github.wez.wezterm")" == "WezTerm" ]] || fail "builtin reverse lookup should use the canonical mapping table" + +cat > "$config_file" <<'EOF' +# Code-Notify click-through configuration +# Maps TERM_PROGRAM values to macOS bundle IDs + +JetBrains-JediTerm=com.jetbrains.pycharm +EOF + +unset __CFBundleIdentifier + +TERM_PROGRAM="JetBrains-JediTerm" +[[ "$(click_through_resolve_configured_bundle_id)" == "com.jetbrains.pycharm" ]] || fail "configured resolution should prefer the live TERM_PROGRAM" + +TERM_PROGRAM="" +CODE_NOTIFY_CLICK_THROUGH_APP_PATH="$pycharm_app" +[[ "$(click_through_resolve_configured_bundle_id)" == "com.jetbrains.pycharm" ]] || fail "configured resolution should fall back to the current app bundle ID" + +rm -f "$config_file" + +TERM_PROGRAM="cursor" +CODE_NOTIFY_CLICK_THROUGH_APP_PATH="" +[[ "$(click_through_resolve_activation_bundle_id)" == "com.todesktop.230313mzl4w4u92" ]] || fail "activation resolution should fall back to built-in TERM_PROGRAM mappings" + +TERM_PROGRAM="JetBrains-JediTerm" +[[ "$(click_through_resolve_default_term_program "com.jetbrains.pycharm" "PyCharm")" == "JetBrains-JediTerm" ]] || fail "default TERM_PROGRAM should prefer the live runtime value" + +TERM_PROGRAM="" +[[ "$(click_through_resolve_default_term_program "com.github.wez.wezterm" "WezTerm")" == "WezTerm" ]] || fail "default TERM_PROGRAM should fall back to builtin reverse lookup" + +[[ "$(click_through_resolve_default_term_program "com.example.fakecodex" "Fake Codex")" == "fake_codex" ]] || fail "default TERM_PROGRAM should normalize unknown app names" + +pass "click-through resolver keeps a single mapping source and stable resolution order" diff --git a/tests/test-click-through.sh b/tests/test-click-through.sh new file mode 100644 index 0000000..914f4b4 --- /dev/null +++ b/tests/test-click-through.sh @@ -0,0 +1,128 @@ +#!/bin/bash + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$SCRIPT_DIR/.." +NOTIFIER="$ROOT_DIR/lib/code-notify/core/notifier.sh" + +pass() { echo "PASS: $1"; } +fail() { echo "FAIL: $1"; exit 1; } + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "SKIP: click-through is macOS-only" + exit 0 +fi + +test_dir="$(mktemp -d)" +trap 'rm -rf "$test_dir"' EXIT + +export HOME="$test_dir/home" +fake_bin="$test_dir/bin" +notification_log="$test_dir/terminal-notifier.log" +config_file="$HOME/.code-notify/click-through.conf" +apps_dir="$HOME/Applications" +fake_app="$apps_dir/FakeCodex.app" +phpstorm_app="$apps_dir/PhpStorm.app" + +mkdir -p "$HOME/.code-notify" "$HOME/.claude/notifications" "$HOME/.claude/logs" "$fake_bin" "$fake_app/Contents" "$phpstorm_app/Contents" + +cat > "$fake_bin/terminal-notifier" <> "$notification_log" +EOF +chmod +x "$fake_bin/terminal-notifier" + +cat > "$fake_app/Contents/Info.plist" <<'EOF' + + + + + CFBundleIdentifier + com.example.fakecodex + + +EOF + +cat > "$phpstorm_app/Contents/Info.plist" <<'EOF' + + + + + CFBundleIdentifier + com.jetbrains.PhpStorm + + +EOF + +status_before=$(HOME="$HOME" "$ROOT_DIR/bin/code-notify" click-through status 2>&1 || true) +printf '%s' "$status_before" | grep -q "click-through add" || fail "status should guide users to add a mapping when none exists" + +add_output=$(printf 'fake_term\n' | HOME="$HOME" "$ROOT_DIR/bin/code-notify" click-through add "$fake_app" 2>&1) +printf '%s' "$add_output" | grep -q "Saved: TERM_PROGRAM=fake_term" || fail "add should persist the requested TERM_PROGRAM" + +[[ -f "$config_file" ]] || fail "click-through config was not created" +grep -q '^fake_term=com.example.fakecodex$' "$config_file" || fail "config file did not store the mapping" + +repeat_add_output=$( + HOME="$HOME" \ + TERM_PROGRAM="fake_term" \ + CODE_NOTIFY_CLICK_THROUGH_APP_PATH="$fake_app" \ + "$ROOT_DIR/bin/code-notify" click-through add 2>&1 +) +printf '%s' "$repeat_add_output" | grep -q "Mapping already exists" || fail "auto-detected add should stop when the current app is already mapped" +printf '%s' "$repeat_add_output" | grep -q "click-through remove" || fail "auto-detected add should point users to remove when a mapping already exists" +[[ "$(grep -c '^fake_term=com.example.fakecodex$' "$config_file")" -eq 1 ]] || fail "repeat add should not duplicate an existing mapping" + +status_after=$(HOME="$HOME" "$ROOT_DIR/bin/code-notify" click-through status 2>&1) +printf '%s' "$status_after" | grep -q "fake_term" || fail "status should show the saved TERM_PROGRAM" +printf '%s' "$status_after" | grep -q "com.example.fakecodex" || fail "status should show the saved bundle ID" + +PATH="$fake_bin:/usr/bin:/bin:/usr/sbin:/sbin" \ +HOME="$HOME" \ +TERM_PROGRAM="fake_term" \ +bash "$NOTIFIER" test >/dev/null 2>&1 + +grep -q -- "-activate com.example.fakecodex" "$notification_log" || fail "notifier should activate the configured bundle ID" + +remove_output=$(printf ' \n' | HOME="$HOME" "$ROOT_DIR/bin/code-notify" click-through remove 2>&1) +printf '%s' "$remove_output" | grep -q "Removed 1 mapping" || fail "interactive remove should delete the selected mapping" +printf '%s' "$remove_output" | grep -q "fake_term" || fail "interactive remove should report the removed TERM_PROGRAM" + +status_final=$(HOME="$HOME" "$ROOT_DIR/bin/code-notify" click-through status 2>&1 || true) +printf '%s' "$status_final" | grep -q "No click-through mappings found" || fail "status should return to the empty-state message after removal" + +: > "$notification_log" +cat > "$config_file" <<'EOF' +# Code-Notify click-through configuration +# Maps TERM_PROGRAM values to macOS bundle IDs + +jb_jediterm=com.jetbrains.PhpStorm +EOF + +PATH="$fake_bin:/usr/bin:/bin:/usr/sbin:/sbin" \ +HOME="$HOME" \ +TERM_PROGRAM="" \ +CODE_NOTIFY_CLICK_THROUGH_APP_PATH="$phpstorm_app" \ +bash "$NOTIFIER" test >/dev/null 2>&1 + +grep -q -- "-activate com.jetbrains.PhpStorm" "$notification_log" || fail "empty TERM_PROGRAM should still honor configured embedded-terminal mappings" + +rm -f "$config_file" +jetbrains_add_output=$( + printf '\n' | \ + HOME="$HOME" \ + TERM_PROGRAM="JetBrains-JediTerm" \ + "$ROOT_DIR/bin/code-notify" click-through add FakeCodex 2>&1 +) +printf '%s' "$jetbrains_add_output" | grep -q "Saved: TERM_PROGRAM=JetBrains-JediTerm" || fail "add should prefer the live TERM_PROGRAM over a guessed app key" +grep -q '^JetBrains-JediTerm=com.example.fakecodex$' "$config_file" || fail "named add should persist the live TERM_PROGRAM value" +if grep -q '^fakecodex=' "$config_file"; then + fail "named add should not persist a guessed app key when TERM_PROGRAM is available" +fi + +cancel_output=$(printf 'q' | HOME="$HOME" "$ROOT_DIR/bin/code-notify" click-through remove 2>&1 || true) +printf '%s' "$cancel_output" | grep -q "Cancelled" || fail "interactive remove should allow quitting without changes" +grep -q '^JetBrains-JediTerm=com.example.fakecodex$' "$config_file" || fail "quit should leave the existing mapping intact" + +pass "click-through commands manage mappings, reviewer edge cases, interactive removal, and notifier activation"