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