mirror of
https://github.com/noctalia-dev/noctalia-shell.git
synced 2026-05-11 17:08:27 +08:00
621 lines
19 KiB
QML
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
|
|
};
|
|
}
|
|
}
|