Compare commits

...

20 Commits

Author SHA1 Message Date
funman300 996f11d333 drop snixembed: waybar provides its own SNI watcher
snixembed registered as the StatusNotifierWatcher but its
RegisterStatusNotifierItem implementation didn't accept SNI publishers
— Telegram, Vesktop, blueman-tray, and a libayatana-appindicator test
script all failed to register, leaving waybar's tray empty.

Removing snixembed lets waybar's tray module register itself as both
host AND watcher (when no other watcher exists). After this change,
all three SNI items registered immediately.

Trade-off: legacy XEmbed-based Wine tray icons no longer bridge to
SNI. The Wine System Tray host window rule is kept (still hides the
empty window off-screen) and the comment updated.

- niri/config.kdl: drop snixembed spawn-at-startup; update Wine rule
- packages.txt: drop snixembed (no longer used)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:10:44 -07:00
funman300 be1fa77c6e niri: propagate XDG_CURRENT_DESKTOP to systemd-user and dbus
The niri 'environment' block only affects processes niri spawns
directly. Apps launched via dbus activation or systemd-user (most
.desktop launches go through one of these) get systemd-user's env,
which still has bare 'niri' — set by niri-session BEFORE niri itself
runs.

Adding a one-shot spawn-at-startup that re-imports XDG_CURRENT_DESKTOP
from niri's env into both systemd-user and dbus-activation, after
niri starts. Future dbus-activated apps will see niri:GNOME.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:35:23 -07:00
funman300 be1d406b26 niri: set XDG_CURRENT_DESKTOP=niri:GNOME for tray support
Many tray libraries (Electron's, Qt's StatusNotifier) gate icon
creation on a recognised XDG_CURRENT_DESKTOP value. Bare "niri"
makes them silently skip tray creation. Adding ":GNOME" as a
fallback identifier keeps niri primary while letting apps create
their tray icons.

Vesktop and Telegram are confirmed to want tray icons but were
not creating them; this fix unblocks both.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:23:42 -07:00
funman300 4303562324 remove nm-applet and blueman-applet autostart
Both daemons were running but not registering SNI items on niri/Wayland,
leaving the waybar tray slot permanently empty. Network is already
accessible via the waybar network module (left-click → networkmanager_dmenu).
Bluetooth GUI (blueman-manager) is still available on demand; only the
applet is dropped.

Tray module stays configured so Electron apps (Discord/Vesktop, Steam,
etc.) still get a tray slot when they're running.

- niri/config.kdl: drop nm-applet and blueman-applet spawn-at-startup
- packages.txt: drop network-manager-applet (nothing else uses it);
  keep blueman package for blueman-manager GUI

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:19:07 -07:00
funman300 d1d3c7f0e9 niri: route volume keys through notification wrapper
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 12:17:31 -07:00
funman300 45ce4594aa volume: add notification wrapper script
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 11:18:22 -07:00
funman300 cc17e7bab4 docs: implementation plan for volume notification
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:09:40 -07:00
funman300 a812715e46 docs: spec for volume change notification
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:36:47 -07:00
funman300 0de20dcd12 power-profile: signal fan widget on profile change
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 17:55:54 -07:00
funman300 841ee432d6 docs: implementation plan for power-profile fan-trigger
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:54:44 -07:00
funman300 5f00fdc2be docs: spec for power-profile→fan sync trigger
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:52:28 -07:00
funman300 096ac3f76a powermenu: route Sleep through suspend-then-hibernate
Aligns the third sleep path (power menu) with the lid handler and
the swayidle 30-min timer — all three now suspend immediately and
hibernate after HibernateDelaySec (30 min).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:30:32 -07:00
funman300 de657685db docs: document one-time hibernation enablement
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 17:24:50 -07:00
funman300 3eb101771e niri: route swayidle 30-min timeout through suspend-then-hibernate
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 17:22:53 -07:00
funman300 fdcec3dd7d logind+sleep: track lid hibernate config; deploy via install.sh
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 17:19:51 -07:00
funman300 e3a6986f81 docs: align lid-hibernate spec with PARTUUID-based resume
Spec said `findmnt -no UUID /` and `resume=UUID=` but the script
correctly uses PARTUUID throughout (matches the system's existing
root=PARTUUID= convention).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:18:25 -07:00
funman300 126b03ad26 scripts: guard enable-hibernation against empty root PARTUUID
If `findmnt -no PARTUUID /` returns nothing (root on LVM, no GPT,
unusual mount state) the script would silently write a broken
resume=PARTUUID= line to /etc/kernel/cmdline. Bail with an error
message instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:18:25 -07:00
funman300 86cfcb0da5 scripts: add enable-hibernation.sh (one-time bootstrap)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 17:08:28 -07:00
funman300 4e69e155d7 docs: implementation plan for lid-close hibernate
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:05:26 -07:00
funman300 d2e9b47584 docs: spec for lid-close hibernate (suspend-then-hibernate)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:59:23 -07:00
16 changed files with 1171 additions and 12 deletions
+11
View File
@@ -25,3 +25,14 @@ Everything is symlinked into `~/.config` via `install.sh`, with packages listed
git clone git@github.com:YOURNAME/dotfiles.git git clone git@github.com:YOURNAME/dotfiles.git
cd dotfiles cd dotfiles
./install.sh ./install.sh
```
### One-time hibernation enablement
Hibernation requires a persistent swap file, kernel resume parameters, and a regenerated UKI. Run once, then reboot:
```bash
sudo bash scripts/enable-hibernation.sh
```
After reboot, `systemctl hibernate` will work, and lid close / 30-min idle will suspend-then-hibernate per the tracked logind config.
@@ -0,0 +1,401 @@
# Lid-Close Hibernate Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make lid close (battery or AC) and 30-min idle both reach hibernation via the kernel's `suspend-then-hibernate` mode, after first enabling hibernation on the system.
**Architecture:** Two layers. (1) A one-time, idempotent bootstrap script (`scripts/enable-hibernation.sh`) that creates a 16 GiB btrfs swap file, adds the `resume` mkinitcpio hook, appends `resume=` + `resume_offset=` to the kernel cmdline, and regenerates the UKI. Reboot required after. (2) Tracked dotfiles (logind drop-in, sleep drop-in, niri swayidle line) deployed by `install.sh` as usual — no reboot needed for these.
**Tech Stack:** bash, systemd-logind, systemd-sleep, mkinitcpio, btrfs, ukify (via `mkinitcpio -P`), niri.
**Spec:** `docs/superpowers/specs/2026-05-10-lid-hibernate-design.md`
**Verification model:** Bash + system commands. Subagents can `bash -n` shell scripts and read INI files but cannot run sudo or reboot. Each task ends with a syntax check + a list of one-line user-side post-deploy verifications the controller will run.
---
## File Structure
| File | Role |
|---|---|
| `scripts/enable-hibernation.sh` | One-time idempotent bootstrap. Creates swap, edits /etc/mkinitcpio.conf, /etc/kernel/cmdline, regenerates UKI. |
| `logind/lid.conf` | Tracked drop-in for `/etc/systemd/logind.conf.d/` — sets all three lid handlers to `suspend-then-hibernate` (or `ignore` for docked). |
| `sleep/hibernate-delay.conf` | Tracked drop-in for `/etc/systemd/sleep.conf.d/` — sets `HibernateDelaySec=30min`. |
| `install.sh` | Modified: adds two `sudo install -Dm644` lines to deploy the drop-ins. |
| `niri/config.kdl` | Modified: line 39 swayidle action `systemctl suspend``systemctl suspend-then-hibernate`. |
| `README.md` | Modified: appended subsection "One-time hibernation enablement" with the run-once instruction. |
---
## Task 1: Bootstrap script
After this task, `sudo bash scripts/enable-hibernation.sh` enables hibernation end-to-end. Re-runs are no-ops. The script does NOT auto-reboot — it prints instructions.
**Files:**
- Create: `scripts/enable-hibernation.sh`
- [ ] **Step 1: Create the script**
Write `scripts/enable-hibernation.sh` with the following exact content:
```bash
#!/bin/bash
# enable-hibernation.sh — one-time setup for laptop hibernation on btrfs.
# Idempotent: re-runs are no-ops once everything is in place. Run with sudo.
# After successful run, reboot before testing `systemctl hibernate`.
set -euo pipefail
if [ "$EUID" -ne 0 ]; then
echo "Run as root: sudo bash $0" >&2
exit 1
fi
SWAPFILE=/swapfile
SWAPSIZE=16g
ROOT_PARTUUID=$(findmnt -no PARTUUID /)
echo "==> Step 1/6: ensure swap file exists at $SWAPFILE (size $SWAPSIZE)"
if [ ! -f "$SWAPFILE" ]; then
btrfs filesystem mkswapfile --size "$SWAPSIZE" "$SWAPFILE"
echo " created $SWAPFILE"
else
actual_bytes=$(stat -c %s "$SWAPFILE")
min_bytes=$((14 * 1024 * 1024 * 1024))
if [ "$actual_bytes" -lt "$min_bytes" ]; then
echo "ERROR: $SWAPFILE exists but is smaller than 14 GiB (got $actual_bytes bytes)." >&2
echo " Remove it manually and re-run." >&2
exit 1
fi
echo " already exists ($((actual_bytes / 1024 / 1024 / 1024)) GiB) — skipping creation"
fi
echo "==> Step 2/6: activate swap and persist in fstab"
if ! swapon --show | grep -q "^$SWAPFILE "; then
swapon "$SWAPFILE"
echo " swapon $SWAPFILE"
else
echo " already active — skipping swapon"
fi
if ! grep -qE "^$SWAPFILE\s" /etc/fstab; then
printf '%s\tnone\tswap\tdefaults\t0 0\n' "$SWAPFILE" >> /etc/fstab
echo " appended to /etc/fstab"
else
echo " /etc/fstab already references $SWAPFILE — skipping"
fi
echo "==> Step 3/6: compute resume params"
RESUME_OFFSET=$(btrfs inspect-internal map-swapfile -r "$SWAPFILE")
echo " resume=PARTUUID=$ROOT_PARTUUID"
echo " resume_offset=$RESUME_OFFSET"
echo "==> Step 4/6: ensure 'resume' hook in /etc/mkinitcpio.conf"
if grep -qE "^HOOKS=\(.*\bresume\b.*\)" /etc/mkinitcpio.conf; then
echo " resume hook already present — skipping"
else
sed -i -E 's/(^HOOKS=\(.*\bblock\b)/\1 resume/' /etc/mkinitcpio.conf
if ! grep -qE "^HOOKS=\(.*\bresume\b.*\)" /etc/mkinitcpio.conf; then
echo "ERROR: failed to insert 'resume' hook. Check /etc/mkinitcpio.conf manually." >&2
exit 1
fi
echo " inserted 'resume' after 'block'"
fi
echo "==> Step 5/6: append resume params to /etc/kernel/cmdline"
if grep -q "resume=" /etc/kernel/cmdline; then
echo " cmdline already contains resume= — skipping"
else
sed -i "1 s|\$| resume=PARTUUID=$ROOT_PARTUUID resume_offset=$RESUME_OFFSET|" /etc/kernel/cmdline
echo " appended to /etc/kernel/cmdline"
fi
echo "==> Step 6/6: regenerate UKI"
mkinitcpio -P
echo
echo "Done. Reboot to activate hibernation:"
echo " sudo reboot"
echo
echo "After reboot, test with: systemctl hibernate"
```
- [ ] **Step 2: Make it executable**
```bash
chmod +x scripts/enable-hibernation.sh
ls -la scripts/enable-hibernation.sh
```
Expected: file mode shows `-rwxr-xr-x` (or similar with `x` bits set).
- [ ] **Step 3: Syntax check**
```bash
bash -n scripts/enable-hibernation.sh && echo "syntax ok"
```
Expected: `syntax ok` and exit 0.
- [ ] **Step 4: Dry-run verify the idempotency guards (without sudo)**
The script bails on the EUID check first if not root, so we can safely "run" it as a non-root user to confirm the bail-out:
```bash
bash scripts/enable-hibernation.sh 2>&1 | head -3
```
Expected output:
```
Run as root: sudo bash scripts/enable-hibernation.sh
```
Exit code 1.
Also visually inspect the script — confirm each of the six steps has a guard (`if [ ! -f ]`, `if ! grep`, etc.) so a re-run is a no-op.
- [ ] **Step 5: Commit**
```bash
git add scripts/enable-hibernation.sh
git commit -m "scripts: add enable-hibernation.sh (one-time bootstrap)"
```
---
## Task 2: Tracked drop-in configs + `install.sh` deploy
After this task, the repo carries the two drop-in files and `install.sh` deploys them. Re-running `install.sh` is safe and overwrites the live drop-ins to match the tracked versions (matches the existing greetd pattern).
**Files:**
- Create: `logind/lid.conf`
- Create: `sleep/hibernate-delay.conf`
- Modify: `install.sh` (add two lines after the existing `==> Deploying greetd config` block)
- [ ] **Step 1: Create `logind/lid.conf`**
```bash
mkdir -p logind
```
Write `logind/lid.conf` with this exact content:
```ini
[Login]
HandleLidSwitch=suspend-then-hibernate
HandleLidSwitchExternalPower=suspend-then-hibernate
HandleLidSwitchDocked=ignore
```
- [ ] **Step 2: Create `sleep/hibernate-delay.conf`**
```bash
mkdir -p sleep
```
Write `sleep/hibernate-delay.conf` with this exact content:
```ini
[Sleep]
HibernateDelaySec=30min
```
- [ ] **Step 3: Verify both INI files**
```bash
cat logind/lid.conf
cat sleep/hibernate-delay.conf
```
Expected: each file's contents print verbatim as above.
- [ ] **Step 4: Add deploy lines to `install.sh`**
Find the existing greetd deploy block in `install.sh`:
```bash
echo "==> Deploying greetd config"
sudo cp "$(pwd)/greetd/config.toml" /etc/greetd/config.toml
sudo cp "$(pwd)/greetd/regreet.toml" /etc/greetd/regreet.toml
```
Immediately *after* the second `sudo cp` line, insert:
```bash
echo "==> Deploying suspend/hibernate config"
sudo install -Dm644 "$(pwd)/logind/lid.conf" /etc/systemd/logind.conf.d/lid.conf
sudo install -Dm644 "$(pwd)/sleep/hibernate-delay.conf" /etc/systemd/sleep.conf.d/hibernate-delay.conf
```
`install -Dm644` creates intermediate directories if they don't exist (the `.conf.d` paths may not exist on a fresh install) and sets file mode to 644 — the right tool for system drop-in files.
- [ ] **Step 5: Bash-syntax-check install.sh**
```bash
bash -n install.sh && echo "syntax ok"
```
Expected: `syntax ok` and exit 0.
- [ ] **Step 6: Confirm the new lines appear in the right place**
```bash
grep -A 4 "==> Deploying suspend" install.sh
```
Expected output:
```
echo "==> Deploying suspend/hibernate config"
sudo install -Dm644 "$(pwd)/logind/lid.conf" /etc/systemd/logind.conf.d/lid.conf
sudo install -Dm644 "$(pwd)/sleep/hibernate-delay.conf" /etc/systemd/sleep.conf.d/hibernate-delay.conf
```
- [ ] **Step 7: Commit**
```bash
git add logind/lid.conf sleep/hibernate-delay.conf install.sh
git commit -m "logind+sleep: track lid hibernate config; deploy via install.sh"
```
---
## Task 3: niri swayidle action
After this task, the 30-min idle path uses `systemctl suspend-then-hibernate` instead of `systemctl suspend`, matching the lid handler.
**Files:**
- Modify: `niri/config.kdl` (line 39)
- [ ] **Step 1: Replace the swayidle action**
In `niri/config.kdl`, find line 39:
```kdl
spawn-at-startup "swayidle" "-w" "timeout" "300" "niri msg action power-off-monitors" "timeout" "600" "gtklock" "-d" "timeout" "1800" "systemctl suspend" "before-sleep" "gtklock" "-d"
```
Replace `"systemctl suspend"` with `"systemctl suspend-then-hibernate"`. The full line becomes:
```kdl
spawn-at-startup "swayidle" "-w" "timeout" "300" "niri msg action power-off-monitors" "timeout" "600" "gtklock" "-d" "timeout" "1800" "systemctl suspend-then-hibernate" "before-sleep" "gtklock" "-d"
```
Only one substring changes; nothing else on the line moves. The `before-sleep "gtklock" "-d"` clause stays — gtklock fires on suspend-then-hibernate's suspend phase too.
- [ ] **Step 2: Validate niri config**
```bash
niri validate 2>&1 | tail -2
```
Expected: a final line `INFO niri: config is valid`. Any "ERROR" or non-zero exit means a syntax issue.
- [ ] **Step 3: Confirm exactly one substitution happened**
```bash
grep -c "systemctl suspend-then-hibernate" niri/config.kdl
grep -c "\"systemctl suspend\"" niri/config.kdl
```
Expected:
- First count: `1` (the new value is present)
- Second count: `0` (the old exact-string `"systemctl suspend"` is gone)
- [ ] **Step 4: Commit**
```bash
git add niri/config.kdl
git commit -m "niri: route swayidle 30-min timeout through suspend-then-hibernate"
```
---
## Task 4: README — One-time hibernation enablement subsection
After this task, the README documents the one-time bootstrap step so a reader setting up the system fresh knows to run the script.
**Files:**
- Modify: `README.md` (append a subsection)
- [ ] **Step 1: Append the new subsection**
The README has exactly one top-level section, `## Setup` (line 22). The new subsection nests under it as `### One-time hibernation enablement`.
Append to the end of `README.md` (after the closing line of the existing Setup section):
````markdown
### One-time hibernation enablement
Hibernation requires a persistent swap file, kernel resume parameters, and a regenerated UKI. Run once, then reboot:
```bash
sudo bash scripts/enable-hibernation.sh
```
After reboot, `systemctl hibernate` will work, and lid close / 30-min idle will suspend-then-hibernate per the tracked logind config.
````
(The outer fence above is four backticks because the block contains a triple-backtick code block — paste the inner content verbatim, no enclosing 4-backtick fence.)
- [ ] **Step 2: Verify the section was added**
```bash
grep -A 6 "One-time hibernation enablement" README.md
```
Expected: the new subsection prints, including the `sudo bash scripts/enable-hibernation.sh` line.
- [ ] **Step 3: Commit**
```bash
git add README.md
git commit -m "docs: document one-time hibernation enablement"
```
---
## Final verification (controller, post-merge)
These can only be run on the live system, after merging and pushing all four task commits.
- [ ] **Bootstrap and reboot**
```bash
sudo bash scripts/enable-hibernation.sh
# Output should show steps 16 with each guard either firing or skipping.
sudo reboot
```
- [ ] **After reboot, verify prerequisites**
```bash
swapon --show # /swapfile, ≥14 GiB
cat /proc/cmdline | grep -oE "resume=\S+ resume_offset=\S+" # both present
grep "^HOOKS=" /etc/mkinitcpio.conf | grep resume # hook in array
```
- [ ] **Re-deploy tracked configs**
```bash
cd ~/Documents/dotfiles
bash install.sh
# Should print "==> Deploying suspend/hibernate config" and the two install lines.
ls -la /etc/systemd/logind.conf.d/lid.conf /etc/systemd/sleep.conf.d/hibernate-delay.conf
```
Expected: both files exist, mode 644, owned by root.
- [ ] **Reload logind so lid.conf takes effect**
```bash
sudo systemctl restart systemd-logind # closes session — log back in after
```
(Optional: a niri restart also picks up the new swayidle args. The change takes effect on next login regardless.)
- [ ] **Functional checks**
1. `systemctl hibernate` — laptop powers off completely; press power → resumes to gtklock in ~2030 s.
2. Close lid on battery — laptop suspends instantly; reopen within 30 min → instant resume; reopen after 30+ min → ~30 s resume from hibernation.
3. Close lid on AC — same as battery (no longer just locks).
4. Leave laptop idle 30 min — `journalctl -u systemd-suspend-then-hibernate.service` shows the action firing.
@@ -0,0 +1,104 @@
# Power-Profile → Fan-Profile Sync Trigger Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make `scripts/power-profile.sh --menu` instantly trigger `scripts/fan-profile.sh` to re-evaluate (so fan auto mode applies the new mapping in under a second instead of waiting for the next 5 s waybar poll).
**Architecture:** One-line change. Chain `pkill -RTMIN+9 waybar` (the fan widget's signal) onto the existing `pkill -RTMIN+8 waybar` after `powerprofilesctl set` succeeds. Reuses the existing single-source-of-truth mapping in `fan-profile.sh::map_profile_to_strategy` — no new logic, no new files.
**Tech Stack:** bash, waybar (`signal:` mechanism), `powerprofilesctl`, `pkill -RTMIN+N`.
**Spec:** `docs/superpowers/specs/2026-05-11-power-profile-fan-trigger-design.md`
**Verification model:** Bash syntax check covers static correctness; end-to-end behaviour requires a live waybar + active power profile change (controller verifies after merge). One task only.
---
## File Structure
| File | Change |
|---|---|
| `scripts/power-profile.sh` | One line modified (line 13): chain `&& pkill -RTMIN+9 waybar` onto the existing `&&`-chained command. |
No new files. No other files touched.
---
## Task 1: Add fan-widget signal to power-profile menu handler
After this task, picking a power profile from the wofi menu fires both the power-widget refresh signal (RTMIN+8) and the fan-widget refresh signal (RTMIN+9). The fan widget's existing handler (in `fan-profile.sh`) checks the auto-mode sentinel and applies the mapped fw-fanctrl strategy if appropriate.
**Files:**
- Modify: `scripts/power-profile.sh` (line 13)
- [ ] **Step 1: Make the one-line change**
Find line 13 in `scripts/power-profile.sh`:
```bash
[ -n "$CHOICE" ] && powerprofilesctl set "$CHOICE" && pkill -RTMIN+8 waybar
```
Replace with:
```bash
[ -n "$CHOICE" ] && powerprofilesctl set "$CHOICE" && pkill -RTMIN+8 waybar && pkill -RTMIN+9 waybar
```
The only change: append ` && pkill -RTMIN+9 waybar` to the end of the existing chain. Indentation, surrounding lines, and the `if`/`exit` block remain untouched.
- [ ] **Step 2: Syntax-check the script**
```bash
bash -n scripts/power-profile.sh && echo "syntax ok"
```
Expected: `syntax ok` and exit 0.
- [ ] **Step 3: Confirm both signals appear in the chain**
```bash
grep -n "pkill -RTMIN" scripts/power-profile.sh
```
Expected output (one line, both signals present):
```
13: [ -n "$CHOICE" ] && powerprofilesctl set "$CHOICE" && pkill -RTMIN+8 waybar && pkill -RTMIN+9 waybar
```
- [ ] **Step 4: Confirm the status-emit branch is unchanged**
```bash
sed -n '17,27p' scripts/power-profile.sh
```
Expected: shows the `CURRENT=$(powerprofilesctl get)` block, `case "$CURRENT" in ... esac`, and `printf` JSON line — all byte-identical to before this commit.
- [ ] **Step 5: End-to-end smoke test (controller — subagent skip)**
Subagents have no display; skip this step. The controller will:
```bash
# Confirm both waybar signals are wired up correctly
powerprofilesctl get # note current profile
~/.local/bin/power-profile --menu # pick a different profile in wofi
powerprofilesctl get # confirm it changed
fw-fanctrl print | grep '^Strategy:' # if fan auto is on, this should match the mapped strategy within ~1 s
```
If fan auto is off (`~/.local/state/fan-profile-auto` does not exist), `fw-fanctrl` strategy stays unchanged — that's the correct behaviour for Option C.
- [ ] **Step 6: Commit**
```bash
git add scripts/power-profile.sh
git commit -m "power-profile: signal fan widget on profile change"
```
---
## Final verification (controller, post-merge)
- [ ] With fan auto on: change power profile via the wofi menu → fan strategy follows within ~1 s (`fw-fanctrl print | grep ^Strategy:` shows the mapped value).
- [ ] With fan auto off: change power profile via the wofi menu → fan strategy unchanged (`fw-fanctrl print | grep ^Strategy:` shows the previously-set manual value).
- [ ] If `powerprofilesctl set` fails (simulate by stubbing `powerprofilesctl` in `PATH` to return exit 1), neither pkill fires — `journalctl --user -u waybar` shows no spurious refreshes.
@@ -0,0 +1,227 @@
# Volume Notification Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the three `pamixer`-direct media-key bindings with calls to a wrapper script that also fires a mako notification showing the new volume level (or "Muted") with a progress bar.
**Architecture:** New `scripts/volume.sh` wrapper accepting `up|down|mute`. Adjusts master sink via `pamixer`, then calls `notify-send` with mako's `x-canonical-private-synchronous` and `int:value` hints so successive notifications replace each other and render as a progress bar. niri keybinds call the wrapper through its `~/.local/bin/volume` symlink. install.sh symlinks the script alongside the existing entries.
**Tech Stack:** bash, pamixer, notify-send, mako (notification daemon), niri.
**Spec:** `docs/superpowers/specs/2026-05-11-volume-notification-design.md`
**Verification model:** Bash syntax check + dry-run script invocation with each subcommand. niri config validity via `niri validate`. End-to-end (actually pressing the volume key and seeing a mako bubble) requires the live desktop — the controller verifies after both task commits.
---
## File Structure
| File | Role |
|---|---|
| `scripts/volume.sh` | New wrapper: pamixer adjust + notify-send. Three subcommands: `up`, `down`, `mute`. |
| `install.sh` | One new `ln -sf` line in the `==> Installing scripts` block. |
| `niri/config.kdl` | Three lines (9799) modified to call `volume up/down/mute` instead of `pamixer` directly. |
---
## Task 1: Wrapper script + install.sh symlink
After this task, `~/.local/bin/volume up|down|mute` works from any shell and produces both the volume change and the mako notification. niri keybinds still call `pamixer` directly — that swap happens in Task 2.
**Files:**
- Create: `scripts/volume.sh`
- Modify: `install.sh` (append one line in the scripts symlink block)
- [ ] **Step 1: Create `scripts/volume.sh`**
Write `scripts/volume.sh` with this exact content:
```bash
#!/bin/bash
case "${1:-}" in
up) pamixer -i 5 ;;
down) pamixer -d 5 ;;
mute) pamixer -t ;;
*) echo "usage: $0 up|down|mute" >&2; exit 1 ;;
esac
if [ "$(pamixer --get-mute)" = "true" ]; then
notify-send -h string:x-canonical-private-synchronous:volume \
-h int:value:0 \
-t 1500 \
"Muted"
else
LEVEL=$(pamixer --get-volume)
notify-send -h string:x-canonical-private-synchronous:volume \
-h int:value:"$LEVEL" \
-t 1500 \
"Volume: ${LEVEL}%"
fi
```
- [ ] **Step 2: Make it executable**
```bash
chmod +x scripts/volume.sh
ls -la scripts/volume.sh
```
Expected: `-rwxr-xr-x` mode.
- [ ] **Step 3: Syntax-check**
```bash
bash -n scripts/volume.sh && echo "syntax ok"
```
Expected: `syntax ok`, exit 0.
- [ ] **Step 4: Dry-run the usage-error branch**
The unrecognised-arg branch is safe to exercise without side effects (pamixer never called):
```bash
bash scripts/volume.sh 2>&1
echo "exit=$?"
```
Expected:
```
usage: scripts/volume.sh up|down|mute
exit=1
```
- [ ] **Step 5: Symlink it into `~/.local/bin`**
```bash
ln -sf "$PWD/scripts/volume.sh" ~/.local/bin/volume
ls -la ~/.local/bin/volume
```
Expected: symlink pointing to `$PWD/scripts/volume.sh` (absolute path of repo's script).
- [ ] **Step 6: Add the symlink line to `install.sh`**
Find the `==> Installing scripts` block in `install.sh`. It currently ends like this:
```bash
ln -sf "$(pwd)/scripts/waybar-restart.sh" ~/.local/bin/waybar-restart
ln -sf "$(pwd)/scripts/screenshot.sh" ~/.local/bin/screenshot
```
Append one line after `screenshot.sh`:
```bash
ln -sf "$(pwd)/scripts/volume.sh" ~/.local/bin/volume
```
The block then ends with three `ln -sf` lines (waybar-restart, screenshot, volume).
- [ ] **Step 7: Sanity-check install.sh parses**
```bash
bash -n install.sh && echo "syntax ok"
```
Expected: `syntax ok`, exit 0.
- [ ] **Step 8: Functional smoke test (controller verifies; subagent skip if no audio session)**
If you have an audio session, run:
```bash
~/.local/bin/volume up
```
Expected: volume rises 5%, a mako bubble appears with `Volume: NN%` and a progress bar, the bubble auto-dismisses in 1.5 s. Then run `~/.local/bin/volume down` and `~/.local/bin/volume mute` and confirm each behaves.
If your sandbox has no audio session or no notification daemon, skip; the controller will verify after the commit.
- [ ] **Step 9: Commit**
```bash
git add scripts/volume.sh install.sh
git commit -m "volume: add notification wrapper script"
```
---
## Task 2: Point niri keybinds at the wrapper
After this task, pressing the three XF86Audio keys runs the wrapper instead of `pamixer` directly. Volume changes now produce notifications.
**Files:**
- Modify: `niri/config.kdl` (lines 9799)
- [ ] **Step 1: Replace the three keybind lines**
Find lines 9799 in `niri/config.kdl`:
```kdl
XF86AudioRaiseVolume allow-when-locked=true { spawn "pamixer" "-i" "5"; }
XF86AudioLowerVolume allow-when-locked=true { spawn "pamixer" "-d" "5"; }
XF86AudioMute allow-when-locked=true { spawn "pamixer" "-t"; }
```
Replace with:
```kdl
XF86AudioRaiseVolume allow-when-locked=true { spawn "volume" "up"; }
XF86AudioLowerVolume allow-when-locked=true { spawn "volume" "down"; }
XF86AudioMute allow-when-locked=true { spawn "volume" "mute"; }
```
`allow-when-locked=true` stays — same behaviour, just routed through the wrapper.
- [ ] **Step 2: Validate niri config**
```bash
niri validate 2>&1 | tail -2
```
Expected: a final line `INFO niri: config is valid`. Non-zero exit or `ERROR` means a syntax issue.
- [ ] **Step 3: Confirm pamixer is no longer referenced in keybinds**
```bash
grep -n "pamixer" niri/config.kdl
```
Expected: zero matches (the wrapper is invoked via `volume`, pamixer is implementation detail).
- [ ] **Step 4: Confirm the three new lines are in place**
```bash
grep -n 'spawn "volume"' niri/config.kdl
```
Expected: three lines, one each for `"up"`, `"down"`, `"mute"`:
```
97: XF86AudioRaiseVolume allow-when-locked=true { spawn "volume" "up"; }
98: XF86AudioLowerVolume allow-when-locked=true { spawn "volume" "down"; }
99: XF86AudioMute allow-when-locked=true { spawn "volume" "mute"; }
```
- [ ] **Step 5: Functional smoke test (controller verifies)**
niri picks up `config.kdl` changes live; no reload needed. Press the volume up key — a mako notification should appear within ~100 ms showing the new level + progress bar. Press mute — notification shows `Muted` with empty bar. Press volume up again — notification returns to `Volume: NN%`.
Each key press should replace the previous notification (no stacking), thanks to the `x-canonical-private-synchronous` hint.
- [ ] **Step 6: Commit**
```bash
git add niri/config.kdl
git commit -m "niri: route volume keys through notification wrapper"
```
---
## Final verification (controller, post-merge)
- [ ] Pressing XF86AudioRaiseVolume / XF86AudioLowerVolume produces a mako notification with the new percentage and a progress bar.
- [ ] Pressing XF86AudioMute toggles the mute state; the notification shows `Muted` (with empty bar) when muting and `Volume: NN%` when unmuting.
- [ ] Holding the key (rapid repeat) produces a single updating bubble, not a stack.
- [ ] Volume changes still work on the lock screen (gtklock). The bubble may not be visible while locked depending on mako's layer ordering relative to gtklock; the volume change itself fires regardless.
- [ ] `~/.local/bin/volume bogus` prints the usage message and exits 1.
@@ -0,0 +1,138 @@
# Lid-Close Hibernate Design
**Date:** 2026-05-10
**Status:** Approved
## Summary
Make the laptop sleep more aggressively to save battery: close the lid (on AC or battery) → suspend immediately → hibernate after 30 min if not reopened. Same path applies to swayidle's existing 30-min idle timer. Reliably reaches 0% battery drain within ~60 min of stopping use, while keeping fast resume for short interruptions.
Requires one-time bootstrap to enable hibernation (swap file, kernel resume params, mkinitcpio hook, UKI regen, reboot). Day-to-day config lives in tracked dotfiles.
## Current Behaviour
Lid handling (`/etc/systemd/logind.conf.d/lid.conf`, not currently tracked in repo):
```
HandleLidSwitch=suspend
HandleLidSwitchExternalPower=lock
HandleLidSwitchDocked=ignore
```
On battery: lid close → suspend (RAM stays powered, slow drain). On AC: lid close → just locks (laptop runs at full power overnight).
swayidle (`niri/config.kdl` line 39):
```
spawn-at-startup "swayidle" "-w" "timeout" "300" "niri msg action power-off-monitors" \
"timeout" "600" "gtklock" "-d" \
"timeout" "1800" "systemctl suspend" \
"before-sleep" "gtklock" "-d"
```
After 30 min idle, calls `systemctl suspend` (not hibernate, no auto-progression).
Hibernation prerequisites are NOT met: only zram swap (4 GiB, RAM-backed, can't persist), no `resume=` kernel param, no `resume` mkinitcpio hook. `systemctl hibernate` would fail.
## Target Behaviour
| Trigger | Immediate action | After 30 min suspended | Result |
|---|---|---|---|
| Lid close (battery or AC) | suspend | hibernate | 0% drain |
| Idle 30 min | suspend | hibernate | 0% drain |
| Lid close while docked | ignore | — | desktop stays awake |
| `systemctl hibernate` | hibernate immediately | — | 0% drain, manual |
`HibernateDelaySec=30min` controls the suspend → hibernate transition. Tunable in one tracked file.
## Implementation
### Hibernation prerequisites (one-time bootstrap)
`scripts/enable-hibernation.sh` — idempotent script, requires sudo, run **once**, reboot afterward:
1. **Swap file.** `btrfs filesystem mkswapfile --size 16g /swapfile` (handles NOCOW, alignment, mkswap in one). Skip if `/swapfile` exists with size ≥14 GiB.
2. **Activate.** `swapon /swapfile`. Append `/swapfile none swap defaults 0 0` to `/etc/fstab` if absent.
3. **Compute resume params.**
- `ROOT_PARTUUID=$(findmnt -no PARTUUID /)` → root partition's GPT PARTUUID (matches the existing `root=PARTUUID=…` convention in `/etc/kernel/cmdline`)
- `RESUME_OFFSET=$(btrfs inspect-internal map-swapfile -r /swapfile)` → physical offset of swap file
4. **mkinitcpio hook.** Edit `/etc/mkinitcpio.conf` HOOKS array to insert `resume` between `block` and `filesystems`. `sed` guarded so a re-run is a no-op.
5. **Kernel cmdline.** Append `resume=PARTUUID=$ROOT_PARTUUID resume_offset=$RESUME_OFFSET` to `/etc/kernel/cmdline` if not already present.
6. **Regenerate UKI.** `mkinitcpio -P` → produces `/boot/EFI/Linux/arch-linux-zen.efi` with new HOOKS + cmdline baked in.
7. **Reboot.** Print explicit instruction; do not auto-reboot.
Why a separate script and not `install.sh`: hibernation enablement is invasive (modifies `/etc/mkinitcpio.conf`, `/etc/kernel/cmdline`, allocates 16 GiB on disk, regenerates the UKI). `install.sh` runs frequently during dotfiles iteration; this should run once.
### Tracked dotfiles (deployed by `install.sh`)
| New file | Destination | Contents |
|---|---|---|
| `logind/lid.conf` | `/etc/systemd/logind.conf.d/lid.conf` | `[Login]`<br>`HandleLidSwitch=suspend-then-hibernate`<br>`HandleLidSwitchExternalPower=suspend-then-hibernate`<br>`HandleLidSwitchDocked=ignore` |
| `sleep/hibernate-delay.conf` | `/etc/systemd/sleep.conf.d/hibernate-delay.conf` | `[Sleep]`<br>`HibernateDelaySec=30min` |
### `install.sh` additions
After the existing greetd block:
```bash
sudo install -Dm644 "$(pwd)/logind/lid.conf" /etc/systemd/logind.conf.d/lid.conf
sudo install -Dm644 "$(pwd)/sleep/hibernate-delay.conf" /etc/systemd/sleep.conf.d/hibernate-delay.conf
```
`install -Dm644` creates intermediate directories if missing and sets correct mode (consistent with system file expectations).
### `niri/config.kdl` swayidle tweak
Line 39: change `"systemctl suspend"` to `"systemctl suspend-then-hibernate"`. The full line becomes:
```kdl
spawn-at-startup "swayidle" "-w" "timeout" "300" "niri msg action power-off-monitors" "timeout" "600" "gtklock" "-d" "timeout" "1800" "systemctl suspend-then-hibernate" "before-sleep" "gtklock" "-d"
```
This makes the 30-min idle path use the same logind/sleep configuration as the lid path. After this edit, idle suspends and lid suspends are identical paths — both transition to hibernation after 30 min suspended.
### `README.md` setup section
Add a paragraph after the existing `install.sh` instructions:
```markdown
### One-time hibernation enablement
Hibernation requires a persistent swap file, kernel resume parameters, and a regenerated UKI. Run once, then reboot:
sudo bash scripts/enable-hibernation.sh
After reboot, `systemctl hibernate` will work, and lid close / 30-min idle will suspend-then-hibernate per the tracked logind config.
```
## Verification
After running `scripts/enable-hibernation.sh` and rebooting:
```bash
swapon --show # /swapfile, ≥14 GiB, prio low
cat /proc/cmdline # contains resume=PARTUUID=… resume_offset=…
systemctl status systemd-hibernate.service
```
Behavioural checks:
1. `systemctl hibernate` — laptop fully powers off, then resumes to gtklock on next press of power button. (Resume takes ~2030 s.)
2. `systemctl suspend-then-hibernate` — laptop suspends instantly. After 30 min, kernel transitions to hibernation. (Don't have to wait for a real test — `journalctl -u systemd-suspend-then-hibernate.service` shows the timer firing.)
3. Close lid on battery → suspends. Reopen within 30 min → instant resume. Reopen after 30 min → ~30 s resume from hibernation.
4. Close lid on AC → same as battery (the asymmetric "AC just locks" behavior is gone).
5. Leave laptop idle 30 min → swayidle calls `systemctl suspend-then-hibernate`, same path as lid.
## Files Touched
| File | Action |
|---|---|
| `logind/lid.conf` | created |
| `sleep/hibernate-delay.conf` | created |
| `scripts/enable-hibernation.sh` | created |
| `install.sh` | append two `sudo install` lines |
| `niri/config.kdl` | one-line edit on line 39 (swayidle action) |
| `README.md` | append a "One-time hibernation enablement" subsection |
## Out of Scope
- **Snapshot-aware swap subvolume.** No snapshot tool (snapper, btrbk) is installed, so a flat `/swapfile` on the root subvolume is safe. If snapshots are added later, the swap file needs to be moved to a dedicated `@swap` subvolume — a future migration, not part of this work.
- **Encrypted hibernation image.** Root is not LUKS, so the hibernation image lands unencrypted on `/swapfile`. If full-disk encryption is added later, the swap file is automatically encrypted along with the rest of root.
- **GUI tools** (`HandleSuspendKey`, `HandlePowerKey`, etc.) — unchanged. Only `Lid*` keys are touched.
- **`HibernateDelaySec` UI exposure.** Tunable in the tracked file; no script or env var to flip it without editing.
- **Auto-reboot from `enable-hibernation.sh`.** The script prints a reboot instruction and exits. Rebooting is a user action.
- **Removing zram swap.** zram stays as a fast in-memory compressed swap layer; hibernation uses `/swapfile` (lower priority is fine — kernel picks higher-priority zram first under memory pressure).
@@ -0,0 +1,68 @@
# Power-Profile → Fan-Profile Sync Trigger Design
**Date:** 2026-05-11
**Status:** Approved
## Summary
When the user changes power profile via `scripts/power-profile.sh --menu`, also nudge the waybar fan widget so `scripts/fan-profile.sh` immediately re-evaluates its auto-mode mapping. Without this nudge, the fan strategy follows the new power profile only on the next 5 s waybar poll; with it, the change is essentially instant. If fan auto mode is off (the `~/.local/state/fan-profile-auto` sentinel is absent), nothing changes — manual fan strategy is preserved.
## Current Behaviour
`scripts/power-profile.sh --menu` ends with:
```bash
[ -n "$CHOICE" ] && powerprofilesctl set "$CHOICE" && pkill -RTMIN+8 waybar
```
Signal `RTMIN+8` is the `signal:` value declared by waybar's `custom/power-profile` module — refreshes the power widget. The fan widget (`signal: 9`) is NOT refreshed; it only re-evaluates on its `interval: 5` poll.
`scripts/fan-profile.sh` already has the coupling logic. When the auto sentinel exists, every status emit:
1. Reads `powerprofilesctl get`
2. Maps it via `map_profile_to_strategy`
3. Calls `fw-fanctrl use <mapped>` if the active strategy differs
So changing the power profile causes the fan strategy to follow within ≤5 s already — but only on the next poll. The user wants this to be instant.
## Target Behaviour
After the user picks a new power profile from the wofi menu:
1. `powerprofilesctl set` applies the change.
2. `pkill -RTMIN+8 waybar` refreshes the power widget.
3. **`pkill -RTMIN+9 waybar` refreshes the fan widget**, which re-runs `fan-profile.sh`. If auto mode is on, fan-profile.sh notices the strategy mismatch and applies the new strategy via `fw-fanctrl use`. If auto mode is off, the script just re-renders the existing strategy (no functional change).
End-to-end latency: well under a second.
## Why This Implementation
Reuses the existing single-source-of-truth mapping in `fan-profile.sh::map_profile_to_strategy`. Power-profile.sh doesn't need to know:
- The mapping table (lives in fan-profile.sh)
- The auto sentinel path (lives in fan-profile.sh)
- The `fw-fanctrl use` command (lives in fan-profile.sh)
It just sends a "hey, you should re-evaluate" signal. Zero duplication. Zero new files. One line.
## Implementation
### `scripts/power-profile.sh`
Change line 13 from:
```bash
[ -n "$CHOICE" ] && powerprofilesctl set "$CHOICE" && pkill -RTMIN+8 waybar
```
to:
```bash
[ -n "$CHOICE" ] && powerprofilesctl set "$CHOICE" && pkill -RTMIN+8 waybar && pkill -RTMIN+9 waybar
```
The chained `&&` means the fan-widget refresh fires only if the power-widget refresh succeeded, which only fires if `powerprofilesctl set` succeeded. So a failure to apply the profile (e.g. dbus not running) skips both refreshes — the bar reflects the unchanged state on its next 5 s poll.
## Files Touched
- `scripts/power-profile.sh` — one-line change to line 13.
## Out of Scope
- **Power-profile changes via tools other than this script.** If the user runs `powerprofilesctl set X` from a terminal or via a GNOME settings panel, the fan still follows on fan-profile.sh's 5 s poll (auto mode). Making that path zero-lag would need a dbus listener for `org.freedesktop.UPower.PowerProfiles` — bigger architectural change, deferred.
- **Changing the mapping** — `power-saver→lazy`, `balanced→medium`, `performance→agile` is locked from the original fan-profile auto spec.
- **Making auto default-on** — auto sentinel still has to be created via the fan wofi menu choice; this spec doesn't change that opt-in.
- **Race condition between powerprofilesctl set and fan-profile.sh re-read.** `powerprofilesctl set` is synchronous on dbus; by the time it returns, `powerprofilesctl get` reflects the new value. No race.
@@ -0,0 +1,99 @@
# Volume Notification Design
**Date:** 2026-05-11
**Status:** Approved
## Summary
Show a mako notification with the current volume level (or "Muted") whenever the user presses the XF86Audio volume keys. Notifications collapse onto themselves so rapid key presses produce a single updating bubble rather than a stack. Mako's progress-bar render shows the current level visually.
## Current Behaviour
`niri/config.kdl` lines 9799 wire the media keys directly to `pamixer`:
```kdl
XF86AudioRaiseVolume allow-when-locked=true { spawn "pamixer" "-i" "5"; }
XF86AudioLowerVolume allow-when-locked=true { spawn "pamixer" "-d" "5"; }
XF86AudioMute allow-when-locked=true { spawn "pamixer" "-t"; }
```
Volume changes silently. The waybar `pulseaudio` widget reflects the new value on its next 5 s poll, but there is no immediate visual feedback at the moment of the keypress — and on the lock screen, no feedback at all (waybar isn't visible).
## Target Behaviour
Each volume keypress:
1. Adjusts the master sink via `pamixer -i 5` / `pamixer -d 5` / `pamixer -t`.
2. Reads the resulting state.
3. Fires a `notify-send` with mako's progress-bar hint, replacing any previous volume notification (no stacking).
| Key | Action | Notification |
|---|---|---|
| XF86AudioRaiseVolume | volume +5% | `Volume: NN%` with N% progress bar |
| XF86AudioLowerVolume | volume 5% | `Volume: NN%` with N% progress bar |
| XF86AudioMute | toggle mute | `Muted` (with 0% bar) when muting; `Volume: NN%` when unmuting |
Notification timeout: 1500 ms.
## Implementation
### `scripts/volume.sh` (new)
```bash
#!/bin/bash
case "${1:-}" in
up) pamixer -i 5 ;;
down) pamixer -d 5 ;;
mute) pamixer -t ;;
*) echo "usage: $0 up|down|mute" >&2; exit 1 ;;
esac
if [ "$(pamixer --get-mute)" = "true" ]; then
notify-send -h string:x-canonical-private-synchronous:volume \
-h int:value:0 \
-t 1500 \
"Muted"
else
LEVEL=$(pamixer --get-volume)
notify-send -h string:x-canonical-private-synchronous:volume \
-h int:value:"$LEVEL" \
-t 1500 \
"Volume: ${LEVEL}%"
fi
```
The `string:x-canonical-private-synchronous:volume` hint is mako's standard mechanism for "transient" notifications — successive notifications carrying the same synchronous-key replace the previous one in place rather than stacking. Mashing the volume key produces a single updating bubble.
`int:value:N` makes mako render a progress bar at N% (mako displays this for any notification carrying the value hint).
### `niri/config.kdl`
Replace lines 9799 with calls to the wrapper:
```kdl
XF86AudioRaiseVolume allow-when-locked=true { spawn "volume" "up"; }
XF86AudioLowerVolume allow-when-locked=true { spawn "volume" "down"; }
XF86AudioMute allow-when-locked=true { spawn "volume" "mute"; }
```
`allow-when-locked=true` stays — the notification is still wanted on the lock screen (the lockscreen appears above mako, but mako's bubble shows briefly when the screen is unlocked, and the volume change itself happens regardless).
### `install.sh`
Append one symlink line in the existing `==> Installing scripts` block (after the existing `waybar-restart.sh` and `screenshot.sh` lines):
```bash
ln -sf "$(pwd)/scripts/volume.sh" ~/.local/bin/volume
```
## Files Touched
| File | Action |
|---|---|
| `scripts/volume.sh` | created |
| `niri/config.kdl` | three lines (9799) modified |
| `install.sh` | one symlink line appended |
## Out of Scope
- **Volume changes from other sources** (terminal `pamixer`, GUI like pavucontrol). Only this wrapper's invocations trigger notifications. A pulseaudio event listener daemon could cover that case but is bigger scope.
- **Per-app sink-input volumes.** Master volume only.
- **Variable step sizes** (Shift for 1% etc.). Fixed 5% step matches the existing keybinds.
- **Microphone toggle** (XF86AudioMicMute). Not in current keybinds.
- **Custom notification icon.** Default mako presentation suffices; icon hint would add machine-specific theming.
+5
View File
@@ -59,6 +59,7 @@ ln -sf "$(pwd)/scripts/power-profile.sh" ~/.local/bin/power-profile
ln -sf "$(pwd)/scripts/fan-profile.sh" ~/.local/bin/fan-profile ln -sf "$(pwd)/scripts/fan-profile.sh" ~/.local/bin/fan-profile
ln -sf "$(pwd)/scripts/waybar-restart.sh" ~/.local/bin/waybar-restart ln -sf "$(pwd)/scripts/waybar-restart.sh" ~/.local/bin/waybar-restart
ln -sf "$(pwd)/scripts/screenshot.sh" ~/.local/bin/screenshot ln -sf "$(pwd)/scripts/screenshot.sh" ~/.local/bin/screenshot
ln -sf "$(pwd)/scripts/volume.sh" ~/.local/bin/volume
echo "==> Enabling systemd user services" echo "==> Enabling systemd user services"
mkdir -p ~/.config/systemd/user mkdir -p ~/.config/systemd/user
@@ -67,4 +68,8 @@ echo "==> Deploying greetd config"
sudo cp "$(pwd)/greetd/config.toml" /etc/greetd/config.toml sudo cp "$(pwd)/greetd/config.toml" /etc/greetd/config.toml
sudo cp "$(pwd)/greetd/regreet.toml" /etc/greetd/regreet.toml sudo cp "$(pwd)/greetd/regreet.toml" /etc/greetd/regreet.toml
echo "==> Deploying suspend/hibernate config"
sudo install -Dm644 "$(pwd)/logind/lid.conf" /etc/systemd/logind.conf.d/lid.conf
sudo install -Dm644 "$(pwd)/sleep/hibernate-delay.conf" /etc/systemd/sleep.conf.d/hibernate-delay.conf
echo "==> Done. Start Niri with: niri-session" echo "==> Done. Start Niri with: niri-session"
+4
View File
@@ -0,0 +1,4 @@
[Login]
HandleLidSwitch=suspend-then-hibernate
HandleLidSwitchExternalPower=suspend-then-hibernate
HandleLidSwitchDocked=ignore
+10 -8
View File
@@ -31,17 +31,19 @@ input {
} }
} }
environment {
XDG_CURRENT_DESKTOP "niri:GNOME"
}
spawn-at-startup "sh" "-c" "systemctl --user import-environment XDG_CURRENT_DESKTOP && dbus-update-activation-environment XDG_CURRENT_DESKTOP"
spawn-at-startup "waybar" spawn-at-startup "waybar"
spawn-at-startup "mako" spawn-at-startup "mako"
spawn-at-startup "awww-daemon" spawn-at-startup "awww-daemon"
spawn-at-startup "nm-applet" "--indicator"
spawn-at-startup "/usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1" spawn-at-startup "/usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1"
spawn-at-startup "swayidle" "-w" "timeout" "300" "niri msg action power-off-monitors" "timeout" "600" "gtklock" "-d" "timeout" "1800" "systemctl suspend" "before-sleep" "gtklock" "-d" spawn-at-startup "swayidle" "-w" "timeout" "300" "niri msg action power-off-monitors" "timeout" "600" "gtklock" "-d" "timeout" "1800" "systemctl suspend-then-hibernate" "before-sleep" "gtklock" "-d"
spawn-at-startup "wl-paste" "--watch" "cliphist" "store" spawn-at-startup "wl-paste" "--watch" "cliphist" "store"
spawn-at-startup "blueman-applet"
spawn-at-startup "wlsunset" "-l" "49.2" "-L" "-123.1" spawn-at-startup "wlsunset" "-l" "49.2" "-L" "-123.1"
spawn-at-startup "xwayland-satellite" spawn-at-startup "xwayland-satellite"
spawn-at-startup "snixembed" "--no-startup-id"
binds { binds {
Mod+Q repeat=false { close-window; } Mod+Q repeat=false { close-window; }
@@ -94,9 +96,9 @@ binds {
Mod+Shift+C { spawn "clipboard-picker"; } Mod+Shift+C { spawn "clipboard-picker"; }
Mod+Shift+X { quit; } Mod+Shift+X { quit; }
XF86AudioRaiseVolume allow-when-locked=true { spawn "pamixer" "-i" "5"; } XF86AudioRaiseVolume allow-when-locked=true { spawn "volume" "up"; }
XF86AudioLowerVolume allow-when-locked=true { spawn "pamixer" "-d" "5"; } XF86AudioLowerVolume allow-when-locked=true { spawn "volume" "down"; }
XF86AudioMute allow-when-locked=true { spawn "pamixer" "-t"; } XF86AudioMute allow-when-locked=true { spawn "volume" "mute"; }
XF86MonBrightnessUp allow-when-locked=true { spawn "brightnessctl" "set" "+10%"; } XF86MonBrightnessUp allow-when-locked=true { spawn "brightnessctl" "set" "+10%"; }
XF86MonBrightnessDown allow-when-locked=true { spawn "brightnessctl" "set" "10%-"; } XF86MonBrightnessDown allow-when-locked=true { spawn "brightnessctl" "set" "10%-"; }
@@ -123,7 +125,7 @@ window-rule {
variable-refresh-rate true variable-refresh-rate true
} }
// Wine System Tray: hide the host window, icons bridge to SNI via snixembed // Wine System Tray: hide the empty host window off-screen
window-rule { window-rule {
match app-id="^explorer.exe$" match app-id="^explorer.exe$"
match title="^Wine System Tray$" match title="^Wine System Tray$"
-2
View File
@@ -12,9 +12,7 @@ brightnessctl
pamixer pamixer
gtklock gtklock
swayidle swayidle
snixembed
ttf-jetbrains-mono-nerd ttf-jetbrains-mono-nerd
network-manager-applet
polkit-gnome polkit-gnome
xdg-desktop-portal-wlr xdg-desktop-portal-wlr
thunar thunar
+79
View File
@@ -0,0 +1,79 @@
#!/bin/bash
# enable-hibernation.sh — one-time setup for laptop hibernation on btrfs.
# Idempotent: re-runs are no-ops once everything is in place. Run with sudo.
# After successful run, reboot before testing `systemctl hibernate`.
set -euo pipefail
if [ "$EUID" -ne 0 ]; then
echo "Run as root: sudo bash $0" >&2
exit 1
fi
SWAPFILE=/swapfile
SWAPSIZE=16g
ROOT_PARTUUID=$(findmnt -no PARTUUID /)
[ -n "$ROOT_PARTUUID" ] || { echo "ERROR: could not determine root PARTUUID via findmnt." >&2; exit 1; }
echo "==> Step 1/6: ensure swap file exists at $SWAPFILE (size $SWAPSIZE)"
if [ ! -f "$SWAPFILE" ]; then
btrfs filesystem mkswapfile --size "$SWAPSIZE" "$SWAPFILE"
echo " created $SWAPFILE"
else
actual_bytes=$(stat -c %s "$SWAPFILE")
min_bytes=$((14 * 1024 * 1024 * 1024))
if [ "$actual_bytes" -lt "$min_bytes" ]; then
echo "ERROR: $SWAPFILE exists but is smaller than 14 GiB (got $actual_bytes bytes)." >&2
echo " Remove it manually and re-run." >&2
exit 1
fi
echo " already exists ($((actual_bytes / 1024 / 1024 / 1024)) GiB) — skipping creation"
fi
echo "==> Step 2/6: activate swap and persist in fstab"
if ! swapon --show | grep -q "^$SWAPFILE "; then
swapon "$SWAPFILE"
echo " swapon $SWAPFILE"
else
echo " already active — skipping swapon"
fi
if ! grep -qE "^$SWAPFILE\s" /etc/fstab; then
printf '%s\tnone\tswap\tdefaults\t0 0\n' "$SWAPFILE" >> /etc/fstab
echo " appended to /etc/fstab"
else
echo " /etc/fstab already references $SWAPFILE — skipping"
fi
echo "==> Step 3/6: compute resume params"
RESUME_OFFSET=$(btrfs inspect-internal map-swapfile -r "$SWAPFILE")
echo " resume=PARTUUID=$ROOT_PARTUUID"
echo " resume_offset=$RESUME_OFFSET"
echo "==> Step 4/6: ensure 'resume' hook in /etc/mkinitcpio.conf"
if grep -qE "^HOOKS=\(.*\bresume\b.*\)" /etc/mkinitcpio.conf; then
echo " resume hook already present — skipping"
else
sed -i -E 's/(^HOOKS=\(.*\bblock\b)/\1 resume/' /etc/mkinitcpio.conf
if ! grep -qE "^HOOKS=\(.*\bresume\b.*\)" /etc/mkinitcpio.conf; then
echo "ERROR: failed to insert 'resume' hook. Check /etc/mkinitcpio.conf manually." >&2
exit 1
fi
echo " inserted 'resume' after 'block'"
fi
echo "==> Step 5/6: append resume params to /etc/kernel/cmdline"
if grep -q "resume=" /etc/kernel/cmdline; then
echo " cmdline already contains resume= — skipping"
else
sed -i "1 s|\$| resume=PARTUUID=$ROOT_PARTUUID resume_offset=$RESUME_OFFSET|" /etc/kernel/cmdline
echo " appended to /etc/kernel/cmdline"
fi
echo "==> Step 6/6: regenerate UKI"
mkinitcpio -P
echo
echo "Done. Reboot to activate hibernation:"
echo " sudo reboot"
echo
echo "After reboot, test with: systemctl hibernate"
+1 -1
View File
@@ -10,7 +10,7 @@ if [ "$1" = "--menu" ]; then
--no-actions \ --no-actions \
--insensitive \ --insensitive \
| awk '{print $NF}') | awk '{print $NF}')
[ -n "$CHOICE" ] && powerprofilesctl set "$CHOICE" && pkill -RTMIN+8 waybar [ -n "$CHOICE" ] && powerprofilesctl set "$CHOICE" && pkill -RTMIN+8 waybar && pkill -RTMIN+9 waybar
exit exit
fi fi
+1 -1
View File
@@ -13,6 +13,6 @@ CHOICE=$(printf " Logout\n Restart\n Sleep\n Shutdown" \
case "$CHOICE" in case "$CHOICE" in
Logout) niri msg action quit ;; Logout) niri msg action quit ;;
Restart) systemctl reboot ;; Restart) systemctl reboot ;;
Sleep) systemctl suspend ;; Sleep) systemctl suspend-then-hibernate ;;
Shutdown) systemctl poweroff ;; Shutdown) systemctl poweroff ;;
esac esac
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
case "${1:-}" in
up) pamixer -i 5 ;;
down) pamixer -d 5 ;;
mute) pamixer -t ;;
*) echo "usage: $0 up|down|mute" >&2; exit 1 ;;
esac
if [ "$(pamixer --get-mute)" = "true" ]; then
notify-send -h string:x-canonical-private-synchronous:volume \
-h int:value:0 \
-t 1500 \
"Muted"
else
LEVEL=$(pamixer --get-volume)
notify-send -h string:x-canonical-private-synchronous:volume \
-h int:value:"$LEVEL" \
-t 1500 \
"Volume: ${LEVEL}%"
fi
+2
View File
@@ -0,0 +1,2 @@
[Sleep]
HibernateDelaySec=30min