From 8ff3fa3ef7e9575c5d588e560b4dee96d8194752 Mon Sep 17 00:00:00 2001 From: Jacky Wong <29943110+jw-12138@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:01:06 +0800 Subject: [PATCH 1/4] feat: add click-through commands for macOS notifications --- bin/code-notify | 10 + lib/code-notify/commands/global.sh | 4 + lib/code-notify/core/notifier.sh | 27 +- lib/code-notify/utils/click-through.sh | 422 +++++++++++++++++++++++++ lib/code-notify/utils/help.sh | 37 +++ scripts/run_tests.sh | 8 + tests/test-click-through.sh | 71 +++++ 7 files changed, 576 insertions(+), 3 deletions(-) create mode 100644 lib/code-notify/utils/click-through.sh create mode 100644 tests/test-click-through.sh 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..eaea027 100755 --- a/lib/code-notify/core/notifier.sh +++ b/lib/code-notify/core/notifier.sh @@ -328,16 +328,37 @@ fi # Get terminal bundle ID for macOS activation get_terminal_bundle_id() { - case "${TERM_PROGRAM:-}" in + local term_prog="${TERM_PROGRAM:-}" + local config_file="${CODE_NOTIFY_HOME:-$HOME/.code-notify}/click-through.conf" + local line key value + + if [[ -n "$term_prog" ]] && [[ -f "$config_file" ]]; then + while IFS= read -r line; do + [[ -z "$line" ]] && continue + [[ "$line" == \#* ]] && continue + key="${line%%=*}" + value="${line#*=}" + if [[ "$key" == "$term_prog" ]]; then + printf '%s\n' "$value" + return + fi + done < "$config_file" + fi + + case "$term_prog" in + "ghostty") echo "com.mitchellh.ghostty" ;; "iTerm.app") echo "com.googlecode.iterm2" ;; "Apple_Terminal") echo "com.apple.Terminal" ;; "vscode") echo "com.microsoft.VSCode" ;; + "cursor") echo "com.todesktop.230313mzl4w4u92" ;; + "zed") echo "dev.zed.Zed" ;; "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 + 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" diff --git a/lib/code-notify/utils/click-through.sh b/lib/code-notify/utils/click-through.sh new file mode 100644 index 0000000..988f2ba --- /dev/null +++ b/lib/code-notify/utils/click-through.sh @@ -0,0 +1,422 @@ +#!/bin/bash + +# Click-through configuration for macOS notifications. +# Maps TERM_PROGRAM values to bundle IDs used by terminal-notifier -activate. + +CLICK_THROUGH_CONFIG="${CODE_NOTIFY_HOME:-$HOME/.code-notify}/click-through.conf" + +click_through_guess_term_program() { + case "$1" in + com.mitchellh.ghostty) echo "ghostty" ;; + com.googlecode.iterm2) echo "iTerm.app" ;; + com.apple.Terminal) echo "Apple_Terminal" ;; + com.microsoft.VSCode|com.microsoft.VSCodeInsiders|com.vscodium) echo "vscode" ;; + com.todesktop.230313mzl4w4u92) echo "cursor" ;; + dev.zed.Zed) echo "zed" ;; + com.github.wez.wezterm) echo "WezTerm" ;; + org.alacritty) echo "Alacritty" ;; + co.zeit.hyper) echo "Hyper" ;; + dev.warp.Warp-Stable) echo "WarpTerminal" ;; + net.kovidgoyal.kitty) echo "kitty" ;; + com.apple.dt.Xcode) echo "Xcode" ;; + *) + local fallback="${2:-app}" + printf '%s\n' "$fallback" | tr '[:upper:]' '[:lower:]' | tr ' ' '_' | tr -cd '[:alnum:]_.-' + ;; + esac +} + +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_each_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_has_entries() { + local line + while IFS= read -r line; do + [[ -n "$line" ]] && return 0 + done < <(click_through_each_entry) + return 1 +} + +click_through_lookup_bundle_id() { + local term_prog="$1" + local line key value + + while IFS= read -r line; do + key="${line%%=*}" + value="${line#*=}" + if [[ "$key" == "$term_prog" ]]; then + printf '%s\n' "$value" + return 0 + fi + done < <(click_through_each_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" +} + +upsert_click_through_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_entry) + + [[ -n "$entries" ]] && entries+=$'\n' + entries+="${term_prog}=${bundle_id}" + click_through_write_entries "$entries" +} + +remove_click_through_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_entry) + + if [[ $removed -ne 0 ]]; then + return 1 + fi + + click_through_write_entries "$entries" + return 0 +} + +detect_parent_app_path() { + local pid=$$ + local parent command app_path + + 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 +} + +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_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 + + echo "" + header " Add Click-Through App" + + if [[ -z "$query" ]]; then + app_path=$(detect_parent_app_path 2>/dev/null || true) + if [[ -n "$app_path" ]]; then + query="$app_path" + 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) + if [[ -n "${TERM_PROGRAM:-}" ]] && [[ "$query" == "$app_path" ]]; then + default_term="${TERM_PROGRAM}" + else + default_term=$(click_through_guess_term_program "$bundle_id" "$app_name") + 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; } + + upsert_click_through_entry "$term_prog" "$bundle_id" + echo "" + success "Saved: TERM_PROGRAM=${term_prog} -> ${app_name} (${bundle_id})" +} + +run_click_through_remove() { + local target="${1:-}" + local -a terms=() + local -a bundles=() + local line choice + + if ! click_through_has_entries; then + info "No click-through mappings to remove." + return 0 + fi + + if [[ -n "$target" ]]; then + if remove_click_through_entry "$target"; then + success "Removed: $target" + return 0 + fi + error "No mapping found for: $target" + return 1 + fi + + while IFS= read -r line; do + terms+=("${line%%=*}") + bundles+=("${line#*=}") + done < <(click_through_each_entry) + + echo "" + header " Remove Click-Through Entry" + echo "" + + local idx + for idx in "${!terms[@]}"; do + printf ' %s%2d)%s %-20s %s%s%s\n' \ + "$BOLD" "$((idx + 1))" "$RESET" \ + "${terms[$idx]}" \ + "$DIM" "${bundles[$idx]}" "$RESET" + done + + echo "" + printf ' Select to remove [1-%d] (or q to cancel): ' "${#terms[@]}" + read -r choice + + case "$choice" in + q|Q|"") + dim " Cancelled." + return 0 + ;; + esac + + if [[ "$choice" -lt 1 ]] || [[ "$choice" -gt ${#terms[@]} ]] 2>/dev/null; then + error "Invalid selection." + return 1 + fi + + if remove_click_through_entry "${terms[$((choice - 1))]}"; then + success "Removed: ${terms[$((choice - 1))]} -> ${bundles[$((choice - 1))]}" + return 0 + fi + + error "Failed to remove mapping." + return 1 +} + +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} [target] Remove a mapping by TERM_PROGRAM or bundle ID + ${GREEN}reset${RESET} Remove all custom mappings + ${GREEN}help${RESET} Show this help text + +${BOLD}EXAMPLES:${RESET} + cn click-through + cn click-through add + cn click-through add Ghostty + cn click-through remove ghostty + 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 "${1:-}" + ;; + "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..fee20bd 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,20 @@ ${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} Remove a mapping + ${GREEN}click-through reset${RESET} Reset to built-in defaults +EOF + fi + + cat << EOF ${BOLD}ALIASES:${RESET} ${CYAN}cn${RESET} Main command @@ -87,6 +114,16 @@ ${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 +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..d345bc5 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -182,6 +182,14 @@ else test_fail "project settings consistency failed" fi +# Test 18: 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.sh b/tests/test-click-through.sh new file mode 100644 index 0000000..0d451df --- /dev/null +++ b/tests/test-click-through.sh @@ -0,0 +1,71 @@ +#!/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" /tmp/FakeCodex.app' 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" +fake_app="/tmp/FakeCodex.app" + +mkdir -p "$HOME/.code-notify" "$HOME/.claude/notifications" "$HOME/.claude/logs" "$fake_bin" "$fake_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 + +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" + +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=$(HOME="$HOME" "$ROOT_DIR/bin/code-notify" click-through remove fake_term 2>&1) +printf '%s' "$remove_output" | grep -q "Removed: fake_term" || fail "remove should delete the saved mapping" + +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" + +pass "click-through commands manage mappings and feed notifier activation" From 3026aec57e10b3050081d7b3adc684464652fe16 Mon Sep 17 00:00:00 2001 From: Jacky Wong <29943110+jw-12138@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:13:21 +0800 Subject: [PATCH 2/4] fix: refine click-through command interactions --- lib/code-notify/utils/click-through.sh | 190 ++++++++++++++++++++----- lib/code-notify/utils/help.sh | 5 +- tests/test-click-through.sh | 22 ++- 3 files changed, 175 insertions(+), 42 deletions(-) diff --git a/lib/code-notify/utils/click-through.sh b/lib/code-notify/utils/click-through.sh index 988f2ba..02d8275 100644 --- a/lib/code-notify/utils/click-through.sh +++ b/lib/code-notify/utils/click-through.sh @@ -69,6 +69,22 @@ click_through_lookup_bundle_id() { return 1 } +click_through_lookup_term_program_by_bundle_id() { + local bundle_id="$1" + local line key value + + while IFS= read -r line; do + key="${line%%=*}" + value="${line#*=}" + if [[ "$value" == "$bundle_id" ]]; then + printf '%s\n' "$key" + return 0 + fi + done < <(click_through_each_entry) + + return 1 +} + click_through_write_entries() { local entries="$1" mkdir -p "$(dirname "$CLICK_THROUGH_CONFIG")" @@ -133,6 +149,11 @@ 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 @@ -257,6 +278,7 @@ 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_bundle existing_term echo "" header " Add Click-Through App" @@ -265,6 +287,7 @@ run_click_through_add() { app_path=$(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 @@ -288,6 +311,26 @@ run_click_through_add() { default_term=$(click_through_guess_term_program "$bundle_id" "$app_name") fi + if [[ "$auto_detected" -eq 1 ]] && [[ -n "${TERM_PROGRAM:-}" ]]; then + existing_bundle=$(click_through_lookup_bundle_id "${TERM_PROGRAM}" || true) + if [[ "$existing_bundle" == "$bundle_id" ]]; then + echo "" + info "Mapping already exists: ${BOLD}${TERM_PROGRAM}${RESET} -> ${DIM}${bundle_id}${RESET}" + dim " Run ${BOLD}cn click-through remove${RESET} to delete it." + return 0 + fi + fi + + if [[ "$auto_detected" -eq 1 ]] && [[ -z "${TERM_PROGRAM:-}" ]]; then + existing_term=$(click_through_lookup_term_program_by_bundle_id "$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}" @@ -305,66 +348,135 @@ run_click_through_add() { 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 target="${1:-}" local -a terms=() local -a bundles=() - local line choice + local -a selected=() + local line if ! click_through_has_entries; then info "No click-through mappings to remove." return 0 fi - if [[ -n "$target" ]]; then - if remove_click_through_entry "$target"; then - success "Removed: $target" - return 0 - fi - error "No mapping found for: $target" - return 1 - fi - while IFS= read -r line; do terms+=("${line%%=*}") bundles+=("${line#*=}") + selected+=(0) done < <(click_through_each_entry) echo "" - header " Remove Click-Through Entry" + header " Remove Click-Through Entries" + echo "" + dim " Up/Down move Space toggle Enter remove q cancel" echo "" - local idx + local idx current=0 total="${#terms[@]}" for idx in "${!terms[@]}"; do - printf ' %s%2d)%s %-20s %s%s%s\n' \ - "$BOLD" "$((idx + 1))" "$RESET" \ - "${terms[$idx]}" \ - "$DIM" "${bundles[$idx]}" "$RESET" + 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 - echo "" - printf ' Select to remove [1-%d] (or q to cancel): ' "${#terms[@]}" - read -r choice - - case "$choice" in - q|Q|"") - 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 - if [[ "$choice" -lt 1 ]] || [[ "$choice" -gt ${#terms[@]} ]] 2>/dev/null; then - error "Invalid selection." - return 1 - fi + 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 remove_click_through_entry "${terms[$((choice - 1))]}"; then - success "Removed: ${terms[$((choice - 1))]} -> ${bundles[$((choice - 1))]}" + if [[ "$selected_count" -eq 0 ]]; then + echo "" + echo "" + info "No mappings selected." return 0 fi - error "Failed to remove mapping." - return 1 + click_through_write_entries "$entries" + echo "" + echo "" + success "Removed ${selected_count} mapping(s): ${removed_terms}" } show_click_through_help() { @@ -378,15 +490,17 @@ ${BOLD}USAGE:${RESET} ${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} [target] Remove a mapping by TERM_PROGRAM or bundle ID + ${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 ghostty + cn click-through remove cn click-through reset EOF @@ -404,7 +518,7 @@ handle_click_through_command() { run_click_through_add "${1:-}" ;; "remove"|"rm") - run_click_through_remove "${1:-}" + run_click_through_remove ;; "reset") rm -f "$CLICK_THROUGH_CONFIG" diff --git a/lib/code-notify/utils/help.sh b/lib/code-notify/utils/help.sh index fee20bd..7ac1bf1 100644 --- a/lib/code-notify/utils/help.sh +++ b/lib/code-notify/utils/help.sh @@ -88,8 +88,10 @@ 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} Remove a 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 @@ -120,6 +122,7 @@ EOF 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 diff --git a/tests/test-click-through.sh b/tests/test-click-through.sh index 0d451df..18cffc9 100644 --- a/tests/test-click-through.sh +++ b/tests/test-click-through.sh @@ -51,6 +51,16 @@ printf '%s' "$add_output" | grep -q "Saved: TERM_PROGRAM=fake_term" || fail "add [[ -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" @@ -62,10 +72,16 @@ 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=$(HOME="$HOME" "$ROOT_DIR/bin/code-notify" click-through remove fake_term 2>&1) -printf '%s' "$remove_output" | grep -q "Removed: fake_term" || fail "remove should delete the saved mapping" +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" -pass "click-through commands manage mappings and feed notifier activation" +printf 'fake_term\n' | HOME="$HOME" "$ROOT_DIR/bin/code-notify" click-through add "$fake_app" >/dev/null 2>&1 +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 '^fake_term=com.example.fakecodex$' "$config_file" || fail "quit should leave the existing mapping intact" + +pass "click-through commands manage mappings, interactive removal, and notifier activation" From dc0de11fe74531e3d8f65e5384937ff9902bc3e9 Mon Sep 17 00:00:00 2001 From: Jacky Wong <29943110+jw-12138@users.noreply.github.com> Date: Tue, 31 Mar 2026 00:22:17 +0800 Subject: [PATCH 3/4] fix: align click-through TERM_PROGRAM lookup --- lib/code-notify/core/notifier.sh | 54 ++--- lib/code-notify/utils/click-through-common.sh | 207 ++++++++++++++++++ lib/code-notify/utils/click-through.sh | 119 +--------- tests/test-click-through.sh | 53 ++++- 4 files changed, 280 insertions(+), 153 deletions(-) create mode 100644 lib/code-notify/utils/click-through-common.sh diff --git a/lib/code-notify/core/notifier.sh b/lib/code-notify/core/notifier.sh index eaea027..60ac512 100755 --- a/lib/code-notify/core/notifier.sh +++ b/lib/code-notify/core/notifier.sh @@ -15,6 +15,7 @@ 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-common.sh" has_jq() { command -v jq >/dev/null 2>&1 @@ -328,45 +329,24 @@ fi # Get terminal bundle ID for macOS activation get_terminal_bundle_id() { - local term_prog="${TERM_PROGRAM:-}" - local config_file="${CODE_NOTIFY_HOME:-$HOME/.code-notify}/click-through.conf" - local line key value - - if [[ -n "$term_prog" ]] && [[ -f "$config_file" ]]; then - while IFS= read -r line; do - [[ -z "$line" ]] && continue - [[ "$line" == \#* ]] && continue - key="${line%%=*}" - value="${line#*=}" - if [[ "$key" == "$term_prog" ]]; then - printf '%s\n' "$value" - return - fi - done < "$config_file" + local term_prog bundle_id + + bundle_id=$(click_through_lookup_bundle_id_for_current_context || true) + if [[ -n "$bundle_id" ]]; then + printf '%s\n' "$bundle_id" + return fi - case "$term_prog" in - "ghostty") echo "com.mitchellh.ghostty" ;; - "iTerm.app") echo "com.googlecode.iterm2" ;; - "Apple_Terminal") echo "com.apple.Terminal" ;; - "vscode") echo "com.microsoft.VSCode" ;; - "cursor") echo "com.todesktop.230313mzl4w4u92" ;; - "zed") echo "dev.zed.Zed" ;; - "WezTerm") echo "com.github.wez.wezterm" ;; - "Alacritty") echo "org.alacritty" ;; - "Hyper") echo "co.zeit.hyper" ;; - *) - 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 - ;; - esac + term_prog=$(click_through_get_runtime_term_program || true) + if [[ -n "$term_prog" ]]; then + bundle_id=$(click_through_get_builtin_bundle_id_for_term_program "$term_prog" || true) + if [[ -n "$bundle_id" ]]; then + printf '%s\n' "$bundle_id" + return + fi + fi + + click_through_get_fallback_bundle_id } # Function to send notification on macOS diff --git a/lib/code-notify/utils/click-through-common.sh b/lib/code-notify/utils/click-through-common.sh new file mode 100644 index 0000000..f1f5a55 --- /dev/null +++ b/lib/code-notify/utils/click-through-common.sh @@ -0,0 +1,207 @@ +#!/bin/bash + +CLICK_THROUGH_CONFIG="${CODE_NOTIFY_HOME:-$HOME/.code-notify}/click-through.conf" + +click_through_guess_term_program() { + case "$1" in + com.mitchellh.ghostty) echo "ghostty" ;; + com.googlecode.iterm2) echo "iTerm.app" ;; + com.apple.Terminal) echo "Apple_Terminal" ;; + com.microsoft.VSCode|com.microsoft.VSCodeInsiders|com.vscodium) echo "vscode" ;; + com.todesktop.230313mzl4w4u92) echo "cursor" ;; + dev.zed.Zed) echo "zed" ;; + com.github.wez.wezterm) echo "WezTerm" ;; + org.alacritty) echo "Alacritty" ;; + co.zeit.hyper) echo "Hyper" ;; + dev.warp.Warp-Stable) echo "WarpTerminal" ;; + net.kovidgoyal.kitty) echo "kitty" ;; + com.apple.dt.Xcode) echo "Xcode" ;; + *) + local fallback="${2:-app}" + printf '%s\n' "$fallback" | tr '[:upper:]' '[:lower:]' | tr ' ' '_' | tr -cd '[:alnum:]_.-' + ;; + esac +} + +click_through_get_builtin_bundle_id_for_term_program() { + case "$1" in + "ghostty") echo "com.mitchellh.ghostty" ;; + "iTerm.app") echo "com.googlecode.iterm2" ;; + "Apple_Terminal") echo "com.apple.Terminal" ;; + "vscode") echo "com.microsoft.VSCode" ;; + "cursor") echo "com.todesktop.230313mzl4w4u92" ;; + "zed") echo "dev.zed.Zed" ;; + "WezTerm") echo "com.github.wez.wezterm" ;; + "Alacritty") echo "org.alacritty" ;; + "Hyper") echo "co.zeit.hyper" ;; + *) + return 1 + ;; + esac +} + +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 +} + +click_through_each_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_bundle_id() { + local term_prog="$1" + local line key value + + while IFS= read -r line; do + key="${line%%=*}" + value="${line#*=}" + if [[ "$key" == "$term_prog" ]]; then + printf '%s\n' "$value" + return 0 + fi + done < <(click_through_each_entry) + + return 1 +} + +click_through_lookup_term_program_by_bundle_id() { + local bundle_id="$1" + local line key value + + while IFS= read -r line; do + key="${line%%=*}" + value="${line#*=}" + if [[ "$value" == "$bundle_id" ]]; then + printf '%s\n' "$key" + return 0 + fi + done < <(click_through_each_entry) + + return 1 +} + +click_through_lookup_bundle_id_for_current_context() { + local term_prog bundle_id + + term_prog=$(click_through_get_runtime_term_program || true) + if [[ -n "$term_prog" ]]; then + bundle_id=$(click_through_lookup_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_term_program_by_bundle_id "$bundle_id" >/dev/null 2>&1; then + printf '%s\n' "$bundle_id" + return 0 + fi + + return 1 +} + +click_through_get_preferred_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_term_program_by_bundle_id "$bundle_id" || true) + if [[ -n "$term_prog" ]]; then + printf '%s\n' "$term_prog" + return 0 + fi + fi + + click_through_guess_term_program "$bundle_id" "$app_name" +} diff --git a/lib/code-notify/utils/click-through.sh b/lib/code-notify/utils/click-through.sh index 02d8275..769ae7d 100644 --- a/lib/code-notify/utils/click-through.sh +++ b/lib/code-notify/utils/click-through.sh @@ -3,47 +3,8 @@ # Click-through configuration for macOS notifications. # Maps TERM_PROGRAM values to bundle IDs used by terminal-notifier -activate. -CLICK_THROUGH_CONFIG="${CODE_NOTIFY_HOME:-$HOME/.code-notify}/click-through.conf" - -click_through_guess_term_program() { - case "$1" in - com.mitchellh.ghostty) echo "ghostty" ;; - com.googlecode.iterm2) echo "iTerm.app" ;; - com.apple.Terminal) echo "Apple_Terminal" ;; - com.microsoft.VSCode|com.microsoft.VSCodeInsiders|com.vscodium) echo "vscode" ;; - com.todesktop.230313mzl4w4u92) echo "cursor" ;; - dev.zed.Zed) echo "zed" ;; - com.github.wez.wezterm) echo "WezTerm" ;; - org.alacritty) echo "Alacritty" ;; - co.zeit.hyper) echo "Hyper" ;; - dev.warp.Warp-Stable) echo "WarpTerminal" ;; - net.kovidgoyal.kitty) echo "kitty" ;; - com.apple.dt.Xcode) echo "Xcode" ;; - *) - local fallback="${2:-app}" - printf '%s\n' "$fallback" | tr '[:upper:]' '[:lower:]' | tr ' ' '_' | tr -cd '[:alnum:]_.-' - ;; - esac -} - -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_each_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_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$CLICK_THROUGH_DIR/click-through-common.sh" click_through_has_entries() { local line @@ -53,38 +14,6 @@ click_through_has_entries() { return 1 } -click_through_lookup_bundle_id() { - local term_prog="$1" - local line key value - - while IFS= read -r line; do - key="${line%%=*}" - value="${line#*=}" - if [[ "$key" == "$term_prog" ]]; then - printf '%s\n' "$value" - return 0 - fi - done < <(click_through_each_entry) - - return 1 -} - -click_through_lookup_term_program_by_bundle_id() { - local bundle_id="$1" - local line key value - - while IFS= read -r line; do - key="${line%%=*}" - value="${line#*=}" - if [[ "$value" == "$bundle_id" ]]; then - printf '%s\n' "$key" - return 0 - fi - done < <(click_through_each_entry) - - return 1 -} - click_through_write_entries() { local entries="$1" mkdir -p "$(dirname "$CLICK_THROUGH_CONFIG")" @@ -145,33 +74,6 @@ remove_click_through_entry() { return 0 } -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 -} - collect_click_through_search_results() { local query="$1" local line candidate query_lower seen="" @@ -284,7 +186,7 @@ run_click_through_add() { header " Add Click-Through App" if [[ -z "$query" ]]; then - app_path=$(detect_parent_app_path 2>/dev/null || true) + app_path=$(click_through_detect_parent_app_path 2>/dev/null || true) if [[ -n "$app_path" ]]; then query="$app_path" auto_detected=1 @@ -305,23 +207,20 @@ run_click_through_add() { [[ -n "$bundle_id" ]] || { error "Could not read bundle ID from: $app_path"; return 1; } app_name=$(basename "$app_path" .app) - if [[ -n "${TERM_PROGRAM:-}" ]] && [[ "$query" == "$app_path" ]]; then - default_term="${TERM_PROGRAM}" - else - default_term=$(click_through_guess_term_program "$bundle_id" "$app_name") - fi + default_term=$(click_through_get_preferred_term_program "$bundle_id" "$app_name") - if [[ "$auto_detected" -eq 1 ]] && [[ -n "${TERM_PROGRAM:-}" ]]; then - existing_bundle=$(click_through_lookup_bundle_id "${TERM_PROGRAM}" || true) + term_prog=$(click_through_get_runtime_term_program || true) + if [[ "$auto_detected" -eq 1 ]] && [[ -n "$term_prog" ]]; then + existing_bundle=$(click_through_lookup_bundle_id "$term_prog" || true) if [[ "$existing_bundle" == "$bundle_id" ]]; then echo "" - info "Mapping already exists: ${BOLD}${TERM_PROGRAM}${RESET} -> ${DIM}${bundle_id}${RESET}" + info "Mapping already exists: ${BOLD}${term_prog}${RESET} -> ${DIM}${bundle_id}${RESET}" dim " Run ${BOLD}cn click-through remove${RESET} to delete it." return 0 fi fi - if [[ "$auto_detected" -eq 1 ]] && [[ -z "${TERM_PROGRAM:-}" ]]; then + if [[ "$auto_detected" -eq 1 ]] && [[ -z "$term_prog" ]]; then existing_term=$(click_through_lookup_term_program_by_bundle_id "$bundle_id" || true) if [[ -n "$existing_term" ]]; then echo "" diff --git a/tests/test-click-through.sh b/tests/test-click-through.sh index 18cffc9..914f4b4 100644 --- a/tests/test-click-through.sh +++ b/tests/test-click-through.sh @@ -15,15 +15,17 @@ if [[ "$(uname -s)" != "Darwin" ]]; then fi test_dir="$(mktemp -d)" -trap 'rm -rf "$test_dir" /tmp/FakeCodex.app' EXIT +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" -fake_app="/tmp/FakeCodex.app" +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" +mkdir -p "$HOME/.code-notify" "$HOME/.claude/notifications" "$HOME/.claude/logs" "$fake_bin" "$fake_app/Contents" "$phpstorm_app/Contents" cat > "$fake_bin/terminal-notifier" < "$fake_app/Contents/Info.plist" <<'EOF' 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" @@ -79,9 +92,37 @@ printf '%s' "$remove_output" | grep -q "fake_term" || fail "interactive remove s 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" -printf 'fake_term\n' | HOME="$HOME" "$ROOT_DIR/bin/code-notify" click-through add "$fake_app" >/dev/null 2>&1 +: > "$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 '^fake_term=com.example.fakecodex$' "$config_file" || fail "quit should leave the existing mapping intact" +grep -q '^JetBrains-JediTerm=com.example.fakecodex$' "$config_file" || fail "quit should leave the existing mapping intact" -pass "click-through commands manage mappings, interactive removal, and notifier activation" +pass "click-through commands manage mappings, reviewer edge cases, interactive removal, and notifier activation" From 56dd8ff6daea6461dc15df1a27767eabb596e688 Mon Sep 17 00:00:00 2001 From: Jacky Wong <29943110+jw-12138@users.noreply.github.com> Date: Tue, 31 Mar 2026 00:36:14 +0800 Subject: [PATCH 4/4] refactor: split click-through resolution layers --- lib/code-notify/core/notifier.sh | 23 +- lib/code-notify/utils/click-through-common.sh | 207 ------------------ .../utils/click-through-resolver.sh | 96 ++++++++ .../utils/click-through-runtime.sh | 83 +++++++ lib/code-notify/utils/click-through-store.sh | 152 +++++++++++++ lib/code-notify/utils/click-through.sh | 97 +------- scripts/run_tests.sh | 10 +- tests/test-click-through-resolver.sh | 74 +++++++ 8 files changed, 428 insertions(+), 314 deletions(-) delete mode 100644 lib/code-notify/utils/click-through-common.sh create mode 100644 lib/code-notify/utils/click-through-resolver.sh create mode 100644 lib/code-notify/utils/click-through-runtime.sh create mode 100644 lib/code-notify/utils/click-through-store.sh create mode 100644 tests/test-click-through-resolver.sh diff --git a/lib/code-notify/core/notifier.sh b/lib/code-notify/core/notifier.sh index 60ac512..c902090 100755 --- a/lib/code-notify/core/notifier.sh +++ b/lib/code-notify/core/notifier.sh @@ -15,7 +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-common.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 @@ -329,24 +331,7 @@ fi # Get terminal bundle ID for macOS activation get_terminal_bundle_id() { - local term_prog bundle_id - - bundle_id=$(click_through_lookup_bundle_id_for_current_context || true) - if [[ -n "$bundle_id" ]]; then - printf '%s\n' "$bundle_id" - return - fi - - term_prog=$(click_through_get_runtime_term_program || true) - if [[ -n "$term_prog" ]]; then - bundle_id=$(click_through_get_builtin_bundle_id_for_term_program "$term_prog" || true) - if [[ -n "$bundle_id" ]]; then - printf '%s\n' "$bundle_id" - return - fi - fi - - click_through_get_fallback_bundle_id + click_through_resolve_activation_bundle_id } # Function to send notification on macOS diff --git a/lib/code-notify/utils/click-through-common.sh b/lib/code-notify/utils/click-through-common.sh deleted file mode 100644 index f1f5a55..0000000 --- a/lib/code-notify/utils/click-through-common.sh +++ /dev/null @@ -1,207 +0,0 @@ -#!/bin/bash - -CLICK_THROUGH_CONFIG="${CODE_NOTIFY_HOME:-$HOME/.code-notify}/click-through.conf" - -click_through_guess_term_program() { - case "$1" in - com.mitchellh.ghostty) echo "ghostty" ;; - com.googlecode.iterm2) echo "iTerm.app" ;; - com.apple.Terminal) echo "Apple_Terminal" ;; - com.microsoft.VSCode|com.microsoft.VSCodeInsiders|com.vscodium) echo "vscode" ;; - com.todesktop.230313mzl4w4u92) echo "cursor" ;; - dev.zed.Zed) echo "zed" ;; - com.github.wez.wezterm) echo "WezTerm" ;; - org.alacritty) echo "Alacritty" ;; - co.zeit.hyper) echo "Hyper" ;; - dev.warp.Warp-Stable) echo "WarpTerminal" ;; - net.kovidgoyal.kitty) echo "kitty" ;; - com.apple.dt.Xcode) echo "Xcode" ;; - *) - local fallback="${2:-app}" - printf '%s\n' "$fallback" | tr '[:upper:]' '[:lower:]' | tr ' ' '_' | tr -cd '[:alnum:]_.-' - ;; - esac -} - -click_through_get_builtin_bundle_id_for_term_program() { - case "$1" in - "ghostty") echo "com.mitchellh.ghostty" ;; - "iTerm.app") echo "com.googlecode.iterm2" ;; - "Apple_Terminal") echo "com.apple.Terminal" ;; - "vscode") echo "com.microsoft.VSCode" ;; - "cursor") echo "com.todesktop.230313mzl4w4u92" ;; - "zed") echo "dev.zed.Zed" ;; - "WezTerm") echo "com.github.wez.wezterm" ;; - "Alacritty") echo "org.alacritty" ;; - "Hyper") echo "co.zeit.hyper" ;; - *) - return 1 - ;; - esac -} - -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 -} - -click_through_each_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_bundle_id() { - local term_prog="$1" - local line key value - - while IFS= read -r line; do - key="${line%%=*}" - value="${line#*=}" - if [[ "$key" == "$term_prog" ]]; then - printf '%s\n' "$value" - return 0 - fi - done < <(click_through_each_entry) - - return 1 -} - -click_through_lookup_term_program_by_bundle_id() { - local bundle_id="$1" - local line key value - - while IFS= read -r line; do - key="${line%%=*}" - value="${line#*=}" - if [[ "$value" == "$bundle_id" ]]; then - printf '%s\n' "$key" - return 0 - fi - done < <(click_through_each_entry) - - return 1 -} - -click_through_lookup_bundle_id_for_current_context() { - local term_prog bundle_id - - term_prog=$(click_through_get_runtime_term_program || true) - if [[ -n "$term_prog" ]]; then - bundle_id=$(click_through_lookup_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_term_program_by_bundle_id "$bundle_id" >/dev/null 2>&1; then - printf '%s\n' "$bundle_id" - return 0 - fi - - return 1 -} - -click_through_get_preferred_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_term_program_by_bundle_id "$bundle_id" || true) - if [[ -n "$term_prog" ]]; then - printf '%s\n' "$term_prog" - return 0 - fi - fi - - click_through_guess_term_program "$bundle_id" "$app_name" -} 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 index 769ae7d..3d24beb 100644 --- a/lib/code-notify/utils/click-through.sh +++ b/lib/code-notify/utils/click-through.sh @@ -4,75 +4,9 @@ # 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-common.sh" - -click_through_has_entries() { - local line - while IFS= read -r line; do - [[ -n "$line" ]] && return 0 - done < <(click_through_each_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" -} - -upsert_click_through_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_entry) - - [[ -n "$entries" ]] && entries+=$'\n' - entries+="${term_prog}=${bundle_id}" - click_through_write_entries "$entries" -} - -remove_click_through_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_entry) - - if [[ $removed -ne 0 ]]; then - return 1 - fi - - click_through_write_entries "$entries" - return 0 -} +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" @@ -170,7 +104,7 @@ show_click_through_status() { key="${line%%=*}" value="${line#*=}" printf ' %s%-20s%s -> %s%s%s\n' "$BOLD" "$key" "$RESET" "$DIM" "$value" "$RESET" - done < <(click_through_each_entry) + done < <(click_through_each_config_entry) echo "" dim " Config: ${CLICK_THROUGH_CONFIG}" @@ -180,7 +114,7 @@ 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_bundle existing_term + local auto_detected=0 existing_term echo "" header " Add Click-Through App" @@ -207,21 +141,10 @@ run_click_through_add() { [[ -n "$bundle_id" ]] || { error "Could not read bundle ID from: $app_path"; return 1; } app_name=$(basename "$app_path" .app) - default_term=$(click_through_get_preferred_term_program "$bundle_id" "$app_name") - - term_prog=$(click_through_get_runtime_term_program || true) - if [[ "$auto_detected" -eq 1 ]] && [[ -n "$term_prog" ]]; then - existing_bundle=$(click_through_lookup_bundle_id "$term_prog" || true) - if [[ "$existing_bundle" == "$bundle_id" ]]; then - echo "" - info "Mapping already exists: ${BOLD}${term_prog}${RESET} -> ${DIM}${bundle_id}${RESET}" - dim " Run ${BOLD}cn click-through remove${RESET} to delete it." - return 0 - fi - fi + default_term=$(click_through_resolve_default_term_program "$bundle_id" "$app_name") - if [[ "$auto_detected" -eq 1 ]] && [[ -z "$term_prog" ]]; then - existing_term=$(click_through_lookup_term_program_by_bundle_id "$bundle_id" || true) + 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}" @@ -242,7 +165,7 @@ run_click_through_add() { term_prog="${input:-$default_term}" [[ -n "$term_prog" ]] || { error "TERM_PROGRAM cannot be empty."; return 1; } - upsert_click_through_entry "$term_prog" "$bundle_id" + click_through_upsert_entry "$term_prog" "$bundle_id" echo "" success "Saved: TERM_PROGRAM=${term_prog} -> ${app_name} (${bundle_id})" } @@ -299,7 +222,7 @@ run_click_through_remove() { terms+=("${line%%=*}") bundles+=("${line#*=}") selected+=(0) - done < <(click_through_each_entry) + done < <(click_through_each_config_entry) echo "" header " Remove Click-Through Entries" diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh index d345bc5..e88bd6b 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -182,7 +182,15 @@ else test_fail "project settings consistency failed" fi -# Test 18: click-through commands persist mappings and drive notifier activation +# 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 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"