max / alloy
5 files changed,
+251 insertions,
-14 deletions
| @@ -333,6 +333,13 @@ | |||
| 333 | 333 | xdg-desktop-portal xdg-desktop-portal-gtk xdg-desktop-portal-wlr \ | |
| 334 | 334 | # Notifications, screenshot annotate, wallpaper (bar = sway's built-in swaybar) | |
| 335 | 335 | mako \ | |
| 336 | + | # notify-send, which is the *client* half. mako implements the | |
| 337 | + | # notification server and does not pull this in, so it has been arriving | |
| 338 | + | # as somebody else's transitive dependency. usr/bin/alloy-shot calls it on | |
| 339 | + | # every screenshot, and the failure mode if it goes missing is the exact | |
| 340 | + | # one that script was written to fix: a capture that happens and says | |
| 341 | + | # nothing. Same reasoning as jq below. | |
| 342 | + | libnotify \ | |
| 336 | 343 | satty \ | |
| 337 | 344 | swww \ | |
| 338 | 345 | # Terminal, editor, shell, prompt | |
| @@ -562,6 +569,51 @@ | |||
| 562 | 569 | && cp /usr/lib64/systemd/system/swayosd-libinput-backend.service \ | |
| 563 | 570 | /usr/lib/systemd/system/swayosd-libinput-backend.service | |
| 564 | 571 | ||
| 572 | + | # The same packaging bug again, in udev this time, and it is why the brightness | |
| 573 | + | # keys do nothing. | |
| 574 | + | # | |
| 575 | + | # Found on real hardware 2026-07-29: the volume Fn keys show an OSD and the | |
| 576 | + | # brightness ones do nothing at all, silently. SwayOSD raises brightness by | |
| 577 | + | # writing /sys/class/backlight/<dev>/brightness directly, which ships | |
| 578 | + | # root-owned 0644. The package knows this and ships the rule that fixes it: | |
| 579 | + | # | |
| 580 | + | # ACTION=="add", SUBSYSTEM=="backlight", RUN+="/bin/chgrp video /sys/class/backlight/%k/brightness" | |
| 581 | + | # ACTION=="add", SUBSYSTEM=="backlight", RUN+="/bin/chmod g+w /sys/class/backlight/%k/brightness" | |
| 582 | + | # | |
| 583 | + | # It ships it at /usr/lib64/udev/rules.d/99-swayosd.rules. udev reads | |
| 584 | + | # /usr/lib/udev/rules.d, /run/udev/rules.d and /etc/udev/rules.d, and on this | |
| 585 | + | # base /usr/lib64/udev is a real directory rather than a symlink, so the rule | |
| 586 | + | # never fires and the permissions never change. Same defect as the unit above, | |
| 587 | + | # same cause, different subsystem. | |
| 588 | + | # | |
| 589 | + | # Why it failed silently rather than logging: swayosd-server is started by | |
| 590 | + | # `exec swayosd-server` from the sway config, so its stderr goes nowhere a | |
| 591 | + | # journal can see it. A permission error on the sysfs write has no reader. | |
| 592 | + | # | |
| 593 | + | # Copied rather than symlinked, and asserted first, for the same reasons as the | |
| 594 | + | # unit: the copy survives the package moving the file, and the day Fedora fixes | |
| 595 | + | # the path this build fails loudly instead of installing a stale duplicate that | |
| 596 | + | # quietly disagrees with the packaged one. | |
| 597 | + | # | |
| 598 | + | # This is half the fix. The rule chgrp's to `video`, and a group with no members | |
| 599 | + | # grants nothing, so the installer puts the account in it — see the `useradd` | |
| 600 | + | # stage in crates/alloy/src/install.rs. Either half alone leaves brightness | |
| 601 | + | # broken, which is why the group membership is asserted further down rather than | |
| 602 | + | # left to be discovered on a booted machine. | |
| 603 | + | RUN set -eux; \ | |
| 604 | + | packaged=/usr/lib64/udev/rules.d/99-swayosd.rules; \ | |
| 605 | + | canon=/usr/lib/udev/rules.d/99-swayosd.rules; \ | |
| 606 | + | [ -f "$packaged" ] \ | |
| 607 | + | || { echo "SwayOSD no longer ships $packaged — re-check where the backlight rule went" >&2; exit 1; }; \ | |
| 608 | + | [ ! -f "$canon" ] \ | |
| 609 | + | || { echo "SwayOSD now ships the udev rule on udev's search path — drop this workaround" >&2; exit 1; }; \ | |
| 610 | + | grep -q 'SUBSYSTEM=="backlight"' "$packaged" \ | |
| 611 | + | || { echo "$packaged no longer matches the backlight subsystem; copying it would fix nothing" >&2; exit 1; }; \ | |
| 612 | + | grep -q 'chgrp video' "$packaged" \ | |
| 613 | + | || { echo "$packaged no longer chgrp's to video; the installer's group grant is now the wrong group" >&2; exit 1; }; \ | |
| 614 | + | mkdir -p /usr/lib/udev/rules.d; \ | |
| 615 | + | cp "$packaged" "$canon" | |
| 616 | + | ||
| 565 | 617 | # distrobox is pinned. v2 is a Go rewrite, at rc.3 as of 2026-06-29, and | |
| 566 | 618 | # upstream's own announcement says v1 stays the production recommendation | |
| 567 | 619 | # and that exported binaries and apps must be re-exported after | |
| @@ -700,6 +752,40 @@ | |||
| 700 | 752 | RUN grep -q -- '--cmd alloy-session' /etc/greetd/config.toml \ | |
| 701 | 753 | || { echo "greetd does not launch the session wrapper; the skeleton would never be applied" >&2; exit 1; } | |
| 702 | 754 | ||
| 755 | + | # ===================================================================== | |
| 756 | + | # The screenshot helper — assert the binds reach it. | |
| 757 | + | # | |
| 758 | + | # Not the boot path, so a lighter case than alloy-session above, but the | |
| 759 | + | # same silence: the four Print binds `exec alloy-shot <mode>`, and sway's | |
| 760 | + | # exec reports a command it cannot run to its own log and nowhere the | |
| 761 | + | # person pressing the key will see. Missing, non-executable, or a mode | |
| 762 | + | # named in the config that the script does not answer to, and the keys go | |
| 763 | + | # back to doing nothing, which is the state this whole helper exists to | |
| 764 | + | # end. | |
| 765 | + | # | |
| 766 | + | # The modes are read back out of the config's binds and asked of the | |
| 767 | + | # script, so the two cannot drift: a renamed mode fails here rather than | |
| 768 | + | # on a keypress. Every dependency the script shells out to is checked in | |
| 769 | + | # the same pass, since each one is a bind that silently stops working. | |
| 770 | + | # ===================================================================== | |
| 771 | + | RUN set -eux; \ | |
| 772 | + | test -x /usr/bin/alloy-shot \ | |
| 773 | + | || { echo "alloy-shot is missing or not executable; every Print bind would do nothing" >&2; exit 1; }; \ | |
| 774 | + | sh -n /usr/bin/alloy-shot \ | |
| 775 | + | || { echo "alloy-shot does not parse; every Print bind would do nothing" >&2; exit 1; }; \ | |
| 776 | + | for tool in grim slurp jq swaymsg satty notify-send; do \ | |
| 777 | + | command -v "$tool" >/dev/null \ | |
| 778 | + | || { echo "alloy-shot needs $tool and it is not in the image" >&2; exit 1; }; \ | |
| 779 | + | done; \ | |
| 780 | + | modes=$(sed -n 's/^bindsym [^ ]*Print *exec alloy-shot \([a-z]*\).*/\1/p' /etc/skel/.config/sway/config); \ | |
| 781 | + | [ -n "$modes" ] \ | |
| 782 | + | || { echo "no Print bind in the shipped sway config calls alloy-shot" >&2; exit 1; }; \ | |
| 783 | + | for mode in $modes; do \ | |
| 784 | + | grep -q "^ $mode)" /usr/bin/alloy-shot \ | |
| 785 | + | || { echo "the sway config binds alloy-shot $mode, which the script does not handle" >&2; exit 1; }; \ | |
| 786 | + | done; \ | |
| 787 | + | echo "alloy-shot: $(echo "$modes" | wc -l) bound modes, all handled" | |
| 788 | + | ||
| 703 | 789 | # ===================================================================== | |
| 704 | 790 | # polkit rules — assert the grant is not inert. | |
| 705 | 791 | # |
| @@ -54,10 +54,12 @@ | |||
| 54 | 54 | ||
| 55 | 55 | ## Screenshot stack | |
| 56 | 56 | ||
| 57 | - | - **grim** for capture, **slurp** for region select. sway has no built-in screenshot (Niri did, which is why this stack changed in the pivot); grim+slurp is the canonical wlroots pairing. Bound in the sway config: Print (full), Shift+Print (region via slurp), Ctrl+Print (active window). The active-window grab reads the focused rect out of `swaymsg -t get_tree` with jq, which the fedora-bootc base already carries, so it costs nothing to ship. | |
| 58 | - | - **satty** for annotation when needed (Rust, modern, replaces swappy). Mod+Print annotates the most recent capture. | |
| 57 | + | - **grim** for capture, **slurp** for region select. sway has no built-in screenshot (Niri did, which is why this stack changed in the pivot); grim+slurp is the canonical wlroots pairing. The active-window grab reads the focused rect out of `swaymsg -t get_tree` with jq, a declared dependency of the image. | |
| 58 | + | - **satty** for annotation when needed (Rust, modern, replaces swappy). | |
| 59 | 59 | - **wl-clipboard** for clipboard plumbing. | |
| 60 | 60 | ||
| 61 | + | The four binds go through [`alloy-shot`](../usr/bin/alloy-shot) rather than calling grim directly: Print (full output), Shift+Print (region via slurp), Ctrl+Print (active window), Mod+Print (annotate the most recent capture in satty). **A capture raises a notification**, which is the reason that script exists. The binds were four inline grim calls until 2026-07-29, when Print Screen was reported as a dead keybind on the one running install: it had been capturing correctly the whole time and saying nothing, and from the keyboard those are the same thing. Passing through one script also means the timestamp in the filename is expanded once rather than once per command, and cancelling a region select with Escape is not reported as an error. | |
| 62 | + | ||
| 61 | 63 | Rejected: swappy (dated annotator; satty replaces it), wayshot (grim is the more standard wlroots grabber). | |
| 62 | 64 | ||
| 63 | 65 | ## File manager | |
| @@ -298,6 +300,10 @@ | |||
| 298 | 300 | ||
| 299 | 301 | **Sway integration:** Fn keys bound to `swayosd-client --output-volume raise` and similar in the sway config (see the config for the block). | |
| 300 | 302 | ||
| 303 | + | **Volume and brightness do not travel the same path, and brightness needs two grants to work.** Volume goes through PipeWire, a userspace daemon, so nothing privileged is involved. Brightness is a direct write to `/sys/class/backlight/<dev>/brightness`, which ships root-owned `0644`. SwayOSD packages the udev rule that fixes it (chgrp to `video`, add group write) and installs it into `/usr/lib64/udev/rules.d/`, which udev does not read, so on Fedora the rule never fires. The Containerfile copies it onto udev's search path, the same shim and for the same reason as the libinput unit above, and the installer puts the account in `video` — a rule that chgrp's to an empty group grants nothing. Either half alone leaves the brightness keys dead. | |
| 304 | + | ||
| 305 | + | That was the state on the first real install, found 2026-07-29: volume keys showed an OSD and brightness keys did nothing, with no error anywhere. `swayosd-server` is started by the sway config's `exec`, so its stderr has no reader and the failed sysfs write was never logged. | |
| 306 | + | ||
| 301 | 307 | Rejected: avizo (Python, less maintained), custom mako notifications for the OSD (mako is for notifications, not indicator overlays; different job). | |
| 302 | 308 | ||
| 303 | 309 | ### Media keys: **playerctl** |
| @@ -786,11 +786,28 @@ | |||
| 786 | 786 | .arg("--force"), | |
| 787 | 787 | ), | |
| 788 | 788 | // useradd rather than a sysusers entry because this account needs a home | |
| 789 | - | // and a supplementary group. wheel is the group Fedora's polkit resolves | |
| 789 | + | // and supplementary groups. wheel is the group Fedora's polkit resolves | |
| 790 | 790 | // an administrator to, which is what `run0` and every writing console | |
| 791 | 791 | // view ask for, and an install whose only account cannot escalate is one | |
| 792 | 792 | // with no way to administer itself. See wiki note `alloy-privilege`. | |
| 793 | 793 | // | |
| 794 | + | // video is what makes the brightness keys work, and it is not | |
| 795 | + | // interchangeable with wheel. SwayOSD sets brightness by writing | |
| 796 | + | // /sys/class/backlight/<dev>/brightness, which is root-owned 0644 until | |
| 797 | + | // a udev rule chgrp's it to `video` and adds group write; the | |
| 798 | + | // Containerfile puts that rule on udev's search path, since Fedora ships | |
| 799 | + | // it somewhere udev does not read. Both halves are required: the rule | |
| 800 | + | // with an empty group grants nothing, and the group with no rule has | |
| 801 | + | // nothing to grant. Found the hard way on 2026-07-29, when the only | |
| 802 | + | // running install had a working volume OSD and dead brightness keys. | |
| 803 | + | // | |
| 804 | + | // Not `wheel` doing this job instead: the sysfs write is the *only* | |
| 805 | + | // privilege being handed out here, it is one file per backlight device, | |
| 806 | + | // and `video` is the group every distribution already uses for it. | |
| 807 | + | // Widening wheel's meaning to cover a hardware attribute would put | |
| 808 | + | // brightness control behind administrator rights on any future account | |
| 809 | + | // that deliberately has none. | |
| 810 | + | // | |
| 794 | 811 | // --no-create-home despite the account needing one: --create-home | |
| 795 | 812 | // resolves the path inside the target, which puts the directory in the | |
| 796 | 813 | // deployment's own var, where nothing will ever look for it. useradd | |
| @@ -808,7 +825,7 @@ | |||
| 808 | 825 | .arg("--no-create-home") | |
| 809 | 826 | .args(["--home-dir", &installed_home]) | |
| 810 | 827 | .args(["--shell", LOGIN_SHELL]) | |
| 811 | - | .args(["--groups", "wheel"]) | |
| 828 | + | .args(["--groups", "wheel,video"]) | |
| 812 | 829 | .arg(username), | |
| 813 | 830 | ), | |
| 814 | 831 | // -p because /var/home does not exist yet either. | |
| @@ -2892,6 +2909,38 @@ | |||
| 2892 | 2909 | assert!(useradd.ends_with("max"), "{useradd}"); | |
| 2893 | 2910 | } | |
| 2894 | 2911 | ||
| 2912 | + | // The other half of the brightness fix. SwayOSD writes | |
| 2913 | + | // /sys/class/backlight/<dev>/brightness, and the udev rule the Containerfile | |
| 2914 | + | // puts on udev's search path chgrp's that file to `video` — a group with no | |
| 2915 | + | // members grants nothing, so an account left out of it has dead brightness | |
| 2916 | + | // keys and no error anywhere. The failure is silent on both sides, which is | |
| 2917 | + | // why it is pinned here rather than trusted to the comment above it. | |
| 2918 | + | #[test] | |
| 2919 | + | fn the_account_can_set_the_backlight() { | |
| 2920 | + | let useradd = command_starting("useradd"); | |
| 2921 | + | ||
| 2922 | + | assert!( | |
| 2923 | + | useradd.contains("video"), | |
| 2924 | + | "without `video` the brightness keys do nothing: {useradd}", | |
| 2925 | + | ); | |
| 2926 | + | } | |
| 2927 | + | ||
| 2928 | + | // Both groups arrive in one --groups, because a second --groups silently | |
| 2929 | + | // replaces the first rather than adding to it. A refactor that gave each | |
| 2930 | + | // group its own flag would drop wheel and cost the account its ability to | |
| 2931 | + | // administer the machine, with nothing failing until someone tried. | |
| 2932 | + | #[test] | |
| 2933 | + | fn the_groups_are_one_flag_not_two() { | |
| 2934 | + | let useradd = command_starting("useradd"); | |
| 2935 | + | ||
| 2936 | + | assert_eq!( | |
| 2937 | + | useradd.matches("--groups").count(), | |
| 2938 | + | 1, | |
| 2939 | + | "a second --groups would discard the first: {useradd}", | |
| 2940 | + | ); | |
| 2941 | + | assert!(useradd.contains("--groups wheel,video"), "{useradd}"); | |
| 2942 | + | } | |
| 2943 | + | ||
| 2895 | 2944 | // --create-home resolves its path against --root, which is the deployment, | |
| 2896 | 2945 | // whose var is an empty directory the stateroot's var is mounted over at | |
| 2897 | 2946 | // boot. A home made there is a home the installed system never sees. |
| @@ -210,18 +210,23 @@ | |||
| 210 | 210 | # ------------------------------------------------------------------- | |
| 211 | 211 | # Binds — screenshots (grim; sway has no built-in capture) | |
| 212 | 212 | # ------------------------------------------------------------------- | |
| 213 | - | set $shot ~/Pictures/Screenshots/screenshot-$(date +%Y%m%d-%H%M%S).png | |
| 213 | + | # All four go through alloy-shot, which captures and then says so. They were | |
| 214 | + | # four inline grim calls until 2026-07-29, when Print Screen was reported as a | |
| 215 | + | # dead keybind: it worked the whole time and told nobody, which is the same | |
| 216 | + | # thing from the keyboard. The script's header has the rest, including why a | |
| 217 | + | # `&& notify-send` appended here would have named the wrong file. | |
| 218 | + | # | |
| 219 | + | # Active window was deferred once on the belief that jq had to be installed for | |
| 220 | + | # it; jq is a declared dependency of the image, so the deferral cost nothing but | |
| 221 | + | # the bind. Ctrl+Print was a second copy of the region-select line until then. | |
| 214 | 222 | # Whole output. | |
| 215 | - | bindsym Print exec grim $shot | |
| 216 | - | # Region select. | |
| 217 | - | bindsym Shift+Print exec grim -g "$(slurp)" $shot | |
| 218 | - | # Active window. This was deferred on the belief that jq had to be | |
| 219 | - | # installed for it; jq is in the fedora-bootc base already, so the | |
| 220 | - | # deferral cost nothing but the bind. Ctrl+Print was a second copy of | |
| 221 | - | # the region-select line until now. | |
| 222 | - | bindsym Ctrl+Print exec grim -g "$(swaymsg -t get_tree | jq -r '.. | select(.focused?) | .rect | "\(.x),\(.y) \(.width)x\(.height)"')" $shot | |
| 223 | + | bindsym Print exec alloy-shot output | |
| 224 | + | # Region select. Escape cancels without an error. | |
| 225 | + | bindsym Shift+Print exec alloy-shot region | |
| 226 | + | # Active window. | |
| 227 | + | bindsym Ctrl+Print exec alloy-shot window | |
| 223 | 228 | # Annotate the most recent screenshot with satty. | |
| 224 | - | bindsym $mod+Print exec sh -c 'satty --filename "$(ls -t ~/Pictures/Screenshots/*.png | head -1)"' | |
| 229 | + | bindsym $mod+Print exec alloy-shot annotate | |
| 225 | 230 | ||
| 226 | 231 | # ------------------------------------------------------------------- | |
| 227 | 232 | # Binds — volume / brightness / media (swayosd + playerctl) |
| @@ -1,0 +1,91 @@ | |||
| 1 | + | #!/bin/sh | |
| 2 | + | # alloy-shot — take a screenshot and say that it worked. | |
| 3 | + | # | |
| 4 | + | # The four Print binds in the sway config used to be four inline grim calls. | |
| 5 | + | # They worked, and they were reported as broken anyway, on 2026-07-29, by the | |
| 6 | + | # person who wrote them: pressing Print produced a correct capture and no | |
| 7 | + | # notification, no OSD, no shutter, no window. Nothing at all. The only way to | |
| 8 | + | # learn it had worked was to go and look in the directory, which is | |
| 9 | + | # indistinguishable from a dead keybind. So this exists for the notification | |
| 10 | + | # more than for the capture. | |
| 11 | + | # | |
| 12 | + | # Three things moved in here that were wrong or fragile inline, and are the | |
| 13 | + | # reason this is a script rather than `&& notify-send` appended four times: | |
| 14 | + | # | |
| 15 | + | # 1. The timestamp is taken ONCE. The config's `set $shot` held a literal | |
| 16 | + | # `$(date ...)`, expanded by the shell sway's `exec` runs it through. An | |
| 17 | + | # appended notification naming `$shot` would have expanded it a second time, | |
| 18 | + | # so any capture that straddled a second boundary would have announced a | |
| 19 | + | # filename that does not exist. A notification that lies about the path is | |
| 20 | + | # worse than no notification, and it would have been intermittent. | |
| 21 | + | # 2. Cancelling a region select is not a failure. `slurp` exits non-zero when | |
| 22 | + | # Escape is pressed, which inline made `grim -g ""` run and fail; with a | |
| 23 | + | # notification appended it would have reported an error for a deliberate | |
| 24 | + | # cancel. Here it exits quietly. | |
| 25 | + | # 3. One notification, one wording, one place to change it. | |
| 26 | + | # | |
| 27 | + | # Modes rather than four scripts because the notification and the timestamp are | |
| 28 | + | # the shared part, which is exactly what was missing. | |
| 29 | + | ||
| 30 | + | set -eu | |
| 31 | + | ||
| 32 | + | # XDG_PICTURES_DIR is what xdg-user-dirs writes and what a localized install | |
| 33 | + | # actually uses; ~/Pictures is the fallback for a session that has no user-dirs | |
| 34 | + | # file yet. The sway config also `mkdir -p`s this at startup, and it is repeated | |
| 35 | + | # here because this script is reachable without that having run: from a bind on | |
| 36 | + | # a hand-edited config, from a shell, or on a first login where the exec order | |
| 37 | + | # is not something to depend on. | |
| 38 | + | dir="${XDG_PICTURES_DIR:-$HOME/Pictures}/Screenshots" | |
| 39 | + | mkdir -p "$dir" | |
| 40 | + | ||
| 41 | + | shot="$dir/screenshot-$(date +%Y%m%d-%H%M%S).png" | |
| 42 | + | ||
| 43 | + | # Told, not guessed: a bind that names a mode this does not know is a typo in | |
| 44 | + | # the config, and it should say so rather than silently capture the whole | |
| 45 | + | # screen. | |
| 46 | + | mode="${1:-}" | |
| 47 | + | case "$mode" in | |
| 48 | + | output) | |
| 49 | + | grim "$shot" | |
| 50 | + | ;; | |
| 51 | + | region) | |
| 52 | + | # `|| exit 0` covers the Escape case above. It also swallows a real | |
| 53 | + | # slurp failure, which is the accepted cost: from here the two are the | |
| 54 | + | # same non-zero exit, and treating a cancel as an error is the louder | |
| 55 | + | # mistake. | |
| 56 | + | geometry="$(slurp)" || exit 0 | |
| 57 | + | grim -g "$geometry" "$shot" | |
| 58 | + | ;; | |
| 59 | + | window) | |
| 60 | + | # The focused node's rect, in grim's `x,y WxH` geometry. jq is a stated | |
| 61 | + | # dependency of the image for exactly this line; see the Containerfile's | |
| 62 | + | # package list. | |
| 63 | + | geometry="$(swaymsg -t get_tree \ | |
| 64 | + | | jq -r '.. | select(.focused?) | .rect | "\(.x),\(.y) \(.width)x\(.height)"')" | |
| 65 | + | grim -g "$geometry" "$shot" | |
| 66 | + | ;; | |
| 67 | + | annotate) | |
| 68 | + | # Annotate the most recent capture rather than taking a new one, which | |
| 69 | + | # is the bind's whole point: shoot first, mark it up after. Nothing to | |
| 70 | + | # notify about, because satty opens a window and is its own feedback. | |
| 71 | + | # | |
| 72 | + | # `ls -t` on a glob that matches nothing exits non-zero under `set -e`, | |
| 73 | + | # so an empty directory ends here instead of running satty on an empty | |
| 74 | + | # filename. | |
| 75 | + | latest="$(ls -t "$dir"/*.png 2>/dev/null | head -1)" || exit 0 | |
| 76 | + | [ -n "$latest" ] || exit 0 | |
| 77 | + | exec satty --filename "$latest" | |
| 78 | + | ;; | |
| 79 | + | *) | |
| 80 | + | echo "alloy-shot: expected one of output, region, window, annotate" >&2 | |
| 81 | + | exit 2 | |
| 82 | + | ;; | |
| 83 | + | esac | |
| 84 | + | ||
| 85 | + | # Past here the capture is on disk. The notification is the point of this | |
| 86 | + | # script, so a notification daemon that is not running is worth one line on | |
| 87 | + | # stderr rather than a failed exit: the screenshot was taken either way, and | |
| 88 | + | # `set -e` would otherwise turn a missing daemon into what looks like a failed | |
| 89 | + | # capture. | |
| 90 | + | notify-send -a alloy -i camera-photo "Screenshot saved" "$shot" \ | |
| 91 | + | || echo "alloy-shot: saved $shot, but the notification could not be sent" >&2 |