From 9975b2b583bb322736300d8e3a6e61a26355648a Mon Sep 17 00:00:00 2001 From: MasterAcnolo Date: Fri, 25 Sep 2026 21:08:18 +0200 Subject: [PATCH] refactor(update): add an UI indicator in frontend disable OS native pop up menu and fix autoupdate crash on rpm and snap #50 --- app/autoUpdater.js | 89 ++++++++-------- app/ipcHandlers.js | 125 +++++++++++++---------- preload.js | 64 ++++++++++-- public/index.html | 29 +++++- public/script/autoUpdate.js | 81 +++++++++++++++ public/styles/components/autoUpdate.css | 128 ++++++++++++++++++++++++ public/styles/styles.css | 1 + 7 files changed, 409 insertions(+), 108 deletions(-) create mode 100644 public/script/autoUpdate.js create mode 100644 public/styles/components/autoUpdate.css diff --git a/app/autoUpdater.js b/app/autoUpdater.js index bbb800a..bb06b74 100644 --- a/app/autoUpdater.js +++ b/app/autoUpdater.js @@ -9,7 +9,6 @@ */ const { autoUpdater } = require("electron-updater"); -const { dialog } = require("electron"); const { logger } = require("../server/logger"); /** @@ -21,6 +20,26 @@ const { logger } = require("../server/logger"); autoUpdater.autoDownload = false; autoUpdater.autoInstallOnAppQuit = false; +let updateAvailable = false; + +/** + * Determines how the update should be handled based on the platform + * @returns {"auto" | "snap" | "flatpak" | "linux-native"} + */ +function getUpdateEnvironment() { + if ( + process.env.APPIMAGE || + process.platform === "win32" || + process.platform === "darwin" + ) { + return "auto"; + } + if (process.env.SNAP) return "snap"; + if (process.env.FLATPAK_ID) return "flatpak"; + + return "linux-native"; +} + /** * Initializes application auto-update lifecycle. * @@ -32,7 +51,6 @@ autoUpdater.autoInstallOnAppQuit = false; * @param {BrowserWindow} mainWindow - main Electron window instance */ function initAutoUpdater(mainWindow) { - /** * Triggered when a new version is detected. * Prompts user to install or defer update. @@ -40,23 +58,12 @@ function initAutoUpdater(mainWindow) { autoUpdater.on("update-available", async (info) => { logger.info(`Update available: ${info.version}`); - const { response } = await dialog.showMessageBox(mainWindow, { - type: "info", - title: "Update Available", - message: `Version ${info.version} is available.`, - detail: "Would you like to download and install it now?", - buttons: ["Install Update", "Maybe Later"], - defaultId: 0, - cancelId: 1, + mainWindow?.webContents.send("update-available", { + version: info.version, + environment: getUpdateEnvironment(), }); + updateAvailable = true; - if (response === 0) { - await autoUpdater.downloadUpdate(); - } else { - mainWindow?.webContents.executeJavaScript( - `window.showUpdateBadge && window.showUpdateBadge("${info.version}")` - ); - } }); /** @@ -66,8 +73,10 @@ function initAutoUpdater(mainWindow) { autoUpdater.on("download-progress", (progress) => { logger.info(`Download progress: ${Math.round(progress.percent)}%`); mainWindow?.webContents.send("update-progress", { - percent: Math.round(progress.percent), - speed: progress.bytesPerSecond, + percent: progress.percent, + bytesPerSecond: progress.bytesPerSecond, + transferred: progress.transferred, + total: progress.total, }); }); @@ -78,17 +87,9 @@ function initAutoUpdater(mainWindow) { autoUpdater.on("update-downloaded", async (info) => { logger.info(`Update downloaded: ${info.version}`); - const { response } = await dialog.showMessageBox(mainWindow, { - type: "info", - title: "Update Ready", - message: `Version ${info.version} has been downloaded.`, - detail: "The application will restart to apply the update.", - buttons: ["Install Now", "Later"], - defaultId: 0, - cancelId: 1, - }); + mainWindow?.webContents.send("update-downloaded", info); + - if (response === 0) autoUpdater.quitAndInstall(); }); /** @@ -102,14 +103,15 @@ function initAutoUpdater(mainWindow) { * If no update is available, I put this because there is no Linux version before 1.6.0 * @type {boolean} */ - const isNoUpdateAvailable = /404/.test(msg) || /Cannot find latest.*\.yml/i.test(msg); + const isNoUpdateAvailable = + /404/.test(msg) || /Cannot find latest.*\.yml/i.test(msg); if (isNoUpdateAvailable) { logger.warn("Auto update: no update metadata found (probably no previous release), ignoring", msg); return; } - logger.error("Auto update error:", msg); + logger.error("Auto update error:", err); }); checkForUpdates(); @@ -120,19 +122,7 @@ function initAutoUpdater(mainWindow) { * Separated from init for reusability and testability. */ async function checkForUpdates() { - - /** - * Prevents electron-updater from running inside sandboxed environments. - * Snap and Flatpak lock the file system in read-only mode, causing the updater to crash. - * In these environments, updates are safely handled by snapd or the Flatpak runtime. - * Standalone Linux packages (.deb, .rpm, AppImage) will bypass this and update normally. - */ - if (process.env.SNAP || process.env.FLATPAK_ID) { - logger.info("Running as Snap or Flatpak. Updates are managed by the OS store. Skipping electron-updater."); - return; - } - - if (!require("electron").app.isPackaged) return ; + if (!require("electron").app.isPackaged) return; try { await autoUpdater.checkForUpdates(); @@ -160,4 +150,13 @@ function installUpdate() { autoUpdater.quitAndInstall(); } -module.exports = { initAutoUpdater, downloadUpdate, installUpdate }; \ No newline at end of file +function isUpdateAvailable() { + return updateAvailable; +} + +module.exports = { + initAutoUpdater, + downloadUpdate, + installUpdate, + isUpdateAvailable, +}; diff --git a/app/ipcHandlers.js b/app/ipcHandlers.js index 854ca09..15dd06b 100644 --- a/app/ipcHandlers.js +++ b/app/ipcHandlers.js @@ -13,13 +13,14 @@ const { ipcMain, dialog, shell } = require("electron"); const fs = require("fs"); const { logger, logDir } = require("../server/logger"); -const {configFeatures, featuresPath, devMode} = require("../config"); +const { configFeatures, featuresPath, devMode } = require("../config"); const { getThemes, reloadThemes } = require("./themeManager"); const config = require("../config"); const { validateDownloadPath, getDefaultDownloadPath } = require("./pathValidator"); const { userThemesPath } = require("../server/helpers/path.helpers"); const { createSystemTray, destroyTray } = require("./tray"); -const {sendReport} = require("./sendReport"); +const { sendReport } = require("./sendReport"); +const { isUpdateAvailable, downloadUpdate, installUpdate } = require("./autoUpdater"); /** * Security whitelist for feature flags that can be modified at runtime. @@ -65,7 +66,6 @@ const themeFolderPath = userThemesPath; * @param {Function} getMainWindow - Function returning current BrowserWindow instance */ function registerIpcHandlers(getMainWindow) { - /** * Returns application version from config. */ @@ -116,17 +116,16 @@ function registerIpcHandlers(getMainWindow) { * Window minimize request from renderer. */ ipcMain.on("window-minimize", () => { - // Minimize to tray - if (configFeatures.systemTray) { - getMainWindow()?.hide(); - logger.info("Window Minimized in SystemTray (Using Minimize Button)"); - } else { - // Native minimize - getMainWindow()?.minimize(); - logger.info("Window minimized normally"); - } - } - ); + // Minimize to tray + if (configFeatures.systemTray) { + getMainWindow()?.hide(); + logger.info("Window Minimized in SystemTray (Using Minimize Button)"); + } else { + // Native minimize + getMainWindow()?.minimize(); + logger.info("Window minimized normally"); + } + }); /** * Toggles maximize/unmaximize state of main window. @@ -142,7 +141,6 @@ function registerIpcHandlers(getMainWindow) { */ ipcMain.on("window-close", () => getMainWindow()?.close()); - /** * Opens Chromium DevTools in detached mode. */ @@ -153,7 +151,7 @@ function registerIpcHandlers(getMainWindow) { /** * Opens application logs directory in system file explorer. */ - ipcMain.on("open-logs", () => logDir && shell.openPath(logDir)); + ipcMain.on("open-logs", () => logDir && shell.openPath(logDir)); /** * Opens external website in default browser. @@ -212,6 +210,24 @@ function registerIpcHandlers(getMainWindow) { return await sendReport(params); }); + ipcMain.handle("is-update-available", () => { + return isUpdateAvailable(); + }); + + ipcMain.handle("download-update", async () => { + return await downloadUpdate(); + }); + + ipcMain.handle("install-update", async () => { + return installUpdate(); + }); + + ipcMain.on("open-release-page", () => { + shell.openExternal( + "https://github.com/MasterAcnolo/Freedom-Loader/releases/latest", + ); + }); + /** * Updates a runtime feature flag and persists it to disk. * @@ -229,47 +245,46 @@ function registerIpcHandlers(getMainWindow) { * @returns {boolean} success state */ ipcMain.handle("set-feature", (event, { key, value }) => { - try { - if (!FEATURE_WHITELIST.has(key)) { - logger.warn(`Rejected feature (not whitelisted): ${key}`); - return false; - } + try { + if (!FEATURE_WHITELIST.has(key)) { + logger.warn(`Rejected feature (not whitelisted): ${key}`); + return false; + } - if (configFeatures[key] === value) { - return true; - } + if (configFeatures[key] === value) { + return true; + } - /** - * Dynamically intercepts and applies System Tray state changes at runtime. - * Instantiates or destroys the tray icon immediately to prevent window - * lifecycle desynchronization (e.g., minimizing a window to a non-existent tray). - */ - if (key === "systemTray") { - if (value === true) { - logger.info("System Tray enabled dynamically."); - createSystemTray(devMode); - } else { - logger.info("System Tray disabled dynamically."); - destroyTray(); - } - } - - configFeatures[key] = value; - - fs.writeFileSync( - configFolderPath, - JSON.stringify(configFeatures, null, 2), - "utf-8" - ); - - logger.info(`Feature updated: ${key} = ${value}`); - return true; - - } catch (err) { - logger.error(`set-feature failed (${key}): ${err.message}`); - return false; + /** + * Dynamically intercepts and applies System Tray state changes at runtime. + * Instantiates or destroys the tray icon immediately to prevent window + * lifecycle desynchronization (e.g., minimizing a window to a non-existent tray). + */ + if (key === "systemTray") { + if (value === true) { + logger.info("System Tray enabled dynamically."); + createSystemTray(devMode); + } else { + logger.info("System Tray disabled dynamically."); + destroyTray(); } - }); + } + + configFeatures[key] = value; + + fs.writeFileSync( + configFolderPath, + JSON.stringify(configFeatures, null, 2), + "utf-8", + ); + + logger.info(`Feature updated: ${key} = ${value}`); + return true; + } catch (err) { + logger.error(`set-feature failed (${key}): ${err.message}`); + return false; + } + }); } -module.exports = { registerIpcHandlers }; \ No newline at end of file +module.exports = { registerIpcHandlers }; diff --git a/preload.js b/preload.js index 7bc2b2f..82363c8 100644 --- a/preload.js +++ b/preload.js @@ -8,7 +8,6 @@ const { contextBridge, ipcRenderer } = require("electron"); * All calls are explicitly whitelisted. */ contextBridge.exposeInMainWorld("electronAPI", { - /** * Sends error log message. * @@ -63,12 +62,11 @@ contextBridge.exposeInMainWorld("electronAPI", { * @param {string} key - Feature name * @param {any} value - Feature value */ - setFeature: (key, value) => - ipcRenderer.invoke("set-feature", { key, value }), + setFeature: (key, value) => ipcRenderer.invoke("set-feature", { key, value }), /** * Returns the current application version. - */ + */ getVersion: () => ipcRenderer.invoke("version"), /** @@ -88,8 +86,61 @@ contextBridge.exposeInMainWorld("electronAPI", { * Forces a reload of theme files (useful after modification/import). */ reloadThemes: () => ipcRenderer.invoke("reload-themes"), -}); + /* + * ========================================== + * Auto Updater IPC + * ========================================== + */ + + /** + * Checks if an update has already been detected by the main process. + * + * @returns {Promise} Resolves to true if an update is available. + */ + isUpdateAvailable: () => ipcRenderer.invoke("is-update-available"), + + /** + * Subscribes to the update available event pushed by the main process. + * + * @param {Function} callback - Function executed with update info when an update is found. + */ + onUpdateAvailable: (callback) => + ipcRenderer.on("update-available", (_, info) => callback(info)), + + /** + * Instructs the main process to start downloading the available update. + * + * @returns {Promise} + */ + downloadUpdate: () => ipcRenderer.invoke("download-update"), + + /** + * Instructs the main process to quit the application and install the downloaded update. + * + * @returns {Promise} + */ + installUpdate: () => ipcRenderer.invoke("install-update"), + + /** + * Subscribes to the download progress event. + * + * @param {Function} callback - Function executed with progress metrics (percent, bytesPerSecond, transferred, total). + */ + onDownloadProgress: (callback) => + ipcRenderer.on("update-progress", (_, progress) => callback(progress)), + + /** + * Subscribes to the update downloaded event. + * Triggered when the update file is fully downloaded and ready to install. + * + * @param {Function} callback - Function executed with update info. + */ + onUpdateDownloaded: (callback) => + ipcRenderer.on("update-downloaded", (_, info) => callback(info)), + + openReleasePage: () => ipcRenderer.send("open-release-page"), +}); /** * Exposes window control and developer utilities for the custom topbar UI. @@ -97,7 +148,6 @@ contextBridge.exposeInMainWorld("electronAPI", { * These methods forward commands to the Electron main process via IPC. */ contextBridge.exposeInMainWorld("topbarAPI", { - /** * Minimizes the application window. */ @@ -154,5 +204,5 @@ contextBridge.exposeInMainWorld("topbarAPI", { * @param params * @returns {Promise} */ - sendReport: (params) => ipcRenderer.invoke('send-report', params), + sendReport: (params) => ipcRenderer.invoke("send-report", params), }); \ No newline at end of file diff --git a/public/index.html b/public/index.html index aec5bf2..777d620 100644 --- a/public/index.html +++ b/public/index.html @@ -12,7 +12,8 @@ - + + @@ -304,6 +305,31 @@

Because Why Not ?

+ @@ -316,6 +342,7 @@ + diff --git a/public/script/autoUpdate.js b/public/script/autoUpdate.js new file mode 100644 index 0000000..f8fbcad --- /dev/null +++ b/public/script/autoUpdate.js @@ -0,0 +1,81 @@ +async function initUpdateUI() { + const container = document.getElementById("updateAvailable"); + + const hasUpdate = await window.electronAPI.isUpdateAvailable(); + if (hasUpdate) { + setupAndShowUpdateUI(); + } + + window.electronAPI.onUpdateAvailable((info) => { + setupAndShowUpdateUI(info); + }); + + function setupAndShowUpdateUI(info) { + container.style.display = "block"; + + const btn = document.getElementById("btn-update"); + const icon = document.getElementById("update-icon"); + const title = document.getElementById("update-title"); + const sub = document.getElementById("update-sub"); + const progressWrap = document.getElementById("progress-wrap"); + const progressFill = document.getElementById("progress-fill"); + const progressPct = document.getElementById("progress-pct"); + const progressEta = document.getElementById("progress-eta"); + + if (info && info.version) { + sub.textContent = `A new version (${info.version}) is ready`; + } + + if (info && info.environment && info.environment !== "auto") { + icon.textContent = "open_in_new"; + + if (info.environment === "snap") { + sub.textContent = `Version ${info.version} is out. Use the Snap Store or run "snap refresh".`; + btn.textContent = "View Release"; + } else { + sub.textContent = `Version ${info.version} is out. Update via your package manager or download it manually.`; + btn.textContent = "Download manually"; + } + + btn.onclick = () => { + window.electronAPI.openReleasePage(); + container.style.display = "none"; + }; + + return; + } + + btn.onclick = () => { + window.electronAPI.downloadUpdate(); + btn.disabled = true; + btn.textContent = "Downloading..."; + icon.textContent = "download"; + title.textContent = "Downloading update"; + progressWrap.style.display = "flex"; + }; + + window.electronAPI.onDownloadProgress((progress) => { + const pct = Math.round(progress.percent); + progressFill.style.width = pct + "%"; + progressPct.textContent = pct + "%"; + + const remaining = (progress.total - progress.transferred) / progress.bytesPerSecond; + progressEta.textContent = + remaining > 0 ? `~${Math.ceil(remaining)}s left` : "almost done"; + }); + + window.electronAPI.onUpdateDownloaded(() => { + progressFill.style.width = "100%"; + progressPct.textContent = "100%"; + progressEta.textContent = "Done"; + icon.textContent = "check"; + title.textContent = "Ready to install"; + sub.textContent = "Restart to apply the update"; + btn.textContent = "Restart"; + btn.disabled = false; + btn.onclick = () => window.electronAPI.installUpdate(); + }); + } +} + +initUpdateUI(); diff --git a/public/styles/components/autoUpdate.css b/public/styles/components/autoUpdate.css new file mode 100644 index 0000000..b19fcac --- /dev/null +++ b/public/styles/components/autoUpdate.css @@ -0,0 +1,128 @@ +:root { + --border-accent: #0ea5e966; + --bg-accent: #0ea5e915; + --fill-accent: #0ea5e9; + --text-accent: #0369a1; + --text-secondary: #64748b; +} + +#updateAvailable { + display: none; + width: fit-content; + max-width: 420px; + position: absolute; + right: 18px; + bottom: 5%; + z-index: 50; +} + +.update-banner { + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px 16px; + border-radius: 10px; + border: 0.5px solid var(--border-accent); + background: var(--bg-accent); + backdrop-filter: blur(8px); +} + +.banner-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.banner-left { + display: flex; + align-items: center; + gap: 12px; + flex: 1; +} + +.icon-wrap { + width: 32px; + height: 32px; + border-radius: 8px; + background: var(--fill-accent); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.icon-wrap i { + font-size: 16px; + color: #fff; +} + +.text-block { + display: flex; + flex-direction: column; + gap: 4px; +} + +.update-title { + font-size: 13px; + font-weight: 600; + color: var(--text-accent); +} + +.update-sub { + font-size: 11px; + color: var(--text-secondary); + line-height: 1.4; +} + +.btn-update { + font-size: 12px; + font-weight: 500; + padding: 6px 14px; + border-radius: 8px; + background: var(--fill-accent); + color: #fff; + border: none; + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; + transition: opacity 0.2s; +} + +.btn-update:hover:not(:disabled) { + opacity: 0.9; +} + +.btn-update:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.progress-wrap { + display: flex; + flex-direction: column; + gap: 6px; +} + +.progress-track { + height: 4px; + border-radius: 99px; + background: var(--border-accent); + overflow: hidden; + width: 100%; +} + +.progress-fill { + height: 100%; + border-radius: 99px; + background: var(--fill-accent); + width: 0%; + transition: width 0.3s ease; +} + +.progress-meta { + font-size: 11px; + color: var(--text-secondary); + display: flex; + justify-content: space-between; +} diff --git a/public/styles/styles.css b/public/styles/styles.css index 948263a..965d5a2 100644 --- a/public/styles/styles.css +++ b/public/styles/styles.css @@ -12,6 +12,7 @@ @import url("components/editpathbutton.css"); @import url("components/progressBar.css"); @import url("components/toast.css"); +@import url("components/autoUpdate.css"); @import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap');