From 762d340b80e2253d5003b7bb002ca7b5b6effee0 Mon Sep 17 00:00:00 2001 From: rayyan Date: Fri, 9 Oct 2026 21:30:15 -0300 Subject: [PATCH] plugins: add Battery Alerts 1.0.0 --- plugins/battery-alerts/.gitignore | 3 + plugins/battery-alerts/AGENTS.md | 98 ++++++++++++++ plugins/battery-alerts/CLAUDE.md | 4 + plugins/battery-alerts/LICENSE | 21 +++ plugins/battery-alerts/README.md | 19 +++ .../battery-alerts/assets/preview-widget.png | Bin 0 -> 29442 bytes plugins/battery-alerts/content/Panel.qml | 92 +++++++++++++ plugins/battery-alerts/content/Widget.qml | 34 +++++ plugins/battery-alerts/manifest.json | 78 +++++++++++ plugins/battery-alerts/product-manifest.json | 121 +++++++++++++++++ plugins/battery-alerts/service/AlertPolicy.js | 18 +++ plugins/battery-alerts/service/Main.qml | 35 +++++ plugins/battery-alerts/service/Monitor.qml | 126 ++++++++++++++++++ plugins/battery-alerts/service/qmldir | 1 + plugins/battery-alerts/tests/policy.test.cjs | 34 +++++ plugins/registry.json | 30 +++++ 16 files changed, 714 insertions(+) create mode 100644 plugins/battery-alerts/.gitignore create mode 100644 plugins/battery-alerts/AGENTS.md create mode 100644 plugins/battery-alerts/CLAUDE.md create mode 100644 plugins/battery-alerts/LICENSE create mode 100644 plugins/battery-alerts/README.md create mode 100644 plugins/battery-alerts/assets/preview-widget.png create mode 100644 plugins/battery-alerts/content/Panel.qml create mode 100644 plugins/battery-alerts/content/Widget.qml create mode 100644 plugins/battery-alerts/manifest.json create mode 100644 plugins/battery-alerts/product-manifest.json create mode 100644 plugins/battery-alerts/service/AlertPolicy.js create mode 100644 plugins/battery-alerts/service/Main.qml create mode 100644 plugins/battery-alerts/service/Monitor.qml create mode 100644 plugins/battery-alerts/service/qmldir create mode 100644 plugins/battery-alerts/tests/policy.test.cjs 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 0000000000000000000000000000000000000000..622854fbd21a56ef90237bc6c5ad541b82695bc7 GIT binary patch literal 29442 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 Kvr<^k?|%VUaZ{QA literal 0 HcmV?d00001 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" } ] }