From b2eb3b3b40126796389ac6f50ada58c492c00418 Mon Sep 17 00:00:00 2001 From: MasterAcnolo Date: Tue, 6 Oct 2026 21:42:57 +0200 Subject: [PATCH] feat: implement changelog feature with UI modal and caching --- app/changelogManager.js | 177 ++++++++++++++++ app/ipcHandlers.js | 11 + preload.js | 10 + public/index.html | 17 ++ public/script/changelog.js | 277 +++++++++++++++++++++++++ public/styles/components/changelog.css | 229 ++++++++++++++++++++ public/styles/styles.css | 1 + 7 files changed, 722 insertions(+) create mode 100644 app/changelogManager.js create mode 100644 public/script/changelog.js create mode 100644 public/styles/components/changelog.css diff --git a/app/changelogManager.js b/app/changelogManager.js new file mode 100644 index 0000000..449223b --- /dev/null +++ b/app/changelogManager.js @@ -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} 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} 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 }; diff --git a/app/ipcHandlers.js b/app/ipcHandlers.js index 9f64b63..5e9f3e8 100644 --- a/app/ipcHandlers.js +++ b/app/ipcHandlers.js @@ -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. */ diff --git a/preload.js b/preload.js index c00d797..a8233a1 100644 --- a/preload.js +++ b/preload.js @@ -69,6 +69,16 @@ contextBridge.exposeInMainWorld("electronAPI", { */ getVersion: () => ipcRenderer.invoke("version"), + /** + * Returns the changelog data + */ + getChangelog: () => ipcRenderer.invoke("get-changelog"), + + /** + * Marks the current changelog version as seen by the user. + */ + markChangelogSeen: () => ipcRenderer.invoke("mark-changelog-seen"), + /** * Validates a download path before using it for file operations. * diff --git a/public/index.html b/public/index.html index 777d620..fbdeb82 100644 --- a/public/index.html +++ b/public/index.html @@ -27,6 +27,7 @@ +
@@ -303,6 +304,21 @@
+ +

Because Why Not ?