* Keep clicking a notification working after a shell restart Notification actions lived only in the sending process: `-a` appended `-A default=default`, so notify-send blocked on a D-Bus ActionInvoked signal and the caller ran the command when it arrived. Nothing about that reached disk, so a restored popup had no action to run and its sender stayed blocked forever. Replace `-a` with `--exec <command>`, carried as an `omarchy-exec` hint into the snapshot's `exec` role. It travels through the popup files and history, and the shell runs it on click, so restored toasts behave exactly like live ones and the sender exits immediately. That drops the scaffolding whose only job was keeping a blocked sender alive: the first-run invitations lose their `--show` re-entry and two transient units each, omarchy-migrate-notify loses its transient service, and the screenshot, recording, download, and taildrop toasts lose their wrapper subshells. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Keep a failed toast from failing the work it announces Moving these sends out of their backgrounded subshells put a fallible command on the foreground path, where the `&` used to swallow its exit status. A notification outage — including the shell restart this branch targets — now propagates: - taildrop's receiver dies under `set -e` mid-delivery - omarchy-capture-screenshot reports failure for a screenshot it already saved - a completed download exits before scheduling its thumbnail cleanup, leaking the mktemp file Announcing is best-effort in all three: the work is already done by the time the toast goes out. Also drop the first-run sleep that spaced out the welcome and Wi-Fi toasts. It compensated for the background notify-send processes this branch removes; each send now returns only once the server has taken the toast, so sending in order is enough to stack them newest-on-top. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Stop tying the preview cleanup to the toast's expiry The shell loads a notification thumbnail into memory when the toast appears and never re-reads the file, so the preview only has to outlive that load. Deriving the cleanup delay from the expiry was false precision, and it turned -t into a variable for no reason: -t is already the helper's expiry setting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
289 lines
10 KiB
Bash
Executable File
289 lines
10 KiB
Bash
Executable File
#!/bin/bash
|
|
|
|
# omarchy:summary=Start or stop screen recording
|
|
# omarchy:group=capture
|
|
# omarchy:args=[--fullscreen] [--with-desktop-audio] [--with-microphone-audio] [--with-webcam] [--webcam-device=<device>] [--webcam-size=<small|medium|large>] [--resolution=<size>] [--stop-recording]
|
|
# omarchy:examples=omarchy screenrecord | omarchy capture screenrecording --with-desktop-audio
|
|
# omarchy:aliases=omarchy screenrecord
|
|
#
|
|
# Env: OMARCHY_SCREENRECORD_USE_PORTAL=true skips the built-in slurp picker and
|
|
# uses gpu-screen-recorder's xdg-desktop-portal capture backend instead. The
|
|
# portal backend was originally added (PR #3401) for HDR-aware capture, support
|
|
# for monitors driven by external GPUs, and window capture — enable it if any
|
|
# of those matter to you. Off by default because the portal path can fail EGL
|
|
# DMA-BUF modifier import on some configurations, leaving recording unable to
|
|
# start.
|
|
#
|
|
# Env: OMARCHY_SCREENRECORD_DEBUG=true appends gpu-screen-recorder's stderr (and
|
|
# the picker target it was launched with) to /tmp/omarchy-screenrecord.log so
|
|
# users can attach a log when reporting capture failures.
|
|
|
|
[[ -f ~/.config/user-dirs.dirs ]] && source ~/.config/user-dirs.dirs
|
|
OUTPUT_DIR="${OMARCHY_SCREENRECORD_DIR:-${XDG_VIDEOS_DIR:-$HOME/Videos}}"
|
|
|
|
if [[ ! -d $OUTPUT_DIR ]]; then
|
|
omarchy-notification-send -u critical -t 3000 "Screen recording directory does not exist: $OUTPUT_DIR"
|
|
exit 1
|
|
fi
|
|
|
|
DESKTOP_AUDIO="false"
|
|
MICROPHONE_AUDIO="false"
|
|
WEBCAM="false"
|
|
WEBCAM_DEVICE=""
|
|
WEBCAM_SIZE="medium"
|
|
RESOLUTION=""
|
|
FULLSCREEN="false"
|
|
STOP_RECORDING="false"
|
|
RECORDING_FILE="/tmp/omarchy-screenrecord-filename"
|
|
REGION_FILE="${XDG_RUNTIME_DIR:-/tmp}/omarchy-screenrecord-region"
|
|
LOG_FILE=$([[ ${OMARCHY_SCREENRECORD_DEBUG:-false} == "true" ]] && echo "/tmp/omarchy-screenrecord.log" || echo "/dev/null")
|
|
|
|
for arg in "$@"; do
|
|
case "$arg" in
|
|
--with-desktop-audio) DESKTOP_AUDIO="true" ;;
|
|
--with-microphone-audio) MICROPHONE_AUDIO="true" ;;
|
|
--with-webcam) WEBCAM="true" ;;
|
|
--webcam-device=*) WEBCAM_DEVICE="${arg#*=}" ;;
|
|
--webcam-size=*) WEBCAM_SIZE="${arg#*=}" ;;
|
|
--resolution=*) RESOLUTION="${arg#*=}" ;;
|
|
--fullscreen) FULLSCREEN="true" ;;
|
|
--stop-recording) STOP_RECORDING="true" ;;
|
|
esac
|
|
done
|
|
|
|
case $WEBCAM_SIZE in
|
|
small | medium | large) ;;
|
|
*)
|
|
echo "Invalid webcam size: $WEBCAM_SIZE (expected small, medium, or large)" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
start_webcam_overlay() {
|
|
cleanup_webcam
|
|
|
|
# Auto-detect first available webcam if none specified
|
|
if [[ -z $WEBCAM_DEVICE ]]; then
|
|
WEBCAM_DEVICE=$(v4l2-ctl --list-devices 2>/dev/null | grep -m1 "^[[:space:]]*/dev/video" | tr -d '\t')
|
|
if [[ -z $WEBCAM_DEVICE ]]; then
|
|
omarchy-notification-send -u critical -t 3000 "No webcam devices found"
|
|
return 1
|
|
fi
|
|
fi
|
|
|
|
# Try preferred 16:9 resolutions in order, use first available
|
|
local preferred_resolutions=("640x360" "1280x720" "1920x1080")
|
|
local capture_options="framerate=30"
|
|
local available_formats=$(v4l2-ctl --list-formats-ext -d "$WEBCAM_DEVICE" 2>/dev/null)
|
|
|
|
for resolution in "${preferred_resolutions[@]}"; do
|
|
if echo "$available_formats" | grep -q "$resolution"; then
|
|
capture_options="video_size=$resolution,$capture_options"
|
|
break
|
|
fi
|
|
done
|
|
|
|
mpv "av://v4l2:$WEBCAM_DEVICE" \
|
|
--profile=low-latency --untimed --no-cache \
|
|
--demuxer-lavf-o="$capture_options" \
|
|
'--vf=lavfi=[crop=ih*8/9:ih]' \
|
|
--title="WebcamOverlay" --wayland-app-id="WebcamOverlay-$WEBCAM_SIZE" \
|
|
--no-border --no-audio --no-osc --osd-level=0 \
|
|
--really-quiet &>/dev/null &
|
|
|
|
# The move has to settle before gpu-screen-recorder starts, or the camera is
|
|
# recorded sliding into its corner. Waiting for the map is what the blind
|
|
# second was partly guessing at, so the remainder is trimmed to hold the
|
|
# pre-capture delay where it was: starting later costs the first words spoken.
|
|
local waited=0
|
|
while ((waited < 40)) && ! hyprctl clients -j | jq -e 'any(.[]; .title == "WebcamOverlay")' >/dev/null 2>&1; do
|
|
sleep 0.05
|
|
((waited++))
|
|
done
|
|
|
|
[[ ${1:-} == region:* ]] && echo "${1#region:}" >"$REGION_FILE"
|
|
omarchy-capture-webcam-resize "$WEBCAM_SIZE"
|
|
|
|
sleep 0.6
|
|
}
|
|
|
|
cleanup_webcam() {
|
|
pkill -f "WebcamOverlay" 2>/dev/null
|
|
rm -f "$REGION_FILE"
|
|
}
|
|
|
|
default_resolution() {
|
|
local width height
|
|
read -r width height < <(hyprctl monitors -j | jq -r '.[] | select(.focused == true) | "\(.width) \(.height)"')
|
|
if ((width > 3840 || height > 2160)); then
|
|
echo "3840x2160"
|
|
else
|
|
echo "0x0"
|
|
fi
|
|
}
|
|
|
|
# Echoes "monitor:NAME" when the selection matches an entire monitor (prefer
|
|
# -w <monitor> over a region capture — same kms backend, but no scaling math
|
|
# and full native res), otherwise "region:WxH+X+Y". Returns non-zero if the
|
|
# user cancelled the picker.
|
|
select_capture_target() {
|
|
local target
|
|
target=$(omarchy-capture-region smart --match-monitor) || return 1
|
|
|
|
if [[ $target == monitor:* ]]; then
|
|
echo "$target"
|
|
return
|
|
fi
|
|
|
|
[[ $target =~ ^(-?[0-9]+),(-?[0-9]+)[[:space:]]([0-9]+)x([0-9]+)$ ]] || return 1
|
|
|
|
# gpu-screen-recorder wants region geometry in the compositor's logical
|
|
# coordinate space — same space slurp returns — so pass the values through
|
|
# untouched. (gsr scales to physical pixels itself based on the monitor.)
|
|
echo "region:${BASH_REMATCH[3]}x${BASH_REMATCH[4]}+${BASH_REMATCH[1]}+${BASH_REMATCH[2]}"
|
|
}
|
|
|
|
start_screenrecording() {
|
|
local capture_args=()
|
|
local target
|
|
|
|
# Opt-in path for HDR, external-GPU monitors, and window capture (all things
|
|
# the portal backend supports and the kms backend doesn't). Default flow uses
|
|
# slurp + the kms backend, which avoids the EGL DMA-BUF modifier import
|
|
# failures the portal path can hit on some configurations.
|
|
if [[ $FULLSCREEN == "true" ]]; then
|
|
target="monitor:$(omarchy-hyprland-monitor-focused)"
|
|
capture_args=(-w "${target#monitor:}" -s "${RESOLUTION:-$(default_resolution)}")
|
|
elif [[ ${OMARCHY_SCREENRECORD_USE_PORTAL:-false} == "true" ]]; then
|
|
target="portal"
|
|
capture_args=(-w portal -s "${RESOLUTION:-$(default_resolution)}")
|
|
else
|
|
target=$(select_capture_target) || return 1
|
|
|
|
case $target in
|
|
monitor:*)
|
|
capture_args=(-w "${target#monitor:}" -s "${RESOLUTION:-$(default_resolution)}")
|
|
;;
|
|
region:*)
|
|
capture_args=(-w "${target#region:}")
|
|
[[ -n $RESOLUTION ]] && capture_args+=(-s "$RESOLUTION")
|
|
;;
|
|
esac
|
|
fi
|
|
|
|
[[ $WEBCAM == "true" ]] && start_webcam_overlay "$target"
|
|
|
|
local filename="$OUTPUT_DIR/screenrecording-$(date +'%Y-%m-%d_%H-%M-%S').mp4"
|
|
local audio_devices=""
|
|
local audio_args=()
|
|
|
|
[[ $DESKTOP_AUDIO == "true" ]] && audio_devices+="default_output"
|
|
|
|
if [[ $MICROPHONE_AUDIO == "true" ]]; then
|
|
# Merge audio tracks into one - separate tracks only play one at a time in most players
|
|
[[ -n $audio_devices ]] && audio_devices+="|"
|
|
audio_devices+="default_input"
|
|
fi
|
|
|
|
[[ -n $audio_devices ]] && audio_args+=(-a "$audio_devices" -ac aac)
|
|
|
|
echo "===== $(date '+%F %T') args: $* target: $target =====" >>"$LOG_FILE"
|
|
gpu-screen-recorder "${capture_args[@]}" -k auto -f 60 -fm cfr -fallback-cpu-encoding yes -o "$filename" "${audio_args[@]}" 2>>"$LOG_FILE" &
|
|
local pid=$!
|
|
|
|
while kill -0 $pid 2>/dev/null && [[ ! -f $filename ]]; do
|
|
sleep 0.2
|
|
done
|
|
|
|
if kill -0 $pid 2>/dev/null; then
|
|
echo "$filename" >"$RECORDING_FILE"
|
|
toggle_screenrecording_indicator
|
|
fi
|
|
}
|
|
|
|
stop_screenrecording() {
|
|
pkill -SIGINT -f "^gpu-screen-recorder" # SIGINT required to save video properly
|
|
|
|
# Wait a maximum of 5 seconds to finish before hard killing
|
|
local count=0
|
|
while pgrep -f "^gpu-screen-recorder" >/dev/null && ((count < 50)); do
|
|
sleep 0.1
|
|
count=$((count + 1))
|
|
done
|
|
|
|
toggle_screenrecording_indicator
|
|
cleanup_webcam
|
|
|
|
if pgrep -f "^gpu-screen-recorder" >/dev/null; then
|
|
pkill -9 -f "^gpu-screen-recorder"
|
|
omarchy-notification-send -u critical -t 5000 "Screen recording error" "Recording process had to be force-killed. Video may be corrupted."
|
|
else
|
|
finalize_recording
|
|
local filename=$(cat "$RECORDING_FILE" 2>/dev/null)
|
|
echo "$filename"
|
|
local preview="${filename%.mp4}-preview.png"
|
|
|
|
# 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.
|
|
(
|
|
sleep 2
|
|
rm -f "$preview"
|
|
) &
|
|
fi
|
|
|
|
rm -f "$RECORDING_FILE"
|
|
}
|
|
|
|
toggle_screenrecording_indicator() {
|
|
omarchy-shell -q omarchy.indicators refresh
|
|
}
|
|
|
|
screenrecording_active() {
|
|
pgrep -f "^gpu-screen-recorder" >/dev/null
|
|
}
|
|
|
|
finalize_recording() {
|
|
local latest
|
|
latest=$(cat "$RECORDING_FILE" 2>/dev/null)
|
|
[[ -f $latest ]] || return
|
|
|
|
# Re-encode only when the first GOP contains discardable warmup packets — stream copy can't
|
|
# trim those (it rewinds to the keyframe). Clean recordings stay on the fast stream-copy path.
|
|
local video_codec=(-c:v copy)
|
|
if ffprobe -v error -select_streams v:0 -read_intervals %+0.2 -show_entries packet=flags -of csv=p=0 "$latest" 2>/dev/null | grep -q D; then
|
|
video_codec=(-c:v libx264 -preset veryfast -crf 20)
|
|
fi
|
|
|
|
# Trim the first frame, and normalize audio to -14 LUFS if present, in a single pass
|
|
local args=(-y -ss 0.1 -i "$latest" "${video_codec[@]}")
|
|
if ffprobe -v error -select_streams a -show_entries stream=codec_type -of csv=p=0 "$latest" 2>/dev/null | grep -q audio; then
|
|
# Hard-mute the first 400ms to drop the PipeWire capture-open pop (a near-clipping
|
|
# transient around 130-200ms that a gentle fade-in can't attenuate enough), then a
|
|
# 50ms fade avoids a click at the boundary before loudnorm normalizes the rest.
|
|
args+=(-af "volume=enable='lt(t,0.4)':volume=0,afade=t=in:st=0.4:d=0.05,loudnorm=I=-14:TP=-1.5:LRA=11")
|
|
fi
|
|
|
|
local processed="${latest%.mp4}-processed.mp4"
|
|
if ffmpeg "${args[@]}" "$processed" -loglevel quiet 2>/dev/null; then
|
|
mv "$processed" "$latest"
|
|
else
|
|
rm -f "$processed"
|
|
fi
|
|
}
|
|
|
|
if screenrecording_active; then
|
|
stop_screenrecording
|
|
elif [[ $STOP_RECORDING == "true" ]]; then
|
|
exit 1
|
|
else
|
|
start_screenrecording || cleanup_webcam
|
|
fi
|