Add support for automatic updates in the pgAdmin 4 Desktop application on macOS. #5766

pull/8852/head
Anil Sahoo 2025-07-31 11:30:19 +05:30 committed by GitHub
parent 6db0cc5c5d
commit 9eec4f5b8c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 5737 additions and 181 deletions

View File

@ -20,8 +20,9 @@ APP_REVISION := $(shell grep ^APP_REVISION web/version.py | awk -F"=" '{print $$
# Include only platform-independent builds in all # Include only platform-independent builds in all
all: docs pip src all: docs pip src
# Add BUILD_OPTS variable to pass arguments
appbundle: appbundle:
./pkg/mac/build.sh ./pkg/mac/build.sh $(BUILD_OPTS)
install-node: install-node:
cd web && yarn install cd web && yarn install

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,96 @@
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: Auto-update Desktop App
:align: center
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 <https://www.electronjs.org/docs/latest/api/auto-updater>`_

View File

@ -128,3 +128,10 @@ The configuration settings are stored in *runtime_config.json* file, which
will be available on Unix systems (~/.local/share/pgadmin/), will be available on Unix systems (~/.local/share/pgadmin/),
on Mac OS X (~/Library/Preferences/pgadmin), on Mac OS X (~/Library/Preferences/pgadmin),
and on Windows (%APPDATA%/pgadmin). and on Windows (%APPDATA%/pgadmin).
For details on the auto-update system for the desktop application, see
.. toctree::
:maxdepth: 1
auto_update_desktop_app

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

View File

@ -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 *notarization.conf* and set the values accordingly. Note that notarization
will fail if the code isn't signed. 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 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 This will create the python virtual environment and install all the required
python modules mentioned in the requirements file using pip, build the 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. directory.

View File

@ -385,6 +385,24 @@ _codesign_bundle() {
-i org.pgadmin.pgadmin4 \ -i org.pgadmin.pgadmin4 \
--sign "${DEVELOPER_ID}" \ --sign "${DEVELOPER_ID}" \
"${BUNDLE_DIR}" "${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() { _create_dmg() {
@ -426,17 +444,22 @@ _codesign_dmg() {
"${DMG_NAME}" "${DMG_NAME}"
} }
_notarize_pkg() { _notarize_pkg() {
local FILE_NAME="$1"
local STAPLE_TARGET="$2"
local FILE_LABEL="$3"
if [ "${CODESIGN}" -eq 0 ]; then if [ "${CODESIGN}" -eq 0 ]; then
return return
fi fi
echo "Uploading DMG for Notarization ..." echo "Uploading ${FILE_LABEL} for Notarization ..."
STATUS=$(xcrun notarytool submit "${DMG_NAME}" \ STATUS=$(xcrun notarytool submit "${FILE_NAME}" \
--team-id "${DEVELOPER_TEAM_ID}" \ --team-id "${DEVELOPER_TEAM_ID}" \
--apple-id "${DEVELOPER_USER}" \ --apple-id "${DEVELOPER_USER}" \
--password "${DEVELOPER_ASP}" 2>&1) --password "${DEVELOPER_ASP}" 2>&1)
echo "${STATUS}"
# Get the submission ID # Get the submission ID
SUBMISSION_ID=$(echo "${STATUS}" | awk -F ': ' '/id:/ { print $2; exit; }') SUBMISSION_ID=$(echo "${STATUS}" | awk -F ': ' '/id:/ { print $2; exit; }')
@ -444,16 +467,16 @@ _notarize_pkg() {
echo "Waiting for Notarization to be completed ..." echo "Waiting for Notarization to be completed ..."
xcrun notarytool wait "${SUBMISSION_ID}" \ xcrun notarytool wait "${SUBMISSION_ID}" \
--team-id "${DEVELOPER_TEAM_ID}" \ --team-id "${DEVELOPER_TEAM_ID}" \
--apple-id "${DEVELOPER_USER}" \ --apple-id "${DEVELOPER_USER}" \
--password "${DEVELOPER_ASP}" --password "${DEVELOPER_ASP}"
# Print status information # Print status information
REQUEST_STATUS=$(xcrun notarytool info "${SUBMISSION_ID}" \ REQUEST_STATUS=$(xcrun notarytool info "${SUBMISSION_ID}" \
--team-id "${DEVELOPER_TEAM_ID}" \ --team-id "${DEVELOPER_TEAM_ID}" \
--apple-id "${DEVELOPER_USER}" \ --apple-id "${DEVELOPER_USER}" \
--password "${DEVELOPER_ASP}" 2>&1 | \ --password "${DEVELOPER_ASP}" 2>&1 | \
awk -F ': ' '/status:/ { print $2; }') awk -F ': ' '/status:/ { print $2; }')
if [[ "${REQUEST_STATUS}" != "Accepted" ]]; then if [[ "${REQUEST_STATUS}" != "Accepted" ]]; then
echo "Notarization failed." echo "Notarization failed."
@ -461,11 +484,28 @@ _notarize_pkg() {
fi fi
# Staple the notarization # Staple the notarization
echo "Stapling the notarization to the pgAdmin DMG..." echo "Stapling the notarization to the ${FILE_LABEL}..."
if ! xcrun stapler staple "${DMG_NAME}"; then if ! xcrun stapler staple "${STAPLE_TARGET}"; then
echo "Stapling failed." echo "Stapling failed."
exit 1 exit 1
fi 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." echo "Notarization completed successfully."
} }
_notarize_zip() {
_notarize_pkg "${ZIP_NAME}" "${BUNDLE_DIR}" "ZIP"
}
_notarize_dmg() {
_notarize_pkg "${DMG_NAME}" "${DMG_NAME}" "DMG"
}

View File

@ -57,6 +57,32 @@ if [ "${PGADMIN_PYTHON_VERSION}" == "" ]; then
export PGADMIN_PYTHON_VERSION=3.13.1 export PGADMIN_PYTHON_VERSION=3.13.1
fi 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 # shellcheck disable=SC1091
source "${SCRIPT_DIR}/build-functions.sh" source "${SCRIPT_DIR}/build-functions.sh"
@ -69,6 +95,16 @@ _complete_bundle
_generate_sbom _generate_sbom
_codesign_binaries _codesign_binaries
_codesign_bundle _codesign_bundle
_create_dmg
_codesign_dmg # Handle ZIP creation if requested
_notarize_pkg 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

View File

@ -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,
})
);
}
}

View File

@ -12,6 +12,7 @@ import { app, Menu, ipcMain, BrowserWindow, globalShortcut } from 'electron';
const isMac = process.platform == 'darwin'; const isMac = process.platform == 'darwin';
const isLinux = process.platform == 'linux'; const isLinux = process.platform == 'linux';
let mainMenu; let mainMenu;
let cachedMenus;
// Use to convert shortcut to accelerator for electron. // Use to convert shortcut to accelerator for electron.
function convertShortcutToAccelerator({ control, meta, shift, alt, key } = {}) { function convertShortcutToAccelerator({ control, meta, shift, alt, key } = {}) {
@ -29,18 +30,27 @@ function convertShortcutToAccelerator({ control, meta, shift, alt, key } = {}) {
return [...mods, k].join('+'); return [...mods, k].join('+');
} }
function buildMenu(pgadminMenus, pgAdminMainScreen, callbacks) { // Binds click events to all menu and submenu items recursively.
const template = []; function bindMenuClicks(pgadminMenus, pgAdminMainScreen) {
return pgadminMenus.map((menuItem) => ({
// bind all menus click event. ...menuItem,
pgadminMenus = pgadminMenus.map((menuItem)=>{ submenu: menuItem.submenu?.map((subMenuItem) => {
return { const smName = `${menuItem.name}_${subMenuItem.name}`;
...menuItem, return {
submenu: menuItem.submenu?.map((subMenuItem)=>{ ...subMenuItem,
const smName = `${menuItem.name}_${subMenuItem.name}`; accelerator: convertShortcutToAccelerator(subMenuItem.shortcut),
return { click: (_menuItem, _browserWindow, event)=>{
...subMenuItem, if(event?.triggeredByAccelerator) {
accelerator: convertShortcutToAccelerator(subMenuItem.shortcut), // We will ignore the click event if it is triggered by an accelerator.
// We use accelerator to only show the shortcut title in the menu.
// The actual shortcut is already handled by pgAdmin.
return;
}
pgAdminMainScreen.webContents.send('menu-click', smName);
},
submenu: subMenuItem.submenu?.map((deeperSubMenuItem) => ({
...deeperSubMenuItem,
accelerator: convertShortcutToAccelerator(deeperSubMenuItem.shortcut),
click: (_menuItem, _browserWindow, event)=>{ click: (_menuItem, _browserWindow, event)=>{
if(event?.triggeredByAccelerator) { if(event?.triggeredByAccelerator) {
// We will ignore the click event if it is triggered by an accelerator. // We will ignore the click event if it is triggered by an accelerator.
@ -48,66 +58,94 @@ function buildMenu(pgadminMenus, pgAdminMainScreen, callbacks) {
// The actual shortcut is already handled by pgAdmin. // The actual shortcut is already handled by pgAdmin.
return; return;
} }
pgAdminMainScreen.webContents.send('menu-click', smName); pgAdminMainScreen.webContents.send('menu-click', `${smName}_${deeperSubMenuItem.name}`);
}, },
submenu: subMenuItem.submenu?.map((deeperSubMenuItem)=>{ })),
return { };
...deeperSubMenuItem, }),
accelerator: convertShortcutToAccelerator(deeperSubMenuItem.shortcut), }));
click: (_menuItem, _browserWindow, event)=>{ }
if(event?.triggeredByAccelerator) {
// We will ignore the click event if it is triggered by an accelerator. // Handles auto-update related menu items for macOS.
// We use accelerator to only show the shortcut title in the menu. // Adds or disables update menu items based on config state.
// The actual shortcut is already handled by pgAdmin. function handleAutoUpdateMenu(menuFile, configStore, callbacks) {
return; if (!configStore.get('auto_update_enabled')) return;
} if (configStore.get('update_downloaded')) {
pgAdminMainScreen.webContents.send('menu-click', `${smName}_${deeperSubMenuItem.name}`); // 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 = [];
pgadminMenus = bindMenuClicks(pgadminMenus, pgAdminMainScreen);
let menuFile = pgadminMenus.shift(); let menuFile = pgadminMenus.shift();
// macOS-specific menu modifications
if (isMac) { if (isMac) {
// Remove About pgAdmin 4 from help menu and add it to the top of menuFile submenu. handleAutoUpdateMenu(menuFile, configStore, callbacks);
const helpMenu = pgadminMenus.find((menu) => menu.name == 'help'); moveAboutMenuToTop(pgadminMenus, menuFile);
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' });
}
}
} }
template.push({ template.push({
...menuFile, ...menuFile,
submenu: [ submenu: [
...menuFile.submenu, ...menuFile.submenu,
{ type: 'separator' }, { type: 'separator' },
{ { label: 'View Logs...', click: callbacks['view_logs'] },
label: 'View Logs...', click: callbacks['view_logs'], { label: 'Configure runtime...', click: callbacks['configure'] },
},
{
label: 'Configure runtime...', click: callbacks['configure'],
},
{ type: 'separator' }, { type: 'separator' },
...(isMac ? [ ...(isMac
{ role: 'hide' }, ? [
{ role: 'hideOthers' }, { role: 'hide' },
{ role: 'unhide' }, { role: 'hideOthers' },
{ type: 'separator' }, { role: 'unhide' },
] : []), { type: 'separator' },
]
: []),
{ role: 'quit' }, { role: 'quit' },
], ],
}); });
if(isMac) { if (isMac) {
template[0].label = app.name; template[0].label = app.name;
} }
@ -119,8 +157,14 @@ function buildMenu(pgadminMenus, pgAdminMainScreen, callbacks) {
{ {
label: 'View', label: 'View',
submenu: [ submenu: [
{ label: 'Reload', click: callbacks['reloadApp']}, { label: 'Reload', click: callbacks['reloadApp'] },
{ label: 'Toggle Developer Tools', click: ()=>BrowserWindow.getFocusedWindow().webContents.openDevTools({ mode: 'bottom' })}, {
label: 'Toggle Developer Tools',
click: () =>
BrowserWindow.getFocusedWindow().webContents.openDevTools({
mode: 'bottom',
}),
},
{ type: 'separator' }, { type: 'separator' },
{ role: 'resetZoom' }, { role: 'resetZoom' },
{ role: 'zoomIn' }, { role: 'zoomIn' },
@ -128,25 +172,34 @@ function buildMenu(pgadminMenus, pgAdminMainScreen, callbacks) {
{ type: 'separator' }, { type: 'separator' },
].concat(isLinux ? [] : [{ role: 'togglefullscreen' }]), ].concat(isLinux ? [] : [{ role: 'togglefullscreen' }]),
}, },
{ role: 'windowMenu' }, { role: 'windowMenu' }
); );
template.push(pgadminMenus[pgadminMenus.length-1]); template.push(pgadminMenus[pgadminMenus.length - 1]);
return Menu.buildFromTemplate(template); 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)=>{ ipcMain.on('setMenus', (event, menus)=>{
mainMenu = buildMenu(menus, pgAdminMainScreen, callbacks);
// this is important because the shortcuts are registered multiple times // this is important because the shortcuts are registered multiple times
// when the menu is set multiple times using accelerators. // when the menu is set multiple times using accelerators.
globalShortcut.unregisterAll(); globalShortcut.unregisterAll();
if(isMac) { cachedMenus = menus; //It will be used later for refreshing the menus
Menu.setApplicationMenu(mainMenu); buildAndSetMenus(menus, pgAdminMainScreen, configStore, callbacks);
} else {
pgAdminMainScreen.setMenu(mainMenu);
}
ipcMain.on('enable-disable-menu-items', (event, menu, menuItem)=>{ ipcMain.on('enable-disable-menu-items', (event, menu, menuItem)=>{
const menuItemObj = mainMenu.getMenuItemById(menuItem?.id); const menuItemObj = mainMenu.getMenuItemById(menuItem?.id);

View File

@ -6,7 +6,7 @@
// This software is released under the PostgreSQL Licence // 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 axios from 'axios';
import Store from 'electron-store'; import Store from 'electron-store';
import fs from 'fs'; import fs from 'fs';
@ -17,6 +17,7 @@ import { fileURLToPath } from 'url';
import { setupMenu } from './menu.js'; import { setupMenu } from './menu.js';
import contextMenu from 'electron-context-menu'; import contextMenu from 'electron-context-menu';
import { setupDownloader } from './downloader.js'; import { setupDownloader } from './downloader.js';
import { setupAutoUpdater, updateConfigAndMenus } from './autoUpdaterHandler.js';
const configStore = new Store({ const configStore = new Store({
defaults: { defaults: {
@ -35,9 +36,13 @@ let configureWindow = null,
viewLogWindow = null; viewLogWindow = null;
let serverPort = 5050; let serverPort = 5050;
let UUID = crypto.randomUUID();
let appStartTime = (new Date()).getTime(); let appStartTime = (new Date()).getTime();
const __dirname = path.dirname(fileURLToPath(import.meta.url)); 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']; let docsURLSubStrings = ['www.enterprisedb.com', 'www.postgresql.org', 'www.pgadmin.org', 'help/help'];
process.env['ELECTRON_ENABLE_SECURITY_WARNINGS'] = false; 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 // Paths to the rest of the app
let [pythonPath, pgadminFile] = misc.getAppPaths(__dirname); 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. // Do not allow a second instance of pgAdmin to run.
const gotTheLock = app.requestSingleInstanceLock(); const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) { if (!gotTheLock) {
@ -153,6 +192,28 @@ function reloadApp() {
currWin.webContents.reload(); 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 // This functions is used to start the pgAdmin4 server by spawning a
// separate process. // separate process.
function startDesktopMode() { function startDesktopMode() {
@ -162,7 +223,6 @@ function startDesktopMode() {
return; return;
let pingIntervalID; let pingIntervalID;
let UUID = crypto.randomUUID();
// Set the environment variables so that pgAdmin 4 server // Set the environment variables so that pgAdmin 4 server
// starts listening on the appropriate port. // starts listening on the appropriate port.
process.env.PGADMIN_INT_PORT = serverPort; process.env.PGADMIN_INT_PORT = serverPort;
@ -170,7 +230,7 @@ function startDesktopMode() {
process.env.PGADMIN_SERVER_MODE = 'OFF'; process.env.PGADMIN_SERVER_MODE = 'OFF';
// Start Page URL // Start Page URL
const baseUrl = `http://127.0.0.1:${serverPort}`; baseUrl = `http://127.0.0.1:${serverPort}`;
startPageUrl = `${baseUrl}/?key=${UUID}`; startPageUrl = `${baseUrl}/?key=${UUID}`;
serverCheckUrl = `${baseUrl}/misc/ping?key=${UUID}`; serverCheckUrl = `${baseUrl}/misc/ping?key=${UUID}`;
@ -307,36 +367,10 @@ function launchPgAdminWindow() {
splashWindow.close(); splashWindow.close();
pgAdminMainScreen.webContents.session.clearCache(); pgAdminMainScreen.webContents.session.clearCache();
setupMenu(pgAdminMainScreen, { setupMenu(pgAdminMainScreen, configStore, menuCallbacks);
'view_logs': ()=>{
if(viewLogWindow === null || viewLogWindow?.isDestroyed()) { setupDownloader()
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();
pgAdminMainScreen.loadURL(startPageUrl); pgAdminMainScreen.loadURL(startPageUrl);
const bounds = configStore.get('bounds'); const bounds = configStore.get('bounds');
@ -346,6 +380,15 @@ function launchPgAdminWindow() {
pgAdminMainScreen.show(); pgAdminMainScreen.show();
setupAutoUpdater({
pgAdminMainScreen,
configStore,
menuCallbacks,
baseUrl,
UUID,
forceQuitAndInstallUpdate,
});
pgAdminMainScreen.webContents.setWindowOpenHandler(({url})=>{ pgAdminMainScreen.webContents.setWindowOpenHandler(({url})=>{
let openDocsInBrowser = configStore.get('openDocsInBrowser', true); let openDocsInBrowser = configStore.get('openDocsInBrowser', true);
let isDocURL = false; let isDocURL = false;
@ -377,18 +420,50 @@ function launchPgAdminWindow() {
}); });
pgAdminMainScreen.on('closed', ()=>{ pgAdminMainScreen.on('closed', ()=>{
cleanupAutoUpdateFlag();
misc.cleanupAndQuitApp(); misc.cleanupAndQuitApp();
}); });
pgAdminMainScreen.on('close', () => { pgAdminMainScreen.on('close', () => {
configStore.set('bounds', pgAdminMainScreen.getBounds()); configStore.set('bounds', pgAdminMainScreen.getBounds());
updateConfigAndMenus('error-close', configStore, pgAdminMainScreen, menuCallbacks);
pgAdminMainScreen.removeAllListeners('close'); pgAdminMainScreen.removeAllListeners('close');
pgAdminMainScreen.close(); pgAdminMainScreen.close();
}); });
// Notify if update was installed (fix: always check after main window is ready)
notifyUpdateInstalled();
} }
let splashWindow; 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. // setup preload events.
ipcMain.handle('showOpenDialog', (e, options) => dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender), options)); ipcMain.handle('showOpenDialog', (e, options) => dialog.showOpenDialog(BrowserWindow.fromWebContents(e.sender), options));
ipcMain.handle('showSaveDialog', (e, options) => dialog.showSaveDialog(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.relaunch();
app.exit(0); app.exit(0);
}); });
ipcMain.on('log', (_e, text) => ()=>{ ipcMain.on('log', (_e, text) => {
misc.writeServerLog(text); misc.writeServerLog(text);
}); });
ipcMain.on('focus', (e) => { ipcMain.on('focus', (e) => {
@ -428,6 +503,7 @@ ipcMain.handle('checkPortAvailable', async (_e, fixedPort)=>{
}); });
ipcMain.handle('openConfigure', openConfigure); ipcMain.handle('openConfigure', openConfigure);
app.whenReady().then(() => { app.whenReady().then(() => {
splashWindow = new BrowserWindow({ splashWindow = new BrowserWindow({
transparent: true, transparent: true,

View File

@ -32,4 +32,10 @@ contextBridge.exposeInMainWorld('electronUI', {
downloadStreamSaveEnd: (...args) => ipcRenderer.send('download-stream-save-end', ...args), downloadStreamSaveEnd: (...args) => ipcRenderer.send('download-stream-save-end', ...args),
downloadBase64UrlData: (...args) => ipcRenderer.invoke('download-base64-url-data', ...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));
},
}); });

View File

@ -15,6 +15,7 @@ import { send_heartbeat, stop_heartbeat } from './heartbeat';
import getApiInstance from '../../../static/js/api_instance'; import getApiInstance from '../../../static/js/api_instance';
import usePreferences, { setupPreferenceBroadcast } from '../../../preferences/static/js/store'; import usePreferences, { setupPreferenceBroadcast } from '../../../preferences/static/js/store';
import checkNodeVisibility from '../../../static/js/check_node_visibility'; import checkNodeVisibility from '../../../static/js/check_node_visibility';
import {appAutoUpdateNotifier} from '../../../static/js/helpers/appAutoUpdateNotifier';
define('pgadmin.browser', [ define('pgadmin.browser', [
'sources/gettext', 'sources/url_for', 'sources/pgadmin', 'sources/gettext', 'sources/url_for', 'sources/pgadmin',
@ -272,12 +273,34 @@ define('pgadmin.browser', [
checkMasterPassword(data, self.masterpass_callback_queue, cancel_callback); checkMasterPassword(data, self.masterpass_callback_queue, cancel_callback);
}, },
check_version_update: function() { check_version_update: async function(trigger_update_check=false) {
getApiInstance().get( getApiInstance().get(
url_for('misc.upgrade_check') url_for('misc.upgrade_check') + '?trigger_update_check=' + trigger_update_check
).then((res)=> { ).then((res)=> {
const data = res.data.data; 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( pgAdmin.Browser.notifier.warning(
` `
${gettext('You are currently running version %s of %s, <br/>however the current version is %s.', data.current_version, data.product_name, data.upgrade_version)} ${gettext('You are currently running version %s of %s, <br/>however the current version is %s.', data.current_version, data.product_name, data.upgrade_version)}
@ -287,9 +310,14 @@ define('pgadmin.browser', [
null null
); );
} }
// If the user manually triggered a check for updates (trigger_update_check is true)
}).catch(function() { // and no update is available (data.outdated is false), show an info notification.
// Suppress any errors 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}`));
}); });
}, },

View File

@ -21,7 +21,7 @@ from pgadmin.utils.csrf import pgCSRFProtect
from pgadmin.utils.session import cleanup_session_files from pgadmin.utils.session import cleanup_session_files
from pgadmin.misc.themes import get_all_themes from pgadmin.misc.themes import get_all_themes
from pgadmin.utils.ajax import precondition_required, make_json_response, \ 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, \ from pgadmin.utils.heartbeat import log_server_heartbeat, \
get_server_heartbeat, stop_server_heartbeat get_server_heartbeat, stop_server_heartbeat
import config import config
@ -32,6 +32,7 @@ import os
import sys import sys
import ssl import ssl
from urllib.request import urlopen from urllib.request import urlopen
from urllib.parse import unquote
from pgadmin.settings import get_setting, store_setting from pgadmin.settings import get_setting, store_setting
MODULE_NAME = 'misc' MODULE_NAME = 'misc'
@ -171,7 +172,7 @@ class MiscModule(PgAdminModule):
return ['misc.ping', 'misc.index', 'misc.cleanup', return ['misc.ping', 'misc.index', 'misc.cleanup',
'misc.validate_binary_path', 'misc.log_heartbeat', 'misc.validate_binary_path', 'misc.log_heartbeat',
'misc.stop_heartbeat', 'misc.get_heartbeat', 'misc.stop_heartbeat', 'misc.get_heartbeat',
'misc.upgrade_check'] 'misc.upgrade_check', 'misc.auto_update']
def register(self, app, options): def register(self, app, options):
""" """
@ -343,59 +344,138 @@ def validate_binary_path():
methods=['GET']) methods=['GET'])
@pga_login_required @pga_login_required
def upgrade_check(): 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. Check for application updates and return update metadata to the client.
ret = { - Compares current version with remote version data.
"outdated": False, - 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: if config.UPGRADE_CHECK_ENABLED:
last_check = get_setting('LastUpdateCheck', default='0') last_check = get_setting('LastUpdateCheck', default='0')
today = time.strftime('%Y%m%d') today = time.strftime('%Y%m%d')
if int(last_check) < int(today):
data = None data = None
url = '%s?version=%s' % ( url = '%s?version=%s' % (
config.UPGRADE_CHECK_URL, config.APP_VERSION) config.UPGRADE_CHECK_URL, config.APP_VERSION)
current_app.logger.debug('Checking version data at: %s' % url) current_app.logger.debug('Checking version data at: %s' % url)
try:
# Do not wait for more than 5 seconds. # Attempt to fetch upgrade data from remote URL
# It stuck on rendering the browser.html, while working in the try:
# broken network. # Do not wait for more than 5 seconds.
if os.path.exists(config.CA_FILE) and sys.version_info >= ( # It stuck on rendering the browser.html, while working in the
3, 13): # broken network.
# Use SSL context for Python 3.13+ if os.path.exists(config.CA_FILE) and sys.version_info >= (
context = ssl.create_default_context(cafile=config.CA_FILE) 3, 13):
response = urlopen(url, data=data, timeout=5, # Use SSL context for Python 3.13+
context=context) context = ssl.create_default_context(cafile=config.CA_FILE)
elif os.path.exists(config.CA_FILE): response = urlopen(url, data=data, timeout=5,
# Use cafile parameter for older versions context=context)
response = urlopen(url, data=data, timeout=5, elif os.path.exists(config.CA_FILE):
cafile=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: else:
response = urlopen(url, data, 5) ret = {**auto_update_common_res, "outdated": False}
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']
}
store_setting('LastUpdateCheck', today) store_setting('LastUpdateCheck', today)
return make_json_response(data=ret) return make_json_response(data=ret)
@blueprint.route("/auto_update/<current_version_int>/<latest_version>"
"/<latest_version_int>/<product_name>/<path:ftp_url>/",
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)

View File

@ -35,7 +35,7 @@ class SettingsModule(PgAdminModule):
'file_items': [ 'file_items': [
MenuItem( MenuItem(
name='mnu_resetlayout', name='mnu_resetlayout',
priority=998, priority=997,
module="pgAdmin.Settings", module="pgAdmin.Settings",
callback='show', callback='show',
label=gettext('Reset Layout') label=gettext('Reset Layout')

View File

@ -35,7 +35,7 @@ import { useWorkspace, WorkspaceProvider } from '../../misc/workspaces/static/js
import { PgAdminProvider, usePgAdmin } from './PgAdminProvider'; import { PgAdminProvider, usePgAdmin } from './PgAdminProvider';
import PreferencesComponent from '../../preferences/static/js/components/PreferencesComponent'; import PreferencesComponent from '../../preferences/static/js/components/PreferencesComponent';
import { ApplicationStateProvider } from '../../settings/static/ApplicationStateProvider'; import { ApplicationStateProvider } from '../../settings/static/ApplicationStateProvider';
import { appAutoUpdateNotifier } from './helpers/appAutoUpdateNotifier';
const objectExplorerGroup = { const objectExplorerGroup = {
tabLocked: true, tabLocked: true,
@ -181,6 +181,36 @@ export default function BrowserComponent({pgAdmin}) {
isNewTab: true, 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(()=>{ useEffect(()=>{
if(uiReady) { if(uiReady) {
pgAdmin?.Browser?.uiloaded?.(); pgAdmin?.Browser?.uiloaded?.();

View File

@ -49,6 +49,8 @@ export default function(basicSettings) {
main: '#eea236', main: '#eea236',
light: '#fce5c5', light: '#fce5c5',
contrastText: '#000', contrastText: '#000',
hoverMain: darken('#eea236', 0.1),
hoverBorderColor: darken('#eea236', 0.1),
}, },
info: { info: {
main: '#fde74c', main: '#fde74c',

View File

@ -1289,6 +1289,7 @@ const StyledNotifierMessageBox = styled(Box)(({theme}) => ({
backgroundColor: theme.palette.warning.light, backgroundColor: theme.palette.warning.light,
'& .FormFooter-iconWarning': { '& .FormFooter-iconWarning': {
color: theme.palette.warning.main, color: theme.palette.warning.main,
marginBottom: theme.spacing(8),
}, },
}, },
'& .FormFooter-message': { '& .FormFooter-message': {

View File

@ -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 (
<StyledBox className={'UpdateWarningNotifier-containerWarning'} data-test={'Update-popup-warning'}>
<Box display="flex" justifyContent="space-between" className='UpdateWarningNotifier-containerHeader'>
<Box marginRight={'1rem'}>{title}</Box>
<PgIconButton size="xs" noBorder icon={<CloseIcon />} onClick={handleClose} title={'Close'} className={'UpdateWarningNotifier-iconWarning'} />
</Box>
<Box className='UpdateWarningNotifier-containerBody'>
{desc && <Box>{desc}</Box>}
<Box display="flex">
{onClick && <Box marginTop={'1rem'} display="flex">
<DefaultButton color={'warning'} onClick={()=>{
onClick();
handleClose();
}}>{status == 'download_update' ? 'Download Update' : 'Install and Restart'}</DefaultButton>
</Box>}
{status == 'update_downloaded' && <Box marginTop={'1rem'} display="flex" marginLeft={'1rem'}>
<DefaultButton color={'default'} onClick={()=>{
handleClose();
}}>Install Later</DefaultButton>
</Box>}
</Box>
</Box>
</StyledBox>
);
}
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(
<UpdateWarningNotifier
title={title}
desc={desc}
onClick={onClick}
status={status}
uniqueKey={uniqueKey}
onClose={() => {
// 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);
});
}