fedora
This commit is contained in:
@@ -0,0 +1,620 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user