diff --git a/quickshell/Modules/Settings/PluginBrowser.qml b/quickshell/Modules/Settings/PluginBrowser.qml
index 9b116e84..5c7de179 100644
--- a/quickshell/Modules/Settings/PluginBrowser.qml
+++ b/quickshell/Modules/Settings/PluginBrowser.qml
@@ -94,8 +94,8 @@ FloatingWindow {
return 0;
}
- function pluginVerified(plugin) {
- return (plugin.status || []).indexOf("verified") !== -1;
+ function pluginReviewed(plugin) {
+ return (plugin.status || []).indexOf("reviewed") !== -1;
}
function statusColor(status) {
@@ -104,7 +104,7 @@ FloatingWindow {
return Theme.error;
case "unmaintained":
return Theme.warning;
- case "verified":
+ case "reviewed":
return Theme.info;
default:
return Theme.outline;
@@ -119,8 +119,8 @@ FloatingWindow {
return I18n.tr("unmaintained", "plugin status");
case "deprecated":
return I18n.tr("deprecated", "plugin status");
- case "verified":
- return I18n.tr("verified", "plugin status");
+ case "reviewed":
+ return I18n.tr("reviewed", "plugin status");
default:
return status;
}
@@ -331,8 +331,8 @@ FloatingWindow {
var votesB = b.upvotes || 0;
if (votesA !== votesB)
return votesB - votesA;
- var verA = root.pluginVerified(a);
- var verB = root.pluginVerified(b);
+ var verA = root.pluginReviewed(a);
+ var verB = root.pluginReviewed(b);
if (verA !== verB)
return verA ? -1 : 1;
return comparePluginName(a, b);
diff --git a/quickshell/Modules/Settings/PluginListItem.qml b/quickshell/Modules/Settings/PluginListItem.qml
index 6509e987..87a1265a 100644
--- a/quickshell/Modules/Settings/PluginListItem.qml
+++ b/quickshell/Modules/Settings/PluginListItem.qml
@@ -316,11 +316,10 @@ StyledRect {
const currentPluginName = root.pluginName;
if (isChecked) {
- if (PluginService.enablePlugin(currentPluginId)) {
- ToastService.showInfo(I18n.tr("Plugin enabled: %1").arg(currentPluginName));
- return;
- }
- ToastService.showError(I18n.tr("Failed to enable plugin: %1").arg(currentPluginName));
+ PluginService.enablePlugin(currentPluginId, ok => {
+ if (ok)
+ ToastService.showInfo(I18n.tr("Plugin enabled: %1").arg(currentPluginName));
+ });
return;
}
if (PluginService.disablePlugin(currentPluginId)) {
diff --git a/quickshell/Modules/Toast.qml b/quickshell/Modules/Toast.qml
index bd061af3..7ae8001a 100644
--- a/quickshell/Modules/Toast.qml
+++ b/quickshell/Modules/Toast.qml
@@ -71,6 +71,14 @@ PanelWindow {
property bool expanded: false
+ function linkify(text) {
+ if (!text)
+ return "";
+ const escaped = text.replace(/&/g, "&").replace(//g, ">");
+ const linked = escaped.replace(/(https?:\/\/[^\s<]+)/g, '$1');
+ return linked.replace(/\n/g, "
");
+ }
+
Connections {
target: ToastService
function onResetToastState() {
@@ -240,7 +248,18 @@ PanelWindow {
StyledText {
id: detailsText
- text: ToastService.currentDetails
+ readonly property bool hasLink: /https?:\/\//.test(ToastService.currentDetails)
+ text: hasLink ? toast.linkify(ToastService.currentDetails) : ToastService.currentDetails
+ textFormat: hasLink ? Text.StyledText : Text.PlainText
+ linkColor: {
+ switch (ToastService.currentLevel) {
+ case ToastService.levelError:
+ case ToastService.levelWarn:
+ return SessionData.isLightMode ? Theme.surfaceText : Theme.background;
+ default:
+ return Theme.primary;
+ }
+ }
font.pixelSize: Theme.fontSizeSmall
color: {
switch (ToastService.currentLevel) {
@@ -255,6 +274,13 @@ PanelWindow {
anchors.right: copyDetailsButton.left
anchors.rightMargin: Theme.spacingS
wrapMode: Text.Wrap
+ onLinkActivated: url => Qt.openUrlExternally(url)
+
+ MouseArea {
+ anchors.fill: parent
+ acceptedButtons: Qt.NoButton
+ cursorShape: detailsText.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor
+ }
}
DankActionButton {
diff --git a/quickshell/PLUGINS/ExampleStartupCheck/StartupCheck.qml b/quickshell/PLUGINS/ExampleStartupCheck/StartupCheck.qml
new file mode 100644
index 00000000..928d0217
--- /dev/null
+++ b/quickshell/PLUGINS/ExampleStartupCheck/StartupCheck.qml
@@ -0,0 +1,22 @@
+import QtQuick
+import qs.Common
+
+QtObject {
+ // Optional async dependency gate. Receives a done(result) callback:
+ // done(null) -> allow activation
+ // done("short message") -> block with a title only
+ // done({ title, details }) -> block with an expandable details body
+ // A synchronous variant (no argument, return the result) is also supported.
+ function check(done) {
+ Proc.runCommand("exampleStartupCheck.depCheck", ["which", "boregard"], (stdout, exitCode) => {
+ if (exitCode === 0) {
+ done(null);
+ return;
+ }
+ done({
+ "title": I18n.tr("boregard is required"),
+ "details": I18n.tr("The 'boregard' tool is not installed or not on your PATH.\n\nInstall it from https://danklinux.com, then re-enable this plugin.")
+ });
+ });
+ }
+}
diff --git a/quickshell/PLUGINS/ExampleStartupCheck/StartupCheckWidget.qml b/quickshell/PLUGINS/ExampleStartupCheck/StartupCheckWidget.qml
new file mode 100644
index 00000000..7f9c8b41
--- /dev/null
+++ b/quickshell/PLUGINS/ExampleStartupCheck/StartupCheckWidget.qml
@@ -0,0 +1,38 @@
+import QtQuick
+import qs.Common
+import qs.Widgets
+import qs.Modules.Plugins
+
+PluginComponent {
+ id: root
+
+ layerNamespacePlugin: "startup-check"
+
+ horizontalBarPill: Component {
+ Row {
+ spacing: Theme.spacingXS
+
+ DankIcon {
+ name: "verified_user"
+ size: root.iconSize
+ color: Theme.primary
+ anchors.verticalCenter: parent.verticalCenter
+ }
+
+ StyledText {
+ text: "boregard"
+ font.pixelSize: Theme.fontSizeSmall
+ color: Theme.surfaceText
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+ }
+
+ verticalBarPill: Component {
+ DankIcon {
+ name: "verified_user"
+ size: root.iconSize
+ color: Theme.primary
+ }
+ }
+}
diff --git a/quickshell/PLUGINS/ExampleStartupCheck/plugin.json b/quickshell/PLUGINS/ExampleStartupCheck/plugin.json
new file mode 100644
index 00000000..07d82c42
--- /dev/null
+++ b/quickshell/PLUGINS/ExampleStartupCheck/plugin.json
@@ -0,0 +1,14 @@
+{
+ "id": "exampleStartupCheck",
+ "name": "Startup Check Example",
+ "description": "Demonstrates startupCheck - blocks activation when the 'boregard' dependency is missing",
+ "version": "1.0.0",
+ "author": "AvengeMedia",
+ "type": "widget",
+ "capabilities": ["dankbar-widget"],
+ "component": "./StartupCheckWidget.qml",
+ "startupCheck": "./StartupCheck.qml",
+ "icon": "verified_user",
+ "dependencies": ["boregard"],
+ "permissions": ["process"]
+}
diff --git a/quickshell/PLUGINS/README.md b/quickshell/PLUGINS/README.md
index 8beecbf1..49ed9eae 100644
--- a/quickshell/PLUGINS/README.md
+++ b/quickshell/PLUGINS/README.md
@@ -557,7 +557,7 @@ PluginService.pluginWidgetComponents: object
PluginService.loadPlugin(pluginId: string): bool
PluginService.unloadPlugin(pluginId: string): bool
PluginService.reloadPlugin(pluginId: string): bool
-PluginService.enablePlugin(pluginId: string): bool
+PluginService.enablePlugin(pluginId: string, onResult?: (ok: bool, error: string) => void): bool
PluginService.disablePlugin(pluginId: string): bool
// Plugin Discovery
@@ -585,6 +585,41 @@ PluginService.pluginLoadFailed(pluginId: string, error: string)
PluginService.globalVarChanged(pluginId: string, varName: string)
```
+## Startup Check (Dependency Gate)
+
+A plugin may optionally gate activation behind a dependency check. Point the manifest's `startupCheck` field at a small, **non-visual** component (a `QtObject` - it must not render in the graphics scene):
+
+```json
+{
+ "startupCheck": "./StartupCheck.qml",
+ "dependencies": ["boregard"]
+}
+```
+
+The component exposes a `check` function that runs before the plugin loads, both on manual enable and on auto-load at startup. Call `done(null)` to allow activation, or `done(error)` to block it. The error can be a short string (title only) or an object with an expandable `details` body for long-form instructions:
+
+```qml
+import QtQuick
+import qs.Common
+
+QtObject {
+ function check(done) {
+ Proc.runCommand("myPlugin.depCheck", ["which", "boregard"], (stdout, exitCode) => {
+ if (exitCode === 0) {
+ done(null)
+ return
+ }
+ done({
+ title: I18n.tr("boregard is required"),
+ details: I18n.tr("Install it from https://danklinux.com, then re-enable this plugin.")
+ })
+ })
+ }
+}
+```
+
+A synchronous variant is supported too - declare `check()` with no argument and return the result directly. When the check fails the enable toggle reverts and the error is shown as a toast (the `details` are expandable, and any `http(s)` URL in them becomes a clickable link). Plugins without a `startupCheck` are unaffected. See `ExampleStartupCheck` for a complete plugin; the last error per plugin is available at `PluginService.pluginLoadErrors[pluginId]`.
+
## Plugin Global Variables
Plugins can share state across multiple instances using global variables. This is useful when you have the same widget displayed on multiple monitors or multiple instances of the same widget on different bars.
diff --git a/quickshell/PLUGINS/plugin-schema.json b/quickshell/PLUGINS/plugin-schema.json
index fc55e832..f1c56ea0 100644
--- a/quickshell/PLUGINS/plugin-schema.json
+++ b/quickshell/PLUGINS/plugin-schema.json
@@ -98,14 +98,26 @@
"description": "Path to settings component QML file",
"pattern": "^\\./.*\\.qml$"
},
+ "startupCheck": {
+ "type": "string",
+ "description": "Path to a non-visual (QtObject) component exposing a check(done) function that gates activation. done(null) allows; done(error) blocks, where error is a string or { title, details }.",
+ "pattern": "^\\./.*\\.qml$"
+ },
"requires_dms": {
"type": "string",
"description": "Minimum DMS version requirement (e.g., '>=0.1.18', '>0.1.0')",
"pattern": "^(>=?|<=?|=|>|<)\\d+\\.\\d+\\.\\d+$"
},
+ "dependencies": {
+ "type": "array",
+ "description": "Array of required system tools/dependencies (registry metadata)",
+ "items": {
+ "type": "string"
+ }
+ },
"requires": {
"type": "array",
- "description": "Array of required system tools/dependencies",
+ "description": "Deprecated alias for 'dependencies'.",
"items": {
"type": "string"
}
diff --git a/quickshell/Services/PluginService.qml b/quickshell/Services/PluginService.qml
index f48b903d..791ee302 100644
--- a/quickshell/Services/PluginService.qml
+++ b/quickshell/Services/PluginService.qml
@@ -28,6 +28,7 @@ Singleton {
property var pathToPluginId: ({})
property var pluginInstances: ({})
property var globalVars: ({})
+ property var pluginLoadErrors: ({})
property var _stateCache: ({})
property var _stateLoaded: ({})
@@ -259,6 +260,9 @@ Singleton {
let settings = manifest.settings;
if (settings && settings.startsWith("./"))
settings = settings.slice(2);
+ let startupCheck = manifest.startupCheck;
+ if (startupCheck && startupCheck.startsWith("./"))
+ startupCheck = startupCheck.slice(2);
const componentPaths = _resolveComponentPaths(manifest, dir);
const surfaces = Object.keys(componentPaths);
@@ -291,6 +295,7 @@ Singleton {
info.surfaces = surfaces;
info.componentPath = componentPaths.widget || componentPaths[surfaces[0]];
info.settingsPath = settings ? (dir + "/" + settings) : null;
+ info.startupCheckPath = startupCheck ? (dir + "/" + startupCheck) : null;
info.loaded = isPluginLoaded(manifest.id);
info.type = manifest.type || (manifest.components ? "composite" : "widget");
info.source = sourceTag;
@@ -316,7 +321,7 @@ Singleton {
const isPureDesktop = surfaces.length === 1 && surfaces[0] === "desktop";
const enabled = isPureDesktop || SettingsData.getPluginSetting(manifest.id, "enabled", false);
if (enabled && !info.loaded)
- loadPlugin(manifest.id);
+ runStartupGate(manifest.id);
} else {
knownManifests[absPath] = {
mtime: mtimeEpochMs,
@@ -637,9 +642,107 @@ Singleton {
return loadedPlugins[pluginId] !== undefined;
}
- function enablePlugin(pluginId) {
+ function enablePlugin(pluginId, onResult) {
SettingsData.setPluginSetting(pluginId, "enabled", true);
- return loadPlugin(pluginId);
+ return runStartupGate(pluginId, onResult);
+ }
+
+ function _setLoadError(pluginId, err) {
+ const m = Object.assign({}, pluginLoadErrors);
+ m[pluginId] = err;
+ pluginLoadErrors = m;
+ }
+
+ function _clearLoadError(pluginId) {
+ if (!pluginLoadErrors[pluginId])
+ return;
+ const m = Object.assign({}, pluginLoadErrors);
+ delete m[pluginId];
+ pluginLoadErrors = m;
+ }
+
+ function _normalizeStartupError(result) {
+ if (!result)
+ return null;
+ if (typeof result === "string")
+ return {
+ "title": result,
+ "details": ""
+ };
+ return {
+ "title": result.title || I18n.tr("Plugin dependency missing"),
+ "details": result.details || ""
+ };
+ }
+
+ function _makeStartupCheckObject(pluginId, plugin) {
+ const comp = Qt.createComponent("file://" + plugin.startupCheckPath, Component.PreferSynchronous);
+ if (comp.status === Component.Error) {
+ log.error("startupCheck component error", pluginId, comp.errorString());
+ return null;
+ }
+ return comp.createObject(root);
+ }
+
+ function runStartupGate(pluginId, onResult) {
+ const plugin = availablePlugins[pluginId];
+ if (!plugin) {
+ if (onResult)
+ onResult(false);
+ return false;
+ }
+
+ if (!plugin.startupCheckPath) {
+ const ok = loadPlugin(pluginId);
+ if (onResult)
+ onResult(ok);
+ return ok;
+ }
+
+ const probe = _makeStartupCheckObject(pluginId, plugin);
+ const finish = result => {
+ if (probe)
+ probe.destroy();
+ const err = _normalizeStartupError(result);
+ if (err) {
+ _setLoadError(pluginId, err);
+ const title = I18n.tr("%1 Startup Failed").arg(plugin.name || pluginId);
+ const body = err.details ? (err.title + "\n\n" + err.details) : err.title;
+ ToastService.showError(title, body, "", "plugin-startup-" + pluginId);
+ pluginLoadFailed(pluginId, err.title);
+ if (onResult)
+ onResult(false);
+ return;
+ }
+ _clearLoadError(pluginId);
+ const ok = loadPlugin(pluginId);
+ if (onResult)
+ onResult(ok);
+ };
+
+ const check = probe ? probe.check : null;
+ if (typeof check !== "function") {
+ finish(null);
+ return true;
+ }
+ if (check.length >= 1) {
+ try {
+ check(finish);
+ } catch (e) {
+ log.warn("startupCheck threw for", pluginId, e.message);
+ finish(null);
+ }
+ return true;
+ }
+ let r = null;
+ try {
+ r = check();
+ } catch (e) {
+ log.warn("startupCheck threw for", pluginId, e.message);
+ r = null;
+ }
+ finish(r);
+ return true;
}
function disablePlugin(pluginId) {
@@ -1024,7 +1127,8 @@ Singleton {
const plugin = root.availablePlugins[pluginId];
if (!plugin)
return `ERROR: unknown pluginId '${pluginId}'`;
- const err = root.pluginLoadErrors[pluginId] || "";
+ const errObj = root.pluginLoadErrors[pluginId];
+ const err = errObj ? (errObj.title || "") : "";
const safeErr = String(err).replace(/[\t\n\r]/g, " ");
return `${plugin.loaded ? "loaded" : "unloaded"}\t${plugin.type || ""}\t${safeErr}`;
}