diff --git a/ideapad-conservation-mode/README.md b/ideapad-conservation-mode/README.md new file mode 100644 index 0000000..7328bb4 --- /dev/null +++ b/ideapad-conservation-mode/README.md @@ -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..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. diff --git a/ideapad-conservation-mode/conservation_mode.luau b/ideapad-conservation-mode/conservation_mode.luau new file mode 100644 index 0000000..fc4f818 --- /dev/null +++ b/ideapad-conservation-mode/conservation_mode.luau @@ -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 diff --git a/ideapad-conservation-mode/plugin.toml b/ideapad-conservation-mode/plugin.toml new file mode 100644 index 0000000..e6c5505 --- /dev/null +++ b/ideapad-conservation-mode/plugin.toml @@ -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" diff --git a/ideapad-conservation-mode/scripts/setup-permissions.sh b/ideapad-conservation-mode/scripts/setup-permissions.sh new file mode 100755 index 0000000..43a82c0 --- /dev/null +++ b/ideapad-conservation-mode/scripts/setup-permissions.sh @@ -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" <