Add a power menu, so a session can be left without rebooting

The hyprcosmic profile disables cosmic-panel, and COSMIC's power applet lives
in that panel. Nothing replaced it, so the session had no logout, reboot or
shutdown anywhere in it: the only way out was `systemctl reboot` typed into a
terminal, which also meant every login-time change cost a reboot to test.

hyprcosmic-powermenu is a rofi menu offering lock, suspend, log out, reboot and
shut down. It is reached two ways -- $mainMod SHIFT E and a button at the right
end of waybar -- and both run the same script, so a click cannot bypass the
confirmation a keypress gets.

Logging out calls com.system76.CosmicSession.Exit, which is the method the
panel applet used and the only one that stops the session's clients in order
rather than pulling the compositor out from under them. Reboot and poweroff go
straight to systemd; polkit already authorises both for an active local session
without a prompt, verified with pkcheck, so no pkexec is involved. Lock uses
loginctl, which cosmic-greeter is listening for.

Confirmation is asked only for the three that end the session. Lock and suspend
undo themselves with a keypress, so a prompt there is pure friction; the other
three throw away everything unsaved and are one keystroke away at all times.
"No" is listed first so it is the row already selected.

The menu entries are plain words rather than Nerd Font glyphs. The bar icon is
the only glyph involved, and it comes through generate-config.py, whose whole
purpose is that no Private Use Area character is ever typed by hand -- U+F011,
confirmed present in the installed JetBrainsMono Nerd Font.

rules.css names every module id explicitly, so #custom-power had to be added
there too or the button would have rendered with no pill behind it.

The script sits under config/bin/ rather than a new top-level directory so that
install-assets.sh's audit still covers it: that check refuses to run unless
every file under config/ is classified, which is what stops a new file from
being silently left uninstalled.
This commit is contained in:
2026-08-10 15:35:45 +07:00
parent 178dfbec1a
commit 08028e6f2c
7 changed files with 174 additions and 5 deletions
+105
View File
@@ -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
+13
View File
@@ -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
+22 -2
View File
@@ -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"
}
}
+22 -2
View File
@@ -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"
}
}
+1
View File
@@ -43,6 +43,7 @@ ICONS = {
"SAVER": 0xF06C,
"BALANCED": 0xF24E,
"PROFILE": 0xF0E7,
"POWER": 0xF011,
"BAT_00": 0xF008E,
"BAT_10": 0xF007A,
"BAT_20": 0xF007B,
+10 -1
View File
@@ -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;
}
+1
View File
@@ -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