diff --git a/Makefile b/Makefile index 59a88596b0e..a85e8b85205 100644 --- a/Makefile +++ b/Makefile @@ -20,8 +20,9 @@ APP_REVISION := $(shell grep ^APP_REVISION web/version.py | awk -F"=" '{print $$ # Include only platform-independent builds in all all: docs pip src +# Add BUILD_OPTS variable to pass arguments appbundle: - ./pkg/mac/build.sh + ./pkg/mac/build.sh $(BUILD_OPTS) install-node: cd web && yarn install diff --git a/docs/en_US/desktop_deployment.rst b/docs/en_US/desktop_deployment.rst index cdc11678cff..74492baaa3d 100644 --- a/docs/en_US/desktop_deployment.rst +++ b/docs/en_US/desktop_deployment.rst @@ -128,3 +128,156 @@ The configuration settings are stored in *runtime_config.json* file, which will be available on Unix systems (~/.local/share/pgadmin/), on Mac OS X (~/Library/Preferences/pgadmin), and on Windows (%APPDATA%/pgadmin). + + +Auto-Update of pgAdmin 4 Desktop Application +******************************************** + +pgAdmin 4's desktop application includes an automated update system built using +Electron's ``autoUpdater`` module. This feature enables users to receive and install +updates seamlessly, ensuring they always have access to the latest features and security fixes. + +Supported Platforms +=================== + +- **macOS:** Fully supported with automatic updates enabled by default +- **Windows:** Not supported +- **Linux:** Not supported + +Update Process Overview +======================= + +1. **Check for Updates:** + + - Automatic check on application startup + - Manual check available via pgAdmin 4 menu > Check for Updates + - Uses Electron's ``autoUpdater`` API to query update server + +2. **Download Process:** + + - Updates download automatically when detected + - Progress shown via notifications + - Background download prevents interruption of work + +3. **Installation Flow:** + + - User prompted to Install & Restart or Restart Later when update ready + - Update applied during application restart + + The flow chart for the update process is as follows: + + .. image:: images/auto_update_desktop_app.png + :alt: Runtime View Log + :align: center + +Technical Architecture +====================== + +1. **Main Process** + + Handles core update functionality: + + File: runtime/src/js/autoUpdaterHandler.js + + .. code-block:: javascript + + autoUpdater.on('checking-for-update', () => { + misc.writeServerLog('[Auto-Updater]: Checking for update...'); + }); + + autoUpdater.on('update-available', () => { + setConfigAndRefreshMenu('update-available'); + misc.writeServerLog('[Auto-Updater]: Update downloading...'); + pgAdminMainScreen.webContents.send('notifyAppAutoUpdate', {update_downloading: true}); + }); + +2. **Renderer Process** + + Manages user interface updates: + + File: web/pgadmin/static/js/BrowserComponent.jsx + + .. code-block:: javascript + + if (window.electronUI && typeof window.electronUI.notifyAppAutoUpdate === 'function') { + window.electronUI.notifyAppAutoUpdate((data)=>{ + if (data?.check_version_update) { + pgAdmin.Browser.check_version_update(true); + } else if (data.update_downloading) { + appAutoUpdateNotifier('Update downloading...', 'info', null, 10000); + } else if (data.no_update_available) { + appAutoUpdateNotifier('No update available...', 'info', null, 10000); + } else if (data.update_downloaded) { + const UPDATE_DOWNLOADED_MESSAGE = gettext('An update is ready. Restart the app now to install it, or later to keep using the current version.'); + appAutoUpdateNotifier(UPDATE_DOWNLOADED_MESSAGE, 'warning', installUpdate, null, 'Update downloaded', 'update_downloaded'); + } else if (data.error) { + appAutoUpdateNotifier(`${data.errMsg}`, 'error'); + } else if (data.update_installed) { + const UPDATE_INSTALLED_MESSAGE = gettext('Update installed successfully!'); + appAutoUpdateNotifier(UPDATE_INSTALLED_MESSAGE, 'success'); + } + }); + } + +3. **Update Server Communication** + + - Configures update feed URL based on version information + - Handles server response validation + - Manages error conditions + +User Interface Components +========================= + +1. **Notification Types:** + + - Update available + - Download progress + - Update ready to install + - Error notifications + +2. **Menu Integration:** + + - Check for Updates option in pgAdmin 4 menu + - Restart to Update option when update available + +Error Handling +============== + +The system includes comprehensive error handling: + +1. **Network Errors:** + + - Connection timeouts + - Download failures + - Server unavailability + +2. **Installation Errors:** + + - Corrupted downloads + +3. **Recovery Mechanisms:** + + - Fallback to manual update + - Error reporting to logs + +Security Considerations +======================= + +The update system implements below security measures: + +1. **Secure Communication:** + + - Protected update metadata + +Platform-Specific Notes +======================= + +1. **macOS:** + + - Uses native update mechanisms + - Requires signed packages + +References +========== + +- `Electron autoUpdater API Documentation `_ \ No newline at end of file diff --git a/docs/en_US/images/auto_update_desktop_app.png b/docs/en_US/images/auto_update_desktop_app.png new file mode 100644 index 00000000000..3c8b0207f35 Binary files /dev/null and b/docs/en_US/images/auto_update_desktop_app.png differ diff --git a/pkg/mac/README.md b/pkg/mac/README.md index 97781d287fa..d95631abdc9 100644 --- a/pkg/mac/README.md +++ b/pkg/mac/README.md @@ -31,11 +31,15 @@ Either build the sources or get them from macports or similar: *notarization.conf* and set the values accordingly. Note that notarization will fail if the code isn't signed. -4. To build, go to pgAdmin4 source root directory and execute: +4. To build only DMG file, go to pgAdmin4 source root directory and execute: make appbundle + + To build both DMG and ZIP files, go to pgAdmin4 source root directory and execute: + + make appbundle BUILD_OPTS="--zip" This will create the python virtual environment and install all the required python modules mentioned in the requirements file using pip, build the - runtime code and finally create the app bundle and the DMG in *./dist* + runtime code and finally create the app bundle and the DMG and/or ZIP in *./dist* directory. diff --git a/pkg/mac/build-functions.sh b/pkg/mac/build-functions.sh index 8c6c8d6423b..590c6f86104 100644 --- a/pkg/mac/build-functions.sh +++ b/pkg/mac/build-functions.sh @@ -385,6 +385,24 @@ _codesign_bundle() { -i org.pgadmin.pgadmin4 \ --sign "${DEVELOPER_ID}" \ "${BUNDLE_DIR}" + + echo "Verifying the signature from bundle dir..." + codesign --verify --deep --verbose=4 "${BUNDLE_DIR}" +} + +_create_zip() { + ZIP_NAME="${DMG_NAME%.dmg}.zip" + echo "ZIP_NAME: ${ZIP_NAME}" + + echo "Compressing pgAdmin 4.app in bundle dir into ${ZIP_NAME}..." + ditto -c -k --sequesterRsrc --keepParent "${BUNDLE_DIR}" "${ZIP_NAME}" + + if [ $? -ne 0 ]; then + echo "Failed to create the ZIP file. Exiting." + exit 1 + fi + + echo "Successfully created ZIP file: ${ZIP_NAME}" } _create_dmg() { @@ -426,17 +444,22 @@ _codesign_dmg() { "${DMG_NAME}" } - _notarize_pkg() { + local FILE_NAME="$1" + local STAPLE_TARGET="$2" + local FILE_LABEL="$3" + if [ "${CODESIGN}" -eq 0 ]; then return fi - echo "Uploading DMG for Notarization ..." - STATUS=$(xcrun notarytool submit "${DMG_NAME}" \ - --team-id "${DEVELOPER_TEAM_ID}" \ - --apple-id "${DEVELOPER_USER}" \ - --password "${DEVELOPER_ASP}" 2>&1) + echo "Uploading ${FILE_LABEL} for Notarization ..." + STATUS=$(xcrun notarytool submit "${FILE_NAME}" \ + --team-id "${DEVELOPER_TEAM_ID}" \ + --apple-id "${DEVELOPER_USER}" \ + --password "${DEVELOPER_ASP}" 2>&1) + + echo "${STATUS}" # Get the submission ID SUBMISSION_ID=$(echo "${STATUS}" | awk -F ': ' '/id:/ { print $2; exit; }') @@ -444,16 +467,16 @@ _notarize_pkg() { echo "Waiting for Notarization to be completed ..." xcrun notarytool wait "${SUBMISSION_ID}" \ - --team-id "${DEVELOPER_TEAM_ID}" \ - --apple-id "${DEVELOPER_USER}" \ - --password "${DEVELOPER_ASP}" + --team-id "${DEVELOPER_TEAM_ID}" \ + --apple-id "${DEVELOPER_USER}" \ + --password "${DEVELOPER_ASP}" # Print status information REQUEST_STATUS=$(xcrun notarytool info "${SUBMISSION_ID}" \ - --team-id "${DEVELOPER_TEAM_ID}" \ - --apple-id "${DEVELOPER_USER}" \ - --password "${DEVELOPER_ASP}" 2>&1 | \ - awk -F ': ' '/status:/ { print $2; }') + --team-id "${DEVELOPER_TEAM_ID}" \ + --apple-id "${DEVELOPER_USER}" \ + --password "${DEVELOPER_ASP}" 2>&1 | \ + awk -F ': ' '/status:/ { print $2; }') if [[ "${REQUEST_STATUS}" != "Accepted" ]]; then echo "Notarization failed." @@ -461,11 +484,28 @@ _notarize_pkg() { fi # Staple the notarization - echo "Stapling the notarization to the pgAdmin DMG..." - if ! xcrun stapler staple "${DMG_NAME}"; then + echo "Stapling the notarization to the ${FILE_LABEL}..." + if ! xcrun stapler staple "${STAPLE_TARGET}"; then echo "Stapling failed." exit 1 fi + # For ZIP, recreate the zip after stapling + if [[ "${FILE_LABEL}" == "ZIP" ]]; then + ditto -c -k --keepParent "${BUNDLE_DIR}" "${ZIP_NAME}" + if [ $? != 0 ]; then + echo "ERROR: could not staple ${ZIP_NAME}" + exit 1 + fi + fi + echo "Notarization completed successfully." } + +_notarize_zip() { + _notarize_pkg "${ZIP_NAME}" "${BUNDLE_DIR}" "ZIP" +} + +_notarize_dmg() { + _notarize_pkg "${DMG_NAME}" "${DMG_NAME}" "DMG" +} diff --git a/pkg/mac/build.sh b/pkg/mac/build.sh index 5f29636a497..45b483890a1 100755 --- a/pkg/mac/build.sh +++ b/pkg/mac/build.sh @@ -57,6 +57,32 @@ if [ "${PGADMIN_PYTHON_VERSION}" == "" ]; then export PGADMIN_PYTHON_VERSION=3.13.1 fi +# Initialize variables +CREATE_ZIP=0 +CREATE_DMG=1 + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + case $1 in + --zip) + CREATE_ZIP=1 + shift + ;; + --help) + echo "Usage: $0 [OPTIONS]" + echo "Options:" + echo " --zip Create both ZIP and DMG files" + echo " --help Display this help message" + exit 0 + ;; + *) + echo "Unknown option: $1" + echo "Use --help for usage information" + exit 1 + ;; + esac +done + # shellcheck disable=SC1091 source "${SCRIPT_DIR}/build-functions.sh" @@ -69,6 +95,16 @@ _complete_bundle _generate_sbom _codesign_binaries _codesign_bundle -_create_dmg -_codesign_dmg -_notarize_pkg + +# Handle ZIP creation if requested +if [ "${CREATE_ZIP}" -eq 1 ]; then + _create_zip + _notarize_zip +fi + +# Handle DMG creation if not disabled +if [ "${CREATE_DMG}" -eq 1 ]; then + _create_dmg + _codesign_dmg + _notarize_dmg +fi diff --git a/runtime/src/js/autoUpdaterHandler.js b/runtime/src/js/autoUpdaterHandler.js new file mode 100644 index 00000000000..5e2f0774aef --- /dev/null +++ b/runtime/src/js/autoUpdaterHandler.js @@ -0,0 +1,128 @@ +import { autoUpdater, ipcMain } from 'electron'; +import { refreshMenus } from './menu.js'; +import * as misc from './misc.js'; + +// This function stores the flags in configStore that are needed +// for auto-update and refreshes menus +export function updateConfigAndMenus(event, configStore, pgAdminMainScreen, menuCallbacks) { + const flags = { + 'update-available': { update_downloading: true }, + 'update-not-available': { update_downloading: false }, + 'update-downloaded': { update_downloading: false, update_downloaded: true }, + 'error-close': { update_downloading: false, update_downloaded: false }, + }; + const flag = flags[event]; + if (flag) { + Object.entries(flag).forEach(([k, v]) => configStore.set(k, v)); + refreshMenus(pgAdminMainScreen, configStore, menuCallbacks); + } +} + +// This function registers autoUpdater event listeners ONCE +function registerAutoUpdaterEvents({ pgAdminMainScreen, configStore, menuCallbacks }) { + autoUpdater.on('checking-for-update', () => { + misc.writeServerLog('[Auto-Updater]: Checking for update...'); + }); + + autoUpdater.on('update-available', () => { + updateConfigAndMenus('update-available', configStore, pgAdminMainScreen, menuCallbacks); + misc.writeServerLog('[Auto-Updater]: Update downloading...'); + pgAdminMainScreen.webContents.send('notifyAppAutoUpdate', { update_downloading: true }); + }); + + autoUpdater.on('update-not-available', () => { + updateConfigAndMenus('update-not-available', configStore, pgAdminMainScreen, menuCallbacks); + misc.writeServerLog('[Auto-Updater]: No update available...'); + pgAdminMainScreen.webContents.send('notifyAppAutoUpdate', { no_update_available: true }); + }); + + autoUpdater.on('update-downloaded', () => { + updateConfigAndMenus('update-downloaded', configStore, pgAdminMainScreen, menuCallbacks); + misc.writeServerLog('[Auto-Updater]: Update downloaded...'); + pgAdminMainScreen.webContents.send('notifyAppAutoUpdate', { update_downloaded: true }); + }); + + autoUpdater.on('error', (message) => { + updateConfigAndMenus('error-close', configStore, pgAdminMainScreen, menuCallbacks); + misc.writeServerLog(`[Auto-Updater]: ${message}`); + pgAdminMainScreen.webContents.send('notifyAppAutoUpdate', { error: true, errMsg: message }); + }); +} + +// Handles 'sendDataForAppUpdate' IPC event: updates config, refreshes menus, triggers update check, or installs update if requested. +function handleSendDataForAppUpdate({ + pgAdminMainScreen, + configStore, + menuCallbacks, + baseUrl, + UUID, + forceQuitAndInstallUpdate, +}) { + return (_, data) => { + // Only update the auto-update enabled flag and refresh menus if the value has changed or is not set + if (typeof data.check_for_updates !== 'undefined') { + const currentFlag = configStore.get('auto_update_enabled'); + if (typeof currentFlag === 'undefined' || currentFlag !== data.check_for_updates) { + configStore.set('auto_update_enabled', data.check_for_updates); + refreshMenus(pgAdminMainScreen, configStore, menuCallbacks); + } + } + // If auto-update is enabled, proceed with the update check + if ( + data.auto_update_url && + data.upgrade_version && + data.upgrade_version_int && + data.current_version_int && + data.product_name + ) { + const ftpUrl = encodeURIComponent( + `${data.auto_update_url}/pgadmin4-${data.upgrade_version}-${process.arch}.zip` + ); + let serverUrl = `${baseUrl}/misc/auto_update/${data.current_version_int}/${data.upgrade_version}/${data.upgrade_version_int}/${data.product_name}/${ftpUrl}/?key=${UUID}`; + + try { + autoUpdater.setFeedURL({ url: serverUrl }); + misc.writeServerLog('[Auto-Updater]: Initiating update check...'); + autoUpdater.checkForUpdates(); + } catch (err) { + misc.writeServerLog('[Auto-Updater]: Error setting autoUpdater feed URL: ' + err.message); + if (pgAdminMainScreen) { + pgAdminMainScreen.webContents.send('notifyAppAutoUpdate', { + error: true, + errMsg: 'Failed to check for updates. Please try again later.', + }); + } + return; + } + } + // If the user has requested to install the update immediately + if (data.install_update_now) { + forceQuitAndInstallUpdate(); + } + }; +} + +export function setupAutoUpdater({ + pgAdminMainScreen, + configStore, + menuCallbacks, + baseUrl, + UUID, + forceQuitAndInstallUpdate, +}) { + // For now only macOS is supported for electron auto-update + if (process.platform === 'darwin') { + registerAutoUpdaterEvents({ pgAdminMainScreen, configStore, menuCallbacks }); + ipcMain.on( + 'sendDataForAppUpdate', + handleSendDataForAppUpdate({ + pgAdminMainScreen, + configStore, + menuCallbacks, + baseUrl, + UUID, + forceQuitAndInstallUpdate, + }) + ); + } +} \ No newline at end of file diff --git a/runtime/src/js/menu.js b/runtime/src/js/menu.js index 8a3b4c8d870..bd8444ccad9 100644 --- a/runtime/src/js/menu.js +++ b/runtime/src/js/menu.js @@ -12,72 +12,110 @@ import { app, Menu, ipcMain, BrowserWindow } from 'electron'; const isMac = process.platform == 'darwin'; const isLinux = process.platform == 'linux'; let mainMenu; +let cachedMenus; -function buildMenu(pgadminMenus, pgAdminMainScreen, callbacks) { +// Binds click events to all menu and submenu items recursively. +function bindMenuClicks(pgadminMenus, pgAdminMainScreen) { + return pgadminMenus.map((menuItem) => ({ + ...menuItem, + submenu: menuItem.submenu?.map((subMenuItem) => { + const smName = `${menuItem.name}_${subMenuItem.name}`; + return { + ...subMenuItem, + click: () => { + pgAdminMainScreen.webContents.send('menu-click', smName); + }, + submenu: subMenuItem.submenu?.map((deeperSubMenuItem) => ({ + ...deeperSubMenuItem, + click: () => { + pgAdminMainScreen.webContents.send('menu-click', `${smName}_${deeperSubMenuItem.name}`); + }, + })), + }; + }), + })); +} + +// Handles auto-update related menu items for macOS. +// Adds or disables update menu items based on config state. +function handleAutoUpdateMenu(menuFile, configStore, callbacks) { + if (!configStore.get('auto_update_enabled')) return; + if (configStore.get('update_downloaded')) { + // Add "Restart to Update" if update is downloaded + menuFile.submenu.unshift({ + name: 'mnu_restart_to_update', + id: 'mnu_restart_to_update', + label: 'Restart to Update...', + enabled: true, + priority: 998, + click: callbacks['restart_to_update'], + }); + } else { + // Add "Check for Updates" if update is not downloaded + menuFile.submenu.unshift({ + name: 'mnu_check_updates', + id: 'mnu_check_updates', + label: 'Check for Updates...', + enabled: true, + priority: 998, + click: callbacks['check_for_updates'], + }); + } + // Disable "Check for Updates" if update is downloading + if (configStore.get('update_downloading')) { + menuFile.submenu.forEach((item) => { + if (item.id == 'mnu_check_updates') item.enabled = false; + }); + } +} + +// Remove About pgAdmin 4 from help menu and add it to the top of menuFile submenu. +function moveAboutMenuToTop(pgadminMenus, menuFile) { + const helpMenu = pgadminMenus.find((menu) => menu.name == 'help'); + if (!helpMenu) return; + const aboutItem = helpMenu.submenu.find((item) => item.name === 'mnu_about'); + if (!aboutItem) return; + helpMenu.submenu = helpMenu.submenu.filter((item) => item.name !== 'mnu_about'); + menuFile.submenu.unshift(aboutItem); + menuFile.submenu.splice(2, 0, { type: 'separator' }); +} + +// Builds the application menu template and binds menu click events. +// Handles platform-specific menu structure and dynamic menu items. +function buildMenu(pgadminMenus, pgAdminMainScreen, configStore, callbacks) { const template = []; - // bind all menus click event. - pgadminMenus = pgadminMenus.map((menuItem)=>{ - return { - ...menuItem, - submenu: menuItem.submenu?.map((subMenuItem)=>{ - const smName = `${menuItem.name}_${subMenuItem.name}`; - return { - ...subMenuItem, - click: ()=>{ - pgAdminMainScreen.webContents.send('menu-click', smName); - }, - submenu: subMenuItem.submenu?.map((deeperSubMenuItem)=>{ - return { - ...deeperSubMenuItem, - click: ()=>{ - pgAdminMainScreen.webContents.send('menu-click', `${smName}_${deeperSubMenuItem.name}`); - }, - }; - }), - }; - }), - }; - }); + pgadminMenus = bindMenuClicks(pgadminMenus, pgAdminMainScreen); let menuFile = pgadminMenus.shift(); + // macOS-specific menu modifications if (isMac) { - // Remove About pgAdmin 4 from help menu and add it to the top of menuFile submenu. - const helpMenu = pgadminMenus.find((menu) => menu.name == 'help'); - if (helpMenu) { - const aboutItem = helpMenu.submenu.find((item) => item.name === 'mnu_about'); - if (aboutItem) { - helpMenu.submenu = helpMenu.submenu.filter((item) => item.name !== 'mnu_about'); - menuFile.submenu.unshift(aboutItem); - menuFile.submenu.splice(1, 0, { type: 'separator' }); - } - } + handleAutoUpdateMenu(menuFile, configStore, callbacks); + moveAboutMenuToTop(pgadminMenus, menuFile); } - + template.push({ ...menuFile, submenu: [ ...menuFile.submenu, { type: 'separator' }, - { - label: 'View Logs...', click: callbacks['view_logs'], - }, - { - label: 'Configure runtime...', click: callbacks['configure'], - }, + { label: 'View Logs...', click: callbacks['view_logs'] }, + { label: 'Configure runtime...', click: callbacks['configure'] }, { type: 'separator' }, - ...(isMac ? [ - { role: 'hide' }, - { role: 'hideOthers' }, - { role: 'unhide' }, - { type: 'separator' }, - ] : []), + ...(isMac + ? [ + { role: 'hide' }, + { role: 'hideOthers' }, + { role: 'unhide' }, + { type: 'separator' }, + ] + : []), { role: 'quit' }, ], }); - if(isMac) { + if (isMac) { template[0].label = app.name; } @@ -89,8 +127,14 @@ function buildMenu(pgadminMenus, pgAdminMainScreen, callbacks) { { label: 'View', submenu: [ - { label: 'Reload', click: callbacks['reloadApp']}, - { label: 'Toggle Developer Tools', click: ()=>BrowserWindow.getFocusedWindow().webContents.openDevTools({ mode: 'bottom' })}, + { label: 'Reload', click: callbacks['reloadApp'] }, + { + label: 'Toggle Developer Tools', + click: () => + BrowserWindow.getFocusedWindow().webContents.openDevTools({ + mode: 'bottom', + }), + }, { type: 'separator' }, { role: 'resetZoom' }, { role: 'zoomIn' }, @@ -98,22 +142,31 @@ function buildMenu(pgadminMenus, pgAdminMainScreen, callbacks) { { type: 'separator' }, ].concat(isLinux ? [] : [{ role: 'togglefullscreen' }]), }, - { role: 'windowMenu' }, + { role: 'windowMenu' } ); - template.push(pgadminMenus[pgadminMenus.length-1]); + template.push(pgadminMenus[pgadminMenus.length - 1]); return Menu.buildFromTemplate(template); } -export function setupMenu(pgAdminMainScreen, callbacks={}) { +function buildAndSetMenus(menus, pgAdminMainScreen, configStore, callbacks={}) { + mainMenu = buildMenu(menus, pgAdminMainScreen, configStore, callbacks); + if(isMac) { + Menu.setApplicationMenu(mainMenu); + } else { + pgAdminMainScreen.setMenu(mainMenu); + } +} + +export function refreshMenus(pgAdminMainScreen, configStore, callbacks={}) { + buildAndSetMenus(cachedMenus, pgAdminMainScreen, configStore, callbacks); +} + +export function setupMenu(pgAdminMainScreen, configStore, callbacks={}) { ipcMain.on('setMenus', (event, menus)=>{ - mainMenu = buildMenu(menus, pgAdminMainScreen, callbacks); - if(isMac) { - Menu.setApplicationMenu(mainMenu); - } else { - pgAdminMainScreen.setMenu(mainMenu); - } + cachedMenus = menus; //It will be used later for refreshing the menus + buildAndSetMenus(menus, pgAdminMainScreen, configStore, callbacks); ipcMain.on('enable-disable-menu-items', (event, menu, menuItem)=>{ const menuItemObj = mainMenu.getMenuItemById(menuItem?.id); diff --git a/runtime/src/js/pgadmin.js b/runtime/src/js/pgadmin.js index 2b2ef8e7266..5a08743ab2e 100644 --- a/runtime/src/js/pgadmin.js +++ b/runtime/src/js/pgadmin.js @@ -6,7 +6,7 @@ // This software is released under the PostgreSQL Licence // ////////////////////////////////////////////////////////////// -import { app, BrowserWindow, dialog, ipcMain, Menu, shell, screen } from 'electron'; +import { app, BrowserWindow, dialog, ipcMain, Menu, shell, screen, autoUpdater } from 'electron'; import axios from 'axios'; import Store from 'electron-store'; import fs from 'fs'; @@ -17,6 +17,7 @@ import { fileURLToPath } from 'url'; import { setupMenu } from './menu.js'; import contextMenu from 'electron-context-menu'; import { setupDownloader } from './downloader.js'; +import { setupAutoUpdater, updateConfigAndMenus } from './autoUpdaterHandler.js'; const configStore = new Store({ defaults: { @@ -35,9 +36,13 @@ let configureWindow = null, viewLogWindow = null; let serverPort = 5050; +let UUID = crypto.randomUUID(); + let appStartTime = (new Date()).getTime(); const __dirname = path.dirname(fileURLToPath(import.meta.url)); +let baseUrl = `http://127.0.0.1:${serverPort}`; + let docsURLSubStrings = ['www.enterprisedb.com', 'www.postgresql.org', 'www.pgadmin.org', 'help/help']; process.env['ELECTRON_ENABLE_SECURITY_WARNINGS'] = false; @@ -45,6 +50,40 @@ process.env['ELECTRON_ENABLE_SECURITY_WARNINGS'] = false; // Paths to the rest of the app let [pythonPath, pgadminFile] = misc.getAppPaths(__dirname); +const menuCallbacks = { + 'check_for_updates': ()=>{ + pgAdminMainScreen.webContents.send('notifyAppAutoUpdate', {check_version_update: true}); + }, + 'restart_to_update': ()=>{ + forceQuitAndInstallUpdate(); + }, + 'view_logs': ()=>{ + if(viewLogWindow === null || viewLogWindow?.isDestroyed()) { + viewLogWindow = new BrowserWindow({ + show: false, + width: 800, + height: 460, + position: 'center', + resizable: false, + parent: pgAdminMainScreen, + icon: '../../assets/pgAdmin4.png', + webPreferences: { + preload: path.join(__dirname, 'other_preload.js'), + }, + }); + viewLogWindow.loadFile('./src/html/view_log.html'); + viewLogWindow.once('ready-to-show', ()=>{ + viewLogWindow.show(); + }); + } else { + viewLogWindow.hide(); + viewLogWindow.show(); + } + }, + 'configure': openConfigure, + 'reloadApp': reloadApp, +}; + // Do not allow a second instance of pgAdmin to run. const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) { @@ -153,6 +192,28 @@ function reloadApp() { currWin.webContents.reload(); } + +// Remove auto_update_enabled from configStore on app close or quit +function cleanupAutoUpdateFlag() { + if (configStore.has('auto_update_enabled')) { + configStore.delete('auto_update_enabled'); + } +} + +// This function will force quit and install update and restart the app +function forceQuitAndInstallUpdate() { + // Disable beforeunload handlers + const preventUnload = (event) => { + event.preventDefault(); + pgAdminMainScreen.webContents.off('will-prevent-unload', preventUnload); + }; + pgAdminMainScreen.webContents.on('will-prevent-unload', preventUnload); + // Set flag to show notification after restart + configStore.set('update_installed', true); + cleanupAutoUpdateFlag(); + autoUpdater.quitAndInstall(); +} + // This functions is used to start the pgAdmin4 server by spawning a // separate process. function startDesktopMode() { @@ -162,7 +223,6 @@ function startDesktopMode() { return; let pingIntervalID; - let UUID = crypto.randomUUID(); // Set the environment variables so that pgAdmin 4 server // starts listening on the appropriate port. process.env.PGADMIN_INT_PORT = serverPort; @@ -170,7 +230,7 @@ function startDesktopMode() { process.env.PGADMIN_SERVER_MODE = 'OFF'; // Start Page URL - const baseUrl = `http://127.0.0.1:${serverPort}`; + baseUrl = `http://127.0.0.1:${serverPort}`; startPageUrl = `${baseUrl}/?key=${UUID}`; serverCheckUrl = `${baseUrl}/misc/ping?key=${UUID}`; @@ -307,36 +367,10 @@ function launchPgAdminWindow() { splashWindow.close(); pgAdminMainScreen.webContents.session.clearCache(); - setupMenu(pgAdminMainScreen, { - 'view_logs': ()=>{ - if(viewLogWindow === null || viewLogWindow?.isDestroyed()) { - viewLogWindow = new BrowserWindow({ - show: false, - width: 800, - height: 460, - position: 'center', - resizable: false, - parent: pgAdminMainScreen, - icon: '../../assets/pgAdmin4.png', - webPreferences: { - preload: path.join(__dirname, 'other_preload.js'), - }, - }); - viewLogWindow.loadFile('./src/html/view_log.html'); - viewLogWindow.once('ready-to-show', ()=>{ - viewLogWindow.show(); - }); - } else { - viewLogWindow.hide(); - viewLogWindow.show(); - } - }, - 'configure': openConfigure, - 'reloadApp': reloadApp, - }); - - setupDownloader(); - + setupMenu(pgAdminMainScreen, configStore, menuCallbacks); + + setupDownloader() + pgAdminMainScreen.loadURL(startPageUrl); const bounds = configStore.get('bounds'); @@ -346,6 +380,15 @@ function launchPgAdminWindow() { pgAdminMainScreen.show(); + setupAutoUpdater({ + pgAdminMainScreen, + configStore, + menuCallbacks, + baseUrl, + UUID, + forceQuitAndInstallUpdate, + }); + pgAdminMainScreen.webContents.setWindowOpenHandler(({url})=>{ let openDocsInBrowser = configStore.get('openDocsInBrowser', true); let isDocURL = false; @@ -377,18 +420,50 @@ function launchPgAdminWindow() { }); pgAdminMainScreen.on('closed', ()=>{ + cleanupAutoUpdateFlag(); misc.cleanupAndQuitApp(); }); pgAdminMainScreen.on('close', () => { configStore.set('bounds', pgAdminMainScreen.getBounds()); + updateConfigAndMenus('error-close', configStore, pgAdminMainScreen, menuCallbacks); pgAdminMainScreen.removeAllListeners('close'); pgAdminMainScreen.close(); }); + + // Notify if update was installed (fix: always check after main window is ready) + notifyUpdateInstalled(); } let splashWindow; +// Helper to notify update installed after restart +function notifyUpdateInstalled() { + if (configStore.get('update_installed')) { + try { + // Notify renderer + if (pgAdminMainScreen) { + misc.writeServerLog('[Auto-Updater]: Update installed successfully...'); + setTimeout(() => { + pgAdminMainScreen.webContents.send('notifyAppAutoUpdate', {update_installed: true}); + }, 10000); + } else { + // If main screen not ready, wait and send after it's created + app.once('browser-window-created', (event, window) => { + misc.writeServerLog('[Auto-Updater]: Update installed successfully...'); + setTimeout(() => { + pgAdminMainScreen.webContents.send('notifyAppAutoUpdate', {update_installed: true}); + }, 10000); + }); + } + // Reset the flag + configStore.set('update_installed', false); + } catch (err) { + misc.writeServerLog(`[Auto-Updater]: ${err}`); + } + } +} + // setup preload events. ipcMain.handle('showOpenDialog', (e, options) => dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender), options)); ipcMain.handle('showSaveDialog', (e, options) => dialog.showSaveDialog(BrowserWindow.fromWebContents(e.sender), options)); @@ -406,7 +481,7 @@ ipcMain.on('restartApp', ()=>{ app.relaunch(); app.exit(0); }); -ipcMain.on('log', (_e, text) => ()=>{ +ipcMain.on('log', (_e, text) => { misc.writeServerLog(text); }); ipcMain.on('focus', (e) => { @@ -428,6 +503,7 @@ ipcMain.handle('checkPortAvailable', async (_e, fixedPort)=>{ }); ipcMain.handle('openConfigure', openConfigure); + app.whenReady().then(() => { splashWindow = new BrowserWindow({ transparent: true, diff --git a/runtime/src/js/pgadmin_preload.js b/runtime/src/js/pgadmin_preload.js index e28a215f783..9a570ed31e7 100644 --- a/runtime/src/js/pgadmin_preload.js +++ b/runtime/src/js/pgadmin_preload.js @@ -31,5 +31,11 @@ contextBridge.exposeInMainWorld('electronUI', { downloadStreamSaveTotal: (...args) => ipcRenderer.send('download-stream-save-total', ...args), downloadStreamSaveEnd: (...args) => ipcRenderer.send('download-stream-save-end', ...args), downloadBase64UrlData: (...args) => ipcRenderer.invoke('download-base64-url-data', ...args), - downloadTextData: (...args) => ipcRenderer.invoke('download-text-data', ...args) + downloadTextData: (...args) => ipcRenderer.invoke('download-text-data', ...args), + //Auto-updater related functions + sendDataForAppUpdate: (data) => ipcRenderer.send('sendDataForAppUpdate', data), + notifyAppAutoUpdate: (callback) => { + ipcRenderer.removeAllListeners('notifyAppAutoUpdate'); // Clean up previous listeners + ipcRenderer.on('notifyAppAutoUpdate', (_, data) => callback(data)); + }, }); \ No newline at end of file diff --git a/web/pgadmin/browser/static/js/browser.js b/web/pgadmin/browser/static/js/browser.js index 438b35ef097..b248761b0e9 100644 --- a/web/pgadmin/browser/static/js/browser.js +++ b/web/pgadmin/browser/static/js/browser.js @@ -15,6 +15,7 @@ import { send_heartbeat, stop_heartbeat } from './heartbeat'; import getApiInstance from '../../../static/js/api_instance'; import usePreferences, { setupPreferenceBroadcast } from '../../../preferences/static/js/store'; import checkNodeVisibility from '../../../static/js/check_node_visibility'; +import {appAutoUpdateNotifier} from '../../../static/js/helpers/appAutoUpdateNotifier'; define('pgadmin.browser', [ 'sources/gettext', 'sources/url_for', 'sources/pgadmin', @@ -270,12 +271,34 @@ define('pgadmin.browser', [ checkMasterPassword(data, self.masterpass_callback_queue, cancel_callback); }, - check_version_update: function() { + check_version_update: async function(trigger_update_check=false) { getApiInstance().get( - url_for('misc.upgrade_check') + url_for('misc.upgrade_check') + '?trigger_update_check=' + trigger_update_check ).then((res)=> { const data = res.data.data; - if(data.outdated) { + window.electronUI?.sendDataForAppUpdate({ + 'check_for_updates': data.check_for_auto_updates, + }); + const isDesktopWithAutoUpdate = pgAdmin.server_mode == 'False' && data.check_for_auto_updates && data.auto_update_url !== ''; + const isUpdateAvailable = data.outdated && data.upgrade_version_int > data.current_version_int; + const noUpdateMessage = 'No update available...'; + // This is for desktop installers whose auto_update_url is mentioned in https://www.pgadmin.org/versions.json + if (isDesktopWithAutoUpdate) { + if (isUpdateAvailable) { + const message = `${gettext('You are currently running version %s of %s, however the current version is %s.', data.current_version, data.product_name, data.upgrade_version)}`; + appAutoUpdateNotifier( + message, + 'warning', + () => { + window.electronUI?.sendDataForAppUpdate(data); + }, + null, + 'Update available', + 'download_update' + ); + } + } else if(data.outdated) { + //This is for server mode or auto-update not supported desktop installer or not mentioned auto_update_url pgAdmin.Browser.notifier.warning( ` ${gettext('You are currently running version %s of %s,
however the current version is %s.', data.current_version, data.product_name, data.upgrade_version)} @@ -285,9 +308,14 @@ define('pgadmin.browser', [ null ); } - - }).catch(function() { - // Suppress any errors + // If the user manually triggered a check for updates (trigger_update_check is true) + // and no update is available (data.outdated is false), show an info notification. + if (!data.outdated && trigger_update_check){ + appAutoUpdateNotifier(noUpdateMessage, 'info', null, 10000); + } + }).catch((error)=>{ + console.error('Error during version check', error); + pgAdmin.Browser.notifier.error(gettext(`${error.response?.data?.errormsg || error?.message}`)); }); }, diff --git a/web/pgadmin/misc/__init__.py b/web/pgadmin/misc/__init__.py index eda2a380ce2..5b619e04039 100644 --- a/web/pgadmin/misc/__init__.py +++ b/web/pgadmin/misc/__init__.py @@ -21,7 +21,7 @@ from pgadmin.utils.session import cleanup_session_files from pgadmin.misc.themes import get_all_themes from pgadmin.utils.ajax import precondition_required, make_json_response, \ - internal_server_error + internal_server_error, make_response from pgadmin.utils.heartbeat import log_server_heartbeat, \ get_server_heartbeat, stop_server_heartbeat import config @@ -32,6 +32,7 @@ import sys import ssl from urllib.request import urlopen +from urllib.parse import unquote from pgadmin.settings import get_setting, store_setting MODULE_NAME = 'misc' @@ -171,7 +172,7 @@ def get_exposed_url_endpoints(self): return ['misc.ping', 'misc.index', 'misc.cleanup', 'misc.validate_binary_path', 'misc.log_heartbeat', 'misc.stop_heartbeat', 'misc.get_heartbeat', - 'misc.upgrade_check'] + 'misc.upgrade_check', 'misc.auto_update'] def register(self, app, options): """ @@ -343,59 +344,138 @@ def validate_binary_path(): methods=['GET']) @pga_login_required def upgrade_check(): - # Get the current version info from the website, and flash a message if - # the user is out of date, and the check is enabled. - ret = { - "outdated": False, - } + """ + Check for application updates and return update metadata to the client. + - Compares current version with remote version data. + - Supports auto-update in desktop mode. + """ + # Determine if this check was manually triggered by the user + trigger_update_check = (request.args.get('trigger_update_check', 'false') + .lower() == 'true') + + platform = None + ret = {"outdated": False} + if config.UPGRADE_CHECK_ENABLED: last_check = get_setting('LastUpdateCheck', default='0') today = time.strftime('%Y%m%d') - if int(last_check) < int(today): - data = None - url = '%s?version=%s' % ( - config.UPGRADE_CHECK_URL, config.APP_VERSION) - current_app.logger.debug('Checking version data at: %s' % url) - try: - # Do not wait for more than 5 seconds. - # It stuck on rendering the browser.html, while working in the - # broken network. - if os.path.exists(config.CA_FILE) and sys.version_info >= ( - 3, 13): - # Use SSL context for Python 3.13+ - context = ssl.create_default_context(cafile=config.CA_FILE) - response = urlopen(url, data=data, timeout=5, - context=context) - elif os.path.exists(config.CA_FILE): - # Use cafile parameter for older versions - response = urlopen(url, data=data, timeout=5, - cafile=config.CA_FILE) - else: - response = urlopen(url, data, 5) - current_app.logger.debug( - 'Version check HTTP response code: %d' % response.getcode() - ) - if response.getcode() == 200: - data = json.loads(response.read().decode('utf-8')) - current_app.logger.debug('Response data: %s' % data) - except Exception: - current_app.logger.exception( - 'Exception when checking for update') - return internal_server_error('Failed to check for update') - - if data is not None and \ - data[config.UPGRADE_CHECK_KEY]['version_int'] > \ - config.APP_VERSION_INT: - ret = { - "outdated": True, - "current_version": config.APP_VERSION, - "upgrade_version": data[config.UPGRADE_CHECK_KEY][ - 'version'], - "product_name": config.APP_NAME, - "download_url": data[config.UPGRADE_CHECK_KEY][ - 'download_url'] - } + data = None + url = '%s?version=%s' % ( + config.UPGRADE_CHECK_URL, config.APP_VERSION) + current_app.logger.debug('Checking version data at: %s' % url) + + # Attempt to fetch upgrade data from remote URL + try: + # Do not wait for more than 5 seconds. + # It stuck on rendering the browser.html, while working in the + # broken network. + if os.path.exists(config.CA_FILE) and sys.version_info >= ( + 3, 13): + # Use SSL context for Python 3.13+ + context = ssl.create_default_context(cafile=config.CA_FILE) + response = urlopen(url, data=data, timeout=5, + context=context) + elif os.path.exists(config.CA_FILE): + # Use cafile parameter for older versions + response = urlopen(url, data=data, timeout=5, + cafile=config.CA_FILE) + else: + response = urlopen(url, data, 5) + current_app.logger.debug( + 'Version check HTTP response code: %d' % response.getcode() + ) + + if response.getcode() == 200: + data = json.loads(response.read().decode('utf-8')) + current_app.logger.debug('Response data: %s' % data) + except Exception: + current_app.logger.exception( + 'Exception when checking for update') + return internal_server_error('Failed to check for update') + + if data: + # Determine platform + if sys.platform == 'darwin': + platform = 'macos' + elif sys.platform == 'win32': + platform = 'windows' + + upgrade_version_int = data[config.UPGRADE_CHECK_KEY]['version_int'] + auto_update_url_exists = data[config.UPGRADE_CHECK_KEY][ + 'auto_update_url'][platform] != '' + + # Construct common response dicts for auto-update support + auto_update_common_res = { + "check_for_auto_updates": True, + "auto_update_url": data[config.UPGRADE_CHECK_KEY][ + 'auto_update_url'][platform], + "platform": platform, + "installer_type": config.UPGRADE_CHECK_KEY, + "current_version": config.APP_VERSION, + "upgrade_version": data[config.UPGRADE_CHECK_KEY]['version'], + "current_version_int": config.APP_VERSION_INT, + "upgrade_version_int": upgrade_version_int, + "product_name": config.APP_NAME, + } + + # Check for updates if the last check was before today(daily check) + if int(last_check) < int(today): + # App is outdated + if upgrade_version_int > config.APP_VERSION_INT: + if not config.SERVER_MODE and auto_update_url_exists: + ret = {**auto_update_common_res, "outdated": True} + else: + # Auto-update unsupported + ret = { + "outdated": True, + "check_for_auto_updates": False, + "current_version": config.APP_VERSION, + "upgrade_version": data[config.UPGRADE_CHECK_KEY][ + 'version'], + "product_name": config.APP_NAME, + "download_url": data[config.UPGRADE_CHECK_KEY][ + 'download_url'] + } + # App is up-to-date, but auto-update should be enabled + elif (upgrade_version_int == config.APP_VERSION_INT and + not config.SERVER_MODE and auto_update_url_exists): + ret = {**auto_update_common_res, "outdated": False} + # If already checked today, + # return auto-update info only if supported + elif (int(last_check) == int(today) and + not config.SERVER_MODE and auto_update_url_exists): + # Check for updates when triggered by user + # and new version is available + if (upgrade_version_int > config.APP_VERSION_INT and + trigger_update_check): + ret = {**auto_update_common_res, "outdated": True} + else: + ret = {**auto_update_common_res, "outdated": False} store_setting('LastUpdateCheck', today) return make_json_response(data=ret) + + +@blueprint.route("/auto_update//" + "////", + methods=['GET']) +@pgCSRFProtect.exempt +def auto_update(current_version_int, latest_version, latest_version_int, + product_name, ftp_url): + """ + Get auto-update information for the desktop app. + + Returns update metadata (download URL and version name) + if a newer version is available. Responds with HTTP 204 + if the current version is up to date. + """ + if latest_version_int > current_version_int: + update_info = { + 'url': unquote(ftp_url), + 'name': f'{product_name} v{latest_version}', + } + current_app.logger.debug(update_info) + return make_response(response=update_info, status=200) + else: + return make_response(status=204) diff --git a/web/pgadmin/settings/__init__.py b/web/pgadmin/settings/__init__.py index 3cb5922563b..fcc18f348bc 100644 --- a/web/pgadmin/settings/__init__.py +++ b/web/pgadmin/settings/__init__.py @@ -35,7 +35,7 @@ def get_own_menuitems(self): 'file_items': [ MenuItem( name='mnu_resetlayout', - priority=998, + priority=997, module="pgAdmin.Settings", callback='show', label=gettext('Reset Layout') diff --git a/web/pgadmin/static/js/BrowserComponent.jsx b/web/pgadmin/static/js/BrowserComponent.jsx index c0a6a9f3ce8..5240e857875 100644 --- a/web/pgadmin/static/js/BrowserComponent.jsx +++ b/web/pgadmin/static/js/BrowserComponent.jsx @@ -35,7 +35,7 @@ import { useWorkspace, WorkspaceProvider } from '../../misc/workspaces/static/js import { PgAdminProvider, usePgAdmin } from './PgAdminProvider'; import PreferencesComponent from '../../preferences/static/js/components/PreferencesComponent'; import { ApplicationStateProvider } from '../../settings/static/ApplicationStateProvider'; - +import { appAutoUpdateNotifier } from './helpers/appAutoUpdateNotifier'; const objectExplorerGroup = { tabLocked: true, @@ -181,6 +181,36 @@ export default function BrowserComponent({pgAdmin}) { isNewTab: true, }); + // Called when Install and Restart btn called for auto-update install + function installUpdate() { + if (window.electronUI) { + window.electronUI.sendDataForAppUpdate({ + 'install_update_now': true + }); + }} + + // Listen for auto-update events from the Electron main process and display notifications + // to the user based on the update status (e.g., update available, downloading, downloaded, installed, or error). + if (window.electronUI && typeof window.electronUI.notifyAppAutoUpdate === 'function') { + window.electronUI.notifyAppAutoUpdate((data)=>{ + if (data?.check_version_update) { + pgAdmin.Browser.check_version_update(true); + } else if (data.update_downloading) { + appAutoUpdateNotifier('Update downloading...', 'info', null, 10000); + } else if (data.no_update_available) { + appAutoUpdateNotifier('No update available...', 'info', null, 10000); + } else if (data.update_downloaded) { + const UPDATE_DOWNLOADED_MESSAGE = gettext('An update is ready. Restart the app now to install it, or later to keep using the current version.'); + appAutoUpdateNotifier(UPDATE_DOWNLOADED_MESSAGE, 'warning', installUpdate, null, 'Update downloaded', 'update_downloaded'); + } else if (data.error) { + appAutoUpdateNotifier(`${data.errMsg}`, 'error'); + } else if (data.update_installed) { + const UPDATE_INSTALLED_MESSAGE = gettext('Update installed successfully!'); + appAutoUpdateNotifier(UPDATE_INSTALLED_MESSAGE, 'success'); + } + }); + } + useEffect(()=>{ if(uiReady) { pgAdmin?.Browser?.uiloaded?.(); diff --git a/web/pgadmin/static/js/Theme/light.js b/web/pgadmin/static/js/Theme/light.js index 68a28abce7b..f507289868d 100644 --- a/web/pgadmin/static/js/Theme/light.js +++ b/web/pgadmin/static/js/Theme/light.js @@ -49,6 +49,8 @@ export default function(basicSettings) { main: '#eea236', light: '#fce5c5', contrastText: '#000', + hoverMain: darken('#eea236', 0.1), + hoverBorderColor: darken('#eea236', 0.1), }, info: { main: '#fde74c', diff --git a/web/pgadmin/static/js/components/FormComponents.jsx b/web/pgadmin/static/js/components/FormComponents.jsx index a40ccbe52b5..2e74c47fb8b 100644 --- a/web/pgadmin/static/js/components/FormComponents.jsx +++ b/web/pgadmin/static/js/components/FormComponents.jsx @@ -1289,6 +1289,7 @@ const StyledNotifierMessageBox = styled(Box)(({theme}) => ({ backgroundColor: theme.palette.warning.light, '& .FormFooter-iconWarning': { color: theme.palette.warning.main, + marginBottom: theme.spacing(8), }, }, '& .FormFooter-message': { diff --git a/web/pgadmin/static/js/helpers/appAutoUpdateNotifier.jsx b/web/pgadmin/static/js/helpers/appAutoUpdateNotifier.jsx new file mode 100644 index 00000000000..d69713c159f --- /dev/null +++ b/web/pgadmin/static/js/helpers/appAutoUpdateNotifier.jsx @@ -0,0 +1,126 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2025, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import React from 'react'; +import { Box } from '@mui/material'; +import { styled } from '@mui/material/styles'; +import CloseIcon from '@mui/icons-material/CloseRounded'; +import PropTypes from 'prop-types'; +import { DefaultButton, PgIconButton } from '../components/Buttons'; +import pgAdmin from 'sources/pgadmin'; + +const StyledBox = styled(Box)(({theme}) => ({ + borderRadius: theme.shape.borderRadius, + padding: '0.25rem 1rem 1rem', + minWidth: '325px', + maxWidth: '400px', + ...theme.mixins.panelBorder.all, + '&.UpdateWarningNotifier-containerWarning': { + borderColor: theme.palette.warning.main, + backgroundColor: theme.palette.warning.light, + }, + '& .UpdateWarningNotifier-containerHeader': { + height: '32px', + display: 'flex', + justifyContent: 'space-between', + fontWeight: 'bold', + alignItems: 'center', + borderTopLeftRadius: 'inherit', + borderTopRightRadius: 'inherit', + '& .UpdateWarningNotifier-iconWarning': { + color: theme.palette.warning.main, + }, + }, + '&.UpdateWarningNotifier-containerBody': { + marginTop: '1rem', + overflowWrap: 'break-word', + }, +})); + +const activeWarningKeys = new Set(); + +function UpdateWarningNotifier({desc, title, onClose, onClick, status, uniqueKey}) { + const handleClose = () => { + if (onClose) onClose(); + if (uniqueKey) { + activeWarningKeys.delete(uniqueKey); + } + }; + return ( + + + {title} + } onClick={handleClose} title={'Close'} className={'UpdateWarningNotifier-iconWarning'} /> + + + {desc && {desc}} + + {onClick && + { + onClick(); + handleClose(); + }}>{status == 'download_update' ? 'Download Update' : 'Install and Restart'} + } + {status == 'update_downloaded' && + { + handleClose(); + }}>Install Later + } + + + + ); +} +UpdateWarningNotifier.propTypes = { + desc: PropTypes.string, + title: PropTypes.string, + onClose: PropTypes.func, + onClick: PropTypes.func, + status: PropTypes.string, + uniqueKey: PropTypes.string, +}; + +export function appAutoUpdateNotifier(desc, type, onClick, hideDuration=null, title='', status='download_update') { + const uniqueKey = `${title}::${desc}`; + + // Check if this warning is already active except error type + if (activeWarningKeys.has(uniqueKey) && type !== 'error') { + // Already showing, do not show again + return; + } + + // Mark this warning as active + activeWarningKeys.add(uniqueKey); + if (type == 'warning') { + pgAdmin.Browser.notifier.notify( + { + // Remove from active keys when closed + activeWarningKeys.delete(uniqueKey); + }} + />, null + ); + } else if(type == 'success') { + pgAdmin.Browser.notifier.success(desc, hideDuration); + } else if(type == 'info') { + pgAdmin.Browser.notifier.info(desc, hideDuration); + } else if(type == 'error') { + pgAdmin.Browser.notifier.error(desc, hideDuration); + } + + // Remove from active keys for valid hideDuration passed in args + setTimeout(()=>{ + hideDuration && activeWarningKeys.delete(uniqueKey); + }); +}