diff --git a/theme/noctalia-plugins/stoa-health/BarWidget.qml b/theme/noctalia-plugins/stoa-health/BarWidget.qml index 308e34c..c4ebc37 100644 --- a/theme/noctalia-plugins/stoa-health/BarWidget.qml +++ b/theme/noctalia-plugins/stoa-health/BarWidget.qml @@ -30,7 +30,6 @@ Item { // ── User settings (Settings.qml / manifest defaults) ── readonly property string cfgIcon: pluginApi?.pluginSettings?.icon ?? "heart" - readonly property string cfgTerminal: pluginApi?.pluginSettings?.terminal ?? "kitty" readonly property int cfgPollMs: (pluginApi?.pluginSettings?.pollSeconds ?? 30) * 1000 readonly property bool cfgBadge: pluginApi?.pluginSettings?.showBadge ?? true readonly property bool cfgPulse: pluginApi?.pluginSettings?.pulse ?? true @@ -81,12 +80,19 @@ Item { return "Stoa Health — " + parts.join(" · ") } - // ── Helpers ── - function runInTerm(title, cmd) { - Quickshell.execDetached([root.cfgTerminal, "--title", title, "--hold", "sh", "-c", - 'export PATH="$HOME/.local/bin:$PATH"; ' + cmd]) + // ── Helpers (all detached, all silent — no terminal opened) ── + function _runBg(label, cmd) { + var script = + 'export PATH="$HOME/.local/bin:$PATH"; ' + + 'notify-send "Stoa Health" "Running: ' + label + '"; ' + + 'if ' + cmd + '; then ' + + ' notify-send "Stoa Health" "Done: ' + label + '"; ' + + 'else ' + + ' notify-send -u critical "Stoa Health" "Failed: ' + label + '"; ' + + 'fi' + Quickshell.execDetached(["sh", "-c", script]) } - function runSilent(cmd) { + function _runOpen(cmd) { Quickshell.execDetached(["sh", "-c", 'export PATH="$HOME/.local/bin:$PATH"; ' + cmd]) } @@ -165,11 +171,11 @@ Item { contextMenu.close() PanelService.closeContextMenu(screen) if (action === "doctor") - root.runInTerm("stoa-doctor", root._bin + "/stoa-doctor") + root._runBg("Doctor", root._bin + "/stoa-doctor") else if (action === "snapshot") - root.runSilent(root._bin + "/stoa-pkg-snapshot && notify-send 'Stoa Health' 'Package snapshot saved'") + root._runBg("Package snapshot", root._bin + "/stoa-pkg-snapshot") else if (action === "log") - root.runInTerm("stoa-doctor log", "cat " + root._home + "/.config/stoa/doctor.log") + root._runOpen("xdg-open " + root._home + "/.config/stoa/doctor.log") else if (action === "settings" && pluginApi?.manifest) BarService.openPluginSettings(screen, pluginApi.manifest) } diff --git a/theme/noctalia-plugins/stoa-health/Panel.qml b/theme/noctalia-plugins/stoa-health/Panel.qml index 9939e0f..2ad9480 100644 --- a/theme/noctalia-plugins/stoa-health/Panel.qml +++ b/theme/noctalia-plugins/stoa-health/Panel.qml @@ -8,9 +8,10 @@ // The Panel reads pluginSettings.actions, filters by tab+visible, // preserves the user's order, and dispatches by id. // -// Every action runs through Quickshell.execDetached — never Process — -// because Process objects are destroyed (and their children killed) -// the moment the panel closes. +// Every action runs through Quickshell.execDetached, dispatched BEFORE +// closePanel() — closing the panel destroys the QML tree synchronously, +// and any execDetached call after that point silently no-ops. Order +// matters: spawn first, close second. import QtQuick import QtQuick.Layouts @@ -26,16 +27,14 @@ Item { property var pluginApi: null property ShellScreen screen - // Float top-center on screen (same placement as the Clipboard - // History panel) instead of anchoring to the bar widget. - readonly property bool panelAnchorHorizontalCenter: true - readonly property bool panelAnchorTop: true - readonly property string _home: Quickshell.env("HOME") || "" readonly property string _bin: _home + "/.local/bin" - readonly property string cfgIcon: pluginApi?.pluginSettings?.icon ?? "heart" - readonly property string cfgTerminal: pluginApi?.pluginSettings?.terminal ?? "kitty" + readonly property string cfgIcon: pluginApi?.pluginSettings?.icon ?? "heart" + // "pkexec" → graphical polkit prompt once per action (default, safe). + // "sudo-n" → sudo -n, requires NOPASSWD sudoers rule for these commands. + readonly property string cfgPrivilege: pluginApi?.pluginSettings?.privilege ?? "pkexec" + readonly property string _sudo: cfgPrivilege === "sudo-n" ? "sudo -n " : "pkexec " // Manifest defaults reproduced here so the panel still works when // pluginSettings.actions is absent (first run, before any save). @@ -113,32 +112,48 @@ Item { Component.onCompleted: statusProc.running = true // ── Helpers ── - function runInTerm(title, cmd) { - Quickshell.execDetached([root.cfgTerminal, "--title", title, "--hold", "sh", "-c", - 'export PATH="$HOME/.local/bin:$PATH"; ' + cmd]) - pluginApi?.closePanel(screen) + // All actions are wrapped in a single sh -c that: + // 1. fires a "Running" notification + // 2. runs the command silently + // 3. fires "Done" or "Failed" depending on exit code + // This runs detached so it survives the panel closing. The panel + // closes AFTER execDetached returns — if the order is reversed the + // QML tree gets destroyed mid-call and the spawn silently no-ops. + function _runBg(label, cmd) { + var script = + 'export PATH="$HOME/.local/bin:$PATH"; ' + + 'notify-send "Stoa Health" "Running: ' + label + '"; ' + + 'if ' + cmd + '; then ' + + ' notify-send "Stoa Health" "Done: ' + label + '"; ' + + 'else ' + + ' notify-send -u critical "Stoa Health" "Failed: ' + label + '"; ' + + 'fi' + Quickshell.execDetached(["sh", "-c", script]) + if (pluginApi) pluginApi.closePanel(screen) } - function runSilent(cmd) { + // Fire-and-forget without notifications (opening file managers etc.) + function _runOpen(cmd) { Quickshell.execDetached(["sh", "-c", 'export PATH="$HOME/.local/bin:$PATH"; ' + cmd]) - pluginApi?.closePanel(screen) + if (pluginApi) pluginApi.closePanel(screen) } + function runAction(id) { switch (id) { - case "doctor": return runInTerm("stoa-doctor", _bin + "/stoa-doctor") - case "log": return runInTerm("stoa-doctor log", "cat " + _home + "/.config/stoa/doctor.log") - case "journal": return runInTerm("journal", "journalctl -p 3..4 -b --no-pager | tail -200") - case "pkgsnap": return runSilent(_bin + "/stoa-pkg-snapshot && notify-send 'Stoa Health' 'Package snapshot saved'") - case "backup": return runInTerm("stoa-maintain backup", _bin + "/stoa-maintain --backup") - case "lspkg": return runSilent("xdg-open " + _home + "/.config/stoa/pkg-snapshots") - case "lsbk": return runSilent("xdg-open " + _home) - case "updAll": return runInTerm("update-all", "sudo pacman -Syu && yay -Syu") - case "updSys": return runInTerm("update", "sudo pacman -Syu") - case "updAur": return runInTerm("update-aur", "yay -Syu") - case "cleanDry": return runInTerm("stoa-maintain (dry-run)", _bin + "/stoa-maintain --cleanup --dry-run") - case "cleanApply": return runInTerm("stoa-maintain", _bin + "/stoa-maintain --cleanup") - case "sched": return runInTerm("stoa-maintain (schedule)", _bin + "/stoa-maintain --schedule") - case "fw": return runInTerm("stoa-locksmith", _bin + "/stoa-locksmith") + case "doctor": return _runBg("Doctor", _bin + "/stoa-doctor") + case "log": return _runOpen("xdg-open " + _home + "/.config/stoa/doctor.log") + case "journal": return _runOpen("xdg-open " + _home + "/.config/stoa/doctor.log") + case "pkgsnap": return _runBg("Package snapshot", _bin + "/stoa-pkg-snapshot") + case "backup": return _runBg("Backup configs", _bin + "/stoa-maintain --backup") + case "lspkg": return _runOpen("xdg-open " + _home + "/.config/stoa/pkg-snapshots") + case "lsbk": return _runOpen("xdg-open " + _home) + case "updAll": return _runBg("Update all", _sudo + "pacman -Syu --noconfirm && yay -Syu --noconfirm") + case "updSys": return _runBg("Update system", _sudo + "pacman -Syu --noconfirm") + case "updAur": return _runBg("Update AUR", "yay -Syu --noconfirm") + case "cleanDry": return _runBg("Cleanup (dry-run)", _bin + "/stoa-maintain --cleanup --dry-run") + case "cleanApply": return _runBg("Cleanup", _bin + "/stoa-maintain --cleanup") + case "sched": return _runBg("Toggle schedule", _sudo + _bin + "/stoa-maintain --schedule") + case "fw": return _runBg("Firewall", _bin + "/stoa-locksmith") } } @@ -271,9 +286,9 @@ Item { hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { - pluginApi?.closePanel(screen) if (pluginApi?.manifest) BarService.openPluginSettings(screen, pluginApi.manifest) + if (pluginApi) pluginApi.closePanel(screen) } } } diff --git a/theme/noctalia-plugins/stoa-health/Settings.qml b/theme/noctalia-plugins/stoa-health/Settings.qml index a9ee510..11f70f8 100644 --- a/theme/noctalia-plugins/stoa-health/Settings.qml +++ b/theme/noctalia-plugins/stoa-health/Settings.qml @@ -1,13 +1,13 @@ // Stoa Health — Settings UI -// Rendered inside Noctalia Settings (gear in the plugin manager, or -// "Settings" in the bar widget's right-click menu). +// Rendered inside Noctalia Settings (gear in the plugin manager, the +// gear in the panel header, or the right-click menu). // -// Two sections: -// 1. Appearance — bar icon, badge, pulse, poll, terminal -// 2. Actions — per-action editor (icon, label, visibility, order) -// grouped by tab. +// Only NSpinBox / NToggle / NComboBox are used — those are the +// confirmed plugin-settings components (see official clipboard plugin). +// Section headers and free text use plain Text from QtQuick. import QtQuick +import QtQuick.Controls import QtQuick.Layouts import qs.Commons import qs.Widgets @@ -20,7 +20,7 @@ ColumnLayout { // ── Appearance values ── property string valueIcon: pluginApi?.pluginSettings?.icon ?? "heart" - property string valueTerminal: pluginApi?.pluginSettings?.terminal ?? "kitty" + property string valuePrivilege: pluginApi?.pluginSettings?.privilege ?? "pkexec" property int valuePollSeconds: pluginApi?.pluginSettings?.pollSeconds ?? 30 property bool valueShowBadge: pluginApi?.pluginSettings?.showBadge ?? true property bool valuePulse: pluginApi?.pluginSettings?.pulse ?? true @@ -44,8 +44,11 @@ ColumnLayout { ] property var valueActions: [] - // Icon options reused everywhere readonly property var _iconChoices: [ + { "key": "heart", "name": "Heart" }, + { "key": "activity", "name": "Activity" }, + { "key": "stethoscope", "name": "Stethoscope" }, + { "key": "shield", "name": "Shield" }, { "key": "refresh", "name": "Refresh" }, { "key": "file-text", "name": "File / Log" }, { "key": "terminal", "name": "Terminal" }, @@ -54,28 +57,20 @@ ColumnLayout { { "key": "download", "name": "Download" }, { "key": "trash", "name": "Trash" }, { "key": "clock", "name": "Clock" }, - { "key": "shield", "name": "Shield" }, - { "key": "stethoscope", "name": "Stethoscope" }, - { "key": "activity", "name": "Activity" }, - { "key": "heart", "name": "Heart" }, - { "key": "heartbeat", "name": "Heartbeat" }, - { "key": "first-aid-kit", "name": "First-aid kit" }, { "key": "alert-triangle", "name": "Alert" }, { "key": "cpu", "name": "CPU" }, { "key": "database", "name": "Database" }, { "key": "device-tv", "name": "Display" }, { "key": "tool", "name": "Tool" }, - { "key": "settings", "name": "Settings" }, + { "key": "settings", "name": "Settings" } ] Component.onCompleted: { - // Initial copy: prefer saved actions, fall back to defaults. var src = pluginApi?.pluginSettings?.actions ?? _defaultActions valueActions = JSON.parse(JSON.stringify(src)) } function _refresh() { - // Force the Repeaters to re-bind after an in-place edit. var v = valueActions valueActions = [] valueActions = v @@ -85,12 +80,11 @@ ColumnLayout { if (valueActions[i].id === id) return i return -1 } - function setLabel(id, txt) { var i = _indexOfId(id); if (i < 0) return; valueActions[i].label = txt; _refresh() } - function setIcon(id, ic) { var i = _indexOfId(id); if (i < 0) return; valueActions[i].icon = ic; _refresh() } - function setVisible(id, vis) { var i = _indexOfId(id); if (i < 0) return; valueActions[i].visible = vis;_refresh() } + function setLabel(id, txt) { var i = _indexOfId(id); if (i < 0) return; valueActions[i].label = txt; _refresh() } + function setIcon(id, ic) { var i = _indexOfId(id); if (i < 0) return; valueActions[i].icon = ic; _refresh() } + function setVisible(id, vis) { var i = _indexOfId(id); if (i < 0) return; valueActions[i].visible = vis;_refresh() } function moveUp(id) { var i = _indexOfId(id); if (i < 0) return - // Find previous index in the same tab for (var j = i - 1; j >= 0; j--) { if (valueActions[j].tab === valueActions[i].tab) { var tmp = valueActions[i]; valueActions[i] = valueActions[j]; valueActions[j] = tmp @@ -110,7 +104,6 @@ ColumnLayout { function resetActions() { valueActions = JSON.parse(JSON.stringify(_defaultActions)) } - function _actionsForTab(t) { var out = [] for (var i = 0; i < valueActions.length; i++) @@ -121,7 +114,7 @@ ColumnLayout { function saveSettings() { if (!pluginApi) return pluginApi.pluginSettings.icon = root.valueIcon - pluginApi.pluginSettings.terminal = root.valueTerminal + pluginApi.pluginSettings.privilege = root.valuePrivilege pluginApi.pluginSettings.pollSeconds = root.valuePollSeconds pluginApi.pluginSettings.showBadge = root.valueShowBadge pluginApi.pluginSettings.pulse = root.valuePulse @@ -132,7 +125,12 @@ ColumnLayout { // ══════════════════════════════════════════════════════════════ // APPEARANCE // ══════════════════════════════════════════════════════════════ - NLabel { text: "Appearance"; font.weight: Font.Bold } + Text { + text: "Appearance" + font.bold: true + font.pointSize: Style.fontSizeM + color: Color.mOnSurface + } NComboBox { Layout.fillWidth: true @@ -170,12 +168,32 @@ ColumnLayout { onValueChanged: root.valuePollSeconds = value } - NTextInput { + // ══════════════════════════════════════════════════════════════ + // PRIVILEGE + // ══════════════════════════════════════════════════════════════ + Text { + Layout.topMargin: Style.marginM + text: "Privileged commands" + font.bold: true + font.pointSize: Style.fontSizeM + color: Color.mOnSurface + } + Text { + Layout.fillWidth: true + text: "Actions that need root (updates, scheduled cleanup) use this method. pkexec opens a graphical polkit prompt; sudo -n is fully silent but requires you to add NOPASSWD rules for pacman/yay/stoa-maintain to /etc/sudoers.d/." + font.pointSize: Style.fontSizeXS + color: Color.mOnSurfaceVariant + wrapMode: Text.WordWrap + } + NComboBox { Layout.fillWidth: true - label: "Terminal" - description: "Terminal emulator used to run actions (must support --title and --hold)" - text: root.valueTerminal - onTextChanged: root.valueTerminal = text + label: "Auth method" + model: [ + { "key": "pkexec", "name": "pkexec (graphical prompt)" }, + { "key": "sudo-n", "name": "sudo -n (NOPASSWD sudoers)" } + ] + currentKey: root.valuePrivilege + onSelected: key => root.valuePrivilege = key } // ══════════════════════════════════════════════════════════════ @@ -183,22 +201,21 @@ ColumnLayout { // ══════════════════════════════════════════════════════════════ RowLayout { Layout.fillWidth: true - spacing: Style.marginS Layout.topMargin: Style.marginM - - NLabel { + spacing: Style.marginS + Text { Layout.fillWidth: true text: "Actions" - font.weight: Font.Bold + font.bold: true + font.pointSize: Style.fontSizeM + color: Color.mOnSurface } - Item { + Rectangle { implicitWidth: resetText.implicitWidth + Style.marginS * 2 - implicitHeight: 24 - Rectangle { - anchors.fill: parent; radius: Style.radiusS - color: resetHover.containsMouse ? Color.mErrorContainer : Color.mSurfaceVariant - } - NText { + implicitHeight: 26 + radius: Style.radiusS + color: resetMA.containsMouse ? Color.mErrorContainer : Color.mSurfaceVariant + Text { id: resetText anchors.centerIn: parent text: "Reset to defaults" @@ -206,7 +223,7 @@ ColumnLayout { color: Color.mOnSurface } MouseArea { - id: resetHover + id: resetMA anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor @@ -214,49 +231,42 @@ ColumnLayout { } } } - - NLabel { + Text { Layout.fillWidth: true - text: "Edit label, icon, visibility and order for each action. Use ↑/↓ to move within a tab." + text: "Each action's label, icon, visibility and order can be customised. Use ↑/↓ to move within a tab." font.pointSize: Style.fontSizeXS + color: Color.mOnSurfaceVariant wrapMode: Text.WordWrap } - // Section header + repeater for each tab Repeater { model: [ { key: "vitals", label: "Vitals tab" }, { key: "snap", label: "Snapshots tab" }, - { key: "maint", label: "Maintenance tab" }, + { key: "maint", label: "Maintenance tab" } ] - delegate: ColumnLayout { required property var modelData Layout.fillWidth: true - spacing: Style.marginXS Layout.topMargin: Style.marginS + spacing: Style.marginXS - NLabel { - Layout.fillWidth: true + Text { text: modelData.label + font.bold: true font.pointSize: Style.fontSizeS - font.weight: Font.Bold color: Color.mOnSurfaceVariant } Repeater { model: root._actionsForTab(modelData.key) - delegate: Item { + delegate: Rectangle { required property var modelData Layout.fillWidth: true - Layout.preferredHeight: 40 - - Rectangle { - anchors.fill: parent - radius: Style.radiusS - color: Color.mSurfaceVariant - opacity: modelData.visible === false ? 0.3 : 0.5 - } + Layout.preferredHeight: 42 + radius: Style.radiusS + color: Color.mSurfaceVariant + opacity: modelData.visible === false ? 0.4 : 0.7 RowLayout { anchors { @@ -267,13 +277,11 @@ ColumnLayout { } spacing: Style.marginXS - // ↑ button - Item { - implicitWidth: 22; implicitHeight: 22 - Rectangle { - anchors.fill: parent; radius: Style.radiusS - color: upHover.containsMouse ? Color.mPrimaryContainer : "transparent" - } + // ↑ + Rectangle { + implicitWidth: 24; implicitHeight: 24 + radius: Style.radiusS + color: upMA.containsMouse ? Color.mPrimaryContainer : "transparent" NIcon { anchors.centerIn: parent icon: "chevron-up" @@ -281,20 +289,18 @@ ColumnLayout { color: Color.mOnSurface } MouseArea { - id: upHover + id: upMA anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: root.moveUp(modelData.id) } } - // ↓ button - Item { - implicitWidth: 22; implicitHeight: 22 - Rectangle { - anchors.fill: parent; radius: Style.radiusS - color: downHover.containsMouse ? Color.mPrimaryContainer : "transparent" - } + // ↓ + Rectangle { + implicitWidth: 24; implicitHeight: 24 + radius: Style.radiusS + color: downMA.containsMouse ? Color.mPrimaryContainer : "transparent" NIcon { anchors.centerIn: parent icon: "chevron-down" @@ -302,7 +308,7 @@ ColumnLayout { color: Color.mOnSurface } MouseArea { - id: downHover + id: downMA anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor @@ -317,22 +323,31 @@ ColumnLayout { color: Color.mOnSurfaceVariant } - // Label editor - NTextInput { + // Label editor (native TextField — NTextInput + // is not part of the public plugin widget set) + TextField { Layout.fillWidth: true text: modelData.label - onTextChanged: root.setLabel(modelData.id, text) + font.pointSize: Style.fontSizeS + color: Color.mOnSurface + background: Rectangle { + color: Color.mSurface + radius: Style.radiusS + border.width: 1 + border.color: Color.mOutline + } + onEditingFinished: root.setLabel(modelData.id, text) } - // Icon picker — compact + // Icon picker NComboBox { - Layout.preferredWidth: 110 + Layout.preferredWidth: 130 model: root._iconChoices currentKey: modelData.icon onSelected: key => root.setIcon(modelData.id, key) } - // Visibility toggle + // Visible NToggle { checked: modelData.visible !== false onToggled: checked => root.setVisible(modelData.id, checked) diff --git a/theme/noctalia-plugins/stoa-health/manifest.json b/theme/noctalia-plugins/stoa-health/manifest.json index 1495f09..cc6155f 100644 --- a/theme/noctalia-plugins/stoa-health/manifest.json +++ b/theme/noctalia-plugins/stoa-health/manifest.json @@ -16,7 +16,7 @@ "metadata": { "defaultSettings": { "icon": "heart", - "terminal": "kitty", + "privilege": "pkexec", "pollSeconds": 30, "showBadge": true, "pulse": true,