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
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

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/),
on Mac OS X (~/Library/Preferences/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
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.

View File

@ -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,18 +444,23 @@ _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}" \
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; }')
echo "Notarization submission ID: ${SUBMISSION_ID}"
@ -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"
}

View File

@ -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
# 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_pkg
_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 isLinux = process.platform == 'linux';
let mainMenu;
let cachedMenus;
// Use to convert shortcut to accelerator for electron.
function convertShortcutToAccelerator({ control, meta, shift, alt, key } = {}) {
@ -29,12 +30,9 @@ function convertShortcutToAccelerator({ control, meta, shift, alt, key } = {}) {
return [...mods, k].join('+');
}
function buildMenu(pgadminMenus, pgAdminMainScreen, callbacks) {
const template = [];
// bind all menus click event.
pgadminMenus = pgadminMenus.map((menuItem)=>{
return {
// 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}`;
@ -50,8 +48,7 @@ function buildMenu(pgadminMenus, pgAdminMainScreen, callbacks) {
}
pgAdminMainScreen.webContents.send('menu-click', smName);
},
submenu: subMenuItem.submenu?.map((deeperSubMenuItem)=>{
return {
submenu: subMenuItem.submenu?.map((deeperSubMenuItem) => ({
...deeperSubMenuItem,
accelerator: convertShortcutToAccelerator(deeperSubMenuItem.shortcut),
click: (_menuItem, _browserWindow, event)=>{
@ -63,26 +60,69 @@ function buildMenu(pgadminMenus, pgAdminMainScreen, callbacks) {
}
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 = [];
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({
@ -90,19 +130,17 @@ function buildMenu(pgadminMenus, pgAdminMainScreen, callbacks) {
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 ? [
...(isMac
? [
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
] : []),
]
: []),
{ role: 'quit' },
],
});
@ -120,7 +158,13 @@ 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: 'Toggle Developer Tools',
click: () =>
BrowserWindow.getFocusedWindow().webContents.openDevTools({
mode: 'bottom',
}),
},
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
@ -128,7 +172,7 @@ function buildMenu(pgadminMenus, pgAdminMainScreen, callbacks) {
{ type: 'separator' },
].concat(isLinux ? [] : [{ role: 'togglefullscreen' }]),
},
{ role: 'windowMenu' },
{ role: 'windowMenu' }
);
template.push(pgadminMenus[pgadminMenus.length - 1]);
@ -136,17 +180,26 @@ function buildMenu(pgadminMenus, pgAdminMainScreen, callbacks) {
return Menu.buildFromTemplate(template);
}
export function setupMenu(pgAdminMainScreen, callbacks={}) {
ipcMain.on('setMenus', (event, menus)=>{
mainMenu = buildMenu(menus, pgAdminMainScreen, callbacks);
// this is important because the shortcuts are registered multiple times
// when the menu is set multiple times using accelerators.
globalShortcut.unregisterAll();
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)=>{
// this is important because the shortcuts are registered multiple times
// when the menu is set multiple times using accelerators.
globalShortcut.unregisterAll();
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);

View File

@ -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,35 +367,9 @@ 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,
});
setupMenu(pgAdminMainScreen, configStore, menuCallbacks);
setupDownloader();
setupDownloader()
pgAdminMainScreen.loadURL(startPageUrl);
@ -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,

View File

@ -32,4 +32,10 @@ contextBridge.exposeInMainWorld('electronUI', {
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),
//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 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',
@ -272,12 +273,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, <br/>however the current version is %s.', data.current_version, data.product_name, data.upgrade_version)}
@ -287,9 +310,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}`));
});
},

View File

@ -21,7 +21,7 @@ from pgadmin.utils.csrf import pgCSRFProtect
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 os
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 @@ class MiscModule(PgAdminModule):
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,19 +344,28 @@ 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)
# 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
@ -384,11 +394,42 @@ def upgrade_check():
'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:
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'],
@ -396,6 +437,45 @@ def upgrade_check():
"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/<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': [
MenuItem(
name='mnu_resetlayout',
priority=998,
priority=997,
module="pgAdmin.Settings",
callback='show',
label=gettext('Reset Layout')

View File

@ -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?.();

View File

@ -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',

View File

@ -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': {

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