diff --git a/config/bin/hyprcosmic-powermenu b/config/bin/hyprcosmic-powermenu new file mode 100755 index 0000000..da01fdb --- /dev/null +++ b/config/bin/hyprcosmic-powermenu @@ -0,0 +1,105 @@ +#!/usr/bin/bash +# +# Power menu for a HyprCosmic session: lock, suspend, log out, reboot, shut down. +# +# WHY THIS EXISTS +# --------------- +# Stock COSMIC puts all of these behind the power applet in cosmic-panel. +# HyprCosmic replaces the panel with waybar, so that applet never starts and the +# session had no exit at all -- not even a logout -- short of `systemctl reboot` +# typed into a terminal. This is the exit. It is reached from a keybinding and +# from the bar, and both run this same script so the two can never disagree. +# +# Logging out goes through cosmic-session's own D-Bus interface rather than +# killing anything. That is the method the panel applet called, and it is the +# only one that lets the session stop its clients in order instead of pulling +# the compositor out from under them; see `com.system76.CosmicSession.Exit` in +# cosmic-session/src/service.rs. +# +# Reboot and poweroff go to systemd directly. polkit already authorises both for +# an active local session without a prompt, which is why no pkexec is involved. +# +# NO GLYPHS HERE, DELIBERATELY +# ---------------------------- +# Every entry is a plain word. Nerd Font icons live in the Private Use Area, +# where they are indistinguishable from each other in a diff and are silently +# destroyed by anything that retypes rather than copies them. waybar's power +# icon is the only glyph in this feature and it comes from generate-config.py, +# which checks each codepoint against the installed font. + +set -uo pipefail + +SESSION_DEST=com.system76.CosmicSession +SESSION_PATH=/com/system76/CosmicSession + +command -v rofi >/dev/null 2>&1 || { + printf 'hyprcosmic-powermenu: rofi is not installed\n' >&2 + exit 1 +} + +# Errors go to a rofi dialog, not just stderr. The two ways in are a keybinding +# and a bar click, and neither has a terminal attached to read stderr from. +fail() { + printf 'hyprcosmic-powermenu: %s\n' "$*" >&2 + rofi -e "$*" >/dev/null 2>&1 || : + exit 1 +} + +# No theme arguments, so ~/.config/rofi/config.rasi applies and this matches the +# launcher. Icons off because these entries have none and the reserved space +# would sit there empty. +# +# -no-custom is what stops a typed line from being returned as if it were a +# choice: without it, Return on an empty filter hands back whatever was typed, +# and the case below would fall through to no branch at all. With it, anything +# that is not one of the offered entries is refused. +menu() { + local prompt="$1" + shift + printf '%s\n' "$@" | rofi -dmenu -i -no-custom -no-show-icons -p "$prompt" +} + +# Only for the three that end the session. Locking and suspending undo +# themselves with a keypress, so a confirmation there is pure friction; logging +# out, rebooting and shutting down each throw away every unsaved thing on the +# desktop, and this menu is one keystroke away at all times. +# +# "No" is listed first so that it is the selected row when the dialog opens. +confirm() { + local answer + answer="$(menu "$1?" "No" "Yes")" || return 1 + [[ "$answer" == "Yes" ]] +} + +logout() { + command -v busctl >/dev/null 2>&1 || + fail "busctl is not installed, so the session cannot be asked to exit" + + busctl --user call "$SESSION_DEST" "$SESSION_PATH" "$SESSION_DEST" Exit && return 0 + + # Reaching here means cosmic-session is not answering on the bus. Say so + # rather than silently doing nothing: being unable to leave the session is + # the exact problem this script was written for, so a dead end here is + # worse than a blunt instrument. loginctl is that blunt instrument, and it + # is named explicitly so the choice to use it is the user's. + fail "cosmic-session did not answer on D-Bus. +To force the session to end, run: + loginctl terminate-session ${XDG_SESSION_ID:-\$XDG_SESSION_ID}" +} + +choice="$(menu "Power" "Lock" "Suspend" "Log out" "Reboot" "Shut down")" || exit 0 + +case "$choice" in + "Lock") exec loginctl lock-session ;; + "Suspend") exec systemctl suspend ;; + "Log out") confirm "Log out" && logout ;; + "Reboot") confirm "Reboot" && exec systemctl reboot ;; + "Shut down") confirm "Shut down" && exec systemctl poweroff ;; + "") ;; + *) fail "unrecognised choice: ${choice}" ;; +esac + +# Reached only by answering "No" to a confirmation, or dismissing it. The four +# branches that act replace this process outright, and `fail` exits on its own, +# so nothing else arrives here -- and changing your mind is not a failure. +exit 0 diff --git a/config/cosmic.conf b/config/cosmic.conf index 0d09238..5a95f92 100644 --- a/config/cosmic.conf +++ b/config/cosmic.conf @@ -72,3 +72,16 @@ bind = $mainMod, W, exec, rofi -show window # Terminal, in the Hyprland idiom. Super+T also still works — cosmic-comp # handles System(Terminal) itself, so that binding never went dead. bind = $mainMod, Return, exec, cosmic-term + +# --- Session ------------------------------------------------------------ +# +# The hyprcosmic profile disables cosmic-panel, and COSMIC's power applet lives +# in that panel, so a HyprCosmic session had no logout, reboot or shutdown +# anywhere -- the only way out was `systemctl reboot` from a terminal. This is +# that way out. The same script backs waybar's power button, so the two cannot +# drift apart, and it asks for confirmation before anything that ends the +# session. +# +# Super+Shift+E is Hyprland's own spelling for "exit". The menu also offers +# lock and suspend, which is why the binding is not named after logout. +bind = $mainMod SHIFT, E, exec, hyprcosmic-powermenu diff --git a/config/waybar/config.jsonc b/config/waybar/config.jsonc index 327dec0..53a6b95 100644 --- a/config/waybar/config.jsonc +++ b/config/waybar/config.jsonc @@ -63,7 +63,8 @@ "memory", "power-profiles-daemon", "battery", - "tray" + "tray", + "custom/power" ], "ext/workspaces": { @@ -265,5 +266,24 @@ "on-click": "cosmic-settings power" }, - "tray": { "icon-size": 18, "spacing": 8 } + "tray": { "icon-size": 18, "spacing": 8 }, + + // Last on the bar, because it is the one button here that can end the + // session or the uptime and the far corner is the hardest place to hit by + // accident. + // + // It is here for the same reason "custom/swaync" is: the hyprcosmic profile + // disables cosmic-panel, and COSMIC's power applet lives in that panel. + // Without this there is no logout, reboot or shutdown anywhere in the + // session -- the only way out was `systemctl reboot` from a terminal. + // + // The click runs the same script as the $mainMod SHIFT E binding rather + // than calling systemctl here, so the confirmation step cannot be bypassed + // by using the mouse. + "custom/power": { + "format": "\uf011", + "tooltip": true, + "tooltip-format": "Lock, suspend, log out, reboot or shut down", + "on-click": "hyprcosmic-powermenu" + } } diff --git a/config/waybar/config.jsonc.in b/config/waybar/config.jsonc.in index 3a25c25..77c955a 100644 --- a/config/waybar/config.jsonc.in +++ b/config/waybar/config.jsonc.in @@ -63,7 +63,8 @@ "memory", "power-profiles-daemon", "battery", - "tray" + "tray", + "custom/power" ], "ext/workspaces": { @@ -265,5 +266,24 @@ "on-click": "cosmic-settings power" }, - "tray": { "icon-size": 18, "spacing": 8 } + "tray": { "icon-size": 18, "spacing": 8 }, + + // Last on the bar, because it is the one button here that can end the + // session or the uptime and the far corner is the hardest place to hit by + // accident. + // + // It is here for the same reason "custom/swaync" is: the hyprcosmic profile + // disables cosmic-panel, and COSMIC's power applet lives in that panel. + // Without this there is no logout, reboot or shutdown anywhere in the + // session -- the only way out was `systemctl reboot` from a terminal. + // + // The click runs the same script as the $mainMod SHIFT E binding rather + // than calling systemctl here, so the confirmation step cannot be bypassed + // by using the mouse. + "custom/power": { + "format": "@@POWER@@", + "tooltip": true, + "tooltip-format": "Lock, suspend, log out, reboot or shut down", + "on-click": "hyprcosmic-powermenu" + } } diff --git a/config/waybar/generate-config.py b/config/waybar/generate-config.py index b75e0a1..0e3bfe1 100755 --- a/config/waybar/generate-config.py +++ b/config/waybar/generate-config.py @@ -43,6 +43,7 @@ ICONS = { "SAVER": 0xF06C, "BALANCED": 0xF24E, "PROFILE": 0xF0E7, + "POWER": 0xF011, "BAT_00": 0xF008E, "BAT_10": 0xF007A, "BAT_20": 0xF007B, diff --git a/config/waybar/rules.css b/config/waybar/rules.css index d2f039c..ca55dbb 100644 --- a/config/waybar/rules.css +++ b/config/waybar/rules.css @@ -90,13 +90,22 @@ window#waybar { #memory, #power-profiles-daemon, #battery, -#tray { +#tray, +#custom-power { padding: 0 10px; margin: 4px 2px; border-radius: 10px; background: @module-bg; } +/* The only module here that is a button rather than a readout, so it is the + * only one that gains anything from a hover state. Colour rather than + * background, to match how the status colours below signal without moving + * anything. */ +#custom-power:hover { + color: @critical; +} + #clock { font-weight: bold; } diff --git a/tools/install-assets.sh b/tools/install-assets.sh index 405326a..1c77e0c 100755 --- a/tools/install-assets.sh +++ b/tools/install-assets.sh @@ -73,6 +73,7 @@ SHARED=( "config/waybar/rules.css:share/hyprcosmic/waybar/rules.css:644" "config/rofi/palette.rasi:share/hyprcosmic/rofi/palette.rasi:644" "config/rofi/rules.rasi:share/hyprcosmic/rofi/rules.rasi:644" + "config/bin/hyprcosmic-powermenu:bin/hyprcosmic-powermenu:755" ) # The session entry point. Kept apart from SHARED because it is versioned in the