DankMaterialShell 插件高级开发模式:Variants、JS 工具库、Singleton 服务与 IPC 集成实战

原创2026-09-25 16:57:551,354 阅读
文章标签:桌面应用

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) → 返回新变体 id
  • removeVariant(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)。

十一、性能实践清单

官方在生产插件中沉淀了六条性能准则:

  1. 外部命令使用 Proc.runCommand 并配合合理的 debounce——其签名支持第 4 个参数指定防抖毫秒数(如 Proc.runCommand(id, args, callback, 500)),避免高频率事件(如壁纸快速切换、滑块拖动)触发大量进程;
  2. 图片密集型插件预缓存图片与缩略图——用 CachingImage(qs.Widgets 内置)代替裸 Image;
  3. 限制并发网络请求数量——为 NetworkRequest 建立请求队列或节流;
  4. Timer 使用合理间隔,不要无谓高频轮询——如守护进程示例中按需设置 60 秒间隔(见 daemon-plugin-guide.md);
  5. 懒加载重量级内容——复杂 popout 内容用 Loader 延迟实例化,弹窗关闭时可 sourceComponent: undefined 卸载;
  6. 避免在 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。

登录后查看全文
DankMaterialShell