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

This commit is contained in:
Anil Sahoo
2025-07-31 11:30:19 +05:30
committed by GitHub
parent 6db0cc5c5d
commit 9eec4f5b8c
19 changed files with 5737 additions and 181 deletions
+34 -6
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}`));
});
},
+131 -51
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,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)
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:
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']
}
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)
+1 -1
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')
+31 -1
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?.();
+2
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',
@@ -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': {
@@ -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);
});
}