diff --git a/bin/omarchy-capture-screenrecording b/bin/omarchy-capture-screenrecording index 4db875d0..4335080b 100755 --- a/bin/omarchy-capture-screenrecording +++ b/bin/omarchy-capture-screenrecording @@ -226,10 +226,15 @@ stop_screenrecording() { # Generate a preview thumbnail from the first frame ffmpeg -y -i "$filename" -ss 00:00:00.1 -vframes 1 -q:v 2 "$preview" -loglevel quiet 2>/dev/null + omarchy-notification-send "Screen recording saved" "Open with Super + Alt + , (or click this)" \ + -t 10000 --image "${preview:-$filename}" \ + --exec "$(printf 'mpv %q' "$filename")" + + # The shell loads the thumbnail into memory when the toast appears and never + # re-reads the file, so the preview only has to outlive that load -- not the + # toast. Clear it out of the recordings directory a moment later. ( - if [[ -n $(omarchy-notification-send "Screen recording saved" "Open with Super + Alt + , (or click this)" -t 10000 --image "${preview:-$filename}" -a) ]]; then - mpv "$filename" - fi + sleep 2 rm -f "$preview" ) & fi diff --git a/bin/omarchy-capture-screenshot b/bin/omarchy-capture-screenshot index 663b04a5..8b2ac7d0 100755 --- a/bin/omarchy-capture-screenshot +++ b/bin/omarchy-capture-screenshot @@ -29,11 +29,6 @@ for arg in "$@"; do done set -- "${ARGS[@]}" -open_editor() { - local filepath="$1" - "$SCREENSHOT_EDITOR" "$filepath" -} - MODE="${1:-smart}" PROCESSING="${2:-slurp}" @@ -71,11 +66,11 @@ case "$PROCESSING" in echo "$FILEPATH" wl-copy --type image/png <"$FILEPATH" - ( - if [[ -n $(omarchy-notification-send "Screenshot saved to clipboard and file" "Edit with Super + Alt + , (or click this)" --image "$FILEPATH" -a) ]]; then - open_editor "$FILEPATH" - fi - ) >/dev/null 2>&1 & + # Best-effort: the screenshot is already saved and on the clipboard, so a + # notification outage must not report the capture itself as failed. + omarchy-notification-send "Screenshot saved to clipboard and file" "Edit with Super + Alt + , (or click this)" \ + --image "$FILEPATH" \ + --exec "$(printf '%q %q' "$SCREENSHOT_EDITOR" "$FILEPATH")" || true ;; copy) grim -g "$SELECTION" - | wl-copy --type image/png diff --git a/bin/omarchy-chromium-ytdlp-host b/bin/omarchy-chromium-ytdlp-host index 5fc5842d..aba03ba4 100755 --- a/bin/omarchy-chromium-ytdlp-host +++ b/bin/omarchy-chromium-ytdlp-host @@ -92,11 +92,17 @@ download_url() { -vf "crop='min(iw,ih)':'min(iw,ih)',scale=256:256" -q:v 2 \ "$preview" -loglevel quiet 2>/dev/null || true - # Clicking the toast opens the video in mpv (-a blocks until clicked or timed out). + # Best-effort: the download already succeeded, and under `set -e` a failed + # toast would exit before the thumbnail cleanup below is ever scheduled. + omarchy-notification-send -g 󰄬 "Download complete" "$title" \ + -t 10000 --image "${preview:-$filepath}" \ + --exec "$(printf 'mpv %q' "$filepath")" || true + + # The shell loads the thumbnail into memory when the toast appears and never + # re-reads the file, so the preview only has to outlive that load, not the + # toast. ( - if [[ -n $(omarchy-notification-send -g 󰄬 "Download complete" "$title" -t 10000 --image "${preview:-$filepath}" -a) ]]; then - mpv "$filepath" - fi + sleep 2 rm -f "$preview" ) & else diff --git a/bin/omarchy-first-run b/bin/omarchy-first-run index a771a9c1..09d3fca6 100755 --- a/bin/omarchy-first-run +++ b/bin/omarchy-first-run @@ -82,12 +82,10 @@ run_first_run_step "apply speaker tuning" \ bash "$OMARCHY_PATH/install/user/first-run/audio-tuning.sh" omarchy-notification-wait || log_first_run "Timed out waiting for notification service; continuing" +# Each send returns only once the server has taken the toast, so sending in +# order is enough to stack them newest-on-top. run_first_run_step "show welcome notification" \ bash "$OMARCHY_PATH/install/user/first-run/welcome.sh" -# The first-run notification scripts register action callbacks in background -# notify-send processes. Give the notification server a tick to ingest the -# welcome toast before queueing the Wi-Fi/update toasts. -sleep 0.3 run_first_run_step "show Wi-Fi/update notifications" \ bash "$OMARCHY_PATH/install/user/first-run/wifi.sh" diff --git a/bin/omarchy-migrate-notify b/bin/omarchy-migrate-notify index c9e1b7d2..0ee2e0b0 100755 --- a/bin/omarchy-migrate-notify +++ b/bin/omarchy-migrate-notify @@ -26,8 +26,6 @@ else message="Click to run $pending_count pending migrations." fi -notify_command=$(printf 'if [[ -n $(omarchy-notification-send -u critical -g  "Pending Omarchy Migrations" %q -a) ]]; then omarchy-launch-floating-terminal-with-presentation omarchy-migrate; fi' "$message") - # This runs from omarchy-migrate-notify.service after graphical-session.target, # but the target can be reached before the shell has claimed # org.freedesktop.Notifications. Without the wait the toast is sent into the @@ -40,11 +38,10 @@ if update_in_progress; then exit 0 fi -# A transient service rather than a scope, which would block here until the -# toast was answered and keep this oneshot activating for that whole time. The -# graphical slice ends an unanswered toast at logout. -unit="omarchy-migrations-notification-$(date +%Y%m%d%H%M%S)" -systemd-run --user --collect --slice=background-graphical.slice --unit="$unit" bash -lc "$notify_command" >/dev/null 2>&1 && exit 0 +# The shell keeps the click command with the toast, so this oneshot can hand the +# invitation over and exit instead of staying activated until it is answered. +omarchy-notification-send -u critical -g  "Pending Omarchy Migrations" "$message" \ + --exec "omarchy-launch-floating-terminal-with-presentation omarchy-migrate" && exit 0 # Reached when the notification could not be handed off, so fall back to telling # the user in the terminal. diff --git a/bin/omarchy-notification-send b/bin/omarchy-notification-send index 05961410..8a5851b8 100755 --- a/bin/omarchy-notification-send +++ b/bin/omarchy-notification-send @@ -1,7 +1,7 @@ #!/bin/bash # omarchy:summary=Send an Omarchy desktop notification -# omarchy:args=[-a] [--app-name ] [-g ] [-u ] [--image ] [description] [notify-send options] +# omarchy:args=[--exec ] [--app-name ] [-g ] [-u ] [--image ] [description] [notify-send options] # omarchy:examples=omarchy notification send "Reminder" "5 minutes are up" -g 󰢌 set -euo pipefail @@ -12,7 +12,7 @@ glyph= urgency="low" app_name="omarchy-action" image= -click_action=0 +exec_command= args=() parsed_option_args=0 @@ -54,9 +54,13 @@ parse_omarchy_option() { parsed_option_args=2 return 0 ;; - -a | --action) - click_action=1 - parsed_option_args=1 + --exec) + if (($# < 2)); then + echo "Missing value for $1" >&2 + exit 1 + fi + exec_command=$2 + parsed_option_args=2 return 0 ;; esac @@ -73,7 +77,7 @@ while (($# > 0)); do done if (($# < 1)); then - echo "Usage: omarchy-notification-send [-a] [--app-name ] [-g ] [-u ] [--image ] [description] [notify-send options]" + echo "Usage: omarchy-notification-send [--exec ] [--app-name ] [-g ] [-u ] [--image ] [description] [notify-send options]" exit 1 fi @@ -105,8 +109,11 @@ if [[ -n $image ]]; then args+=("--hint=string:image-path:$image") fi -if ((click_action)); then - args+=("-A" "default=default") +# The shell runs the click command itself, from a copy it keeps alongside the +# on-screen popup. A libnotify action would instead keep this process blocked +# until the click, and die unanswered whenever the shell restarts underneath it. +if [[ -n $exec_command ]]; then + args+=("--hint=string:omarchy-exec:$exec_command") fi if [[ -n $description ]]; then diff --git a/bin/omarchy-tailscale-receive b/bin/omarchy-tailscale-receive index 6232646e..6da2b125 100755 --- a/bin/omarchy-tailscale-receive +++ b/bin/omarchy-tailscale-receive @@ -70,11 +70,9 @@ announce() { ;; esac - # Clicking the notification opens the file, so this waits for the toast to - # go away. Callers background it to keep receiving in the meantime. - if [[ -n $(omarchy-notification-send "${args[@]}" -a) ]]; then - xdg-open "$path" - fi + # Announcing is best-effort: the file is already delivered, and under `set -e` + # a notification outage would otherwise kill the long-running receiver. + omarchy-notification-send "${args[@]}" --exec "$(printf 'xdg-open %q' "$path")" || true } deliver() { @@ -82,7 +80,7 @@ deliver() { while IFS= read -r staged; do target=$(claim_path "$staged") || continue - announce "$target" & + announce "$target" done < <(find "$staging" -mindepth 1 -maxdepth 1) } diff --git a/install/user/first-run/install-voxtype.hook b/install/user/first-run/install-voxtype.hook index 1fcb0cbc..eb180f81 100644 --- a/install/user/first-run/install-voxtype.hook +++ b/install/user/first-run/install-voxtype.hook @@ -2,26 +2,8 @@ set -e -show_invitation() { - if [[ -n $(omarchy-notification-send -u critical -g  "Install Dictation with Voxtype" "Click to install voice dictation for Omarchy." -a) ]]; then - # Launch the installer in its own transient unit so this invitation service - # can exit right after the click. If it stayed alive for the life of the - # install terminal, the installer's omarchy-restart-shell would re-trigger - # this still-running *-invitation unit and pop the toast a second time. - # KillMode=process because the launcher's setsid forks and returns, so the - # unit's main process exits within milliseconds. The default control-group - # kill would take the terminal down with it before it ever appears. - systemd-run --user --collect --quiet -p KillMode=process \ - --unit=omarchy-voxtype-install \ - omarchy-launch-floating-terminal-with-presentation omarchy-voxtype-install - fi -} - -if [[ ${1:-} == "--show" ]]; then - show_invitation -elif omarchy-done ensure voxtype-install-invitation; then - # Keep the notification action alive after the update terminal closes. - systemd-run --user --collect --quiet --service-type=exec \ - --unit=omarchy-voxtype-install-invitation \ - bash "$0" --show +if omarchy-done ensure voxtype-install-invitation; then + omarchy-notification-send -u critical -g  "Install Dictation with Voxtype" \ + "Click to install voice dictation for Omarchy." \ + --exec "omarchy-launch-floating-terminal-with-presentation omarchy-voxtype-install" fi diff --git a/install/user/first-run/setup-fingerprint.hook b/install/user/first-run/setup-fingerprint.hook index b863a92d..1ff17b5d 100644 --- a/install/user/first-run/setup-fingerprint.hook +++ b/install/user/first-run/setup-fingerprint.hook @@ -2,29 +2,11 @@ set -e -show_invitation() { - if [[ -n $(omarchy-notification-send -u critical -g 󰈷 "Setup Fingerprint Reader" "Enable sudo and unlocking with your fingerprint." -a) ]]; then - # Launch the setup in its own transient unit so this invitation service can - # exit right after the click. If it stayed alive for the life of the setup - # terminal, the setup's omarchy-restart-shell would re-trigger this - # still-running *-invitation unit and pop the toast a second time. - # KillMode=process because the launcher's setsid forks and returns, so the - # unit's main process exits within milliseconds. The default control-group - # kill would take the terminal down with it before it ever appears. - systemd-run --user --collect --quiet -p KillMode=process \ - --unit=omarchy-setup-security-fingerprint \ - omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fingerprint - fi -} - -if [[ ${1:-} == "--show" ]]; then - show_invitation # Only invite when there's a reader to use and it isn't set up yet (the lock # PAM file is the last thing the setup writes on success). -elif omarchy-hw-fingerprint && [[ ! -f /etc/pam.d/omarchy-lock-fingerprint ]] && +if omarchy-hw-fingerprint && [[ ! -f /etc/pam.d/omarchy-lock-fingerprint ]] && omarchy-done ensure fingerprint-setup-invitation; then - # Keep the notification action alive after the update terminal closes. - systemd-run --user --collect --quiet --service-type=exec \ - --unit=omarchy-fingerprint-setup-invitation \ - bash "$0" --show + omarchy-notification-send -u critical -g 󰈷 "Setup Fingerprint Reader" \ + "Enable sudo and unlocking with your fingerprint." \ + --exec "omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fingerprint" fi diff --git a/install/user/first-run/welcome.sh b/install/user/first-run/welcome.sh index 0ff2ec31..dfbc2e20 100644 --- a/install/user/first-run/welcome.sh +++ b/install/user/first-run/welcome.sh @@ -1,5 +1,3 @@ -( - if [[ -n $(omarchy-notification-send -u critical -g  "Learn Keybindings" "Super + K for cheatsheet.\nSuper + Space for application launcher.\nSuper + Alt + Space for Omarchy Menu." -a) ]]; then - omarchy-menu-keybindings - fi -) >/dev/null 2>&1 & +omarchy-notification-send -u critical -g  "Learn Keybindings" \ + "Super + K for cheatsheet.\nSuper + Space for application launcher.\nSuper + Alt + Space for Omarchy Menu." \ + --exec omarchy-menu-keybindings diff --git a/install/user/first-run/wifi.sh b/install/user/first-run/wifi.sh index 16a927f4..4d961be9 100644 --- a/install/user/first-run/wifi.sh +++ b/install/user/first-run/wifi.sh @@ -1,25 +1,16 @@ notify_update() { - ( - if [[ -n $(omarchy-notification-send -u critical -g  "Update System" "$1" -a) ]]; then - omarchy-launch-floating-terminal-with-presentation omarchy-update - fi - ) >/dev/null 2>&1 & + omarchy-notification-send -u critical -g  "Update System" "$1" \ + --exec "omarchy-launch-floating-terminal-with-presentation omarchy-update" } notify_wifi() { - ( - if [[ -n $(omarchy-notification-send -u critical -g 󰖩 "Setup Wi-Fi" "Click to configure the wireless network." -a) ]]; then - omarchy-shell shell toggle omarchy.network - fi - ) >/dev/null 2>&1 & + omarchy-notification-send -u critical -g 󰖩 "Setup Wi-Fi" "Click to configure the wireless network." \ + --exec "omarchy-shell shell toggle omarchy.network" } if ! ping -c3 -W1 1.1.1.1 >/dev/null 2>&1; then + # Newest stacks on top, and Wi-Fi is what you need first, so send it last. notify_update "When you have internet, click to update the system." - # Both toasts are sent from background subshells, so let the update one - # register before queueing Wi-Fi. Newest stacks on top, and Wi-Fi is what - # you need first. - sleep 0.3 notify_wifi else notify_update "Click to update the system." diff --git a/shell/plugins/notifications/NotificationLogic.js b/shell/plugins/notifications/NotificationLogic.js index 64dcaaef..6f3968b4 100644 --- a/shell/plugins/notifications/NotificationLogic.js +++ b/shell/plugins/notifications/NotificationLogic.js @@ -42,17 +42,31 @@ function isEphemeralApp(appName) { return name === "notify-send" || name === "omarchy-action" } -function glyphFromHints(hints) { +function stringHint(hints, name) { try { if (hints) { - var glyph = hints["omarchy-glyph"] - if (glyph !== undefined && glyph !== null) return String(glyph) + var value = hints[name] + if (value !== undefined && value !== null) return String(value) } } catch (e) { } return "" } +function glyphFromHints(hints) { + return stringHint(hints, "omarchy-glyph") +} + +// Shell command to run when the card is clicked, sent by +// omarchy-notification-send --exec. Carrying the action as data means it +// travels with the popup through the persistence files, so a toast restored +// after a shell restart clicks through exactly like a live one. A libnotify +// action can't: its sender is still waiting on an id from a server generation +// that no longer exists. +function execFromHints(hints) { + return stringHint(hints, "omarchy-exec") +} + function shouldRenderCompactGlyph(glyph, iconSource, singleLineToast) { return String(glyph || "").length > 0 && String(iconSource || "").length === 0 && !!singleLineToast } @@ -71,6 +85,7 @@ function snapshotOf(notification, timestamp) { body: n.body || "", image: n.image || "", glyph: glyphFromHints(n.hints), + exec: execFromHints(n.hints), urgency: n.urgency, expireTimeout: expireTimeout, timestamp: timestamp === undefined ? Date.now() : timestamp @@ -88,6 +103,7 @@ function historyEntry(value, normalUrgency) { body: e.body || "", image: e.image || "", glyph: e.glyph || "", + exec: e.exec || "", urgency: typeof e.urgency === "number" ? e.urgency : normalUrgency, expireTimeout: 0, timestamp: e.timestamp || 0 @@ -187,6 +203,7 @@ function dumpRows(rows) { body: r.body, image: r.image, glyph: r.glyph || "", + exec: r.exec || "", urgency: r.urgency, timestamp: r.timestamp }) @@ -299,7 +316,9 @@ if (typeof module !== "undefined") { summaryStartsWithGlyph: summaryStartsWithGlyph, shouldBypassDnd: shouldBypassDnd, isEphemeralApp: isEphemeralApp, + stringHint: stringHint, glyphFromHints: glyphFromHints, + execFromHints: execFromHints, shouldRenderCompactGlyph: shouldRenderCompactGlyph, snapshotOf: snapshotOf, historyEntry: historyEntry, diff --git a/shell/plugins/notifications/Service.qml b/shell/plugins/notifications/Service.qml index e9cb24ff..e5cb90b5 100644 --- a/shell/plugins/notifications/Service.qml +++ b/shell/plugins/notifications/Service.qml @@ -299,6 +299,7 @@ Item { body: row.body, image: row.image, glyph: row.glyph || "", + exec: row.exec || "", urgency: row.urgency, expireTimeout: row.expireTimeout || 0, timestamp: row.timestamp @@ -390,6 +391,7 @@ Item { body: "", image: "", glyph: "󰂚", + exec: "", urgency: NotificationUrgency.Low, expireTimeout: 0, timestamp: Date.now() @@ -438,13 +440,22 @@ Item { scheduleHistorySave() } - // Invoke the libnotify "default" action on the popup's underlying - // notification, if it has one, then dismiss. Clients register the default - // action with the canonical identifier "default"; e.g. screenshot toasts - // use `notify-send -A default=Edit ...` so click-the-card opens the editor. + // Run the popup's click action, then dismiss. Omarchy's own toasts carry the + // action as a command in the `exec` role (see execFromHints), which the + // persistence files preserve, so restored toasts stay clickable. Third-party + // clients register a libnotify action under the canonical identifier + // "default" instead; that one only works while the sender is still live. function invokePopupDefault(index) { if (index < 0 || index >= popupModel.count) return var entry = popupModel.get(index) + var command = entry ? String(entry.exec || "") : "" + if (command) { + // Detached so the launched command outlives the shell process, which the + // installer toasts depend on: they restart the shell as their first act. + Util.execDetached(command) + dismissPopup(index) + return + } // Restored rows have no live actions, and looking up liveRefs by their // old-generation id could fire an unrelated fresh notification's action. var ref = entry && !isRestoredRow(entry) ? liveRefs[entry.originalId] : null @@ -806,6 +817,7 @@ Item { body: r.body, image: r.image, glyph: r.glyph || "", + exec: r.exec || "", urgency: r.urgency, expireTimeout: r.expireTimeout || 0, timestamp: r.timestamp diff --git a/test/shell.d/fingerprint-invitation-test.sh b/test/shell.d/fingerprint-invitation-test.sh index a746530e..09e7edd3 100644 --- a/test/shell.d/fingerprint-invitation-test.sh +++ b/test/shell.d/fingerprint-invitation-test.sh @@ -31,27 +31,18 @@ chmod +x "$test_bin/omarchy-hw-fingerprint" cat >"$test_bin/omarchy-notification-send" <<'EOF' #!/bin/bash echo notification >>"$TEST_LOG" -echo action +while (($# > 0)); do + [[ $1 == "--exec" ]] && echo "exec:$2" >>"$TEST_LOG" + shift +done EOF chmod +x "$test_bin/omarchy-notification-send" -cat >"$test_bin/omarchy-launch-floating-terminal-with-presentation" <<'EOF' -#!/bin/bash -echo launch >>"$TEST_LOG" -EOF -chmod +x "$test_bin/omarchy-launch-floating-terminal-with-presentation" - +# The shell runs the click command, so the invitation must not need a unit of its +# own to keep a blocked sender alive until the toast is answered. cat >"$test_bin/systemd-run" <<'EOF' #!/bin/bash echo "systemd-run:$*" >>"$TEST_LOG" -while (($# > 0)); do - case $1 in - -p) shift 2 ;; - -*) shift ;; - *) break ;; - esac -done -exec "$@" EOF chmod +x "$test_bin/systemd-run" @@ -70,17 +61,13 @@ run_invitation_hook [[ -f $test_home/.local/state/omarchy/done/fingerprint-setup-invitation ]] || fail "fingerprint invitation records completion" [[ -f $hook_path ]] || fail "fingerprint invitation keeps its hook installed" -[[ $(grep -c '^systemd-run:' "$log_file") -eq 2 ]] || fail "fingerprint invitation uses durable user services" -grep -q -- '--user --collect --quiet --service-type=exec --unit=omarchy-fingerprint-setup-invitation' "$log_file" || fail "fingerprint invitation configures its user service" -# KillMode=process keeps the launcher's setsid child alive once the short-lived -# main process exits, otherwise the setup terminal never appears. -grep -q -- '--user --collect --quiet -p KillMode=process --unit=omarchy-setup-security-fingerprint ' "$log_file" || fail "fingerprint invitation outlives its launcher unit" [[ $(grep -c '^notification$' "$log_file") -eq 1 ]] || fail "fingerprint invitation sends one notification" -[[ $(grep -c '^launch$' "$log_file") -eq 1 ]] || fail "fingerprint invitation handles the notification action" +grep -qx 'exec:omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fingerprint' "$log_file" || + fail "fingerprint invitation attaches the setup to the notification" +grep -q '^systemd-run:' "$log_file" && fail "fingerprint invitation needs no unit to hold an unanswered toast" HOME="$test_home" PATH="$test_bin:$ROOT/bin:$PATH" TEST_LOG="$log_file" TEST_HW_MARKER="$hw_marker" bash "$hook_path" -[[ $(grep -c '^systemd-run:' "$log_file") -eq 2 ]] || fail "completed fingerprint invitation does not schedule again" [[ $(grep -c '^notification$' "$log_file") -eq 1 ]] || fail "completed fingerprint invitation hook does not notify again" pass "fingerprint invitation waits for a reader and only runs once" diff --git a/test/shell.d/migrate-notify-test.sh b/test/shell.d/migrate-notify-test.sh index b67b30fa..84dc7be3 100644 --- a/test/shell.d/migrate-notify-test.sh +++ b/test/shell.d/migrate-notify-test.sh @@ -22,23 +22,6 @@ fi SH chmod +x "$stub_bin/omarchy-migrate" -cat >"$stub_bin/systemd-run" <<'SH' -#!/bin/bash -if [[ ${OMARCHY_TEST_SYSTEMD_RUN:-run} == "fail" ]]; then - exit 1 -fi - -command=${!#} - -# --scope blocks on the command, a transient service detaches it. -if [[ " $* " == *" --scope "* ]]; then - bash -c "$command" -else - setsid bash -c "$command" >/dev/null 2>&1 & -fi -SH -chmod +x "$stub_bin/systemd-run" - # Waiting for the notification server is the notifier's one long pause, so it is # also where an update can start underneath it. Stand one up from inside the # wait to prove the notifier re-checks afterwards instead of sending a toast it @@ -67,18 +50,8 @@ chmod +x "$stub_bin/omarchy-notification-wait" cat >"$stub_bin/omarchy-notification-send" <<'SH' #!/bin/bash -# Written whole so a reader polling for the file never sees half the arguments. -printf '%s\n' "$@" >"$OMARCHY_TEST_NOTIFY_ARGS.partial" -mv "$OMARCHY_TEST_NOTIFY_ARGS.partial" "$OMARCHY_TEST_NOTIFY_ARGS" - -# Stands in for a toast nobody has answered yet, until the test releases it. -if [[ -n ${OMARCHY_TEST_NOTIFY_HOLD:-} ]]; then - for _ in {1..200}; do - [[ -e $OMARCHY_TEST_NOTIFY_HOLD ]] || break - sleep 0.05 - done - : >"$OMARCHY_TEST_NOTIFY_ANSWERED" -fi +[[ ${OMARCHY_TEST_NOTIFY_SEND:-send} == "fail" ]] && exit 1 +printf '%s\n' "$@" >"$OMARCHY_TEST_NOTIFY_ARGS" SH chmod +x "$stub_bin/omarchy-notification-send" @@ -91,21 +64,14 @@ run_notify() { XDG_RUNTIME_DIR="$runtime_dir" \ OMARCHY_TEST_PENDING_MIGRATIONS="$1" \ OMARCHY_TEST_NOTIFY_ARGS="$test_tmp/notify-args" \ - OMARCHY_TEST_SYSTEMD_RUN="${2:-run}" \ + OMARCHY_TEST_NOTIFY_SEND="${2:-send}" \ OMARCHY_TEST_LOCK_DURING_WAIT="${OMARCHY_TEST_LOCK_DURING_WAIT:-0}" \ OMARCHY_TEST_LOCK_HOLDER_PID="$test_tmp/lock-holder-pid" \ - OMARCHY_TEST_NOTIFY_HOLD="${OMARCHY_TEST_NOTIFY_HOLD:-}" \ - OMARCHY_TEST_NOTIFY_ANSWERED="$test_tmp/notify-answered" \ "$ROOT/bin/omarchy-migrate-notify" } -# The notification outlives the notifier, so its arguments land after it exits. -wait_for_notify_args() { - for _ in {1..200}; do - [[ -s $test_tmp/notify-args ]] && return 0 - sleep 0.05 - done - return 1 +notify_args_written() { + [[ -s $test_tmp/notify-args ]] } run_notify 0 >"$test_tmp/not-pending.out" 2>"$test_tmp/not-pending.err" @@ -119,7 +85,7 @@ grep -q '200-migration.sh' "$test_tmp/pending.err" || fail "migration notifier l pass "migration notifier reports pending migrations" run_notify 1 >"$test_tmp/notified.out" 2>"$test_tmp/notified.err" -wait_for_notify_args || fail "migration notifier sends a notification for pending migrations" +notify_args_written || fail "migration notifier sends a notification for pending migrations" grep -Fx 'Pending Omarchy Migrations' "$test_tmp/notify-args" >/dev/null || fail "migration notifier uses pending migrations title" grep -Fx 'Click to run 1 pending migration.' "$test_tmp/notify-args" >/dev/null || fail "migration notifier describes the pending migration" grep -Fx '' "$test_tmp/notify-args" >/dev/null || fail "migration notifier includes the large-slot glyph" @@ -143,7 +109,7 @@ pass "migration notifier stays quiet while omarchy update holds its lock" exec {update_lock_fd}>&- run_notify 1 >/dev/null 2>&1 -wait_for_notify_args && +notify_args_written && grep -Fx 'Pending Omarchy Migrations' "$test_tmp/notify-args" >/dev/null || fail "migration notifier resumes notifying once the update lock is released" pass "migration notifier resumes notifying after the update releases its lock" @@ -172,20 +138,22 @@ exec {foreign_lock_fd}>"$foreign_lock" flock -n "$foreign_lock_fd" || fail "test could not hold the foreign update lock" run_notify 1 >/dev/null 2>&1 -wait_for_notify_args && +notify_args_written && grep -Fx 'Pending Omarchy Migrations' "$test_tmp/notify-args" >/dev/null || fail "migration notifier ignores update locks outside its own runtime directory" pass "migration notifier ignores update locks outside its own runtime directory" exec {foreign_lock_fd}>&- -# The notifier is a Type=oneshot with no start timeout, so blocking on the toast -# leaves it activating until the user answers. -rm -f "$test_tmp/notify-args" "$test_tmp/notify-answered" -: >"$test_tmp/notify-hold" -OMARCHY_TEST_NOTIFY_HOLD="$test_tmp/notify-hold" run_notify 1 >/dev/null 2>&1 -wait_for_notify_args || fail "migration notifier sends the notification it detaches" -[[ ! -e $test_tmp/notify-answered ]] || - fail "migration notifier waited for the toast to be answered before exiting" -rm -f "$test_tmp/notify-hold" -pass "migration notifier exits while the toast is still unanswered" +# The notifier is a Type=oneshot with no start timeout, so it must not stay +# activating until the toast is answered. Handing the click command to the shell +# is what lets it exit immediately -- and what keeps the toast working after the +# shell restart an update performs. +rm -f "$test_tmp/notify-args" +run_notify 1 >/dev/null 2>&1 +notify_args_written || fail "migration notifier sends the notification before exiting" +grep -Fx -- '--exec' "$test_tmp/notify-args" >/dev/null || + fail "migration notifier attaches the click command to the toast" +grep -Fx 'omarchy-launch-floating-terminal-with-presentation omarchy-migrate' "$test_tmp/notify-args" >/dev/null || + fail "migration notifier points the click command at omarchy-migrate" +pass "migration notifier lets the shell own the click instead of waiting for it" diff --git a/test/shell.d/notification-send-test.sh b/test/shell.d/notification-send-test.sh index c68a59ab..e754bb6e 100644 --- a/test/shell.d/notification-send-test.sh +++ b/test/shell.d/notification-send-test.sh @@ -17,7 +17,8 @@ printf '%s\n' \ chmod +x "$stub" OMARCHY_TEST_NOTIFY_ARGS="$args_file" PATH="$tmpdir:$ROOT/bin:$PATH" \ - omarchy-notification-send --app-name custom-app -g K -u critical --image /tmp/image.png "Learn Keybindings" "Body" -a + omarchy-notification-send --app-name custom-app -g K -u critical --image /tmp/image.png \ + --exec "omarchy-menu-keybindings 'a b'" "Learn Keybindings" "Body" mapfile -t args <"$args_file" @@ -27,8 +28,24 @@ mapfile -t args <"$args_file" [[ ${args[3]} == "critical" ]] || fail "notification wrapper uses custom urgency" [[ ${args[4]} == "--hint=string:omarchy-glyph:K" ]] || fail "notification wrapper converts glyph to hint" [[ ${args[5]} == "--hint=string:image-path:/tmp/image.png" ]] || fail "notification wrapper converts image to hint" -[[ ${args[6]} == "-A" ]] || fail "notification wrapper passes default action flag" -[[ ${args[7]} == "default=default" ]] || fail "notification wrapper enables default action" -[[ ${args[8]} == "Learn Keybindings" ]] || fail "notification wrapper preserves headline" -[[ ${args[9]} == "Body" ]] || fail "notification wrapper preserves description" -pass "notification wrapper supports app, glyph, urgency, image, and action options" +[[ ${args[6]} == "--hint=string:omarchy-exec:omarchy-menu-keybindings 'a b'" ]] || fail "notification wrapper converts exec to hint" +[[ ${args[7]} == "Learn Keybindings" ]] || fail "notification wrapper preserves headline" +[[ ${args[8]} == "Body" ]] || fail "notification wrapper preserves description" +pass "notification wrapper supports app, glyph, urgency, image, and exec options" + +# The shell runs the click command itself, so nothing may block the sender on a +# libnotify action round-trip. +grep -q -- "-A" "$args_file" && fail "notification wrapper must not register a libnotify action" + +: >"$args_file" +OMARCHY_TEST_NOTIFY_ARGS="$args_file" PATH="$tmpdir:$ROOT/bin:$PATH" \ + omarchy-notification-send "Plain" >/dev/null + +grep -q "omarchy-exec" "$args_file" && fail "notification wrapper adds no exec hint without --exec" +pass "notification wrapper omits the exec hint when no command is given" + +if OMARCHY_TEST_NOTIFY_ARGS="$args_file" PATH="$tmpdir:$ROOT/bin:$PATH" \ + omarchy-notification-send "Headline" --exec 2>/dev/null; then + fail "notification wrapper rejects --exec without a command" +fi +pass "notification wrapper rejects --exec without a command" diff --git a/test/shell.d/notifications-test.sh b/test/shell.d/notifications-test.sh index 9cb439fd..fc286265 100644 --- a/test/shell.d/notifications-test.sh +++ b/test/shell.d/notifications-test.sh @@ -255,6 +255,43 @@ assertEqual( 'notifications omit the deadline field until a restore sets it' ) +// A click action carried as a command is the only kind that survives a shell +// restart: a libnotify action leaves its sender waiting on an id from a server +// generation that no longer exists. +assertEqual( + notifications.snapshotOf({ id: 3, hints: { 'omarchy-exec': 'omarchy-menu-keybindings' } }, 1).exec, + 'omarchy-menu-keybindings', + 'notifications capture the click command from the exec hint' +) +assertEqual( + notifications.snapshotOf({ id: 3, hints: { 'omarchy-glyph': '!' } }, 1).exec, + '', + 'notifications leave the click command empty without an exec hint' +) +assertEqual( + notifications.popupEntry( + JSON.parse(notifications.serializePopup({ id: 1, originalId: 1, timestamp: 5, exec: "mpv '/tmp/a b.mp4'" }, 1)), + 1 + ).exec, + "mpv '/tmp/a b.mp4'", + 'notifications round-trip the click command through popup files' +) +assertEqual( + notifications.popupEntry({ id: 1, originalId: 1, timestamp: 5 }, 1).exec, + '', + 'notifications restore an empty click command for popups without one' +) +assertEqual( + notifications.historyEntry({ id: 1, exec: 'xdg-open /tmp/received' }, 1).exec, + 'xdg-open /tmp/received', + 'notifications keep the click command on history rows' +) +assertEqual( + notifications.dumpRows([{ id: 1, exec: 'xdg-open /tmp/received' }])[0].exec, + 'xdg-open /tmp/received', + 'notifications write the click command back out with history' +) + assertEqual(notifications.imageExtension('/tmp/screenshot.PNG'), 'png', 'notifications normalize image extensions') assertEqual(notifications.imageExtension('/tmp/no-extension'), 'png', 'notifications default missing image extension') assertEqual(notifications.imageExtension('/tmp/archive.reallylong'), 'png', 'notifications reject suspicious image extensions') @@ -304,4 +341,16 @@ assert( /awk 1 \\"\$1\\"\/\*\.json/.test(serviceQml), 'notifications service delimits every popup file during restore' ) +assert( + /var command = entry \? String\(entry\.exec \|\| ""\) : ""[\s\S]{0,300}?Util\.execDetached\(command\)/.test(serviceQml), + 'notifications service runs the popup click command itself instead of a libnotify action' +) +assert( + /exec: row\.exec \|\| ""/.test(serviceQml), + 'notifications service carries the click command between models' +) +assert( + /exec: r\.exec \|\| ""/.test(serviceQml), + 'notifications service saves the click command with history' +) JS diff --git a/test/shell.d/tailscale-receive-test.sh b/test/shell.d/tailscale-receive-test.sh index 4b59afed..a3d45976 100644 --- a/test/shell.d/tailscale-receive-test.sh +++ b/test/shell.d/tailscale-receive-test.sh @@ -25,14 +25,6 @@ SH cat >"$WORKDIR/bin/omarchy-notification-send" <>"$WORKDIR/notifications" -# Only the photo notification gets clicked. -[[ \$* == *photo.png* ]] && echo default -exit 0 -SH - -cat >"$WORKDIR/bin/xdg-open" <>"$WORKDIR/opened" SH chmod +x "$WORKDIR/bin/"* @@ -68,9 +60,14 @@ grep -q "^Received notes with space.pdf .* -g " <<<"$notifications" || fail "taildrop receive announces other files with a glyph" "$notifications" pass "taildrop receive announces other files with a glyph" -grep -qxF "$downloads/photo.png" "$WORKDIR/opened" || - fail "taildrop receive opens a clicked file" "$(cat "$WORKDIR/opened" 2>/dev/null)" -pass "taildrop receive opens a clicked file" +# The shell keeps the click command with the toast, so receiving does not have +# to sit blocked on an answer -- and the toast still opens the file after a shell +# restart. Names with spaces have to arrive quoted for the shell to run them. +grep -qF -- "--exec xdg-open $downloads/photo.png" <<<"$notifications" || + fail "taildrop receive attaches the open command to the notification" "$notifications" +grep -qF -- "--exec xdg-open $(printf %q "$downloads/notes with space.pdf")" <<<"$notifications" || + fail "taildrop receive quotes spaced names in the open command" "$notifications" +pass "taildrop receive lets a click open the received file" grep -q "unrelated.txt" <<<"$notifications" && fail "taildrop receive leaves the rest of the downloads directory alone" "$notifications" diff --git a/test/shell.d/voxtype-invitation-test.sh b/test/shell.d/voxtype-invitation-test.sh index 6038950f..b3eabb43 100644 --- a/test/shell.d/voxtype-invitation-test.sh +++ b/test/shell.d/voxtype-invitation-test.sh @@ -18,27 +18,18 @@ mkdir -p "$(dirname "$hook_path")" cat >"$test_bin/omarchy-notification-send" <<'EOF' #!/bin/bash echo notification >>"$TEST_LOG" -echo action +while (($# > 0)); do + [[ $1 == "--exec" ]] && echo "exec:$2" >>"$TEST_LOG" + shift +done EOF chmod +x "$test_bin/omarchy-notification-send" -cat >"$test_bin/omarchy-launch-floating-terminal-with-presentation" <<'EOF' -#!/bin/bash -echo launch >>"$TEST_LOG" -EOF -chmod +x "$test_bin/omarchy-launch-floating-terminal-with-presentation" - +# The shell runs the click command, so the invitation must not need a unit of its +# own to keep a blocked sender alive until the toast is answered. cat >"$test_bin/systemd-run" <<'EOF' #!/bin/bash echo "systemd-run:$*" >>"$TEST_LOG" -while (($# > 0)); do - case $1 in - -p) shift 2 ;; - -*) shift ;; - *) break ;; - esac -done -exec "$@" EOF chmod +x "$test_bin/systemd-run" @@ -51,19 +42,14 @@ run_invitation_hook [[ -f $test_home/.local/state/omarchy/done/voxtype-install-invitation ]] || fail "Voxtype invitation records completion" [[ -f $hook_path ]] || fail "Voxtype invitation keeps its hook installed" -[[ $(grep -c '^systemd-run:' "$log_file") -eq 2 ]] || fail "Voxtype invitation uses durable user services" -grep -q -- '--user --collect --quiet --service-type=exec --unit=omarchy-voxtype-install-invitation' "$log_file" || fail "Voxtype invitation configures its user service" -# KillMode=process keeps the launcher's setsid child alive once the short-lived -# main process exits, otherwise the install terminal never appears. -grep -q -- '--user --collect --quiet -p KillMode=process --unit=omarchy-voxtype-install ' "$log_file" || fail "Voxtype invitation outlives its launcher unit" [[ $(grep -c '^notification$' "$log_file") -eq 1 ]] || fail "Voxtype invitation sends one notification" -[[ $(grep -c '^launch$' "$log_file") -eq 1 ]] || fail "Voxtype invitation handles the notification action" +grep -qx 'exec:omarchy-launch-floating-terminal-with-presentation omarchy-voxtype-install' "$log_file" || + fail "Voxtype invitation attaches the installer to the notification" +grep -q '^systemd-run:' "$log_file" && fail "Voxtype invitation needs no unit to hold an unanswered toast" HOME="$test_home" PATH="$test_bin:$ROOT/bin:$PATH" TEST_LOG="$log_file" bash "$hook_path" [[ -f $hook_path ]] || fail "completed Voxtype invitation keeps its hook installed" -[[ $(grep -c '^systemd-run:' "$log_file") -eq 2 ]] || fail "completed Voxtype invitation does not schedule again" [[ $(grep -c '^notification$' "$log_file") -eq 1 ]] || fail "completed Voxtype invitation hook does not notify again" -[[ $(grep -c '^launch$' "$log_file") -eq 1 ]] || fail "completed Voxtype invitation hook does not launch again" pass "Voxtype invitation only runs once"