This commit is contained in:
2026-05-28 21:45:21 +02:00
parent 653439ac24
commit a356a3d369
610 changed files with 205679 additions and 0 deletions
@@ -0,0 +1,336 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services.UI
// GitHub API logic for contributors
Singleton {
id: root
property string githubDataFile: Quickshell.env("NOCTALIA_GITHUB_FILE") || (Settings.cacheDir + "github.json")
property int githubUpdateFrequency: 60 * 60 // 1 hour expressed in seconds
property bool isFetchingData: false
readonly property alias data: adapter // Used to access via GitHubService.data.xxx.yyy
// Public properties for easy access
property string latestVersion: I18n.tr("common.unknown")
property string latestQSVersion: I18n.tr("common.unknown")
property var contributors: []
// Avatar caching properties (simplified - uses ImageCacheService)
property var cachedAvatars: ({}) // username → file:// path
property bool avatarsCached: false // Track if we've already processed avatars
property bool isInitialized: false
FileView {
id: githubDataFileView
path: githubDataFile
printErrors: false
watchChanges: false // Disable to prevent reload on our own writes
Component.onCompleted: {
// Data loading handled by FileView onLoaded
}
onLoaded: {
if (!root.isInitialized) {
root.isInitialized = true;
loadFromCache();
}
}
onLoadFailed: function (error) {
if (error.toString().includes("No such file") || error === 2) {
// No cache file exists, fetch fresh data
root.isInitialized = true;
fetchFromGitHub();
}
}
JsonAdapter {
id: adapter
property string version: I18n.tr("common.unknown")
property string qsVersion: I18n.tr("common.unknown")
property var contributors: []
property real timestamp: 0
}
}
// --------------------------------
function init() {
Logger.i("GitHub", "Service started");
// FileView will handle loading automatically via onLoaded
}
// --------------------------------
function loadFromCache() {
const now = Time.timestamp;
var needsRefetch = false;
Logger.i("GitHub", "Checking cache - timestamp:", data.timestamp, "now:", now, "age:", data.timestamp ? Math.round((now - data.timestamp) / 60) : "N/A", "minutes");
if (!data.timestamp || (now >= data.timestamp + githubUpdateFrequency)) {
needsRefetch = true;
Logger.i("GitHub", "Cache expired or missing, scheduling fetch (update frequency:", Math.round(githubUpdateFrequency / 60), "minutes)");
} else {
Logger.i("GitHub", "Cache is fresh, using cached data (age:", Math.round((now - data.timestamp) / 60) + " minutes)");
}
if (data.version) {
root.latestVersion = data.version;
}
if (data.qsVersion) {
root.latestQSVersion = data.qsVersion;
}
if (data.contributors && data.contributors.length > 0) {
root.contributors = data.contributors;
Logger.d("GitHub", "Loaded", data.contributors.length, "contributors from cache");
}
if (needsRefetch) {
fetchFromGitHub();
}
}
// --------------------------------
function fetchFromGitHub() {
if (isFetchingData) {
Logger.d("GitHub", "GitHub data is still fetching");
return;
}
isFetchingData = true;
versionProcess.running = true;
qsVersionProcess.running = true;
contributorsProcess.running = true;
}
// --------------------------------
function saveData() {
data.timestamp = Time.timestamp;
Logger.d("GitHub", "Saving data to cache file:", githubDataFile, "with timestamp:", data.timestamp);
Logger.d("GitHub", "Data to save - version:", data.version, "qsVersion:", data.qsVersion, "contributors:", data.contributors.length);
// Ensure cache directory exists
Quickshell.execDetached(["mkdir", "-p", Settings.cacheDir]);
try {
// Write immediately instead of Qt.callLater to ensure it completes
githubDataFileView.writeAdapter();
Logger.d("GitHub", "Cache file written successfully");
} catch (error) {
Logger.e("GitHub", "Failed to write cache file:", error);
}
}
// --------------------------------
function checkAndSaveData() {
// Only save when all processes are finished
if (!versionProcess.running && !qsVersionProcess.running && !contributorsProcess.running) {
root.isFetchingData = false;
// Check results
var anySucceeded = versionProcess.fetchSucceeded || qsVersionProcess.fetchSucceeded || contributorsProcess.fetchSucceeded;
var wasRateLimited = versionProcess.wasRateLimited || qsVersionProcess.wasRateLimited || contributorsProcess.wasRateLimited;
if (anySucceeded) {
root.saveData();
Logger.d("GitHub", "Successfully fetched data from GitHub");
} else if (wasRateLimited) {
root.saveData();
Logger.w("GitHub", "API rate limited - using cached data (retry in", Math.round(githubUpdateFrequency / 60), "minutes)");
} else {
Logger.w("GitHub", "API request failed - using cached data without updating timestamp");
}
// Reset fetch flags for next time
versionProcess.fetchSucceeded = false;
versionProcess.wasRateLimited = false;
qsVersionProcess.fetchSucceeded = false;
qsVersionProcess.wasRateLimited = false;
contributorsProcess.fetchSucceeded = false;
contributorsProcess.wasRateLimited = false;
}
}
// --------------------------------
function resetCache() {
data.version = I18n.tr("common.unknown");
data.qsVersion = I18n.tr("common.unknown");
data.contributors = [];
data.timestamp = 0;
// Try to fetch immediately
fetchFromGitHub();
}
// --------------------------------
// Avatar Caching Functions (simplified - uses ImageCacheService)
// --------------------------------
function getAvatarPath(username) {
return cachedAvatars[username] || "";
}
function cacheTopContributorAvatars() {
if (contributors.length === 0)
return;
avatarsCached = true;
for (var i = 0; i < Math.min(contributors.length, 20); i++) {
var contributor = contributors[i];
var username = contributor.login;
var avatarUrl = contributor.avatar_url;
// Use closure to capture username
(function (uname, url) {
ImageCacheService.getCircularAvatar(url, uname, function (cachedPath, success) {
if (success) {
cachedAvatars[uname] = "file://" + cachedPath;
cachedAvatarsChanged();
}
});
})(username, avatarUrl);
}
}
// --------------------------------
// Hook into contributors change - only process once
onContributorsChanged: {
if (contributors.length > 0 && !avatarsCached && ImageCacheService.initialized) {
Qt.callLater(cacheTopContributorAvatars);
}
}
// Also watch for ImageCacheService to become initialized
Connections {
target: ImageCacheService
function onInitializedChanged() {
if (ImageCacheService.initialized && contributors.length > 0 && !avatarsCached) {
Qt.callLater(cacheTopContributorAvatars);
}
}
}
Process {
id: versionProcess
property bool fetchSucceeded: false
property bool wasRateLimited: false
command: ["curl", "-s", "https://api.github.com/repos/noctalia-dev/noctalia-shell/releases/latest"]
stdout: StdioCollector {
onStreamFinished: {
try {
const response = text;
if (response && response.trim()) {
const data = JSON.parse(response);
if (data.tag_name) {
const version = data.tag_name;
root.data.version = version;
root.latestVersion = version;
versionProcess.fetchSucceeded = true;
Logger.d("GitHub", "Latest version fetched:", version);
} else if (data.message) {
// Check if it's a rate limit error
if (data.message.includes("rate limit")) {
versionProcess.wasRateLimited = true;
} else {
Logger.w("GitHub", "Version API error:", data.message);
}
}
}
} catch (e) {
Logger.e("GitHub", "Failed to parse version response:", e);
}
// Check if all processes are done
checkAndSaveData();
}
}
}
Process {
id: qsVersionProcess
property bool fetchSucceeded: false
property bool wasRateLimited: false
command: ["curl", "-s", "https://api.github.com/repos/noctalia-dev/noctalia-qs/releases/latest"]
stdout: StdioCollector {
onStreamFinished: {
try {
const response = text;
if (response && response.trim()) {
const data = JSON.parse(response);
if (data.tag_name) {
const version = data.tag_name;
root.data.qsVersion = version;
root.latestQSVersion = version;
qsVersionProcess.fetchSucceeded = true;
Logger.d("GitHub", "Latest QS version fetched:", version);
} else if (data.message) {
// Check if it's a rate limit error
if (data.message.includes("rate limit")) {
qsVersionProcess.wasRateLimited = true;
} else {
Logger.w("GitHub", "QS Version API error:", data.message);
}
}
}
} catch (e) {
Logger.e("GitHub", "Failed to parse QS version response:", e);
}
// Check if all processes are done
checkAndSaveData();
}
}
}
Process {
id: contributorsProcess
property bool fetchSucceeded: false
property bool wasRateLimited: false
command: ["curl", "-s", "https://api.github.com/repos/noctalia-dev/noctalia-shell/contributors?per_page=100"]
stdout: StdioCollector {
onStreamFinished: {
try {
const response = text;
Logger.d("GitHub", "Raw contributors response length:", response ? response.length : 0);
if (response && response.trim()) {
const data = JSON.parse(response);
Logger.d("GitHub", "Parsed contributors data type:", typeof data, "length:", Array.isArray(data) ? data.length : "not array");
// Only update if we got a valid array
if (Array.isArray(data)) {
root.data.contributors = data;
root.contributors = root.data.contributors;
contributorsProcess.fetchSucceeded = true;
Logger.d("GitHub", "Contributors fetched:", root.contributors.length);
} else if (data.message) {
// Check if it's a rate limit error
if (data.message.includes("rate limit")) {
contributorsProcess.wasRateLimited = true;
} else {
Logger.w("GitHub", "Contributors API error:", data.message);
}
}
}
} catch (e) {
Logger.e("GitHub", "Failed to parse contributors response:", e);
}
// Check if all processes are done
checkAndSaveData();
}
}
}
}
@@ -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
};
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,169 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services.UI
Singleton {
id: root
property string supporterDataFile: Settings.cacheDir + "supporters.json"
property int updateFrequency: 60 * 60 // 1 hour in seconds
property bool isFetching: false
property bool isInitialized: false
readonly property alias data: adapter
property var supporters: []
property var cachedAvatars: ({}) // username -> file:// path
property bool avatarsCached: false
FileView {
id: supporterDataFileView
path: supporterDataFile
printErrors: false
watchChanges: false
onLoaded: {
if (!root.isInitialized) {
root.isInitialized = true;
loadFromCache();
}
}
onLoadFailed: function (error) {
if (error.toString().includes("No such file") || error === 2) {
root.isInitialized = true;
fetchFromApi();
}
}
JsonAdapter {
id: adapter
property var supporters: []
property real timestamp: 0
}
}
function init() {
Logger.i("Supporter", "Service started");
}
function loadFromCache() {
const now = Time.timestamp;
var needsRefetch = false;
if (!data.timestamp || (now >= data.timestamp + updateFrequency)) {
needsRefetch = true;
Logger.i("Supporter", "Cache expired or missing, scheduling fetch");
} else {
Logger.i("Supporter", "Cache is fresh, using cached data");
}
if (data.supporters && data.supporters.length > 0) {
root.supporters = data.supporters;
Logger.d("Supporter", "Loaded", data.supporters.length, "supporters from cache");
}
if (needsRefetch) {
fetchFromApi();
}
}
function fetchFromApi() {
if (isFetching) {
Logger.d("Supporter", "Already fetching");
return;
}
isFetching = true;
supporterProcess.running = true;
}
function saveData() {
data.timestamp = Time.timestamp;
Quickshell.execDetached(["mkdir", "-p", Settings.cacheDir]);
try {
supporterDataFileView.writeAdapter();
Logger.d("Supporter", "Cache file written successfully");
} catch (error) {
Logger.e("Supporter", "Failed to write cache file:", error);
}
}
function getAvatarPath(username) {
return cachedAvatars[username] || "";
}
function cacheAvatars() {
if (supporters.length === 0)
return;
avatarsCached = true;
for (var i = 0; i < supporters.length; i++) {
var supporter = supporters[i];
var username = supporter.github_username;
// Only cache avatars for supporters with GitHub accounts
if (!username)
continue;
var avatarUrl = "https://github.com/" + username + ".png?size=256";
(function (uname, url) {
ImageCacheService.getCircularAvatar(url, "supporter_" + uname, function (cachedPath, success) {
if (success) {
cachedAvatars[uname] = "file://" + cachedPath;
cachedAvatarsChanged();
}
});
})(username, avatarUrl);
}
}
onSupportersChanged: {
if (supporters.length > 0 && !avatarsCached && ImageCacheService.initialized) {
Qt.callLater(cacheAvatars);
}
}
Connections {
target: ImageCacheService
function onInitializedChanged() {
if (ImageCacheService.initialized && supporters.length > 0 && !avatarsCached) {
Qt.callLater(cacheAvatars);
}
}
}
Process {
id: supporterProcess
command: ["curl", "-s", "https://api.noctalia.dev/supporters"]
stdout: StdioCollector {
onStreamFinished: {
try {
const response = text;
if (response && response.trim()) {
const parsed = JSON.parse(response);
if (Array.isArray(parsed)) {
root.data.supporters = parsed;
root.supporters = parsed;
root.saveData();
Logger.d("Supporter", "Fetched", parsed.length, "supporters");
} else if (parsed.message) {
Logger.w("Supporter", "API error:", parsed.message);
}
}
} catch (e) {
Logger.e("Supporter", "Failed to parse response:", e);
}
root.isFetching = false;
}
}
}
}
@@ -0,0 +1,156 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services.Compositor
import qs.Services.Noctalia
import qs.Services.System
Singleton {
id: root
property bool initialized: false
property bool isSending: false
property int totalRamGb: 0
property string instanceId: ""
readonly property string telemetryEndpoint: Quickshell.env("NOCTALIA_TELEMETRY_ENDPOINT") || "https://api.noctalia.dev/ping"
function init() {
if (initialized)
return;
initialized = true;
if (!Settings.data.general.telemetryEnabled) {
Logger.d("Telemetry", "Telemetry disabled by user");
return;
}
// Get or generate instance ID from ShellState
instanceId = ShellState.getTelemetryInstanceId();
if (!instanceId) {
instanceId = generateRandomId();
ShellState.setTelemetryInstanceId(instanceId);
Logger.d("Telemetry", "Generated new random instance ID");
} else {
Logger.d("Telemetry", "Using stored instance ID");
}
// Read RAM info, then send ping
memInfoProcess.running = true;
}
function generateRandomId() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
function getInstanceId() {
return instanceId;
}
Process {
id: memInfoProcess
command: ["sh", "-c", "grep MemTotal /proc/meminfo | awk '{print int($2/1048576)}'"]
running: false
stdout: StdioCollector {
onStreamFinished: {
const ramGb = parseInt(text.trim()) || 0;
root.totalRamGb = ramGb;
root.sendPing();
}
}
onExited: function (exitCode) {
if (exitCode !== 0) {
// Still send ping even if RAM detection fails
root.sendPing();
}
}
}
function sendPing() {
if (isSending)
return;
isSending = true;
const payload = {
instanceId: instanceId,
version: UpdateService.currentVersion,
compositor: getCompositorType(),
os: HostService.osPretty || "Unknown",
ramGb: totalRamGb,
monitors: getMonitorInfo(),
ui: {
scaleRatio: Settings.data.general.scaleRatio,
fontDefaultScale: Settings.data.ui.fontDefaultScale,
fontFixedScale: Settings.data.ui.fontFixedScale
}
};
Logger.d("Telemetry", "Sending anonymous ping:", JSON.stringify(payload));
const request = new XMLHttpRequest();
request.onreadystatechange = function () {
if (request.readyState === XMLHttpRequest.DONE) {
if (request.status >= 200 && request.status < 300) {
Logger.d("Telemetry", "Ping sent successfully");
} else {
Logger.d("Telemetry", "Ping failed with status:", request.status);
}
isSending = false;
}
};
request.open("POST", telemetryEndpoint);
request.setRequestHeader("Content-Type", "application/json");
request.send(JSON.stringify(payload));
}
function getMonitorInfo() {
const monitors = [];
const screens = Quickshell.screens || [];
const scales = CompositorService.displayScales || {};
for (let i = 0; i < screens.length; i++) {
const screen = screens[i];
const name = screen.name || "Unknown";
const scaleData = scales[name];
// Extract just the numeric scale value
const scaleValue = (typeof scaleData === "object" && scaleData !== null) ? (scaleData.scale || 1.0) : (scaleData || 1.0);
monitors.push({
width: screen.width || 0,
height: screen.height || 0,
scale: scaleValue
});
}
return monitors;
}
function getCompositorType() {
if (CompositorService.isHyprland)
return "Hyprland";
if (CompositorService.isNiri)
return "Niri";
if (CompositorService.isScroll)
return "Scroll";
if (CompositorService.isSway)
return "Sway";
if (CompositorService.isMango)
return "MangoWC";
if (CompositorService.isLabwc)
return "LabWC";
if (CompositorService.isExtWorkspace)
return "ExtWorkspace";
return "Unknown";
}
}
@@ -0,0 +1,435 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services.Noctalia
import qs.Services.UI
Singleton {
id: root
// Version properties
readonly property string baseVersion: "4.7.5"
readonly property bool isDevelopment: false
readonly property string developmentSuffix: "-git"
readonly property string currentVersion: `v${!isDevelopment ? baseVersion : baseVersion + developmentSuffix}`
// Telemetry was introduced in this version - users upgrading from earlier need to see the wizard
readonly property string telemetryIntroVersion: "4.0.2"
// URLs
readonly property string discordUrl: "https://discord.noctalia.dev"
readonly property string feedbackUrl: Quickshell.env("NOCTALIA_CHANGELOG_FEEDBACK_URL") || ""
readonly property string upgradeLogBaseUrl: Quickshell.env("NOCTALIA_UPGRADELOG_URL") || "https://api.noctalia.dev/upgradelog"
// Changelog properties
property bool initialized: false
property bool changelogPending: false
property string changelogFromVersion: ""
property string changelogToVersion: ""
property string previousVersion: ""
property string changelogCurrentVersion: ""
property string releaseContent: ""
property string lastShownVersion: ""
property bool popupScheduled: false
property string fetchError: ""
property string changelogLastSeenVersion: ""
property bool changelogStateLoaded: false
property bool pendingShowRequest: false
property bool pendingTelemetryWizardCheck: false
// Fix for FileView race condition
property bool saveInProgress: false
property bool pendingSave: false
property int saveDebounceTimer: 0
Connections {
target: PanelService
function onPopupMenuWindowRegistered(screen) {
if (popupScheduled) {
if (!viewChangelogTargetScreen || viewChangelogTargetScreen.name === screen.name) {
openWhenReady();
}
}
}
}
signal popupQueued(string fromVersion, string toVersion)
signal telemetryWizardNeeded
function init() {
if (initialized)
return;
initialized = true;
Logger.i("UpdateService", "Version:", root.currentVersion);
// Load changelog state from ShellState
Qt.callLater(() => {
if (typeof ShellState !== 'undefined' && ShellState.isLoaded) {
loadChangelogState();
}
});
}
Connections {
target: typeof ShellState !== 'undefined' ? ShellState : null
function onIsLoadedChanged() {
if (ShellState.isLoaded) {
loadChangelogState();
}
}
}
// Debounce timer to prevent rapid successive saves
Timer {
id: saveDebouncer
interval: 300
repeat: false
onTriggered: executeSave()
}
function handleChangelogRequest() {
const fromVersion = changelogFromVersion || "";
const toVersion = changelogToVersion || "";
if (Settings.shouldOpenSetupWizard) {
// If you'll see the setup wizard then you don't need to see the changelog
markChangelogSeen(toVersion);
return;
}
if (!toVersion)
return;
if (popupScheduled && changelogCurrentVersion === toVersion)
return;
if (!popupScheduled && lastShownVersion === toVersion)
return;
previousVersion = fromVersion;
changelogCurrentVersion = toVersion;
// Fetch the upgrade log from the server
fetchUpgradeLog(fromVersion, toVersion);
popupScheduled = true;
root.popupQueued(previousVersion, changelogCurrentVersion);
clearChangelogRequest();
}
function fetchUpgradeLog(fromVersion, toVersion) {
// Normalize and ensure "v" prefix for consistent URL format
let from = ensureVersionPrefix(fromVersion || changelogLastSeenVersion || "3.0.0");
let to = ensureVersionPrefix(toVersion);
// Strip -git suffix
from = from.replace(root.developmentSuffix, "");
to = to.replace(root.developmentSuffix, "");
// 'from' always needs to be before 'to' (use semantic comparison)
if (compareVersions(from, to) >= 0) {
from = "v3.0.0";
}
const url = `${upgradeLogBaseUrl}/${from}/${to}`;
Logger.i("UpdateService", "Fetching upgrade log:", url);
const request = new XMLHttpRequest();
request.onreadystatechange = function () {
if (request.readyState === XMLHttpRequest.DONE) {
Logger.d("UpdateService", "Request completed with status:", request.status);
Logger.d("UpdateService", "Response text length:", request.responseText ? request.responseText.length : 0);
if (request.status >= 200 && request.status < 300) {
releaseContent = request.responseText || "";
Logger.d("UpdateService", "Successfully fetched upgrade log");
fetchError = "";
openWhenReady();
} else {
Logger.w("UpdateService", "Failed to fetch upgrade log, status:", request.status);
releaseContent = "";
if (request.status === 404) {
// Changelog not available for this version range - skip silently
Logger.w("UpdateService", "Changelog not found, skipping display");
fetchError = "";
popupScheduled = false;
markChangelogSeen(toVersion);
} else {
// Network error or server issue - show error to user
fetchError = I18n.tr("changelog.error.fetch-failed");
openWhenReady();
}
}
}
};
request.open("GET", url);
request.send();
}
function normalizeVersion(version) {
if (!version)
return "";
return version.startsWith("v") ? version.substring(1) : version;
}
function ensureVersionPrefix(version) {
if (!version)
return "";
return version.startsWith("v") ? version : "v" + version;
}
function parseVersionParts(version) {
const clean = normalizeVersion(version);
if (!clean)
return [];
return clean.split(/[^0-9]+/).filter(part => part.length > 0).map(part => parseInt(part));
}
function compareVersions(a, b) {
if (a === b)
return 0;
const partsA = parseVersionParts(a);
const partsB = parseVersionParts(b);
const length = Math.max(partsA.length, partsB.length);
for (var i = 0; i < length; i++) {
const valA = partsA[i] || 0;
const valB = partsB[i] || 0;
if (valA > valB)
return 1;
if (valA < valB)
return -1;
}
return 0;
}
// Check if user is upgrading from a version before telemetry was introduced
function shouldShowTelemetryWizard() {
if (!changelogStateLoaded)
return false;
if (Settings.isFreshInstall)
return false;
if (Settings.shouldOpenSetupWizard)
return false;
// No previous version recorded but settings exist - assume upgrading from old version
// (e.g., user deleted shell-state.json but has existing settings)
if (!changelogLastSeenVersion || changelogLastSeenVersion === "")
return true;
// Check if last seen version is before telemetry introduction
return compareVersions(changelogLastSeenVersion, telemetryIntroVersion) < 0;
}
// Called by shell.qml to check for telemetry wizard after init
// If state isn't loaded yet, sets a pending flag and emits telemetryWizardNeeded later
function checkTelemetryWizardOrChangelog() {
Logger.d("UpdateService", "checkTelemetryWizardOrChangelog called, stateLoaded:", changelogStateLoaded);
if (!changelogStateLoaded) {
// State not loaded yet, set pending flags
Logger.d("UpdateService", "State not loaded yet, setting pending flags");
pendingTelemetryWizardCheck = true;
pendingShowRequest = true;
return;
}
// State is already loaded, check immediately
const needsTelemetryWizard = shouldShowTelemetryWizard();
Logger.d("UpdateService", "shouldShowTelemetryWizard:", needsTelemetryWizard, "lastSeenVersion:", changelogLastSeenVersion);
if (needsTelemetryWizard) {
Logger.i("UpdateService", "Emitting telemetryWizardNeeded signal");
root.telemetryWizardNeeded();
} else {
showLatestChangelog();
}
}
function openWhenReady() {
if (!popupScheduled)
return;
if (!Quickshell.screens || Quickshell.screens.length === 0) {
return;
}
let targetScreen = viewChangelogTargetScreen;
if (targetScreen) {
// Explicit screen requested - validate it
if (!PanelService.canShowPanelsOnScreen(targetScreen)) {
Logger.w("UpdateService", "Changelog cannot be shown on screen without bar:", targetScreen.name);
popupScheduled = false;
viewChangelogTargetScreen = null;
return;
}
} else {
// No explicit screen - find one that can show panels
targetScreen = PanelService.findScreenForPanels();
if (!targetScreen) {
Logger.w("UpdateService", "No screen available to show changelog");
popupScheduled = false;
return;
}
}
const panel = PanelService.getPanel("changelogPanel", targetScreen);
if (!panel) {
// Panel not found yet. Wait for popupMenuWindowRegistered signal.
// This avoids the memory leak (#1306).
Logger.d("UpdateService", "Waiting for changelogPanel on screen:", targetScreen.name);
return;
}
panel.open();
popupScheduled = false;
lastShownVersion = changelogCurrentVersion;
viewChangelogTargetScreen = null;
}
function openDiscord() {
if (!discordUrl)
return;
Quickshell.execDetached(["xdg-open", discordUrl]);
}
function openFeedbackForm() {
if (!feedbackUrl)
return;
Quickshell.execDetached(["xdg-open", feedbackUrl]);
}
function showLatestChangelog() {
if (!currentVersion)
return;
if (!changelogStateLoaded) {
pendingShowRequest = true;
return;
}
// Normalize versions for comparison (strip -git, ensure v prefix)
const lastSeen = ensureVersionPrefix(changelogLastSeenVersion.replace(developmentSuffix, ""));
const target = ensureVersionPrefix(currentVersion.replace(developmentSuffix, ""));
if (lastSeen === target)
return;
if (!Settings.data.general.showChangelogOnStartup) {
// user has opted out of seeing changelogs, mark as seen
markChangelogSeen(target);
return;
}
changelogFromVersion = lastSeen;
changelogToVersion = target;
changelogPending = true;
handleChangelogRequest();
}
// Manual changelog viewing (e.g., from Settings > About > View Changelog)
// Shows all changes since v3.0.0, unlike showLatestChangelog() which uses lastSeenVersion
property var viewChangelogTargetScreen: null
function viewChangelog(screen) {
if (!currentVersion)
return;
const target = ensureVersionPrefix(currentVersion.replace(developmentSuffix, ""));
const fromVersion = "v3.8.2";
previousVersion = fromVersion;
changelogCurrentVersion = target;
viewChangelogTargetScreen = screen || null;
popupScheduled = true;
fetchUpgradeLog(fromVersion, target);
}
function clearChangelogRequest() {
changelogPending = false;
changelogFromVersion = "";
changelogToVersion = "";
}
function markChangelogSeen(version) {
if (!version)
return;
changelogLastSeenVersion = version;
debouncedSaveChangelogState();
}
function loadChangelogState() {
try {
const changelog = ShellState.getChangelogState();
changelogLastSeenVersion = changelog.lastSeenVersion || "";
// Migration is now handled in Settings.qml
Logger.d("UpdateService", "Loaded changelog state from ShellState");
} catch (error) {
Logger.e("UpdateService", "Failed to load changelog state:", error);
}
changelogStateLoaded = true;
// Handle pending telemetry wizard check first
if (pendingTelemetryWizardCheck) {
pendingTelemetryWizardCheck = false;
if (shouldShowTelemetryWizard()) {
root.telemetryWizardNeeded();
} else if (pendingShowRequest) {
pendingShowRequest = false;
Qt.callLater(root.showLatestChangelog);
}
return;
}
if (pendingShowRequest) {
pendingShowRequest = false;
Qt.callLater(root.showLatestChangelog);
}
}
function debouncedSaveChangelogState() {
// Queue a save and restart the debounce timer
pendingSave = true;
saveDebouncer.restart();
}
function executeSave() {
if (!pendingSave)
return;
// Prevent concurrent saves
if (saveInProgress) {
// Retry after a short delay
saveDebouncer.start();
return;
}
pendingSave = false;
saveInProgress = true;
try {
ShellState.setChangelogState({
lastSeenVersion: changelogLastSeenVersion || ""
});
Logger.d("UpdateService", "Saved changelog state to ShellState");
saveInProgress = false;
// Check if another save was queued while we were saving
if (pendingSave) {
Qt.callLater(executeSave);
}
} catch (error) {
Logger.e("UpdateService", "Failed to save changelog state:", error);
saveInProgress = false;
}
}
function saveChangelogState() {
// Immediate save (backward compatibility)
debouncedSaveChangelogState();
}
}