mirror of
https://github.com/zoriya/noctalia-shell.git
synced 2026-08-15 18:43:59 +00:00
679 lines
19 KiB
QML
679 lines
19 KiB
QML
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
|
|
property string baseVersion: "3.2.0"
|
|
property bool isDevelopment: true
|
|
property string currentVersion: `v${!isDevelopment ? baseVersion : baseVersion + "-dev"}`
|
|
property string changelogStateFile: Quickshell.env("NOCTALIA_CHANGELOG_STATE_FILE") || (Settings.cacheDir + "changelog-state.json")
|
|
|
|
// Changelog properties
|
|
property bool initialized: false
|
|
property bool changelogPending: false
|
|
property string changelogFromVersion: ""
|
|
property string changelogToVersion: ""
|
|
property string previousVersion: ""
|
|
property string changelogCurrentVersion: ""
|
|
property var releaseHighlights: []
|
|
property string releaseNotesUrl: ""
|
|
property string discordUrl: "https://discord.noctalia.dev"
|
|
property string lastShownVersion: ""
|
|
property bool popupScheduled: false
|
|
property string feedbackUrl: Quickshell.env("NOCTALIA_CHANGELOG_FEEDBACK_URL") || ""
|
|
property string fetchError: ""
|
|
property string changelogLastSeenVersion: ""
|
|
property bool changelogStateLoaded: false
|
|
property bool pendingShowRequest: false
|
|
|
|
// Changelog fetching
|
|
property string changelogBaseUrl: Quickshell.env("NOCTALIA_CHANGELOG_URL") || "https://noctalia.dev:7777/changelogs"
|
|
property int changelogFetchLimit: 25
|
|
property int changelogUpdateFrequency: 60 * 60 // 1 hour in seconds
|
|
property bool isFetchingChangelogs: false
|
|
property string releaseNotes: ""
|
|
property var releases: []
|
|
property string changelogDataFile: Quickshell.env("NOCTALIA_CHANGELOG_FILE") || (Settings.cacheDir + "changelogs.json")
|
|
|
|
// Fix for FileView race condition
|
|
property bool saveInProgress: false
|
|
property bool pendingSave: false
|
|
property int saveDebounceTimer: 0
|
|
|
|
signal popupQueued(string fromVersion, string toVersion)
|
|
|
|
// Internal helpers
|
|
function getVersion() {
|
|
return root.currentVersion;
|
|
}
|
|
|
|
function checkForUpdates() {
|
|
// TODO: Implement update checking logic
|
|
Logger.i("UpdateService", "Checking for updates...");
|
|
}
|
|
|
|
function init() {
|
|
if (initialized)
|
|
return;
|
|
|
|
initialized = true;
|
|
Logger.i("UpdateService", "Version:", root.currentVersion);
|
|
}
|
|
|
|
// Watch for changes to trigger highlight rebuilds
|
|
onReleasesChanged: {
|
|
rebuildHighlights();
|
|
}
|
|
|
|
onReleaseNotesChanged: {
|
|
rebuildHighlights();
|
|
}
|
|
|
|
// Changelog data cache
|
|
FileView {
|
|
id: changelogDataFileView
|
|
path: root.changelogDataFile
|
|
watchChanges: true
|
|
onFileChanged: reload()
|
|
onAdapterUpdated: writeAdapter()
|
|
Component.onCompleted: {
|
|
reload();
|
|
}
|
|
onLoaded: {
|
|
loadChangelogCache();
|
|
}
|
|
onLoadFailed: function (error) {
|
|
if (error.toString().includes("No such file") || error === 2) {
|
|
Qt.callLater(() => {
|
|
fetchChangelogs();
|
|
});
|
|
}
|
|
}
|
|
|
|
JsonAdapter {
|
|
id: changelogAdapter
|
|
|
|
property string releaseNotes: ""
|
|
property var releases: []
|
|
property real timestamp: 0
|
|
}
|
|
}
|
|
|
|
FileView {
|
|
id: changelogStateFileView
|
|
path: root.changelogStateFile
|
|
printErrors: false
|
|
onLoaded: loadChangelogState()
|
|
onLoadFailed: error => {
|
|
if (error === 2) {
|
|
// File doesn't exist, create it
|
|
debouncedSaveChangelogState();
|
|
} else {
|
|
Logger.e("UpdateService", "Failed to load changelog state file:", error);
|
|
}
|
|
changelogStateLoaded = true;
|
|
if (pendingShowRequest) {
|
|
pendingShowRequest = false;
|
|
Qt.callLater(root.showLatestChangelog);
|
|
}
|
|
}
|
|
|
|
JsonAdapter {
|
|
id: changelogStateAdapter
|
|
property string lastSeenVersion: ""
|
|
}
|
|
}
|
|
|
|
// 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 (!toVersion)
|
|
return;
|
|
|
|
if (popupScheduled && changelogCurrentVersion === toVersion)
|
|
return;
|
|
|
|
if (!popupScheduled && lastShownVersion === toVersion)
|
|
return;
|
|
|
|
previousVersion = fromVersion;
|
|
changelogCurrentVersion = toVersion;
|
|
releaseHighlights = buildReleaseHighlights(previousVersion, changelogCurrentVersion);
|
|
releaseNotesUrl = buildReleaseNotesUrl(toVersion);
|
|
|
|
popupScheduled = true;
|
|
root.popupQueued(previousVersion, changelogCurrentVersion);
|
|
|
|
clearChangelogRequest();
|
|
openWhenReady();
|
|
}
|
|
|
|
function rebuildHighlights() {
|
|
if (!changelogCurrentVersion)
|
|
return;
|
|
releaseHighlights = buildReleaseHighlights(previousVersion, changelogCurrentVersion);
|
|
}
|
|
|
|
function buildReleaseHighlights(fromVersion, toVersion) {
|
|
const selected = [];
|
|
const fromNorm = normalizeVersion(fromVersion);
|
|
const toNorm = normalizeVersion(toVersion);
|
|
|
|
if (releases.length > 0) {
|
|
for (var i = 0; i < releases.length; i++) {
|
|
const rel = releases[i];
|
|
const tag = rel.version || "";
|
|
const tagNorm = normalizeVersion(tag);
|
|
if (!tagNorm)
|
|
continue;
|
|
|
|
if (toNorm && compareVersions(tagNorm, toNorm) > 0) {
|
|
continue;
|
|
}
|
|
|
|
if (fromNorm && compareVersions(tagNorm, fromNorm) <= 0) {
|
|
break;
|
|
}
|
|
|
|
const entries = parseReleaseNotes(rel.body);
|
|
if (entries.length === 0)
|
|
continue;
|
|
|
|
selected.push({
|
|
"version": tag,
|
|
"date": rel.createdAt || "",
|
|
"entries": entries
|
|
});
|
|
}
|
|
}
|
|
|
|
if (selected.length === 0 && toVersion) {
|
|
const fallback = parseReleaseNotes(releaseNotes);
|
|
if (fallback.length > 0) {
|
|
selected.push({
|
|
"version": toVersion,
|
|
"date": "",
|
|
"entries": fallback
|
|
});
|
|
fetchError = "";
|
|
}
|
|
}
|
|
|
|
return selected;
|
|
}
|
|
|
|
function normalizeVersion(version) {
|
|
if (!version)
|
|
return "";
|
|
return version.startsWith("v") ? version.substring(1) : 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;
|
|
}
|
|
|
|
function buildReleaseNotesUrl(version) {
|
|
if (!version)
|
|
return "";
|
|
const tag = version.startsWith("v") ? version : `v${version}`;
|
|
return `${changelogBaseUrl}/CHANGELOG-${tag}.txt`;
|
|
}
|
|
|
|
function parseReleaseNotes(body) {
|
|
if (!body)
|
|
return [];
|
|
|
|
const lines = body.split(/\r?\n/);
|
|
var entries = [];
|
|
|
|
for (var i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
const trimmed = line.trim();
|
|
|
|
if (trimmed.match(/^Release\s+v[0-9]/i)) {
|
|
continue;
|
|
}
|
|
|
|
if (trimmed.match(/^##\s*Changes since/i)) {
|
|
break;
|
|
}
|
|
|
|
// If this line is just an emoji and the next line has text, merge them
|
|
if (trimmed.match(/^[\u{1F000}-\u{1F9FF}]$/u) && i + 1 < lines.length) {
|
|
const nextLine = lines[i + 1].trim();
|
|
if (nextLine.length > 0) {
|
|
entries.push(trimmed + " " + nextLine);
|
|
i++; // Skip the next line since we merged it
|
|
continue;
|
|
}
|
|
}
|
|
|
|
entries.push(line);
|
|
}
|
|
|
|
// Remove trailing blank lines
|
|
while (entries.length > 0 && entries[entries.length - 1].trim().length === 0) {
|
|
entries.pop();
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
function isVersionLine(text) {
|
|
return /^v?\d/i.test(text);
|
|
}
|
|
|
|
function cleanEntry(text) {
|
|
if (!text)
|
|
return "";
|
|
|
|
var cleaned = text;
|
|
|
|
// Strip markdown links [label](url)
|
|
cleaned = cleaned.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1").trim();
|
|
|
|
// Drop bare URLs or parentheses wrapping URLs
|
|
cleaned = cleaned.replace(/\((https?:\/\/[^)]+)\)/gi, "").trim();
|
|
|
|
cleaned = cleaned.replace(/\([0-9a-f]{7,}\)/gi, "").trim();
|
|
cleaned = cleaned.replace(/\s+by\s+[A-Za-z0-9_-]+$/i, "").trim();
|
|
cleaned = cleaned.replace(/\s{2,}/g, " ");
|
|
|
|
if (cleaned.toLowerCase().startsWith("merge branch")) {
|
|
const ofIndex = cleaned.indexOf(" of ");
|
|
if (ofIndex > -1) {
|
|
cleaned = cleaned.substring(0, ofIndex).trim();
|
|
}
|
|
}
|
|
|
|
return cleaned;
|
|
}
|
|
|
|
function isIgnoredEntry(text) {
|
|
const lower = text.toLowerCase();
|
|
if (lower.startsWith("release v"))
|
|
return true;
|
|
if (lower.includes("autoformat") || lower.includes("auto-formatting"))
|
|
return true;
|
|
if (lower.includes("qmlfmt"))
|
|
return true;
|
|
return false;
|
|
}
|
|
|
|
function openWhenReady() {
|
|
if (!popupScheduled)
|
|
return;
|
|
|
|
if (!Quickshell.screens || Quickshell.screens.length === 0) {
|
|
Qt.callLater(openWhenReady);
|
|
return;
|
|
}
|
|
|
|
const targetScreen = Quickshell.screens[0];
|
|
const panel = PanelService.getPanel("changelogPanel", targetScreen);
|
|
if (!panel) {
|
|
Qt.callLater(openWhenReady);
|
|
return;
|
|
}
|
|
|
|
panel.open();
|
|
popupScheduled = false;
|
|
lastShownVersion = changelogCurrentVersion;
|
|
}
|
|
|
|
function openReleaseNotes() {
|
|
if (!releaseNotesUrl)
|
|
return;
|
|
Quickshell.execDetached(["xdg-open", releaseNotesUrl]);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
const lastSeen = changelogLastSeenVersion || "";
|
|
if (lastSeen === currentVersion)
|
|
return;
|
|
|
|
changelogFromVersion = lastSeen;
|
|
changelogToVersion = currentVersion;
|
|
changelogPending = true;
|
|
handleChangelogRequest();
|
|
}
|
|
|
|
function clearChangelogRequest() {
|
|
changelogPending = false;
|
|
changelogFromVersion = "";
|
|
changelogToVersion = "";
|
|
}
|
|
|
|
function markChangelogSeen(version) {
|
|
if (!version)
|
|
return;
|
|
changelogLastSeenVersion = version;
|
|
debouncedSaveChangelogState();
|
|
}
|
|
|
|
function loadChangelogState() {
|
|
try {
|
|
changelogLastSeenVersion = changelogStateAdapter.lastSeenVersion || "";
|
|
if (!changelogLastSeenVersion && Settings.data && Settings.data.changelog && Settings.data.changelog.lastSeenVersion) {
|
|
changelogLastSeenVersion = Settings.data.changelog.lastSeenVersion;
|
|
debouncedSaveChangelogState();
|
|
Logger.i("UpdateService", "Migrated changelog lastSeenVersion from settings to cache");
|
|
}
|
|
} catch (error) {
|
|
Logger.e("UpdateService", "Failed to load changelog state:", error);
|
|
}
|
|
changelogStateLoaded = true;
|
|
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 {
|
|
changelogStateAdapter.lastSeenVersion = changelogLastSeenVersion || "";
|
|
|
|
// Ensure cache directory exists
|
|
Quickshell.execDetached(["mkdir", "-p", Settings.cacheDir]);
|
|
|
|
// Small delay to ensure directory creation completes
|
|
Qt.callLater(() => {
|
|
try {
|
|
changelogStateFileView.writeAdapter();
|
|
saveInProgress = false;
|
|
|
|
// Check if another save was queued while we were saving
|
|
if (pendingSave) {
|
|
Qt.callLater(executeSave);
|
|
}
|
|
} catch (writeError) {
|
|
Logger.e("UpdateService", "Failed to write changelog state:", writeError);
|
|
saveInProgress = false;
|
|
}
|
|
});
|
|
} catch (error) {
|
|
Logger.e("UpdateService", "Failed to save changelog state:", error);
|
|
saveInProgress = false;
|
|
}
|
|
}
|
|
|
|
function saveChangelogState() {
|
|
// Immediate save (backward compatibility)
|
|
debouncedSaveChangelogState();
|
|
}
|
|
|
|
// Changelog fetching functions
|
|
|
|
function loadChangelogCache() {
|
|
const now = Time.timestamp;
|
|
var needsRefetch = false;
|
|
if (!changelogAdapter.timestamp || (now >= changelogAdapter.timestamp + changelogUpdateFrequency)) {
|
|
needsRefetch = true;
|
|
Logger.d("UpdateService", "Changelog cache expired or missing, scheduling fetch");
|
|
} else {
|
|
Logger.d("UpdateService", "Loading cached changelog data (age:", Math.round((now - changelogAdapter.timestamp) / 60), "minutes)");
|
|
}
|
|
|
|
if (changelogAdapter.releaseNotes) {
|
|
root.releaseNotes = changelogAdapter.releaseNotes;
|
|
}
|
|
if (changelogAdapter.releases && changelogAdapter.releases.length > 0) {
|
|
root.releases = changelogAdapter.releases;
|
|
} else {
|
|
Logger.d("UpdateService", "Cached releases missing, scheduling fetch");
|
|
needsRefetch = true;
|
|
}
|
|
|
|
if (needsRefetch) {
|
|
fetchChangelogs();
|
|
}
|
|
}
|
|
|
|
function fetchChangelogs() {
|
|
if (isFetchingChangelogs) {
|
|
Logger.w("UpdateService", "Changelog data is still fetching");
|
|
return;
|
|
}
|
|
|
|
isFetchingChangelogs = true;
|
|
fetchError = "";
|
|
fetchChangelogIndex();
|
|
}
|
|
|
|
function fetchChangelogIndex() {
|
|
const request = new XMLHttpRequest();
|
|
request.onreadystatechange = function () {
|
|
if (request.readyState === XMLHttpRequest.DONE) {
|
|
if (request.status >= 200 && request.status < 300) {
|
|
const entries = parseChangelogIndex(request.responseText || "");
|
|
if (entries.length === 0) {
|
|
Logger.w("UpdateService", "No changelog entries found at", changelogBaseUrl);
|
|
fetchError = I18n.tr("changelog.error.fetch-failed");
|
|
finalizeChangelogFetch([]);
|
|
} else {
|
|
fetchChangelogFiles(entries, 0, []);
|
|
}
|
|
} else {
|
|
Logger.e("UpdateService", "Failed to fetch changelog index:", request.status, request.responseText);
|
|
fetchError = I18n.tr("changelog.error.fetch-failed");
|
|
finalizeChangelogFetch([]);
|
|
}
|
|
}
|
|
};
|
|
request.open("GET", changelogBaseUrl);
|
|
request.send();
|
|
}
|
|
|
|
function parseChangelogIndex(content) {
|
|
if (!content)
|
|
return [];
|
|
|
|
const lines = content.split(/\r?\n/);
|
|
var entries = [];
|
|
for (var i = 0; i < lines.length; i++) {
|
|
const trimmed = lines[i].trim();
|
|
const match = trimmed.match(/CHANGELOG-(v[0-9A-Za-z.\-]+)\.txt/);
|
|
if (match && match.length >= 2) {
|
|
const version = match[1];
|
|
const fileName = match[0];
|
|
var modified = "";
|
|
for (var j = i + 1; j < Math.min(lines.length, i + 4); j++) {
|
|
const modLine = lines[j].trim();
|
|
const modMatch = modLine.match(/^Last modified:\s*(.+)$/i);
|
|
if (modMatch && modMatch.length >= 2) {
|
|
modified = modMatch[1].trim();
|
|
break;
|
|
}
|
|
}
|
|
|
|
entries.push({
|
|
"version": version,
|
|
"fileName": fileName,
|
|
"url": `${changelogBaseUrl}/${fileName}`,
|
|
"createdAt": modified
|
|
});
|
|
}
|
|
}
|
|
|
|
entries.sort(function (a, b) {
|
|
return compareVersions(b.version, a.version);
|
|
});
|
|
|
|
if (entries.length > changelogFetchLimit) {
|
|
entries = entries.slice(0, changelogFetchLimit);
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
function fetchChangelogFiles(entries, index, accumulator) {
|
|
if (!entries || entries.length === 0) {
|
|
finalizeChangelogFetch([]);
|
|
return;
|
|
}
|
|
|
|
if (index >= entries.length) {
|
|
finalizeChangelogFetch(accumulator);
|
|
return;
|
|
}
|
|
|
|
const entry = entries[index];
|
|
const request = new XMLHttpRequest();
|
|
request.onreadystatechange = function () {
|
|
if (request.readyState === XMLHttpRequest.DONE) {
|
|
if (request.status >= 200 && request.status < 300) {
|
|
accumulator.push({
|
|
"version": entry.version,
|
|
"createdAt": entry.createdAt || "",
|
|
"body": request.responseText || ""
|
|
});
|
|
} else {
|
|
Logger.e("UpdateService", "Failed to fetch changelog file:", entry.url, "status:", request.status);
|
|
if (!fetchError) {
|
|
fetchError = I18n.tr("changelog.error.fetch-failed");
|
|
}
|
|
}
|
|
fetchChangelogFiles(entries, index + 1, accumulator);
|
|
}
|
|
};
|
|
request.open("GET", entry.url);
|
|
request.send();
|
|
}
|
|
|
|
function finalizeChangelogFetch(releasesList) {
|
|
isFetchingChangelogs = false;
|
|
|
|
if (releasesList && releasesList.length > 0) {
|
|
releasesList.sort(function (a, b) {
|
|
return compareVersions(b.version, a.version);
|
|
});
|
|
|
|
changelogAdapter.releases = releasesList;
|
|
root.releases = releasesList;
|
|
const latest = releasesList[0];
|
|
if (latest) {
|
|
changelogAdapter.releaseNotes = latest.body || "";
|
|
root.releaseNotes = changelogAdapter.releaseNotes;
|
|
}
|
|
|
|
if (!fetchError) {
|
|
Logger.d("UpdateService", "Fetched changelog entries:", releasesList.length);
|
|
}
|
|
} else {
|
|
changelogAdapter.releases = [];
|
|
root.releases = [];
|
|
if (!fetchError) {
|
|
Logger.w("UpdateService", "No changelog entries fetched");
|
|
fetchError = I18n.tr("changelog.error.fetch-failed");
|
|
}
|
|
}
|
|
|
|
saveChangelogData();
|
|
}
|
|
|
|
function saveChangelogData() {
|
|
changelogAdapter.timestamp = Time.timestamp;
|
|
Logger.d("UpdateService", "Saving changelog data to cache file:", changelogDataFile);
|
|
|
|
// Ensure cache directory exists
|
|
Quickshell.execDetached(["mkdir", "-p", Settings.cacheDir]);
|
|
|
|
Qt.callLater(() => {
|
|
changelogDataFileView.writeAdapter();
|
|
Logger.d("UpdateService", "Changelog cache file written successfully");
|
|
});
|
|
}
|
|
|
|
function resetChangelogCache() {
|
|
changelogAdapter.version = I18n.tr("system.unknown-version");
|
|
changelogAdapter.releaseNotes = "";
|
|
changelogAdapter.releases = [];
|
|
changelogAdapter.timestamp = 0;
|
|
|
|
fetchChangelogs();
|
|
}
|
|
|
|
function clearReleaseCache() {
|
|
Logger.d("UpdateService", "Clearing cached release data");
|
|
changelogAdapter.releases = [];
|
|
root.releases = [];
|
|
changelogDataFileView.writeAdapter();
|
|
}
|
|
}
|