diff --git a/config/dot/quickshell/scripts/panama-action b/config/dot/quickshell/scripts/panama-action index 7038338..08e0598 100755 --- a/config/dot/quickshell/scripts/panama-action +++ b/config/dot/quickshell/scripts/panama-action @@ -7,18 +7,12 @@ set -euo pipefail script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" action="${1:-}" +helper_dir="${PANAMA_ACTION_HELPER_DIR:-$script_dir}" +confirm_attempts="${PANAMA_ACTION_CONFIRM_ATTEMPTS:-20}" +confirm_delay="${PANAMA_ACTION_CONFIRM_DELAY:-0.1}" -if command -v panama-osd >/dev/null 2>&1; then - osd_command=(panama-osd) -else - osd_command=("$script_dir/panama-osd") -fi - -if command -v panama-prism-gallery >/dev/null 2>&1; then - gallery_command=(panama-prism-gallery) -else - gallery_command=("$script_dir/panama-prism-gallery") -fi +osd_command=("$helper_dir/panama-osd") +gallery_command=("$helper_dir/panama-prism-gallery") report_failure() { local status=$? @@ -35,8 +29,8 @@ trap report_failure EXIT show_state() { local state="$1" on_icon="$2" off_icon="$3" label="$4" case "$state" in - true) "${osd_command[@]}" message "$on_icon" "$label On" ;; - false) "${osd_command[@]}" message "$off_icon" "$label Off" ;; + true) PANAMA_OSD_STRICT=1 "${osd_command[@]}" message "$on_icon" "$label On" ;; + false) PANAMA_OSD_STRICT=1 "${osd_command[@]}" message "$off_icon" "$label Off" ;; *) printf 'Panama action returned an invalid state: %s\n' "$state" >&2 return 1 @@ -44,12 +38,55 @@ show_state() { esac } +state_is_active() { + local target="$1" + case "$target" in + caffeine) + systemd-inhibit --list --no-pager --no-legend 2>/dev/null \ + | awk '$1 == "Panama" && $(NF - 1) == "Caffeine" && $NF == "block" { found = 1 } END { exit !found }' + ;; + night-light) + pgrep -u "$(id -u)" -x hyprsunset >/dev/null 2>&1 + ;; + *) return 2 ;; + esac +} + +wait_for_confirmed_state() { + local target="$1" expected="$2" attempt + for ((attempt = 0; attempt < confirm_attempts; attempt++)); do + if [[ $expected == true ]] && state_is_active "$target"; then + return 0 + fi + if [[ $expected == false ]] && ! state_is_active "$target"; then + return 0 + fi + sleep "$confirm_delay" + done + printf 'Panama action could not confirm %s reached state %s\n' "$target" "$expected" >&2 + return 1 +} + toggle_state() { local target="$1" on_icon="$2" off_icon="$3" label="$4" state state="$(qs ipc call "$target" toggle)" + case "$state" in true|false) ;; *) show_state "$state" "$on_icon" "$off_icon" "$label"; return ;; esac + wait_for_confirmed_state "$target" "$state" show_state "$state" "$on_icon" "$off_icon" "$label" } +wait_for_shell() { + local attempt + for ((attempt = 0; attempt < confirm_attempts; attempt++)); do + if qs ipc show >/dev/null 2>&1; then + return 0 + fi + sleep "$confirm_delay" + done + printf 'Panama shell did not become ready after restart\n' >&2 + return 1 +} + case "$action" in control-center) qs ipc call quicksettings open ;; notifications) qs ipc call notifications open ;; @@ -69,23 +106,30 @@ case "$action" in toggle_state night-light night-light-symbolic night-light-disabled-symbolic "Night Light" ;; focus-start) - qs ipc call focus start - "${osd_command[@]}" message preferences-system-time-symbolic "Focus Session Started" + state="$(qs ipc call focus start)" + [[ $state == true ]] + PANAMA_OSD_STRICT=1 "${osd_command[@]}" message preferences-system-time-symbolic "Focus Session Started" ;; focus-end) - qs ipc call focus end - "${osd_command[@]}" message media-playback-stop-symbolic "Focus Session Ended" + state="$(qs ipc call focus end)" + if [[ $state == true ]]; then + PANAMA_OSD_STRICT=1 "${osd_command[@]}" message media-playback-stop-symbolic "Focus Session Ended" + else + PANAMA_OSD_STRICT=1 "${osd_command[@]}" message dialog-information-symbolic "No Focus Session Running" + fi ;; capture) qs ipc call capture open ;; intelligence) qs ipc call screen-intelligence open ;; screenshot) qs ipc call capture screenNow ;; - microphone) "${osd_command[@]}" microphone toggle ;; + microphone) PANAMA_OSD_STRICT=1 "${osd_command[@]}" microphone toggle ;; gallery) "${gallery_command[@]}" ;; restart-shell) - qs kill + qs kill >/dev/null 2>&1 || true quickshell --daemonize + wait_for_shell + PANAMA_OSD_STRICT=1 "${osd_command[@]}" message view-refresh-symbolic "Panama Restarted" ;; *) printf 'Usage: panama-action {%s}\n' \ diff --git a/config/dot/quickshell/scripts/panama-osd b/config/dot/quickshell/scripts/panama-osd index 3960fdc..001aead 100755 --- a/config/dot/quickshell/scripts/panama-osd +++ b/config/dot/quickshell/scripts/panama-osd @@ -2,12 +2,22 @@ set -u +strict_delivery() { + [[ ${PANAMA_OSD_STRICT:-false} == true || ${PANAMA_OSD_STRICT:-false} == 1 ]] +} + show_progress() { - qs ipc call osd progress "$1" "$2" 100 "$3" >/dev/null 2>&1 || true + if ! qs ipc call osd progress "$1" "$2" 100 "$3" >/dev/null 2>&1; then + strict_delivery && return 1 + fi + return 0 } show_message() { - qs ipc call osd message "$1" "$2" >/dev/null 2>&1 || true + if ! qs ipc call osd message "$1" "$2" >/dev/null 2>&1; then + strict_delivery && return 1 + fi + return 0 } volume_state() { diff --git a/config/dot/quickshell/services/NightLight.qml b/config/dot/quickshell/services/NightLight.qml index ba53d0d..92687b7 100644 --- a/config/dot/quickshell/services/NightLight.qml +++ b/config/dot/quickshell/services/NightLight.qml @@ -26,6 +26,7 @@ Singleton { id: root property bool initialized: false + property bool suppressStatusEvents: false // The manual switch. Ignored while `automatic` is on. property bool enabled: Settings.nightLightEnabledByDefault @@ -53,6 +54,22 @@ Singleton { } } + // Launcher actions already provide immediate Prism OSD feedback. Keep the + // ambient Signal Glass channel quiet for that path so one choice produces + // one confirmation instead of two competing transients. + function toggleQuietly(): void { + root.suppressStatusEvents = true; + root.toggle(); + root.suppressStatusEvents = false; + } + + function restore(manualEnabled: bool, automaticEnabled: bool): void { + root.suppressStatusEvents = true; + root.enabled = manualEnabled; + root.automatic = automaticEnabled; + root.suppressStatusEvents = false; + } + // The window wraps midnight (17:00 → 10:00), so the comparison flips when // `from` is later in the day than `to`. function inWindow(hour: real): bool { @@ -118,7 +135,7 @@ Singleton { } onActiveChanged: { - if (!root.initialized) + if (!root.initialized || root.suppressStatusEvents) return; StatusEvents.publish({ key: "display-night-light", diff --git a/config/dot/quickshell/shell.qml b/config/dot/quickshell/shell.qml index 6af9630..5cb03e0 100644 --- a/config/dot/quickshell/shell.qml +++ b/config/dot/quickshell/shell.qml @@ -114,7 +114,10 @@ ShellRoot { IpcHandler { target: "focus" - function start(): void { FocusSession.startDefault(); } + function start(): bool { + FocusSession.startDefault(); + return FocusSession.active; + } function reveal(): void { FocusSession.reveal(); } function dismiss(): void { FocusSession.dismiss(); } function pause(): void { FocusSession.pauseOrResume(); } @@ -123,7 +126,12 @@ ShellRoot { ShellState.openOverview(FocusSession.workspaceId); FocusSession.dismiss(); } - function end(): void { FocusSession.end(false); } + function end(): bool { + if (!FocusSession.active) + return false; + FocusSession.end(false); + return !FocusSession.active; + } function status(): string { return JSON.stringify({ active: FocusSession.active, @@ -272,10 +280,21 @@ ShellRoot { IpcHandler { target: "night-light" function toggle(): bool { - NightLight.toggle(); + NightLight.toggleQuietly(); return NightLight.active; } function status(): bool { return NightLight.active; } + function settings(): string { + return JSON.stringify({ + enabled: NightLight.enabled, + automatic: NightLight.automatic, + active: NightLight.active + }); + } + function restore(enabled: bool, automatic: bool): bool { + NightLight.restore(enabled, automatic); + return NightLight.active; + } } IpcHandler { diff --git a/install b/install index 47342af..c0a09d5 100755 --- a/install +++ b/install @@ -21,8 +21,9 @@ fi gsettings set org.gnome.desktop.screensaver lock-enabled false gsettings set org.gnome.desktop.session idle-delay 0 -# Run scripts -for script in ~/.local/share/Panama/setup/scripts/*; do source $script; done +# Run each setup stage in its own process. This keeps strict-shell options and +# helper variables local to the script that owns them. +for script in ~/.local/share/Panama/setup/scripts/*; do "$script"; done # Revert to normal idle settings gsettings set org.gnome.desktop.screensaver lock-enabled true diff --git a/setup/scripts/link-dotfiles b/setup/scripts/link-dotfiles index 2d2c5ab..e3eb5a3 100755 --- a/setup/scripts/link-dotfiles +++ b/setup/scripts/link-dotfiles @@ -88,11 +88,6 @@ else log "Linked Tokyo Night Moon theme → $VICINAE_THEME" fi -# Install the Panama command collection into Vicinae's XDG data directory. -# This is intentionally separate from ~/.config/vicinae: script commands are -# user data in Vicinae 0.26, just like custom themes. -"$PANAMA_PATH/setup/scripts/link-vicinae-scripts" - # Panama-native applications live in the user data directory so launchers can # discover them alongside system desktop entries. Keep each authored file in # the repository and expose it with a narrow per-file symlink. diff --git a/setup/scripts/link-vicinae-scripts b/setup/scripts/link-vicinae-scripts index 3054ab2..f7c8982 100755 --- a/setup/scripts/link-vicinae-scripts +++ b/setup/scripts/link-vicinae-scripts @@ -11,14 +11,27 @@ vicinae_data_dir="${VICINAE_DATA_DIR:-$HOME/.local/share/vicinae}" source_dir="$panama_path/config/local/share/vicinae/scripts" scripts_dir="$vicinae_data_dir/scripts" target_dir="$scripts_dir/panama" -backup_dir="$scripts_dir/panama.pre-panama" +backup_root="$vicinae_data_dir/backups" +backup_dir="$backup_root/panama.pre-panama" +legacy_backup="$scripts_dir/panama.pre-panama" [[ -d "$source_dir" ]] || { printf 'Panama command source is missing: %s\n' "$source_dir" >&2 exit 1 } -mkdir -p "$scripts_dir" +mkdir -p "$scripts_dir" "$backup_root" + +# Older Panama builds preserved this directory alongside searchable commands. +# Move it out before reloading so those scripts cannot appear twice. +if [[ -e "$legacy_backup" ]]; then + if [[ -e "$backup_dir" ]]; then + printf 'Refusing to move %s; backup already exists at %s\n' \ + "$legacy_backup" "$backup_dir" >&2 + exit 1 + fi + mv "$legacy_backup" "$backup_dir" +fi if [[ -L "$target_dir" ]]; then rm "$target_dir" diff --git a/tests/quickshell/osd-helper-contract.sh b/tests/quickshell/osd-helper-contract.sh index 3dca564..328a610 100755 --- a/tests/quickshell/osd-helper-contract.sh +++ b/tests/quickshell/osd-helper-contract.sh @@ -47,12 +47,16 @@ cat >"$scratch/bin/qs" <<'SH' printf 'qs' >>"$OSD_TEST_LOG" printf ' <%s>' "$@" >>"$OSD_TEST_LOG" printf '\n' >>"$OSD_TEST_LOG" +[[ ${OSD_TEST_FAIL_QS:-false} != true ]] SH chmod +x "$scratch/bin/"* run_helper() { - PATH="$scratch/bin:$PATH" OSD_TEST_LOG="$log" "$helper" "$@" + PATH="$scratch/bin:$PATH" OSD_TEST_LOG="$log" \ + OSD_TEST_FAIL_QS="${OSD_TEST_FAIL_QS:-false}" \ + PANAMA_OSD_STRICT="${PANAMA_OSD_STRICT:-false}" \ + "$helper" "$@" } assert_line() { @@ -95,4 +99,13 @@ assert_line 'qs ' run_helper message notifications-disabled-symbolic 'Do Not Disturb On' assert_line 'qs ' +: >"$log" +if OSD_TEST_FAIL_QS=true PANAMA_OSD_STRICT=1 run_helper message dialog-error-symbolic 'Strict failure'; then + printf 'osd helper contract: strict delivery swallowed an IPC failure\n' >&2 + exit 1 +fi + +: >"$log" +OSD_TEST_FAIL_QS=true run_helper message dialog-information-symbolic 'Best effort' + printf 'osd helper contract: PASS\n' diff --git a/tests/quickshell/panama-action-contract.sh b/tests/quickshell/panama-action-contract.sh index c671b75..da616cc 100755 --- a/tests/quickshell/panama-action-contract.sh +++ b/tests/quickshell/panama-action-contract.sh @@ -37,7 +37,13 @@ case "$*" in 'ipc call notifications dnd'|'ipc call caffeine toggle'|'ipc call night-light toggle') printf '%s\n' "${PANAMA_ACTION_TEST_STATE:-true}" ;; + 'ipc call focus start'|'ipc call focus end') + printf '%s\n' "${PANAMA_ACTION_TEST_FOCUS_STATE:-true}" + ;; esac +if [[ ${PANAMA_ACTION_TEST_FAIL_KILL:-false} == true && ${1:-} == kill ]]; then + exit 1 +fi [[ ${PANAMA_ACTION_TEST_FAIL_QS:-false} != true ]] EOF @@ -53,6 +59,7 @@ make_recorder panama-osd <<'EOF' printf 'panama-osd' >>"$PANAMA_ACTION_TEST_LOG" printf ' <%s>' "$@" >>"$PANAMA_ACTION_TEST_LOG" printf '\n' >>"$PANAMA_ACTION_TEST_LOG" +[[ ${PANAMA_ACTION_TEST_FAIL_OSD:-false} != true ]] EOF make_recorder panama-prism-gallery <<'EOF' @@ -71,9 +78,29 @@ printf ' <%s>' "$@" >>"$PANAMA_ACTION_TEST_LOG" printf '\n' >>"$PANAMA_ACTION_TEST_LOG" EOF +make_recorder systemd-inhibit <<'EOF' +#!/usr/bin/env bash +if [[ ${PANAMA_ACTION_TEST_CONFIRMED_STATE:-${PANAMA_ACTION_TEST_STATE:-true}} == true ]]; then + printf 'Panama 1000 gib 42 systemd-inhibit sleep:idle Caffeine block\n' +fi +EOF + +make_recorder pgrep <<'EOF' +#!/usr/bin/env bash +[[ ${PANAMA_ACTION_TEST_CONFIRMED_STATE:-${PANAMA_ACTION_TEST_STATE:-true}} == true ]] +EOF + +make_recorder id <<'EOF' +#!/usr/bin/env bash +printf '1000\n' +EOF + run_action() { : >"$command_log" HOME="$work/home" PATH="$fake_bin:$PATH" \ + PANAMA_ACTION_HELPER_DIR="$fake_bin" \ + PANAMA_ACTION_CONFIRM_ATTEMPTS=1 \ + PANAMA_ACTION_CONFIRM_DELAY=0 \ PANAMA_ACTION_TEST_LOG="$command_log" \ "$dispatcher" "$1" } @@ -119,6 +146,9 @@ assert_commands $'qs \npanama-osd \npanama-osd ' +PANAMA_ACTION_TEST_FOCUS_STATE=false run_action focus-end +assert_commands $'qs \npanama-osd ' + run_action capture assert_commands 'qs ' @@ -135,18 +165,43 @@ run_action gallery assert_commands 'panama-prism-gallery' run_action restart-shell -assert_commands $'qs \nquickshell <--daemonize>' +assert_commands $'qs \nquickshell <--daemonize>\nqs \npanama-osd ' -if HOME="$work/home" PATH="$fake_bin:$PATH" PANAMA_ACTION_TEST_LOG="$command_log" \ +PANAMA_ACTION_TEST_FAIL_KILL=true run_action restart-shell +assert_commands $'qs \nquickshell <--daemonize>\nqs \npanama-osd ' + +if HOME="$work/home" PATH="$fake_bin:$PATH" PANAMA_ACTION_HELPER_DIR="$fake_bin" \ + PANAMA_ACTION_CONFIRM_ATTEMPTS=1 PANAMA_ACTION_CONFIRM_DELAY=0 PANAMA_ACTION_TEST_LOG="$command_log" \ "$dispatcher" definitely-not-an-action >/dev/null 2>&1; then fail 'unknown action was accepted' fi : >"$command_log" if PANAMA_ACTION_TEST_FAIL_QS=true HOME="$work/home" PATH="$fake_bin:$PATH" \ - PANAMA_ACTION_TEST_LOG="$command_log" "$dispatcher" control-center >/dev/null 2>&1; then + PANAMA_ACTION_HELPER_DIR="$fake_bin" PANAMA_ACTION_CONFIRM_ATTEMPTS=1 \ + PANAMA_ACTION_CONFIRM_DELAY=0 PANAMA_ACTION_TEST_LOG="$command_log" \ + "$dispatcher" control-center >/dev/null 2>&1; then fail 'failed shell action returned success' fi assert_commands $'qs \nnotify-send <-a> <-i> ' +: >"$command_log" +if PANAMA_ACTION_TEST_FAIL_OSD=true HOME="$work/home" PATH="$fake_bin:$PATH" \ + PANAMA_ACTION_HELPER_DIR="$fake_bin" PANAMA_ACTION_CONFIRM_ATTEMPTS=1 \ + PANAMA_ACTION_CONFIRM_DELAY=0 PANAMA_ACTION_TEST_LOG="$command_log" \ + "$dispatcher" microphone >/dev/null 2>&1; then + fail 'failed Prism OSD delivery returned success' +fi +assert_commands $'panama-osd \nnotify-send <-a> <-i> ' + +: >"$command_log" +if PANAMA_ACTION_TEST_CONFIRMED_STATE=false HOME="$work/home" PATH="$fake_bin:$PATH" \ + PANAMA_ACTION_HELPER_DIR="$fake_bin" PANAMA_ACTION_CONFIRM_ATTEMPTS=1 \ + PANAMA_ACTION_CONFIRM_DELAY=0 PANAMA_ACTION_TEST_LOG="$command_log" \ + "$dispatcher" caffeine >/dev/null 2>&1; then + fail 'unconfirmed Caffeine state returned success' +fi +grep -Fqx 'notify-send <-a> <-i> ' "$command_log" \ + || fail 'unconfirmed state did not send a failure notification' + printf 'Panama action contract: PASS\n' diff --git a/tests/quickshell/panama-action-ipc-contract.sh b/tests/quickshell/panama-action-ipc-contract.sh index 8cf8d53..61a7b3e 100755 --- a/tests/quickshell/panama-action-ipc-contract.sh +++ b/tests/quickshell/panama-action-ipc-contract.sh @@ -9,6 +9,7 @@ fail() { active_target="" original_state="" +night_light_snapshot="" restore_target() { local target="$1" expected="$2" current @@ -22,6 +23,12 @@ cleanup() { if [[ -n $active_target ]]; then restore_target "$active_target" "$original_state" fi + if [[ -n $night_light_snapshot ]]; then + local enabled automatic + enabled="$(jq -r '.enabled' <<<"$night_light_snapshot")" + automatic="$(jq -r '.automatic' <<<"$night_light_snapshot")" + qs ipc call night-light restore "$enabled" "$automatic" >/dev/null 2>&1 || true + fi } trap cleanup EXIT @@ -51,6 +58,22 @@ rg -q '^target caffeine$' <<<"$ipc" || fail 'caffeine IPC target is missing' rg -q '^target night-light$' <<<"$ipc" || fail 'night-light IPC target is missing' exercise_toggle caffeine + +night_light_snapshot="$(qs ipc call night-light settings)" +jq -e '.enabled | type == "boolean"' <<<"$night_light_snapshot" >/dev/null \ + || fail 'night-light settings omitted enabled state' +jq -e '.automatic | type == "boolean"' <<<"$night_light_snapshot" >/dev/null \ + || fail 'night-light settings omitted automatic state' exercise_toggle night-light +original_enabled="$(jq -r '.enabled' <<<"$night_light_snapshot")" +original_automatic="$(jq -r '.automatic' <<<"$night_light_snapshot")" +qs ipc call night-light restore "$original_enabled" "$original_automatic" >/dev/null +restored_snapshot="$(qs ipc call night-light settings)" +[[ "$(jq -r '.enabled' <<<"$restored_snapshot")" == "$original_enabled" ]] \ + || fail 'night-light enabled state was not restored' +[[ "$(jq -r '.automatic' <<<"$restored_snapshot")" == "$original_automatic" ]] \ + || fail 'night-light automatic schedule was not restored' +night_light_snapshot="" + printf 'Panama action IPC contract: PASS\n' diff --git a/tests/quickshell/panama-command-install-contract.sh b/tests/quickshell/panama-command-install-contract.sh index f81b00c..93d627c 100755 --- a/tests/quickshell/panama-command-install-contract.sh +++ b/tests/quickshell/panama-command-install-contract.sh @@ -4,6 +4,8 @@ set -euo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" installer="$repo_dir/setup/scripts/link-vicinae-scripts" +dotfile_installer="$repo_dir/setup/scripts/link-dotfiles" +top_level_installer="$repo_dir/install" work="$(mktemp -d /tmp/panama-command-install.XXXXXX)" data_dir="$work/share/vicinae" fake_bin="$work/bin" @@ -20,6 +22,11 @@ cleanup() { trap cleanup EXIT [[ -x "$installer" ]] || fail 'Vicinae script installer is missing or not executable' +if rg -q 'setup/scripts/link-vicinae-scripts' "$dotfile_installer"; then + fail 'link-dotfiles also invokes the command installer' +fi +rg -Fq 'do "$script"; done' "$top_level_installer" \ + || fail 'top-level installer sources setup scripts into one shared shell' mkdir -p "$data_dir/scripts/panama" "$fake_bin" printf 'user-owned\n' >"$data_dir/scripts/panama/keep.sh" @@ -37,12 +44,14 @@ PATH="$fake_bin:$PATH" PANAMA_PATH="$repo_dir" VICINAE_DATA_DIR="$data_dir" \ PANAMA_VICINAE_TEST_LOG="$vicinae_log" "$installer" >/dev/null target="$data_dir/scripts/panama" -backup="$data_dir/scripts/panama.pre-panama" +backup="$data_dir/backups/panama.pre-panama" [[ -L "$target" ]] || fail 'Panama command directory was not linked' [[ "$(readlink -f "$target")" == "$(readlink -f "$repo_dir/config/local/share/vicinae/scripts")" ]] \ || fail 'runtime command link points outside the tracked source' [[ -f "$backup/keep.sh" ]] || fail 'pre-existing user command directory was not preserved' [[ "$(cat "$backup/keep.sh")" == user-owned ]] || fail 'preserved user command changed' +[[ ! -e "$data_dir/scripts/panama.pre-panama" ]] \ + || fail 'preserved commands remained inside Vicinae search paths' expected_calls=$'vicinae \nvicinae ' [[ "$(cat "$vicinae_log")" == "$expected_calls" ]] \ @@ -55,7 +64,7 @@ PATH="$fake_bin:$PATH" PANAMA_PATH="$repo_dir" VICINAE_DATA_DIR="$data_dir" \ PANAMA_VICINAE_TEST_LOG="$vicinae_log" "$installer" >/dev/null [[ -L "$target" ]] || fail 'second install did not leave the command link intact' [[ -f "$backup/keep.sh" ]] || fail 'second install disturbed the original backup' -[[ ! -e "$data_dir/scripts/panama.pre-panama.pre-panama" ]] \ +[[ ! -e "$data_dir/backups/panama.pre-panama.pre-panama" ]] \ || fail 'second install created a duplicate backup' printf 'Panama command install contract: PASS\n'