Skip to content

Commit 2d76da1

Browse files
committed
feat(power-menu): per-DE commands config with safe subprocess execution
1 parent ace9136 commit 2d76da1

File tree

4 files changed

+152
-28
lines changed

4 files changed

+152
-28
lines changed

src/mewline/constants.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,22 @@
144144
"reboot_icon_size": "20px",
145145
"shutdown_icon": "",
146146
"shutdown_icon_size": "20px",
147+
"commands": {
148+
"hyprland": {
149+
"lock": "hyprlock",
150+
"logout": "hyprctl dispatch exit",
151+
"suspend": "systemctl suspend",
152+
"reboot": "systemctl reboot",
153+
"shutdown": "systemctl poweroff",
154+
},
155+
"bspwm": {
156+
"lock": "i3lock",
157+
"logout": "bspc quit",
158+
"suspend": "systemctl suspend",
159+
"reboot": "systemctl reboot",
160+
"shutdown": "systemctl poweroff",
161+
},
162+
}
147163
},
148164
"compact": {
149165
"window_titles": {

src/mewline/utils/config_structure.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
1+
import re
12
from typing import Literal
23

34
from pydantic import BaseModel
5+
from pydantic import field_validator
6+
7+
# Shell metacharacters that could enable command injection.
8+
# $( and backtick allow command substitution; |, ;, & chain commands;
9+
# <, > redirect I/O; ( ) start subshells.
10+
# Plain $VAR / ${VAR} references are intentionally allowed so users can
11+
# point to scripts via env-var paths (expanded at runtime, not here).
12+
_UNSAFE_SHELL_RE = re.compile(r"[|;&`<>()]|\$\(")
413

514

615
class Theme(BaseModel):
@@ -72,6 +81,45 @@ class PowerModule(BaseModel):
7281
tooltip: bool
7382

7483

84+
class PowerMenuCommands(BaseModel):
85+
"""Commands executed for each power-menu action.
86+
87+
Keys of the ``commands`` dict in :class:`PowerMenu` must match the value
88+
of ``XDG_CURRENT_DESKTOP`` (compared case-insensitively at runtime).
89+
90+
**Environment-variable substitution** – ``$VAR`` and ``${VAR}`` references
91+
inside a command string are expanded at runtime via
92+
:func:`os.path.expandvars` *before* the command is split and executed.
93+
This lets you write paths like ``$HOME/.local/bin/screen-lock.sh``
94+
without hard-coding your home directory.
95+
96+
**Security** – Commands are executed via :func:`subprocess.Popen` with
97+
``shell=False``, so no shell features (pipes, redirections, command
98+
substitution, etc.) are available. To enforce this, the validator rejects
99+
any command string that contains the following characters or sequences:
100+
``|``, ``;``, ``&``, backtick, ``<``, ``>``, ``(``, ``)``, ``$(``. If
101+
you need complex logic, put it in a standalone script and reference that
102+
script here.
103+
"""
104+
105+
lock: str = "hyprlock"
106+
logout: str = "hyprctl dispatch exit"
107+
suspend: str = "systemctl suspend"
108+
reboot: str = "systemctl reboot"
109+
shutdown: str = "systemctl poweroff"
110+
111+
@field_validator("lock", "logout", "suspend", "reboot", "shutdown", mode="before")
112+
@classmethod
113+
def _no_shell_metacharacters(cls, v: str) -> str:
114+
if _UNSAFE_SHELL_RE.search(v):
115+
raise ValueError(
116+
f"Command contains unsafe shell metacharacters: {v!r}. "
117+
"Shell features are not supported; use an absolute path to a "
118+
"wrapper script instead (e.g. /home/user/.local/bin/lock.sh)."
119+
)
120+
return v
121+
122+
75123
class PowerMenu(BaseModel):
76124
lock_icon: str
77125
lock_icon_size: str
@@ -83,6 +131,7 @@ class PowerMenu(BaseModel):
83131
reboot_icon_size: str
84132
shutdown_icon: str
85133
shutdown_icon_size: str
134+
commands: dict[str, PowerMenuCommands] = {}
86135

87136

88137
class DatetimeModule(BaseModel):

src/mewline/widgets/dynamic_island/power.py

Lines changed: 70 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
import os
2+
import shlex
3+
import subprocess
14
from typing import TYPE_CHECKING
25

36
from fabric.widgets.box import Box
47
from fabric.widgets.button import Button
5-
from gi.repository import GLib
68
from loguru import logger
79

810
from mewline.config import cfg
11+
from mewline.utils.config_structure import PowerMenuCommands
912
from mewline.utils.widget_utils import setup_cursor_hover
1013
from mewline.utils.widget_utils import text_icon
1114
from mewline.widgets.dynamic_island.base import BaseDiWidget
@@ -102,27 +105,83 @@ def __init__(self, di: "DynamicIsland", **kwargs):
102105
def close_menu(self):
103106
self.dynamic_island.close()
104107

108+
def _get_commands(self) -> PowerMenuCommands:
109+
"""Return commands for the current desktop environment.
110+
111+
Looks up ``XDG_CURRENT_DESKTOP`` in ``config.commands``
112+
(case-insensitive). Falls back to the first entry in the dict, or
113+
to built-in defaults when the dict is empty.
114+
"""
115+
desktop = os.environ.get("XDG_CURRENT_DESKTOP", "").lower()
116+
commands_map = self.config.commands
117+
118+
for key, cmds in commands_map.items():
119+
if key.lower() == desktop:
120+
return cmds
121+
122+
if commands_map:
123+
first_key = next(iter(commands_map))
124+
logger.warning(
125+
f"[PowerMenu] No commands configured for desktop {desktop!r}, "
126+
f"falling back to {first_key!r} commands."
127+
)
128+
return commands_map[first_key]
129+
130+
logger.warning(
131+
f"[PowerMenu] No commands configured for desktop {desktop!r} and "
132+
"no fallback available - using built-in defaults."
133+
)
134+
return PowerMenuCommands()
135+
136+
@staticmethod
137+
def _run_command(command: str) -> None:
138+
"""Execute *command* safely without a shell.
139+
140+
``$VAR`` / ``${VAR}`` references are expanded via
141+
:func:`os.path.expandvars` before the string is split with
142+
:func:`shlex.split`. The resulting argument list is handed directly
143+
to :class:`subprocess.Popen` (``shell=False``), so no shell injection
144+
is possible regardless of the command string's content.
145+
"""
146+
expanded = os.path.expandvars(command)
147+
try:
148+
args = shlex.split(expanded)
149+
except ValueError as exc:
150+
logger.error(f"[PowerMenu] Failed to parse command {command!r}: {exc}")
151+
return
152+
153+
if not args:
154+
logger.warning(f"[PowerMenu] Empty command after parsing: {command!r}")
155+
return
156+
157+
try:
158+
subprocess.Popen(args)
159+
except FileNotFoundError:
160+
logger.error(f"[PowerMenu] Command not found: {args[0]!r}")
161+
except OSError as exc:
162+
logger.error(f"[PowerMenu] Failed to start {args!r}: {exc}")
163+
105164
def lock(self, *args):
106-
logger.info("Locking screen...")
107-
GLib.spawn_command_line_async("swaylock")
165+
logger.info("[PowerMenu] Locking screen...")
166+
self._run_command(self._get_commands().lock)
108167
self.close_menu()
109168

110169
def suspend(self, *args):
111-
logger.info("Suspending screen...")
112-
GLib.spawn_command_line_async("systemctl suspend")
170+
logger.info("[PowerMenu] Suspending system...")
171+
self._run_command(self._get_commands().suspend)
113172
self.close_menu()
114173

115174
def logout(self, *args):
116-
logger.info("Logging out...")
117-
GLib.spawn_command_line_async("hyprctl dispatch exit")
175+
logger.info("[PowerMenu] Logging out...")
176+
self._run_command(self._get_commands().logout)
118177
self.close_menu()
119178

120179
def reboot(self, *args):
121-
logger.info("Rebooting system...")
122-
GLib.spawn_command_line_async("systemctl reboot")
180+
logger.info("[PowerMenu] Rebooting system...")
181+
self._run_command(self._get_commands().reboot)
123182
self.close_menu()
124183

125184
def poweroff(self, *args):
126-
logger.info("Powering off system...")
127-
GLib.spawn_command_line_async("systemctl poweroff")
185+
logger.info("[PowerMenu] Powering off system...")
186+
self._run_command(self._get_commands().shutdown)
128187
self.close_menu()

whitelist-vulture

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -34,40 +34,40 @@ MONITOR_FOCUS # unused variable (src/mewline/custom_fabric/bspwm/service.py:54)
3434
MONITOR_GEOMETRY # unused variable (src/mewline/custom_fabric/bspwm/service.py:55)
3535
POINTER_ACTION # unused variable (src/mewline/custom_fabric/bspwm/service.py:56)
3636
_.event_thread # unused attribute (src/mewline/custom_fabric/bspwm/service.py:107)
37-
_.empty # unused attribute (src/mewline/custom_fabric/bspwm/widgets.py:83)
38-
_.empty # unused attribute (src/mewline/custom_fabric/bspwm/widgets.py:121)
3937
_._duration # unused attribute (src/mewline/shared/animator.py:76)
4038
_.rowstride # unused attribute (src/mewline/utils/temporary_fixes.py:29)
4139
_.has_alpha # unused attribute (src/mewline/utils/temporary_fixes.py:30)
4240
_.bits_per_sample # unused attribute (src/mewline/utils/temporary_fixes.py:31)
4341
_.channels # unused attribute (src/mewline/utils/temporary_fixes.py:32)
4442
_._pixbuf # unused attribute (src/mewline/utils/temporary_fixes.py:43)
4543
_.byte_array # unused attribute (src/mewline/utils/temporary_fixes.py:44)
44+
_.min_width # unused attribute (src/mewline/widgets/dynamic_island/__init__.py:404)
45+
_.min_height # unused attribute (src/mewline/widgets/dynamic_island/__init__.py:405)
46+
_.max_width # unused attribute (src/mewline/widgets/dynamic_island/__init__.py:406)
47+
_.max_height # unused attribute (src/mewline/widgets/dynamic_island/__init__.py:407)
48+
allocation # unused variable (src/mewline/widgets/dynamic_island/__init__.py:482)
4649
_.xalign # unused attribute (src/mewline/widgets/dynamic_island/app_launcher.py:59)
4750
_.xalign # unused attribute (src/mewline/widgets/dynamic_island/clipboard.py:59)
4851
_.xalign # unused attribute (src/mewline/widgets/dynamic_island/emoji.py:57)
4952
_.xalign # unused attribute (src/mewline/widgets/dynamic_island/pawlette_themes.py:81)
50-
_.xalign # unused attribute (src/mewline/widgets/dynamic_island/wallpapers.py:159)
53+
_.xalign # unused attribute (src/mewline/widgets/dynamic_island/wallpapers.py:172)
5154
_.toggle_shuffle # unused method (src/mewline/services/mpris.py:87)
52-
_.set_pointing_to # unused method (src/mewline/shared/popover.py:107)
53-
_.set_content # unused method (src/mewline/shared/popover.py:110)
55+
_.set_pointing_to # unused method (src/mewline/shared/popover.py:182)
56+
_.set_content # unused method (src/mewline/shared/popover.py:185)
57+
_.set_pointing_to # unused method (src/mewline/shared/popover.py:301)
58+
_.set_content # unused method (src/mewline/shared/popover.py:304)
5459
_.do_action_next # unused method (src/mewline/custom_fabric/bspwm/widgets.py:136)
5560
_.do_action_previous # unused method (src/mewline/custom_fabric/bspwm/widgets.py:140)
5661
_.do_button_clicked # unused method (src/mewline/custom_fabric/bspwm/widgets.py:144)
57-
_._unblock_service_updates # unused method (src/mewline/widgets/combined_controls.py:367)
5862
_.show_for_device # unused method (src/mewline/widgets/osd.py:159)
5963
_.get_profile_icon # unused method (src/mewline/services/battery.py:176)
60-
popover_opened # unused function (src/mewline/shared/popover.py:76)
61-
popover_closed # unused function (src/mewline/shared/popover.py:82)
64+
popover_opened # unused function (src/mewline/shared/popover.py:151)
65+
popover_closed # unused function (src/mewline/shared/popover.py:157)
6266
_.stop # unused method (src/mewline/shared/animator.py:174)
63-
_.return_popover_window # unused method (src/mewline/shared/popover.py:60)
64-
_.is_x11 # unused method (src/mewline/utils/window_manager.py:68)
67+
_.return_popover_window # unused method (src/mewline/shared/popover.py:66)
68+
_.return_popover_window # unused method (src/mewline/shared/popover.py:136)
69+
_._no_shell_metacharacters # unused method (src/mewline/utils/config_structure.py:111)
6570
get_display_backend # unused function (src/mewline/utils/window_manager.py:120)
66-
_._detach_nav # unused method (src/mewline/widgets/dynamic_island/notifications.py:555)
67-
BspwmEventType # unused class (src/mewline/custom_fabric/bspwm/service.py:20)
68-
min_width # unused attribute (src/mewline/widgets/dynamic_island/__init__.py:404)
69-
min_height # unused attribute (src/mewline/widgets/dynamic_island/__init__.py:405)
70-
max_width # unused attribute (src/mewline/widgets/dynamic_island/__init__.py:406)
71-
max_height # unused attribute (src/mewline/widgets/dynamic_island/__init__.py:407)
72-
allocation # unused variable (src/mewline/widgets/dynamic_island/__init__.py:482)
71+
_._detach_nav # unused method (src/mewline/widgets/dynamic_island/notifications.py:564)
7372
_._on_item_removed # unused method (src/mewline/widgets/system_tray.py:206)
73+
BspwmEventType # unused class (src/mewline/custom_fabric/bspwm/service.py:20)

0 commit comments

Comments
 (0)