Add IdeaPad Conservation Mode plugin (#20)

* feat: add lux/ideapad-conservation-mode plugin

* fix: address review blockers on ideapad-conservation-mode

- shell-quote pluginDir/USER before splicing into the sudo/runInTerminal
  command (shell injection)
- declare every external command setup-permissions.sh invokes as a
  manifest dependency, and document them in README Requirements
- drop the hardcoded KERNEL=="VPC2004:00" match from the udev rule so it
  matches any ideapad_acpi instance, consistent with the dynamic
  discovery the Luau side already does
- add the promised NixOS declarative setup section to README
- move all user-visible strings out of conservation_mode.luau into
  translations/en.json, accessed via noctalia.tr()
- add the required README Plugin/Usage sections

* Update dependencies in plugin.toml

* Update setup script requirements in README.md

---------

Co-authored-by: Lemmy <studio@quadbyte.net>
This commit is contained in:
Damir Rakhmatullin
2026-07-16 08:10:11 -04:00
committed by GitHub
co-authored by Lemmy
parent f1a38ca585
commit 2922dd5d40
6 changed files with 266 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
# IdeaPad Conservation Mode
![IdeaPad Conservation Mode thumbnail](thumbnail.webp)
**IdeaPad Conservation Mode** is a Control Center shortcut plugin for [Noctalia](https://docs.noctalia.dev) (v5) that toggles **Conservation Mode** — capping charging around ~60% to preserve battery health — via the `ideapad_acpi` kernel driver's `conservation_mode` sysfs attribute. That driver covers Lenovo IdeaPad and Legion laptops alike, not just Legion.
Writing that attribute needs root by default. The first time you click the tile and the write fails, the plugin opens a terminal and runs a bundled one-time setup script with `sudo`. It creates a `lenovoctl` group, adds you to it, and installs a udev rule so future writes are unprivileged. Log out and back in once afterward; every click after that just works, with no further prompts.
## Plugin
| Field | Value |
| --- | --- |
| ID | `lux/ideapad-conservation-mode` |
| Entries | Shortcut: `conservation-mode` |
## Requirements
The bundled setup script (`scripts/setup-permissions.sh`) shells out to `sudo`, `dirname`, `env`, `bash`, `chgrp`, `chmod`, `getent`, `groupadd`, `usermod`, `cat`, and `udevadm`. All of these ship by default on essentially every mainstream Linux distribution (coreutils, util-linux, shadow-utils, systemd/udev).
## Usage
Add the `conservation-mode` shortcut under Settings → Control Center shortcuts. Click it to toggle Conservation Mode; the label shows the current state (On / Off / N/A if the sysfs attribute can't be found). The first click may open a terminal for the one-time `sudo` setup described above.
## NixOS
`/etc/udev/rules.d` is generated from system config on NixOS, so the setup script exits instead of writing to it. Add the equivalent declaratively instead:
```nix
{
users.groups.lenovoctl = {};
users.users.<your-username>.extraGroups = [ "lenovoctl" ];
services.udev.extraRules = ''
ACTION=="add|change", SUBSYSTEM=="platform", DRIVER=="ideapad_acpi", RUN+="${pkgs.coreutils}/bin/chgrp lenovoctl /sys%p/conservation_mode", RUN+="${pkgs.coreutils}/bin/chmod 664 /sys%p/conservation_mode"
'';
}
```
Rebuild, log out and back in, then click the tile.
@@ -0,0 +1,144 @@
--!nonstrict
-- Control-center [[shortcut]] entry: toggles Lenovo IdeaPad/Legion battery
-- Conservation Mode (caps charging around ~60% to preserve battery health)
-- via the ideapad_acpi driver's conservation_mode sysfs attribute.
--
-- Writing that attribute needs root by default. The first time a write
-- fails, onClick() opens a terminal running a bundled one-time setup script
-- (scripts/setup-permissions.sh) via sudo, instead of just failing -- see
-- that script for what it changes. Every write after that is a plain,
-- unprivileged noctalia.writeFile().
local DRIVER_DIR = "/sys/bus/platform/drivers/ideapad_acpi"
local ATTR_NAME = "conservation_mode"
-- The ACPI instance name (e.g. "VPC2004:00") isn't hardcoded: we discover it
-- by scanning the driver's bound devices, so this keeps working if it ever
-- differs. Cached in plugin state under a generic key since other future
-- entries (fan mode, camera power, ...) live under the same device dir.
local function findDevicePath()
local cached = noctalia.state.get("ideapad_device_path")
if cached then
return cached
end
local entries = noctalia.listDir(DRIVER_DIR)
if not entries then
return nil
end
for _, name in ipairs(entries) do
if name ~= "uevent" and name ~= "bind" and name ~= "unbind" and name ~= "module" then
local path = DRIVER_DIR .. "/" .. name
if noctalia.fileExists(path .. "/" .. ATTR_NAME) then
noctalia.state.set("ideapad_device_path", path)
return path
end
end
end
return nil
end
local function attrPath()
local devicePath = findDevicePath()
if not devicePath then
return nil
end
return devicePath .. "/" .. ATTR_NAME
end
-- true/false for a known state, nil when the attribute couldn't be read.
local function readState()
local path = attrPath()
if not path then
return nil
end
local contents = noctalia.readFile(path)
if not contents then
return nil
end
return noctalia.string.trim(contents) == "1"
end
local function render(on, enabled)
shortcut.setLabel(noctalia.tr(if on then "shortcut.on" else "shortcut.off"))
shortcut.setIcon("battery-charging", "battery")
shortcut.setActive(on)
shortcut.setEnabled(enabled)
end
local on = readState()
render(on == true, on ~= nil)
if on == nil then
shortcut.setLabel(noctalia.tr("shortcut.na"))
end
function onClick()
local path = attrPath()
if not path then
noctalia.notifyError(noctalia.tr("title"), noctalia.tr("error.device_not_found"))
return
end
local current = readState()
if current == nil then
noctalia.notifyError(noctalia.tr("title"), noctalia.tr("error.read_failed"))
return
end
local flipped = not current
local ok, err = noctalia.writeFile(path, if flipped then "1" else "0")
if ok then
render(flipped, true)
noctalia.notify(
noctalia.tr("title"),
noctalia.tr(if flipped then "notify.enabled" else "notify.disabled")
)
return
end
render(current, true)
runSetup(err)
end
-- POSIX single-quote escaping: wraps s in '...', turning any embedded ' into
-- '\'' so the result is safe to splice into a shell command string. Needed
-- because pluginDir and $USER are attacker/environment-influenced strings
-- spliced into a command that runs through sh -lc.
local function shQuote(s)
return "'" .. string.gsub(s, "'", "'\\''") .. "'"
end
-- Not set up yet (or permissions regressed): run the bundled setup script in
-- a terminal via sudo. Deliberately not pkexec -- minimal Wayland setups
-- (niri, sway, ...) commonly have no polkit GUI agent registered, so pkexec
-- would fail silently with no prompt at all. A terminal + sudo has no such
-- dependency.
function runSetup(writeErr)
local pluginDir = noctalia.pluginDir()
if not pluginDir then
noctalia.notifyError(
noctalia.tr("title"),
noctalia.tr("error.write_failed", { error = writeErr or "permission denied" })
)
return
end
local setupScript = pluginDir .. "/scripts/setup-permissions.sh"
local user = noctalia.string.trim(noctalia.getenv("USER") or "")
local cmd = "sudo "
.. shQuote(setupScript)
.. " "
.. shQuote(user)
.. " && echo && echo 'Setup complete. Log out and back in, then click the tile again.' && read -r -p 'Press Enter to close...'"
if noctalia.runInTerminal(cmd) then
noctalia.notify(noctalia.tr("title"), noctalia.tr("notify.setup_needed"))
else
noctalia.notifyError(
noctalia.tr("title"),
noctalia.tr("error.write_failed_manual", { error = writeErr or "permission denied" })
)
end
end
+14
View File
@@ -0,0 +1,14 @@
id = "lux/ideapad-conservation-mode"
name = "IdeaPad Conservation Mode"
version = "0.3.0"
min_noctalia = "5.0.0"
author = "lux"
license = "MIT"
tags = ["hardware", "shortcut", "system"]
icon = "laptop"
description = "Toggles battery conservation mode via the ideapad_acpi driver, on Lenovo IdeaPad and Legion laptops."
dependencies = ["sudo", "dirname", "env", "bash", "chgrp", "chmod", "getent", "groupadd", "usermod", "cat", "udevadm"]
[[shortcut]]
id = "conservation-mode"
entry = "conservation_mode.luau"
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# One-time setup: lets the "lenovoctl" group toggle Lenovo IdeaPad/Legion ACPI
# features (currently battery conservation_mode) without root.
#
# Run via sudo (the plugin invokes this itself, in a terminal, the first time
# a write fails). Idempotent -- safe to re-run.
#
# NOTE: udev's GROUP=/MODE= rule keys only apply to the /dev device node they
# create, NOT to arbitrary sysfs ATTR files (see udev(7)) -- a rule matching
# ATTR{conservation_mode} with GROUP=/MODE= is a silent no-op here. We use
# RUN+= to chmod/chgrp the resolved sysfs path directly instead.
set -euo pipefail
if [ "$EUID" -ne 0 ]; then
echo "Run this with sudo." >&2
exit 1
fi
TARGET_USER="${SUDO_USER:-${1:-}}"
if [ -z "$TARGET_USER" ]; then
echo "No target user specified (expected \$SUDO_USER or \$1)." >&2
exit 1
fi
GROUP_NAME=lenovoctl
RULE_FILE=/etc/udev/rules.d/99-ideapad-conservation-mode.rules
if [ ! -w "$(dirname "$RULE_FILE")" ]; then
echo "Cannot write to $(dirname "$RULE_FILE") -- this looks like an immutable" >&2
echo "/etc (e.g. NixOS, where udev rules are generated from system config)." >&2
echo "See this plugin's README.md for the declarative NixOS setup instead." >&2
exit 1
fi
CHGRP_BIN="$(command -v chgrp)"
CHMOD_BIN="$(command -v chmod)"
echo "Creating group '$GROUP_NAME' (if missing) and adding $TARGET_USER..."
getent group "$GROUP_NAME" >/dev/null || groupadd "$GROUP_NAME"
usermod -aG "$GROUP_NAME" "$TARGET_USER"
echo "Writing $RULE_FILE..."
cat >"$RULE_FILE" <<EOF
ACTION=="add|change", SUBSYSTEM=="platform", DRIVER=="ideapad_acpi", RUN+="$CHGRP_BIN $GROUP_NAME /sys%p/conservation_mode", RUN+="$CHMOD_BIN 664 /sys%p/conservation_mode"
EOF
echo "Reloading udev rules..."
udevadm control --reload-rules
udevadm trigger --subsystem-match=platform
echo "Done."
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

@@ -0,0 +1,19 @@
{
"title": "Conservation Mode",
"shortcut": {
"on": "Conservation On",
"off": "Conservation Off",
"na": "Conservation N/A"
},
"notify": {
"enabled": "Enabled - charging capped for battery health.",
"disabled": "Disabled - charging to 100%.",
"setup_needed": "One-time setup needed - check the terminal window that just opened."
},
"error": {
"device_not_found": "Could not find the ideapad_acpi conservation_mode sysfs file on this machine.",
"read_failed": "Could not read the current conservation_mode state.",
"write_failed": "Failed to write conservation_mode ({error}) and could not locate the plugin directory to run setup.",
"write_failed_manual": "Failed to write conservation_mode ({error}). Run scripts/setup-permissions.sh with sudo manually, then log out and back in."
}
}