Power: one Protect battery switch (thresholds, charge_types or conservation_mode) #41

Closed
Breadway wants to merge 0 commits from fix/battery-protect into main
Owner

The charge limit control was gated on charge_control_{start,end}_threshold, so a laptop like the Yoga Slim 7 (which has /sys/class/power_supply/BAT1/charge_types = [Standard] Long_Life and ideapad conservation_mode) had no battery protection in the UI.

Backend (power.rs): get_power_info gains protection { kind, enabled, limit_pct }; new set_battery_protection(enabled). Mechanism preference:

  1. thresholds: protect = stop at 80, resume at 75 (end written before start when enabling, start before end when disabling, so start is never above end; start is optional);
  2. charge_types that lists Long_Life: Long_Life / Standard;
  3. ideapad conservation_mode: 1 / 0.
    The control hides itself only when none exist.

Privilege. Writes use the existing pkexec tee <path> pattern (util::run_with_stdin). The path is never accepted from the webview: it is discovered under /sys by the backend and re-validated by valid_protect_path right before each write (exact file names, charge_* only under /sys/class/power_supply/, conservation_mode only under /sys/bus/platform/drivers/ideapad_acpi/, no .., no whitespace). The same check now guards the existing set_charge_threshold. Values come from a fixed table (80, 75, 100, 0, Long_Life, Standard, 1, 0).

Frontend (Power panel, minimal): a "Protect battery" switch in its own group; the switch moves only after the write succeeded, and a refused or cancelled authentication restores it and shows the reason. The threshold sliders stay for hardware that has them.

Tests (9 new): charge_types parsing, detection priority against a fake sysfs tree, charge_types without Long_Life is not protection, threshold write order, exact fixed-mode values, and the path allowlist (wrong directory for a file name, traversal, other files).

Manual test checklist (UI not run, by instruction)

  1. Power & battery on the Yoga: a "Protect battery" group appears. Toggle it, approve the polkit prompt; cat /sys/class/power_supply/BAT1/charge_types shows Standard [Long_Life] (or [Standard] Long_Life after turning it off).
  2. Cancel the polkit prompt: the switch returns to its old position and a message is shown.
  3. On a machine with threshold files: the switch sets 80/75 and the existing sliders follow.

Verification: npm run check (0 errors, 0 warnings), npm run build, cargo fmt --all -- --check, cargo clippy --all-targets --locked -- -D warnings, cargo test --locked (82 passed).

Closes BSET-36

The charge limit control was gated on `charge_control_{start,end}_threshold`, so a laptop like the Yoga Slim 7 (which has `/sys/class/power_supply/BAT1/charge_types` = `[Standard] Long_Life` and ideapad `conservation_mode`) had no battery protection in the UI. **Backend (`power.rs`)**: `get_power_info` gains `protection { kind, enabled, limit_pct }`; new `set_battery_protection(enabled)`. Mechanism preference: 1. thresholds: protect = stop at 80, resume at 75 (end written before start when enabling, start before end when disabling, so start is never above end; start is optional); 2. `charge_types` that lists `Long_Life`: `Long_Life` / `Standard`; 3. ideapad `conservation_mode`: `1` / `0`. The control hides itself only when none exist. **Privilege.** Writes use the existing `pkexec tee <path>` pattern (`util::run_with_stdin`). The path is never accepted from the webview: it is discovered under `/sys` by the backend and re-validated by `valid_protect_path` right before each write (exact file names, `charge_*` only under `/sys/class/power_supply/`, `conservation_mode` only under `/sys/bus/platform/drivers/ideapad_acpi/`, no `..`, no whitespace). The same check now guards the existing `set_charge_threshold`. Values come from a fixed table (`80`, `75`, `100`, `0`, `Long_Life`, `Standard`, `1`, `0`). **Frontend (Power panel, minimal)**: a "Protect battery" switch in its own group; the switch moves only after the write succeeded, and a refused or cancelled authentication restores it and shows the reason. The threshold sliders stay for hardware that has them. **Tests (9 new)**: charge_types parsing, detection priority against a fake sysfs tree, `charge_types` without `Long_Life` is not protection, threshold write order, exact fixed-mode values, and the path allowlist (wrong directory for a file name, traversal, other files). **Manual test checklist (UI not run, by instruction)** 1. Power & battery on the Yoga: a "Protect battery" group appears. Toggle it, approve the polkit prompt; `cat /sys/class/power_supply/BAT1/charge_types` shows `Standard [Long_Life]` (or `[Standard] Long_Life` after turning it off). 2. Cancel the polkit prompt: the switch returns to its old position and a message is shown. 3. On a machine with threshold files: the switch sets 80/75 and the existing sliders follow. Verification: `npm run check` (0 errors, 0 warnings), `npm run build`, `cargo fmt --all -- --check`, `cargo clippy --all-targets --locked -- -D warnings`, `cargo test --locked` (82 passed). Closes BSET-36
fix(power): one Protect battery switch for thresholds, charge_types or conservation_mode
All checks were successful
check / check (push) Successful in 2m8s
85432a2c09
The charge limit control only appeared for charge_control_*_threshold, so
laptops that expose charge_types (Long_Life) or ideapad conservation_mode, like
the Yoga Slim 7, had no battery protection in the UI.

get_power_info now reports a protection descriptor and set_battery_protection
flips it, preferring thresholds (stop 80, resume 75, written in an order that
never leaves start above end), then charge_types Long_Life/Standard, then
conservation_mode 1/0. Writes use the existing pkexec tee pattern; the path is
discovered under /sys by the backend, never taken from the webview, and is
re-validated against a strict allowlist of file names and directories before
every write. The existing threshold sliders stay for hardware that has them.
Author
Owner

Not merged yet: #42 (now on main) and this PR both add imports at the top of frontend/src/lib/views/Power.svelte (SwitchField + the BatteryProtection interface vs liveGate), and that one hunk is the only conflict. power.rs and lib.rs merge cleanly. Rebase onto main keeping both imports and the BatteryProtection interface, and it is good to merge.

Not merged yet: #42 (now on main) and this PR both add imports at the top of `frontend/src/lib/views/Power.svelte` (`SwitchField` + the `BatteryProtection` interface vs `liveGate`), and that one hunk is the only conflict. `power.rs` and `lib.rs` merge cleanly. Rebase onto main keeping both imports and the `BatteryProtection` interface, and it is good to merge.
Breadway closed this pull request 2026-10-13 19:05:28 +08:00
Author
Owner

Closing: these commits reached main through #46 (feature/settings-v2 was merged into the v2 branch).

Closing: these commits reached main through #46 (feature/settings-v2 was merged into the v2 branch).
All checks were successful
check / check (push) Successful in 2m8s

Pull request closed

Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Breadway/bos-settings!41
No description provided.