mirror of
https://github.com/MasterAcnolo/Freedom-Loader.git
synced 2026-11-13 15:09:20 +01:00
feat: implement changelog feature with UI modal and caching
This commit is contained in:
177
app/changelogManager.js
Normal file
177
app/changelogManager.js
Normal file
@@ -0,0 +1,177 @@
|
||||
const { app } = require("electron");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const https = require("https");
|
||||
const config = require("../config");
|
||||
const { logger } = require("../server/logger");
|
||||
|
||||
/** Maximum age of the cached release list before it must be refreshed. */
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Filename used to persist changelog data in the Electron user data folder. */
|
||||
const CACHE_FILE_NAME = "changelog-cache.json";
|
||||
|
||||
/** GitHub API endpoint containing the application's published releases. */
|
||||
const RELEASES_URL = "https://api.github.com/repos/MasterAcnolo/Freedom-Loader/releases";
|
||||
|
||||
/** Returns the path of the local changelog cache file. */
|
||||
function getCachePath() {
|
||||
return path.join(app.getPath("userData"), CACHE_FILE_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the numeric major, minor, and patch parts from a version string.
|
||||
*
|
||||
* @param {string} version - Version string or release tag
|
||||
* @returns {number[]|null} Parsed version parts, or null when invalid
|
||||
*/
|
||||
function normalizeVersion(version) {
|
||||
const match = String(version || "").match(/(\d+)\.(\d+)\.(\d+)/);
|
||||
return match ? match.slice(1).map(Number) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two semantic version strings.
|
||||
*
|
||||
* @param {string} left - First version to compare
|
||||
* @param {string} right - Second version to compare
|
||||
* @returns {number} Negative, zero, or positive comparison result
|
||||
*/
|
||||
function compareVersions(left, right) {
|
||||
const leftParts = normalizeVersion(left) || [0, 0, 0];
|
||||
const rightParts = normalizeVersion(right) || [0, 0, 0];
|
||||
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
if (leftParts[index] !== rightParts[index]) {
|
||||
return leftParts[index] - rightParts[index];
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Reads and parses the cached changelog, if one is available. */
|
||||
function readCache() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(getCachePath(), "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Persists the fetched releases together with their cache timestamp. */
|
||||
function writeCache(releases) {
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
getCachePath(),
|
||||
JSON.stringify({ cachedAt: Date.now(), releases }, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn(`Unable to cache changelog: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches published releases from GitHub and maps them to the UI data shape.
|
||||
*
|
||||
* @returns {Promise<object[]>} Published releases
|
||||
*/
|
||||
function fetchReleases() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = https.get(
|
||||
RELEASES_URL,
|
||||
{
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"User-Agent": "Freedom-Loader",
|
||||
},
|
||||
},
|
||||
(response) => {
|
||||
let body = "";
|
||||
response.setEncoding("utf8");
|
||||
response.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
response.on("end", () => {
|
||||
// Do not attempt to parse an error response as a release list.
|
||||
if (response.statusCode !== 200) {
|
||||
reject(new Error(`GitHub returned HTTP ${response.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Draft releases are not visible to users and should not appear in the changelog.
|
||||
const releases = JSON.parse(body)
|
||||
.filter((release) => !release.draft)
|
||||
.map((release) => ({
|
||||
version: release.tag_name,
|
||||
name: release.name || release.tag_name,
|
||||
date: release.published_at || release.created_at,
|
||||
body: release.body || "No changelog available.",
|
||||
url: release.html_url,
|
||||
}));
|
||||
resolve(releases);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
request.setTimeout(10000, () => {
|
||||
request.destroy(new Error("Changelog request timed out"));
|
||||
});
|
||||
request.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns cached or freshly fetched releases and identifies unread versions.
|
||||
*
|
||||
* @returns {Promise<object>} Changelog data for the renderer
|
||||
*/
|
||||
async function getChangelog() {
|
||||
const cache = readCache();
|
||||
let releases = cache?.releases || [];
|
||||
const cacheIsFresh = cache && cache.releases?.length > 0 && Date.now() - cache.cachedAt < CACHE_TTL_MS;
|
||||
|
||||
// Refresh stale or missing data, while keeping the last cache as a fallback.
|
||||
if (!cacheIsFresh) {
|
||||
try {
|
||||
releases = await fetchReleases();
|
||||
writeCache(releases);
|
||||
} catch (error) {
|
||||
logger.warn(`Unable to retrieve changelog: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const lastSeenVersion = cache?.lastSeenVersion || null;
|
||||
|
||||
// Without a previous version, all available releases are considered unread.
|
||||
const unreadReleases = lastSeenVersion
|
||||
? releases.filter((release) => compareVersions(release.version, lastSeenVersion) > 0)
|
||||
: releases;
|
||||
|
||||
return {
|
||||
// An empty release list means that the fetch failed or returned no releases.
|
||||
error: releases.length === 0,
|
||||
currentVersion: config.version,
|
||||
lastSeenVersion,
|
||||
hasUnread: unreadReleases.length > 0,
|
||||
releases,
|
||||
unreadReleases,
|
||||
};
|
||||
}
|
||||
|
||||
/** Marks the current application version as seen in the local cache. */
|
||||
function markChangelogSeen() {
|
||||
const cache = readCache() || { releases: [] };
|
||||
cache.lastSeenVersion = config.version;
|
||||
cache.cachedAt = cache.cachedAt || 0 ;
|
||||
fs.mkdirSync(path.dirname(getCachePath()), { recursive: true });
|
||||
fs.writeFileSync(getCachePath(), JSON.stringify(cache, null, 2), "utf8");
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = { getChangelog, markChangelogSeen };
|
||||
@@ -21,6 +21,7 @@ const { userThemesPath } = require("../server/helpers/path.helpers");
|
||||
const { createSystemTray, destroyTray } = require("./tray");
|
||||
const {sendReport, checkReportService, getReportServiceStatus} = require("./sendReport");
|
||||
const { isUpdateAvailable, downloadUpdate, installUpdate } = require("./autoUpdater");
|
||||
const { getChangelog, markChangelogSeen } = require("./changelogManager");
|
||||
|
||||
/**
|
||||
* Security whitelist for feature flags that can be modified at runtime.
|
||||
@@ -71,6 +72,16 @@ function registerIpcHandlers(getMainWindow) {
|
||||
*/
|
||||
ipcMain.handle("version", () => config.version);
|
||||
|
||||
/**
|
||||
* Retrieves the changelog from cache or GitHub API.
|
||||
*/
|
||||
ipcMain.handle("get-changelog", () => getChangelog());
|
||||
|
||||
/**
|
||||
* Marks all changelog entries as seen and updates cache.
|
||||
*/
|
||||
ipcMain.handle("mark-changelog-seen", () => markChangelogSeen());
|
||||
|
||||
/**
|
||||
* Returns runtime feature configuration object.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user