DankMaterialShell 插件高级开发模式:Variants、JS 工具库、Singleton 服务与 IPC 集成实战
DankMaterialShell 插件高级开发模式:Variants、JS 工具库、Singleton 服务与 IPC 集成实战
DankMaterialShell(DMS)是一款基于 Quickshell 与 Go 构建的 Wayland 桌面壳层,其插件体系允许开发者用 QML 为顶栏(DankBar)、Dock、控制中心、桌面、Dash 与后台守护进程贡献能力。当基础插件(如widget 插件指南、launcher 插件指南)已经不能满足需求时,就需要掌握「高级模式」:把一份插件定义拆出多个可独立配置的实例(Variants)、用 .pragma library 隔离复杂逻辑、用 qmldir 声明跨实例单例服务、通过 IPC 响应快捷键与外部命令、接入 Quickshell 网络模块等。本文以生产环境中 DMS 插件真实沉淀的advanced-patterns.md 为骨架,结合仓库内 quickshell/Services/PluginService.qml、quickshell/Modules/Plugins/PluginComponent.qml 等源码与 quickshell/PLUGINS/ 下的官方示例,逐项讲解这些模式的原理、完整代码与落地要点,读完后你将具备开发多实例、可扩展、可外部触发的生产级 DMS 插件的能力。
前置知识:建议先阅读 DMS 插件开发技能总纲 与 plugin.json 清单参考,了解插件目录约定(插件从
~/.config/DankMaterialShell/plugins/发现)、七种插件类型与PluginComponent/PluginSettings基础用法。插件开发是纯阅读与配置过程,无需修改仓库本身。
一、Plugin Variants:一份定义,多份实例
Variants(变体)是生产环境 DMS 插件中最常用的高级能力:用一个插件定义创建多个 widget 实例,每个实例拥有自己独立的配置。典型场景包括:同一脚本运行器跑不同脚本、不同监控目标(CPU/GPU/网络)的系统监视器、多个时区的时钟、不同数据源的指示器。
1.1 原理与 Manifest
从源码看,变体系统内建于 PluginComponent 与 PluginService,因此 plugin.json 无需任何特殊字段——普通 widget 清单即可。变体数据本身被持久化在插件设置中(源码实现见 PluginService.qml 的 getPluginVariants,它从 SettingsData.getPluginSetting(pluginId, "variants", <a href="https://link.gitcode.com/i/74325d6ab2b55b2202d509e8f9b801e5" target="_blank">]) 读取),PluginComponent 在加载插件数据时会同步填充自身的 variants 属性(见 [PluginComponent.qml)。
仓库提供了完整的官方示例插件 ExampleWithVariants(含 plugin.json、VariantWidget.qml、VariantSettings.qml),可作为直接复刻的范本。其 plugin.json 就是一个标准的 widget 清单:
{
"id": "exampleVariants",
"name": "Example with Variants",
"description": "Demonstrates dynamic variant creation for plugins",
"version": "1.0.0",
"author": "DMS",
"type": "widget",
"capabilities": ["multiple-usecases", "variants"],
"component": "./VariantWidget.qml",
"icon": "widgets",
"settings": "./VariantSettings.qml",
"permissions": [
"settings_read",
"settings_write"
]
}
1.2 支持 Variant 的 Widget 组件
组件内通过 variantId 与 variantData 两个注入属性感知自己属于哪个变体,并用 variantData?.xxx || 默认值 的安全写法读取配置:
PluginComponent {
property string variantId: ""
property var variantData: ({})
property string displayText: variantData?.text || "Default"
horizontalBarPill: Component {
StyledRect {
width: label.implicitWidth + Theme.spacingM * 2
height: parent.widgetThickness
StyledText {
id: label
anchors.centerIn: parent
text: root.displayText
}
}
}
}
变体在顶栏配置中的格式为 pluginId:variantId,例如 exampleVariants:variant_1234567890。这意味着变体实例在 Bar 设置里会作为一个独立 widget 项出现在「Add Widget」菜单中;删除变体时,PluginService.removePluginVariant 会通过 removeWidgetFromDankBar 同步把 pluginId:variantId 从顶栏左右中三段布局中移除(见 PluginService.qml),不会留下失效引用。
1.3 带变体管理的 Settings 组件
设置页需要提供「创建 / 删除 / 更新变体」的 UI。PluginSettings 内置了响应式的 variants 属性与 variantsModel 别名(见 PluginSettings.qml),创建操作通过 root.createVariant(id, config) 完成:
PluginSettings {
pluginId: "exampleVariants"
// Variant creation UI
DankButton {
text: "Add New Instance"
onClicked: {
var id = "variant_" + Date.now()
root.createVariant(id, { name: "New Instance", text: "Hello" })
}
}
// Per-variant configuration
Repeater {
model: root.variants
delegate: Column {
StringSetting {
settingKey: modelData.id + "_text"
label: modelData.name || modelData.id
}
}
}
}
需要注意:StringSetting 的 settingKey 必须以 modelData.id + "_" 前缀拼接,才能保证每个变体拥有彼此隔离的配置键。
1.4 源码级变体 API
PluginComponent 提供了便捷封装(PluginComponent.qml):
createVariant(variantName, variantConfig)→ 返回新变体 idremoveVariant(variantId)updateVariant(variantId, variantConfig)
底层对应 PluginService 的完整 API(PluginService.qml):
| 方法 | 行为 |
|---|---|
getPluginVariants(pluginId) |
返回该插件的全部变体数组 |
getAllPluginVariants() |
返回所有插件的变体,并生成 fullId = pluginId:variantId 的扁平列表(无变体的插件以 fullId = pluginId、variantId = null 出现) |
createPluginVariant(pluginId, name, config) |
生成 variant_ + Date.now() 的 id,合并配置后写入设置并广播 pluginDataChanged |
removePluginVariant(pluginId, variantId) |
删除变体并清理顶栏引用 |
updatePluginVariant(pluginId, variantId, config) |
合并更新变体配置 |
getPluginVariantData(pluginId, variantId) |
获取单个变体数据 |
二、JavaScript 工具文件:用 .pragma library 隔离复杂逻辑
当插件逻辑复杂(时间格式化、响应解析、数组处理)时,应把纯函数拆进 .js 文件,让 QML 组件保持可读。
2.1 utils.js
.pragma library
function formatDuration(ms) {
if (ms < 60000) return "just now"
if (ms < 3600000) return Math.floor(ms / 60000) + "m ago"
return Math.floor(ms / 3600000) + "h ago"
}
function parseResponse(json) {
try {
return JSON.parse(json)
} catch (e) {
return null
}
}
2.2 在 QML 中使用
import "utils.js" as Utils
Item {
StyledText {
text: Utils.formatDuration(Date.now() - timestamp)
}
}
关键语义:文件首行的 .pragma library 指令让该 JS 文件成为共享单例——它只被加载一次,所有导入它的 QML 实例共享同一份函数与模块级状态。这对于多个 widget 实例 / 多个屏幕之间共享缓存、请求节流状态尤其有价值。
三、qmldir 单例服务:插件的内部 Singleton
对于需要「内部单例服务」的插件(如跨组件共享的内存缓存、全局事件总线),可以通过 qmldir 注册一个 singleton 类型的 QML 类型。
3.1 qmldir
singleton MyService 1.0 MyService.qml
3.2 MyService.qml
pragma Singleton
import QtQuick
QtObject {
property var cache: ({})
function getData(key) {
return cache[key] || null
}
function setData(key, value) {
cache[key] = value
}
}
注意 pragma Singleton 是 Qt 层面的单例指令,与 qmldir 中的 singleton 声明配合使用,二者缺一不可。
3.3 使用该单例
import "." as Local
Item {
Component.onCompleted: {
Local.MyService.setData("key", "value")
}
}
通过 import "." as Local 把插件目录本身作为导入作用域,即可用 Local.MyService 访问注册好的单例,避免在多个文件里重复实例化同一服务。
四、Inline Component 声明:文件内复用小组件
当某个子组件只在当前文件内复用(如状态徽章、图标按钮),Qt 6 的 inline component 语法可以在 Item 内部直接声明,无需拆文件:
Item {
component StatusBadge: Rectangle {
property string label: ""
property color badgeColor: Theme.primary
width: badgeText.implicitWidth + Theme.spacingM * 2
height: 24
radius: 12
color: badgeColor
StyledText {
id: badgeText
anchors.centerIn: parent
text: label
color: Theme.onPrimary
font.pixelSize: Theme.fontSizeSmall
}
}
Row {
spacing: Theme.spacingS
StatusBadge { label: "Running"; badgeColor: Theme.success }
StatusBadge { label: "Stopped"; badgeColor: Theme.error }
}
}
component StatusBadge: Rectangle { ... } 声明了一个文件作用域内的可复用组件,适合与 DMS 主题属性(Theme.primary、Theme.success、Theme.error、Theme.spacingM 等)组合成一致的视觉单元,避免在多个 delegate 中复制粘贴相同的矩形结构。
五、Multi-Provider 适配器模式:一套 UI,多后端
对于需要支持多个后端(AI 服务商、API 服务、数据源)的插件,适配器模式可以把「协议差异」收敛到一个工厂函数里。以 .pragma library 形式实现,所有组件共享同一适配器层:
apiAdapters.js
.pragma library
function createAdapter(provider) {
switch (provider) {
case "openai": return {
url: "https://api.openai.com/v1/chat/completions",
headers: (key) => ({ "Authorization": "Bearer " + key }),
formatRequest: (messages) => JSON.stringify({ model: "gpt-4", messages: messages }),
parseResponse: (text) => JSON.parse(text).choices[0].message.content
}
case "anthropic": return {
url: "https://api.anthropic.com/v1/messages",
headers: (key) => ({ "x-api-key": key, "anthropic-version": "2023-06-01" }),
formatRequest: (messages) => JSON.stringify({ model: "claude-sonnet-4-20250514", messages: messages }),
parseResponse: (text) => JSON.parse(text).content[0].text
}
default: return null
}
}
要点:每个适配器对外暴露统一的 url / headers(key) / formatRequest(messages) / parseResponse(text) 四个契约,UI 层只面向该契约编程;新增后端只需增加一个 case 分支,无需改动组件代码。default: return null 保证了未知 provider 的优雅降级。该模式可与下文第五节(变体系统)组合:每个变体通过 variantData 指定 provider,再在设置页通过 SelectionSetting 暴露给用户选择。
六、IPC 集成:响应快捷键与外部命令
生产插件常需要响应键盘快捷键或外部命令(如 dms ipc call)。通过 Connections 监听 DMSIpc 的命令信号即可:
PluginComponent {
Connections {
target: DMSIpc
function onCommandReceived(command, args) {
if (command === "myPlugin.toggle") {
doToggle()
} else if (command === "myPlugin.next") {
goNext()
}
}
}
}
外部触发方式为:
dms ipc call myPlugin.toggle
IPC 基础设施在仓库的 Go 侧有完整实现:dms ipc call <target> <function> <a href="https://link.gitcode.com/i/30614cca0d2e3a683fec63287eb5276a" target="_blank">args...] 命令定义于 [commands_common.go,实际调用经由 qsipc.Call 写入 qs 实例的 IPC socket(见 qsipc/client.go)。插件侧最典型的 IPC 案例是「运行时插件扫描」:PluginService 通过 IpcHandler 注册了 plugin-scan target,提供 scan / rescan <id> / reload <id> / list / status <id> 五个函数,并校验插件 ID 符合 ^<a href="https://link.gitcode.com/i/1fc7101391adf1c34ed839de144bf124" target="_blank">a-zA-Z0-9_\-:]{1,64}$(见 [PluginService.qml):
dms ipc plugin-scan scan # 全量重扫插件目录
dms ipc plugin-scan rescan <id> # 强制重扫单个插件
dms ipc plugin-scan reload <id> # 强制重载已加载插件
dms ipc plugin-scan list # 列出已知插件(TSV:id、loaded、type、name)
dms ipc plugin-scan status <id> # 查询插件状态(TSV:loaded、type、error)
因此,为自己的插件注册自定义 IPC 命令时,可以沿用这套「IpcHandler + target + 函数」的模式,让快捷键、脚本、Waybar 之外的任何外部进程都能与插件交互。
七、Networking:用 Quickshell.Networking 做 API 调用
插件需要访问网络时,应使用 Quickshell 内置网络模块,而不是浏览器 fetch API(QML 运行时不存在):
import Quickshell.Networking
Item {
NetworkRequest {
id: request
url: "https://api.example.com/data"
method: "GET"
onResponseReceived: (response) => {
const data = JSON.parse(response.body)
processData(data)
}
onErrorOccurred: (error) => {
console.error("Network error:", error)
}
}
function fetchData() {
request.send()
}
}
NetworkRequest 的响应通过 response.body 提供,错误通过 onErrorOccurred 回调暴露;配合 .pragma library 的 parseResponse 工具函数可以统一 JSON 解析。注意 DMS 插件清单中的 permissions 含 network 权限项(当前未强制校验,但建议声明,参考 plugin-manifest-reference.md)。
八、Toast 通知:给用户即时反馈
DMS 提供 ToastService(位于 qs.Services),用于操作成功、失败与提示:
import qs.Services
// Info toast
ToastService?.showInfo("Operation completed")
// With title
ToastService?.showInfo("Plugin Name", "Data refreshed successfully")
源码签名验证于 ToastService.qml:
function showInfo(message, details = "", command = "", category = "") { ... }
同类方法还有 showWarning、showError,底层统一走 showToast(message, level, details, command, category)(ToastService.qml)。必须始终使用可选链(?.),因为 ToastService 并非在所有上下文(如部分 headless 或启动阶段)都可用;同样地,访问 pluginService 也应使用 pluginService?. 或空值检查(这也是 SKILL.md 中反复强调的十大常见错误之一)。
九、Clipboard 操作:绕开不存在的浏览器 API
QML 运行时没有 globalThis.clipboard、navigator.clipboard 或任何浏览器剪贴板 API。正确做法是把剪贴板写入交给 DMS 的 CLI:
import Quickshell
function copyToClipboard(text) {
Quickshell.execDetached(["dms", "cl", "copy", text])
ToastService?.showInfo("Copied to clipboard")
}
Quickshell.execDetached 以 fire-and-forget 方式启动进程,不阻塞 UI 线程;dms cl copy 是 DMS 剪贴板子命令(见 commands_clipboard.go 中 copy [text] 的定义)。剪贴板服务端还支持 paste、send-paste、watch、history、get <id> 等子命令,可满足更复杂的剪贴板集成需求。
十、多文件插件架构:大型插件的组织方式
大型插件应拆分成职责清晰的多个文件,而不是堆在一个巨型 QML 里:
MyPlugin/
plugin.json
Main.qml # Main widget component
Settings.qml # Settings UI
DetailView.qml # Popout detail view
utils.js # Utility functions
apiAdapter.js # API adapter layer
qmldir # Optional: singleton registrations
跨文件导入使用相对目录导入语法:
// In Main.qml
import "." as Local
Item {
Loader {
source: "DetailView.qml"
}
}
组合建议:.pragma library 的 JS 文件负责纯逻辑;qmldir 负责注册内部单例服务;Loader 负责懒加载重量级视图(如弹窗详情页);清单中通过 component / settings / startupCheck 字段分别指向对应文件(字段规则见 plugin-manifest-reference.md)。
十一、性能实践清单
官方在生产插件中沉淀了六条性能准则:
- 外部命令使用
Proc.runCommand并配合合理的 debounce——其签名支持第 4 个参数指定防抖毫秒数(如Proc.runCommand(id, args, callback, 500)),避免高频率事件(如壁纸快速切换、滑块拖动)触发大量进程; - 图片密集型插件预缓存图片与缩略图——用
CachingImage(qs.Widgets内置)代替裸Image; - 限制并发网络请求数量——为
NetworkRequest建立请求队列或节流; Timer使用合理间隔,不要无谓高频轮询——如守护进程示例中按需设置 60 秒间隔(见 daemon-plugin-guide.md);- 懒加载重量级内容——复杂 popout 内容用
Loader延迟实例化,弹窗关闭时可sourceComponent: undefined卸载; - 避免在 UI 线程执行同步阻塞操作——把耗时任务放进
Process(来自Quickshell.Io)并配合StdioCollector流式读取 stdout/stderr。
仓库中的 WallpaperWatcherDaemon.qml 是上述多条准则的集中体现:它用 Connections 响应 SessionData.onWallpaperPathChanged 事件、按需 createObject 动态创建 Process、通过 StdioCollector.onStreamFinished 聚合输出、用 ToastService.showError 反馈脚本错误、并在进程退出后 destroy() 释放资源——一个事件驱动 + 进程管理 + 通知反馈的完整高级模式样本。
十二、验证与调试建议
- 用 plugin-schema.json 校验清单:
python3 -c "...jsonschema.validate..."或至少jq . plugin.json; - 运行时通过
dms ipc plugin-scan list与dms ipc plugin-scan status <id>确认插件被识别且加载状态正确; - 变体相关:创建变体后到 Bar 设置的「Add Widget」菜单确认出现
插件名 - 变体名条目,添加后确认顶栏出现pluginId:variantId格式的实例; - 若 Settings 页报错,检查
plugin.json是否声明了"permissions": ["settings_write"]——这是 Settings UI 正常显示的硬性前置条件; - 所有主题相关属性一律使用
Theme.*(qs.Common),不要硬编码颜色与尺寸,保证变体与多主题下视觉一致。
至此,从变体系统、共享 JS 单例、qmldir 服务、内联组件、多 Provider 适配、IPC 集成、网络与 Toast、剪贴板,到多文件架构与性能优化,你已经掌握了生产级 DMS 插件所需的全部高级模式。更细分的主题(弹窗服务、数据持久化、Dash 集成)可继续查阅 popout-service-reference.md 与 data-persistence-guide.md。