add noctalia and fuzzel
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user