diff --git a/plugins/battery-alerts/.gitignore b/plugins/battery-alerts/.gitignore new file mode 100644 index 0000000..b8eb3e3 --- /dev/null +++ b/plugins/battery-alerts/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +*.log +*.tmp diff --git a/plugins/battery-alerts/AGENTS.md b/plugins/battery-alerts/AGENTS.md new file mode 100644 index 0000000..3a3f734 --- /dev/null +++ b/plugins/battery-alerts/AGENTS.md @@ -0,0 +1,98 @@ +# Agent guide for the battery-alerts plugin + +This folder is a Ryoku shell plugin. If you are an AI agent editing it, follow +every rule below. They are the same rules `ryoku plugin validate` enforces, so +work that breaks one will not install. This file is self-contained: you do not +need any other document to work here. + +## The rules + +R1 Place. A plugin is one folder named after its id. Author it under +`$(xdg-user-dir DOCUMENTS)/ryoku-plugins//` (fallback `~/ryoku-plugins//`); +`ryoku plugin new ` creates it there. Never write into +`~/.local/share/ryoku/plugins/` (the store's install root, receipt-owned) or +`~/.config/quickshell/` (the shipped shell). + +R2 Shape. `manifest.json` at the root; `service/Main.qml` (logic, no UI); +`content/Widget.qml` (the one view); optional `content/Panel.qml` (a bar panel); +`README.md`; `LICENSE`; `assets/preview-widget.png` (a real capture); scripts +only under `bin/` with a shebang and the exec bit. Every extra file listed in +manifest `files`. + +R3 Id. Lowercase `[a-z0-9][a-z0-9-]*`, unique, not a built-in widget id. + +R4 Imports. Only `QtQuick*`, `Quickshell*`, `Ryoku.PluginKit`, +`Ryoku.PluginKit.Singletons`, and files inside the plugin folder. Never +`shell.*`, `Ryoku.Ui*` internals, or a relative import that climbs out of the +folder. + +R5 Settings. Declared in `metadata.settings` (types the bar renders: toggle, +choice, multi, int, text); read through `pluginApi.pluginSettings` behind a +default; written only through `pluginApi.saveSetting(key, value)`. Never edit +`shell.json` or `plugins.json` directly. + +R6 Commands. Every external program the plugin runs is either in its own `bin/` +or listed in `dependencies.commands`. No `sudo`, `doas`, `su`. A privileged +action is allowed only through `pkexec`, only on an explicit click, only when +listed in manifest `capabilities.privileged` (exact command strings) and +explained in README. + +R7 Network. Every host the plugin talks to is listed in +`capabilities.network`. No `curl … | sh`, no downloading and running code. + +R8 Files. Write only under `pluginApi.stateDir` +(`$XDG_STATE_HOME/ryoku/plugins/`), `$XDG_CACHE_HOME/ryoku/plugins/`, or +a temp dir. Never touch `~/.ssh`, `/etc`, shell rc files, or another plugin's +folder. + +R9 Shell. No `sh -c` with a string built from settings or program output +(injection). Pass argv arrays. + +R10 Secrets and binaries. No tokens, keys or credentials in the tree; no +compiled binaries (ELF/Mach-O/.so); scripts only. + +R11 Honesty. `official` is never true for a community plugin. `author` is +`Name `. README says what runs, what it reads, what it writes, and every +privileged/network capability. + +## The workflow + +1. `ryoku plugin new [--bar|--desktop|--popout|--sidebar]` scaffolds this + folder (already done). This scaffold selected `topbarGlyph`. +2. Edit `service/Main.qml` for logic and `content/Widget.qml` for the view. + Edit `content/Panel.qml` only for a bar plugin. Capture + `assets/preview-widget.png`. +3. `ryoku plugin validate ` and fix every blocking finding (warnings are + advisory but read them). +4. `ryoku plugin add --yes`, then enable and place it under + **Ryoku Settings > Add-ons**. For a bar plugin, `--bar` enables it on the + current bar immediately. +5. `ryoku plugin share ` ONLY when the user asks to publish. Never share a + plugin unless asked. + +## What the host sets + +Ryoku ships four hosts: `topbarGlyph`, `desktopWidget`, `framePopout`, and +`sidebarCard`. They all mount `content/Widget.qml`; only a bar plugin may add +`content/Panel.qml`. + +The bar, desktop, and popout hosts set `pluginApi`, `s`, `active`, `density`, and +`widthBudget`. Read them; never assign them. + +For `sidebarCard`, the widget root is an `Item` with +`pragma ComponentBehavior: Bound`. The host sets `s`, `open`, `reveal`, +`tabActive`, `width`, and `pluginApi`; the card reports `implicitHeight` and +declares `signal requestClose()`. Read host-set values and never assign them. +There is no `content/Sidebar.qml`. Ryoku draws the card plate and owns its +position, size, motion, and chrome. + +`pluginApi` exposes: + +- `mainInstance`: the live `service/Main.qml` instance every view shares. +- `pluginSettings`: the resolved settings object; read behind a default. +- `pluginDir`: this plugin's folder. +- `stateDir`: `$XDG_STATE_HOME/ryoku/plugins/`; the only place to write. +- `saveSetting(key, value)`: persist one setting. +- `panelOpen` (bool), `openPanel()`, `closePanel()`, `togglePanel()`: the bar + panel controls (present but inert off the bar). The sanctioned bar-widget + click is `onClicked: pluginApi.togglePanel()`. diff --git a/plugins/battery-alerts/CLAUDE.md b/plugins/battery-alerts/CLAUDE.md new file mode 100644 index 0000000..3c35c32 --- /dev/null +++ b/plugins/battery-alerts/CLAUDE.md @@ -0,0 +1,4 @@ +# CLAUDE.md + +Read `AGENTS.md` in this folder before editing anything here. +It holds every rule this plugin must follow and the full authoring workflow. diff --git a/plugins/battery-alerts/LICENSE b/plugins/battery-alerts/LICENSE new file mode 100644 index 0000000..615b8ef --- /dev/null +++ b/plugins/battery-alerts/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 rayyan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/battery-alerts/README.md b/plugins/battery-alerts/README.md new file mode 100644 index 0000000..61786a4 --- /dev/null +++ b/plugins/battery-alerts/README.md @@ -0,0 +1,19 @@ +# Battery Alerts + +Low-battery desktop notifications for Ryoku. The existing battery indicator stays in place; the battery-alert bar mark opens alert status and a test button. + +Warnings default to **25%** and critical alerts to **10%**. Change both with the **− / + controls directly in the bar panel**, or in **QS Bar Settings → Community → Battery Alerts**. Critical is constrained below the warning threshold. Pause/resume and send a test from the bar panel. + +Alerts fire once per level per discharge session, including when enabled or loaded below a threshold. Plugging into AC rearms them. A jump directly below critical emits only the critical alert. Small percentage fluctuations do not repeat alerts. One shared monitor prevents duplicates on multiple displays in the same shell. Restarting the shell starts a new monitoring session. + +Notifications use the desktop notification service and remain in its history. **Ryoku Do Not Disturb suppresses popups, including critical ones**; turn it off to see warnings. Use **Test notification** to confirm delivery. A warning cannot guarantee protection from an abrupt power loss or an inaccurate battery gauge. This plugin never shuts down or suspends the machine. + +## Permissions + +Reads laptop battery presence, percentage, discharge state, and AC status through Quickshell's UPower API. Runs only `notify-send` with an argument array. No network, elevated privileges, scripts, or disk writes. Settings are saved by the host through the public plugin API. No polling subprocesses: event-driven battery updates with a 30-second notification retry timer while discharging. + +## Development and validation + +`node tests/policy.test.cjs` exercises the exact alert policy loaded by the live service. Validate with `ryoku plugin validate .`, install through `ryoku plugin add . --bar --yes`, then open the panel and test a notification. The preview is a real live capture. + +MIT licensed. Community maintained by rayyan; issues/contact: https://github.com/Sipper1236. Ryoku does not maintain this plugin. diff --git a/plugins/battery-alerts/assets/preview-widget.png b/plugins/battery-alerts/assets/preview-widget.png new file mode 100644 index 0000000..622854f Binary files /dev/null and b/plugins/battery-alerts/assets/preview-widget.png differ diff --git a/plugins/battery-alerts/content/Panel.qml b/plugins/battery-alerts/content/Panel.qml new file mode 100644 index 0000000..08001cb --- /dev/null +++ b/plugins/battery-alerts/content/Panel.qml @@ -0,0 +1,92 @@ +pragma ComponentBehavior: Bound +import QtQuick +import Ryoku.PluginKit.Singletons +import "../service" as Shared +Item { + id: root + property var pluginApi + property string density: "full" + property real s: 1 + property real widthBudget: 280 + property bool active: false + readonly property var service: Shared.Monitor + implicitWidth: widthBudget + implicitHeight: col.implicitHeight + 24 * s + + component Tap: Rectangle { + id: button + property string label + property bool strong: false + signal tapped() + implicitWidth: caption.implicitWidth + 16 * root.s + implicitHeight: 26 * root.s + color: strong ? Theme.accent : "transparent" + border.width: strong ? 0 : 1 + border.color: Theme.border + Text { + id: caption; anchors.centerIn: parent + text: button.label; color: button.strong ? Theme.cardBot : Theme.bright + font.family: Theme.mono; font.pixelSize: 10 * root.s + } + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: button.tapped() } + } + component Threshold: Item { + id: control + property string label + property int value + property int minimum: 1 + property int maximum: 50 + signal edited(int nextValue) + width: col.width; height: 28 * root.s + Text { + anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter + text: control.label; color: Theme.dim; font.family: Theme.font; font.pixelSize: 12 * root.s + } + Row { + anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter + spacing: 7 * root.s + Tap { label: "−"; onTapped: if (control.value > control.minimum) control.edited(control.value - 1) } + Text { + width: 32 * root.s; height: 26 * root.s + text: control.value + "%"; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter + color: Theme.bright; font.family: Theme.mono; font.pixelSize: 11 * root.s + } + Tap { label: "+"; onTapped: if (control.value < control.maximum) control.edited(control.value + 1) } + } + } + Column { + id: col + x: 12 * root.s; y: 12 * root.s + width: root.width - 24 * root.s; spacing: 8 * root.s + Item { + width: parent.width; height: 22 * root.s + Text { anchors.left: parent.left; text: qsTr("BATTERY ALERTS"); color: Theme.bright; font.family: Theme.mono; font.pixelSize: 11 * root.s; font.letterSpacing: 1 * root.s } + Text { anchors.right: parent.right; text: root.service && root.service.percentage >= 0 ? root.service.percentage + "%" : "—"; color: Theme.accent; font.family: Theme.mono; font.pixelSize: 11 * root.s } + } + Threshold { + label: qsTr("Warning"); value: root.service ? root.service.lowThreshold : 25 + minimum: root.service ? Math.max(5, root.service.criticalThreshold + 1) : 11; maximum: 50 + onEdited: (nextValue) => root.service.saveSetting("lowThreshold", nextValue) + } + Threshold { + label: qsTr("Critical"); value: root.service ? root.service.criticalThreshold : 10 + maximum: root.service ? root.service.lowThreshold - 1 : 24 + onEdited: (nextValue) => root.service.saveSetting("criticalThreshold", nextValue) + } + Rectangle { width: parent.width; height: 1; color: Theme.border } + Item { + width: parent.width; height: 26 * root.s + Tap { + anchors.left: parent.left + label: root.service && root.service.enabled ? qsTr("PAUSE") : qsTr("ENABLE") + onTapped: root.service.saveSetting("enabled", !root.service.enabled) + } + Tap { anchors.right: parent.right; strong: true; label: qsTr("TEST ALERT"); onTapped: if (root.service) root.service.testNotification() } + } + Text { + width: parent.width + text: root.service && root.service.lastResult.length > 0 ? root.service.lastResult : qsTr("Once per level · resets on AC") + color: Theme.dim; font.family: Theme.font; font.pixelSize: 10 * root.s; wrapMode: Text.WordWrap + } + } +} diff --git a/plugins/battery-alerts/content/Widget.qml b/plugins/battery-alerts/content/Widget.qml new file mode 100644 index 0000000..4a3fce7 --- /dev/null +++ b/plugins/battery-alerts/content/Widget.qml @@ -0,0 +1,34 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Controls +import Ryoku.PluginKit.Singletons +Item { + id: root + property var pluginApi + property var screen + property bool active: false + property string density: "glyph" + property real s: 1 + property real widthBudget: 220 + readonly property var service: pluginApi ? pluginApi.mainInstance : null + implicitWidth: glyph.implicitWidth + implicitHeight: 22 + Text { + id: glyph + anchors.verticalCenter: parent.verticalCenter + text: root.service && root.service.enabled ? "battery_alert" : "notifications_off" + font.family: "Material Symbols Rounded" + font.pixelSize: 19 + font.weight: 500 + font.variableAxes: ({"FILL": root.service && root.service.enabled ? 1 : 0, "opsz": 20}) + color: root.service && root.service.enabled ? Theme.accent : Theme.dim + renderType: Text.QtRendering + } + HoverHandler { id: hover } + ToolTip.visible: hover.hovered + ToolTip.delay: 350 + ToolTip.text: root.service && root.service.enabled + ? qsTr("Battery alerts · %1% / %2% (click to configure)").arg(root.service.lowThreshold).arg(root.service.criticalThreshold) + : qsTr("Battery alerts paused (click to configure)") + MouseArea { anchors.fill: parent; cursorShape: Qt.PointingHandCursor; onClicked: if (root.pluginApi) root.pluginApi.togglePanel() } +} diff --git a/plugins/battery-alerts/manifest.json b/plugins/battery-alerts/manifest.json new file mode 100644 index 0000000..0c1de0b --- /dev/null +++ b/plugins/battery-alerts/manifest.json @@ -0,0 +1,78 @@ +{ + "id": "battery-alerts", + "name": "Battery Alerts", + "version": "1.0.0", + "author": "rayyan <82241081+Sipper1236@users.noreply.github.com>", + "description": "Configurable low-battery notifications, with warnings at 25% and 10%, discharge-session deduplication, and a notification test panel.", + "license": "MIT", + "official": false, + "hosts": [ + "topbarGlyph" + ], + "entryPoints": { + "main": "service/Main.qml", + "content": "content/Widget.qml", + "panel": "content/Panel.qml" + }, + "panel": { + "width": 280 + }, + "files": [ + "service/qmldir", + "service/Monitor.qml", + "service/AlertPolicy.js", + "tests/policy.test.cjs", + "assets/preview-widget.png" + ], + "capabilities": { + "densities": [ + "glyph" + ], + "network": [] + }, + "defaults": { + "host": "topbarGlyph", + "label": "Battery Alerts", + "icon": "battery_alert" + }, + "dependencies": { + "commands": [ + "notify-send" + ] + }, + "metadata": { + "settings": [ + { + "key": "enabled", + "type": "toggle", + "label": "Enable low-battery alerts", + "group": "Alerts", + "default": true + }, + { + "key": "lowThreshold", + "type": "int", + "label": "Warning percentage", + "group": "Alerts", + "default": 25, + "min": 5, + "max": 50 + }, + { + "key": "criticalThreshold", + "type": "int", + "label": "Critical percentage (below warning)", + "group": "Alerts", + "default": 10, + "min": 1, + "max": 49 + } + ] + }, + "tags": [ + "battery", + "notifications", + "power", + "bar-widget" + ] +} diff --git a/plugins/battery-alerts/product-manifest.json b/plugins/battery-alerts/product-manifest.json new file mode 100644 index 0000000..f3778db --- /dev/null +++ b/plugins/battery-alerts/product-manifest.json @@ -0,0 +1,121 @@ +{ + "schema": 1, + "id": "battery-alerts", + "category": "plugins", + "version": "1.0.0", + "destination": "ryoku/plugins/battery-alerts", + "files": [ + { + "source": ".gitignore", + "destination": ".gitignore", + "sha256": "379dfa9273952c08fa44042de676ed4addc26a7c4d4c90235d6ff2c73dffb810", + "mode": "0644", + "size": 22, + "install": true + }, + { + "source": "AGENTS.md", + "destination": "AGENTS.md", + "sha256": "7932678a6a41d97b9fa24fd57f6febef6d3ebb39a232d4e752ae4c1bdbe4b069", + "mode": "0644", + "size": 4633, + "install": true + }, + { + "source": "CLAUDE.md", + "destination": "CLAUDE.md", + "sha256": "09ffb8ebdb8adf50bf4bde26dfe2109b4af3a4a8cc3f30cc536be42bb912c033", + "mode": "0644", + "size": 152, + "install": true + }, + { + "source": "LICENSE", + "destination": "LICENSE", + "sha256": "16276cb85960f4aa41c264f22c53be73114d81e42ec70db90a4de3755ab2c6f7", + "mode": "0644", + "size": 1063, + "install": false + }, + { + "source": "README.md", + "destination": "README.md", + "sha256": "fbbe823287c4d58acb86cb579da3584e756d2c8fe885007b1cea508c657b9702", + "mode": "0644", + "size": 2053, + "install": false + }, + { + "source": "assets/preview-widget.png", + "destination": "assets/preview-widget.png", + "sha256": "863655dd8745ee4e33b22f9d37ef4054d3c1463f2477a86ebdee110993e2fff1", + "mode": "0644", + "size": 29442, + "install": false + }, + { + "source": "content/Panel.qml", + "destination": "content/Panel.qml", + "sha256": "e92880ca7c4c690a890a92880f05910ec2b3fb8c15241f653a2ddd2308552b24", + "mode": "0644", + "size": 4328, + "install": true + }, + { + "source": "content/Widget.qml", + "destination": "content/Widget.qml", + "sha256": "8a91f3b410ee061177ebd005688aad58b223ca8cf3ea4956ec8035b9f1ce5034", + "mode": "0644", + "size": 1411, + "install": true + }, + { + "source": "manifest.json", + "destination": "manifest.json", + "sha256": "14e8ca1b059b96eda42fb07bf2d5c477413b9150066984c4eed1bafa6382cd05", + "mode": "0644", + "size": 1664, + "install": true + }, + { + "source": "service/AlertPolicy.js", + "destination": "service/AlertPolicy.js", + "sha256": "77a7e2e0c272f7ccf5f32ff3a363a34f126ccc83e75d85049735a93b419766bd", + "mode": "0644", + "size": 916, + "install": true + }, + { + "source": "service/Main.qml", + "destination": "service/Main.qml", + "sha256": "8af0a3d551b079283cc45bc38f3272cbf244f61ab5651719aec8429af99c6ecf", + "mode": "0644", + "size": 1306, + "install": true + }, + { + "source": "service/Monitor.qml", + "destination": "service/Monitor.qml", + "sha256": "30f489625e8aeb96ccd7c07c03c87afb6eac8f37ccd3a1634f2de61cb6f2c5b0", + "mode": "0644", + "size": 5514, + "install": true + }, + { + "source": "service/qmldir", + "destination": "service/qmldir", + "sha256": "00fe55feaca6edeab29d7c49a3f48d4c4d961021864bd22c43af3c3bc8ad340b", + "mode": "0644", + "size": 34, + "install": true + }, + { + "source": "tests/policy.test.cjs", + "destination": "tests/policy.test.cjs", + "sha256": "a08cc6eb16499f681ec10b181d591742d4cde206a18c03e92ac6ccb225ff10e8", + "mode": "0644", + "size": 1739, + "install": true + } + ] +} diff --git a/plugins/battery-alerts/service/AlertPolicy.js b/plugins/battery-alerts/service/AlertPolicy.js new file mode 100644 index 0000000..2d2ddf3 --- /dev/null +++ b/plugins/battery-alerts/service/AlertPolicy.js @@ -0,0 +1,18 @@ +// The live QML service and regression tests use this same policy. +function initialState() { return { lowSent: false, criticalSent: false }; } +function evaluate(state, sample, settings) { + var next = { lowSent: state.lowSent, criticalSent: state.criticalSent }; + if (sample.onAc) return { state: initialState(), level: "" }; + if (!settings.enabled || !sample.present || !sample.ready || !sample.discharging + || !Number.isFinite(sample.percent) || sample.percent < 0 || sample.percent > 100) + return { state: next, level: "" }; + if (sample.percent <= settings.critical && !next.criticalSent) { + next.lowSent = true; next.criticalSent = true; + return { state: next, level: "critical" }; + } + if (sample.percent <= settings.low && !next.lowSent) { + next.lowSent = true; + return { state: next, level: "low" }; + } + return { state: next, level: "" }; +} diff --git a/plugins/battery-alerts/service/Main.qml b/plugins/battery-alerts/service/Main.qml new file mode 100644 index 0000000..de0e688 --- /dev/null +++ b/plugins/battery-alerts/service/Main.qml @@ -0,0 +1,35 @@ +pragma ComponentBehavior: Bound +import QtQuick +import "." as Shared + +Item { + id: root + property var pluginApi + property bool attached: false + property var attachedApi: null + readonly property var settings: pluginApi ? pluginApi.pluginSettings : null + readonly property bool enabled: Shared.Monitor.enabled + readonly property int lowThreshold: Shared.Monitor.lowThreshold + readonly property int criticalThreshold: Shared.Monitor.criticalThreshold + readonly property int percentage: Shared.Monitor.percentage + readonly property string status: Shared.Monitor.status + readonly property string lastResult: Shared.Monitor.lastResult + function sync() { + if (!pluginApi) return; + if (attached && attachedApi !== pluginApi) { + Shared.Monitor.detach(attachedApi); + attached = false; + } + if (!attached) { + attached = true; + attachedApi = pluginApi; + Shared.Monitor.attach(pluginApi); + } + Shared.Monitor.settings = settings || ({}); + } + function testNotification() { Shared.Monitor.testNotification(); } + onPluginApiChanged: sync() + onSettingsChanged: sync() + Component.onCompleted: sync() + Component.onDestruction: if (attached) Shared.Monitor.detach(attachedApi) +} diff --git a/plugins/battery-alerts/service/Monitor.qml b/plugins/battery-alerts/service/Monitor.qml new file mode 100644 index 0000000..e1f0286 --- /dev/null +++ b/plugins/battery-alerts/service/Monitor.qml @@ -0,0 +1,126 @@ +pragma Singleton +pragma ComponentBehavior: Bound +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Services.UPower +import "AlertPolicy.js" as Policy + +// One monitor per QML engine, even with several physical displays. +Singleton { + id: svc + property int clients: 0 + property var apis: [] + readonly property var activeApi: apis.length > 0 ? apis[apis.length - 1] : null + function attach(api) { apis = apis.concat([api]); clients++; } + function detach(api) { + var next = apis.slice(); + var index = next.indexOf(api); + if (index >= 0) next.splice(index, 1); + apis = next; + clients = Math.max(0, clients - 1); + } + function saveSetting(key, value) { + if (activeApi) activeApi.saveSetting(key, value); + } + property var settings: ({}) + readonly property bool enabled: clients > 0 && (settings.enabled ?? true) + readonly property int lowThreshold: Math.max(5, Math.min(50, Number(settings.lowThreshold ?? 25))) + readonly property int criticalThreshold: Math.max(1, Math.min(lowThreshold - 1, Number(settings.criticalThreshold ?? 10))) + readonly property var battery: { + var devices = UPower.devices ? UPower.devices.values : []; + for (var i = 0; i < devices.length; i++) { + if (devices[i].isLaptopBattery && devices[i].isPresent) return devices[i]; + } + return null; + } + readonly property bool present: battery !== null && battery.isPresent + readonly property bool ready: present && battery.ready + readonly property bool onAc: !UPower.onBattery + readonly property bool discharging: present && battery.state === UPowerDeviceState.Discharging + readonly property real percent: ready ? battery.percentage * 100 : NaN + readonly property int percentage: Number.isFinite(percent) ? Math.round(percent) : -1 + readonly property string status: !present ? qsTr("No laptop battery") + : !ready ? qsTr("Waiting for battery") : onAc ? qsTr("On AC power") + : discharging ? qsTr("On battery") : qsTr("Battery idle") + property string lastResult: "" + property var alertState: Policy.initialState() + property var pendingState: null + property int session: 0 + property int pendingSession: 0 + property bool testing: false + + // Public diagnostics use the same live instance and notification path as + // the panel; no simulated battery or bypass of the alert policy. + IpcHandler { + target: svc.clients > 0 ? "battery-alerts" : "" + function test(): void { svc.testNotification(); } + function status(): string { + return JSON.stringify({ clients: svc.clients, percent: svc.percentage, + enabled: svc.enabled, onAc: svc.onAc, ready: svc.ready, + warning: svc.lowThreshold, critical: svc.criticalThreshold, + lastResult: svc.lastResult }); + } + } + + function evaluate() { + if (onAc) { alertState = Policy.initialState(); return; } + if (notifier.running) return; + var result = Policy.evaluate(alertState, + { present: present, ready: ready, onAc: onAc, discharging: discharging, percent: percent }, + { enabled: enabled, low: lowThreshold, critical: criticalThreshold }); + if (!result.level) return; + pendingState = result.state; + pendingSession = session; + testing = false; + send(result.level === "critical" ? qsTr("Battery critical") : qsTr("Battery low"), + qsTr("Battery at %1%. Connect your charger.").arg(percentage), result.level === "critical"); + } + function send(title, body, critical) { + notifier.command = ["notify-send", "--app-name=Battery Alerts", "--icon=battery-caution-symbolic", + critical ? "--urgency=critical" : "--urgency=normal", title, body]; + lastResult = qsTr("Sending notification…"); + notifier.running = true; + } + function testNotification() { + if (notifier.running) return; + testing = true; + pendingState = null; + send(qsTr("Battery Alerts test"), + qsTr("Low-battery alerts are ready. Warning: %1%. Critical: %2%.").arg(lowThreshold).arg(criticalThreshold), false); + } + onOnAcChanged: { + session++; + if (onAc) alertState = Policy.initialState(); + soon.restart(); + } + onPercentChanged: soon.restart() + onReadyChanged: soon.restart() + onDischargingChanged: soon.restart() + onSettingsChanged: soon.restart() + onEnabledChanged: soon.restart() + Component.onCompleted: soon.restart() + Timer { id: soon; interval: 300; onTriggered: svc.evaluate() } + // Alerts stay alive while their panel is closed or bar hidden. + Timer { + interval: 30000 + running: svc.enabled && svc.present && !svc.onAc + repeat: true + onTriggered: svc.evaluate() + } + Process { + id: notifier + onExited: (code) => { + if (code === 0) { + if (!svc.testing && svc.pendingState && svc.pendingSession === svc.session && !svc.onAc) + svc.alertState = svc.pendingState; + svc.lastResult = qsTr("Notification sent."); + } else { + svc.lastResult = qsTr("Notification failed. Check notify-send and the notification service."); + } + svc.pendingState = null; + // The periodic timer retries failures; avoid an immediate retry loop. + if (code === 0) soon.restart(); + } + } +} diff --git a/plugins/battery-alerts/service/qmldir b/plugins/battery-alerts/service/qmldir new file mode 100644 index 0000000..60359c1 --- /dev/null +++ b/plugins/battery-alerts/service/qmldir @@ -0,0 +1 @@ +singleton Monitor 1.0 Monitor.qml diff --git a/plugins/battery-alerts/tests/policy.test.cjs b/plugins/battery-alerts/tests/policy.test.cjs new file mode 100644 index 0000000..01af985 --- /dev/null +++ b/plugins/battery-alerts/tests/policy.test.cjs @@ -0,0 +1,34 @@ +const fs = require('node:fs'); +const vm = require('node:vm'); +const assert = require('node:assert/strict'); +const ctx = vm.createContext({}); +vm.runInContext(fs.readFileSync(__dirname + '/../service/AlertPolicy.js', 'utf8'), ctx); +const options = { enabled: true, low: 25, critical: 10 }; +const sample = { present: true, ready: true, onAc: false, discharging: true, percent: 50 }; +let state = ctx.initialState(); +function step(overrides = {}, settings = options) { + const result = ctx.evaluate(state, { ...sample, ...overrides }, settings); + state = result.state; + return result.level; +} +assert.equal(step(), ''); +assert.equal(step({ percent: 25 }), 'low'); +assert.equal(step({ percent: 24 }), ''); +assert.equal(step({ percent: 26 }), ''); +assert.equal(step({ percent: 25 }), ''); +assert.equal(step({ percent: 10 }), 'critical'); +assert.equal(step({ percent: 9 }), ''); +assert.equal(step({ onAc: true, percent: 9 }), ''); +assert.equal(step({ percent: 9 }), 'critical'); +assert.equal(step({ percent: 23 }), ''); +step({ onAc: true }); +for (const invalid of [{ present: false }, { ready: false }, { discharging: false }, { percent: NaN }, { percent: -1 }, { percent: 101 }]) + assert.equal(step({ percent: 5, ...invalid }), ''); +assert.equal(step({ percent: 5 }, { ...options, enabled: false }), ''); +assert.equal(step({ percent: 5 }), 'critical'); +step({ onAc: true }); +assert.equal(step({ percent: 20 }, { ...options, low: 30 }), 'low'); +assert.equal(step({ percent: 11.1 }), ''); +assert.equal(step({ percent: 10.1 }), ''); +assert.equal(step({ percent: 9.9 }), 'critical'); +console.log('PASS: startup below threshold, exact thresholds, rapid drops, deduplication, AC reset, invalid samples, disabled alerts, fractional charge'); diff --git a/plugins/registry.json b/plugins/registry.json index ee1011f..b1fb036 100644 --- a/plugins/registry.json +++ b/plugins/registry.json @@ -1651,6 +1651,36 @@ "topbarGlyph" ], "lastUpdated": "2026-10-07" + }, + { + "id": "battery-alerts", + "name": "Battery Alerts", + "version": "1.0.0", + "path": "plugins/battery-alerts", + "author": "rayyan <82241081+Sipper1236@users.noreply.github.com>", + "summary": "Configurable low-battery notifications, with warnings at 25% and 10%, discharge-session deduplication, and a notification test panel.", + "description": "Configurable low-battery notifications, with warnings at 25% and 10%, discharge-session deduplication, and a notification test panel.", + "tags": [ + "battery", + "notifications", + "power", + "bar-widget" + ], + "accent": "#8f8378", + "surface": "#101010", + "preview": "assets/preview-widget.png", + "screenshots": [ + "assets/preview-widget.png" + ], + "manifest": "product-manifest.json", + "manifestSha256": "b1e54cf95417c87032412eaedf097a6e8c98c22dd869e4ab2e220f59a08b962f", + "official": false, + "tagline": "Configurable low-battery notifications, with warnings at 25% and 10%, discharge-session deduplication, and a notification test panel.", + "icon": "battery_alert", + "hosts": [ + "topbarGlyph" + ], + "lastUpdated": "2026-10-09" } ] }