Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions plugins/battery-alerts/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.DS_Store
*.log
*.tmp
98 changes: 98 additions & 0 deletions plugins/battery-alerts/AGENTS.md
Original file line number Diff line number Diff line change
@@ -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/<id>/` (fallback `~/ryoku-plugins/<id>/`);
`ryoku plugin new <id>` 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/<id>`), `$XDG_CACHE_HOME/ryoku/plugins/<id>`, 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 <mail>`. README says what runs, what it reads, what it writes, and every
privileged/network capability.

## The workflow

1. `ryoku plugin new <id> [--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 <dir>` and fix every blocking finding (warnings are
advisory but read them).
4. `ryoku plugin add <dir> --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 <id>` 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/<id>`; 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()`.
4 changes: 4 additions & 0 deletions plugins/battery-alerts/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions plugins/battery-alerts/LICENSE
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 19 additions & 0 deletions plugins/battery-alerts/README.md
Original file line number Diff line number Diff line change
@@ -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.
Binary file added plugins/battery-alerts/assets/preview-widget.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
92 changes: 92 additions & 0 deletions plugins/battery-alerts/content/Panel.qml
Original file line number Diff line number Diff line change
@@ -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
}
}
}
34 changes: 34 additions & 0 deletions plugins/battery-alerts/content/Widget.qml
Original file line number Diff line number Diff line change
@@ -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() }
}
78 changes: 78 additions & 0 deletions plugins/battery-alerts/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
{
"id": "battery-alerts",
"name": "Battery Alerts",
"version": "1.0.0",
"author": "rayyan <[email protected]>",
"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"
]
}
Loading
Loading