1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-01-24 13:32:50 -05:00

feat: Plugin System (#276)

* feat: Plugin System

* fix: merge conflicts
This commit is contained in:
Bruno Cesar Rocha
2025-10-01 16:28:10 +01:00
committed by GitHub
parent ab0759f441
commit 53983933dc
13 changed files with 1662 additions and 49 deletions

167
CLAUDE.md
View File

@@ -63,6 +63,9 @@ quickshell -p shell.qml
# Or use the shorthand # Or use the shorthand
qs -p . qs -p .
# Run with verbose output for debugging
qs -v -p shell.qml
# Code formatting and linting # Code formatting and linting
qmlfmt -t 4 -i 4 -b 250 -w /path/to/file.qml # Format a QML file (requires qmlfmt, do not use qmlformat) qmlfmt -t 4 -i 4 -b 250 -w /path/to/file.qml # Format a QML file (requires qmlfmt, do not use qmlformat)
qmllint **/*.qml # Lint all QML files for syntax errors qmllint **/*.qml # Lint all QML files for syntax errors
@@ -89,6 +92,7 @@ shell.qml # Main entry point (minimal orchestration)
│ ├── DisplayService.qml │ ├── DisplayService.qml
│ ├── NotificationService.qml │ ├── NotificationService.qml
│ ├── WeatherService.qml │ ├── WeatherService.qml
│ ├── PluginService.qml
│ └── [14 more services] │ └── [14 more services]
├── Modules/ # UI components (93 files) ├── Modules/ # UI components (93 files)
│ ├── TopBar/ # Panel components (13 files) │ ├── TopBar/ # Panel components (13 files)
@@ -104,15 +108,21 @@ shell.qml # Main entry point (minimal orchestration)
│ ├── SettingsModal.qml │ ├── SettingsModal.qml
│ ├── ClipboardHistoryModal.qml │ ├── ClipboardHistoryModal.qml
│ ├── ProcessListModal.qml │ ├── ProcessListModal.qml
│ ├── PluginSettingsModal.qml
│ └── [7 more modals] │ └── [7 more modals]
── Widgets/ # Reusable UI controls (19 files) ── Widgets/ # Reusable UI controls (19 files)
├── DankIcon.qml ├── DankIcon.qml
├── DankSlider.qml ├── DankSlider.qml
├── DankToggle.qml ├── DankToggle.qml
├── DankTabBar.qml ├── DankTabBar.qml
├── DankGridView.qml ├── DankGridView.qml
├── DankListView.qml ├── DankListView.qml
└── [13 more widgets] └── [13 more widgets]
└── plugins/ # External plugins directory ($CONFIGPATH/DankMaterialShell/plugins/)
└── PluginName/ # Example Plugin structure
├── plugin.json # Plugin manifest
├── PluginNameWidget.qml # Widget component
└── PluginNameSettings.qml # Settings UI
``` ```
### Component Organization ### Component Organization
@@ -163,6 +173,12 @@ shell.qml # Main entry point (minimal orchestration)
- **DankLocationSearch**: Location picker with search - **DankLocationSearch**: Location picker with search
- **SystemLogo**: Animated system branding component - **SystemLogo**: Animated system branding component
7. **Plugins/** - External plugin system (`$CONFIGPATH/DankMaterialShell/plugins/`)
- **PluginService**: Discovers, loads, and manages plugin lifecycle
- **Dynamic Loading**: Plugins loaded at runtime from external directory
- **DankBar Integration**: Plugin widgets rendered alongside built-in widgets
- **Settings System**: Per-plugin settings with persistence
### Key Architectural Patterns ### Key Architectural Patterns
1. **Singleton Services Pattern**: 1. **Singleton Services Pattern**:
@@ -408,10 +424,10 @@ When modifying the shell:
Singleton { Singleton {
id: root id: root
property bool featureAvailable: false property bool featureAvailable: false
property type currentValue: defaultValue property type currentValue: defaultValue
function performAction(param) { function performAction(param) {
// Implementation // Implementation
} }
@@ -422,7 +438,7 @@ When modifying the shell:
```qml ```qml
// In module files // In module files
property alias serviceValue: NewService.currentValue property alias serviceValue: NewService.currentValue
SomeControl { SomeControl {
visible: NewService.featureAvailable visible: NewService.featureAvailable
enabled: NewService.featureAvailable enabled: NewService.featureAvailable
@@ -430,6 +446,134 @@ When modifying the shell:
} }
``` ```
### Creating Plugins
Plugins are external, dynamically-loaded components that extend DankBar functionality. Plugins are stored in `~/.config/DankMaterialShell/plugins/` and have their settings isolated from core DMS settings.
1. **Create plugin directory**:
```bash
mkdir -p ~/.config/DankMaterialShell/plugins/YourPlugin
```
2. **Create manifest** (`plugin.json`):
```json
{
"id": "yourPlugin",
"name": "Your Plugin",
"description": "Widget description",
"version": "1.0.0",
"author": "Your Name",
"icon": "extension",
"component": "./YourWidget.qml",
"settings": "./YourSettings.qml",
"permissions": ["settings_read", "settings_write"]
}
```
3. **Create widget component** (`YourWidget.qml`):
```qml
import QtQuick
import qs.Services
Rectangle {
id: root
property bool compactMode: false
property string section: "center"
property real widgetHeight: 30
property var pluginService: null
width: content.implicitWidth + 16
height: widgetHeight
radius: 8
color: "#20FFFFFF"
Component.onCompleted: {
if (pluginService) {
var data = pluginService.loadPluginData("yourPlugin", "key", defaultValue)
}
}
}
```
4. **Create settings component** (`YourSettings.qml`):
```qml
import QtQuick
import QtQuick.Controls
FocusScope {
id: root
property var pluginService: null
implicitHeight: settingsColumn.implicitHeight
height: implicitHeight
Column {
id: settingsColumn
anchors.fill: parent
anchors.margins: 16
spacing: 12
Text {
text: "Your Plugin Settings"
font.pixelSize: 18
font.weight: Font.Bold
}
// Your settings UI here
}
function saveSettings(key, value) {
if (pluginService) {
pluginService.savePluginData("yourPlugin", key, value)
}
}
function loadSettings(key, defaultValue) {
if (pluginService) {
return pluginService.loadPluginData("yourPlugin", key, defaultValue)
}
return defaultValue
}
}
```
5. **Enable plugin**:
- Open Settings → Plugins
- Click "Scan for Plugins"
- Toggle plugin to enable
- Add plugin ID to DankBar widget list
**Plugin Directory Structure:**
```
~/.config/DankMaterialShell/
├── settings.json # Core DMS settings + plugin settings
│ └── pluginSettings: {
│ └── yourPlugin: {
│ ├── enabled: true,
│ └── customData: {...}
│ }
│ }
└── plugins/ # Plugin files directory
└── YourPlugin/ # Plugin directory (matches manifest ID)
├── plugin.json # Plugin manifest
├── YourWidget.qml # Widget component
└── YourSettings.qml # Settings UI (optional)
```
**Key Plugin APIs:**
- `pluginService.loadPluginData(pluginId, key, default)` - Load persistent data
- `pluginService.savePluginData(pluginId, key, value)` - Save persistent data
- `PluginService.enablePlugin(pluginId)` - Load plugin
- `PluginService.disablePlugin(pluginId)` - Unload plugin
**Important Notes:**
- Plugin settings are automatically injected by the PluginService via `item.pluginService = PluginService`
- Settings are stored in the main settings.json but namespaced under `pluginSettings.{pluginId}`
- Plugin directories must match the plugin ID in the manifest
- Use the injected `pluginService` property in both widget and settings components
### Debugging Common Issues ### Debugging Common Issues
1. **Import errors**: Check import paths 1. **Import errors**: Check import paths
@@ -454,6 +598,7 @@ When modifying the shell:
- **Function Discovery**: Use grep/search tools to find existing utility functions before implementing new ones - **Function Discovery**: Use grep/search tools to find existing utility functions before implementing new ones
- **Modern QML Patterns**: Leverage new widgets like DankTextField, DankDropdown, CachingImage - **Modern QML Patterns**: Leverage new widgets like DankTextField, DankDropdown, CachingImage
- **Structured Organization**: Follow the established Services/Modules/Widgets/Modals separation - **Structured Organization**: Follow the established Services/Modules/Widgets/Modals separation
- **Plugin System**: For user extensions, create plugins instead of modifying core modules - see docs/PLUGINS.md
### Common Widget Patterns ### Common Widget Patterns

View File

@@ -164,6 +164,8 @@ Singleton {
property bool _loading: false property bool _loading: false
property var pluginSettings: ({})
function getEffectiveTimeFormat() { function getEffectiveTimeFormat() {
if (use24HourClock) { if (use24HourClock) {
return Locale.ShortFormat return Locale.ShortFormat
@@ -350,6 +352,7 @@ Singleton {
widgetBackgroundColor = settings.widgetBackgroundColor !== undefined ? settings.widgetBackgroundColor : "sch" widgetBackgroundColor = settings.widgetBackgroundColor !== undefined ? settings.widgetBackgroundColor : "sch"
surfaceBase = settings.surfaceBase !== undefined ? settings.surfaceBase : "s" surfaceBase = settings.surfaceBase !== undefined ? settings.surfaceBase : "s"
screenPreferences = settings.screenPreferences !== undefined ? settings.screenPreferences : ({}) screenPreferences = settings.screenPreferences !== undefined ? settings.screenPreferences : ({})
pluginSettings = settings.pluginSettings !== undefined ? settings.pluginSettings : ({})
applyStoredTheme() applyStoredTheme()
detectAvailableIconThemes() detectAvailableIconThemes()
detectQtTools() detectQtTools()
@@ -468,7 +471,8 @@ Singleton {
"notificationTimeoutNormal": notificationTimeoutNormal, "notificationTimeoutNormal": notificationTimeoutNormal,
"notificationTimeoutCritical": notificationTimeoutCritical, "notificationTimeoutCritical": notificationTimeoutCritical,
"notificationPopupPosition": notificationPopupPosition, "notificationPopupPosition": notificationPopupPosition,
"screenPreferences": screenPreferences "screenPreferences": screenPreferences,
"pluginSettings": pluginSettings
}, null, 2)) }, null, 2))
} }
@@ -1229,6 +1233,33 @@ Singleton {
return Quickshell.screens.filter(screen => prefs.includes(screen.name)) return Quickshell.screens.filter(screen => prefs.includes(screen.name))
} }
// Plugin settings functions
function getPluginSetting(pluginId, key, defaultValue) {
if (!pluginSettings[pluginId]) {
return defaultValue
}
return pluginSettings[pluginId][key] !== undefined ? pluginSettings[pluginId][key] : defaultValue
}
function setPluginSetting(pluginId, key, value) {
if (!pluginSettings[pluginId]) {
pluginSettings[pluginId] = {}
}
pluginSettings[pluginId][key] = value
saveSettings()
}
function removePluginSettings(pluginId) {
if (pluginSettings[pluginId]) {
delete pluginSettings[pluginId]
saveSettings()
}
}
function getPluginSettingsForPlugin(pluginId) {
return pluginSettings[pluginId] || {}
}
function _shq(s) { function _shq(s) {
return "'" + String(s).replace(/'/g, "'\\''") + "'" return "'" + String(s).replace(/'/g, "'\\''") + "'"
} }

View File

@@ -154,13 +154,26 @@ Item {
} }
Loader { Loader {
id: aboutLoader id: pluginsLoader
anchors.fill: parent anchors.fill: parent
active: root.currentIndex === 10 active: root.currentIndex === 10
visible: active visible: active
asynchronous: true asynchronous: true
sourceComponent: PluginsTab {
}
}
Loader {
id: aboutLoader
anchors.fill: parent
active: root.currentIndex === 11
visible: active
asynchronous: true
sourceComponent: AboutTab { sourceComponent: AboutTab {
} }

View File

@@ -34,7 +34,7 @@ DankModal {
objectName: "settingsModal" objectName: "settingsModal"
width: 800 width: 800
height: 750 height: 800
visible: false visible: false
onBackgroundClicked: () => { onBackgroundClicked: () => {
return hide(); return hide();

View File

@@ -38,6 +38,9 @@ Rectangle {
}, { }, {
"text": "Power", "text": "Power",
"icon": "power_settings_new" "icon": "power_settings_new"
}, {
"text": "Plugins",
"icon": "extension"
}, { }, {
"text": "About", "text": "About",
"icon": "info" "icon": "info"

View File

@@ -266,7 +266,8 @@ Item {
} }
function getWidgetComponent(widgetId) { function getWidgetComponent(widgetId) {
const componentMap = { // Build dynamic component map including plugins
let baseMap = {
"launcherButton": "launcherButtonComponent", "launcherButton": "launcherButtonComponent",
"workspaceSwitcher": "workspaceSwitcherComponent", "workspaceSwitcher": "workspaceSwitcherComponent",
"focusedWindow": "focusedWindowComponent", "focusedWindow": "focusedWindowComponent",
@@ -296,8 +297,15 @@ Item {
"systemUpdate": "systemUpdateComponent" "systemUpdate": "systemUpdateComponent"
} }
const componentKey = componentMap[widgetId] // For built-in components, get from components property
return componentKey ? root.components[componentKey] : null const componentKey = baseMap[widgetId]
if (componentKey && root.components[componentKey]) {
return root.components[componentKey]
}
// For plugin components, get from PluginService
let pluginMap = PluginService.getWidgetComponents()
return pluginMap[widgetId] || null
} }
height: parent.height height: parent.height
@@ -337,6 +345,7 @@ Item {
id: centerRepeater id: centerRepeater
model: root.widgetsModel model: root.widgetsModel
Loader { Loader {
property string widgetId: model.widgetId property string widgetId: model.widgetId
property var widgetData: model property var widgetData: model
@@ -364,6 +373,17 @@ Item {
if (root.axis && "isVertical" in item) { if (root.axis && "isVertical" in item) {
item.isVertical = root.axis.isVertical item.isVertical = root.axis.isVertical
} }
// Inject PluginService for plugin widgets
if (item.pluginService !== undefined) {
console.log("CenterSection: Injecting PluginService into plugin widget:", model.widgetId)
item.pluginService = PluginService
if (item.loadTimezones) {
console.log("CenterSection: Calling loadTimezones for widget:", model.widgetId)
item.loadTimezones()
}
}
layoutTimer.restart() layoutTimer.restart()
} }
@@ -379,4 +399,27 @@ Item {
layoutTimer.restart() layoutTimer.restart()
} }
} }
// Listen for plugin changes and refresh components
Connections {
target: PluginService
function onPluginLoaded(pluginId) {
// Force refresh of component lookups
for (var i = 0; i < centerRepeater.count; i++) {
var item = centerRepeater.itemAt(i)
if (item && item.widgetId === pluginId) {
item.sourceComponent = root.getWidgetComponent(pluginId)
}
}
}
function onPluginUnloaded(pluginId) {
// Force refresh of component lookups
for (var i = 0; i < centerRepeater.count; i++) {
var item = centerRepeater.itemAt(i)
if (item && item.widgetId === pluginId) {
item.sourceComponent = root.getWidgetComponent(pluginId)
}
}
}
}
} }

View File

@@ -95,6 +95,24 @@ Item {
Qt.callLater(() => Qt.callLater(forceWidgetRefresh)) Qt.callLater(() => Qt.callLater(forceWidgetRefresh))
} }
Connections {
target: PluginService
function onPluginLoaded(pluginId) {
console.log("DankBar: Plugin loaded:", pluginId)
// Force componentMap to update by triggering property change
if (topBarContent) {
topBarContent.updateComponentMap()
}
}
function onPluginUnloaded(pluginId) {
console.log("DankBar: Plugin unloaded:", pluginId)
// Force componentMap to update by triggering property change
if (topBarContent) {
topBarContent.updateComponentMap()
}
}
}
function forceWidgetRefresh() { function forceWidgetRefresh() {
} }
@@ -366,7 +384,13 @@ Item {
anchors.bottomMargin: !barWindow.isVertical ? SettingsData.dankBarInnerPadding / 2 : Math.max(Theme.spacingXS, SettingsData.dankBarInnerPadding * 0.8) anchors.bottomMargin: !barWindow.isVertical ? SettingsData.dankBarInnerPadding / 2 : Math.max(Theme.spacingXS, SettingsData.dankBarInnerPadding * 0.8)
clip: true clip: true
readonly property int availableWidth: width property int componentMapRevision: 0
function updateComponentMap() {
componentMapRevision++
}
readonly property int availableWidth: width
readonly property int launcherButtonWidth: 40 readonly property int launcherButtonWidth: 40
readonly property int workspaceSwitcherWidth: 120 readonly property int workspaceSwitcherWidth: 120
readonly property int focusedAppMaxWidth: 456 readonly property int focusedAppMaxWidth: 456
@@ -421,35 +445,44 @@ Item {
return widgetVisibility[widgetId] ?? true return widgetVisibility[widgetId] ?? true
} }
readonly property var componentMap: ({ readonly property var componentMap: {
"launcherButton": launcherButtonComponent, // This property depends on componentMapRevision to ensure it updates when plugins change
"workspaceSwitcher": workspaceSwitcherComponent, componentMapRevision;
"focusedWindow": focusedWindowComponent,
"runningApps": runningAppsComponent, let baseMap = {
"clock": clockComponent, "launcherButton": launcherButtonComponent,
"music": mediaComponent, "workspaceSwitcher": workspaceSwitcherComponent,
"weather": weatherComponent, "focusedWindow": focusedWindowComponent,
"systemTray": systemTrayComponent, "runningApps": runningAppsComponent,
"privacyIndicator": privacyIndicatorComponent, "clock": clockComponent,
"clipboard": clipboardComponent, "music": mediaComponent,
"cpuUsage": cpuUsageComponent, "weather": weatherComponent,
"memUsage": memUsageComponent, "systemTray": systemTrayComponent,
"diskUsage": diskUsageComponent, "privacyIndicator": privacyIndicatorComponent,
"cpuTemp": cpuTempComponent, "clipboard": clipboardComponent,
"gpuTemp": gpuTempComponent, "cpuUsage": cpuUsageComponent,
"notificationButton": notificationButtonComponent, "memUsage": memUsageComponent,
"battery": batteryComponent, "diskUsage": diskUsageComponent,
"controlCenterButton": controlCenterButtonComponent, "cpuTemp": cpuTempComponent,
"idleInhibitor": idleInhibitorComponent, "gpuTemp": gpuTempComponent,
"spacer": spacerComponent, "notificationButton": notificationButtonComponent,
"separator": separatorComponent, "battery": batteryComponent,
"network_speed_monitor": networkComponent, "controlCenterButton": controlCenterButtonComponent,
"keyboard_layout_name": keyboardLayoutNameComponent, "idleInhibitor": idleInhibitorComponent,
"vpn": vpnComponent, "spacer": spacerComponent,
"notepadButton": notepadButtonComponent, "separator": separatorComponent,
"colorPicker": colorPickerComponent, "network_speed_monitor": networkComponent,
"systemUpdate": systemUpdateComponent "keyboard_layout_name": keyboardLayoutNameComponent,
}) "vpn": vpnComponent,
"notepadButton": notepadButtonComponent,
"colorPicker": colorPickerComponent,
"systemUpdate": systemUpdateComponent
}
// Merge with plugin widgets
let pluginMap = PluginService.getWidgetComponents()
return Object.assign(baseMap, pluginMap)
}
function getWidgetComponent(widgetId) { function getWidgetComponent(widgetId) {
return componentMap[widgetId] || null return componentMap[widgetId] || null
@@ -1016,6 +1049,7 @@ Item {
} }
} }
} }
} }
} }
} }

View File

@@ -33,10 +33,21 @@ Loader {
if (axis && "isVertical" in item) { if (axis && "isVertical" in item) {
item.isVertical = axis.isVertical item.isVertical = axis.isVertical
} }
// Inject PluginService for plugin widgets
if (item.pluginService !== undefined) {
console.log("WidgetHost: Injecting PluginService into plugin widget:", widgetId)
item.pluginService = PluginService
if (item.loadTimezones) {
console.log("WidgetHost: Calling loadTimezones for widget:", widgetId)
item.loadTimezones()
}
}
} }
} }
function getWidgetComponent(widgetId, components) { function getWidgetComponent(widgetId, components) {
// Build component map for built-in widgets
const componentMap = { const componentMap = {
"launcherButton": components.launcherButtonComponent, "launcherButton": components.launcherButtonComponent,
"workspaceSwitcher": components.workspaceSwitcherComponent, "workspaceSwitcher": components.workspaceSwitcherComponent,
@@ -67,7 +78,14 @@ Loader {
"systemUpdate": components.systemUpdateComponent "systemUpdate": components.systemUpdateComponent
} }
return componentMap[widgetId] || null // Check for built-in component first
if (componentMap[widgetId]) {
return componentMap[widgetId]
}
// Check for plugin component
let pluginMap = PluginService.getWidgetComponents()
return pluginMap[widgetId] || null
} }
function getWidgetVisible(widgetId, dgopAvailable) { function getWidgetVisible(widgetId, dgopAvailable) {

View File

@@ -8,7 +8,8 @@ import qs.Widgets
Item { Item {
id: dankBarTab id: dankBarTab
property var baseWidgetDefinitions: [{ property var baseWidgetDefinitions: {
var coreWidgets = [{
"id": "launcherButton", "id": "launcherButton",
"text": "App Launcher", "text": "App Launcher",
"description": "Quick access to application launcher", "description": "Quick access to application launcher",
@@ -177,6 +178,22 @@ Item {
"icon": "update", "icon": "update",
"enabled": SystemUpdateService.distributionSupported "enabled": SystemUpdateService.distributionSupported
}] }]
// Add plugin widgets dynamically
var loadedPlugins = PluginService.getLoadedPlugins()
for (var i = 0; i < loadedPlugins.length; i++) {
var plugin = loadedPlugins[i]
coreWidgets.push({
"id": plugin.id,
"text": plugin.name,
"description": plugin.description || "Plugin widget",
"icon": plugin.icon || "extension",
"enabled": true
})
}
return coreWidgets
}
property var defaultLeftWidgets: [{ property var defaultLeftWidgets: [{
"id": "launcherButton", "id": "launcherButton",
"enabled": true "enabled": true

View File

@@ -0,0 +1,552 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import qs.Common
import qs.Services
import qs.Widgets
Item {
id: pluginsTab
property string expandedPluginId: ""
Component.onCompleted: {
console.log("PluginsTab: Component completed")
console.log("PluginsTab: PluginService available:", typeof PluginService !== "undefined")
if (typeof PluginService !== "undefined") {
console.log("PluginsTab: Available plugins:", Object.keys(PluginService.availablePlugins).length)
console.log("PluginsTab: Plugin directory:", PluginService.pluginDirectory)
}
}
DankFlickable {
anchors.fill: parent
anchors.topMargin: Theme.spacingL
clip: true
contentHeight: mainColumn.height
contentWidth: width
Column {
id: mainColumn
width: parent.width
spacing: Theme.spacingXL
// Header Section
StyledRect {
width: parent.width
height: headerColumn.implicitHeight + Theme.spacingL * 2
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.3)
border.width: 1
Column {
id: headerColumn
anchors.fill: parent
anchors.margins: Theme.spacingL
spacing: Theme.spacingM
Row {
width: parent.width
spacing: Theme.spacingM
DankIcon {
name: "extension"
size: Theme.iconSizeLarge
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
}
Column {
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXS
StyledText {
text: "Plugin Management"
font.pixelSize: Theme.fontSizeXLarge
color: "#FFFFFF"
font.weight: Font.Medium
}
StyledText {
text: "Manage and configure plugins for extending DMS functionality"
font.pixelSize: Theme.fontSizeSmall
color: "#CCCCCC"
}
}
}
Row {
width: parent.width
spacing: Theme.spacingM
Button {
text: "Scan for Plugins"
background: Rectangle {
color: parent.hovered ? "#4A90E2" : "#3A3A3A"
radius: Theme.cornerRadius
border.color: "#666666"
border.width: 1
}
contentItem: Text {
text: parent.text
color: "#FFFFFF"
font.pixelSize: Theme.fontSizeMedium
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
onClicked: {
PluginService.scanPlugins()
ToastService.showInfo("Scanning for plugins...")
}
}
Button {
text: "Create Plugin Directory"
background: Rectangle {
color: parent.hovered ? "#4A90E2" : "#3A3A3A"
radius: Theme.cornerRadius
border.color: "#666666"
border.width: 1
}
contentItem: Text {
text: parent.text
color: "#FFFFFF"
font.pixelSize: Theme.fontSizeMedium
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
onClicked: {
PluginService.createPluginDirectory()
ToastService.showInfo("Created plugin directory: " + PluginService.pluginDirectory)
}
}
}
}
}
// Plugin Directory Info
StyledRect {
width: parent.width
height: directoryColumn.implicitHeight + Theme.spacingL * 2
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.3)
border.width: 1
Column {
id: directoryColumn
anchors.fill: parent
anchors.margins: Theme.spacingL
spacing: Theme.spacingM
StyledText {
text: "Plugin Directory"
font.pixelSize: Theme.fontSizeLarge
color: "#FFFFFF"
font.weight: Font.Medium
}
StyledText {
text: PluginService.pluginDirectory
font.pixelSize: Theme.fontSizeSmall
color: "#CCCCCC"
font.family: "monospace"
}
StyledText {
text: "Place plugin directories here. Each plugin should have a plugin.json manifest file."
font.pixelSize: Theme.fontSizeSmall
color: "#CCCCCC"
wrapMode: Text.WordWrap
width: parent.width
}
}
}
// Available Plugins Section
StyledRect {
width: parent.width
height: Math.max(200, availableColumn.implicitHeight + Theme.spacingL * 2)
radius: Theme.cornerRadius
color: Theme.surfaceContainerHigh
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.3)
border.width: 1
Column {
id: availableColumn
anchors.fill: parent
anchors.margins: Theme.spacingL
spacing: Theme.spacingM
StyledText {
text: "Available Plugins"
font.pixelSize: Theme.fontSizeLarge
color: "#FFFFFF"
font.weight: Font.Medium
}
Item {
width: parent.width
height: Math.max(150, pluginListView.contentHeight)
ListView {
id: pluginListView
anchors.fill: parent
model: PluginService.getAvailablePlugins()
spacing: Theme.spacingM
clip: true
delegate: StyledRect {
id: pluginDelegate
width: pluginListView.width
height: pluginItemColumn.implicitHeight + Theme.spacingM * 2 + settingsContainer.height
radius: Theme.cornerRadius
// Store plugin data in properties to avoid scope issues
property var pluginData: modelData
property string pluginId: pluginData ? pluginData.id : ""
property string pluginName: pluginData ? (pluginData.name || pluginData.id) : ""
property string pluginVersion: pluginData ? (pluginData.version || "1.0.0") : ""
property string pluginAuthor: pluginData ? (pluginData.author || "Unknown") : ""
property string pluginDescription: pluginData ? (pluginData.description || "") : ""
property string pluginIcon: pluginData ? (pluginData.icon || "extension") : "extension"
property string pluginSettingsPath: pluginData ? (pluginData.settingsPath || "") : ""
property var pluginPermissions: pluginData ? (pluginData.permissions || []) : []
property bool hasSettings: pluginData && pluginData.settings !== undefined && pluginData.settings !== ""
property bool isExpanded: pluginsTab.expandedPluginId === pluginId
onIsExpandedChanged: {
console.log("Plugin", pluginId, "isExpanded changed to:", isExpanded)
}
color: pluginMouseArea.containsMouse ? Theme.surfacePressed : (isExpanded ? Theme.surfaceContainerHigh : Theme.surfaceContainer)
border.color: isExpanded ? Theme.primary : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
border.width: isExpanded ? 2 : 1
Behavior on height {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.standardEasing
}
}
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
Behavior on border.color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
MouseArea {
id: pluginMouseArea
anchors.fill: parent
anchors.bottomMargin: pluginDelegate.isExpanded ? settingsContainer.height : 0
hoverEnabled: true
cursorShape: pluginDelegate.hasSettings ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
console.log("Plugin clicked:", pluginDelegate.pluginId, "hasSettings:", pluginDelegate.hasSettings, "isLoaded:", PluginService.isPluginLoaded(pluginDelegate.pluginId))
if (pluginDelegate.hasSettings) {
if (pluginsTab.expandedPluginId === pluginDelegate.pluginId) {
console.log("Collapsing plugin:", pluginDelegate.pluginId)
pluginsTab.expandedPluginId = ""
} else {
console.log("Expanding plugin:", pluginDelegate.pluginId)
pluginsTab.expandedPluginId = pluginDelegate.pluginId
}
}
}
}
Column {
id: pluginItemColumn
width: parent.width
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Theme.spacingM
spacing: Theme.spacingS
Row {
width: parent.width
spacing: Theme.spacingM
DankIcon {
name: pluginDelegate.pluginIcon
size: Theme.iconSize
color: PluginService.isPluginLoaded(pluginDelegate.pluginId) ? Theme.primary : "#CCCCCC"
anchors.verticalCenter: parent.verticalCenter
}
Column {
anchors.verticalCenter: parent.verticalCenter
spacing: 2
width: parent.width - 250
Row {
spacing: Theme.spacingXS
width: parent.width
StyledText {
text: pluginDelegate.pluginName
font.pixelSize: Theme.fontSizeMedium
color: "#FFFFFF"
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
DankIcon {
name: pluginDelegate.hasSettings ? (pluginDelegate.isExpanded ? "expand_less" : "expand_more") : ""
size: 16
color: pluginDelegate.hasSettings ? Theme.primary : "transparent"
anchors.verticalCenter: parent.verticalCenter
visible: pluginDelegate.hasSettings
Behavior on rotation {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
}
StyledText {
text: "v" + pluginDelegate.pluginVersion + " by " + pluginDelegate.pluginAuthor
font.pixelSize: Theme.fontSizeSmall
color: "#CCCCCC"
width: parent.width
elide: Text.ElideRight
}
}
Item {
width: 10
height: 1
}
DankToggle {
anchors.verticalCenter: parent.verticalCenter
checked: PluginService.isPluginLoaded(pluginDelegate.pluginId)
onToggled: (isChecked) => {
if (isChecked) {
if (PluginService.enablePlugin(pluginDelegate.pluginId)) {
ToastService.showInfo("Plugin enabled: " + pluginDelegate.pluginName)
} else {
ToastService.showError("Failed to enable plugin: " + pluginDelegate.pluginName)
checked = false
}
} else {
if (PluginService.disablePlugin(pluginDelegate.pluginId)) {
ToastService.showInfo("Plugin disabled: " + pluginDelegate.pluginName)
if (pluginsTab.expandedPluginId === pluginDelegate.pluginId) {
pluginsTab.expandedPluginId = ""
}
} else {
ToastService.showError("Failed to disable plugin: " + pluginDelegate.pluginName)
checked = true
}
}
}
}
}
StyledText {
width: parent.width
text: pluginDelegate.pluginDescription
font.pixelSize: Theme.fontSizeSmall
color: "#CCCCCC"
wrapMode: Text.WordWrap
visible: pluginDelegate.pluginDescription !== ""
}
Flow {
width: parent.width
spacing: Theme.spacingXS
visible: pluginDelegate.pluginPermissions && Array.isArray(pluginDelegate.pluginPermissions) && pluginDelegate.pluginPermissions.length > 0
Repeater {
model: pluginDelegate.pluginPermissions
Rectangle {
height: 20
width: permissionText.implicitWidth + Theme.spacingXS * 2
radius: 10
color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1)
border.color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.3)
border.width: 1
StyledText {
id: permissionText
anchors.centerIn: parent
text: modelData
font.pixelSize: Theme.fontSizeSmall - 1
color: Theme.primary
}
}
}
}
}
// Settings container
Item {
id: settingsContainer
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
height: pluginDelegate.isExpanded && pluginDelegate.hasSettings ? Math.min(500, settingsLoader.item ? settingsLoader.item.implicitHeight + Theme.spacingL * 2 : 200) : 0
clip: true
onHeightChanged: {
console.log("Settings container height changed:", height, "for plugin:", pluginDelegate.pluginId)
}
Behavior on height {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.standardEasing
}
}
Rectangle {
anchors.fill: parent
color: Qt.rgba(Theme.surfaceContainerHighest.r, Theme.surfaceContainerHighest.g, Theme.surfaceContainerHighest.b, 0.5)
radius: Theme.cornerRadius
anchors.topMargin: Theme.spacingXS
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.1)
border.width: 1
}
DankFlickable {
anchors.fill: parent
anchors.margins: Theme.spacingL
contentHeight: settingsLoader.height
contentWidth: width
clip: true
Loader {
id: settingsLoader
width: parent.width
active: pluginDelegate.isExpanded && pluginDelegate.hasSettings && PluginService.isPluginLoaded(pluginDelegate.pluginId)
asynchronous: false
onActiveChanged: {
console.log("Settings loader active changed to:", active, "for plugin:", pluginDelegate.pluginId,
"isExpanded:", pluginDelegate.isExpanded, "hasSettings:", pluginDelegate.hasSettings,
"isLoaded:", PluginService.isPluginLoaded(pluginDelegate.pluginId))
}
source: {
if (active && pluginDelegate.pluginSettingsPath) {
console.log("Loading plugin settings from:", pluginDelegate.pluginSettingsPath)
var path = pluginDelegate.pluginSettingsPath
if (!path.startsWith("file://")) {
path = "file://" + path
}
return path
}
return ""
}
onStatusChanged: {
console.log("Settings loader status changed:", status, "for plugin:", pluginDelegate.pluginId)
if (status === Loader.Error) {
console.error("Failed to load plugin settings:", pluginDelegate.pluginSettingsPath)
} else if (status === Loader.Ready) {
console.log("Settings successfully loaded for plugin:", pluginDelegate.pluginId)
}
}
onLoaded: {
if (item) {
console.log("Plugin settings loaded for:", pluginDelegate.pluginId)
// Make PluginService available to the loaded component
if (typeof PluginService !== "undefined") {
console.log("Making PluginService available to plugin settings")
console.log("PluginService functions available:",
"savePluginData" in PluginService,
"loadPluginData" in PluginService)
item.pluginService = PluginService
console.log("PluginService assignment completed, item.pluginService:", item.pluginService !== null)
} else {
console.error("PluginService not available in PluginsTab context")
}
// Connect to height changes for dynamic resizing
if (item.implicitHeightChanged) {
item.implicitHeightChanged.connect(function() {
console.log("Plugin settings height changed:", item.implicitHeight)
})
}
// Force load timezones for WorldClock plugin
if (item.loadTimezones) {
console.log("Calling loadTimezones for WorldClock plugin")
item.loadTimezones()
}
// Generic initialization for any plugin
if (item.initializeSettings) {
item.initializeSettings()
}
}
}
}
}
StyledText {
anchors.centerIn: parent
text: !PluginService.isPluginLoaded(pluginDelegate.pluginId) ?
"Enable plugin to access settings" :
(settingsLoader.status === Loader.Error ?
"Failed to load settings" :
"No configurable settings")
font.pixelSize: Theme.fontSizeSmall
color: "#CCCCCC"
visible: pluginDelegate.isExpanded && (!settingsLoader.active || settingsLoader.status === Loader.Error)
}
}
}
}
StyledText {
anchors.centerIn: parent
text: "No plugins found.\nPlace plugins in " + PluginService.pluginDirectory
font.pixelSize: Theme.fontSizeMedium
color: "#CCCCCC"
horizontalAlignment: Text.AlignHCenter
visible: pluginListView.model.length === 0
}
}
}
}
}
}
// Connections to update plugin list when plugins are loaded/unloaded
Connections {
target: PluginService
function onPluginLoaded() {
pluginListView.model = PluginService.getAvailablePlugins()
}
function onPluginUnloaded() {
pluginListView.model = PluginService.getAvailablePlugins()
// Close expanded plugin if it was unloaded
if (pluginsTab.expandedPluginId !== "" && !PluginService.isPluginLoaded(pluginsTab.expandedPluginId)) {
pluginsTab.expandedPluginId = ""
}
}
}
}

299
Services/PluginService.qml Normal file
View File

@@ -0,0 +1,299 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtCore
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Common
Singleton {
id: root
property var availablePlugins: ({})
property var loadedPlugins: ({})
property var pluginWidgetComponents: ({})
property string pluginDirectory: {
var configDir = StandardPaths.writableLocation(StandardPaths.ConfigLocation)
var configDirStr = configDir.toString()
if (configDirStr.startsWith("file://")) {
configDirStr = configDirStr.substring(7)
}
return configDirStr + "/DankMaterialShell/plugins"
}
signal pluginLoaded(string pluginId)
signal pluginUnloaded(string pluginId)
signal pluginLoadFailed(string pluginId, string error)
Component.onCompleted: {
Qt.callLater(initializePlugins)
}
function initializePlugins() {
scanPlugins()
}
property var lsProcess: Process {
id: dirScanner
stdout: StdioCollector {
onStreamFinished: {
var output = text.trim()
if (output) {
var directories = output.split('\n')
for (var i = 0; i < directories.length; i++) {
var dir = directories[i].trim()
if (dir) {
var manifestPath = pluginDirectory + "/" + dir + "/plugin.json"
console.log("PluginService: Found plugin directory:", dir, "checking manifest at:", manifestPath)
loadPluginManifest(manifestPath)
}
}
} else {
console.log("PluginService: No directories found in plugin directory")
}
}
}
onExited: function(exitCode) {
if (exitCode !== 0) {
console.error("PluginService: Failed to scan plugin directory, exit code:", exitCode)
}
}
}
function scanPlugins() {
lsProcess.command = ["find", pluginDirectory, "-maxdepth", "1", "-type", "d", "-not", "-path", pluginDirectory, "-exec", "basename", "{}", ";"]
lsProcess.running = true
}
property var manifestReaders: ({})
function loadPluginManifest(manifestPath) {
console.log("PluginService: Loading manifest:", manifestPath)
// Create a unique key for this manifest reader
var readerId = "reader_" + Date.now() + "_" + Math.random()
var catProcess = Qt.createComponent("data:text/plain,import Quickshell.Io; Process { stdout: StdioCollector { } }")
if (catProcess.status === Component.Ready) {
var process = catProcess.createObject(root)
process.command = ["cat", manifestPath]
process.stdout.streamFinished.connect(function() {
try {
console.log("PluginService: DEBUGGING parsing manifest, text length:", process.stdout.text.length)
var manifest = JSON.parse(process.stdout.text.trim())
console.log("PluginService: Successfully parsed manifest for plugin:", manifest.id)
processManifest(manifest, manifestPath)
} catch (e) {
console.error("PluginService: Failed to parse manifest", manifestPath, ":", e.message)
}
process.destroy()
delete manifestReaders[readerId]
})
process.exited.connect(function(exitCode) {
if (exitCode !== 0) {
console.error("PluginService: Failed to read manifest file:", manifestPath, "exit code:", exitCode)
process.destroy()
delete manifestReaders[readerId]
}
})
manifestReaders[readerId] = process
process.running = true
} else {
console.error("PluginService: Failed to create manifest reader process")
}
}
function processManifest(manifest, manifestPath) {
registerPlugin(manifest, manifestPath)
// Auto-load plugin if it's enabled in settings (default to enabled)
var enabled = SettingsData.getPluginSetting(manifest.id, "enabled", true)
if (enabled) {
loadPlugin(manifest.id)
}
}
function registerPlugin(manifest, manifestPath) {
console.log("PluginService: registerPlugin called with", manifest.id)
if (!manifest.id || !manifest.name || !manifest.component) {
console.error("PluginService: Invalid manifest, missing required fields:", manifestPath)
return
}
var pluginDir = manifestPath.substring(0, manifestPath.lastIndexOf('/'))
// Clean up relative paths by removing './' prefix
var componentFile = manifest.component
if (componentFile.startsWith('./')) {
componentFile = componentFile.substring(2)
}
var settingsFile = manifest.settings
if (settingsFile && settingsFile.startsWith('./')) {
settingsFile = settingsFile.substring(2)
}
var pluginInfo = {}
for (var key in manifest) {
pluginInfo[key] = manifest[key]
}
pluginInfo.manifestPath = manifestPath
pluginInfo.pluginDirectory = pluginDir
pluginInfo.componentPath = pluginDir + '/' + componentFile
pluginInfo.settingsPath = settingsFile ? pluginDir + '/' + settingsFile : null
pluginInfo.loaded = false
availablePlugins[manifest.id] = pluginInfo
console.log("PluginService: Registered plugin:", manifest.id, "-", manifest.name)
console.log("PluginService: Component path:", pluginInfo.componentPath)
}
function loadPlugin(pluginId) {
console.log("PluginService: loadPlugin called for", pluginId)
var plugin = availablePlugins[pluginId]
if (!plugin) {
console.error("PluginService: Plugin not found:", pluginId)
pluginLoadFailed(pluginId, "Plugin not found")
return false
}
if (plugin.loaded) {
console.log("PluginService: Plugin already loaded:", pluginId)
return true
}
try {
// Create the widget component
var componentUrl = "file://" + plugin.componentPath
console.log("PluginService: Loading component from:", componentUrl)
var component = Qt.createComponent(componentUrl)
if (component.status === Component.Error) {
console.error("PluginService: Failed to create component for plugin:", pluginId, "Error:", component.errorString())
pluginLoadFailed(pluginId, component.errorString())
return false
}
pluginWidgetComponents[pluginId] = component
plugin.loaded = true
loadedPlugins[pluginId] = plugin
console.log("PluginService: Successfully loaded plugin:", pluginId)
pluginLoaded(pluginId)
return true
} catch (error) {
console.error("PluginService: Error loading plugin:", pluginId, "Error:", error.message)
pluginLoadFailed(pluginId, error.message)
return false
}
}
function unloadPlugin(pluginId) {
var plugin = loadedPlugins[pluginId]
if (!plugin) {
console.warn("PluginService: Plugin not loaded:", pluginId)
return false
}
try {
// Remove from component map
delete pluginWidgetComponents[pluginId]
// Mark as unloaded
plugin.loaded = false
delete loadedPlugins[pluginId]
console.log("PluginService: Successfully unloaded plugin:", pluginId)
pluginUnloaded(pluginId)
return true
} catch (error) {
console.error("PluginService: Error unloading plugin:", pluginId, "Error:", error.message)
return false
}
}
function getWidgetComponents() {
return pluginWidgetComponents
}
function getAvailablePlugins() {
var result = []
for (var key in availablePlugins) {
result.push(availablePlugins[key])
}
return result
}
function getLoadedPlugins() {
var result = []
for (var key in loadedPlugins) {
result.push(loadedPlugins[key])
}
return result
}
function isPluginLoaded(pluginId) {
return loadedPlugins[pluginId] !== undefined
}
function enablePlugin(pluginId) {
console.log("PluginService: Enabling plugin:", pluginId)
SettingsData.setPluginSetting(pluginId, "enabled", true)
return loadPlugin(pluginId)
}
function disablePlugin(pluginId) {
console.log("PluginService: Disabling plugin:", pluginId)
SettingsData.setPluginSetting(pluginId, "enabled", false)
return unloadPlugin(pluginId)
}
function reloadPlugin(pluginId) {
if (isPluginLoaded(pluginId)) {
unloadPlugin(pluginId)
}
return loadPlugin(pluginId)
}
function savePluginData(pluginId, key, value) {
console.log("PluginService: Saving plugin data:", pluginId, key, JSON.stringify(value))
SettingsData.setPluginSetting(pluginId, key, value)
console.log("PluginService: Data saved successfully")
return true
}
function loadPluginData(pluginId, key, defaultValue) {
console.log("PluginService: Loading plugin data:", pluginId, key)
var value = SettingsData.getPluginSetting(pluginId, key, defaultValue)
console.log("PluginService: Loaded key:", key, "value:", JSON.stringify(value))
return value
}
function createPluginDirectory() {
console.log("PluginService: Creating plugin directory:", pluginDirectory)
var mkdirProcess = Qt.createComponent("data:text/plain,import Quickshell.Io; Process { }")
if (mkdirProcess.status === Component.Ready) {
var process = mkdirProcess.createObject(root)
process.command = ["mkdir", "-p", pluginDirectory]
process.exited.connect(function(exitCode) {
if (exitCode === 0) {
console.log("PluginService: Successfully created plugin directory")
} else {
console.error("PluginService: Failed to create plugin directory, exit code:", exitCode)
}
process.destroy()
})
process.running = true
return true
} else {
console.error("PluginService: Failed to create mkdir process")
return false
}
}
}

456
docs/PLUGINS.md Normal file
View File

@@ -0,0 +1,456 @@
# Plugin System
The DMS shell includes an experimental plugin system that allows extending functionality through self-contained, dynamically-loaded QML components.
## Overview
The plugin system enables developers to create custom widgets that can be displayed in the DankBar alongside built-in widgets. Plugins are discovered, loaded, and managed through the **PluginService**, providing a clean separation between core shell functionality and user extensions.
## Architecture
### Core Components
1. **PluginService** (`Services/PluginService.qml`)
- Singleton service managing plugin lifecycle
- Discovers plugins from `$CONFIGPATH/DankMaterialShell/plugins/`
- Handles loading, unloading, and state management
- Provides data persistence for plugin settings
2. **PluginsTab** (`Modules/Settings/PluginsTab.qml`)
- UI for managing available plugins
- Access plugin settings
3. **PluginsTab Settings** (`Modules/Settings/PluginsTab.qml`)
- Accordion-style plugin configuration interface
- Dynamically loads plugin settings components inline
- Provides consistent settings interface with proper focus handling
4. **DankBar Integration** (`Modules/DankBar/DankBar.qml`)
- Renders plugin widgets in the bar
- Merges plugin components with built-in widgets
- Supports left, center, and right sections
## Plugin Structure
Each plugin must be a directory in `$CONFIGPATH/DankMaterialShell/plugins/` containing:
```
$CONFIGPATH/DankMaterialShell/plugins/YourPlugin/
├── plugin.json # Required: Plugin manifest
├── YourWidget.qml # Required: Widget component
├── YourSettings.qml # Optional: Settings UI
├── qmldir # Optional: QML module definition
└── *.js # Optional: JavaScript utilities
```
### Plugin Manifest (plugin.json)
The manifest file defines plugin metadata and configuration:
```json
{
"id": "yourPlugin",
"name": "Your Plugin Name",
"description": "Brief description of what your plugin does",
"version": "1.0.0",
"author": "Your Name",
"icon": "material_icon_name",
"component": "./YourWidget.qml",
"settings": "./YourSettings.qml",
"dependencies": {
"libraryName": {
"url": "https://cdn.example.com/library.js",
"optional": true
}
},
"settings_schema": {
"settingKey": {
"type": "string|number|boolean|array|object",
"default": "defaultValue"
}
},
"permissions": [
"settings_read",
"settings_write"
]
}
```
**Required Fields:**
- `id`: Unique plugin identifier (camelCase, no spaces)
- `name`: Human-readable plugin name
- `component`: Relative path to widget QML file
**Optional Fields:**
- `description`: Short description of plugin functionality
- `version`: Semantic version string
- `author`: Plugin creator name
- `icon`: Material Design icon name
- `settings`: Path to settings component
- `dependencies`: External JS libraries
- `settings_schema`: Configuration schema
- `permissions`: Required capabilities
### Widget Component
The main widget component is displayed in the DankBar. It receives several properties from the shell:
```qml
import QtQuick
Rectangle {
id: root
// Standard properties provided by DankBar
property bool compactMode: false
property string section: "center" // "left", "center", or "right"
property var popupTarget: null
property var parentScreen: null
property real barHeight: 48
property real widgetHeight: 30
// Widget dimensions
width: content.implicitWidth + horizontalPadding * 2
height: widgetHeight
// PluginService is injected by PluginsTab when loading settings
property var pluginService
// Access plugin data
Component.onCompleted: {
if (pluginService) {
var savedData = pluginService.loadPluginData("yourPlugin", "dataKey", defaultValue)
}
}
// Save plugin data
function saveData(key, value) {
if (pluginService) {
pluginService.savePluginData("yourPlugin", key, value)
}
}
}
```
**Important Properties:**
- `compactMode`: Whether the bar is in compact display mode
- `section`: Which bar section the widget is in
- `barHeight`: Height of the entire bar
- `widgetHeight`: Recommended widget height
- `parentScreen`: Reference to the screen object
### Settings Component
Optional settings UI loaded inline in the PluginsTab accordion interface:
```qml
import QtQuick
import QtQuick.Controls
import qs.Common
import qs.Services
import qs.Widgets
Column {
id: root
// PluginService is injected by PluginsTab
property var pluginService
spacing: Theme.spacingM
DankTextField {
id: settingInput
width: parent.width
label: "Setting Label"
text: pluginService ? pluginService.loadPluginData("yourPlugin", "settingKey", "default") : ""
onTextChanged: {
if (pluginService) {
pluginService.savePluginData("yourPlugin", "settingKey", text)
}
}
}
DankToggle {
checked: pluginService ? pluginService.loadPluginData("yourPlugin", "enabled", true) : false
onToggled: {
if (pluginService) {
pluginService.savePluginData("yourPlugin", "enabled", checked)
}
}
}
}
```
## PluginService API
### Properties
```qml
PluginService.pluginDirectory: string
// Path to plugins directory ($CONFIGPATH/DankMaterialShell/plugins)
PluginService.availablePlugins: object
// Map of all discovered plugins {pluginId: pluginInfo}
PluginService.loadedPlugins: object
// Map of currently loaded plugins {pluginId: pluginInfo}
PluginService.pluginWidgetComponents: object
// Map of loaded widget components {pluginId: Component}
```
### Functions
```qml
// Plugin Management
PluginService.loadPlugin(pluginId: string): bool
PluginService.unloadPlugin(pluginId: string): bool
PluginService.reloadPlugin(pluginId: string): bool
PluginService.enablePlugin(pluginId: string): bool
PluginService.disablePlugin(pluginId: string): bool
// Plugin Discovery
PluginService.scanPlugins(): void
PluginService.getAvailablePlugins(): array
PluginService.getLoadedPlugins(): array
PluginService.isPluginLoaded(pluginId: string): bool
PluginService.getWidgetComponents(): object
// Data Persistence
PluginService.savePluginData(pluginId: string, key: string, value: any): bool
PluginService.loadPluginData(pluginId: string, key: string, defaultValue: any): any
```
### Signals
```qml
PluginService.pluginLoaded(pluginId: string)
PluginService.pluginUnloaded(pluginId: string)
PluginService.pluginLoadFailed(pluginId: string, error: string)
```
## Creating a Plugin
### Step 1: Create Plugin Directory
```bash
mkdir -p $CONFIGPATH/DankMaterialShell/plugins/MyPlugin
cd $CONFIGPATH/DankMaterialShell/plugins/MyPlugin
```
### Step 2: Create Manifest
Create `plugin.json`:
```json
{
"id": "myPlugin",
"name": "My Plugin",
"description": "A sample plugin",
"version": "1.0.0",
"author": "Your Name",
"icon": "extension",
"component": "./MyWidget.qml",
"settings": "./MySettings.qml",
"permissions": ["settings_read", "settings_write"]
}
```
### Step 3: Create Widget Component
Create `MyWidget.qml`:
```qml
import QtQuick
import qs.Services
Rectangle {
id: root
property bool compactMode: false
property string section: "center"
property real widgetHeight: 30
property string displayText: "Hello World"
width: textItem.implicitWidth + 16
height: widgetHeight
radius: 8
color: "#20FFFFFF"
Component.onCompleted: {
displayText = PluginService.loadPluginData("myPlugin", "text", "Hello World")
}
Text {
id: textItem
anchors.centerIn: parent
text: root.displayText
color: "#FFFFFF"
font.pixelSize: 13
}
MouseArea {
anchors.fill: parent
onClicked: console.log("Plugin clicked!")
}
}
```
### Step 4: Create Settings Component (Optional)
Create `MySettings.qml`:
```qml
import QtQuick
import QtQuick.Controls
import qs.Common
import qs.Services
import qs.Widgets
Column {
// PluginService is injected by PluginsTab
property var pluginService
spacing: Theme.spacingM
StyledText {
text: "Plugin Settings"
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Medium
}
DankTextField {
width: parent.width
label: "Display Text"
text: pluginService ? pluginService.loadPluginData("myPlugin", "text", "Hello World") : ""
onTextChanged: {
if (pluginService) {
pluginService.savePluginData("myPlugin", "text", text)
}
}
}
}
```
### Step 5: Enable Plugin
1. Run the shell: `qs -p $CONFIGPATH/quickshell/dms/shell.qml`
2. Open Settings (Ctrl+,)
3. Navigate to Plugins tab
4. Click "Scan for Plugins"
5. Enable your plugin with the toggle switch
6. Add the plugin to your DankBar configuration
## Adding Plugin to DankBar
After enabling a plugin, add it to the bar:
1. Open Settings → Appearance → DankBar Layout
2. Add a new widget entry with your plugin ID
3. Choose section (left, center, right)
4. Save and reload
Or edit `$CONFIGPATH/quickshell/dms/config.json`:
```json
{
"dankBarLeftWidgets": [
{"widgetId": "myPlugin", "enabled": true}
]
}
```
## Best Practices
1. **Use Existing Widgets**: Leverage `qs.Widgets` components (DankIcon, DankToggle, etc.) for consistency
2. **Follow Theme**: Use `Theme` singleton for colors, spacing, and fonts
3. **Data Persistence**: Use PluginService data APIs instead of manual file operations
4. **Error Handling**: Gracefully handle missing dependencies and invalid data
5. **Performance**: Keep widgets lightweight, avoid expensive operations
6. **Responsive Design**: Adapt to `compactMode` and different screen sizes
7. **Clean Code**: Follow QML code conventions from CLAUDE.md
8. **Documentation**: Include README.md explaining plugin usage
9. **Versioning**: Use semantic versioning for updates
10. **Dependencies**: Document external library requirements
## Debugging
### Console Logging
View plugin logs:
```bash
qs -v -p $CONFIGPATH/quickshell/dms/shell.qml
```
Look for lines prefixed with:
- `PluginService:` - Service operations
- `PluginsTab:` - UI interactions
- `PluginsTab:` - Settings loading and accordion interface
### Common Issues
1. **Plugin Not Detected**
- Check plugin.json syntax (use `jq` or JSON validator)
- Verify directory is in `$CONFIGPATH/DankMaterialShell/plugins/`
- Click "Scan for Plugins" in Settings
2. **Widget Not Displaying**
- Ensure plugin is enabled in Settings
- Add plugin ID to DankBar widget list
- Check widget width/height properties
3. **Settings Not Loading**
- Verify `settings` path in plugin.json
- Check settings component for errors
- Ensure plugin is enabled and loaded
- Review PluginsTab console output for injection issues
4. **Data Not Persisting**
- Confirm pluginService.savePluginData() calls (with injection)
- Check `$CONFIGPATH/DankMaterialShell/settings.json` for pluginSettings data
- Verify plugin has settings permissions
- Ensure PluginService was properly injected into settings component
## Security Considerations
Plugins run with full QML runtime access. Only install plugins from trusted sources.
**Permissions System:**
- `settings_read`: Read plugin configuration
- `settings_write`: Write plugin configuration
- `process`: Execute system commands
- `network`: Network access
Future versions may enforce permission restrictions.
## API Stability
The plugin API is currently **experimental**. Breaking changes may occur in minor version updates. Pin to specific DMS versions for production use.
**Roadmap:**
- Plugin marketplace/repository
- Sandboxed plugin execution
- Enhanced permission system
- Plugin update notifications
- Inter-plugin communication
## Resources
- **Example Plugin**: https://github.com/rochacbruno/WorldClock
- **PluginService**: `Services/PluginService.qml`
- **Settings UI**: `Modules/Settings/PluginsTab.qml`
- **DankBar Integration**: `Modules/DankBar/DankBar.qml`
- **Theme Reference**: `Common/Theme.qml`
- **Widget Library**: `Widgets/`
## Contributing
Share your plugins with the community:
1. Create a public repository with your plugin
2. Include comprehensive README.md
4. Add example screenshots
5. Document dependencies and permissions
For plugin system improvements, submit issues or PRs to the main DMS repository.

View File

@@ -37,6 +37,8 @@ ShellRoot {
DisplayService.nightModeEnabled DisplayService.nightModeEnabled
// Initialize WallpaperCyclingService // Initialize WallpaperCyclingService
WallpaperCyclingService.cyclingActive WallpaperCyclingService.cyclingActive
// Initialize PluginService by accessing its properties
PluginService.pluginDirectory
} }
WallpaperBackground {} WallpaperBackground {}