Add omarchy-shell with a plugin registry
This commit is contained in:
@@ -252,7 +252,7 @@ stop_screenrecording() {
|
||||
toggle_screenrecording_indicator() {
|
||||
pkill -RTMIN+8 waybar
|
||||
OMARCHY_PATH=${OMARCHY_PATH:-$HOME/.local/share/omarchy}
|
||||
quickshell ipc -p "$OMARCHY_PATH/default/quickshell/bar" call bar refreshScreenRecording >/dev/null 2>&1 || true
|
||||
quickshell ipc -p "$OMARCHY_PATH/default/quickshell/omarchy-shell" call bar refreshScreenRecording >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
screenrecording_active() {
|
||||
|
||||
@@ -56,7 +56,7 @@ PY
|
||||
|
||||
omarchy-restart-waybar
|
||||
OMARCHY_PATH=${OMARCHY_PATH:-$HOME/.local/share/omarchy}
|
||||
if quickshell list -p "$OMARCHY_PATH/default/quickshell/bar" 2>/dev/null | grep -q '^Instance '; then
|
||||
if quickshell list -p "$OMARCHY_PATH/default/quickshell/omarchy-shell" 2>/dev/null | grep -q '^Instance '; then
|
||||
omarchy-restart-bar
|
||||
fi
|
||||
omarchy-restart-swayosd
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# omarchy:examples=omarchy restart bar
|
||||
|
||||
OMARCHY_PATH=${OMARCHY_PATH:-$HOME/.local/share/omarchy}
|
||||
CONFIG_DIR="$OMARCHY_PATH/default/quickshell/bar"
|
||||
CONFIG_DIR="$OMARCHY_PATH/default/quickshell/omarchy-shell"
|
||||
|
||||
quickshell kill -p "$CONFIG_DIR" >/dev/null 2>&1 || true
|
||||
setsid uwsm-app -- env OMARCHY_PATH="$OMARCHY_PATH" quickshell -p "$CONFIG_DIR" >/dev/null 2>&1 &
|
||||
|
||||
@@ -54,7 +54,7 @@ if pgrep -x waybar >/dev/null; then
|
||||
omarchy-restart-waybar
|
||||
fi
|
||||
OMARCHY_PATH=${OMARCHY_PATH:-$HOME/.local/share/omarchy}
|
||||
if quickshell list -p "$OMARCHY_PATH/default/quickshell/bar" 2>/dev/null | grep -q '^Instance '; then
|
||||
if quickshell list -p "$OMARCHY_PATH/default/quickshell/omarchy-shell" 2>/dev/null | grep -q '^Instance '; then
|
||||
omarchy-restart-bar
|
||||
fi
|
||||
omarchy-restart-swayosd
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# omarchy:examples=omarchy toggle bar
|
||||
|
||||
OMARCHY_PATH=${OMARCHY_PATH:-$HOME/.local/share/omarchy}
|
||||
CONFIG_DIR="$OMARCHY_PATH/default/quickshell/bar"
|
||||
CONFIG_DIR="$OMARCHY_PATH/default/quickshell/omarchy-shell"
|
||||
|
||||
omarchy-toggle bar-off
|
||||
|
||||
|
||||
@@ -12,4 +12,4 @@ fi
|
||||
|
||||
pkill -RTMIN+9 waybar
|
||||
OMARCHY_PATH=${OMARCHY_PATH:-$HOME/.local/share/omarchy}
|
||||
quickshell ipc -p "$OMARCHY_PATH/default/quickshell/bar" call bar refreshIndicators >/dev/null 2>&1 || true
|
||||
quickshell ipc -p "$OMARCHY_PATH/default/quickshell/omarchy-shell" call bar refreshIndicators >/dev/null 2>&1 || true
|
||||
|
||||
@@ -12,4 +12,4 @@ fi
|
||||
|
||||
pkill -RTMIN+10 waybar
|
||||
OMARCHY_PATH=${OMARCHY_PATH:-$HOME/.local/share/omarchy}
|
||||
quickshell ipc -p "$OMARCHY_PATH/default/quickshell/bar" call bar refreshIndicators >/dev/null 2>&1 || true
|
||||
quickshell ipc -p "$OMARCHY_PATH/default/quickshell/omarchy-shell" call bar refreshIndicators >/dev/null 2>&1 || true
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
|
||||
pkill -RTMIN+7 waybar
|
||||
OMARCHY_PATH=${OMARCHY_PATH:-$HOME/.local/share/omarchy}
|
||||
quickshell ipc -p "$OMARCHY_PATH/default/quickshell/bar" call bar refreshUpdate >/dev/null 2>&1 || true
|
||||
quickshell ipc -p "$OMARCHY_PATH/default/quickshell/omarchy-shell" call bar refreshUpdate >/dev/null 2>&1 || true
|
||||
exit 0
|
||||
|
||||
@@ -22,7 +22,7 @@ if gum confirm "Install Voxtype + AI model (~150MB) to enable dictation?"; then
|
||||
|
||||
omarchy-restart-waybar
|
||||
OMARCHY_PATH=${OMARCHY_PATH:-$HOME/.local/share/omarchy}
|
||||
if quickshell list -p "$OMARCHY_PATH/default/quickshell/bar" 2>/dev/null | grep -q '^Instance '; then
|
||||
if quickshell list -p "$OMARCHY_PATH/default/quickshell/omarchy-shell" 2>/dev/null | grep -q '^Instance '; then
|
||||
omarchy-restart-bar
|
||||
fi
|
||||
notify-send " Voxtype Dictation Ready" "Hold F9 to dictate (or toggle with Super + Ctrl + X)." -t 10000
|
||||
|
||||
@@ -7,6 +7,6 @@ set -e
|
||||
omarchy-launch-floating-terminal-with-presentation "voxtype setup model"
|
||||
omarchy-restart-waybar
|
||||
OMARCHY_PATH=${OMARCHY_PATH:-$HOME/.local/share/omarchy}
|
||||
if quickshell list -p "$OMARCHY_PATH/default/quickshell/bar" 2>/dev/null | grep -q '^Instance '; then
|
||||
if quickshell list -p "$OMARCHY_PATH/default/quickshell/omarchy-shell" 2>/dev/null | grep -q '^Instance '; then
|
||||
omarchy-restart-bar
|
||||
fi
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
hl.on("hyprland.start", function()
|
||||
hl.exec_cmd("uwsm-app -- hypridle")
|
||||
hl.exec_cmd("uwsm-app -- mako")
|
||||
hl.exec_cmd("! omarchy-toggle-enabled bar-off && uwsm-app -- env OMARCHY_PATH=$HOME/.local/share/omarchy quickshell -p $HOME/.local/share/omarchy/default/quickshell/bar")
|
||||
hl.exec_cmd("! omarchy-toggle-enabled bar-off && uwsm-app -- env OMARCHY_PATH=$HOME/.local/share/omarchy quickshell -p $HOME/.local/share/omarchy/default/quickshell/omarchy-shell")
|
||||
hl.exec_cmd("uwsm-app -- fcitx5 --disable notificationitem")
|
||||
hl.exec_cmd("uwsm-app -- swaybg -i ~/.config/omarchy/current/background -m fill")
|
||||
hl.exec_cmd("/usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -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