Add omarchy-shell with a plugin registry
This commit is contained in:
44 files changed
+582
-60
No files matched your search
@@ -18,7 +18,7 @@ ShellRoot {
|
||||
}
|
||||
property string omarchyPath: deriveOmarchyPath()
|
||||
readonly property string userConfigPath: home + "/.config/omarchy/bar.json"
|
||||
readonly property string defaultsPath: omarchyPath + "/default/quickshell/bar/bar-defaults.json"
|
||||
readonly property string defaultsPath: omarchyPath + "/default/quickshell/omarchy-shell/plugins/bar/bar-defaults.json"
|
||||
|
||||
property color foreground: "#cacccc"
|
||||
property color background: "#101315"
|
||||
@@ -29,7 +29,7 @@ ShellRoot {
|
||||
|
||||
// Bundled fallback so 'Reset to defaults' never produces an empty bar even
|
||||
// if bar-defaults.json fails to load. Keep in rough sync with the layout
|
||||
// shipped in default/quickshell/bar/bar-defaults.json.
|
||||
// shipped in default/quickshell/omarchy-shell/plugins/bar/bar-defaults.json.
|
||||
readonly property var builtinBarConfig: ({
|
||||
position: "top",
|
||||
fontFamily: "JetBrainsMono Nerd Font",
|
||||
|
||||
+93
-45
@@ -10,22 +10,15 @@ import Quickshell.Widgets
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
|
||||
ShellRoot {
|
||||
import "../../services" as Services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// The omarchy-shell host injects omarchyPath when it instantiates this Bar.
|
||||
// Default fallback keeps the file loadable in isolation (e.g. for QML tooling).
|
||||
required property string omarchyPath
|
||||
property string home: Quickshell.env("HOME")
|
||||
// Derive omarchyPath from the loaded shell.qml's directory so the bar finds
|
||||
// its own widgets/defaults regardless of where it was launched. shellDir is
|
||||
// `<omarchyPath>/default/quickshell/bar` so strip three trailing segments.
|
||||
function deriveOmarchyPath() {
|
||||
var env = Quickshell.env("OMARCHY_PATH")
|
||||
if (env) return env
|
||||
var dir = String(Quickshell.shellDir || "")
|
||||
if (dir.indexOf("/default/quickshell/bar") !== -1)
|
||||
return dir.substring(0, dir.indexOf("/default/quickshell/bar"))
|
||||
return home + "/.local/share/omarchy"
|
||||
}
|
||||
property string omarchyPath: deriveOmarchyPath()
|
||||
property string omarchyConfigDir: home + "/.config/omarchy"
|
||||
property var builtinBarConfig: ({
|
||||
position: "top",
|
||||
@@ -305,33 +298,69 @@ ShellRoot {
|
||||
return source ? fileUrl(source) : ""
|
||||
}
|
||||
|
||||
readonly property var firstPartyWidgets: ({
|
||||
"media": true,
|
||||
"audioPanel": true,
|
||||
"networkPanel": true,
|
||||
"bluetoothPanel": true,
|
||||
"calendar": true,
|
||||
"notificationCenter": true,
|
||||
"brightness": true,
|
||||
"powerProfile": true,
|
||||
"systemStats": true,
|
||||
"weatherFlyout": true,
|
||||
"workspacesPro": true,
|
||||
"powerMenu": true,
|
||||
"idleInhibitor": true,
|
||||
"microphone": true,
|
||||
"notificationCenter": true,
|
||||
"activeWindow": true,
|
||||
"nightLight": true,
|
||||
"keyboardLayout": true,
|
||||
"lockKeys": true,
|
||||
"spacer": true,
|
||||
"controlCenter": true
|
||||
// First-party widgets are registered with the BarWidgetRegistry at startup.
|
||||
// Each entry maps a widget id to its display metadata; the QML source lives
|
||||
// at widgets/<id>.qml and is loaded asynchronously via Qt.createComponent.
|
||||
readonly property var firstPartyWidgetMetadata: ({
|
||||
"media": { displayName: "Media", description: "MPRIS now-playing with playback controls", category: "Media", allowMultiple: false },
|
||||
"audioPanel": { displayName: "Audio", description: "Volume slider, output picker, per-app mixer", category: "Audio", allowMultiple: false },
|
||||
"networkPanel": { displayName: "Network", description: "Wi-Fi list and connection state", category: "Network", allowMultiple: false },
|
||||
"bluetoothPanel": { displayName: "Bluetooth", description: "Bluetooth device list with connect/disconnect", category: "Network", allowMultiple: false },
|
||||
"calendar": { displayName: "Calendar", description: "Clock with month-grid popup", category: "Time", allowMultiple: false },
|
||||
"notificationCenter": { displayName: "Notification center", description: "Recent notifications + DND (replaces mako)", category: "Status", allowMultiple: false },
|
||||
"brightness": { displayName: "Brightness", description: "Screen brightness slider", category: "System", allowMultiple: false },
|
||||
"powerProfile": { displayName: "Power profile", description: "power-profiles-daemon selector", category: "System", allowMultiple: false },
|
||||
"systemStats": { displayName: "System stats", description: "Inline CPU + memory sparklines", category: "System", allowMultiple: false },
|
||||
"weatherFlyout": { displayName: "Weather", description: "Weather pill with detail popup", category: "Info", allowMultiple: false },
|
||||
"workspacesPro": { displayName: "Workspaces", description: "Animated workspace switcher", category: "Compositor", allowMultiple: false },
|
||||
"powerMenu": { displayName: "Power menu", description: "Lock / suspend / reboot / shutdown", category: "System", allowMultiple: false },
|
||||
"idleInhibitor": { displayName: "Keep awake", description: "Toggle idle inhibitor", category: "System", allowMultiple: false },
|
||||
"microphone": { displayName: "Microphone", description: "Mic input state and mute toggle", category: "Audio", allowMultiple: false },
|
||||
"activeWindow": { displayName: "Active window", description: "Title of the focused window", category: "Compositor", allowMultiple: false },
|
||||
"nightLight": { displayName: "Night light", description: "hyprsunset toggle", category: "System", allowMultiple: false },
|
||||
"keyboardLayout": { displayName: "Keyboard layout", description: "Current xkb layout, click cycles", category: "Compositor", allowMultiple: false },
|
||||
"lockKeys": { displayName: "Lock keys", description: "Caps / Num / Scroll lock indicators", category: "System", allowMultiple: false },
|
||||
"spacer": { displayName: "Spacer", description: "Configurable blank space", category: "Layout", allowMultiple: true },
|
||||
"controlCenter": { displayName: "Quick settings", description: "Volume / brightness / DND in one popup", category: "System", allowMultiple: false }
|
||||
})
|
||||
|
||||
function firstPartyWidgetSource(name) {
|
||||
if (!firstPartyWidgets[String(name)]) return ""
|
||||
return Qt.resolvedUrl("widgets/" + String(name) + ".qml")
|
||||
property var registeredFirstPartyComponents: ({})
|
||||
|
||||
Component.onCompleted: registerFirstPartyWidgets()
|
||||
|
||||
function registerFirstPartyWidgets() {
|
||||
var ids = Object.keys(firstPartyWidgetMetadata)
|
||||
for (var i = 0; i < ids.length; i++) {
|
||||
var id = ids[i]
|
||||
if (Services.BarWidgetRegistry.has(id)) continue
|
||||
registerOneFirstPartyWidget(id)
|
||||
}
|
||||
}
|
||||
|
||||
function registerOneFirstPartyWidget(id) {
|
||||
var url = Qt.resolvedUrl("widgets/" + id + ".qml")
|
||||
var meta = firstPartyWidgetMetadata[id] || {}
|
||||
var enrichedMeta = {
|
||||
displayName: meta.displayName || id,
|
||||
description: meta.description || "",
|
||||
category: meta.category || "Misc",
|
||||
allowMultiple: meta.allowMultiple === true,
|
||||
source: "first-party"
|
||||
}
|
||||
var comp = Qt.createComponent(url, Component.Asynchronous)
|
||||
function finalize() {
|
||||
if (comp.status === Component.Ready) {
|
||||
Services.BarWidgetRegistry.register(id, comp, enrichedMeta)
|
||||
var next = ({})
|
||||
for (var k in registeredFirstPartyComponents) next[k] = registeredFirstPartyComponents[k]
|
||||
next[id] = comp
|
||||
registeredFirstPartyComponents = next
|
||||
} else if (comp.status === Component.Error) {
|
||||
console.warn("first-party widget " + id + " failed to load: " + comp.errorString())
|
||||
}
|
||||
}
|
||||
if (comp.status === Component.Loading) comp.statusChanged.connect(finalize)
|
||||
else finalize()
|
||||
}
|
||||
|
||||
function networkCommand() {
|
||||
@@ -1146,11 +1175,22 @@ ShellRoot {
|
||||
readonly property var moduleSettings: root.entrySettings(entry)
|
||||
readonly property string customType: root.customModuleType(entry)
|
||||
readonly property var builtinComponent: customType ? null : root.builtinModuleComponent(moduleName)
|
||||
readonly property string firstPartySource: customType || builtinComponent ? "" : root.firstPartyWidgetSource(moduleName)
|
||||
// Re-evaluate when the registry mutates (Component reference changes,
|
||||
// plugin enabled/disabled, etc.). Reading the `widgets` property creates
|
||||
// the binding dependency — the wrapped function call alone wouldn't.
|
||||
readonly property var registryComponent: {
|
||||
var w = Services.BarWidgetRegistry.widgets
|
||||
if (customType || builtinComponent) return null
|
||||
return w[moduleName] ? w[moduleName].component : null
|
||||
}
|
||||
readonly property bool qmlCustom: customType === "qml"
|
||||
readonly property bool commandCustom: customType === "command"
|
||||
readonly property bool firstParty: firstPartySource !== ""
|
||||
readonly property var activeItem: qmlCustom || firstParty ? qmlLoader.item : componentLoader.item
|
||||
readonly property bool registered: registryComponent !== null
|
||||
readonly property var activeItem: {
|
||||
if (registered) return registryLoader.item
|
||||
if (qmlCustom) return qmlLoader.item
|
||||
return componentLoader.item
|
||||
}
|
||||
|
||||
implicitWidth: activeItem && activeItem.visible ? activeItem.implicitWidth : 0
|
||||
implicitHeight: activeItem && activeItem.visible ? activeItem.implicitHeight : 0
|
||||
@@ -1159,15 +1199,23 @@ ShellRoot {
|
||||
|
||||
Loader {
|
||||
id: componentLoader
|
||||
active: !slot.qmlCustom && !slot.firstParty
|
||||
active: !slot.qmlCustom && !slot.registered
|
||||
sourceComponent: slot.builtinComponent || (slot.commandCustom ? customCommandModuleComponent : emptyModuleComponent)
|
||||
anchors.fill: parent
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: registryLoader
|
||||
active: slot.registered
|
||||
sourceComponent: slot.registered ? slot.registryComponent : null
|
||||
anchors.fill: parent
|
||||
onLoaded: slot.injectProps()
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: qmlLoader
|
||||
active: slot.qmlCustom || slot.firstParty
|
||||
source: slot.qmlCustom ? root.customModuleSource(slot.entry) : (slot.firstParty ? slot.firstPartySource : "")
|
||||
active: slot.qmlCustom
|
||||
source: slot.qmlCustom ? root.customModuleSource(slot.entry) : ""
|
||||
anchors.fill: parent
|
||||
onLoaded: slot.injectProps()
|
||||
}
|
||||
@@ -1175,7 +1223,7 @@ ShellRoot {
|
||||
onModuleSettingsChanged: injectProps()
|
||||
|
||||
function injectProps() {
|
||||
var target = qmlLoader.item
|
||||
var target = registryLoader.item || qmlLoader.item
|
||||
if (!target) return
|
||||
if ("bar" in target) target.bar = root
|
||||
if ("moduleName" in target) target.moduleName = moduleName
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
This is the Quickshell implementation of the Omarchy status bar.
|
||||
|
||||
- `shell.qml` is Omarchy-owned bar engine code. Users should not edit it directly.
|
||||
- `Bar.qml` is Omarchy-owned bar engine code, loaded by the omarchy-shell host. Users should not edit it directly.
|
||||
- `bar-defaults.json` is the Omarchy-owned default layout and module settings.
|
||||
- `widgets/` holds first-party widgets — modular, interactive components shipped with Omarchy.
|
||||
- `common/` holds shared QML helpers (buttons, sliders, popup cards).
|
||||
@@ -11,7 +11,7 @@ This is the Quickshell implementation of the Omarchy status bar.
|
||||
|
||||
## Customizing
|
||||
|
||||
The bar reads `~/.local/share/omarchy/default/quickshell/bar/bar-defaults.json`, then deep-merges `~/.config/omarchy/bar.json` on top of it. Each `layout.{left,center,right}` entry is an object: at minimum `{ "id": "<widget>" }`, plus any inline settings the widget reads.
|
||||
The bar reads `~/.local/share/omarchy/default/quickshell/omarchy-shell/plugins/bar/bar-defaults.json`, then deep-merges `~/.config/omarchy/bar.json` on top of it. Each `layout.{left,center,right}` entry is an object: at minimum `{ "id": "<widget>" }`, plus any inline settings the widget reads.
|
||||
|
||||
Launch the visual editor with `omarchy launch bar-settings` (or run `omarchy-launch-bar-settings`) to reorder widgets, add/remove them, and tweak per-widget options without editing JSON by hand.
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "omarchy.bar",
|
||||
"name": "Bar",
|
||||
"version": "1.0.0",
|
||||
"author": "Omarchy",
|
||||
"description": "Status bar with widgets",
|
||||
"kinds": ["bar"],
|
||||
"activation": "persistent",
|
||||
"entryPoints": { "bar": "Bar.qml" }
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,52 @@
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
|
||||
QtObject {
|
||||
id: registry
|
||||
|
||||
// { widgetId: { component: Component, metadata: var } }
|
||||
property var widgets: ({})
|
||||
property int revision: 0
|
||||
|
||||
signal changed()
|
||||
|
||||
function register(id, component, metadata) {
|
||||
var key = String(id)
|
||||
if (!key) return
|
||||
var next = {}
|
||||
for (var k in widgets) next[k] = widgets[k]
|
||||
next[key] = { component: component, metadata: metadata || {} }
|
||||
widgets = next
|
||||
revision++
|
||||
changed()
|
||||
}
|
||||
|
||||
function unregister(id) {
|
||||
var key = String(id)
|
||||
if (!widgets[key]) return
|
||||
var next = {}
|
||||
for (var k in widgets) if (k !== key) next[k] = widgets[k]
|
||||
widgets = next
|
||||
revision++
|
||||
changed()
|
||||
}
|
||||
|
||||
function componentFor(id) {
|
||||
var entry = widgets[String(id)]
|
||||
return entry ? entry.component : null
|
||||
}
|
||||
|
||||
function metadataFor(id) {
|
||||
var entry = widgets[String(id)]
|
||||
return entry ? entry.metadata : null
|
||||
}
|
||||
|
||||
function availableIds() {
|
||||
return Object.keys(widgets)
|
||||
}
|
||||
|
||||
function has(id) {
|
||||
return widgets[String(id)] !== undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
QtObject {
|
||||
id: registry
|
||||
|
||||
property string home: Quickshell.env("HOME")
|
||||
property string pluginsDir: home + "/.config/omarchy/plugins"
|
||||
property string stateFile: home + "/.config/omarchy/plugins.json"
|
||||
|
||||
// Set by shell.qml at startup so we can also scan bundled first-party plugins.
|
||||
property string firstPartyDir: ""
|
||||
|
||||
// { pluginId: manifest } — manifests have __sourceDir and __isFirstParty stamped in.
|
||||
property var installedPlugins: ({})
|
||||
// { pluginId: { enabled: bool } } — persisted to stateFile.
|
||||
property var pluginStates: ({})
|
||||
property int registryRevision: 0
|
||||
property bool scanning: false
|
||||
|
||||
signal pluginsChanged()
|
||||
signal pluginLoadFailed(string id, string error)
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
function isPlainObject(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function fileUrl(path) {
|
||||
return "file://" + String(path).split("/").map(encodeURIComponent).join("/")
|
||||
}
|
||||
|
||||
function validateManifest(manifest, sourcePath) {
|
||||
if (!isPlainObject(manifest)) {
|
||||
console.warn("PluginRegistry: manifest is not an object at " + sourcePath)
|
||||
return null
|
||||
}
|
||||
if (manifest.schemaVersion !== 1) {
|
||||
console.warn("PluginRegistry: unsupported schemaVersion at " + sourcePath)
|
||||
return null
|
||||
}
|
||||
var required = ["id", "name", "version", "kinds", "entryPoints"]
|
||||
for (var i = 0; i < required.length; i++) {
|
||||
if (manifest[required[i]] === undefined) {
|
||||
console.warn("PluginRegistry: missing required field '" + required[i] + "' at " + sourcePath)
|
||||
return null
|
||||
}
|
||||
}
|
||||
var id = String(manifest.id)
|
||||
if (!id || id.indexOf("/") !== -1 || id.indexOf("..") !== -1 || id.charAt(0) === "/") {
|
||||
console.warn("PluginRegistry: invalid plugin id '" + id + "' at " + sourcePath)
|
||||
return null
|
||||
}
|
||||
if (!Array.isArray(manifest.kinds) || manifest.kinds.length === 0) {
|
||||
console.warn("PluginRegistry: kinds must be a non-empty array at " + sourcePath)
|
||||
return null
|
||||
}
|
||||
if (!isPlainObject(manifest.entryPoints)) {
|
||||
console.warn("PluginRegistry: entryPoints must be an object at " + sourcePath)
|
||||
return null
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
|
||||
function entryPointUrl(manifest, kind) {
|
||||
if (!isPlainObject(manifest)) return ""
|
||||
var ep = manifest.entryPoints ? manifest.entryPoints[kind] : null
|
||||
if (!ep) return ""
|
||||
var dir = manifest.__sourceDir || ""
|
||||
if (!dir) return ""
|
||||
return fileUrl(dir + "/" + ep)
|
||||
}
|
||||
|
||||
function isEnabled(id) {
|
||||
var state = pluginStates[String(id)]
|
||||
return !!(state && state.enabled)
|
||||
}
|
||||
|
||||
function setEnabled(id, value) {
|
||||
var key = String(id)
|
||||
var next = {}
|
||||
for (var k in pluginStates) next[k] = pluginStates[k]
|
||||
next[key] = { enabled: !!value }
|
||||
pluginStates = next
|
||||
persistStates()
|
||||
registryRevision++
|
||||
pluginsChanged()
|
||||
}
|
||||
|
||||
function manifestsOfKind(kind) {
|
||||
var result = []
|
||||
for (var id in installedPlugins) {
|
||||
var m = installedPlugins[id]
|
||||
if (m && Array.isArray(m.kinds) && m.kinds.indexOf(kind) !== -1) result.push(m)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- persistence
|
||||
|
||||
property bool suppressStateReload: false
|
||||
|
||||
function persistStates() {
|
||||
suppressStateReload = true
|
||||
stateFileView.setText(JSON.stringify({ version: 1, states: pluginStates }, null, 2) + "\n")
|
||||
}
|
||||
|
||||
property FileView stateFileView: FileView {
|
||||
path: registry.stateFile
|
||||
watchChanges: true
|
||||
atomicWrites: true
|
||||
printErrors: false
|
||||
onLoaded: {
|
||||
if (registry.suppressStateReload) {
|
||||
registry.suppressStateReload = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
var data = JSON.parse(text() || "{}")
|
||||
registry.pluginStates = (data && data.states) || {}
|
||||
} catch (e) {
|
||||
console.warn("PluginRegistry: bad plugins.json:", e)
|
||||
registry.pluginStates = {}
|
||||
}
|
||||
}
|
||||
onFileChanged: reload()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- scanning
|
||||
|
||||
// Output format produced by the rescan script:
|
||||
// ===<kind>::<absolute-source-dir>===
|
||||
// ... raw manifest.json content ...
|
||||
// === EOM ===
|
||||
// (repeating for every manifest found)
|
||||
function parseScanOutput(text) {
|
||||
var lines = String(text || "").split("\n")
|
||||
var firstParty = {}
|
||||
var thirdParty = {}
|
||||
var currentSource = null
|
||||
var currentKind = null
|
||||
var currentJson = []
|
||||
|
||||
function flush() {
|
||||
if (!currentSource) return
|
||||
var raw = currentJson.join("\n").trim()
|
||||
try {
|
||||
var manifest = JSON.parse(raw)
|
||||
manifest.__sourceDir = currentSource
|
||||
manifest.__isFirstParty = (currentKind === "firstparty")
|
||||
var validated = validateManifest(manifest, currentSource + "/manifest.json")
|
||||
if (validated) {
|
||||
if (currentKind === "firstparty") firstParty[validated.id] = validated
|
||||
else thirdParty[validated.id] = validated
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("PluginRegistry: bad manifest at " + currentSource + ": " + e)
|
||||
}
|
||||
currentSource = null
|
||||
currentKind = null
|
||||
currentJson = []
|
||||
}
|
||||
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i]
|
||||
var startMatch = line.match(/^===([a-z]+)::(.+)===$/)
|
||||
if (startMatch) {
|
||||
flush()
|
||||
currentKind = startMatch[1]
|
||||
currentSource = startMatch[2].replace(/\/$/, "")
|
||||
currentJson = []
|
||||
continue
|
||||
}
|
||||
if (line === "=== EOM ===") {
|
||||
flush()
|
||||
continue
|
||||
}
|
||||
if (currentSource) currentJson.push(line)
|
||||
}
|
||||
flush()
|
||||
|
||||
// Merge defaults into pluginStates: first-party defaults to enabled, third-party to disabled.
|
||||
var nextStates = {}
|
||||
for (var k in pluginStates) nextStates[k] = pluginStates[k]
|
||||
for (var fpid in firstParty) {
|
||||
if (!nextStates[fpid]) nextStates[fpid] = { enabled: true }
|
||||
}
|
||||
for (var tpid in thirdParty) {
|
||||
if (!nextStates[tpid]) nextStates[tpid] = { enabled: false }
|
||||
}
|
||||
|
||||
var merged = {}
|
||||
for (var fk in firstParty) merged[fk] = firstParty[fk]
|
||||
for (var tk in thirdParty) merged[tk] = thirdParty[tk]
|
||||
|
||||
pluginStates = nextStates
|
||||
installedPlugins = merged
|
||||
registryRevision++
|
||||
scanning = false
|
||||
pluginsChanged()
|
||||
}
|
||||
|
||||
property Process scanProcess: Process {
|
||||
onExited: function(exitCode) {
|
||||
var output = scanStdout.text || ""
|
||||
registry.parseScanOutput(output)
|
||||
}
|
||||
stdout: StdioCollector {
|
||||
id: scanStdout
|
||||
waitForEnd: true
|
||||
}
|
||||
}
|
||||
|
||||
property Process initProcess: Process {
|
||||
onExited: registry.rescan()
|
||||
}
|
||||
|
||||
function rescan() {
|
||||
if (scanning) return
|
||||
scanning = true
|
||||
// $0 = first-party dir, $1 = third-party dir. Some bash versions need the explicit -- separator.
|
||||
var script = ""
|
||||
+ "scan() { local dir=\"$1\"; local kind=\"$2\"; "
|
||||
+ " [[ -d \"$dir\" ]] || return 0; "
|
||||
+ " for sub in \"$dir\"/*/; do "
|
||||
+ " [[ -f \"$sub/manifest.json\" ]] || continue; "
|
||||
+ " printf '===%s::%s===\\n' \"$kind\" \"$sub\"; "
|
||||
+ " cat \"$sub/manifest.json\"; "
|
||||
+ " printf '\\n=== EOM ===\\n'; "
|
||||
+ " done; "
|
||||
+ "}; "
|
||||
+ "scan \"$0\" firstparty; "
|
||||
+ "scan \"$1\" thirdparty"
|
||||
scanProcess.command = ["bash", "-lc", script, registry.firstPartyDir, registry.pluginsDir]
|
||||
scanProcess.running = true
|
||||
}
|
||||
|
||||
function ensureUserDir() {
|
||||
initProcess.command = ["bash", "-lc", "mkdir -p \"$0\"", registry.pluginsDir]
|
||||
initProcess.running = true
|
||||
}
|
||||
|
||||
Component.onCompleted: ensureUserDir()
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module omarchy.services
|
||||
singleton PluginRegistry 1.0 PluginRegistry.qml
|
||||
singleton BarWidgetRegistry 1.0 BarWidgetRegistry.qml
|
||||
@@ -0,0 +1,160 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
import "plugins/bar"
|
||||
import "services" as Services
|
||||
|
||||
ShellRoot {
|
||||
id: shell
|
||||
|
||||
property string home: Quickshell.env("HOME")
|
||||
|
||||
// The omarchy-shell host is the long-running entry point. Plugins live in
|
||||
// sibling directories under plugins/. Most child code (the bar, future
|
||||
// plugins) accept omarchyPath as a required property; resolve it once here
|
||||
// from the shellDir so we don't depend on OMARCHY_PATH being set in the env.
|
||||
function deriveOmarchyPath() {
|
||||
var env = Quickshell.env("OMARCHY_PATH")
|
||||
if (env) return env
|
||||
var dir = String(Quickshell.shellDir || "")
|
||||
if (dir.indexOf("/default/quickshell/omarchy-shell") !== -1)
|
||||
return dir.substring(0, dir.indexOf("/default/quickshell/omarchy-shell"))
|
||||
return home + "/.local/share/omarchy"
|
||||
}
|
||||
property string omarchyPath: deriveOmarchyPath()
|
||||
readonly property string firstPartyPluginsDir: omarchyPath + "/default/quickshell/omarchy-shell/plugins"
|
||||
|
||||
Component.onCompleted: {
|
||||
console.log("omarchy-shell paths",
|
||||
"omarchyPath=" + shell.omarchyPath,
|
||||
"shellDir=" + Quickshell.shellDir,
|
||||
"firstPartyPluginsDir=" + shell.firstPartyPluginsDir)
|
||||
Services.PluginRegistry.firstPartyDir = shell.firstPartyPluginsDir
|
||||
// PluginRegistry.ensureUserDir() runs in its own Component.onCompleted and
|
||||
// chains rescan() once the directory exists. We also kick a scan here in
|
||||
// case the user dir already existed at startup.
|
||||
Services.PluginRegistry.rescan()
|
||||
}
|
||||
|
||||
Bar {
|
||||
id: bar
|
||||
omarchyPath: shell.omarchyPath
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- plugin loader
|
||||
|
||||
// Mirror plugin registry state into BarWidgetRegistry whenever it changes.
|
||||
// Each enabled plugin with kind "bar-widget" gets a Component created from
|
||||
// its manifest entry point and registered under the id "plugin:<id>".
|
||||
Connections {
|
||||
target: Services.PluginRegistry
|
||||
function onPluginsChanged() { shell.syncPluginWidgets() }
|
||||
}
|
||||
|
||||
property var pluginWidgetComponents: ({})
|
||||
|
||||
function syncPluginWidgets() {
|
||||
var plugins = Services.PluginRegistry.installedPlugins
|
||||
var seen = ({})
|
||||
|
||||
for (var pluginId in plugins) {
|
||||
var manifest = plugins[pluginId]
|
||||
if (!manifest || !manifest.kinds || manifest.kinds.indexOf("bar-widget") === -1) continue
|
||||
if (!Services.PluginRegistry.isEnabled(pluginId)) continue
|
||||
|
||||
var registryKey = "plugin:" + manifest.id
|
||||
seen[registryKey] = true
|
||||
|
||||
// Already loaded with matching source — leave it alone.
|
||||
var existing = pluginWidgetComponents[registryKey]
|
||||
var url = Services.PluginRegistry.entryPointUrl(manifest, "barWidget")
|
||||
if (!url) {
|
||||
console.warn("Plugin " + manifest.id + " has no barWidget entry point")
|
||||
continue
|
||||
}
|
||||
if (existing && existing.url === url && Services.BarWidgetRegistry.has(registryKey)) continue
|
||||
|
||||
var meta = manifest.barWidget || {}
|
||||
meta = {
|
||||
displayName: meta.displayName || manifest.name,
|
||||
description: meta.description || manifest.description,
|
||||
category: meta.category || "Plugin",
|
||||
allowMultiple: meta.allowMultiple === true,
|
||||
defaults: meta.defaults || {},
|
||||
schema: meta.schema || [],
|
||||
pluginId: manifest.id,
|
||||
source: "plugin"
|
||||
}
|
||||
|
||||
loadPluginWidget(registryKey, url, meta)
|
||||
}
|
||||
|
||||
// Drop registrations for plugins that are no longer present or enabled.
|
||||
var allIds = Services.BarWidgetRegistry.availableIds()
|
||||
for (var i = 0; i < allIds.length; i++) {
|
||||
var id = allIds[i]
|
||||
if (id.indexOf("plugin:") !== 0) continue
|
||||
if (!seen[id]) {
|
||||
Services.BarWidgetRegistry.unregister(id)
|
||||
var next = ({})
|
||||
for (var k in pluginWidgetComponents) if (k !== id) next[k] = pluginWidgetComponents[k]
|
||||
pluginWidgetComponents = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadPluginWidget(registryKey, url, meta) {
|
||||
var comp = Qt.createComponent(url, Component.Asynchronous)
|
||||
function finalize() {
|
||||
if (comp.status === Component.Ready) {
|
||||
Services.BarWidgetRegistry.register(registryKey, comp, meta)
|
||||
var next = ({})
|
||||
for (var k in pluginWidgetComponents) next[k] = pluginWidgetComponents[k]
|
||||
next[registryKey] = { url: url, component: comp }
|
||||
pluginWidgetComponents = next
|
||||
} else if (comp.status === Component.Error) {
|
||||
console.warn("Plugin widget " + registryKey + " failed: " + comp.errorString())
|
||||
Services.PluginRegistry.pluginLoadFailed(registryKey, comp.errorString())
|
||||
}
|
||||
}
|
||||
if (comp.status === Component.Loading) {
|
||||
comp.statusChanged.connect(finalize)
|
||||
} else {
|
||||
finalize()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- shell IPC
|
||||
|
||||
IpcHandler {
|
||||
target: "shell"
|
||||
|
||||
function ping(): string {
|
||||
return "ok"
|
||||
}
|
||||
|
||||
function rescanPlugins(): void {
|
||||
Services.PluginRegistry.rescan()
|
||||
}
|
||||
|
||||
function setPluginEnabled(id: string, enabled: string): void {
|
||||
Services.PluginRegistry.setEnabled(id, enabled === "true")
|
||||
}
|
||||
|
||||
function listPlugins(): string {
|
||||
var out = []
|
||||
var plugins = Services.PluginRegistry.installedPlugins
|
||||
for (var id in plugins) {
|
||||
out.push({
|
||||
id: id,
|
||||
name: plugins[id].name,
|
||||
kinds: plugins[id].kinds,
|
||||
enabled: Services.PluginRegistry.isEnabled(id),
|
||||
firstParty: !!plugins[id].__isFirstParty
|
||||
})
|
||||
}
|
||||
return JSON.stringify(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user