Files
noctalia-shell/Services/Noctalia/PluginRegistry.qml
T

621 lines
19 KiB
QML

pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import "../../Helpers/sha256.js" as Crypto
import qs.Commons
Singleton {
id: root
readonly property string pluginsDir: Settings.configDir + "plugins"
readonly property string pluginsFile: Settings.configDir + "plugins.json"
readonly property int currentVersion: 2
// Main source URL - plugins from this source keep plain IDs
readonly property string mainSourceUrl: "https://github.com/noctalia-dev/noctalia-plugins"
Component.onCompleted: {
ensurePluginsDirectory();
ensurePluginsFile();
}
// Generate a short hash (6 characters) from a source URL
function generateSourceHash(sourceUrl) {
var hash = Crypto.sha256(sourceUrl);
return hash.substring(0, 6);
}
// Check if a source is the main Noctalia plugins repository
function isMainSource(sourceUrl) {
return sourceUrl === root.mainSourceUrl;
}
// Generate composite key: plain ID for official, "hash:id" for custom
function generateCompositeKey(pluginId, sourceUrl) {
if (!sourceUrl || isMainSource(sourceUrl)) {
return pluginId;
}
var hash = generateSourceHash(sourceUrl);
return hash + ":" + pluginId;
}
// Parse composite key back to components
function parseCompositeKey(compositeKey) {
var colonIndex = compositeKey.indexOf(":");
// If no colon or colon is after position 6 (hash length), it's a plain ID
if (colonIndex === -1 || colonIndex > 6) {
return {
sourceHash: null,
pluginId: compositeKey,
isOfficial: true
};
}
// Has hash prefix (custom source plugin)
return {
sourceHash: compositeKey.substring(0, colonIndex),
pluginId: compositeKey.substring(colonIndex + 1),
isOfficial: false
};
}
// Get source name by URL
function getSourceNameByUrl(sourceUrl) {
for (var i = 0; i < root.pluginSources.length; i++) {
if (root.pluginSources[i].url === sourceUrl) {
return root.pluginSources[i].name;
}
}
return null;
}
// Get source name by hash
function getSourceNameByHash(hash) {
for (var i = 0; i < root.pluginSources.length; i++) {
if (generateSourceHash(root.pluginSources[i].url) === hash) {
return root.pluginSources[i].name;
}
}
return null;
}
// Get source URL from plugin state
function getPluginSourceUrl(compositeKey) {
var state = root.pluginStates[compositeKey];
return state?.sourceUrl || root.mainSourceUrl;
}
// Signals
signal pluginsChanged
// In-memory plugin cache (populated by scanning disk)
property var installedPlugins: ({}) // { pluginId: manifest }
property var pluginStates: ({}) // { pluginId: { enabled: bool } }
property var pluginSources: [] // Array of { name, url }
property var pluginLoadVersions: ({}) // { pluginId: versionNumber } - for cache busting
// Track async loading
property int pendingManifests: 0
// File storage (minimal - only states and sources)
property FileView pluginsFileView: FileView {
id: pluginsFileView
path: root.pluginsFile
adapter: JsonAdapter {
id: adapter
property int version: root.currentVersion
property var states: ({})
property list<var> sources: []
}
onLoaded: {
Logger.i("PluginRegistry", "Loaded plugin states from:", path);
root.pluginStates = adapter.states || {};
root.pluginSources = adapter.sources || [];
// Ensure default repo is in sources
if (root.pluginSources.length === 0) {
root.pluginSources = [
{
"name": "Noctalia Plugins",
"url": "https://github.com/noctalia-dev/noctalia-plugins",
"enabled": true
}
];
root.save();
}
// Migrate from v1 to v2 (add sourceUrl to states)
root.migratePluginData();
// Scan plugin folder to discover installed plugins
scanPluginFolder();
}
onLoadFailed: function (error) {
Logger.w("PluginRegistry", "Failed to load plugins.json, will create it:", error);
// Initialize defaults and continue
root.pluginStates = {};
root.pluginSources = [
{
"name": "Noctalia Plugins",
"url": "https://github.com/noctalia-dev/noctalia-plugins",
"enabled": true
}
];
// Scan for installed plugins
root.scanPluginFolder();
}
}
function init() {
Logger.d("PluginRegistry", "Initialized");
// Force instantiation of PluginService to set up signal listener
PluginService.initialized;
}
// Migrate plugin data from older versions
function migratePluginData() {
var needsSave = false;
// Migration v1 -> v2: add sourceUrl to states
for (var pluginId in root.pluginStates) {
if (root.pluginStates[pluginId].sourceUrl === undefined) {
Logger.i("PluginRegistry", "Migrating plugin data to v2 (adding sourceUrl)");
var newStates = {};
for (var id in root.pluginStates) {
// For v1 -> v2 migration, we assume plugins are from main source
// Custom plugins installed before this feature need to be reinstalled
newStates[id] = {
enabled: root.pluginStates[id].enabled,
sourceUrl: root.mainSourceUrl
};
}
root.pluginStates = newStates;
needsSave = true;
break;
}
}
// Migration: rename "Official Noctalia Plugins" -> "Noctalia Plugins"
var newSources = [];
var sourcesChanged = false;
for (var i = 0; i < root.pluginSources.length; i++) {
var source = root.pluginSources[i];
if (source.name === "Official Noctalia Plugins") {
newSources.push({
name: "Noctalia Plugins",
url: source.url,
enabled: source.enabled
});
sourcesChanged = true;
Logger.i("PluginRegistry", "Migrating source name: 'Official Noctalia Plugins' -> 'Noctalia Plugins'");
} else {
newSources.push(source);
}
}
if (sourcesChanged) {
root.pluginSources = newSources;
needsSave = true;
}
if (needsSave) {
root.save();
Logger.i("PluginRegistry", "Migration complete");
}
}
// Ensure plugins directory exists
function ensurePluginsDirectory() {
var mkdirProcess = Qt.createQmlObject(`
import QtQuick
import Quickshell.Io
Process {
command: ["mkdir", "-p", "${root.pluginsDir}"]
}
`, root, "MkdirPlugins");
mkdirProcess.exited.connect(function (exitCode) {
if (exitCode === 0) {
Logger.d("PluginRegistry", "Plugins directory ensured:", root.pluginsDir);
} else {
Logger.e("PluginRegistry", "Failed to create plugins directory");
}
mkdirProcess.destroy();
});
mkdirProcess.running = true;
}
// Ensure plugins.json exists (create minimal one if it doesn't)
function ensurePluginsFile() {
var checkProcess = Qt.createQmlObject(`
import QtQuick
import Quickshell.Io
Process {
command: ["sh", "-c", "test -f '${root.pluginsFile}' || echo '{\\"version\\":${root.currentVersion},\\"states\\":{},\\"sources\\":[]}' > '${root.pluginsFile}'"]
}
`, root, "EnsurePluginsFile");
checkProcess.exited.connect(function (exitCode) {
if (exitCode === 0) {
Logger.d("PluginRegistry", "Plugins file ensured:", root.pluginsFile);
}
checkProcess.destroy();
});
checkProcess.running = true;
}
// Scan plugin folder to discover installed plugins (single process reads all manifests)
function scanPluginFolder() {
Logger.i("PluginRegistry", "Scanning plugin folder:", root.pluginsDir);
var scanProcess = Qt.createQmlObject(`
import QtQuick
import Quickshell.Io
Process {
command: ["sh", "-c", "for d in '${root.pluginsDir}'/*/; do [ -d \\"$d\\" ] || continue; [ -f \\"$d/manifest.json\\" ] || continue; echo \\"@@PLUGIN@@$(basename \\"$d\\")\\" ; cat \\"$d/manifest.json\\" ; done"]
stdout: StdioCollector {}
running: true
}
`, root, "ScanAllPlugins");
scanProcess.exited.connect(function (exitCode) {
var output = String(scanProcess.stdout.text || "");
var sections = output.split("@@PLUGIN@@");
var loadedCount = 0;
for (var i = 1; i < sections.length; i++) {
var section = sections[i];
var newlineIdx = section.indexOf('\n');
if (newlineIdx === -1)
continue;
var pluginId = section.substring(0, newlineIdx).trim();
var manifestJson = section.substring(newlineIdx + 1).trim();
if (!pluginId || !manifestJson)
continue;
try {
var manifest = JSON.parse(manifestJson);
var validation = validateManifest(manifest);
if (validation.valid) {
manifest.compositeKey = pluginId;
root.installedPlugins[pluginId] = manifest;
Logger.i("PluginRegistry", "Loaded plugin:", pluginId, "-", manifest.name);
if (!root.pluginStates[pluginId]) {
root.pluginStates[pluginId] = {
enabled: false
};
}
loadedCount++;
} else {
Logger.e("PluginRegistry", "Invalid manifest for", pluginId + ":", validation.error);
}
} catch (e) {
Logger.e("PluginRegistry", "Failed to parse manifest for", pluginId + ":", e.toString());
}
}
Logger.i("PluginRegistry", "All plugin manifests loaded. Total plugins:", loadedCount);
root.pluginsChanged();
scanProcess.destroy();
});
}
// Load a single plugin's manifest from disk
function loadPluginManifest(pluginId) {
var manifestPath = root.pluginsDir + "/" + pluginId + "/manifest.json";
var catProcess = Qt.createQmlObject(`
import QtQuick
import Quickshell.Io
Process {
command: ["cat", "${manifestPath}"]
stdout: StdioCollector {}
running: true
}
`, root, "LoadManifest_" + pluginId);
catProcess.exited.connect(function (exitCode) {
var output = String(catProcess.stdout.text || "");
if (exitCode === 0 && output) {
try {
var manifest = JSON.parse(output);
var validation = validateManifest(manifest);
if (validation.valid) {
manifest.compositeKey = pluginId;
root.installedPlugins[pluginId] = manifest;
Logger.i("PluginRegistry", "Loaded plugin:", pluginId, "-", manifest.name);
// Ensure state exists (default to disabled)
if (!root.pluginStates[pluginId]) {
root.pluginStates[pluginId] = {
enabled: false
};
}
} else {
Logger.e("PluginRegistry", "Invalid manifest for", pluginId + ":", validation.error);
}
} catch (e) {
Logger.e("PluginRegistry", "Failed to parse manifest for", pluginId + ":", e.toString());
}
} else {
Logger.d("PluginRegistry", "No manifest found for:", pluginId);
}
// Decrement pending count and emit signal when all are done
root.pendingManifests--;
Logger.d("PluginRegistry", "Pending manifests remaining:", root.pendingManifests);
if (root.pendingManifests === 0) {
var installedIds = Object.keys(root.installedPlugins);
Logger.i("PluginRegistry", "All plugin manifests loaded. Total plugins:", installedIds.length);
Logger.d("PluginRegistry", "Installed plugin IDs:", JSON.stringify(installedIds));
root.pluginsChanged();
}
catProcess.destroy();
});
}
// Save registry to disk (only states and sources)
function save() {
adapter.version = root.currentVersion;
adapter.states = root.pluginStates;
adapter.sources = root.pluginSources;
Qt.callLater(() => {
pluginsFileView.writeAdapter();
Logger.d("PluginRegistry", "Plugin states saved");
});
}
// Enable/disable a plugin
function setPluginEnabled(pluginId, enabled) {
if (!root.installedPlugins[pluginId]) {
Logger.w("PluginRegistry", "Cannot set state for non-existent plugin:", pluginId);
return;
}
if (!root.pluginStates[pluginId]) {
root.pluginStates[pluginId] = {
enabled: enabled
};
} else {
root.pluginStates[pluginId].enabled = enabled;
}
save();
root.pluginsChanged();
Logger.i("PluginRegistry", "Plugin", pluginId, enabled ? "enabled" : "disabled");
}
// Check if plugin is enabled
function isPluginEnabled(pluginId) {
return root.pluginStates[pluginId]?.enabled || false;
}
// Check if plugin is downloaded/installed
function isPluginDownloaded(pluginId) {
return pluginId in root.installedPlugins;
}
// Get plugin manifest from cache
function getPluginManifest(pluginId) {
return root.installedPlugins[pluginId] || null;
}
// Get ALL installed plugin IDs (discovered from disk)
function getAllInstalledPluginIds() {
return Object.keys(root.installedPlugins);
}
// Get enabled plugin IDs only
function getEnabledPluginIds() {
return Object.keys(root.pluginStates).filter(function (id) {
return root.pluginStates[id].enabled === true;
});
}
// Register a plugin (add to installed plugins after download)
// sourceUrl is required for new plugins to generate composite key
function registerPlugin(manifest, sourceUrl) {
var compositeKey = generateCompositeKey(manifest.id, sourceUrl);
manifest.compositeKey = compositeKey;
root.installedPlugins[compositeKey] = manifest;
// Ensure state exists (default to disabled, store sourceUrl)
if (!root.pluginStates[compositeKey]) {
root.pluginStates[compositeKey] = {
enabled: false,
sourceUrl: sourceUrl || root.mainSourceUrl
};
} else {
// Preserve enabled state but update sourceUrl
root.pluginStates[compositeKey].sourceUrl = sourceUrl || root.mainSourceUrl;
}
save();
root.pluginsChanged();
Logger.i("PluginRegistry", "Registered plugin:", compositeKey);
return compositeKey;
}
// Unregister a plugin (remove from registry)
function unregisterPlugin(pluginId) {
delete root.pluginStates[pluginId];
delete root.installedPlugins[pluginId];
save();
root.pluginsChanged();
Logger.i("PluginRegistry", "Unregistered plugin:", pluginId);
}
// Increment plugin load version (for cache busting when plugin is updated)
function incrementPluginLoadVersion(pluginId) {
var versions = Object.assign({}, root.pluginLoadVersions);
versions[pluginId] = (versions[pluginId] || 0) + 1;
root.pluginLoadVersions = versions;
Logger.d("PluginRegistry", "Incremented load version for", pluginId, "to", versions[pluginId]);
return versions[pluginId];
}
// Remove plugin state (call after deleting plugin folder)
function removePluginState(pluginId) {
delete root.pluginStates[pluginId];
delete root.installedPlugins[pluginId];
save();
root.pluginsChanged();
Logger.i("PluginRegistry", "Removed plugin state:", pluginId);
}
// Add a plugin source
function addPluginSource(name, url) {
for (var i = 0; i < root.pluginSources.length; i++) {
if (root.pluginSources[i].url === url) {
Logger.w("PluginRegistry", "Source already exists:", url);
return false;
}
}
// Create a new array to trigger property change notification
var newSources = root.pluginSources.slice();
newSources.push({
name: name,
url: url,
enabled: true
});
root.pluginSources = newSources;
save();
Logger.i("PluginRegistry", "Added plugin source:", name);
return true;
}
// Remove a plugin source
function removePluginSource(url) {
var newSources = [];
for (var i = 0; i < root.pluginSources.length; i++) {
if (root.pluginSources[i].url !== url) {
newSources.push(root.pluginSources[i]);
}
}
if (newSources.length === root.pluginSources.length) {
Logger.w("PluginRegistry", "Source not found:", url);
return false;
}
root.pluginSources = newSources;
save();
Logger.i("PluginRegistry", "Removed plugin source:", url);
return true;
}
// Set source enabled/disabled state
function setSourceEnabled(url, enabled) {
var newSources = [];
var found = false;
for (var i = 0; i < root.pluginSources.length; i++) {
if (root.pluginSources[i].url === url) {
newSources.push({
name: root.pluginSources[i].name,
url: root.pluginSources[i].url,
enabled: enabled
});
found = true;
} else {
newSources.push(root.pluginSources[i]);
}
}
if (!found) {
Logger.w("PluginRegistry", "Source not found:", url);
return false;
}
root.pluginSources = newSources;
save();
Logger.i("PluginRegistry", "Source", url, enabled ? "enabled" : "disabled");
return true;
}
// Check if source is enabled
function isSourceEnabled(url) {
for (var i = 0; i < root.pluginSources.length; i++) {
if (root.pluginSources[i].url === url) {
return root.pluginSources[i].enabled !== false; // Default to true if not set
}
}
return false;
}
// Get enabled sources only
function getEnabledSources() {
var enabledSources = [];
for (var i = 0; i < root.pluginSources.length; i++) {
if (root.pluginSources[i].enabled !== false) {
enabledSources.push(root.pluginSources[i]);
}
}
return enabledSources;
}
// Get plugin directory path
function getPluginDir(pluginId) {
return root.pluginsDir + "/" + pluginId;
}
// Get plugin settings file path
function getPluginSettingsFile(pluginId) {
return getPluginDir(pluginId) + "/settings.json";
}
// Validate manifest
function validateManifest(manifest) {
if (!manifest) {
return {
valid: false,
error: "Manifest is null or undefined"
};
}
var required = ["id", "name", "version", "author", "description"];
for (var i = 0; i < required.length; i++) {
if (!manifest[required[i]]) {
return {
valid: false,
error: "Missing required field: " + required[i]
};
}
}
if (!manifest.entryPoints) {
return {
valid: false,
error: "Missing 'entryPoints' field"
};
}
// Check version format (simple x.y.z check)
var versionRegex = /^\d+\.\d+\.\d+$/;
if (!versionRegex.test(manifest.version)) {
return {
valid: false,
error: "Invalid version format (must be x.y.z)"
};
}
return {
valid: true,
error: null
};
}
}