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:
co-authored by
Lemmy
parent
f1a38ca585
commit
2922dd5d40
@@ -0,0 +1,38 @@
|
||||
# IdeaPad Conservation Mode
|
||||

|
||||
|
||||
**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
|
||||
@@ -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
@@ -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."
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user